refactor: extract protocol conversion layer into standalone relaykit module (#6369)

* test(relayconvert): add golden snapshot matrix and relaykit boundary guard

Phase 0 of the relaykit extraction plan: pin byte-level output of every
registered (from,to) request/response/stream conversion route, and
forbid kit-bound packages from growing host-only imports.

* wip(relayconvert): drop gin.Context from converter signatures; add convmeta draft

Phase 1 in progress: relayconvert now takes context.Context; host media
resolver adapts gin.Context back at the service boundary.

* refactor(relayconvert): decouple converters from RelayInfo, gin, and settings

Phase 1 of the relaykit extraction plan:
- converters now depend on convmeta.Meta (implemented by RelayInfo) instead
  of *relaycommon.RelayInfo; ClaudeConvertInfo and the format guesser move
  to convmeta with aliases left behind
- host settings reach converters via a convmeta.Options snapshot built in
  RelayInfo.ConvOptions; no more model_setting/reasoning global reads inside
  the conversion layer
- effort-suffix helpers move to service/relayconvert/reasoning (old package
  forwards); chat-to-responses upgrade policy moves to service (host routing
  logic, not conversion)
- golden conversion matrix unchanged

* test(relayconvert): tighten boundary — kit packages now free of gin/setting imports

* refactor(dto): drop gin and logger dependencies

Phase 2 (part 1): dto.Request.IsStream now takes *http.Request instead of
*gin.Context (Gemini's impl reads query/path off the std request); dto's
three logger calls become common.SysError. Boundary test allowlist is now
empty — kit-bound packages import no gin/setting/logger/model.

* refactor(kit): extract dependency-free kitutil; dto/types/relayconvert stop importing common

Phase 2 of the relaykit extraction plan:
- new service/relayconvert/kitutil holds the pure helpers the kit needs
  (JSON wrappers, pointer/string/uuid/timestamp utils, MaskSensitiveInfo,
  pluggable LogInfo/LogError hooks, Debug flag)
- dto, types, and all relayconvert packages now use kitutil; their only
  remaining internal deps are dto/types/constant
- common keeps every original symbol (MaskSensitiveInfo delegates to
  kitutil) so host code is untouched; main.go routes kit logging into
  common.SysLog/SysError and mirrors DebugEnabled
- golden conversion matrix unchanged

* refactor(kit): move EndpointType/FinishReason to types; OpenRouter dialect via Options

Kit packages (dto/types/relayconvert/reasonmap) no longer import constant:
- EndpointType and finish-reason values live in types; constant re-exports
- the OpenRouter special-case in claude->openai request conversion reads
  Options.OpenRouterDialect, set by the host from the channel type;
  InitChannelMeta invalidates the cached snapshot on channel switch

* refactor: extract relaykit submodule (dto/types/relayconvert/reasonmap)

Phase 3 of the relaykit extraction plan:
- new go module github.com/QuantumNous/new-api/relaykit containing dto
  (minus task family), types, relayconvert (with convmeta/kitutil/reasoning),
  and reasonmap; host consumes it via require + replace, go.work for dev
- task-family dto (task/suno/midjourney/video) stays in the host dto
  package; dual-consumer host files alias it as taskdto
- relaykit builds and tests standalone (GOWORK=off): no host imports,
  no gin, no DB, no settings
- golden conversion matrix unchanged

* build(docker): copy relaykit/go.mod before go mod download

The local-replace submodule's go.mod must exist inside the build context
for the main module graph to resolve.

* fix: address relaykit extraction regressions

* fix: address relaykit review regressions

* docs: document Meta nil receiver contract

* fix(relaykit): fail OpenAI→Claude conversion without max_tokens; reject negative default_max_tokens

The Claude Messages API requires max_tokens (omitting it is a 400
"Field required"), but with a nil Options.Claude.DefaultMaxTokens hook
the converters silently emitted a request the upstream is guaranteed to
reject. Both OpenAI Chat and Responses → Claude conversions now return
sharedclaude.ErrMissingMaxTokens when no path (client value, default
hook, thinking-adapter floor) supplied one. Unreachable in the host,
which always configures the hook.

Host side, claude.default_max_tokens now rejects negative values at the
option API before persisting — they would wrap into huge unsigned values
during conversion. Zero stays allowed: the current API treats
max_tokens: 0 as cache pre-warming.

* fix: make Gemini safety settings read path race-free
This commit is contained in:
Calcium-Ion
2026-07-27 15:56:21 +08:00
committed by GitHub
parent f51dd4d808
commit 86ac0f7745
368 changed files with 7144 additions and 1594 deletions
+38
View File
@@ -0,0 +1,38 @@
package dto
import (
"encoding/json"
"net/http"
"github.com/QuantumNous/new-api/relaykit/types"
)
// AlphaSearchRequest is the Codex standalone web search request.
// RawBody preserves the original JSON so unknown fields are forwarded intact.
type AlphaSearchRequest struct {
Model string `json:"model"`
Id string `json:"id,omitempty"`
Stream *bool `json:"stream,omitempty"`
RawBody json.RawMessage `json:"-"`
}
func (r *AlphaSearchRequest) GetTokenCountMeta() *types.TokenCountMeta {
combineText := ""
if len(r.RawBody) > 0 {
combineText = string(r.RawBody)
}
return &types.TokenCountMeta{
CombineText: combineText,
TokenType: types.TokenTypeTokenizer,
}
}
func (r *AlphaSearchRequest) IsStream(_ *http.Request) bool {
return false
}
func (r *AlphaSearchRequest) SetModelName(modelName string) {
if modelName != "" {
r.Model = modelName
}
}
+76
View File
@@ -0,0 +1,76 @@
package dto
import (
"encoding/json"
"net/http"
"strings"
"github.com/QuantumNous/new-api/relaykit/types"
)
type AudioRequest struct {
Model string `json:"model"`
Input string `json:"input"`
Voice string `json:"voice"`
Instructions string `json:"instructions,omitempty"`
ResponseFormat string `json:"response_format,omitempty"`
Speed *float64 `json:"speed,omitempty"`
StreamFormat string `json:"stream_format,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
// vllm-omini
TaskType json.RawMessage `json:"task_type,omitempty"`
Language json.RawMessage `json:"language,omitempty"`
RefAudio json.RawMessage `json:"ref_audio,omitempty"`
RefText json.RawMessage `json:"ref_text,omitempty"`
XVectorOnlyMode json.RawMessage `json:"x_vector_only_mode,omitempty"`
MaxNewTokens json.RawMessage `json:"max_new_tokens,omitempty"`
InitialCodecChunkFrames json.RawMessage `json:"initial_codec_chunk_frames,omitempty"`
// TODOensure that the logic remains correct after the stream is started.
//Stream json.RawMessage `json:"stream,omitempty"`
}
func (r *AudioRequest) GetTokenCountMeta() *types.TokenCountMeta {
meta := &types.TokenCountMeta{
CombineText: r.Input,
TokenType: types.TokenTypeTextNumber,
}
if strings.Contains(r.Model, "gpt") {
meta.TokenType = types.TokenTypeTokenizer
}
return meta
}
func (r *AudioRequest) IsStream(c *http.Request) bool {
return r.StreamFormat == "sse"
}
func (r *AudioRequest) SetModelName(modelName string) {
if modelName != "" {
r.Model = modelName
}
}
type AudioResponse struct {
Text string `json:"text"`
}
type WhisperVerboseJSONResponse struct {
Task string `json:"task,omitempty"`
Language string `json:"language,omitempty"`
Duration float64 `json:"duration,omitempty"`
Text string `json:"text,omitempty"`
Segments []Segment `json:"segments,omitempty"`
}
type Segment struct {
Id int `json:"id"`
Seek int `json:"seek"`
Start float64 `json:"start"`
End float64 `json:"end"`
Text string `json:"text"`
Tokens []int `json:"tokens"`
Temperature float64 `json:"temperature"`
AvgLogprob float64 `json:"avg_logprob"`
CompressionRatio float64 `json:"compression_ratio"`
NoSpeechProb float64 `json:"no_speech_prob"`
}
+218
View File
@@ -0,0 +1,218 @@
package dto
const (
BillingUsageSourceClaudeMessages = "claude_messages"
BillingUsageSourceGeminiChat = "gemini_chat"
BillingUsageSourceOAIChat = "oai_chat"
BillingUsageSourceOAIResponses = "oai_responses"
BillingUsageSemanticAnthropic = "anthropic"
BillingUsageSemanticGemini = "gemini"
BillingUsageSemanticOpenAI = "openai"
)
type BillingUsage struct {
Source string `json:"source,omitempty"`
Semantic string `json:"semantic,omitempty"`
Estimated bool `json:"estimated,omitempty"`
OpenAIUsage *Usage `json:"openai_usage,omitempty"`
ClaudeUsage *ClaudeUsage `json:"claude_usage,omitempty"`
GeminiUsageMetadata *GeminiUsageMetadata `json:"gemini_usage_metadata,omitempty"`
}
func NewClaudeMessagesBillingUsage(usage *ClaudeUsage) *BillingUsage {
if !HasClaudeUsageTokens(usage) {
return nil
}
return &BillingUsage{
Source: BillingUsageSourceClaudeMessages,
Semantic: BillingUsageSemanticAnthropic,
ClaudeUsage: cloneClaudeUsage(usage),
}
}
// HasClaudeUsageTokens mirrors HasOpenAIUsageTokens/HasGeminiUsageMetadataTokens:
// an all-zero ClaudeUsage must not become a BillingUsage, otherwise it would take
// precedence during settlement and zero out a non-zero top-level usage.
func HasClaudeUsageTokens(usage *ClaudeUsage) bool {
if usage == nil {
return false
}
if usage.InputTokens != 0 ||
usage.OutputTokens != 0 ||
usage.CacheCreationInputTokens != 0 ||
usage.CacheReadInputTokens != 0 ||
usage.ClaudeCacheCreation5mTokens != 0 ||
usage.ClaudeCacheCreation1hTokens != 0 {
return true
}
if usage.CacheCreation != nil &&
(usage.CacheCreation.Ephemeral5mInputTokens != 0 || usage.CacheCreation.Ephemeral1hInputTokens != 0) {
return true
}
return false
}
func NewOpenAIChatBillingUsage(usage *Usage) *BillingUsage {
return newOpenAIBillingUsage(BillingUsageSourceOAIChat, usage)
}
func NewOpenAIResponsesBillingUsage(usage *Usage) *BillingUsage {
return newOpenAIBillingUsage(BillingUsageSourceOAIResponses, usage)
}
func newOpenAIBillingUsage(source string, usage *Usage) *BillingUsage {
if !HasOpenAIUsageTokens(usage) {
return nil
}
return &BillingUsage{
Source: source,
Semantic: BillingUsageSemanticOpenAI,
OpenAIUsage: cloneOpenAIUsage(usage),
}
}
func HasOpenAIUsageTokens(usage *Usage) bool {
if usage == nil {
return false
}
if usage.PromptTokens != 0 ||
usage.CompletionTokens != 0 ||
usage.TotalTokens != 0 ||
usage.InputTokens != 0 ||
usage.OutputTokens != 0 ||
usage.PromptCacheHitTokens != 0 ||
usage.ClaudeCacheCreation5mTokens != 0 ||
usage.ClaudeCacheCreation1hTokens != 0 {
return true
}
if usage.PromptTokensDetails.CachedTokens != 0 ||
usage.PromptTokensDetails.CachedCreationTokens != 0 ||
usage.PromptTokensDetails.CacheWriteTokens != 0 ||
usage.PromptTokensDetails.TextTokens != 0 ||
usage.PromptTokensDetails.ImageTokens != 0 ||
usage.PromptTokensDetails.AudioTokens != 0 {
return true
}
if usage.CompletionTokenDetails.ReasoningTokens != 0 ||
usage.CompletionTokenDetails.TextTokens != 0 ||
usage.CompletionTokenDetails.ImageTokens != 0 ||
usage.CompletionTokenDetails.AudioTokens != 0 {
return true
}
return usage.InputTokensDetails != nil
}
func NewGeminiChatBillingUsage(metadata *GeminiUsageMetadata) *BillingUsage {
return newGeminiChatBillingUsage(metadata, false)
}
func NewEstimatedGeminiChatBillingUsage(usage *Usage) *BillingUsage {
if usage == nil {
return nil
}
totalTokens := usage.TotalTokens
if totalTokens == 0 {
totalTokens = usage.PromptTokens + usage.CompletionTokens
}
return newGeminiChatBillingUsage(&GeminiUsageMetadata{
PromptTokenCount: usage.PromptTokens,
CandidatesTokenCount: usage.CompletionTokens,
TotalTokenCount: totalTokens,
}, true)
}
func newGeminiChatBillingUsage(metadata *GeminiUsageMetadata, estimated bool) *BillingUsage {
if !HasGeminiUsageMetadataTokens(metadata) {
return nil
}
usageMetadata := cloneGeminiUsageMetadata(*metadata)
return &BillingUsage{
Source: BillingUsageSourceGeminiChat,
Semantic: BillingUsageSemanticGemini,
Estimated: estimated,
GeminiUsageMetadata: &usageMetadata,
}
}
func CloneBillingUsage(usage *BillingUsage) *BillingUsage {
if usage == nil {
return nil
}
clone := *usage
clone.OpenAIUsage = cloneOpenAIUsage(usage.OpenAIUsage)
clone.ClaudeUsage = cloneClaudeUsage(usage.ClaudeUsage)
if usage.GeminiUsageMetadata != nil {
metadata := cloneGeminiUsageMetadata(*usage.GeminiUsageMetadata)
clone.GeminiUsageMetadata = &metadata
}
return &clone
}
func cloneOpenAIUsage(usage *Usage) *Usage {
if usage == nil {
return nil
}
clone := *usage
clone.BillingUsage = nil
if usage.InputTokensDetails != nil {
inputTokensDetails := *usage.InputTokensDetails
clone.InputTokensDetails = &inputTokensDetails
}
return &clone
}
func cloneClaudeUsage(usage *ClaudeUsage) *ClaudeUsage {
if usage == nil {
return nil
}
clone := *usage
clone.BillingUsage = nil
if usage.CacheCreation != nil {
cacheCreation := *usage.CacheCreation
clone.CacheCreation = &cacheCreation
}
if usage.ServerToolUse != nil {
serverToolUse := *usage.ServerToolUse
clone.ServerToolUse = &serverToolUse
}
return &clone
}
func cloneGeminiUsageMetadata(metadata GeminiUsageMetadata) GeminiUsageMetadata {
metadata.PromptTokensDetails = append([]GeminiPromptTokensDetails{}, metadata.PromptTokensDetails...)
metadata.ToolUsePromptTokensDetails = append([]GeminiPromptTokensDetails{}, metadata.ToolUsePromptTokensDetails...)
metadata.CandidatesTokensDetails = append([]GeminiPromptTokensDetails{}, metadata.CandidatesTokensDetails...)
metadata.BillingUsage = nil
return metadata
}
func HasGeminiUsageMetadataTokens(metadata *GeminiUsageMetadata) bool {
if metadata == nil {
return false
}
if metadata.PromptTokenCount != 0 ||
metadata.ToolUsePromptTokenCount != 0 ||
metadata.CandidatesTokenCount != 0 ||
metadata.TotalTokenCount != 0 ||
metadata.ThoughtsTokenCount != 0 ||
metadata.CachedContentTokenCount != 0 {
return true
}
for _, detail := range metadata.PromptTokensDetails {
if detail.TokenCount != 0 {
return true
}
}
for _, detail := range metadata.ToolUsePromptTokensDetails {
if detail.TokenCount != 0 {
return true
}
}
for _, detail := range metadata.CandidatesTokensDetails {
if detail.TokenCount != 0 {
return true
}
}
return false
}
+89
View File
@@ -0,0 +1,89 @@
package dto
import (
"testing"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewGeminiChatBillingUsageRequiresTokenContent(t *testing.T) {
require.Nil(t, NewGeminiChatBillingUsage(nil))
require.Nil(t, NewGeminiChatBillingUsage(&GeminiUsageMetadata{}))
billingUsage := NewGeminiChatBillingUsage(&GeminiUsageMetadata{PromptTokenCount: 1})
require.NotNil(t, billingUsage)
require.NotNil(t, billingUsage.GeminiUsageMetadata)
assert.Equal(t, BillingUsageSourceGeminiChat, billingUsage.Source)
assert.Equal(t, BillingUsageSemanticGemini, billingUsage.Semantic)
assert.False(t, billingUsage.Estimated)
}
func TestNewClaudeMessagesBillingUsageRequiresTokenContent(t *testing.T) {
require.Nil(t, NewClaudeMessagesBillingUsage(nil))
require.Nil(t, NewClaudeMessagesBillingUsage(&ClaudeUsage{}))
require.Nil(t, NewClaudeMessagesBillingUsage(&ClaudeUsage{CacheCreation: &ClaudeCacheCreationUsage{}}))
billingUsage := NewClaudeMessagesBillingUsage(&ClaudeUsage{InputTokens: 1})
require.NotNil(t, billingUsage)
require.NotNil(t, billingUsage.ClaudeUsage)
assert.Equal(t, BillingUsageSourceClaudeMessages, billingUsage.Source)
assert.Equal(t, BillingUsageSemanticAnthropic, billingUsage.Semantic)
cacheOnly := NewClaudeMessagesBillingUsage(&ClaudeUsage{
CacheCreation: &ClaudeCacheCreationUsage{Ephemeral5mInputTokens: 4},
})
require.NotNil(t, cacheOnly)
}
func TestNewOpenAIChatBillingUsageRequiresTokenContent(t *testing.T) {
require.Nil(t, NewOpenAIChatBillingUsage(nil))
require.Nil(t, NewOpenAIChatBillingUsage(&Usage{}))
billingUsage := NewOpenAIChatBillingUsage(&Usage{PromptTokens: 1})
require.NotNil(t, billingUsage)
require.NotNil(t, billingUsage.OpenAIUsage)
assert.Equal(t, BillingUsageSourceOAIChat, billingUsage.Source)
assert.Equal(t, BillingUsageSemanticOpenAI, billingUsage.Semantic)
assert.Equal(t, 1, billingUsage.OpenAIUsage.PromptTokens)
}
func TestNewEstimatedGeminiChatBillingUsage(t *testing.T) {
billingUsage := NewEstimatedGeminiChatBillingUsage(&Usage{
PromptTokens: 11,
CompletionTokens: 7,
})
require.NotNil(t, billingUsage)
require.NotNil(t, billingUsage.GeminiUsageMetadata)
assert.True(t, billingUsage.Estimated)
assert.Equal(t, 11, billingUsage.GeminiUsageMetadata.PromptTokenCount)
assert.Equal(t, 7, billingUsage.GeminiUsageMetadata.CandidatesTokenCount)
assert.Equal(t, 18, billingUsage.GeminiUsageMetadata.TotalTokenCount)
}
func TestBillingUsageJSONUsesProtocolNamedFields(t *testing.T) {
billingUsage := &BillingUsage{
OpenAIUsage: &Usage{PromptTokens: 1, BillingUsage: NewClaudeMessagesBillingUsage(&ClaudeUsage{InputTokens: 9})},
ClaudeUsage: &ClaudeUsage{InputTokens: 2, BillingUsage: NewOpenAIChatBillingUsage(&Usage{PromptTokens: 8})},
GeminiUsageMetadata: &GeminiUsageMetadata{PromptTokenCount: 3, BillingUsage: NewOpenAIChatBillingUsage(&Usage{PromptTokens: 7})},
}
data, err := kitutil.Marshal(billingUsage)
require.NoError(t, err)
assert.Contains(t, string(data), `"openai_usage"`)
assert.Contains(t, string(data), `"claude_usage"`)
assert.Contains(t, string(data), `"gemini_usage_metadata"`)
assert.NotContains(t, string(data), `"usage":`)
assert.NotContains(t, string(data), `"usage_metadata"`)
clone := CloneBillingUsage(billingUsage)
require.NotNil(t, clone.OpenAIUsage)
require.NotNil(t, clone.ClaudeUsage)
require.NotNil(t, clone.GeminiUsageMetadata)
assert.Nil(t, clone.OpenAIUsage.BillingUsage)
assert.Nil(t, clone.ClaudeUsage.BillingUsage)
assert.Nil(t, clone.GeminiUsageMetadata.BillingUsage)
}
+535
View File
@@ -0,0 +1,535 @@
package dto
import (
"fmt"
"net/url"
"regexp"
"strings"
"sync"
"github.com/QuantumNous/new-api/relaykit/types"
)
type ChannelSettings struct {
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"`
}
type VertexKeyType string
const (
VertexKeyTypeJSON VertexKeyType = "json"
VertexKeyTypeAPIKey VertexKeyType = "api_key"
)
type AwsKeyType string
const (
AwsKeyTypeAKSK AwsKeyType = "ak_sk" // 默认
AwsKeyTypeApiKey AwsKeyType = "api_key"
)
type ChannelOtherSettings struct {
AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key"
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 透传(默认过滤以避免额外计费)
AllowInferenceGeo bool `json:"allow_inference_geo,omitempty"` // 是否允许 inference_geo 透传(仅 Claude,默认过滤以满足数据驻留合规
AllowSpeed bool `json:"allow_speed,omitempty"` // 是否允许 speed 透传(仅 Claude,默认过滤以避免意外切换推理速度模式)
AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私)
DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用)
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"`
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"` // 上次检测时间
UpstreamModelUpdateLastDetectedModels []string `json:"upstream_model_update_last_detected_models,omitempty"` // 上次检测到的可加入模型
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"`
}
func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool {
if s == nil || s.OpenRouterEnterprise == nil {
return false
}
return *s.OpenRouterEnterprise
}
const (
advancedCustomConverterNone = "none"
advancedCustomConverterClaudeMessagesToOpenAIChat = "anthropic_messages_to_openai_chat_completions"
advancedCustomConverterOpenAIChatToClaudeMessages = "openai_chat_completions_to_anthropic_messages"
advancedCustomConverterOpenAIChatToOpenAIResponses = "openai_chat_completions_to_openai_responses"
advancedCustomConverterOpenAIResponsesToOpenAIChat = "openai_responses_to_openai_chat_completions"
advancedCustomConverterOpenAIResponsesToGemini = "openai_responses_to_gemini_generate_content"
advancedCustomConverterGeminiContentToOpenAIChat = "gemini_generate_content_to_openai_chat_completions"
advancedCustomConverterOpenAIChatToGeminiContent = "openai_chat_completions_to_gemini_generate_content"
)
const (
AdvancedCustomAuthTypeNone = "none"
AdvancedCustomAuthTypeHeader = "header"
AdvancedCustomAuthTypeQuery = "query"
)
type AdvancedCustomConfig struct {
Routes []AdvancedCustomRoute `json:"advanced_routes,omitempty"`
}
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"`
}
type AdvancedCustomRouteAuth struct {
Type string `json:"type,omitempty"`
Name string `json:"name,omitempty"`
Value string `json:"value,omitempty"`
}
const (
advancedCustomModelPlaceholder = "{model}"
advancedCustomModelRegexPrefix = "re:"
)
const (
advancedCustomEndpointPathOpenAIChat = "/v1/chat/completions"
advancedCustomEndpointPathOpenAIResponses = "/v1/responses"
advancedCustomEndpointPathOpenAIResponsesCompact = "/v1/responses/compact"
advancedCustomEndpointPathOpenAIAlphaSearch = "/v1/alpha/search"
advancedCustomEndpointPathClaudeMessages = "/v1/messages"
advancedCustomEndpointPathJinaRerank = "/v1/rerank"
advancedCustomEndpointPathImageGeneration = "/v1/images/generations"
advancedCustomEndpointPathEmbeddings = "/v1/embeddings"
)
// AdvancedCustomModelListPath identifies the optional OpenAI Models discovery route.
const AdvancedCustomModelListPath = "/v1/models"
// MatchPath returns the first route whose IncomingPath matches requestPath.
// Matching mirrors the relay adaptor: exact match, {model} placeholder, and
// :generateContent <-> :streamGenerateContent equivalence.
func (c *AdvancedCustomConfig) MatchPath(requestPath string) (AdvancedCustomRoute, bool) {
if c == nil {
return AdvancedCustomRoute{}, false
}
for _, route := range c.Routes {
if matchAdvancedCustomIncomingPath(strings.TrimSpace(route.IncomingPath), requestPath) {
return route, true
}
}
return AdvancedCustomRoute{}, false
}
// MatchPathForModel returns the first route whose IncomingPath and Models match.
// An empty Models list is a catch-all fallback for that incoming path.
func (c *AdvancedCustomConfig) MatchPathForModel(requestPath string, model string) (AdvancedCustomRoute, bool) {
if c == nil {
return AdvancedCustomRoute{}, false
}
model = strings.TrimSpace(model)
for _, route := range c.Routes {
if matchAdvancedCustomIncomingPath(strings.TrimSpace(route.IncomingPath), requestPath) &&
matchAdvancedCustomRouteModel(route.Models, model) {
return route, true
}
}
return AdvancedCustomRoute{}, false
}
// ModelListRoute returns the explicitly configured OpenAI Models discovery route.
// Template routes that merely happen to match /v1/models are not discovery routes.
func (c *AdvancedCustomConfig) ModelListRoute() (AdvancedCustomRoute, bool) {
if c == nil {
return AdvancedCustomRoute{}, false
}
for _, route := range c.Routes {
if strings.TrimSpace(route.IncomingPath) == AdvancedCustomModelListPath {
return route, true
}
}
return AdvancedCustomRoute{}, false
}
// SupportsPath reports whether any route matches requestPath.
func (c *AdvancedCustomConfig) SupportsPath(requestPath string) bool {
_, ok := c.MatchPath(requestPath)
return ok
}
// SupportsPathForModel reports whether any route matches requestPath and model.
func (c *AdvancedCustomConfig) SupportsPathForModel(requestPath string, model string) bool {
_, ok := c.MatchPathForModel(requestPath, model)
return ok
}
func (c *AdvancedCustomConfig) SupportedEndpointTypesForModel(model string) []types.EndpointType {
if c == nil {
return nil
}
model = strings.TrimSpace(model)
endpoints := make([]types.EndpointType, 0, len(c.Routes))
seen := make(map[types.EndpointType]struct{}, len(c.Routes))
for _, route := range c.Routes {
if !matchAdvancedCustomRouteModel(route.Models, model) {
continue
}
endpointType, ok := advancedCustomEndpointTypeFromIncomingPath(strings.TrimSpace(route.IncomingPath))
if !ok {
continue
}
if _, exists := seen[endpointType]; exists {
continue
}
seen[endpointType] = struct{}{}
endpoints = append(endpoints, endpointType)
}
return endpoints
}
func advancedCustomEndpointTypeFromIncomingPath(incomingPath string) (types.EndpointType, bool) {
switch incomingPath {
case advancedCustomEndpointPathOpenAIChat:
return types.EndpointTypeOpenAI, true
case advancedCustomEndpointPathOpenAIResponses:
return types.EndpointTypeOpenAIResponse, true
case advancedCustomEndpointPathOpenAIResponsesCompact:
return types.EndpointTypeOpenAIResponseCompact, true
case advancedCustomEndpointPathOpenAIAlphaSearch:
return types.EndpointTypeOpenAIAlphaSearch, true
case advancedCustomEndpointPathClaudeMessages:
return types.EndpointTypeAnthropic, true
case advancedCustomEndpointPathJinaRerank:
return types.EndpointTypeJinaRerank, true
case advancedCustomEndpointPathImageGeneration:
return types.EndpointTypeImageGeneration, true
case advancedCustomEndpointPathEmbeddings:
return types.EndpointTypeEmbeddings, true
default:
if isAdvancedCustomGeminiIncomingPath(incomingPath) {
return types.EndpointTypeGemini, true
}
return "", false
}
}
func isAdvancedCustomGeminiIncomingPath(incomingPath string) bool {
if !strings.HasPrefix(incomingPath, "/v1beta/models/") {
return false
}
return strings.Contains(incomingPath, ":generateContent") || strings.Contains(incomingPath, ":streamGenerateContent")
}
func matchAdvancedCustomRouteModel(models []string, model string) bool {
normalizedModels := normalizeAdvancedCustomRouteModels(models)
if len(normalizedModels) == 0 {
return true
}
for _, allowedModel := range normalizedModels {
if matchAdvancedCustomRouteModelRule(allowedModel, model) {
return true
}
}
return false
}
// advancedCustomModelRegexCache caches compiled route model patterns. Route model
// matching runs on the request hot path (distributor affinity, ability filtering,
// channel cache filtering, adaptor resolve), so patterns must not be recompiled per
// request. Invalid patterns are cached as nil to avoid recompiling them as well.
var advancedCustomModelRegexCache sync.Map // pattern string -> *regexp.Regexp (nil when invalid)
func compileAdvancedCustomModelRegex(pattern string) *regexp.Regexp {
if cached, ok := advancedCustomModelRegexCache.Load(pattern); ok {
re, _ := cached.(*regexp.Regexp)
return re
}
re, err := regexp.Compile(pattern)
if err != nil {
re = nil
}
advancedCustomModelRegexCache.Store(pattern, re)
return re
}
func matchAdvancedCustomRouteModelRule(rule string, model string) bool {
if !strings.HasPrefix(rule, advancedCustomModelRegexPrefix) {
return rule == model
}
pattern := strings.TrimPrefix(rule, advancedCustomModelRegexPrefix)
if pattern == "" {
return false
}
re := compileAdvancedCustomModelRegex(pattern)
return re != nil && re.MatchString(model)
}
func matchAdvancedCustomIncomingPath(configuredPath string, requestPath string) bool {
if matchAdvancedCustomIncomingPathTemplate(configuredPath, requestPath) {
return true
}
if strings.Contains(configuredPath, ":generateContent") {
streamPath := strings.Replace(configuredPath, ":generateContent", ":streamGenerateContent", 1)
return matchAdvancedCustomIncomingPathTemplate(streamPath, requestPath)
}
return false
}
func matchAdvancedCustomIncomingPathTemplate(configuredPath string, requestPath string) bool {
if !strings.Contains(configuredPath, advancedCustomModelPlaceholder) {
return configuredPath == requestPath
}
parts := strings.Split(configuredPath, advancedCustomModelPlaceholder)
if len(parts) != 2 {
return false
}
if !strings.HasPrefix(requestPath, parts[0]) || !strings.HasSuffix(requestPath, parts[1]) {
return false
}
model := strings.TrimSuffix(strings.TrimPrefix(requestPath, parts[0]), parts[1])
return model != "" && !strings.Contains(model, "/")
}
func IsAdvancedCustomConverterAllowed(converter string) bool {
switch converter {
case advancedCustomConverterNone,
advancedCustomConverterClaudeMessagesToOpenAIChat,
advancedCustomConverterOpenAIChatToClaudeMessages,
advancedCustomConverterOpenAIChatToOpenAIResponses,
advancedCustomConverterOpenAIResponsesToOpenAIChat,
advancedCustomConverterOpenAIResponsesToGemini,
advancedCustomConverterGeminiContentToOpenAIChat,
advancedCustomConverterOpenAIChatToGeminiContent:
return true
default:
return false
}
}
func (c *AdvancedCustomConfig) Validate() error {
if c == nil {
return fmt.Errorf("advanced_custom is required")
}
if len(c.Routes) == 0 {
return fmt.Errorf("advanced_custom requires at least one route")
}
paths := make(map[string]*advancedCustomPathModelState, len(c.Routes))
modelListRouteIndex := -1
for i := range c.Routes {
route := c.Routes[i]
route.IncomingPath = strings.TrimSpace(route.IncomingPath)
upstreamPath := strings.TrimSpace(route.UpstreamPath)
route.Converter = strings.TrimSpace(route.Converter)
if route.Converter == "" {
route.Converter = advancedCustomConverterNone
}
if route.IncomingPath == "" {
return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path is required", i)
}
if !strings.HasPrefix(route.IncomingPath, "/") {
return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path must start with /", i)
}
if strings.Contains(route.IncomingPath, "?") {
return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path must not include query", i)
}
if route.IncomingPath == AdvancedCustomModelListPath {
if modelListRouteIndex >= 0 {
return fmt.Errorf("advanced_custom.advanced_routes[%d] duplicates the /v1/models route at advanced_routes[%d]", i, modelListRouteIndex)
}
modelListRouteIndex = i
if len(normalizeAdvancedCustomRouteModels(route.Models)) > 0 {
return fmt.Errorf("advanced_custom.advanced_routes[%d].models must be empty for /v1/models", i)
}
if route.Converter != advancedCustomConverterNone {
return fmt.Errorf("advanced_custom.advanced_routes[%d].converter must be none for /v1/models", i)
}
if strings.Contains(upstreamPath, advancedCustomModelPlaceholder) {
return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path must not contain %s for /v1/models", i, advancedCustomModelPlaceholder)
}
}
if err := validateAdvancedCustomRouteModels(i, route.IncomingPath, route.Models, paths); err != nil {
return err
}
if upstreamPath == "" {
return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path is required", i)
}
if err := validateAdvancedCustomUpstreamTarget(i, upstreamPath); err != nil {
return err
}
if !IsAdvancedCustomConverterAllowed(route.Converter) {
return fmt.Errorf("advanced_custom.advanced_routes[%d].converter is not registered: %s", i, route.Converter)
}
if err := validateAdvancedCustomConverterPath(i, route.IncomingPath, route.Converter); err != nil {
return err
}
if err := validateAdvancedCustomRouteAuth(i, route.Auth); err != nil {
return err
}
}
return nil
}
type advancedCustomPathModelState struct {
catchAllIndex int
modelIndexes map[string]int
}
func validateAdvancedCustomRouteModels(index int, incomingPath string, models []string, paths map[string]*advancedCustomPathModelState) error {
state := paths[incomingPath]
if state == nil {
state = &advancedCustomPathModelState{
catchAllIndex: -1,
modelIndexes: make(map[string]int),
}
paths[incomingPath] = state
}
normalizedModels := normalizeAdvancedCustomRouteModels(models)
if len(normalizedModels) == 0 {
if state.catchAllIndex >= 0 {
return fmt.Errorf("advanced_custom.advanced_routes[%d].models catch-all already exists for incoming_path: %s", index, incomingPath)
}
state.catchAllIndex = index
return nil
}
if state.catchAllIndex >= 0 {
return fmt.Errorf("advanced_custom.advanced_routes[%d].models catch-all route must be last for incoming_path: %s", index, incomingPath)
}
seenInRoute := make(map[string]struct{}, len(normalizedModels))
for _, model := range normalizedModels {
if err := validateAdvancedCustomRouteModelRule(index, incomingPath, model); err != nil {
return err
}
if _, exists := seenInRoute[model]; exists {
return fmt.Errorf("advanced_custom.advanced_routes[%d].models contains duplicate model for incoming_path %s: %s", index, incomingPath, model)
}
seenInRoute[model] = struct{}{}
if existingIndex, exists := state.modelIndexes[model]; exists {
return fmt.Errorf("advanced_custom.advanced_routes[%d].models overlaps with advanced_routes[%d] for incoming_path %s: %s", index, existingIndex, incomingPath, model)
}
state.modelIndexes[model] = index
}
return nil
}
func validateAdvancedCustomRouteModelRule(index int, incomingPath string, model string) error {
if !strings.HasPrefix(model, advancedCustomModelRegexPrefix) {
return nil
}
pattern := strings.TrimPrefix(model, advancedCustomModelRegexPrefix)
if pattern == "" {
return fmt.Errorf("advanced_custom.advanced_routes[%d].models regex is empty for incoming_path %s: %s", index, incomingPath, model)
}
if _, err := regexp.Compile(pattern); err != nil {
return fmt.Errorf("advanced_custom.advanced_routes[%d].models regex is invalid for incoming_path %s: %s", index, incomingPath, model)
}
return nil
}
func normalizeAdvancedCustomRouteModels(models []string) []string {
if len(models) == 0 {
return nil
}
normalized := make([]string, 0, len(models))
for _, model := range models {
model = strings.TrimSpace(model)
if model != "" {
normalized = append(normalized, model)
}
}
return normalized
}
func validateAdvancedCustomUpstreamTarget(index int, upstreamPath string) error {
if strings.HasPrefix(upstreamPath, "/") {
if strings.HasPrefix(upstreamPath, "//") {
return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path must be a full URL or a path starting with /", index)
}
return nil
}
parsedURL, err := url.Parse(upstreamPath)
if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path must be a full URL or a path starting with /", index)
}
if !strings.EqualFold(parsedURL.Scheme, "http") && !strings.EqualFold(parsedURL.Scheme, "https") {
return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path must use http or https", index)
}
return nil
}
func validateAdvancedCustomConverterPath(index int, incomingPath string, converter string) error {
if incomingPath == advancedCustomEndpointPathOpenAIAlphaSearch {
if converter == advancedCustomConverterNone {
return nil
}
return fmt.Errorf("advanced_custom.advanced_routes[%d].converter does not match incoming_path: %s", index, converter)
}
switch converter {
case advancedCustomConverterNone:
return nil
case advancedCustomConverterClaudeMessagesToOpenAIChat:
if incomingPath == "/v1/messages" {
return nil
}
case advancedCustomConverterOpenAIChatToClaudeMessages,
advancedCustomConverterOpenAIChatToOpenAIResponses,
advancedCustomConverterOpenAIChatToGeminiContent:
if incomingPath == "/v1/chat/completions" {
return nil
}
case advancedCustomConverterOpenAIResponsesToOpenAIChat:
if incomingPath == "/v1/responses" {
return nil
}
case advancedCustomConverterOpenAIResponsesToGemini:
if incomingPath == "/v1/responses" {
return nil
}
case advancedCustomConverterGeminiContentToOpenAIChat:
if strings.Contains(incomingPath, ":generateContent") || strings.Contains(incomingPath, ":streamGenerateContent") {
return nil
}
}
return fmt.Errorf("advanced_custom.advanced_routes[%d].converter does not match incoming_path: %s", index, converter)
}
func validateAdvancedCustomRouteAuth(index int, auth *AdvancedCustomRouteAuth) error {
if auth == nil {
return nil
}
authType := strings.TrimSpace(auth.Type)
switch authType {
case AdvancedCustomAuthTypeNone:
return nil
case AdvancedCustomAuthTypeHeader, AdvancedCustomAuthTypeQuery:
if strings.TrimSpace(auth.Name) == "" {
return fmt.Errorf("advanced_custom.advanced_routes[%d].auth.name is required", index)
}
if strings.TrimSpace(auth.Value) == "" {
return fmt.Errorf("advanced_custom.advanced_routes[%d].auth.value is required", index)
}
return nil
default:
return fmt.Errorf("advanced_custom.advanced_routes[%d].auth.type is invalid: %s", index, auth.Type)
}
}
+521
View File
@@ -0,0 +1,521 @@
package dto
import (
"regexp"
"testing"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAdvancedCustomValidateResponsesToChatConverterPath(t *testing.T) {
valid := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1/chat/completions",
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
},
},
}
require.NoError(t, valid.Validate())
validGemini := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: advancedCustomConverterOpenAIResponsesToGemini,
},
},
}
require.NoError(t, validGemini.Validate())
tests := []struct {
name string
incomingPath string
}{
{name: "chat completions", incomingPath: "/v1/chat/completions"},
{name: "responses compact", incomingPath: "/v1/responses/compact"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: tt.incomingPath,
UpstreamPath: "/v1/chat/completions",
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
},
},
}
err := config.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "converter does not match incoming_path")
})
}
}
func TestAdvancedCustomValidateModelListRouteConstraints(t *testing.T) {
valid := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: AdvancedCustomModelListPath,
UpstreamPath: "https://upstream.example/custom/models",
Converter: advancedCustomConverterNone,
},
},
}
require.NoError(t, valid.Validate())
tests := []struct {
name string
routes []AdvancedCustomRoute
want string
}{
{
name: "model matching rules",
routes: []AdvancedCustomRoute{
{
IncomingPath: AdvancedCustomModelListPath,
UpstreamPath: "/v1/models",
Models: []string{"gpt-4o"},
},
},
want: "models must be empty",
},
{
name: "converter",
routes: []AdvancedCustomRoute{
{
IncomingPath: AdvancedCustomModelListPath,
UpstreamPath: "/v1/models",
Converter: advancedCustomConverterOpenAIChatToOpenAIResponses,
},
},
want: "converter must be none",
},
{
name: "model placeholder",
routes: []AdvancedCustomRoute{
{
IncomingPath: AdvancedCustomModelListPath,
UpstreamPath: "/v1/models/{model}",
},
},
want: "upstream_path must not contain {model}",
},
{
name: "duplicate routes",
routes: []AdvancedCustomRoute{
{IncomingPath: AdvancedCustomModelListPath, UpstreamPath: "/v1/models"},
{IncomingPath: AdvancedCustomModelListPath, UpstreamPath: "/provider/models"},
},
want: "duplicates the /v1/models route",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := (&AdvancedCustomConfig{Routes: tt.routes}).Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), tt.want)
})
}
}
func TestAdvancedCustomModelListRouteRequiresExactIncomingPath(t *testing.T) {
config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/{model}",
UpstreamPath: "/generic/{model}",
},
{
IncomingPath: AdvancedCustomModelListPath,
UpstreamPath: "/provider/models",
},
},
}
require.NoError(t, config.Validate())
route, ok := config.ModelListRoute()
require.True(t, ok)
assert.Equal(t, "/provider/models", route.UpstreamPath)
}
func TestAdvancedCustomValidateDuplicateIncomingPathWithDisjointModels(t *testing.T) {
config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1/chat/completions",
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
Models: []string{"gpt-4o"},
},
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: advancedCustomConverterOpenAIResponsesToGemini,
Models: []string{"gemini-2.5-flash"},
},
},
}
require.NoError(t, config.Validate())
}
func TestAdvancedCustomValidateDuplicateIncomingPathRejectsOverlappingModels(t *testing.T) {
config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1/chat/completions",
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
Models: []string{"shared-model"},
},
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: advancedCustomConverterOpenAIResponsesToGemini,
Models: []string{"shared-model"},
},
},
}
err := config.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "models overlaps")
}
func TestAdvancedCustomValidateDuplicateIncomingPathRejectsMultipleCatchAllRoutes(t *testing.T) {
config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1/chat/completions",
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
},
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: advancedCustomConverterOpenAIResponsesToGemini,
},
},
}
err := config.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "catch-all already exists")
}
func TestAdvancedCustomValidateDuplicateIncomingPathRequiresCatchAllLast(t *testing.T) {
config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1/chat/completions",
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
},
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: advancedCustomConverterOpenAIResponsesToGemini,
Models: []string{"gemini-2.5-flash"},
},
},
}
err := config.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "catch-all route must be last")
}
func TestAdvancedCustomMatchPathForModel(t *testing.T) {
config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: advancedCustomConverterOpenAIResponsesToGemini,
Models: []string{"gemini-2.5-flash"},
},
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1/chat/completions",
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
Models: []string{"gpt-4o"},
},
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1/responses",
Converter: advancedCustomConverterNone,
},
},
}
require.NoError(t, config.Validate())
geminiRoute, ok := config.MatchPathForModel("/v1/responses", "gemini-2.5-flash")
require.True(t, ok)
assert.Equal(t, advancedCustomConverterOpenAIResponsesToGemini, geminiRoute.Converter)
chatRoute, ok := config.MatchPathForModel("/v1/responses", "gpt-4o")
require.True(t, ok)
assert.Equal(t, advancedCustomConverterOpenAIResponsesToOpenAIChat, chatRoute.Converter)
fallbackRoute, ok := config.MatchPathForModel("/v1/responses", "unknown-model")
require.True(t, ok)
assert.Equal(t, advancedCustomConverterNone, fallbackRoute.Converter)
}
func TestAdvancedCustomMatchPathForModelRegexRules(t *testing.T) {
config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: advancedCustomConverterOpenAIResponsesToGemini,
Models: []string{"re:^gemini-"},
},
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1/chat/completions",
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
Models: []string{"re:(?i)^OAI-"},
},
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1/responses",
Converter: advancedCustomConverterNone,
},
},
}
require.NoError(t, config.Validate())
geminiRoute, ok := config.MatchPathForModel("/v1/responses", "gemini-2.5-flash")
require.True(t, ok)
assert.Equal(t, advancedCustomConverterOpenAIResponsesToGemini, geminiRoute.Converter)
chatRoute, ok := config.MatchPathForModel("/v1/responses", "oai-test")
require.True(t, ok)
assert.Equal(t, advancedCustomConverterOpenAIResponsesToOpenAIChat, chatRoute.Converter)
fallbackRoute, ok := config.MatchPathForModel("/v1/responses", "gpt-4o")
require.True(t, ok)
assert.Equal(t, advancedCustomConverterNone, fallbackRoute.Converter)
}
func TestAdvancedCustomRouteModelRegexRulesAreCachedCompiled(t *testing.T) {
require.True(t, matchAdvancedCustomRouteModelRule("re:^cache-probe-", "cache-probe-model"))
cached, ok := advancedCustomModelRegexCache.Load("^cache-probe-")
require.True(t, ok)
require.NotNil(t, cached)
_, isRegexp := cached.(*regexp.Regexp)
require.True(t, isRegexp)
// Invalid patterns never match and are cached as nil so they are not recompiled.
require.False(t, matchAdvancedCustomRouteModelRule("re:(", "anything"))
cached, ok = advancedCustomModelRegexCache.Load("(")
require.True(t, ok)
re, _ := cached.(*regexp.Regexp)
require.Nil(t, re)
// Cached entries keep matching correctly on subsequent calls.
require.True(t, matchAdvancedCustomRouteModelRule("re:^cache-probe-", "cache-probe-other"))
require.False(t, matchAdvancedCustomRouteModelRule("re:^cache-probe-", "other-model"))
}
func TestAdvancedCustomMatchPathForModelExactRuleDoesNotMatchPrefix(t *testing.T) {
config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: advancedCustomConverterOpenAIResponsesToGemini,
Models: []string{"gemini"},
},
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1/responses",
Converter: advancedCustomConverterNone,
},
},
}
require.NoError(t, config.Validate())
fallbackRoute, ok := config.MatchPathForModel("/v1/responses", "gemini-2.5-flash")
require.True(t, ok)
assert.Equal(t, advancedCustomConverterNone, fallbackRoute.Converter)
}
func TestAdvancedCustomValidateDuplicateIncomingPathRejectsInvalidRegexModels(t *testing.T) {
tests := []struct {
name string
models []string
want string
}{
{name: "empty regex", models: []string{"re:"}, want: "regex is empty"},
{name: "invalid regex", models: []string{"re:["}, want: "regex is invalid"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: advancedCustomConverterOpenAIResponsesToGemini,
Models: tt.models,
},
},
}
err := config.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), tt.want)
})
}
}
func TestAdvancedCustomValidateDuplicateIncomingPathRejectsDuplicateRegexModels(t *testing.T) {
config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: advancedCustomConverterOpenAIResponsesToGemini,
Models: []string{"re:^gemini-"},
},
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1/chat/completions",
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
Models: []string{"re:^gemini-"},
},
},
}
err := config.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "models overlaps")
}
func TestAdvancedCustomMatchPathForModelUsesFirstMatchingRegexRoute(t *testing.T) {
config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: advancedCustomConverterOpenAIResponsesToGemini,
Models: []string{"re:^gemini-"},
},
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1/chat/completions",
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
Models: []string{"gemini-2.5-flash"},
},
},
}
require.NoError(t, config.Validate())
route, ok := config.MatchPathForModel("/v1/responses", "gemini-2.5-flash")
require.True(t, ok)
assert.Equal(t, advancedCustomConverterOpenAIResponsesToGemini, route.Converter)
}
func TestAdvancedCustomSupportedEndpointTypesForModel(t *testing.T) {
config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: advancedCustomConverterOpenAIResponsesToGemini,
Models: []string{"re:^gemini-"},
},
{
IncomingPath: "/v1beta/models/{model}:generateContent",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Models: []string{"re:^gemini-"},
},
{
IncomingPath: "/v1beta/models/{model}:streamGenerateContent",
UpstreamPath: "/v1beta/models/{model}:streamGenerateContent",
Models: []string{"re:^gemini-"},
},
{
IncomingPath: "/v1/chat/completions",
UpstreamPath: "/v1/chat/completions",
Models: []string{"gpt-4o"},
},
{
IncomingPath: "/v1/messages",
UpstreamPath: "/v1/messages",
},
{
IncomingPath: "/custom/endpoint",
UpstreamPath: "/custom/endpoint",
},
},
}
require.NoError(t, config.Validate())
assert.Equal(t, []types.EndpointType{
types.EndpointTypeOpenAIResponse,
types.EndpointTypeGemini,
types.EndpointTypeAnthropic,
}, config.SupportedEndpointTypesForModel("gemini-2.5-flash"))
assert.Equal(t, []types.EndpointType{
types.EndpointTypeOpenAI,
types.EndpointTypeAnthropic,
}, config.SupportedEndpointTypesForModel("gpt-4o"))
assert.Equal(t, []types.EndpointType{
types.EndpointTypeAnthropic,
}, config.SupportedEndpointTypesForModel("other-model"))
}
func TestAdvancedCustomValidateAlphaSearchConverterPath(t *testing.T) {
valid := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/alpha/search",
UpstreamPath: "/v1/alpha/search",
Converter: advancedCustomConverterNone,
},
},
}
require.NoError(t, valid.Validate())
assert.Equal(t, []types.EndpointType{
types.EndpointTypeOpenAIAlphaSearch,
}, valid.SupportedEndpointTypesForModel("gpt-5.1"))
nonNoneConverters := []string{
advancedCustomConverterClaudeMessagesToOpenAIChat,
advancedCustomConverterOpenAIChatToClaudeMessages,
advancedCustomConverterOpenAIChatToOpenAIResponses,
advancedCustomConverterOpenAIResponsesToOpenAIChat,
advancedCustomConverterOpenAIResponsesToGemini,
advancedCustomConverterGeminiContentToOpenAIChat,
advancedCustomConverterOpenAIChatToGeminiContent,
}
for _, converter := range nonNoneConverters {
t.Run(converter, func(t *testing.T) {
config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/alpha/search",
UpstreamPath: "/v1/alpha/search",
Converter: converter,
},
},
}
err := config.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "converter does not match incoming_path")
})
}
}
+600
View File
@@ -0,0 +1,600 @@
package dto
import (
"encoding/json"
"fmt"
"net/http"
"strings"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
type ClaudeMetadata struct {
UserId string `json:"user_id"`
}
type ClaudeMediaMessage struct {
Type string `json:"type,omitempty"`
Text *string `json:"text,omitempty"`
Model string `json:"model,omitempty"`
Source *ClaudeMessageSource `json:"source,omitempty"`
Usage *ClaudeUsage `json:"usage,omitempty"`
StopReason *string `json:"stop_reason,omitempty"`
PartialJson *string `json:"partial_json,omitempty"`
Role string `json:"role,omitempty"`
Thinking *string `json:"thinking,omitempty"`
Signature string `json:"signature,omitempty"`
Delta string `json:"delta,omitempty"`
CacheControl json.RawMessage `json:"cache_control,omitempty"`
// tool_calls
Id string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input any `json:"input,omitempty"`
Content any `json:"content,omitempty"`
ToolUseId string `json:"tool_use_id,omitempty"`
}
func (c *ClaudeMediaMessage) SetText(s string) {
c.Text = &s
}
func (c *ClaudeMediaMessage) GetText() string {
if c.Text == nil {
return ""
}
return *c.Text
}
func (c *ClaudeMediaMessage) IsStringContent() bool {
if c.Content == nil {
return false
}
_, ok := c.Content.(string)
if ok {
return true
}
return false
}
func (c *ClaudeMediaMessage) GetStringContent() string {
if c.Content == nil {
return ""
}
switch c.Content.(type) {
case string:
return c.Content.(string)
case []any:
var contentStr string
for _, contentItem := range c.Content.([]any) {
contentMap, ok := contentItem.(map[string]any)
if !ok {
continue
}
if contentMap["type"] == ContentTypeText {
if subStr, ok := contentMap["text"].(string); ok {
contentStr += subStr
}
}
}
return contentStr
}
return ""
}
func (c *ClaudeMediaMessage) GetJsonRowString() string {
jsonContent, _ := kitutil.Marshal(c)
return string(jsonContent)
}
func (c *ClaudeMediaMessage) SetContent(content any) {
c.Content = content
}
func (c *ClaudeMediaMessage) ParseMediaContent() []ClaudeMediaMessage {
mediaContent, _ := kitutil.Any2Type[[]ClaudeMediaMessage](c.Content)
return mediaContent
}
func (m *ClaudeMediaMessage) ToFileSource() types.FileSource {
if m.Source == nil {
return nil
}
data := m.Source.Url
if data == "" {
data = kitutil.Interface2String(m.Source.Data)
}
if data == "" {
return nil
}
return types.NewFileSourceFromData(data, m.Source.MediaType)
}
type ClaudeMessageSource struct {
Type string `json:"type"`
MediaType string `json:"media_type,omitempty"`
Data any `json:"data,omitempty"`
Url string `json:"url,omitempty"`
}
type ClaudeMessage struct {
Role string `json:"role"`
Content any `json:"content"`
}
func (c *ClaudeMessage) IsStringContent() bool {
if c.Content == nil {
return false
}
_, ok := c.Content.(string)
return ok
}
func (c *ClaudeMessage) GetStringContent() string {
if c.Content == nil {
return ""
}
switch c.Content.(type) {
case string:
return c.Content.(string)
case []any:
var contentStr string
for _, contentItem := range c.Content.([]any) {
contentMap, ok := contentItem.(map[string]any)
if !ok {
continue
}
if contentMap["type"] == ContentTypeText {
if subStr, ok := contentMap["text"].(string); ok {
contentStr += subStr
}
}
}
return contentStr
}
return ""
}
func (c *ClaudeMessage) SetStringContent(content string) {
c.Content = content
}
func (c *ClaudeMessage) SetContent(content any) {
c.Content = content
}
func (c *ClaudeMessage) ParseContent() ([]ClaudeMediaMessage, error) {
return kitutil.Any2Type[[]ClaudeMediaMessage](c.Content)
}
type Tool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema map[string]interface{} `json:"input_schema"`
}
type InputSchema struct {
Type string `json:"type"`
Properties any `json:"properties,omitempty"`
Required any `json:"required,omitempty"`
}
type ClaudeWebSearchTool struct {
Type string `json:"type"`
Name string `json:"name"`
MaxUses int `json:"max_uses,omitempty"`
UserLocation *ClaudeWebSearchUserLocation `json:"user_location,omitempty"`
}
type ClaudeWebSearchUserLocation struct {
Type string `json:"type"`
Timezone string `json:"timezone,omitempty"`
Country string `json:"country,omitempty"`
Region string `json:"region,omitempty"`
City string `json:"city,omitempty"`
}
type ClaudeToolChoice struct {
Type string `json:"type"`
Name string `json:"name,omitempty"`
DisableParallelToolUse bool `json:"disable_parallel_tool_use,omitempty"`
}
type ClaudeRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt,omitempty"`
System any `json:"system,omitempty"`
Messages []ClaudeMessage `json:"messages,omitempty"`
CacheControl json.RawMessage `json:"cache_control,omitempty"`
// InferenceGeo controls Claude data residency region.
// This field is filtered by default and can be enabled via channel setting allow_inference_geo.
InferenceGeo string `json:"inference_geo,omitempty"`
MaxTokens *uint `json:"max_tokens,omitempty"`
MaxTokensToSample *uint `json:"max_tokens_to_sample,omitempty"`
StopSequences []string `json:"stop_sequences,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
TopK *int `json:"top_k,omitempty"`
Stream *bool `json:"stream,omitempty"`
Tools any `json:"tools,omitempty"`
ContextManagement json.RawMessage `json:"context_management,omitempty"`
OutputConfig json.RawMessage `json:"output_config,omitempty"`
OutputFormat json.RawMessage `json:"output_format,omitempty"`
Container json.RawMessage `json:"container,omitempty"`
ToolChoice any `json:"tool_choice,omitempty"`
Thinking *Thinking `json:"thinking,omitempty"`
McpServers json.RawMessage `json:"mcp_servers,omitempty"`
Metadata json.RawMessage `json:"metadata,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"`
// ServiceTier specifies upstream service level and may affect billing.
// This field is filtered by default and can be enabled via channel setting allow_service_tier.
ServiceTier string `json:"service_tier,omitempty"`
}
// OutputConfigForEffort just for extract effort
type OutputConfigForEffort struct {
Effort string `json:"effort,omitempty"`
}
func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
maxTokens := 0
if c.MaxTokens != nil {
maxTokens = int(*c.MaxTokens)
}
var tokenCountMeta = types.TokenCountMeta{
TokenType: types.TokenTypeTokenizer,
MaxTokens: maxTokens,
}
var texts = make([]string, 0)
var fileMeta = make([]*types.FileMeta, 0)
// system
if c.System != nil {
if c.IsStringSystem() {
sys := c.GetStringSystem()
if sys != "" {
texts = append(texts, sys)
}
} else {
systemMedia := c.ParseSystem()
for _, media := range systemMedia {
switch media.Type {
case "text":
texts = append(texts, media.GetText())
case "image":
if source := media.ToFileSource(); source != nil {
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage,
Source: source,
})
}
}
}
}
}
// messages
for _, message := range c.Messages {
tokenCountMeta.MessagesCount++
texts = append(texts, message.Role)
if message.IsStringContent() {
content := message.GetStringContent()
if content != "" {
texts = append(texts, content)
}
continue
}
content, _ := message.ParseContent()
for _, media := range content {
switch media.Type {
case "text":
texts = append(texts, media.GetText())
case "image":
if source := media.ToFileSource(); source != nil {
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage,
Source: source,
})
}
case "tool_use":
if media.Name != "" {
texts = append(texts, media.Name)
}
if media.Input != nil {
b, _ := kitutil.Marshal(media.Input)
texts = append(texts, string(b))
}
case "tool_result":
if media.Content != nil {
b, _ := kitutil.Marshal(media.Content)
texts = append(texts, string(b))
}
}
}
}
// tools
if c.Tools != nil {
tools := c.GetTools()
normalTools, webSearchTools := ProcessTools(tools)
if normalTools != nil {
for _, t := range normalTools {
tokenCountMeta.ToolsCount++
if t.Name != "" {
texts = append(texts, t.Name)
}
if t.Description != "" {
texts = append(texts, t.Description)
}
if t.InputSchema != nil {
b, _ := kitutil.Marshal(t.InputSchema)
texts = append(texts, string(b))
}
}
}
if webSearchTools != nil {
for _, t := range webSearchTools {
tokenCountMeta.ToolsCount++
if t.Name != "" {
texts = append(texts, t.Name)
}
if t.UserLocation != nil {
b, _ := kitutil.Marshal(t.UserLocation)
texts = append(texts, string(b))
}
}
}
}
tokenCountMeta.CombineText = strings.Join(texts, "\n")
tokenCountMeta.Files = fileMeta
return &tokenCountMeta
}
func (c *ClaudeRequest) IsStream(ctx *http.Request) bool {
if c.Stream == nil {
return false
}
return *c.Stream
}
func (c *ClaudeRequest) SetModelName(modelName string) {
if modelName != "" {
c.Model = modelName
}
}
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 {
c.Tools = make([]any, 0)
}
switch tools := c.Tools.(type) {
case []any:
c.Tools = append(tools, tool)
default:
// 如果Tools不是[]any类型,重新初始化为[]any
c.Tools = []any{tool}
}
}
// GetTools 获取工具列表
func (c *ClaudeRequest) GetTools() []any {
if c.Tools == nil {
return nil
}
switch tools := c.Tools.(type) {
case []any:
return tools
default:
return nil
}
}
func (c *ClaudeRequest) GetEfforts() string {
var OutputConfig OutputConfigForEffort
if err := json.Unmarshal(c.OutputConfig, &OutputConfig); err == nil {
effort := OutputConfig.Effort
return effort
}
return ""
}
// ProcessTools 处理工具列表,支持类型断言
func ProcessTools(tools []any) ([]*Tool, []*ClaudeWebSearchTool) {
var normalTools []*Tool
var webSearchTools []*ClaudeWebSearchTool
for _, tool := range tools {
switch t := tool.(type) {
case *Tool:
normalTools = append(normalTools, t)
case *ClaudeWebSearchTool:
webSearchTools = append(webSearchTools, t)
case Tool:
normalTools = append(normalTools, &t)
case ClaudeWebSearchTool:
webSearchTools = append(webSearchTools, &t)
default:
// 未知类型,跳过
continue
}
}
return normalTools, webSearchTools
}
type Thinking struct {
Type string `json:"type,omitempty"`
BudgetTokens *int `json:"budget_tokens,omitempty"`
// Display controls whether thinking content is returned in the response.
// Used with adaptive thinking on Claude Opus 4.7+: "summarized" restores
// 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"`
}
func (c *Thinking) GetBudgetTokens() int {
if c.BudgetTokens == nil {
return 0
}
return *c.BudgetTokens
}
func (c *ClaudeRequest) IsStringSystem() bool {
_, ok := c.System.(string)
return ok
}
func (c *ClaudeRequest) GetStringSystem() string {
if c.IsStringSystem() {
return c.System.(string)
}
return ""
}
func (c *ClaudeRequest) SetStringSystem(system string) {
c.System = system
}
func (c *ClaudeRequest) ParseSystem() []ClaudeMediaMessage {
mediaContent, _ := kitutil.Any2Type[[]ClaudeMediaMessage](c.System)
return mediaContent
}
type ClaudeErrorWithStatusCode struct {
Error types.ClaudeError `json:"error"`
StatusCode int `json:"status_code"`
LocalError bool
}
type ClaudeResponse struct {
Id string `json:"id,omitempty"`
Type string `json:"type"`
Role string `json:"role,omitempty"`
Content []ClaudeMediaMessage `json:"content,omitempty"`
Completion string `json:"completion,omitempty"`
StopReason string `json:"stop_reason,omitempty"`
Model string `json:"model,omitempty"`
Error any `json:"error,omitempty"`
Usage *ClaudeUsage `json:"usage,omitempty"`
Index *int `json:"index,omitempty"`
ContentBlock *ClaudeMediaMessage `json:"content_block,omitempty"`
Delta *ClaudeMediaMessage `json:"delta,omitempty"`
Message *ClaudeMediaMessage `json:"message,omitempty"`
}
// set index
func (c *ClaudeResponse) SetIndex(i int) {
c.Index = &i
}
// get index
func (c *ClaudeResponse) GetIndex() int {
if c.Index == nil {
return 0
}
return *c.Index
}
// GetClaudeError 从动态错误类型中提取ClaudeError结构
func (c *ClaudeResponse) GetClaudeError() *types.ClaudeError {
if c.Error == nil {
return nil
}
switch err := c.Error.(type) {
case types.ClaudeError:
return &err
case *types.ClaudeError:
return err
case map[string]interface{}:
// 处理从JSON解析来的map结构
claudeErr := &types.ClaudeError{}
if errType, ok := err["type"].(string); ok {
claudeErr.Type = errType
}
if errMsg, ok := err["message"].(string); ok {
claudeErr.Message = errMsg
}
return claudeErr
case string:
// 处理简单字符串错误
return &types.ClaudeError{
Type: "upstream_error",
Message: err,
}
default:
// 未知类型,尝试转换为字符串
return &types.ClaudeError{
Type: "unknown_upstream_error",
Message: fmt.Sprintf("unknown_error: %v", err),
}
}
}
type ClaudeUsage struct {
InputTokens int `json:"input_tokens"`
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheCreation *ClaudeCacheCreationUsage `json:"cache_creation,omitempty"`
// claude cache 1h
ClaudeCacheCreation5mTokens int `json:"claude_cache_creation_5_m_tokens"`
ClaudeCacheCreation1hTokens int `json:"claude_cache_creation_1_h_tokens"`
ServerToolUse *ClaudeServerToolUse `json:"server_tool_use,omitempty"`
BillingUsage *BillingUsage `json:"billing_usage,omitempty"`
}
type ClaudeCacheCreationUsage struct {
Ephemeral5mInputTokens int `json:"ephemeral_5m_input_tokens,omitempty"`
Ephemeral1hInputTokens int `json:"ephemeral_1h_input_tokens,omitempty"`
}
func (u *ClaudeUsage) GetCacheCreation5mTokens() int {
if u == nil || u.CacheCreation == nil {
return 0
}
return u.CacheCreation.Ephemeral5mInputTokens
}
func (u *ClaudeUsage) GetCacheCreation1hTokens() int {
if u == nil || u.CacheCreation == nil {
return 0
}
return u.CacheCreation.Ephemeral1hInputTokens
}
func (u *ClaudeUsage) GetCacheCreationTotalTokens() int {
if u == nil {
return 0
}
if u.CacheCreationInputTokens > 0 {
return u.CacheCreationInputTokens
}
return u.GetCacheCreation5mTokens() + u.GetCacheCreation1hTokens()
}
type ClaudeServerToolUse struct {
WebSearchRequests int `json:"web_search_requests"`
}
+87
View File
@@ -0,0 +1,87 @@
package dto
import (
"net/http"
"strings"
"github.com/QuantumNous/new-api/relaykit/types"
)
type EmbeddingOptions struct {
Seed int `json:"seed,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopK int `json:"top_k,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"`
PresencePenalty *float64 `json:"presence_penalty,omitempty"`
NumPredict int `json:"num_predict,omitempty"`
NumCtx int `json:"num_ctx,omitempty"`
}
type EmbeddingRequest struct {
Model string `json:"model"`
Input any `json:"input"`
EncodingFormat string `json:"encoding_format,omitempty"`
Dimensions *int `json:"dimensions,omitempty"`
User string `json:"user,omitempty"`
Seed *float64 `json:"seed,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"`
PresencePenalty *float64 `json:"presence_penalty,omitempty"`
}
func (r *EmbeddingRequest) GetTokenCountMeta() *types.TokenCountMeta {
var texts = make([]string, 0)
inputs := r.ParseInput()
for _, input := range inputs {
texts = append(texts, input)
}
return &types.TokenCountMeta{
CombineText: strings.Join(texts, "\n"),
}
}
func (r *EmbeddingRequest) IsStream(c *http.Request) bool {
return false
}
func (r *EmbeddingRequest) SetModelName(modelName string) {
if modelName != "" {
r.Model = modelName
}
}
func (r *EmbeddingRequest) ParseInput() []string {
if r.Input == nil {
return make([]string, 0)
}
var input []string
switch r.Input.(type) {
case string:
input = []string{r.Input.(string)}
case []any:
input = make([]string, 0, len(r.Input.([]any)))
for _, item := range r.Input.([]any) {
if str, ok := item.(string); ok {
input = append(input, str)
}
}
}
return input
}
type EmbeddingResponseItem struct {
Object string `json:"object"`
Index int `json:"index"`
Embedding []float64 `json:"embedding"`
}
type EmbeddingResponse struct {
Object string `json:"object"`
Data []EmbeddingResponseItem `json:"data"`
Model string `json:"model"`
Usage `json:"usage"`
}
+93
View File
@@ -0,0 +1,93 @@
package dto
import (
"encoding/json"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
//type OpenAIError struct {
// Message string `json:"message"`
// Type string `json:"type"`
// Param string `json:"param"`
// Code any `json:"code"`
//}
type OpenAIErrorWithStatusCode struct {
Error types.OpenAIError `json:"error"`
StatusCode int `json:"status_code"`
LocalError bool
}
type GeneralErrorResponse struct {
Error json.RawMessage `json:"error"`
Message string `json:"message"`
Msg string `json:"msg"`
Err string `json:"err"`
ErrorMsg string `json:"error_msg"`
Metadata json.RawMessage `json:"metadata,omitempty"`
Detail string `json:"detail,omitempty"`
Header struct {
Message string `json:"message"`
} `json:"header"`
Response struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
} `json:"response"`
}
func (e GeneralErrorResponse) TryToOpenAIError() *types.OpenAIError {
var openAIError types.OpenAIError
if len(e.Error) > 0 {
err := kitutil.Unmarshal(e.Error, &openAIError)
if err == nil && openAIError.Message != "" {
return &openAIError
}
}
return nil
}
func (e GeneralErrorResponse) ToMessage() string {
if len(e.Error) > 0 {
switch kitutil.GetJsonType(e.Error) {
case "object":
var openAIError types.OpenAIError
err := kitutil.Unmarshal(e.Error, &openAIError)
if err == nil && openAIError.Message != "" {
return openAIError.Message
}
case "string":
var msg string
err := kitutil.Unmarshal(e.Error, &msg)
if err == nil && msg != "" {
return msg
}
default:
return string(e.Error)
}
}
if e.Message != "" {
return e.Message
}
if e.Msg != "" {
return e.Msg
}
if e.Err != "" {
return e.Err
}
if e.ErrorMsg != "" {
return e.ErrorMsg
}
if e.Detail != "" {
return e.Detail
}
if e.Header.Message != "" {
return e.Header.Message
}
if e.Response.Error.Message != "" {
return e.Response.Error.Message
}
return ""
}
+626
View File
@@ -0,0 +1,626 @@
package dto
import (
"encoding/json"
"net/http"
"strings"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
type GeminiChatRequest struct {
Requests []GeminiChatRequest `json:"requests,omitempty"` // For batch requests
Contents []GeminiChatContent `json:"contents"`
SafetySettings []GeminiChatSafetySettings `json:"safetySettings,omitempty"`
GenerationConfig GeminiChatGenerationConfig `json:"generationConfig,omitempty"`
Tools json.RawMessage `json:"tools,omitempty"`
ToolConfig *ToolConfig `json:"toolConfig,omitempty"`
SystemInstructions *GeminiChatContent `json:"systemInstruction,omitempty"`
CachedContent string `json:"cachedContent,omitempty"`
}
// UnmarshalJSON allows GeminiChatRequest to accept both snake_case and camelCase fields.
func (r *GeminiChatRequest) UnmarshalJSON(data []byte) error {
type Alias GeminiChatRequest
var aux struct {
Alias
SystemInstructionSnake *GeminiChatContent `json:"system_instruction,omitempty"`
}
if err := kitutil.Unmarshal(data, &aux); err != nil {
return err
}
*r = GeminiChatRequest(aux.Alias)
if aux.SystemInstructionSnake != nil {
r.SystemInstructions = aux.SystemInstructionSnake
}
return nil
}
type ToolConfig struct {
FunctionCallingConfig *FunctionCallingConfig `json:"functionCallingConfig,omitempty"`
RetrievalConfig *RetrievalConfig `json:"retrievalConfig,omitempty"`
IncludeServerSideToolInvocations *bool `json:"includeServerSideToolInvocations,omitempty"`
}
type FunctionCallingConfig struct {
Mode FunctionCallingConfigMode `json:"mode,omitempty"`
AllowedFunctionNames []string `json:"allowedFunctionNames,omitempty"`
}
type FunctionCallingConfigMode string
type RetrievalConfig struct {
LatLng *LatLng `json:"latLng,omitempty"`
LanguageCode string `json:"languageCode,omitempty"`
}
type LatLng struct {
Latitude *float64 `json:"latitude,omitempty"`
Longitude *float64 `json:"longitude,omitempty"`
}
func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
var files []*types.FileMeta = make([]*types.FileMeta, 0)
var maxTokens int
if r.GenerationConfig.MaxOutputTokens != nil && *r.GenerationConfig.MaxOutputTokens > 0 {
maxTokens = int(*r.GenerationConfig.MaxOutputTokens)
}
var inputTexts []string
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
var fileType types.FileType
if strings.HasPrefix(mimeType, "image/") {
fileType = types.FileTypeImage
} else if strings.HasPrefix(mimeType, "audio/") {
fileType = types.FileTypeAudio
} else if strings.HasPrefix(mimeType, "video/") {
fileType = types.FileTypeVideo
} else {
fileType = types.FileTypeFile
}
files = append(files, &types.FileMeta{
FileType: fileType,
Source: source,
})
}
}
}
inputText := strings.Join(inputTexts, "\n")
return &types.TokenCountMeta{
CombineText: inputText,
Files: files,
MaxTokens: maxTokens,
}
}
func (r *GeminiChatRequest) IsStream(c *http.Request) bool {
if c == nil {
return false
}
if c.URL.Query().Get("alt") == "sse" {
return true
}
// Native Gemini API uses URL action to indicate streaming:
// /v1beta/models/{model}:streamGenerateContent
if strings.Contains(c.URL.Path, "streamGenerateContent") {
return true
}
return false
}
func (r *GeminiChatRequest) SetModelName(modelName string) {
// GeminiChatRequest does not have a model field, so this method does nothing.
}
func (r *GeminiChatRequest) GetTools() []GeminiChatTool {
var tools []GeminiChatTool
if strings.HasPrefix(string(r.Tools), "[") {
// is array
if err := kitutil.Unmarshal(r.Tools, &tools); err != nil {
kitutil.LogError("error_unmarshalling_tools: " + err.Error())
return nil
}
} else if strings.HasPrefix(string(r.Tools), "{") {
// is object
singleTool := GeminiChatTool{}
if err := kitutil.Unmarshal(r.Tools, &singleTool); err != nil {
kitutil.LogError("error_unmarshalling_single_tool: " + err.Error())
return nil
}
tools = []GeminiChatTool{singleTool}
}
return tools
}
func (r *GeminiChatRequest) SetTools(tools []GeminiChatTool) {
if len(tools) == 0 {
r.Tools = json.RawMessage("[]")
return
}
// Marshal the tools to JSON
data, err := kitutil.Marshal(tools)
if err != nil {
kitutil.LogError("error_marshalling_tools: " + err.Error())
return
}
r.Tools = data
}
type GeminiThinkingConfig struct {
IncludeThoughts bool `json:"includeThoughts,omitempty"`
ThinkingBudget *int `json:"thinkingBudget,omitempty"`
// TODO Conflict with thinkingbudget.
ThinkingLevel string `json:"thinkingLevel,omitempty"`
}
// UnmarshalJSON allows GeminiThinkingConfig to accept both snake_case and camelCase fields.
func (c *GeminiThinkingConfig) UnmarshalJSON(data []byte) error {
type Alias GeminiThinkingConfig
var aux struct {
Alias
IncludeThoughtsSnake *bool `json:"include_thoughts,omitempty"`
ThinkingBudgetSnake *int `json:"thinking_budget,omitempty"`
ThinkingLevelSnake string `json:"thinking_level,omitempty"`
}
if err := kitutil.Unmarshal(data, &aux); err != nil {
return err
}
*c = GeminiThinkingConfig(aux.Alias)
if aux.IncludeThoughtsSnake != nil {
c.IncludeThoughts = *aux.IncludeThoughtsSnake
}
if aux.ThinkingBudgetSnake != nil {
c.ThinkingBudget = aux.ThinkingBudgetSnake
}
if aux.ThinkingLevelSnake != "" {
c.ThinkingLevel = aux.ThinkingLevelSnake
}
return nil
}
func (c *GeminiThinkingConfig) SetThinkingBudget(budget int) {
c.ThinkingBudget = &budget
}
type GeminiInlineData struct {
MimeType string `json:"mimeType"`
Data string `json:"data"`
}
func (d *GeminiInlineData) ToFileSource() types.FileSource {
if d == nil || d.Data == "" {
return nil
}
return types.NewFileSourceFromData(d.Data, d.MimeType)
}
// UnmarshalJSON custom unmarshaler for GeminiInlineData to support snake_case and camelCase for MimeType
func (g *GeminiInlineData) UnmarshalJSON(data []byte) error {
type Alias GeminiInlineData // Use type alias to avoid recursion
var aux struct {
Alias
MimeTypeSnake string `json:"mime_type"`
}
if err := kitutil.Unmarshal(data, &aux); err != nil {
return err
}
*g = GeminiInlineData(aux.Alias) // Copy other fields if any in future
// Prioritize snake_case if present
if aux.MimeTypeSnake != "" {
g.MimeType = aux.MimeTypeSnake
} else if aux.MimeType != "" { // Fallback to camelCase from Alias
g.MimeType = aux.MimeType
}
// g.Data would be populated by aux.Alias.Data
return nil
}
type FunctionCall struct {
FunctionName string `json:"name"`
Arguments any `json:"args"`
}
type GeminiFunctionResponse struct {
Name string `json:"name"`
Response map[string]interface{} `json:"response"`
WillContinue json.RawMessage `json:"willContinue,omitempty"`
Scheduling json.RawMessage `json:"scheduling,omitempty"`
Parts json.RawMessage `json:"parts,omitempty"`
ID json.RawMessage `json:"id,omitempty"`
}
type GeminiPartExecutableCode struct {
Language string `json:"language,omitempty"`
Code string `json:"code,omitempty"`
}
type GeminiPartCodeExecutionResult struct {
Outcome string `json:"outcome,omitempty"`
Output string `json:"output,omitempty"`
}
type GeminiFileData struct {
MimeType string `json:"mimeType,omitempty"`
FileUri string `json:"fileUri,omitempty"`
}
type GeminiPart struct {
Text string `json:"text,omitempty"`
Thought bool `json:"thought,omitempty"`
InlineData *GeminiInlineData `json:"inlineData,omitempty"`
FunctionCall *FunctionCall `json:"functionCall,omitempty"`
ThoughtSignature json.RawMessage `json:"thoughtSignature,omitempty"`
FunctionResponse *GeminiFunctionResponse `json:"functionResponse,omitempty"`
// Optional. Media resolution for the input media.
MediaResolution json.RawMessage `json:"mediaResolution,omitempty"`
VideoMetadata json.RawMessage `json:"videoMetadata,omitempty"`
FileData *GeminiFileData `json:"fileData,omitempty"`
ExecutableCode *GeminiPartExecutableCode `json:"executableCode,omitempty"`
CodeExecutionResult *GeminiPartCodeExecutionResult `json:"codeExecutionResult,omitempty"`
}
// UnmarshalJSON custom unmarshaler for GeminiPart to support snake_case and camelCase for InlineData
func (p *GeminiPart) UnmarshalJSON(data []byte) error {
// Alias to avoid recursion during unmarshalling
type Alias GeminiPart
var aux struct {
Alias
InlineDataSnake *GeminiInlineData `json:"inline_data,omitempty"` // snake_case variant
}
if err := kitutil.Unmarshal(data, &aux); err != nil {
return err
}
// Assign fields from alias
*p = GeminiPart(aux.Alias)
// Prioritize snake_case for InlineData if present
if aux.InlineDataSnake != nil {
p.InlineData = aux.InlineDataSnake
} else if aux.InlineData != nil { // Fallback to camelCase from Alias
p.InlineData = aux.InlineData
}
// Other fields like Text, FunctionCall etc. are already populated via aux.Alias
return nil
}
type GeminiChatContent struct {
Role string `json:"role,omitempty"`
Parts []GeminiPart `json:"parts"`
}
type GeminiChatSafetySettings struct {
Category string `json:"category"`
Threshold string `json:"threshold"`
}
type GeminiChatTool struct {
GoogleSearch any `json:"googleSearch,omitempty"`
GoogleSearchRetrieval any `json:"googleSearchRetrieval,omitempty"`
CodeExecution any `json:"codeExecution,omitempty"`
FunctionDeclarations any `json:"functionDeclarations,omitempty"`
URLContext any `json:"urlContext,omitempty"`
}
type GeminiChatGenerationConfig struct {
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"topP,omitempty"`
TopK *float64 `json:"topK,omitempty"`
MaxOutputTokens *uint `json:"maxOutputTokens,omitempty"`
CandidateCount *int `json:"candidateCount,omitempty"`
StopSequences []string `json:"stopSequences,omitempty"`
ResponseMimeType string `json:"responseMimeType,omitempty"`
ResponseSchema any `json:"responseSchema,omitempty"`
ResponseJsonSchema json.RawMessage `json:"responseJsonSchema,omitempty"`
PresencePenalty *float32 `json:"presencePenalty,omitempty"`
FrequencyPenalty *float32 `json:"frequencyPenalty,omitempty"`
ResponseLogprobs *bool `json:"responseLogprobs,omitempty"`
Logprobs *int32 `json:"logprobs,omitempty"`
EnableEnhancedCivicAnswers *bool `json:"enableEnhancedCivicAnswers,omitempty"`
MediaResolution MediaResolution `json:"mediaResolution,omitempty"`
Seed *int64 `json:"seed,omitempty"`
ResponseModalities []string `json:"responseModalities,omitempty"`
ThinkingConfig *GeminiThinkingConfig `json:"thinkingConfig,omitempty"`
SpeechConfig json.RawMessage `json:"speechConfig,omitempty"` // RawMessage to allow flexible speech config
ImageConfig json.RawMessage `json:"imageConfig,omitempty"` // RawMessage to allow flexible image config
}
// UnmarshalJSON allows GeminiChatGenerationConfig to accept both snake_case and camelCase fields.
func (c *GeminiChatGenerationConfig) UnmarshalJSON(data []byte) error {
type Alias GeminiChatGenerationConfig
var aux struct {
Alias
TopPSnake *float64 `json:"top_p,omitempty"`
TopKSnake *float64 `json:"top_k,omitempty"`
MaxOutputTokensSnake *uint `json:"max_output_tokens,omitempty"`
CandidateCountSnake *int `json:"candidate_count,omitempty"`
StopSequencesSnake []string `json:"stop_sequences,omitempty"`
ResponseMimeTypeSnake string `json:"response_mime_type,omitempty"`
ResponseSchemaSnake any `json:"response_schema,omitempty"`
ResponseJsonSchemaSnake json.RawMessage `json:"response_json_schema,omitempty"`
PresencePenaltySnake *float32 `json:"presence_penalty,omitempty"`
FrequencyPenaltySnake *float32 `json:"frequency_penalty,omitempty"`
ResponseLogprobsSnake *bool `json:"response_logprobs,omitempty"`
EnableEnhancedCivicAnswersSnake *bool `json:"enable_enhanced_civic_answers,omitempty"`
MediaResolutionSnake MediaResolution `json:"media_resolution,omitempty"`
ResponseModalitiesSnake []string `json:"response_modalities,omitempty"`
ThinkingConfigSnake *GeminiThinkingConfig `json:"thinking_config,omitempty"`
SpeechConfigSnake json.RawMessage `json:"speech_config,omitempty"`
ImageConfigSnake json.RawMessage `json:"image_config,omitempty"`
}
if err := kitutil.Unmarshal(data, &aux); err != nil {
return err
}
*c = GeminiChatGenerationConfig(aux.Alias)
// Prioritize snake_case if present
if aux.TopPSnake != nil {
c.TopP = aux.TopPSnake
}
if aux.TopKSnake != nil {
c.TopK = aux.TopKSnake
}
if aux.MaxOutputTokensSnake != nil {
c.MaxOutputTokens = aux.MaxOutputTokensSnake
}
if aux.CandidateCountSnake != nil {
c.CandidateCount = aux.CandidateCountSnake
}
if len(aux.StopSequencesSnake) > 0 {
c.StopSequences = aux.StopSequencesSnake
}
if aux.ResponseMimeTypeSnake != "" {
c.ResponseMimeType = aux.ResponseMimeTypeSnake
}
if aux.ResponseSchemaSnake != nil {
c.ResponseSchema = aux.ResponseSchemaSnake
}
if len(aux.ResponseJsonSchemaSnake) > 0 {
c.ResponseJsonSchema = aux.ResponseJsonSchemaSnake
}
if aux.PresencePenaltySnake != nil {
c.PresencePenalty = aux.PresencePenaltySnake
}
if aux.FrequencyPenaltySnake != nil {
c.FrequencyPenalty = aux.FrequencyPenaltySnake
}
if aux.ResponseLogprobsSnake != nil {
c.ResponseLogprobs = aux.ResponseLogprobsSnake
}
if aux.EnableEnhancedCivicAnswersSnake != nil {
c.EnableEnhancedCivicAnswers = aux.EnableEnhancedCivicAnswersSnake
}
if aux.MediaResolutionSnake != "" {
c.MediaResolution = aux.MediaResolutionSnake
}
if len(aux.ResponseModalitiesSnake) > 0 {
c.ResponseModalities = aux.ResponseModalitiesSnake
}
if aux.ThinkingConfigSnake != nil {
c.ThinkingConfig = aux.ThinkingConfigSnake
}
if len(aux.SpeechConfigSnake) > 0 {
c.SpeechConfig = aux.SpeechConfigSnake
}
if len(aux.ImageConfigSnake) > 0 {
c.ImageConfig = aux.ImageConfigSnake
}
return nil
}
type MediaResolution string
type GeminiChatCandidate struct {
Content GeminiChatContent `json:"content"`
FinishReason *string `json:"finishReason"`
Index int64 `json:"index"`
SafetyRatings []GeminiChatSafetyRating `json:"safetyRatings"`
GroundingMetadata *GeminiGroundingMetadata `json:"groundingMetadata,omitempty"`
}
type GeminiGroundingMetadata struct {
WebSearchQueries []string `json:"webSearchQueries,omitempty"`
}
type GeminiChatSafetyRating struct {
Category string `json:"category"`
Probability string `json:"probability"`
}
type GeminiChatPromptFeedback struct {
SafetyRatings []GeminiChatSafetyRating `json:"safetyRatings"`
BlockReason *string `json:"blockReason,omitempty"`
}
type GeminiChatResponse struct {
Candidates []GeminiChatCandidate `json:"candidates"`
PromptFeedback *GeminiChatPromptFeedback `json:"promptFeedback,omitempty"`
UsageMetadata GeminiUsageMetadata `json:"usageMetadata"`
HasUsageMetadata bool `json:"-"`
}
// UnmarshalJSON records whether Gemini returned usageMetadata while preserving
// the historical wire shape that always marshals the usageMetadata field.
//
// IMPORTANT: aux shadows GeminiChatResponse. Any field added to
// GeminiChatResponse must also be added to aux (and copied below), otherwise it
// is silently dropped during unmarshal.
func (r *GeminiChatResponse) UnmarshalJSON(data []byte) error {
var aux struct {
Candidates []GeminiChatCandidate `json:"candidates"`
PromptFeedback *GeminiChatPromptFeedback `json:"promptFeedback,omitempty"`
UsageMetadata *GeminiUsageMetadata `json:"usageMetadata"`
}
if err := kitutil.Unmarshal(data, &aux); err != nil {
return err
}
r.Candidates = aux.Candidates
r.PromptFeedback = aux.PromptFeedback
r.HasUsageMetadata = aux.UsageMetadata != nil
if aux.UsageMetadata != nil {
r.UsageMetadata = *aux.UsageMetadata
} else {
r.UsageMetadata = GeminiUsageMetadata{}
}
return nil
}
func (r *GeminiChatResponse) GetUsageMetadata() *GeminiUsageMetadata {
if r == nil {
return nil
}
if r.HasUsageMetadata || HasGeminiUsageMetadataTokens(&r.UsageMetadata) {
return &r.UsageMetadata
}
return nil
}
type GeminiUsageMetadata struct {
PromptTokenCount int `json:"promptTokenCount"`
ToolUsePromptTokenCount int `json:"toolUsePromptTokenCount"`
CandidatesTokenCount int `json:"candidatesTokenCount"`
TotalTokenCount int `json:"totalTokenCount"`
ThoughtsTokenCount int `json:"thoughtsTokenCount"`
CachedContentTokenCount int `json:"cachedContentTokenCount"`
PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"`
ToolUsePromptTokensDetails []GeminiPromptTokensDetails `json:"toolUsePromptTokensDetails"`
CandidatesTokensDetails []GeminiPromptTokensDetails `json:"candidatesTokensDetails"`
BillingUsage *BillingUsage `json:"billing_usage,omitempty"`
}
type GeminiPromptTokensDetails struct {
Modality string `json:"modality"`
TokenCount int `json:"tokenCount"`
}
// Imagen related structs
type GeminiImageRequest struct {
Instances []GeminiImageInstance `json:"instances"`
Parameters GeminiImageParameters `json:"parameters"`
}
type GeminiImageInstance struct {
Prompt string `json:"prompt"`
}
type GeminiImageParameters struct {
SampleCount int `json:"sampleCount,omitempty"`
AspectRatio string `json:"aspectRatio,omitempty"`
PersonGeneration string `json:"personGeneration,omitempty"`
ImageSize string `json:"imageSize,omitempty"`
}
type GeminiImageResponse struct {
Predictions []GeminiImagePrediction `json:"predictions"`
}
type GeminiImagePrediction struct {
MimeType string `json:"mimeType"`
BytesBase64Encoded string `json:"bytesBase64Encoded"`
RaiFilteredReason string `json:"raiFilteredReason,omitempty"`
SafetyAttributes any `json:"safetyAttributes,omitempty"`
}
// Embedding related structs
type GeminiEmbeddingRequest struct {
Model string `json:"model,omitempty"`
Content GeminiChatContent `json:"content"`
TaskType string `json:"taskType,omitempty"`
Title string `json:"title,omitempty"`
OutputDimensionality int `json:"outputDimensionality,omitempty"`
}
func (r *GeminiEmbeddingRequest) IsStream(c *http.Request) bool {
// Gemini embedding requests are not streamed
return false
}
func (r *GeminiEmbeddingRequest) GetTokenCountMeta() *types.TokenCountMeta {
var inputTexts []string
for _, part := range r.Content.Parts {
if part.Text != "" {
inputTexts = append(inputTexts, part.Text)
}
}
inputText := strings.Join(inputTexts, "\n")
return &types.TokenCountMeta{
CombineText: inputText,
}
}
func (r *GeminiEmbeddingRequest) SetModelName(modelName string) {
if modelName != "" {
r.Model = modelName
}
}
type GeminiBatchEmbeddingRequest struct {
Requests []*GeminiEmbeddingRequest `json:"requests"`
}
func (r *GeminiBatchEmbeddingRequest) IsStream(c *http.Request) bool {
// Gemini batch embedding requests are not streamed
return false
}
func (r *GeminiBatchEmbeddingRequest) GetTokenCountMeta() *types.TokenCountMeta {
var inputTexts []string
for _, request := range r.Requests {
meta := request.GetTokenCountMeta()
if meta != nil && meta.CombineText != "" {
inputTexts = append(inputTexts, meta.CombineText)
}
}
inputText := strings.Join(inputTexts, "\n")
return &types.TokenCountMeta{
CombineText: inputText,
}
}
func (r *GeminiBatchEmbeddingRequest) SetModelName(modelName string) {
if modelName != "" {
for _, req := range r.Requests {
req.SetModelName(modelName)
}
}
}
type GeminiEmbeddingResponse struct {
Embedding ContentEmbedding `json:"embedding"`
}
type GeminiBatchEmbeddingResponse struct {
Embeddings []*ContentEmbedding `json:"embeddings"`
}
type ContentEmbedding struct {
Values []float64 `json:"values"`
}
@@ -0,0 +1,89 @@
package dto
import (
"testing"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGeminiChatGenerationConfigPreservesExplicitZeroValuesCamelCase(t *testing.T) {
raw := []byte(`{
"contents":[{"role":"user","parts":[{"text":"hello"}]}],
"generationConfig":{
"topP":0,
"topK":0,
"maxOutputTokens":0,
"candidateCount":0,
"seed":0,
"responseLogprobs":false
}
}`)
var req GeminiChatRequest
require.NoError(t, kitutil.Unmarshal(raw, &req))
encoded, err := kitutil.Marshal(req)
require.NoError(t, err)
var out map[string]any
require.NoError(t, kitutil.Unmarshal(encoded, &out))
generationConfig, ok := out["generationConfig"].(map[string]any)
require.True(t, ok)
assert.Contains(t, generationConfig, "topP")
assert.Contains(t, generationConfig, "topK")
assert.Contains(t, generationConfig, "maxOutputTokens")
assert.Contains(t, generationConfig, "candidateCount")
assert.Contains(t, generationConfig, "seed")
assert.Contains(t, generationConfig, "responseLogprobs")
assert.Equal(t, float64(0), generationConfig["topP"])
assert.Equal(t, float64(0), generationConfig["topK"])
assert.Equal(t, float64(0), generationConfig["maxOutputTokens"])
assert.Equal(t, float64(0), generationConfig["candidateCount"])
assert.Equal(t, float64(0), generationConfig["seed"])
assert.Equal(t, false, generationConfig["responseLogprobs"])
}
func TestGeminiChatGenerationConfigPreservesExplicitZeroValuesSnakeCase(t *testing.T) {
raw := []byte(`{
"contents":[{"role":"user","parts":[{"text":"hello"}]}],
"generationConfig":{
"top_p":0,
"top_k":0,
"max_output_tokens":0,
"candidate_count":0,
"seed":0,
"response_logprobs":false
}
}`)
var req GeminiChatRequest
require.NoError(t, kitutil.Unmarshal(raw, &req))
encoded, err := kitutil.Marshal(req)
require.NoError(t, err)
var out map[string]any
require.NoError(t, kitutil.Unmarshal(encoded, &out))
generationConfig, ok := out["generationConfig"].(map[string]any)
require.True(t, ok)
assert.Contains(t, generationConfig, "topP")
assert.Contains(t, generationConfig, "topK")
assert.Contains(t, generationConfig, "maxOutputTokens")
assert.Contains(t, generationConfig, "candidateCount")
assert.Contains(t, generationConfig, "seed")
assert.Contains(t, generationConfig, "responseLogprobs")
assert.Equal(t, float64(0), generationConfig["topP"])
assert.Equal(t, float64(0), generationConfig["topK"])
assert.Equal(t, float64(0), generationConfig["maxOutputTokens"])
assert.Equal(t, float64(0), generationConfig["candidateCount"])
assert.Equal(t, float64(0), generationConfig["seed"])
assert.Equal(t, false, generationConfig["responseLogprobs"])
}
+68
View File
@@ -0,0 +1,68 @@
package dto
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
)
func TestGeminiChatRequest_IsStream(t *testing.T) {
tests := []struct {
name string
path string
query string
expected bool
}{
{
name: "streamGenerateContent without alt=sse",
path: "/v1beta/models/gemini-2.0-flash:streamGenerateContent",
query: "key=sk-xxx",
expected: true,
},
{
name: "streamGenerateContent with alt=sse",
path: "/v1beta/models/gemini-2.0-flash:streamGenerateContent",
query: "alt=sse&key=sk-xxx",
expected: true,
},
{
name: "generateContent without alt=sse",
path: "/v1beta/models/gemini-2.0-flash:generateContent",
query: "key=sk-xxx",
expected: false,
},
{
name: "generateContent with alt=sse",
path: "/v1beta/models/gemini-2.0-flash:generateContent",
query: "alt=sse",
expected: true,
},
{
name: "GenerateContent capitalized",
path: "/v1beta/models/gemini-2.0-flash:GenerateContent",
query: "key=sk-xxx",
expected: false,
},
{
name: "embedding path",
path: "/v1beta/models/gemini-2.0-flash:embedContent",
query: "",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
url := tt.path
if tt.query != "" {
url += "?" + tt.query
}
httpReq, err := http.NewRequest("POST", url, nil)
assert.NoError(t, err)
req := &GeminiChatRequest{}
assert.Equal(t, tt.expected, req.IsStream(httpReq))
})
}
}
+34
View File
@@ -0,0 +1,34 @@
package dto
import (
"testing"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGeminiChatResponseUsageMetadataPresence(t *testing.T) {
var missing GeminiChatResponse
require.NoError(t, kitutil.Unmarshal([]byte(`{"candidates":[]}`), &missing))
assert.False(t, missing.HasUsageMetadata)
assert.Nil(t, missing.GetUsageMetadata())
var empty GeminiChatResponse
require.NoError(t, kitutil.Unmarshal([]byte(`{"candidates":[],"usageMetadata":{}}`), &empty))
assert.True(t, empty.HasUsageMetadata)
require.NotNil(t, empty.GetUsageMetadata())
assert.False(t, HasGeminiUsageMetadataTokens(empty.GetUsageMetadata()))
var populated GeminiChatResponse
require.NoError(t, kitutil.Unmarshal([]byte(`{"candidates":[],"usageMetadata":{"promptTokenCount":3}}`), &populated))
assert.True(t, populated.HasUsageMetadata)
require.NotNil(t, populated.GetUsageMetadata())
assert.True(t, HasGeminiUsageMetadataTokens(populated.GetUsageMetadata()))
}
func TestGeminiChatResponseMarshalKeepsUsageMetadataField(t *testing.T) {
data, err := kitutil.Marshal(GeminiChatResponse{})
require.NoError(t, err)
assert.Contains(t, string(data), `"usageMetadata"`)
}
+25
View File
@@ -0,0 +1,25 @@
package dto
type Notify struct {
Type string `json:"type"`
Title string `json:"title"`
Content string `json:"content"`
Values []interface{} `json:"values"`
}
const ContentValueParam = "{{value}}"
const (
NotifyTypeQuotaExceed = "quota_exceed"
NotifyTypeChannelUpdate = "channel_update"
NotifyTypeChannelTest = "channel_test"
)
func NewNotify(t string, title string, content string, values []interface{}) Notify {
return Notify{
Type: t,
Title: title,
Content: content,
Values: values,
}
}
+20
View File
@@ -0,0 +1,20 @@
package dto
import (
"encoding/json"
"github.com/QuantumNous/new-api/relaykit/types"
)
type OpenAIResponsesCompactionResponse struct {
ID string `json:"id"`
Object string `json:"object"`
CreatedAt int `json:"created_at"`
Output json.RawMessage `json:"output"`
Usage *Usage `json:"usage"`
Error any `json:"error,omitempty"`
}
func (o *OpenAIResponsesCompactionResponse) GetOpenAIError() *types.OpenAIError {
return GetOpenAIError(o.Error)
}
+192
View File
@@ -0,0 +1,192 @@
package dto
import (
"encoding/json"
"net/http"
"reflect"
"strings"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
// MaxImageN caps the image generation count. Without this bound a huge or
// wrapped-negative n overflows quota calculation into a negative charge.
const MaxImageN = 128
type ImageRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt" binding:"required"`
N *uint `json:"n,omitempty"`
Size string `json:"size,omitempty"`
Quality string `json:"quality,omitempty"`
ResponseFormat string `json:"response_format,omitempty"`
Style json.RawMessage `json:"style,omitempty"`
User json.RawMessage `json:"user,omitempty"`
ExtraFields json.RawMessage `json:"extra_fields,omitempty"`
Background json.RawMessage `json:"background,omitempty"`
Moderation json.RawMessage `json:"moderation,omitempty"`
OutputFormat json.RawMessage `json:"output_format,omitempty"`
OutputCompression json.RawMessage `json:"output_compression,omitempty"`
PartialImages json.RawMessage `json:"partial_images,omitempty"`
Stream *bool `json:"stream,omitempty"`
Images json.RawMessage `json:"images,omitempty"`
Mask json.RawMessage `json:"mask,omitempty"`
InputFidelity json.RawMessage `json:"input_fidelity,omitempty"`
Watermark *bool `json:"watermark,omitempty"`
// zhipu 4v
WatermarkEnabled json.RawMessage `json:"watermark_enabled,omitempty"`
UserId json.RawMessage `json:"user_id,omitempty"`
Image json.RawMessage `json:"image,omitempty"`
// 用匿名参数接收额外参数
Extra map[string]json.RawMessage `json:"-"`
}
func (i *ImageRequest) UnmarshalJSON(data []byte) error {
// 先解析成 map[string]interface{}
var rawMap map[string]json.RawMessage
if err := kitutil.Unmarshal(data, &rawMap); err != nil {
return err
}
// 用 struct tag 获取所有已定义字段名
knownFields := GetJSONFieldNames(reflect.TypeOf(*i))
// 再正常解析已定义字段
type Alias ImageRequest
var known Alias
if err := kitutil.Unmarshal(data, &known); err != nil {
return err
}
*i = ImageRequest(known)
// 提取多余字段
i.Extra = make(map[string]json.RawMessage)
for k, v := range rawMap {
if _, ok := knownFields[k]; !ok {
i.Extra[k] = v
}
}
return nil
}
// 序列化时需要重新把字段平铺
func (r ImageRequest) MarshalJSON() ([]byte, error) {
// 将已定义字段转为 map
type Alias ImageRequest
alias := Alias(r)
base, err := kitutil.Marshal(alias)
if err != nil {
return nil, err
}
var baseMap map[string]json.RawMessage
if err := kitutil.Unmarshal(base, &baseMap); err != nil {
return nil, err
}
// 不能合并ExtraFields!!!!!!!!
// 合并 ExtraFields
//for k, v := range r.Extra {
// if _, exists := baseMap[k]; !exists {
// baseMap[k] = v
// }
//}
return kitutil.Marshal(baseMap)
}
func GetJSONFieldNames(t reflect.Type) map[string]struct{} {
fields := make(map[string]struct{})
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
// 跳过匿名字段(例如 ExtraFields
if field.Anonymous {
continue
}
tag := field.Tag.Get("json")
if tag == "-" || tag == "" {
continue
}
// 取逗号前字段名(排除 omitempty 等)
name := tag
if commaIdx := indexComma(tag); commaIdx != -1 {
name = tag[:commaIdx]
}
fields[name] = struct{}{}
}
return fields
}
func indexComma(s string) int {
for i := 0; i < len(s); i++ {
if s[i] == ',' {
return i
}
}
return -1
}
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
}
// Keep n separate from ImagePriceRatio so size/quality and count remain
// independent billing dimensions. Fixed-price pre-consume stores this on
// PriceData, and image settlement reuses or replaces the same "n" ratio.
return &types.TokenCountMeta{
CombineText: i.Prompt,
MaxTokens: 1584,
ImagePriceRatio: sizeRatio * qualityRatio,
BillingRatios: map[string]float64{"n": float64(imageN)},
}
}
func (i *ImageRequest) IsStream(c *http.Request) bool {
return i.Stream != nil && *i.Stream
}
func (i *ImageRequest) SetModelName(modelName string) {
if modelName != "" {
i.Model = modelName
}
}
type ImageResponse struct {
Data []ImageData `json:"data"`
Created int64 `json:"created"`
Metadata json.RawMessage `json:"metadata,omitempty"`
}
type ImageData struct {
Url string `json:"url"`
B64Json string `json:"b64_json"`
RevisedPrompt string `json:"revised_prompt"`
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,97 @@
package dto
import (
"testing"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestGeneralOpenAIRequestPreserveExplicitZeroValues(t *testing.T) {
raw := []byte(`{
"model":"gpt-4.1",
"stream":false,
"max_tokens":0,
"max_completion_tokens":0,
"top_p":0,
"top_k":0,
"n":0,
"frequency_penalty":0,
"presence_penalty":0,
"seed":0,
"logprobs":false,
"top_logprobs":0,
"dimensions":0,
"return_images":false,
"return_related_questions":false
}`)
var req GeneralOpenAIRequest
err := kitutil.Unmarshal(raw, &req)
require.NoError(t, err)
encoded, err := kitutil.Marshal(req)
require.NoError(t, err)
require.True(t, gjson.GetBytes(encoded, "stream").Exists())
require.True(t, gjson.GetBytes(encoded, "max_tokens").Exists())
require.True(t, gjson.GetBytes(encoded, "max_completion_tokens").Exists())
require.True(t, gjson.GetBytes(encoded, "top_p").Exists())
require.True(t, gjson.GetBytes(encoded, "top_k").Exists())
require.True(t, gjson.GetBytes(encoded, "n").Exists())
require.True(t, gjson.GetBytes(encoded, "frequency_penalty").Exists())
require.True(t, gjson.GetBytes(encoded, "presence_penalty").Exists())
require.True(t, gjson.GetBytes(encoded, "seed").Exists())
require.True(t, gjson.GetBytes(encoded, "logprobs").Exists())
require.True(t, gjson.GetBytes(encoded, "top_logprobs").Exists())
require.True(t, gjson.GetBytes(encoded, "dimensions").Exists())
require.True(t, gjson.GetBytes(encoded, "return_images").Exists())
require.True(t, gjson.GetBytes(encoded, "return_related_questions").Exists())
}
func TestOpenAIResponsesRequestPreserveExplicitZeroValues(t *testing.T) {
raw := []byte(`{
"model":"gpt-4.1",
"max_output_tokens":0,
"max_tool_calls":0,
"stream":false,
"top_p":0
}`)
var req OpenAIResponsesRequest
err := kitutil.Unmarshal(raw, &req)
require.NoError(t, err)
encoded, err := kitutil.Marshal(req)
require.NoError(t, err)
require.True(t, gjson.GetBytes(encoded, "max_output_tokens").Exists())
require.True(t, gjson.GetBytes(encoded, "max_tool_calls").Exists())
require.True(t, gjson.GetBytes(encoded, "stream").Exists())
require.True(t, gjson.GetBytes(encoded, "top_p").Exists())
}
func TestGeneralOpenAIRequestGetSystemRoleName(t *testing.T) {
tests := []struct {
name string
model string
want string
}{
{name: "o1 uses developer", model: "o1", want: "developer"},
{name: "o3 family uses developer", model: "o3-mini-high", want: "developer"},
{name: "o4 family uses developer", model: "o4-mini", want: "developer"},
{name: "o1 mini stays system", model: "o1-mini", want: "system"},
{name: "o1 preview stays system", model: "o1-preview", want: "system"},
{name: "gpt 5 uses developer", model: "gpt-5", want: "developer"},
{name: "omni is not o series", model: "omni-moderation-latest", want: "system"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := GeneralOpenAIRequest{Model: tt.model}
require.Equal(t, tt.want, req.GetSystemRoleName())
})
}
}
+440
View File
@@ -0,0 +1,440 @@
package dto
import (
"encoding/json"
"fmt"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
const (
ResponsesOutputTypeImageGenerationCall = "image_generation_call"
)
type SimpleResponse struct {
Usage `json:"usage"`
Error any `json:"error"`
}
// GetOpenAIError 从动态错误类型中提取OpenAIError结构
func (s *SimpleResponse) GetOpenAIError() *types.OpenAIError {
return GetOpenAIError(s.Error)
}
type TextResponse struct {
Id string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []OpenAITextResponseChoice `json:"choices"`
Usage `json:"usage"`
}
type OpenAITextResponseChoice struct {
Index int `json:"index"`
Message `json:"message"`
FinishReason string `json:"finish_reason"`
}
type OpenAITextResponse struct {
Id string `json:"id"`
Model string `json:"model"`
Object string `json:"object"`
Created any `json:"created"`
Choices []OpenAITextResponseChoice `json:"choices"`
Error any `json:"error,omitempty"`
Usage `json:"usage"`
}
// GetOpenAIError 从动态错误类型中提取OpenAIError结构
func (o *OpenAITextResponse) GetOpenAIError() *types.OpenAIError {
return GetOpenAIError(o.Error)
}
type OpenAIEmbeddingResponseItem struct {
Object string `json:"object"`
Index int `json:"index"`
Embedding []float64 `json:"embedding"`
}
type OpenAIEmbeddingResponse struct {
Object string `json:"object"`
Data []OpenAIEmbeddingResponseItem `json:"data"`
Model string `json:"model"`
Usage `json:"usage"`
}
type FlexibleEmbeddingResponseItem struct {
Object string `json:"object"`
Index int `json:"index"`
Embedding any `json:"embedding"`
}
type FlexibleEmbeddingResponse struct {
Object string `json:"object"`
Data []FlexibleEmbeddingResponseItem `json:"data"`
Model string `json:"model"`
Usage `json:"usage"`
}
type ChatCompletionsStreamResponseChoice struct {
Delta ChatCompletionsStreamResponseChoiceDelta `json:"delta,omitempty"`
Logprobs *any `json:"logprobs"`
FinishReason *string `json:"finish_reason"`
Index int `json:"index"`
}
type ChatCompletionsStreamResponseChoiceDelta struct {
Content *string `json:"content,omitempty"`
ReasoningContent *string `json:"reasoning_content,omitempty"`
Reasoning *string `json:"reasoning,omitempty"`
Role string `json:"role,omitempty"`
ToolCalls []ToolCallResponse `json:"tool_calls,omitempty"`
}
func (c *ChatCompletionsStreamResponseChoiceDelta) SetContentString(s string) {
c.Content = &s
}
func (c *ChatCompletionsStreamResponseChoiceDelta) GetContentString() string {
if c.Content == nil {
return ""
}
return *c.Content
}
func (c *ChatCompletionsStreamResponseChoiceDelta) GetReasoningContent() string {
if c.ReasoningContent == nil && c.Reasoning == nil {
return ""
}
if c.ReasoningContent != nil {
return *c.ReasoningContent
}
return *c.Reasoning
}
func (c *ChatCompletionsStreamResponseChoiceDelta) SetReasoningContent(s string) {
c.ReasoningContent = &s
//c.Reasoning = &s
}
type ToolCallResponse struct {
// Index is not nil only in chat completion chunk object
Index *int `json:"index,omitempty"`
ID string `json:"id,omitempty"`
Type any `json:"type"`
Function FunctionResponse `json:"function"`
}
func (c *ToolCallResponse) SetIndex(i int) {
c.Index = &i
}
type FunctionResponse struct {
Description string `json:"description,omitempty"`
Name string `json:"name,omitempty"`
// call function with arguments in JSON format
Parameters any `json:"parameters,omitempty"` // request
Arguments string `json:"arguments"` // response
}
type ChatCompletionsStreamResponse struct {
Id string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
SystemFingerprint *string `json:"system_fingerprint"`
Choices []ChatCompletionsStreamResponseChoice `json:"choices"`
Usage *Usage `json:"usage"`
}
func (c *ChatCompletionsStreamResponse) IsFinished() bool {
if len(c.Choices) == 0 {
return false
}
return c.Choices[0].FinishReason != nil && *c.Choices[0].FinishReason != ""
}
func (c *ChatCompletionsStreamResponse) IsToolCall() bool {
if len(c.Choices) == 0 {
return false
}
return len(c.Choices[0].Delta.ToolCalls) > 0
}
func (c *ChatCompletionsStreamResponse) GetFirstToolCall() *ToolCallResponse {
if c.IsToolCall() {
return &c.Choices[0].Delta.ToolCalls[0]
}
return nil
}
func (c *ChatCompletionsStreamResponse) ClearToolCalls() {
if !c.IsToolCall() {
return
}
for choiceIdx := range c.Choices {
for callIdx := range c.Choices[choiceIdx].Delta.ToolCalls {
c.Choices[choiceIdx].Delta.ToolCalls[callIdx].ID = ""
c.Choices[choiceIdx].Delta.ToolCalls[callIdx].Type = nil
c.Choices[choiceIdx].Delta.ToolCalls[callIdx].Function.Name = ""
}
}
}
func (c *ChatCompletionsStreamResponse) Copy() *ChatCompletionsStreamResponse {
choices := make([]ChatCompletionsStreamResponseChoice, len(c.Choices))
copy(choices, c.Choices)
return &ChatCompletionsStreamResponse{
Id: c.Id,
Object: c.Object,
Created: c.Created,
Model: c.Model,
SystemFingerprint: c.SystemFingerprint,
Choices: choices,
Usage: c.Usage,
}
}
func (c *ChatCompletionsStreamResponse) GetSystemFingerprint() string {
if c.SystemFingerprint == nil {
return ""
}
return *c.SystemFingerprint
}
func (c *ChatCompletionsStreamResponse) SetSystemFingerprint(s string) {
c.SystemFingerprint = &s
}
type ChatCompletionsStreamResponseSimple struct {
Choices []ChatCompletionsStreamResponseChoice `json:"choices"`
Usage *Usage `json:"usage"`
}
type CompletionsStreamResponse struct {
Choices []struct {
Text string `json:"text"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
}
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
PromptCacheHitTokens int `json:"prompt_cache_hit_tokens,omitempty"`
UsageSemantic string `json:"usage_semantic,omitempty"`
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"`
// claude cache 1h
ClaudeCacheCreation5mTokens int `json:"claude_cache_creation_5_m_tokens"`
ClaudeCacheCreation1hTokens int `json:"claude_cache_creation_1_h_tokens"`
// OpenRouter Params
Cost any `json:"cost,omitempty"`
}
type OpenAIVideoResponse struct {
Id string `json:"id" example:"file-abc123"`
Object string `json:"object" example:"file"`
Bytes int64 `json:"bytes" example:"120000"`
CreatedAt int64 `json:"created_at" example:"1677610602"`
ExpiresAt int64 `json:"expires_at" example:"1677614202"`
Filename string `json:"filename" example:"mydata.jsonl"`
Purpose string `json:"purpose" example:"fine-tune"`
}
type InputTokenDetails struct {
CachedTokens int `json:"cached_tokens"`
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
// cache-creation price.
CacheWriteTokens int `json:"cache_write_tokens,omitempty"`
TextTokens int `json:"text_tokens"`
AudioTokens int `json:"audio_tokens"`
ImageTokens int `json:"image_tokens"`
}
// 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
// are billed at the cache-creation price; when both are present the larger
// value wins so the same tokens are never double-counted. Negative upstream
// values are clamped to zero so they can never lower a charge.
func (d InputTokenDetails) CacheCreationTokensTotal() int {
total := d.CachedCreationTokens
if d.CacheWriteTokens > total {
total = d.CacheWriteTokens
}
if total < 0 {
return 0
}
return total
}
type OutputTokenDetails struct {
TextTokens int `json:"text_tokens"`
AudioTokens int `json:"audio_tokens"`
ImageTokens int `json:"image_tokens"`
ReasoningTokens int `json:"reasoning_tokens"`
}
type OpenAIResponsesResponse struct {
ID string `json:"id"`
Object string `json:"object"`
CreatedAt int `json:"created_at"`
Status json.RawMessage `json:"status"`
Error any `json:"error,omitempty"`
IncompleteDetails *IncompleteDetails `json:"incomplete_details,omitempty"`
Instructions json.RawMessage `json:"instructions"`
MaxOutputTokens int `json:"max_output_tokens"`
Model string `json:"model"`
Output []ResponsesOutput `json:"output"`
ParallelToolCalls bool `json:"parallel_tool_calls"`
PreviousResponseID json.RawMessage `json:"previous_response_id"`
Reasoning *Reasoning `json:"reasoning"`
Store bool `json:"store"`
Temperature float64 `json:"temperature"`
ToolChoice json.RawMessage `json:"tool_choice"`
Tools []map[string]any `json:"tools"`
TopP float64 `json:"top_p"`
Truncation json.RawMessage `json:"truncation"`
Usage *Usage `json:"usage"`
User json.RawMessage `json:"user"`
Metadata json.RawMessage `json:"metadata"`
}
// GetOpenAIError 从动态错误类型中提取OpenAIError结构
func (o *OpenAIResponsesResponse) GetOpenAIError() *types.OpenAIError {
return GetOpenAIError(o.Error)
}
type IncompleteDetails struct {
Reason string `json:"reason"`
}
type ResponsesOutput struct {
Type string `json:"type"`
ID string `json:"id"`
Status string `json:"status"`
Role string `json:"role"`
Content []ResponsesOutputContent `json:"content"`
Quality string `json:"quality"`
Size string `json:"size"`
Result string `json:"result,omitempty"`
CallId string `json:"call_id,omitempty"`
Name string `json:"name,omitempty"`
Arguments json.RawMessage `json:"arguments,omitempty"`
}
// ArgumentsString returns function call arguments in the string form expected by Chat Completions.
func (r *ResponsesOutput) ArgumentsString() string {
if r == nil {
return ""
}
return ResponsesArgumentsString(r.Arguments)
}
// ResponsesArgumentsString returns function call arguments in the string form expected by Chat Completions.
func ResponsesArgumentsString(arguments json.RawMessage) string {
return kitutil.JsonRawMessageToString(arguments)
}
type ResponsesOutputContent struct {
Type string `json:"type"`
Text string `json:"text"`
Annotations []interface{} `json:"annotations"`
}
type ResponsesReasoningSummaryPart struct {
Type string `json:"type"`
Text string `json:"text"`
}
const (
BuildInToolWebSearchPreview = "web_search_preview"
BuildInToolWebSearch = "web_search"
BuildInToolFileSearch = "file_search"
BuildInToolGoogleSearch = "google_search"
BuildInToolImageGeneration = "image_generation"
)
const (
BuildInCallWebSearchCall = "web_search_call"
BuildInCallFileSearchCall = "file_search_call"
BuildInCallFunctionCall = "function_call"
BuildInCallToolUse = "tool_use"
)
const (
ResponsesOutputTypeItemAdded = "response.output_item.added"
ResponsesOutputTypeItemDone = "response.output_item.done"
)
// ResponsesStreamResponse 用于处理 /v1/responses 流式响应
type ResponsesStreamResponse struct {
Type string `json:"type"`
Response *OpenAIResponsesResponse `json:"response,omitempty"`
Delta string `json:"delta,omitempty"`
Item *ResponsesOutput `json:"item,omitempty"`
// - response.function_call_arguments.delta
// - response.function_call_arguments.done
OutputIndex *int `json:"output_index,omitempty"`
ContentIndex *int `json:"content_index,omitempty"`
SummaryIndex *int `json:"summary_index,omitempty"`
ItemID string `json:"item_id,omitempty"`
Part *ResponsesReasoningSummaryPart `json:"part,omitempty"`
}
// GetOpenAIError 从动态错误类型中提取OpenAIError结构
func GetOpenAIError(errorField any) *types.OpenAIError {
if errorField == nil {
return nil
}
switch err := errorField.(type) {
case types.OpenAIError:
return &err
case *types.OpenAIError:
return err
case map[string]interface{}:
// 处理从JSON解析来的map结构
openaiErr := &types.OpenAIError{}
if errType, ok := err["type"].(string); ok {
openaiErr.Type = errType
}
if errMsg, ok := err["message"].(string); ok {
openaiErr.Message = errMsg
}
if errParam, ok := err["param"].(string); ok {
openaiErr.Param = errParam
}
if errCode, ok := err["code"]; ok {
openaiErr.Code = errCode
}
return openaiErr
case string:
// 处理简单字符串错误
return &types.OpenAIError{
Type: "error",
Message: err,
}
default:
// 未知类型,尝试转换为字符串
return &types.OpenAIError{
Type: "unknown_error",
Message: fmt.Sprintf("%v", err),
}
}
}
@@ -0,0 +1,50 @@
package dto
import (
"encoding/json"
"net/http"
"strings"
"github.com/QuantumNous/new-api/relaykit/types"
)
type OpenAIResponsesCompactionRequest struct {
Model string `json:"model"`
Input json.RawMessage `json:"input,omitempty"`
Instructions json.RawMessage `json:"instructions,omitempty"`
PreviousResponseID string `json:"previous_response_id,omitempty"`
// Codex compact request parity:
// https://github.com/openai/codex/commit/53d59722268dde82fb93c1f37964ce196c2a86d7
// https://github.com/openai/codex/commit/5d6f23a27bf9c90709af527a7108c1c2eadf5123
Tools json.RawMessage `json:"tools,omitempty"`
ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"`
Reasoning *Reasoning `json:"reasoning,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"`
PromptCacheOptions json.RawMessage `json:"prompt_cache_options,omitempty"`
PromptCacheRetention json.RawMessage `json:"prompt_cache_retention,omitempty"`
Text json.RawMessage `json:"text,omitempty"`
}
func (r *OpenAIResponsesCompactionRequest) GetTokenCountMeta() *types.TokenCountMeta {
var parts []string
if len(r.Instructions) > 0 {
parts = append(parts, string(r.Instructions))
}
if len(r.Input) > 0 {
parts = append(parts, string(r.Input))
}
return &types.TokenCountMeta{
CombineText: strings.Join(parts, "\n"),
}
}
func (r *OpenAIResponsesCompactionRequest) IsStream(c *http.Request) bool {
return false
}
func (r *OpenAIResponsesCompactionRequest) SetModelName(modelName string) {
if modelName != "" {
r.Model = modelName
}
}
+53
View File
@@ -0,0 +1,53 @@
package dto
import (
"strconv"
"strings"
)
const (
VideoStatusUnknown = "unknown"
VideoStatusQueued = "queued"
VideoStatusInProgress = "in_progress"
VideoStatusCompleted = "completed"
VideoStatusFailed = "failed"
)
type OpenAIVideo struct {
ID string `json:"id"`
TaskID string `json:"task_id,omitempty"` //兼容旧接口 待废弃
Object string `json:"object"`
Model string `json:"model"`
Status string `json:"status"` // Should use VideoStatus constants: VideoStatusQueued, VideoStatusInProgress, VideoStatusCompleted, VideoStatusFailed
Progress int `json:"progress"`
CreatedAt int64 `json:"created_at"`
CompletedAt int64 `json:"completed_at,omitempty"`
ExpiresAt int64 `json:"expires_at,omitempty"`
Seconds string `json:"seconds,omitempty"`
Size string `json:"size,omitempty"`
RemixedFromVideoID string `json:"remixed_from_video_id,omitempty"`
Error *OpenAIVideoError `json:"error,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
func (m *OpenAIVideo) SetProgressStr(progress string) {
progress = strings.TrimSuffix(progress, "%")
m.Progress, _ = strconv.Atoi(progress)
}
func (m *OpenAIVideo) SetMetadata(k string, v any) {
if m.Metadata == nil {
m.Metadata = make(map[string]any)
}
m.Metadata[k] = v
}
func NewOpenAIVideo() *OpenAIVideo {
return &OpenAIVideo{
Object: "video",
Status: VideoStatusQueued,
}
}
type OpenAIVideoError struct {
Message string `json:"message"`
Code string `json:"code"`
}
+6
View File
@@ -0,0 +1,6 @@
package dto
type PlayGroundRequest struct {
Model string `json:"model,omitempty"`
Group string `json:"group,omitempty"`
}
+35
View File
@@ -0,0 +1,35 @@
package dto
import "github.com/QuantumNous/new-api/relaykit/types"
// 这里不好动就不动了,本来想独立出来的(
type OpenAIModels struct {
Id string `json:"id"`
Object string `json:"object"`
Created int `json:"created"`
OwnedBy string `json:"owned_by"`
SupportedEndpointTypes []types.EndpointType `json:"supported_endpoint_types"`
}
type AnthropicModel struct {
ID string `json:"id"`
CreatedAt string `json:"created_at"`
DisplayName string `json:"display_name"`
Type string `json:"type"`
}
type GeminiModel struct {
Name interface{} `json:"name"`
BaseModelId interface{} `json:"baseModelId"`
Version interface{} `json:"version"`
DisplayName interface{} `json:"displayName"`
Description interface{} `json:"description"`
InputTokenLimit interface{} `json:"inputTokenLimit"`
OutputTokenLimit interface{} `json:"outputTokenLimit"`
SupportedGenerationMethods []interface{} `json:"supportedGenerationMethods"`
Thinking interface{} `json:"thinking"`
Temperature interface{} `json:"temperature"`
MaxTemperature interface{} `json:"maxTemperature"`
TopP interface{} `json:"topP"`
TopK interface{} `json:"topK"`
}
+39
View File
@@ -0,0 +1,39 @@
package dto
type UpstreamDTO struct {
ID int `json:"id,omitempty"`
Name string `json:"name" binding:"required"`
BaseURL string `json:"base_url" binding:"required"`
Endpoint string `json:"endpoint"`
}
type UpstreamRequest struct {
ChannelIDs []int64 `json:"channel_ids"`
Upstreams []UpstreamDTO `json:"upstreams"`
Timeout int `json:"timeout"`
}
// TestResult 上游测试连通性结果
type TestResult struct {
Name string `json:"name"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
// DifferenceItem 差异项
// Current 为本地值,可能为 nil
// Upstreams 为各渠道的上游值,具体数值 / "same" / nil
type DifferenceItem struct {
Current interface{} `json:"current"`
Upstreams map[string]interface{} `json:"upstreams"`
Confidence map[string]bool `json:"confidence"`
}
type SyncableChannel struct {
ID int `json:"id"`
Name string `json:"name"`
BaseURL string `json:"base_url"`
Status int `json:"status"`
Type int `json:"type"`
}
+88
View File
@@ -0,0 +1,88 @@
package dto
import "github.com/QuantumNous/new-api/relaykit/types"
const (
RealtimeEventTypeError = "error"
RealtimeEventTypeSessionUpdate = "session.update"
RealtimeEventTypeConversationCreate = "conversation.item.create"
RealtimeEventTypeResponseCreate = "response.create"
RealtimeEventInputAudioBufferAppend = "input_audio_buffer.append"
)
const (
RealtimeEventTypeResponseDone = "response.done"
RealtimeEventTypeSessionUpdated = "session.updated"
RealtimeEventTypeSessionCreated = "session.created"
RealtimeEventResponseAudioDelta = "response.audio.delta"
RealtimeEventResponseAudioTranscriptionDelta = "response.audio_transcript.delta"
RealtimeEventResponseFunctionCallArgumentsDelta = "response.function_call_arguments.delta"
RealtimeEventResponseFunctionCallArgumentsDone = "response.function_call_arguments.done"
RealtimeEventConversationItemCreated = "conversation.item.created"
)
type RealtimeEvent struct {
EventId string `json:"event_id"`
Type string `json:"type"`
//PreviousItemId string `json:"previous_item_id"`
Session *RealtimeSession `json:"session,omitempty"`
Item *RealtimeItem `json:"item,omitempty"`
Error *types.OpenAIError `json:"error,omitempty"`
Response *RealtimeResponse `json:"response,omitempty"`
Delta string `json:"delta,omitempty"`
Audio string `json:"audio,omitempty"`
}
type RealtimeResponse struct {
Usage *RealtimeUsage `json:"usage"`
}
type RealtimeUsage struct {
TotalTokens int `json:"total_tokens"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
InputTokenDetails InputTokenDetails `json:"input_token_details"`
OutputTokenDetails OutputTokenDetails `json:"output_token_details"`
}
type RealtimeSession struct {
Modalities []string `json:"modalities"`
Instructions string `json:"instructions"`
Voice string `json:"voice"`
InputAudioFormat string `json:"input_audio_format"`
OutputAudioFormat string `json:"output_audio_format"`
InputAudioTranscription InputAudioTranscription `json:"input_audio_transcription"`
TurnDetection interface{} `json:"turn_detection"`
Tools []RealTimeTool `json:"tools"`
ToolChoice string `json:"tool_choice"`
Temperature float64 `json:"temperature"`
//MaxResponseOutputTokens int `json:"max_response_output_tokens"`
}
type InputAudioTranscription struct {
Model string `json:"model"`
}
type RealTimeTool struct {
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description"`
Parameters any `json:"parameters"`
}
type RealtimeItem struct {
Id string `json:"id"`
Type string `json:"type"`
Status string `json:"status"`
Role string `json:"role"`
Content []RealtimeContent `json:"content"`
Name *string `json:"name,omitempty"`
ToolCalls any `json:"tool_calls,omitempty"`
CallId string `json:"call_id,omitempty"`
}
type RealtimeContent struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
Audio string `json:"audio,omitempty"` // Base64-encoded audio bytes.
Transcript string `json:"transcript,omitempty"`
}
+25
View File
@@ -0,0 +1,25 @@
package dto
import (
"github.com/QuantumNous/new-api/relaykit/types"
"net/http"
)
type Request interface {
GetTokenCountMeta() *types.TokenCountMeta
IsStream(c *http.Request) bool
SetModelName(modelName string)
}
type BaseRequest struct {
}
func (b *BaseRequest) GetTokenCountMeta() *types.TokenCountMeta {
return &types.TokenCountMeta{
TokenType: types.TokenTypeTokenizer,
}
}
func (b *BaseRequest) IsStream(c *http.Request) bool {
return false
}
func (b *BaseRequest) SetModelName(modelName string) {}
+67
View File
@@ -0,0 +1,67 @@
package dto
import (
"fmt"
"net/http"
"strings"
"github.com/QuantumNous/new-api/relaykit/types"
)
type RerankRequest struct {
Documents []any `json:"documents"`
Query string `json:"query"`
Model string `json:"model"`
TopN *int `json:"top_n,omitempty"`
ReturnDocuments *bool `json:"return_documents,omitempty"`
MaxChunkPerDoc *int `json:"max_chunk_per_doc,omitempty"`
OverLapTokens *int `json:"overlap_tokens,omitempty"`
}
func (r *RerankRequest) IsStream(c *http.Request) bool {
return false
}
func (r *RerankRequest) GetTokenCountMeta() *types.TokenCountMeta {
var texts = make([]string, 0)
for _, document := range r.Documents {
texts = append(texts, fmt.Sprintf("%v", document))
}
if r.Query != "" {
texts = append(texts, r.Query)
}
return &types.TokenCountMeta{
CombineText: strings.Join(texts, "\n"),
}
}
func (r *RerankRequest) SetModelName(modelName string) {
if modelName != "" {
r.Model = modelName
}
}
func (r *RerankRequest) GetReturnDocuments() bool {
if r.ReturnDocuments == nil {
return false
}
return *r.ReturnDocuments
}
type RerankResponseResult struct {
Document any `json:"document,omitempty"`
Index int `json:"index"`
RelevanceScore float64 `json:"relevance_score"`
}
type RerankDocument struct {
Text any `json:"text"`
}
type RerankResponse struct {
Results []RerankResponseResult `json:"results"`
Usage Usage `json:"usage"`
}
+6
View File
@@ -0,0 +1,6 @@
package dto
type SensitiveResponse struct {
SensitiveWords []string `json:"sensitive_words"`
Content string `json:"content"`
}
+26
View File
@@ -0,0 +1,26 @@
package dto
type UserSetting struct {
NotifyType string `json:"notify_type,omitempty"` // QuotaWarningType 额度预警类型
QuotaWarningThreshold float64 `json:"quota_warning_threshold,omitempty"` // QuotaWarningThreshold 额度预警阈值
WebhookUrl string `json:"webhook_url,omitempty"` // WebhookUrl webhook地址
WebhookSecret string `json:"webhook_secret,omitempty"` // WebhookSecret webhook密钥
NotificationEmail string `json:"notification_email,omitempty"` // NotificationEmail 通知邮箱地址
BarkUrl string `json:"bark_url,omitempty"` // BarkUrl Bark推送URL
GotifyUrl string `json:"gotify_url,omitempty"` // GotifyUrl Gotify服务器地址
GotifyToken string `json:"gotify_token,omitempty"` // GotifyToken Gotify应用令牌
GotifyPriority int `json:"gotify_priority"` // GotifyPriority Gotify消息优先级
UpstreamModelUpdateNotifyEnabled bool `json:"upstream_model_update_notify_enabled,omitempty"` // 是否接收上游模型更新定时检测通知(仅管理员)
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 左侧边栏模块配置
BillingPreference string `json:"billing_preference,omitempty"` // BillingPreference 扣费策略(订阅/钱包)
Language string `json:"language,omitempty"` // Language 用户语言偏好 (zh, en)
}
var (
NotifyTypeEmail = "email" // Email 邮件
NotifyTypeWebhook = "webhook" // Webhook
NotifyTypeBark = "bark" // Bark 推送
NotifyTypeGotify = "gotify" // Gotify 推送
)
+77
View File
@@ -0,0 +1,77 @@
package dto
import (
"encoding/json"
"strconv"
)
type StringValue string
func (s *StringValue) UnmarshalJSON(data []byte) error {
var str string
if err := json.Unmarshal(data, &str); err == nil {
*s = StringValue(str)
return nil
}
var raw json.Number
if err := json.Unmarshal(data, &raw); err == nil {
*s = StringValue(raw.String())
return nil
}
return json.Unmarshal(data, &str)
}
func (s StringValue) MarshalJSON() ([]byte, error) {
return json.Marshal(string(s))
}
type IntValue int
func (i *IntValue) UnmarshalJSON(b []byte) error {
var n int
if err := json.Unmarshal(b, &n); err == nil {
*i = IntValue(n)
return nil
}
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
v, err := strconv.Atoi(s)
if err != nil {
return err
}
*i = IntValue(v)
return nil
}
func (i IntValue) MarshalJSON() ([]byte, error) {
return json.Marshal(int(i))
}
type BoolValue bool
func (b *BoolValue) UnmarshalJSON(data []byte) error {
var boolean bool
if err := json.Unmarshal(data, &boolean); err == nil {
*b = BoolValue(boolean)
return nil
}
var str string
if err := json.Unmarshal(data, &str); err != nil {
return err
}
if str == "true" {
*b = BoolValue(true)
} else if str == "false" {
*b = BoolValue(false)
} else {
return json.Unmarshal(data, &boolean)
}
return nil
}
func (b BoolValue) MarshalJSON() ([]byte, error) {
return json.Marshal(bool(b))
}
+24
View File
@@ -0,0 +1,24 @@
module github.com/QuantumNous/new-api/relaykit
go 1.25.1
require (
github.com/google/uuid v1.6.0
github.com/samber/lo v1.53.0
github.com/shopspring/decimal v1.4.0
github.com/stretchr/testify v1.11.1
github.com/tidwall/gjson v1.19.0
github.com/tidwall/sjson v1.2.5
)
require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
golang.org/x/text v0.35.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+40
View File
@@ -0,0 +1,40 @@
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+41
View File
@@ -0,0 +1,41 @@
package reasonmap
import (
"strings"
"github.com/QuantumNous/new-api/relaykit/types"
)
func ClaudeStopReasonToOpenAIFinishReason(stopReason string) string {
switch strings.ToLower(stopReason) {
case "stop_sequence":
return "stop"
case "end_turn":
return "stop"
case "max_tokens":
return "length"
case "tool_use":
return "tool_calls"
case "refusal":
return types.FinishReasonContentFilter
default:
return stopReason
}
}
func OpenAIFinishReasonToClaudeStopReason(finishReason string) string {
switch strings.ToLower(finishReason) {
case "stop":
return "end_turn"
case "stop_sequence":
return "stop_sequence"
case "length", "max_tokens":
return "max_tokens"
case types.FinishReasonContentFilter:
return "refusal"
case "tool_calls":
return "tool_use"
default:
return finishReason
}
}
+100
View File
@@ -0,0 +1,100 @@
package relayconvert
// boundary_test.go enforces the relaykit extraction dependency boundary
// (plans/relaykit-extraction-plan.md): packages that will move into the
// relaykit module must not grow imports of host-only packages. Entries in
// allowedViolations are the known couplings scheduled for removal in
// Phase 1/2 — shrink this list, never grow it.
import (
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
"testing"
)
const modulePrefix = "github.com/QuantumNous/new-api/"
// Packages (relative to the relaykit module root) covered by the boundary.
var kitDirs = []string{
"relayconvert",
"dto",
"types",
"reasonmap",
}
// Import prefixes forbidden inside the kit module: the entire host module
// (everything outside relaykit/) and gin.
var forbiddenPrefixes = []string{
modulePrefix,
"github.com/gin-gonic/gin",
}
// hostModuleExceptions are host-prefix imports that are actually the kit's
// own packages (the kit module path nests under the host path).
const kitModulePrefix = modulePrefix + "relaykit/"
// Known pre-existing couplings, removed phase by phase. Key: "dir|import".
// All initial violations have been cleared; keep the map so future
// exemptions (if ever needed) are explicit and reviewed.
var allowedViolations = map[string]bool{}
func TestRelaykitBoundary(t *testing.T) {
root := repoRoot(t)
fset := token.NewFileSet()
for _, dir := range kitDirs {
err := filepath.WalkDir(filepath.Join(root, dir), func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
if err != nil {
return err
}
for _, imp := range file.Imports {
importPath := strings.Trim(imp.Path.Value, `"`)
for _, prefix := range forbiddenPrefixes {
if importPath != prefix && !strings.HasPrefix(importPath, prefix) {
continue
}
if strings.HasPrefix(importPath, kitModulePrefix) {
continue
}
if allowedViolations[dir+"|"+importPath] {
continue
}
rel, _ := filepath.Rel(root, path)
t.Errorf("%s imports %q — forbidden inside future relaykit package %s (see plans/relaykit-extraction-plan.md)", rel, importPath, dir)
}
}
return nil
})
if err != nil {
t.Fatalf("walking %s: %v", dir, err)
}
}
}
func repoRoot(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("go.mod not found above test directory")
}
dir = parent
}
}
@@ -0,0 +1,111 @@
package relayconvert
import (
"context"
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestClaudeDefaultMaxTokensPresence(t *testing.T) {
converters := []struct {
name string
convert func(t *testing.T, meta convmeta.Meta, clientMaxTokens *uint) (*dto.ClaudeRequest, error)
}{
{
name: "chat completions",
convert: func(t *testing.T, meta convmeta.Meta, clientMaxTokens *uint) (*dto.ClaudeRequest, error) {
t.Helper()
return OpenAIChatRequestToClaudeMessages(context.Background(), meta, dto.GeneralOpenAIRequest{
Model: "claude-test",
MaxTokens: clientMaxTokens,
Messages: []dto.Message{
{Role: "user", Content: "hello"},
},
})
},
},
{
name: "responses",
convert: func(t *testing.T, meta convmeta.Meta, clientMaxTokens *uint) (*dto.ClaudeRequest, error) {
t.Helper()
return OpenAIResponsesRequestToClaudeMessages(context.Background(), meta, &dto.OpenAIResponsesRequest{
Model: "claude-test",
Input: []byte(`"hello"`),
MaxOutputTokens: clientMaxTokens,
})
},
},
}
for _, converter := range converters {
t.Run(converter.name, func(t *testing.T) {
t.Run("callback absent fails conversion", func(t *testing.T) {
got, err := converter.convert(t, &convmeta.Values{}, nil)
require.ErrorIs(t, err, sharedclaude.ErrMissingMaxTokens)
assert.Nil(t, got)
})
t.Run("callback absent, client value wins", func(t *testing.T) {
clientMaxTokens := uint(99)
got, err := converter.convert(t, &convmeta.Values{}, &clientMaxTokens)
require.NoError(t, err)
require.NotNil(t, got.MaxTokens)
assert.Equal(t, clientMaxTokens, *got.MaxTokens)
})
t.Run("configured zero", func(t *testing.T) {
got, err := converter.convert(t, claudeDefaultsMeta(func(string) int { return 0 }), nil)
require.NoError(t, err)
require.NotNil(t, got.MaxTokens)
assert.Zero(t, *got.MaxTokens)
})
t.Run("configured positive", func(t *testing.T) {
got, err := converter.convert(t, claudeDefaultsMeta(func(string) int { return 512 }), nil)
require.NoError(t, err)
require.NotNil(t, got.MaxTokens)
assert.Equal(t, uint(512), *got.MaxTokens)
})
t.Run("client nonzero wins", func(t *testing.T) {
clientMaxTokens := uint(99)
got, err := converter.convert(t, claudeDefaultsMeta(func(string) int { return 512 }), &clientMaxTokens)
require.NoError(t, err)
require.NotNil(t, got.MaxTokens)
assert.Equal(t, clientMaxTokens, *got.MaxTokens)
})
})
}
}
// The thinking adapter's max_tokens floor is an injection path of its own: a
// "-thinking" request without max_tokens must keep converting even when no
// DefaultMaxTokens hook is configured.
func TestClaudeThinkingAdapterSatisfiesMaxTokensWithoutCallback(t *testing.T) {
meta := &convmeta.Values{Options: &convmeta.Options{
Claude: convmeta.ClaudeOptions{
ThinkingAdapterEnabled: true,
ThinkingAdapterBudgetTokensPercentage: 0.8,
},
}}
got, err := OpenAIChatRequestToClaudeMessages(context.Background(), meta, dto.GeneralOpenAIRequest{
Model: "claude-test-thinking",
Messages: []dto.Message{
{Role: "user", Content: "hello"},
},
})
require.NoError(t, err)
require.NotNil(t, got.MaxTokens)
assert.Equal(t, uint(1280), *got.MaxTokens)
}
func claudeDefaultsMeta(defaultMaxTokens func(string) int) convmeta.Meta {
return &convmeta.Values{Options: &convmeta.Options{
Claude: convmeta.ClaudeOptions{DefaultMaxTokens: defaultMaxTokens},
}}
}
+31
View File
@@ -0,0 +1,31 @@
package convmeta
import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
)
// GuessRelayFormatFromRequest infers the relay format from a request DTO's
// concrete type. Moved from relay/common (which keeps a delegating alias).
func GuessRelayFormatFromRequest(req any) (types.RelayFormat, bool) {
switch req.(type) {
case *dto.GeneralOpenAIRequest, dto.GeneralOpenAIRequest:
return types.RelayFormatOpenAI, true
case *dto.OpenAIResponsesRequest, dto.OpenAIResponsesRequest:
return types.RelayFormatOpenAIResponses, true
case *dto.ClaudeRequest, dto.ClaudeRequest:
return types.RelayFormatClaude, true
case *dto.GeminiChatRequest, dto.GeminiChatRequest:
return types.RelayFormatGemini, true
case *dto.EmbeddingRequest, dto.EmbeddingRequest:
return types.RelayFormatEmbedding, true
case *dto.RerankRequest, dto.RerankRequest:
return types.RelayFormatRerank, true
case *dto.ImageRequest, dto.ImageRequest:
return types.RelayFormatOpenAIImage, true
case *dto.AudioRequest, dto.AudioRequest:
return types.RelayFormatOpenAIAudio, true
default:
return "", false
}
}
+215
View File
@@ -0,0 +1,215 @@
// Package convmeta defines the conversion-context contract between format
// converters (future relaykit) and the hosting application. Converters read
// protocol state and per-request options exclusively through the Meta
// interface; the host's RelayInfo implements it.
package convmeta
import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
)
// Meta is the only view of the relay session that format converters may use.
// It is satisfied by *relaycommon.RelayInfo on the host side; other embedders
// (tests, external relaykit users) can use *Values.
// Implementations backed by pointer types must make every method safe on a nil
// receiver: a typed-nil pointer stored in Meta is still a non-nil interface,
// and relaykit deliberately does not use reflection to detect that case.
type Meta interface {
GetOriginModelName() string
GetUpstreamModelName() string
// HasChannelMeta reports whether upstream channel information is attached;
// converters use it to decide if GetUpstreamModelName is meaningful.
HasChannelMeta() bool
GetChannelID() int
GetChannelType() int
GetIsStream() bool
GetReasoningEffort() string
// SetReasoningEffort records the effort level a converter derived from a
// model-name suffix so downstream billing/logging can see it.
SetReasoningEffort(effort string)
GetEstimatePromptTokens() int
// EnsureClaudeConvertInfo lazily creates and returns the mutable
// OpenAI→Claude stream conversion state. For non-nil receivers, the same
// instance must be returned for the lifetime of one streaming session; a
// nil receiver may return a temporary initialized state.
EnsureClaudeConvertInfo() *ClaudeConvertInfo
// GetSendResponseCount / IncrSendResponseCount expose the shared
// downstream-chunk counter (the host may also increment it).
GetSendResponseCount() int
IncrSendResponseCount()
// AppendRequestConversion records a hop in the request format chain.
AppendRequestConversion(format types.RelayFormat)
// ConvOptions returns the request-scoped conversion options snapshot.
// Must never return nil.
ConvOptions() *Options
}
// ClaudeConvertInfo carries mutable state for OpenAI chat → Claude Messages
// stream conversion. Moved here from relay/common (which keeps an alias).
type ClaudeConvertInfo struct {
LastMessagesType string
Index int
Usage *dto.Usage
FinishReason string
Done bool
ToolCallBaseIndex int
ToolCallMaxIndexOffset int
}
const (
LastMessageTypeNone = "none"
LastMessageTypeText = "text"
LastMessageTypeTools = "tools"
LastMessageTypeThinking = "thinking"
)
// Values is a plain-struct Meta implementation for tests and non-RelayInfo
// hosts (the relaykit-native entry point).
type Values struct {
OriginModelName string
UpstreamModelName string
ChannelMetaAttached bool
ChannelID int
ChannelType int
IsStream bool
ReasoningEffort string
EstimatePromptTokens int
ClaudeConvertInfo *ClaudeConvertInfo
SendResponseCount int
ConversionChain []types.RelayFormat
Options *Options
}
var _ Meta = (*Values)(nil)
func (v *Values) GetOriginModelName() string {
if v == nil {
return ""
}
return v.OriginModelName
}
func (v *Values) GetUpstreamModelName() string {
if v == nil {
return ""
}
return v.UpstreamModelName
}
func (v *Values) HasChannelMeta() bool {
return v != nil && v.ChannelMetaAttached
}
func (v *Values) GetChannelID() int {
if v == nil {
return 0
}
return v.ChannelID
}
func (v *Values) GetChannelType() int {
if v == nil {
return 0
}
return v.ChannelType
}
func (v *Values) GetIsStream() bool {
return v != nil && v.IsStream
}
func (v *Values) GetReasoningEffort() string {
if v == nil {
return ""
}
return v.ReasoningEffort
}
func (v *Values) SetReasoningEffort(effort string) {
if v != nil {
v.ReasoningEffort = effort
}
}
func (v *Values) GetEstimatePromptTokens() int {
if v == nil {
return 0
}
return v.EstimatePromptTokens
}
func (v *Values) EnsureClaudeConvertInfo() *ClaudeConvertInfo {
if v == nil {
return &ClaudeConvertInfo{LastMessagesType: LastMessageTypeNone}
}
if v.ClaudeConvertInfo == nil {
v.ClaudeConvertInfo = &ClaudeConvertInfo{LastMessagesType: LastMessageTypeNone}
}
return v.ClaudeConvertInfo
}
func (v *Values) GetSendResponseCount() int {
if v == nil {
return 0
}
return v.SendResponseCount
}
func (v *Values) IncrSendResponseCount() {
if v != nil {
v.SendResponseCount++
}
}
func (v *Values) AppendRequestConversion(format types.RelayFormat) {
if v == nil || format == "" {
return
}
if n := len(v.ConversionChain); n > 0 && v.ConversionChain[n-1] == format {
return
}
v.ConversionChain = append(v.ConversionChain, format)
}
func (v *Values) ConvOptions() *Options {
if v == nil {
return &Options{}
}
if v.Options == nil {
v.Options = &Options{}
}
return v.Options
}
// UpstreamModelName / ChannelTypeOf are nil-safe accessors for optional Meta
// values (converters are often called with a nil Meta in tests and compat
// shims).
func UpstreamModelName(m Meta) string {
if m == nil || !m.HasChannelMeta() {
return ""
}
return m.GetUpstreamModelName()
}
func ChannelTypeOf(m Meta) int {
if m == nil || !m.HasChannelMeta() {
return 0
}
return m.GetChannelType()
}
// OptionsOf returns m's conversion options, or empty defaults when m is nil.
func OptionsOf(m Meta) *Options {
if m == nil {
return &Options{}
}
return m.ConvOptions()
}
@@ -0,0 +1,38 @@
package convmeta
import (
"testing"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValuesTypedNilMetaIsSafe(t *testing.T) {
var values *Values
var meta Meta = values
assert.Empty(t, meta.GetOriginModelName())
assert.Empty(t, meta.GetUpstreamModelName())
assert.False(t, meta.HasChannelMeta())
assert.Zero(t, meta.GetChannelID())
assert.Zero(t, meta.GetChannelType())
assert.False(t, meta.GetIsStream())
assert.Empty(t, meta.GetReasoningEffort())
assert.Zero(t, meta.GetEstimatePromptTokens())
assert.Zero(t, meta.GetSendResponseCount())
require.NotPanics(t, func() {
meta.SetReasoningEffort("high")
meta.IncrSendResponseCount()
meta.AppendRequestConversion(types.RelayFormatClaude)
})
convertInfo := meta.EnsureClaudeConvertInfo()
require.NotNil(t, convertInfo)
assert.Equal(t, LastMessageTypeNone, convertInfo.LastMessagesType)
require.NotNil(t, meta.ConvOptions())
require.NotNil(t, OptionsOf(meta))
assert.Empty(t, UpstreamModelName(meta))
assert.Zero(t, ChannelTypeOf(meta))
}
+79
View File
@@ -0,0 +1,79 @@
package convmeta
// Options is the per-request snapshot of host configuration that converters
// consult. The host fills it from its settings system when constructing the
// Meta (see relaycommon.RelayInfo.ConvOptions); relaykit users fill it
// directly. Zero value = every adaptation disabled, no defaults applied.
type Options struct {
Claude ClaudeOptions
Gemini GeminiOptions
// OpenRouterDialect marks the upstream as OpenRouter's OpenAI-compatible
// surface, which accepts extra fields (reasoning config, cache_control on
// system parts) that converters emit only for that dialect. The host sets
// it from the channel type.
OpenRouterDialect bool
// PreserveThinkingSuffix reports models whose -thinking/-nothinking/effort
// suffix must be kept on the outgoing model name (host blacklist lookup).
// Nil means "never preserve".
PreserveThinkingSuffix func(modelName string) bool
}
type ClaudeOptions struct {
// ThinkingAdapterEnabled turns "-thinking"-suffixed OpenAI model names
// into Claude extended-thinking requests.
ThinkingAdapterEnabled bool
// ThinkingAdapterBudgetTokensPercentage sizes thinking budget_tokens as a
// fraction of max_tokens when the adapter fires.
ThinkingAdapterBudgetTokensPercentage float64
// DefaultMaxTokens returns the max_tokens to inject when the source
// request carries none. The Claude Messages API requires max_tokens
// (omitting it is a 400), so when this hook is nil and no other path
// supplies a value, OpenAI→Claude request conversion fails with an
// explicit error instead of emitting a request the upstream is
// guaranteed to reject. The new-api host always provides this hook;
// standalone relaykit users must supply one or guarantee max_tokens on
// every request.
DefaultMaxTokens func(modelName string) int
}
type GeminiOptions struct {
// ThinkingAdapterEnabled maps -thinking/-nothinking/effort suffixes to
// Gemini thinkingConfig.
ThinkingAdapterEnabled bool
// ThinkingAdapterBudgetTokensPercentage sizes thinkingBudget as a fraction
// of maxOutputTokens when the adapter fires.
ThinkingAdapterBudgetTokensPercentage float64
// FunctionCallThoughtSignatureEnabled attaches thoughtSignature bypass
// values to function-call parts.
FunctionCallThoughtSignatureEnabled bool
// SupportsImagine reports whether the model supports image generation
// (switches response modalities). Nil means "never".
SupportsImagine func(modelName string) bool
// SafetySetting returns the harm threshold for a category. Nil or empty
// return means no safetySettings are attached.
SafetySetting func(category string) string
}
func (o *ClaudeOptions) DefaultMaxTokensFor(modelName string) (int, bool) {
if o == nil || o.DefaultMaxTokens == nil {
return 0, false
}
return o.DefaultMaxTokens(modelName), true
}
func (o *GeminiOptions) SupportsImagineModel(modelName string) bool {
return o != nil && o.SupportsImagine != nil && o.SupportsImagine(modelName)
}
func (o *GeminiOptions) SafetySettingFor(category string) string {
if o == nil || o.SafetySetting == nil {
return ""
}
return o.SafetySetting(category)
}
func (o *Options) ShouldPreserveThinkingSuffix(modelName string) bool {
return o != nil && o.PreserveThinkingSuffix != nil && o.PreserveThinkingSuffix(modelName)
}
+475
View File
@@ -0,0 +1,475 @@
package relayconvert
// golden_test.go pins the byte-level output of every registered (from, to)
// conversion route so the relaykit extraction refactor can prove behavior is
// unchanged at each phase. Run with -update to regenerate testdata/golden.
//
// Volatile values (generated UUID-based ids, unix timestamps) are normalized
// before comparison so the snapshots are deterministic.
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"regexp"
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/stretchr/testify/require"
)
var updateGolden = flag.Bool("update", false, "update golden files")
// TestMain installs a deterministic media resolver so image-bearing fixtures
// convert without network access.
func TestMain(m *testing.M) {
flag.Parse()
SetMediaResolver(MediaResolver{
GetBase64Data: func(c context.Context, source types.FileSource, reason ...string) (string, string, error) {
return "aGVsbG8=", "image/png", nil
},
DecodeBase64FileData: func(base64String string) (string, string, error) {
return "aGVsbG8=", "image/png", nil
},
})
os.Exit(m.Run())
}
const goldenDir = "testdata/golden"
var (
hex32Re = regexp.MustCompile(`[0-9a-f]{32}`)
timestampRe = regexp.MustCompile(`("created(_at)?"\s*:\s*)\d{9,}`)
)
func normalizeVolatile(data []byte) []byte {
data = hex32Re.ReplaceAll(data, []byte("<uuid>"))
data = timestampRe.ReplaceAll(data, []byte(`${1}0`))
return data
}
func marshalGolden(t *testing.T, value any) []byte {
t.Helper()
data, err := json.MarshalIndent(value, "", " ")
require.NoError(t, err)
return append(normalizeVolatile(data), '\n')
}
func checkGolden(t *testing.T, name string, got []byte) {
t.Helper()
path := filepath.Join(goldenDir, name+".golden.json")
if *updateGolden {
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, got, 0o644))
return
}
want, err := os.ReadFile(path)
require.NoError(t, err, "golden file missing, run: go test ./service/relayconvert -run TestGolden -update")
require.Equal(t, string(want), string(got), "conversion output drifted from golden snapshot %s", path)
}
// goldenInfo mirrors the host's default converter options (new-api's
// model_setting defaults at the time the snapshots were recorded) so the
// golden files stay comparable across the extraction.
func goldenInfo() convmeta.Meta {
return &convmeta.Values{
ChannelMetaAttached: true,
UpstreamModelName: "upstream-model",
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{
LastMessagesType: convmeta.LastMessageTypeNone,
},
Options: &convmeta.Options{
Gemini: convmeta.GeminiOptions{
ThinkingAdapterBudgetTokensPercentage: 0.6,
FunctionCallThoughtSignatureEnabled: true,
SafetySetting: func(string) string { return "OFF" },
},
},
}
}
// ---------------------------------------------------------------------------
// Fixtures: one representative rich request per source format
// ---------------------------------------------------------------------------
// Fixtures are built by unmarshalling wire-format JSON into the dto types —
// the same path production requests take — so they stay valid as struct
// internals evolve.
func fixtureRequests() map[types.RelayFormat]any {
openai := &dto.GeneralOpenAIRequest{}
mustUnmarshalFixture(`{
"model": "gpt-test",
"max_tokens": 1024,
"stream": true,
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": [
{"type": "text", "text": "What is in this image?"},
{"type": "image_url", "image_url": {"url": "https://example.com/cat.png", "detail": "high"}}
]},
{"role": "assistant", "tool_calls": [{"id": "call_abc", "type": "function", "function": {"name": "get_weather", "arguments": "{\"city\":\"Paris\"}"}}]},
{"role": "tool", "tool_call_id": "call_abc", "content": "15 degrees"},
{"role": "user", "content": "Summarize."}
],
"tools": [{"type": "function", "function": {"name": "get_weather", "description": "Get weather by city", "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}}],
"tool_choice": "auto"
}`, openai)
claude := &dto.ClaudeRequest{}
mustUnmarshalFixture(`{
"model": "claude-test",
"max_tokens": 1024,
"stream": true,
"system": "You are a helpful assistant.",
"messages": [
{"role": "user", "content": [
{"type": "text", "text": "What is in this image?"},
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "aGVsbG8="}}
]},
{"role": "assistant", "content": [
{"type": "thinking", "thinking": "Let me look.", "signature": "sig"},
{"type": "tool_use", "id": "toolu_abc", "name": "get_weather", "input": {"city": "Paris"}}
]},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_abc", "content": "15 degrees"}]}
],
"tools": [{"name": "get_weather", "description": "Get weather by city", "input_schema": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}],
"thinking": {"type": "enabled", "budget_tokens": 512}
}`, claude)
gemini := &dto.GeminiChatRequest{}
mustUnmarshalFixture(`{
"contents": [
{"role": "user", "parts": [
{"text": "What is in this image?"},
{"inlineData": {"mimeType": "image/png", "data": "aGVsbG8="}}
]},
{"role": "model", "parts": [{"functionCall": {"name": "get_weather", "args": {"city": "Paris"}}}]},
{"role": "user", "parts": [{"functionResponse": {"name": "get_weather", "response": {"result": "15 degrees"}}}]}
],
"systemInstruction": {"parts": [{"text": "You are a helpful assistant."}]},
"tools": [{"functionDeclarations": [{"name": "get_weather", "description": "Get weather by city", "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}]}],
"generationConfig": {"maxOutputTokens": 1024, "temperature": 0.7}
}`, gemini)
responses := &dto.OpenAIResponsesRequest{}
mustUnmarshalFixture(`{
"model": "gpt-test",
"stream": true,
"max_output_tokens": 1024,
"instructions": "You are a helpful assistant.",
"input": [
{"type": "message", "role": "user", "content": [
{"type": "input_text", "text": "What is in this image?"},
{"type": "input_image", "image_url": "https://example.com/cat.png"}
]},
{"type": "function_call", "call_id": "call_abc", "name": "get_weather", "arguments": "{\"city\":\"Paris\"}"},
{"type": "function_call_output", "call_id": "call_abc", "output": "15 degrees"}
],
"tools": [{"type": "function", "name": "get_weather", "description": "Get weather by city", "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}]
}`, responses)
return map[types.RelayFormat]any{
types.RelayFormatOpenAI: openai,
types.RelayFormatClaude: claude,
types.RelayFormatGemini: gemini,
types.RelayFormatOpenAIResponses: responses,
}
}
// ---------------------------------------------------------------------------
// Fixtures: one representative non-stream response per source format
// ---------------------------------------------------------------------------
func fixtureResponses() map[types.RelayFormat]any {
openai := &dto.OpenAITextResponse{}
mustUnmarshalFixture(`{
"id": "chatcmpl-fixed",
"object": "chat.completion",
"created": 1700000000,
"model": "gpt-test",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "The answer is 42.",
"reasoning_content": "Deep thought.",
"tool_calls": [{"id": "call_abc", "type": "function", "function": {"name": "get_weather", "arguments": "{\"city\":\"Paris\"}"}}]
},
"finish_reason": "tool_calls"
}],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15,
"prompt_tokens_details": {"cached_tokens": 3},
"completion_tokens_details": {"reasoning_tokens": 2}
}
}`, openai)
claude := &dto.ClaudeResponse{}
mustUnmarshalFixture(`{
"id": "msg_fixed",
"type": "message",
"role": "assistant",
"model": "claude-test",
"content": [
{"type": "text", "text": "The answer is 42."},
{"type": "tool_use", "id": "toolu_abc", "name": "get_weather", "input": {"city": "Paris"}}
],
"stop_reason": "tool_use",
"usage": {"input_tokens": 10, "output_tokens": 5, "cache_read_input_tokens": 3, "cache_creation_input_tokens": 2}
}`, claude)
gemini := &dto.GeminiChatResponse{}
mustUnmarshalFixture(`{
"candidates": [{
"finishReason": "STOP",
"content": {
"role": "model",
"parts": [
{"text": "The answer is 42."},
{"functionCall": {"name": "get_weather", "args": {"city": "Paris"}}}
]
}
}],
"usageMetadata": {"promptTokenCount": 10, "candidatesTokenCount": 5, "thoughtsTokenCount": 2, "totalTokenCount": 15}
}`, gemini)
responses := &dto.OpenAIResponsesResponse{}
mustUnmarshalFixture(`{
"id": "resp_fixed",
"object": "response",
"model": "gpt-test",
"status": "completed",
"output": [
{"type": "reasoning", "summary": [{"type": "summary_text", "text": "Deep thought."}]},
{"type": "message", "role": "assistant", "status": "completed", "content": [{"type": "output_text", "text": "The answer is 42."}]},
{"type": "function_call", "call_id": "call_abc", "name": "get_weather", "arguments": "{\"city\":\"Paris\"}", "status": "completed"}
],
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}
}`, responses)
return map[types.RelayFormat]any{
types.RelayFormatOpenAI: openai,
types.RelayFormatClaude: claude,
types.RelayFormatGemini: gemini,
types.RelayFormatOpenAIResponses: responses,
}
}
// ---------------------------------------------------------------------------
// Fixtures: stream chunk sequences per source format
// ---------------------------------------------------------------------------
func fixtureStreamChunks() map[types.RelayFormat][]any {
return map[types.RelayFormat][]any{
types.RelayFormatOpenAI: {
chatStreamChunk(`{"id":"chatcmpl-fixed","object":"chat.completion.chunk","created":1700000000,"model":"gpt-test","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"}}]}`),
chatStreamChunk(`{"id":"chatcmpl-fixed","object":"chat.completion.chunk","created":1700000000,"model":"gpt-test","choices":[{"index":0,"delta":{"content":" world"}}]}`),
chatStreamChunk(`{"id":"chatcmpl-fixed","object":"chat.completion.chunk","created":1700000000,"model":"gpt-test","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":4,"completion_tokens":2,"total_tokens":6}}`),
},
types.RelayFormatClaude: {
claudeStreamChunk(`{"type":"message_start","message":{"id":"msg_fixed","type":"message","role":"assistant","model":"claude-test","content":[],"usage":{"input_tokens":4,"output_tokens":0}}}`),
claudeStreamChunk(`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`),
claudeStreamChunk(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello world"}}`),
claudeStreamChunk(`{"type":"content_block_stop","index":0}`),
claudeStreamChunk(`{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":2}}`),
claudeStreamChunk(`{"type":"message_stop"}`),
},
types.RelayFormatGemini: {
geminiStreamChunk(`{"candidates":[{"content":{"role":"model","parts":[{"text":"Hello"}]}}]}`),
geminiStreamChunk(`{"candidates":[{"finishReason":"STOP","content":{"role":"model","parts":[{"text":" world"}]}}],"usageMetadata":{"promptTokenCount":4,"candidatesTokenCount":2,"totalTokenCount":6}}`),
},
types.RelayFormatOpenAIResponses: {
responsesStreamChunk(`{"type":"response.output_text.delta","delta":"Hello"}`),
responsesStreamChunk(`{"type":"response.output_text.delta","delta":" world"}`),
responsesStreamChunk(`{"type":"response.completed","response":{"id":"resp_fixed","object":"response","status":"completed","model":"gpt-test","usage":{"input_tokens":4,"output_tokens":2,"total_tokens":6}}}`),
},
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
func allFormats() []types.RelayFormat {
return []types.RelayFormat{
types.RelayFormatOpenAI,
types.RelayFormatClaude,
types.RelayFormatGemini,
types.RelayFormatOpenAIResponses,
}
}
func TestGoldenRequestConversionMatrix(t *testing.T) {
requests := fixtureRequests()
for _, from := range allFormats() {
for _, to := range allFormats() {
if from == to {
continue
}
if _, ok := lookupRequestRoute(from, to); !ok {
t.Fatalf("request route %s -> %s is no longer registered", from, to)
}
name := fmt.Sprintf("request/%s_to_%s", from, to)
t.Run(name, func(t *testing.T) {
result, err := ConvertRequest(nil, goldenInfo(), to, deepCopyFixture(t, requests[from]))
require.NoError(t, err)
checkGolden(t, name, marshalGolden(t, result.Value))
})
}
}
}
func TestGoldenResponseConversionMatrix(t *testing.T) {
responses := fixtureResponses()
for _, from := range allFormats() {
for _, to := range allFormats() {
if from == to {
continue
}
name := fmt.Sprintf("response/%s_to_%s", from, to)
t.Run(name, func(t *testing.T) {
result, err := ConvertResponse(nil, goldenInfo(), to, deepCopyFixture(t, responses[from]))
require.NoError(t, err)
checkGolden(t, name, marshalGolden(t, result.Value))
})
}
}
}
func TestGoldenStreamConversionMatrix(t *testing.T) {
chunkSets := fixtureStreamChunks()
for _, from := range allFormats() {
for _, to := range allFormats() {
if from == to {
continue
}
name := fmt.Sprintf("stream/%s_to_%s", from, to)
t.Run(name, func(t *testing.T) {
info := goldenInfo()
state, err := NewResponseStreamState(from, to, ResponseStreamOptions{
ID: "stream_fixed",
Model: "stream-model",
})
require.NoError(t, err)
var outputs []any
for _, chunk := range chunkSets[from] {
results, err := ConvertStreamResponseChunk(nil, info, state, deepCopyFixture(t, chunk))
require.NoError(t, err)
for _, r := range results {
outputs = append(outputs, r.Value)
}
}
finals, err := FinalizeStreamResponse(nil, info, state)
require.NoError(t, err)
for _, r := range finals {
outputs = append(outputs, r.Value)
}
snapshot := map[string]any{
"events": outputs,
"usage": state.Usage(),
}
checkGolden(t, name, marshalGolden(t, snapshot))
})
}
}
}
// ---------------------------------------------------------------------------
// Fixture helpers
// ---------------------------------------------------------------------------
func rawJSON(s string) json.RawMessage {
return json.RawMessage(s)
}
// deepCopyFixture guards against converters mutating shared fixture state
// between subtests (JSON round-trip through the concrete type).
func deepCopyFixture(t *testing.T, v any) any {
t.Helper()
data, err := json.Marshal(v)
require.NoError(t, err)
switch v.(type) {
case *dto.GeneralOpenAIRequest:
out := &dto.GeneralOpenAIRequest{}
require.NoError(t, json.Unmarshal(data, out))
return out
case *dto.ClaudeRequest:
out := &dto.ClaudeRequest{}
require.NoError(t, json.Unmarshal(data, out))
return out
case *dto.GeminiChatRequest:
out := &dto.GeminiChatRequest{}
require.NoError(t, json.Unmarshal(data, out))
return out
case *dto.OpenAIResponsesRequest:
out := &dto.OpenAIResponsesRequest{}
require.NoError(t, json.Unmarshal(data, out))
return out
case *dto.OpenAITextResponse:
out := &dto.OpenAITextResponse{}
require.NoError(t, json.Unmarshal(data, out))
return out
case *dto.ClaudeResponse:
out := &dto.ClaudeResponse{}
require.NoError(t, json.Unmarshal(data, out))
return out
case *dto.GeminiChatResponse:
out := &dto.GeminiChatResponse{}
require.NoError(t, json.Unmarshal(data, out))
return out
case *dto.OpenAIResponsesResponse:
out := &dto.OpenAIResponsesResponse{}
require.NoError(t, json.Unmarshal(data, out))
return out
case *dto.ChatCompletionsStreamResponse:
out := &dto.ChatCompletionsStreamResponse{}
require.NoError(t, json.Unmarshal(data, out))
return out
case *dto.ResponsesStreamResponse:
out := &dto.ResponsesStreamResponse{}
require.NoError(t, json.Unmarshal(data, out))
return out
default:
t.Fatalf("deepCopyFixture: unsupported fixture type %T", v)
return nil
}
}
func chatStreamChunk(raw string) *dto.ChatCompletionsStreamResponse {
var r dto.ChatCompletionsStreamResponse
mustUnmarshalFixture(raw, &r)
return &r
}
func claudeStreamChunk(raw string) *dto.ClaudeResponse {
var r dto.ClaudeResponse
mustUnmarshalFixture(raw, &r)
return &r
}
func geminiStreamChunk(raw string) *dto.GeminiChatResponse {
var r dto.GeminiChatResponse
mustUnmarshalFixture(raw, &r)
return &r
}
func responsesStreamChunk(raw string) *dto.ResponsesStreamResponse {
var r dto.ResponsesStreamResponse
mustUnmarshalFixture(raw, &r)
return &r
}
func mustUnmarshalFixture(raw string, out any) {
if err := json.Unmarshal([]byte(raw), out); err != nil {
panic(fmt.Sprintf("bad fixture JSON: %v", err))
}
}
@@ -0,0 +1,219 @@
package claudemessages
import (
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
const (
webSearchMaxUsesLow = 1
webSearchMaxUsesMedium = 5
webSearchMaxUsesHigh = 10
)
type openRouterRequestReasoning struct {
Enabled bool `json:"enabled"`
Effort string `json:"effort,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Exclude bool `json:"exclude,omitempty"`
}
func ClaudeMessagesRequestToOpenAIChat(claudeRequest dto.ClaudeRequest, info convmeta.Meta) (*dto.GeneralOpenAIRequest, error) {
openAIRequest := dto.GeneralOpenAIRequest{
Model: claudeRequest.Model,
Temperature: claudeRequest.Temperature,
}
if claudeRequest.MaxTokens != nil {
openAIRequest.MaxTokens = kitutil.GetPointer(*claudeRequest.MaxTokens)
}
if claudeRequest.TopP != nil {
openAIRequest.TopP = kitutil.GetPointer(*claudeRequest.TopP)
}
if claudeRequest.TopK != nil {
openAIRequest.TopK = kitutil.GetPointer(*claudeRequest.TopK)
}
if claudeRequest.Stream != nil {
openAIRequest.Stream = kitutil.GetPointer(*claudeRequest.Stream)
}
isOpenRouter := convmeta.OptionsOf(info).OpenRouterDialect
if isOpenRouter {
if effort := claudeRequest.GetEfforts(); effort != "" {
effortBytes, _ := kitutil.Marshal(effort)
openAIRequest.Verbosity = effortBytes
}
if claudeRequest.Thinking != nil {
var reasoningConfig openRouterRequestReasoning
if claudeRequest.Thinking.Type == "enabled" {
reasoningConfig = openRouterRequestReasoning{
Enabled: true,
MaxTokens: claudeRequest.Thinking.GetBudgetTokens(),
}
} else if claudeRequest.Thinking.Type == "adaptive" {
reasoningConfig = openRouterRequestReasoning{
Enabled: true,
}
}
reasoningJSON, err := kitutil.Marshal(reasoningConfig)
if err != nil {
return nil, fmt.Errorf("failed to marshal reasoning: %w", err)
}
openAIRequest.Reasoning = reasoningJSON
}
} else if info != nil {
thinkingSuffix := "-thinking"
if strings.HasSuffix(info.GetOriginModelName(), thinkingSuffix) &&
!strings.HasSuffix(openAIRequest.Model, thinkingSuffix) {
openAIRequest.Model = openAIRequest.Model + thinkingSuffix
}
}
if len(claudeRequest.StopSequences) == 1 {
openAIRequest.Stop = claudeRequest.StopSequences[0]
} else if len(claudeRequest.StopSequences) > 1 {
openAIRequest.Stop = claudeRequest.StopSequences
}
tools, _ := kitutil.Any2Type[[]dto.Tool](claudeRequest.Tools)
openAITools := make([]dto.ToolCallRequest, 0)
for _, claudeTool := range tools {
openAITool := dto.ToolCallRequest{
Type: "function",
Function: dto.FunctionRequest{
Name: claudeTool.Name,
Description: claudeTool.Description,
Parameters: claudeTool.InputSchema,
},
}
openAITools = append(openAITools, openAITool)
}
openAIRequest.Tools = openAITools
openAIMessages := make([]dto.Message, 0)
if claudeRequest.System != nil {
if claudeRequest.IsStringSystem() && claudeRequest.GetStringSystem() != "" {
openAIMessage := dto.Message{
Role: "system",
}
openAIMessage.SetStringContent(claudeRequest.GetStringSystem())
openAIMessages = append(openAIMessages, openAIMessage)
} else {
systems := claudeRequest.ParseSystem()
if len(systems) > 0 {
openAIMessage := dto.Message{
Role: "system",
}
isOpenRouterClaude := isOpenRouter && strings.HasPrefix(convmeta.UpstreamModelName(info), "anthropic/claude")
if isOpenRouterClaude {
systemMediaMessages := make([]dto.MediaContent, 0, len(systems))
for _, system := range systems {
message := dto.MediaContent{
Type: "text",
Text: system.GetText(),
CacheControl: system.CacheControl,
}
systemMediaMessages = append(systemMediaMessages, message)
}
openAIMessage.SetMediaContent(systemMediaMessages)
} else {
systemStr := ""
for _, system := range systems {
if system.Text != nil {
systemStr += *system.Text
}
}
openAIMessage.SetStringContent(systemStr)
}
openAIMessages = append(openAIMessages, openAIMessage)
}
}
}
for _, claudeMessage := range claudeRequest.Messages {
openAIMessage := dto.Message{
Role: claudeMessage.Role,
}
if claudeMessage.IsStringContent() {
openAIMessage.SetStringContent(claudeMessage.GetStringContent())
} else {
content, err := claudeMessage.ParseContent()
if err != nil {
return nil, err
}
var toolCalls []dto.ToolCallRequest
mediaMessages := make([]dto.MediaContent, 0, len(content))
for _, mediaMsg := range content {
switch mediaMsg.Type {
case "text", "input_text":
message := dto.MediaContent{
Type: "text",
Text: mediaMsg.GetText(),
CacheControl: mediaMsg.CacheControl,
}
mediaMessages = append(mediaMessages, message)
case "image":
imageData := fmt.Sprintf("data:%s;base64,%s", mediaMsg.Source.MediaType, mediaMsg.Source.Data)
mediaMessage := dto.MediaContent{
Type: "image_url",
ImageUrl: &dto.MessageImageUrl{Url: imageData},
}
mediaMessages = append(mediaMessages, mediaMessage)
case "tool_use":
toolCall := dto.ToolCallRequest{
ID: mediaMsg.Id,
Type: "function",
Function: dto.FunctionRequest{
Name: mediaMsg.Name,
Arguments: requestToJSONString(mediaMsg.Input),
},
}
toolCalls = append(toolCalls, toolCall)
case "tool_result":
toolName := mediaMsg.Name
if toolName == "" {
toolName = claudeRequest.SearchToolNameByToolCallId(mediaMsg.ToolUseId)
}
oaiToolMessage := dto.Message{
Role: "tool",
Name: &toolName,
ToolCallId: mediaMsg.ToolUseId,
}
if mediaMsg.IsStringContent() {
oaiToolMessage.SetStringContent(mediaMsg.GetStringContent())
} else {
mediaContents := mediaMsg.ParseMediaContent()
encodedJSON, _ := kitutil.Marshal(mediaContents)
oaiToolMessage.SetStringContent(string(encodedJSON))
}
openAIMessages = append(openAIMessages, oaiToolMessage)
}
}
if len(toolCalls) > 0 {
openAIMessage.SetToolCalls(toolCalls)
}
if len(mediaMessages) > 0 && len(toolCalls) == 0 {
openAIMessage.SetMediaContent(mediaMessages)
}
}
if len(openAIMessage.ParseContent()) > 0 || len(openAIMessage.ToolCalls) > 0 {
openAIMessages = append(openAIMessages, openAIMessage)
}
}
openAIRequest.Messages = openAIMessages
return &openAIRequest, nil
}
func requestToJSONString(v interface{}) string {
b, err := kitutil.Marshal(v)
if err != nil {
return "{}"
}
return string(b)
}
@@ -0,0 +1,400 @@
package claudemessages
import (
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/reasonmap"
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
type ClaudeResponseInfo struct {
ResponseId string
Created int64
Model string
ResponseText strings.Builder
Usage *dto.Usage
Done bool
}
func StopReasonClaudeToOpenAI(reason string) string {
return reasonmap.ClaudeStopReasonToOpenAIFinishReason(reason)
}
func StreamResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.ChatCompletionsStreamResponse {
var response dto.ChatCompletionsStreamResponse
response.Object = "chat.completion.chunk"
response.Model = claudeResponse.Model
response.Choices = make([]dto.ChatCompletionsStreamResponseChoice, 0)
tools := make([]dto.ToolCallResponse, 0)
fcIdx := 0
if claudeResponse.Index != nil {
fcIdx = *claudeResponse.Index
}
var choice dto.ChatCompletionsStreamResponseChoice
if claudeResponse.Type == "message_start" {
if claudeResponse.Message != nil {
response.Id = claudeResponse.Message.Id
response.Model = claudeResponse.Message.Model
}
choice.Delta.SetContentString("")
choice.Delta.Role = "assistant"
} else if claudeResponse.Type == "content_block_start" {
if claudeResponse.ContentBlock != nil {
if claudeResponse.ContentBlock.Type == "text" && claudeResponse.ContentBlock.Text != nil {
choice.Delta.SetContentString(*claudeResponse.ContentBlock.Text)
}
if claudeResponse.ContentBlock.Type == "tool_use" {
tools = append(tools, dto.ToolCallResponse{
Index: kitutil.GetPointer(fcIdx),
ID: claudeResponse.ContentBlock.Id,
Type: "function",
Function: dto.FunctionResponse{
Name: claudeResponse.ContentBlock.Name,
Arguments: "",
},
})
}
} else {
return nil
}
} else if claudeResponse.Type == "content_block_delta" {
if claudeResponse.Delta != nil {
choice.Delta.Content = claudeResponse.Delta.Text
switch claudeResponse.Delta.Type {
case "input_json_delta":
tools = append(tools, dto.ToolCallResponse{
Type: "function",
Index: kitutil.GetPointer(fcIdx),
Function: dto.FunctionResponse{
Arguments: *claudeResponse.Delta.PartialJson,
},
})
case "signature_delta":
signatureContent := "\n"
choice.Delta.ReasoningContent = &signatureContent
case "thinking_delta":
choice.Delta.ReasoningContent = claudeResponse.Delta.Thinking
}
}
} else if claudeResponse.Type == "message_delta" {
if claudeResponse.Delta != nil && claudeResponse.Delta.StopReason != nil {
finishReason := StopReasonClaudeToOpenAI(*claudeResponse.Delta.StopReason)
if finishReason != "null" {
choice.FinishReason = &finishReason
}
}
} else if claudeResponse.Type == "message_stop" {
return nil
} else {
return nil
}
if len(tools) > 0 {
choice.Delta.Content = nil
choice.Delta.ToolCalls = tools
}
response.Choices = append(response.Choices, choice)
return &response
}
func ResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.OpenAITextResponse {
choices := make([]dto.OpenAITextResponseChoice, 0)
fullTextResponse := dto.OpenAITextResponse{
Id: fmt.Sprintf("chatcmpl-%s", kitutil.GetUUID()),
Object: "chat.completion",
Created: kitutil.GetTimestamp(),
}
var responseText string
var responseThinking string
if len(claudeResponse.Content) > 0 {
responseText = claudeResponse.Content[0].GetText()
if claudeResponse.Content[0].Thinking != nil {
responseThinking = *claudeResponse.Content[0].Thinking
}
}
tools := make([]dto.ToolCallResponse, 0)
thinkingContent := ""
fullTextResponse.Id = claudeResponse.Id
for _, message := range claudeResponse.Content {
switch message.Type {
case "tool_use":
args, _ := kitutil.Marshal(message.Input)
tools = append(tools, dto.ToolCallResponse{
ID: message.Id,
Type: "function",
Function: dto.FunctionResponse{
Name: message.Name,
Arguments: string(args),
},
})
case "thinking":
if message.Thinking != nil {
thinkingContent = *message.Thinking
}
case "text":
responseText = message.GetText()
}
}
choice := dto.OpenAITextResponseChoice{
Index: 0,
Message: dto.Message{
Role: "assistant",
},
FinishReason: StopReasonClaudeToOpenAI(claudeResponse.StopReason),
}
choice.SetStringContent(responseText)
if len(responseThinking) > 0 {
choice.ReasoningContent = &responseThinking
}
if len(tools) > 0 {
choice.Message.SetToolCalls(tools)
}
if thinkingContent != "" {
choice.Message.ReasoningContent = &thinkingContent
}
fullTextResponse.Model = claudeResponse.Model
choices = append(choices, choice)
fullTextResponse.Choices = choices
return &fullTextResponse
}
func UsageFromClaudeAPIUsage(usage *dto.ClaudeUsage) *dto.Usage {
if usage == nil {
return &dto.Usage{}
}
semanticUsage := &dto.Usage{
PromptTokens: usage.InputTokens,
CompletionTokens: usage.OutputTokens,
UsageSemantic: "anthropic",
UsageSource: "anthropic",
BillingUsage: dto.CloneBillingUsage(usage.BillingUsage),
}
if semanticUsage.BillingUsage == nil {
semanticUsage.BillingUsage = dto.NewClaudeMessagesBillingUsage(usage)
}
semanticUsage.PromptTokensDetails.CachedTokens = usage.CacheReadInputTokens
semanticUsage.PromptTokensDetails.CachedCreationTokens = usage.CacheCreationInputTokens
semanticUsage.ClaudeCacheCreation5mTokens = usage.GetCacheCreation5mTokens()
semanticUsage.ClaudeCacheCreation1hTokens = usage.GetCacheCreation1hTokens()
return UsageFromClaudeUsage(semanticUsage)
}
func UsageFromClaudeUsage(usage *dto.Usage) *dto.Usage {
mapped := buildOpenAIStyleUsageFromClaudeUsage(usage)
return &mapped
}
func cacheCreationTokensForOpenAIUsage(usage *dto.Usage) int {
if usage == nil {
return 0
}
splitCacheCreationTokens := usage.ClaudeCacheCreation5mTokens + usage.ClaudeCacheCreation1hTokens
if splitCacheCreationTokens == 0 {
return usage.PromptTokensDetails.CachedCreationTokens
}
if usage.PromptTokensDetails.CachedCreationTokens > splitCacheCreationTokens {
return usage.PromptTokensDetails.CachedCreationTokens
}
return splitCacheCreationTokens
}
func buildOpenAIStyleUsageFromClaudeUsage(usage *dto.Usage) dto.Usage {
if usage == nil {
return dto.Usage{}
}
clone := *usage
clone.BillingUsage = dto.CloneBillingUsage(usage.BillingUsage)
clone.ClaudeCacheCreation5mTokens, clone.ClaudeCacheCreation1hTokens = sharedclaude.NormalizeCacheCreationSplit(
usage.PromptTokensDetails.CachedCreationTokens,
usage.ClaudeCacheCreation5mTokens,
usage.ClaudeCacheCreation1hTokens,
)
cacheCreationTokens := cacheCreationTokensForOpenAIUsage(usage)
// Expose the standard OpenAI cache-write field alongside the legacy
// cached_creation_tokens so OpenAI-format clients can bill cache writes.
clone.PromptTokensDetails.CacheWriteTokens = cacheCreationTokens
totalInputTokens := usage.PromptTokens + usage.PromptTokensDetails.CachedTokens + cacheCreationTokens
clone.PromptTokens = totalInputTokens
clone.InputTokens = totalInputTokens
clone.TotalTokens = totalInputTokens + usage.CompletionTokens
clone.UsageSemantic = "openai"
clone.UsageSource = "anthropic"
return clone
}
func BuildMessageDeltaPatchUsage(claudeResponse *dto.ClaudeResponse, claudeInfo *ClaudeResponseInfo) *dto.ClaudeUsage {
usage := &dto.ClaudeUsage{}
if claudeResponse != nil && claudeResponse.Usage != nil {
*usage = *claudeResponse.Usage
}
if claudeInfo == nil || claudeInfo.Usage == nil {
return usage
}
if usage.InputTokens == 0 && claudeInfo.Usage.PromptTokens > 0 {
usage.InputTokens = claudeInfo.Usage.PromptTokens
}
if usage.CacheReadInputTokens == 0 && claudeInfo.Usage.PromptTokensDetails.CachedTokens > 0 {
usage.CacheReadInputTokens = claudeInfo.Usage.PromptTokensDetails.CachedTokens
}
if usage.CacheCreationInputTokens == 0 && claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens > 0 {
usage.CacheCreationInputTokens = claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens
}
cacheCreation5m := 0
cacheCreation1h := 0
if usage.CacheCreation != nil {
cacheCreation5m = usage.CacheCreation.Ephemeral5mInputTokens
cacheCreation1h = usage.CacheCreation.Ephemeral1hInputTokens
} else {
cacheCreation5m = claudeInfo.Usage.ClaudeCacheCreation5mTokens
cacheCreation1h = claudeInfo.Usage.ClaudeCacheCreation1hTokens
}
cacheCreation5m, cacheCreation1h = sharedclaude.NormalizeCacheCreationSplit(
usage.CacheCreationInputTokens,
cacheCreation5m,
cacheCreation1h,
)
if usage.CacheCreation == nil && (cacheCreation5m > 0 || cacheCreation1h > 0) {
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{}
}
if usage.CacheCreation != nil {
usage.CacheCreation.Ephemeral5mInputTokens = cacheCreation5m
usage.CacheCreation.Ephemeral1hInputTokens = cacheCreation1h
}
return usage
}
func claudeBillingUsageFromSemanticUsage(usage *dto.Usage) *dto.BillingUsage {
if usage == nil {
return nil
}
cacheCreation5m, cacheCreation1h := sharedclaude.NormalizeCacheCreationSplit(
usage.PromptTokensDetails.CachedCreationTokens,
usage.ClaudeCacheCreation5mTokens,
usage.ClaudeCacheCreation1hTokens,
)
claudeUsage := &dto.ClaudeUsage{
InputTokens: usage.PromptTokens,
CacheCreationInputTokens: usage.PromptTokensDetails.CachedCreationTokens,
CacheReadInputTokens: usage.PromptTokensDetails.CachedTokens,
OutputTokens: usage.CompletionTokens,
}
if cacheCreation5m > 0 || cacheCreation1h > 0 {
claudeUsage.CacheCreation = &dto.ClaudeCacheCreationUsage{
Ephemeral5mInputTokens: cacheCreation5m,
Ephemeral1hInputTokens: cacheCreation1h,
}
}
return dto.NewClaudeMessagesBillingUsage(claudeUsage)
}
func PatchClaudeMessageDeltaUsageData(data string, usage *dto.ClaudeUsage) string {
if data == "" || usage == nil {
return data
}
data = setMessageDeltaUsageInt(data, "usage.input_tokens", usage.InputTokens)
data = setMessageDeltaUsageInt(data, "usage.cache_read_input_tokens", usage.CacheReadInputTokens)
data = setMessageDeltaUsageInt(data, "usage.cache_creation_input_tokens", usage.CacheCreationInputTokens)
if usage.CacheCreation != nil {
data = setMessageDeltaUsageInt(data, "usage.cache_creation.ephemeral_5m_input_tokens", usage.CacheCreation.Ephemeral5mInputTokens)
data = setMessageDeltaUsageInt(data, "usage.cache_creation.ephemeral_1h_input_tokens", usage.CacheCreation.Ephemeral1hInputTokens)
}
return data
}
func setMessageDeltaUsageInt(data string, path string, localValue int) string {
if localValue <= 0 {
return data
}
upstreamValue := gjson.Get(data, path)
if upstreamValue.Exists() && upstreamValue.Int() > 0 {
return data
}
patchedData, err := sjson.Set(data, path, localValue)
if err != nil {
return data
}
return patchedData
}
func FormatClaudeResponseInfo(claudeResponse *dto.ClaudeResponse, oaiResponse *dto.ChatCompletionsStreamResponse, claudeInfo *ClaudeResponseInfo) bool {
if claudeInfo == nil {
return false
}
if claudeInfo.Usage == nil {
claudeInfo.Usage = &dto.Usage{}
}
if claudeResponse.Type == "message_start" {
if claudeResponse.Message != nil {
claudeInfo.ResponseId = claudeResponse.Message.Id
claudeInfo.Model = claudeResponse.Message.Model
}
if claudeResponse.Message != nil && claudeResponse.Message.Usage != nil {
claudeInfo.Usage.PromptTokens = claudeResponse.Message.Usage.InputTokens
claudeInfo.Usage.UsageSemantic = "anthropic"
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Message.Usage.CacheReadInputTokens
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Message.Usage.CacheCreationInputTokens
claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Message.Usage.GetCacheCreation5mTokens()
claudeInfo.Usage.ClaudeCacheCreation1hTokens = claudeResponse.Message.Usage.GetCacheCreation1hTokens()
claudeInfo.Usage.CompletionTokens = claudeResponse.Message.Usage.OutputTokens
claudeInfo.Usage.BillingUsage = claudeBillingUsageFromSemanticUsage(claudeInfo.Usage)
}
} else if claudeResponse.Type == "content_block_delta" {
if claudeResponse.Delta != nil {
if claudeResponse.Delta.Text != nil {
claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Text)
}
if claudeResponse.Delta.Thinking != nil {
claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Thinking)
}
}
} else if claudeResponse.Type == "message_delta" {
if claudeResponse.Usage != nil {
claudeInfo.Usage.UsageSemantic = "anthropic"
if claudeResponse.Usage.InputTokens > 0 {
claudeInfo.Usage.PromptTokens = claudeResponse.Usage.InputTokens
}
if claudeResponse.Usage.CacheReadInputTokens > 0 {
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Usage.CacheReadInputTokens
}
if claudeResponse.Usage.CacheCreationInputTokens > 0 {
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Usage.CacheCreationInputTokens
}
if cacheCreation5m := claudeResponse.Usage.GetCacheCreation5mTokens(); cacheCreation5m > 0 {
claudeInfo.Usage.ClaudeCacheCreation5mTokens = cacheCreation5m
}
if cacheCreation1h := claudeResponse.Usage.GetCacheCreation1hTokens(); cacheCreation1h > 0 {
claudeInfo.Usage.ClaudeCacheCreation1hTokens = cacheCreation1h
}
if claudeResponse.Usage.OutputTokens > 0 {
claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens
}
claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens
claudeInfo.Usage.BillingUsage = claudeBillingUsageFromSemanticUsage(claudeInfo.Usage)
}
claudeInfo.Done = true
} else if claudeResponse.Type == "content_block_start" {
} else {
return false
}
if oaiResponse != nil {
oaiResponse.Id = claudeInfo.ResponseId
oaiResponse.Created = claudeInfo.Created
oaiResponse.Model = claudeInfo.Model
}
return true
}
@@ -0,0 +1,174 @@
package geminichat
import (
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/jsonutil"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
func GeminiGenerateContentRequestToOpenAIChat(geminiRequest *dto.GeminiChatRequest, info convmeta.Meta) (*dto.GeneralOpenAIRequest, error) {
modelName := ""
isStream := false
if info != nil {
isStream = info.GetIsStream()
}
modelName = convmeta.UpstreamModelName(info)
openaiRequest := &dto.GeneralOpenAIRequest{
Model: modelName,
Stream: kitutil.GetPointer(isStream),
}
var messages []dto.Message
for _, content := range geminiRequest.Contents {
message := dto.Message{
Role: convertGeminiRoleToOpenAI(content.Role),
}
var mediaContents []dto.MediaContent
var toolCalls []dto.ToolCallRequest
for _, part := range content.Parts {
if part.Text != "" {
mediaContent := dto.MediaContent{
Type: "text",
Text: part.Text,
}
mediaContents = append(mediaContents, mediaContent)
} else if part.InlineData != nil {
mediaContent := dto.MediaContent{
Type: "image_url",
ImageUrl: &dto.MessageImageUrl{
Url: fmt.Sprintf("data:%s;base64,%s", part.InlineData.MimeType, part.InlineData.Data),
Detail: "auto",
MimeType: part.InlineData.MimeType,
},
}
mediaContents = append(mediaContents, mediaContent)
} else if part.FileData != nil {
mediaContent := dto.MediaContent{
Type: "image_url",
ImageUrl: &dto.MessageImageUrl{
Url: part.FileData.FileUri,
Detail: "auto",
MimeType: part.FileData.MimeType,
},
}
mediaContents = append(mediaContents, mediaContent)
} else if part.FunctionCall != nil {
toolCall := dto.ToolCallRequest{
ID: fmt.Sprintf("call_%d", len(toolCalls)+1),
Type: "function",
Function: dto.FunctionRequest{
Name: part.FunctionCall.FunctionName,
Arguments: jsonutil.ToJSONString(part.FunctionCall.Arguments),
},
}
toolCalls = append(toolCalls, toolCall)
} else if part.FunctionResponse != nil {
toolMessage := dto.Message{
Role: "tool",
ToolCallId: fmt.Sprintf("call_%d", len(toolCalls)),
}
toolMessage.SetStringContent(jsonutil.ToJSONString(part.FunctionResponse.Response))
messages = append(messages, toolMessage)
}
}
if len(toolCalls) > 0 {
message.SetToolCalls(toolCalls)
} else if len(mediaContents) == 1 && mediaContents[0].Type == "text" {
message.Content = mediaContents[0].Text
} else if len(mediaContents) > 0 {
message.SetMediaContent(mediaContents)
}
if len(message.ParseContent()) > 0 || len(message.ToolCalls) > 0 {
messages = append(messages, message)
}
}
openaiRequest.Messages = messages
if geminiRequest.GenerationConfig.Temperature != nil {
openaiRequest.Temperature = geminiRequest.GenerationConfig.Temperature
}
if geminiRequest.GenerationConfig.TopP != nil && *geminiRequest.GenerationConfig.TopP > 0 {
openaiRequest.TopP = kitutil.GetPointer(*geminiRequest.GenerationConfig.TopP)
}
if geminiRequest.GenerationConfig.TopK != nil && *geminiRequest.GenerationConfig.TopK > 0 {
openaiRequest.TopK = kitutil.GetPointer(int(*geminiRequest.GenerationConfig.TopK))
}
if geminiRequest.GenerationConfig.MaxOutputTokens != nil && *geminiRequest.GenerationConfig.MaxOutputTokens > 0 {
openaiRequest.MaxTokens = kitutil.GetPointer(*geminiRequest.GenerationConfig.MaxOutputTokens)
}
if len(geminiRequest.GenerationConfig.StopSequences) > 0 {
openaiRequest.Stop = geminiRequest.GenerationConfig.StopSequences[:min(len(geminiRequest.GenerationConfig.StopSequences), 4)]
}
if geminiRequest.GenerationConfig.CandidateCount != nil && *geminiRequest.GenerationConfig.CandidateCount > 0 {
openaiRequest.N = kitutil.GetPointer(*geminiRequest.GenerationConfig.CandidateCount)
}
if len(geminiRequest.GetTools()) > 0 {
var tools []dto.ToolCallRequest
for _, tool := range geminiRequest.GetTools() {
if tool.FunctionDeclarations == nil {
continue
}
functionDeclarations, err := kitutil.Any2Type[[]dto.FunctionRequest](tool.FunctionDeclarations)
if err != nil {
kitutil.LogSystemError(fmt.Sprintf("failed to parse gemini function declarations: %v (type=%T)", err, tool.FunctionDeclarations))
continue
}
for _, function := range functionDeclarations {
openAITool := dto.ToolCallRequest{
Type: "function",
Function: dto.FunctionRequest{
Name: function.Name,
Description: function.Description,
Parameters: function.Parameters,
},
}
tools = append(tools, openAITool)
}
}
if len(tools) > 0 {
openaiRequest.Tools = tools
}
}
if geminiRequest.SystemInstructions != nil {
systemMessage := dto.Message{
Role: "system",
Content: extractTextFromGeminiParts(geminiRequest.SystemInstructions.Parts),
}
openaiRequest.Messages = append([]dto.Message{systemMessage}, openaiRequest.Messages...)
}
return openaiRequest, nil
}
func convertGeminiRoleToOpenAI(geminiRole string) string {
switch geminiRole {
case "user":
return "user"
case "model":
return "assistant"
case "function":
return "function"
default:
return "user"
}
}
func extractTextFromGeminiParts(parts []dto.GeminiPart) string {
texts := make([]string, 0)
for _, part := range parts {
if part.Text != "" {
texts = append(texts, part.Text)
}
}
return strings.Join(texts, "\n")
}
@@ -0,0 +1,298 @@
package geminichat
import (
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
func UsageFromGeminiMetadata(metadata *dto.GeminiUsageMetadata, fallbackPromptTokens int) *dto.Usage {
if metadata == nil {
if fallbackPromptTokens <= 0 {
return nil
}
usage := &dto.Usage{PromptTokens: fallbackPromptTokens}
usage.PromptTokensDetails.TextTokens = fallbackPromptTokens
return usage
}
promptTokens := metadata.PromptTokenCount + metadata.ToolUsePromptTokenCount
if promptTokens <= 0 && fallbackPromptTokens > 0 {
promptTokens = fallbackPromptTokens
}
usage := &dto.Usage{
PromptTokens: promptTokens,
CompletionTokens: metadata.CandidatesTokenCount + metadata.ThoughtsTokenCount,
TotalTokens: metadata.TotalTokenCount,
BillingUsage: dto.CloneBillingUsage(metadata.BillingUsage),
}
if usage.BillingUsage == nil {
usage.BillingUsage = dto.NewGeminiChatBillingUsage(metadata)
}
usage.CompletionTokenDetails.ReasoningTokens = metadata.ThoughtsTokenCount
usage.PromptTokensDetails.CachedTokens = metadata.CachedContentTokenCount
for _, detail := range metadata.PromptTokensDetails {
if detail.Modality == "AUDIO" {
usage.PromptTokensDetails.AudioTokens += detail.TokenCount
} else if detail.Modality == "IMAGE" {
usage.PromptTokensDetails.ImageTokens += detail.TokenCount
} else if detail.Modality == "TEXT" {
usage.PromptTokensDetails.TextTokens += detail.TokenCount
}
}
for _, detail := range metadata.ToolUsePromptTokensDetails {
if detail.Modality == "AUDIO" {
usage.PromptTokensDetails.AudioTokens += detail.TokenCount
} else if detail.Modality == "IMAGE" {
usage.PromptTokensDetails.ImageTokens += detail.TokenCount
} else if detail.Modality == "TEXT" {
usage.PromptTokensDetails.TextTokens += detail.TokenCount
}
}
for _, detail := range metadata.CandidatesTokensDetails {
switch detail.Modality {
case "IMAGE":
usage.CompletionTokenDetails.ImageTokens += detail.TokenCount
case "AUDIO":
usage.CompletionTokenDetails.AudioTokens += detail.TokenCount
case "TEXT":
usage.CompletionTokenDetails.TextTokens += detail.TokenCount
}
}
if usage.TotalTokens > 0 && usage.CompletionTokens <= 0 {
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
}
if usage.PromptTokens > 0 && usage.PromptTokensDetails.TextTokens == 0 && usage.PromptTokensDetails.AudioTokens == 0 {
usage.PromptTokensDetails.TextTokens = usage.PromptTokens
}
return usage
}
func ResponseGeminiChat2OpenAI(id string, created int64, response *dto.GeminiChatResponse) *dto.OpenAITextResponse {
fullTextResponse := dto.OpenAITextResponse{
Id: id,
Object: "chat.completion",
Created: created,
Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)),
}
isToolCall := false
for _, candidate := range response.Candidates {
choice := dto.OpenAITextResponseChoice{
Index: int(candidate.Index),
Message: dto.Message{
Role: "assistant",
Content: "",
},
FinishReason: types.FinishReasonStop,
}
if len(candidate.Content.Parts) > 0 {
var content strings.Builder
var inlineGrow int
for _, part := range candidate.Content.Parts {
if part.InlineData != nil {
inlineGrow += len(part.InlineData.MimeType) + len(part.InlineData.Data) + 32
}
}
if inlineGrow > 0 {
content.Grow(inlineGrow)
}
appended := 0
writeSep := func() {
if appended > 0 {
content.WriteByte('\n')
}
appended++
}
var toolCalls []dto.ToolCallResponse
for _, part := range candidate.Content.Parts {
if part.InlineData != nil {
if strings.HasPrefix(part.InlineData.MimeType, "image") {
writeSep()
content.WriteString("![image](data:")
content.WriteString(part.InlineData.MimeType)
content.WriteString(";base64,")
content.WriteString(part.InlineData.Data)
content.WriteByte(')')
} else {
writeSep()
content.WriteString("[media](data:")
content.WriteString(part.InlineData.MimeType)
content.WriteString(";base64,")
content.WriteString(part.InlineData.Data)
content.WriteByte(')')
}
} else if part.FunctionCall != nil {
choice.FinishReason = types.FinishReasonToolCalls
if call := geminiResponseToolCall(&part); call != nil {
toolCalls = append(toolCalls, *call)
}
} else if part.Thought {
choice.Message.ReasoningContent = &part.Text
} else {
if part.ExecutableCode != nil {
writeSep()
content.WriteString("```")
content.WriteString(part.ExecutableCode.Language)
content.WriteByte('\n')
content.WriteString(part.ExecutableCode.Code)
content.WriteString("\n```")
} else if part.CodeExecutionResult != nil {
writeSep()
content.WriteString("```output\n")
content.WriteString(part.CodeExecutionResult.Output)
content.WriteString("\n```")
} else if part.Text != "\n" {
writeSep()
content.WriteString(part.Text)
}
}
}
if len(toolCalls) > 0 {
choice.Message.SetToolCalls(toolCalls)
isToolCall = true
}
choice.Message.SetStringContent(content.String())
}
if candidate.FinishReason != nil {
switch *candidate.FinishReason {
case "STOP":
choice.FinishReason = types.FinishReasonStop
case "MAX_TOKENS":
choice.FinishReason = types.FinishReasonLength
case "SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII", "OTHER":
choice.FinishReason = types.FinishReasonContentFilter
default:
choice.FinishReason = types.FinishReasonContentFilter
}
}
if isToolCall {
choice.FinishReason = types.FinishReasonToolCalls
}
fullTextResponse.Choices = append(fullTextResponse.Choices, choice)
}
return &fullTextResponse
}
func StreamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*dto.ChatCompletionsStreamResponse, bool) {
choices := make([]dto.ChatCompletionsStreamResponseChoice, 0, len(geminiResponse.Candidates))
isStop := false
for _, candidate := range geminiResponse.Candidates {
if candidate.FinishReason != nil && *candidate.FinishReason == "STOP" {
isStop = true
candidate.FinishReason = nil
}
choice := dto.ChatCompletionsStreamResponseChoice{
Index: int(candidate.Index),
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{},
}
var content strings.Builder
var inlineGrow int
for _, part := range candidate.Content.Parts {
if part.InlineData != nil {
inlineGrow += len(part.InlineData.MimeType) + len(part.InlineData.Data) + 32
}
}
if inlineGrow > 0 {
content.Grow(inlineGrow)
}
appended := 0
writeSep := func() {
if appended > 0 {
content.WriteByte('\n')
}
appended++
}
isTools := false
isThought := false
if candidate.FinishReason != nil {
switch *candidate.FinishReason {
case "STOP":
choice.FinishReason = &types.FinishReasonStop
case "MAX_TOKENS":
choice.FinishReason = &types.FinishReasonLength
case "SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII", "OTHER":
choice.FinishReason = &types.FinishReasonContentFilter
default:
choice.FinishReason = &types.FinishReasonContentFilter
}
}
for _, part := range candidate.Content.Parts {
if part.InlineData != nil {
if strings.HasPrefix(part.InlineData.MimeType, "image") {
writeSep()
content.WriteString("![image](data:")
content.WriteString(part.InlineData.MimeType)
content.WriteString(";base64,")
content.WriteString(part.InlineData.Data)
content.WriteByte(')')
}
} else if part.FunctionCall != nil {
isTools = true
if call := geminiResponseToolCall(&part); call != nil {
call.SetIndex(len(choice.Delta.ToolCalls))
choice.Delta.ToolCalls = append(choice.Delta.ToolCalls, *call)
}
} else if part.Thought {
isThought = true
writeSep()
content.WriteString(part.Text)
} else {
if part.ExecutableCode != nil {
writeSep()
content.WriteString("```")
content.WriteString(part.ExecutableCode.Language)
content.WriteByte('\n')
content.WriteString(part.ExecutableCode.Code)
content.WriteString("\n```\n")
} else if part.CodeExecutionResult != nil {
writeSep()
content.WriteString("```output\n")
content.WriteString(part.CodeExecutionResult.Output)
content.WriteString("\n```\n")
} else if part.Text != "\n" {
writeSep()
content.WriteString(part.Text)
}
}
}
if isThought {
choice.Delta.SetReasoningContent(content.String())
} else {
choice.Delta.SetContentString(content.String())
}
if isTools {
choice.FinishReason = &types.FinishReasonToolCalls
}
choices = append(choices, choice)
}
response := dto.ChatCompletionsStreamResponse{
Object: "chat.completion.chunk",
Choices: choices,
}
return &response, isStop
}
func geminiResponseToolCall(item *dto.GeminiPart) *dto.ToolCallResponse {
argsBytes, err := kitutil.Marshal(item.FunctionCall.Arguments)
if err != nil {
return nil
}
return &dto.ToolCallResponse{
ID: fmt.Sprintf("call_%s", kitutil.GetUUID()),
Type: "function",
Function: dto.FunctionResponse{
Arguments: string(argsBytes),
Name: item.FunctionCall.FunctionName,
},
}
}
@@ -0,0 +1,15 @@
package jsonutil
import (
"fmt"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
func ToJSONString(v interface{}) string {
bytes, err := kitutil.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(bytes)
}
@@ -0,0 +1,46 @@
package media
import (
"errors"
"sync"
"context"
"github.com/QuantumNous/new-api/relaykit/types"
)
type MediaResolver struct {
GetBase64Data func(c context.Context, source types.FileSource, reason ...string) (string, string, error)
DecodeBase64FileData func(base64String string) (string, string, error)
}
var (
mediaResolverMu sync.RWMutex
mediaResolver MediaResolver
)
func SetMediaResolver(resolver MediaResolver) {
mediaResolverMu.Lock()
defer mediaResolverMu.Unlock()
mediaResolver = resolver
}
func ResolveBase64Data(c context.Context, source types.FileSource, reason ...string) (string, string, error) {
mediaResolverMu.RLock()
resolver := mediaResolver.GetBase64Data
mediaResolverMu.RUnlock()
if resolver == nil {
return "", "", errors.New("relayconvert media resolver is not configured")
}
return resolver(c, source, reason...)
}
func DecodeBase64FileData(base64String string) (string, string, error) {
mediaResolverMu.RLock()
resolver := mediaResolver.DecodeBase64FileData
mediaResolverMu.RUnlock()
if resolver == nil {
return "", "", errors.New("relayconvert media resolver is not configured")
}
return resolver(base64String)
}
@@ -0,0 +1,409 @@
package oaichat
import (
"encoding/json"
"fmt"
"strings"
"context"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
const (
webSearchMaxUsesLow = 1
webSearchMaxUsesMedium = 5
webSearchMaxUsesHigh = 10
)
type openRouterRequestReasoning struct {
Enabled bool `json:"enabled"`
Effort string `json:"effort,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Exclude bool `json:"exclude,omitempty"`
}
func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, textRequest dto.GeneralOpenAIRequest) (*dto.ClaudeRequest, error) {
opts := convmeta.OptionsOf(info)
claudeTools := make([]any, 0, len(textRequest.Tools))
for _, tool := range textRequest.Tools {
if params, ok := tool.Function.Parameters.(map[string]any); ok {
claudeTool := dto.Tool{
Name: tool.Function.Name,
Description: tool.Function.Description,
}
claudeTool.InputSchema = make(map[string]interface{})
if params["type"] != nil {
claudeTool.InputSchema["type"] = params["type"].(string)
}
claudeTool.InputSchema["properties"] = params["properties"]
claudeTool.InputSchema["required"] = params["required"]
for key, value := range params {
if key == "type" || key == "properties" || key == "required" {
continue
}
claudeTool.InputSchema[key] = value
}
claudeTools = append(claudeTools, &claudeTool)
}
}
if textRequest.WebSearchOptions != nil {
webSearchTool := dto.ClaudeWebSearchTool{
Type: "web_search_20250305",
Name: "web_search",
}
if textRequest.WebSearchOptions.UserLocation != nil {
anthropicUserLocation := &dto.ClaudeWebSearchUserLocation{
Type: "approximate",
}
var userLocationMap map[string]interface{}
if err := kitutil.Unmarshal(textRequest.WebSearchOptions.UserLocation, &userLocationMap); err == nil {
if approximateData, ok := userLocationMap["approximate"].(map[string]interface{}); ok {
if timezone, ok := approximateData["timezone"].(string); ok && timezone != "" {
anthropicUserLocation.Timezone = timezone
}
if country, ok := approximateData["country"].(string); ok && country != "" {
anthropicUserLocation.Country = country
}
if region, ok := approximateData["region"].(string); ok && region != "" {
anthropicUserLocation.Region = region
}
if city, ok := approximateData["city"].(string); ok && city != "" {
anthropicUserLocation.City = city
}
}
}
webSearchTool.UserLocation = anthropicUserLocation
}
switch textRequest.WebSearchOptions.SearchContextSize {
case "low":
webSearchTool.MaxUses = webSearchMaxUsesLow
case "medium":
webSearchTool.MaxUses = webSearchMaxUsesMedium
case "high":
webSearchTool.MaxUses = webSearchMaxUsesHigh
}
claudeTools = append(claudeTools, &webSearchTool)
}
claudeRequest := dto.ClaudeRequest{
Model: textRequest.Model,
StopSequences: nil,
Temperature: textRequest.Temperature,
Tools: claudeTools,
}
if maxTokens := textRequest.GetMaxTokens(); maxTokens > 0 {
claudeRequest.MaxTokens = kitutil.GetPointer(maxTokens)
}
if textRequest.TopP != nil {
claudeRequest.TopP = kitutil.GetPointer(*textRequest.TopP)
}
if textRequest.TopK != nil {
claudeRequest.TopK = kitutil.GetPointer(*textRequest.TopK)
}
if textRequest.IsStream(nil) {
claudeRequest.Stream = kitutil.GetPointer(true)
}
if textRequest.ToolChoice != nil || textRequest.ParallelTooCalls != nil {
claudeToolChoice := sharedclaude.MapOpenAIToolChoice(textRequest.ToolChoice, textRequest.ParallelTooCalls)
if claudeToolChoice != nil {
claudeRequest.ToolChoice = claudeToolChoice
}
}
if claudeRequest.MaxTokens == nil || *claudeRequest.MaxTokens == 0 {
if defaultMaxTokens, configured := opts.Claude.DefaultMaxTokensFor(textRequest.Model); configured {
value := uint(defaultMaxTokens)
claudeRequest.MaxTokens = &value
}
}
if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(textRequest.Model); ok && effortLevel != "" &&
(strings.HasPrefix(textRequest.Model, "claude-opus-4-6") ||
strings.HasPrefix(textRequest.Model, "claude-opus-4-7") ||
strings.HasPrefix(textRequest.Model, "claude-opus-4-8")) {
claudeRequest.Model = baseModel
claudeRequest.Thinking = &dto.Thinking{
Type: "adaptive",
}
claudeRequest.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
if strings.HasPrefix(baseModel, "claude-opus-4-7") ||
strings.HasPrefix(baseModel, "claude-opus-4-8") {
claudeRequest.Thinking.Display = "summarized"
claudeRequest.Temperature = nil
claudeRequest.TopP = nil
claudeRequest.TopK = nil
} else {
claudeRequest.TopP = nil
claudeRequest.Temperature = kitutil.GetPointer[float64](1.0)
}
} else if opts.Claude.ThinkingAdapterEnabled &&
strings.HasSuffix(textRequest.Model, "-thinking") {
trimmedModel := strings.TrimSuffix(textRequest.Model, "-thinking")
if strings.HasPrefix(trimmedModel, "claude-opus-4-7") ||
strings.HasPrefix(trimmedModel, "claude-opus-4-8") {
claudeRequest.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"}
claudeRequest.OutputConfig = json.RawMessage(`{"effort":"high"}`)
claudeRequest.Temperature = nil
claudeRequest.TopP = nil
claudeRequest.TopK = nil
} else {
if claudeRequest.MaxTokens == nil || *claudeRequest.MaxTokens < 1280 {
claudeRequest.MaxTokens = kitutil.GetPointer[uint](1280)
}
claudeRequest.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: kitutil.GetPointer[int](int(float64(*claudeRequest.MaxTokens) * opts.Claude.ThinkingAdapterBudgetTokensPercentage)),
}
claudeRequest.TopP = nil
claudeRequest.Temperature = kitutil.GetPointer[float64](1.0)
}
if !opts.ShouldPreserveThinkingSuffix(textRequest.Model) {
claudeRequest.Model = trimmedModel
}
}
if textRequest.ReasoningEffort != "" {
switch textRequest.ReasoningEffort {
case "low":
claudeRequest.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: kitutil.GetPointer[int](1280),
}
case "medium":
claudeRequest.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: kitutil.GetPointer[int](2048),
}
case "high":
claudeRequest.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: kitutil.GetPointer[int](4096),
}
}
}
if textRequest.Reasoning != nil {
var reasoningConfig openRouterRequestReasoning
if err := kitutil.Unmarshal(textRequest.Reasoning, &reasoningConfig); err != nil {
return nil, err
}
budgetTokens := reasoningConfig.MaxTokens
if budgetTokens > 0 {
claudeRequest.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: &budgetTokens,
}
}
}
if textRequest.Stop != nil {
switch stop := textRequest.Stop.(type) {
case string:
claudeRequest.StopSequences = []string{stop}
case []interface{}:
stopSequences := make([]string, 0)
for _, item := range stop {
stopSequences = append(stopSequences, item.(string))
}
claudeRequest.StopSequences = stopSequences
}
}
formatMessages := make([]dto.Message, 0)
lastMessage := dto.Message{
Role: "tool",
}
for i, message := range textRequest.Messages {
if message.Role == "" {
textRequest.Messages[i].Role = "user"
}
fmtMessage := dto.Message{
Role: message.Role,
Content: message.Content,
}
if message.Role == "tool" {
fmtMessage.ToolCallId = message.ToolCallId
}
if message.Role == "assistant" && message.ToolCalls != nil {
fmtMessage.ToolCalls = message.ToolCalls
}
if lastMessage.Role == message.Role && lastMessage.Role != "tool" {
if lastMessage.IsStringContent() && message.IsStringContent() {
fmtMessage.SetStringContent(strings.Trim(fmt.Sprintf("%s %s", lastMessage.StringContent(), message.StringContent()), "\""))
formatMessages = formatMessages[:len(formatMessages)-1]
}
}
if fmtMessage.Content == nil || (fmtMessage.IsStringContent() && fmtMessage.StringContent() == "") {
fmtMessage.SetStringContent("...")
}
formatMessages = append(formatMessages, fmtMessage)
lastMessage = fmtMessage
}
claudeMessages := make([]dto.ClaudeMessage, 0)
isFirstMessage := true
var systemMessages []dto.ClaudeMediaMessage
for _, message := range formatMessages {
if message.Role == "system" {
if message.IsStringContent() {
if text := message.StringContent(); text != "" {
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
Type: "text",
Text: kitutil.GetPointer[string](text),
})
}
} else {
for _, ctx := range message.ParseContent() {
if ctx.Type == "text" && ctx.Text != "" {
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
Type: "text",
Text: kitutil.GetPointer[string](ctx.Text),
})
}
}
}
continue
}
if isFirstMessage {
isFirstMessage = false
if message.Role != "user" {
claudeMessage := dto.ClaudeMessage{
Role: "user",
Content: []dto.ClaudeMediaMessage{
{
Type: "text",
Text: kitutil.GetPointer[string]("..."),
},
},
}
claudeMessages = append(claudeMessages, claudeMessage)
}
}
claudeMessage := dto.ClaudeMessage{
Role: message.Role,
}
if message.Role == "tool" {
if len(claudeMessages) > 0 && claudeMessages[len(claudeMessages)-1].Role == "user" {
lastClaudeMessage := claudeMessages[len(claudeMessages)-1]
if content, ok := lastClaudeMessage.Content.(string); ok {
lastClaudeMessage.Content = []dto.ClaudeMediaMessage{
{
Type: "text",
Text: kitutil.GetPointer[string](content),
},
}
}
lastClaudeMessage.Content = append(lastClaudeMessage.Content.([]dto.ClaudeMediaMessage), dto.ClaudeMediaMessage{
Type: "tool_result",
ToolUseId: message.ToolCallId,
Content: message.Content,
})
claudeMessages[len(claudeMessages)-1] = lastClaudeMessage
continue
}
claudeMessage.Role = "user"
claudeMessage.Content = []dto.ClaudeMediaMessage{
{
Type: "tool_result",
ToolUseId: message.ToolCallId,
Content: message.Content,
},
}
} else if message.IsStringContent() && message.ToolCalls == nil {
text := message.StringContent()
if text == "" {
text = "..."
}
claudeMessage.Content = text
} else {
claudeMediaMessages := make([]dto.ClaudeMediaMessage, 0)
for _, mediaMessage := range message.ParseContent() {
switch mediaMessage.Type {
case "text":
if mediaMessage.Text != "" {
claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{
Type: "text",
Text: kitutil.GetPointer[string](mediaMessage.Text),
})
}
default:
source := mediaMessage.ToFileSource()
if source == nil {
continue
}
base64Data, mimeType, err := relaymedia.ResolveBase64Data(c, source, "formatting image for Claude")
if err != nil {
return nil, fmt.Errorf("get file data failed: %s", err.Error())
}
claudeMediaMessage := dto.ClaudeMediaMessage{
Source: &dto.ClaudeMessageSource{
Type: "base64",
},
}
if strings.HasPrefix(mimeType, "application/pdf") {
claudeMediaMessage.Type = "document"
} else {
claudeMediaMessage.Type = "image"
}
claudeMediaMessage.Source.MediaType = mimeType
claudeMediaMessage.Source.Data = base64Data
claudeMediaMessages = append(claudeMediaMessages, claudeMediaMessage)
continue
}
}
if message.ToolCalls != nil {
for _, toolCall := range message.ParseToolCalls() {
inputObj := make(map[string]any)
if args := toolCall.Function.Arguments; args != "" {
if err := kitutil.Unmarshal([]byte(args), &inputObj); err != nil {
kitutil.LogInfo("tool call function arguments is not a map[string]any: " + fmt.Sprintf("%v", toolCall.Function.Arguments))
}
}
claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{
Type: "tool_use",
Id: toolCall.ID,
Name: toolCall.Function.Name,
Input: inputObj,
})
}
}
claudeMessage.Content = claudeMediaMessages
}
claudeMessages = append(claudeMessages, claudeMessage)
}
if len(systemMessages) > 0 {
claudeRequest.System = systemMessages
}
claudeRequest.Prompt = ""
claudeRequest.Messages = claudeMessages
// Checked last so every injection path (default hook, thinking adapter
// floor) has had its chance to satisfy the required field.
if claudeRequest.MaxTokens == nil {
return nil, sharedclaude.ErrMissingMaxTokens
}
return &claudeRequest, nil
}
@@ -0,0 +1,475 @@
package oaichat
import (
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/reasonmap"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/samber/lo"
)
func generateStopBlock(index int) *dto.ClaudeResponse {
return &dto.ClaudeResponse{
Type: "content_block_stop",
Index: kitutil.GetPointer[int](index),
}
}
func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
if oaiUsage == nil {
return nil
}
if billingUsage := dto.CloneBillingUsage(oaiUsage.BillingUsage); billingUsage != nil && billingUsage.ClaudeUsage != nil {
if billingUsage.Source == dto.BillingUsageSourceClaudeMessages || billingUsage.Semantic == dto.BillingUsageSemanticAnthropic {
return billingUsage.ClaudeUsage
}
}
billingUsage := dto.NewOpenAIChatBillingUsage(oaiUsage)
if existingBillingUsage := dto.CloneBillingUsage(oaiUsage.BillingUsage); existingBillingUsage != nil && existingBillingUsage.OpenAIUsage != nil {
if existingBillingUsage.Source == dto.BillingUsageSourceOAIChat ||
existingBillingUsage.Source == dto.BillingUsageSourceOAIResponses ||
existingBillingUsage.Semantic == dto.BillingUsageSemanticOpenAI {
billingUsage = existingBillingUsage
}
}
cacheCreation5m, cacheCreation1h := NormalizeCacheCreationSplit(
oaiUsage.PromptTokensDetails.CachedCreationTokens,
oaiUsage.ClaudeCacheCreation5mTokens,
oaiUsage.ClaudeCacheCreation1hTokens,
)
cacheCreationTokens := oaiUsage.PromptTokensDetails.CacheCreationTokensTotal()
inputTokens := oaiUsage.PromptTokens
if oaiUsage.PromptTokensDetails.CacheWriteTokens > 0 {
// OpenAI native cache-write usage counts cached and cache-write tokens
// inside prompt_tokens, while Claude semantics reports input_tokens
// excluding both. Both counts are unadjusted prefixes and may overlap,
// so clamp a negative remainder at zero.
inputTokens = oaiUsage.PromptTokens - oaiUsage.PromptTokensDetails.CachedTokens - cacheCreationTokens
if inputTokens < 0 {
inputTokens = 0
}
}
usage := &dto.ClaudeUsage{
InputTokens: inputTokens,
OutputTokens: oaiUsage.CompletionTokens,
CacheCreationInputTokens: cacheCreationTokens,
CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens,
BillingUsage: billingUsage,
}
if cacheCreation5m > 0 || cacheCreation1h > 0 {
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{
Ephemeral5mInputTokens: cacheCreation5m,
Ephemeral1hInputTokens: cacheCreation1h,
}
}
return usage
}
func NormalizeCacheCreationSplit(totalTokens int, tokens5m int, tokens1h int) (int, int) {
remainder := lo.Max([]int{totalTokens - tokens5m - tokens1h, 0})
return tokens5m + remainder, tokens1h
}
func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamResponse, info convmeta.Meta) []*dto.ClaudeResponse {
if info == nil {
info = &convmeta.Values{}
}
state := info.EnsureClaudeConvertInfo()
if state.Done {
return nil
}
var claudeResponses []*dto.ClaudeResponse
// stopOpenBlocks emits the required content_block_stop event(s) for the currently open block(s)
// according to Anthropic's SSE streaming state machine:
// content_block_start -> content_block_delta* -> content_block_stop (per index).
//
// For text/thinking, there is at most one open block at state.Index.
// For tools, OpenAI tool_calls can stream multiple parallel tool_use blocks (indexed from 0),
// so we may have multiple open blocks and must stop each one explicitly.
stopOpenBlocks := func() {
switch state.LastMessagesType {
case convmeta.LastMessageTypeText, convmeta.LastMessageTypeThinking:
claudeResponses = append(claudeResponses, generateStopBlock(state.Index))
case convmeta.LastMessageTypeTools:
base := state.ToolCallBaseIndex
for offset := 0; offset <= state.ToolCallMaxIndexOffset; offset++ {
claudeResponses = append(claudeResponses, generateStopBlock(base+offset))
}
}
}
// stopOpenBlocksAndAdvance closes the currently open block(s) and advances the content block index
// to the next available slot for subsequent content_block_start events.
//
// This prevents invalid streams where a content_block_delta (e.g. thinking_delta) is emitted for an
// index whose active content_block type is different (the typical cause of "Mismatched content block type").
stopOpenBlocksAndAdvance := func() {
if state.LastMessagesType == convmeta.LastMessageTypeNone {
return
}
stopOpenBlocks()
switch state.LastMessagesType {
case convmeta.LastMessageTypeTools:
state.Index = state.ToolCallBaseIndex + state.ToolCallMaxIndexOffset + 1
state.ToolCallBaseIndex = 0
state.ToolCallMaxIndexOffset = 0
default:
state.Index++
}
state.LastMessagesType = convmeta.LastMessageTypeNone
}
if info.GetSendResponseCount() == 1 {
msg := &dto.ClaudeMediaMessage{
Id: openAIResponse.Id,
Model: openAIResponse.Model,
Type: "message",
Role: "assistant",
Usage: &dto.ClaudeUsage{
InputTokens: info.GetEstimatePromptTokens(),
OutputTokens: 0,
},
}
msg.SetContent(make([]any, 0))
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_start",
Message: msg,
})
//claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
// Type: "ping",
//})
if openAIResponse.IsToolCall() {
state.LastMessagesType = convmeta.LastMessageTypeTools
state.ToolCallBaseIndex = 0
state.ToolCallMaxIndexOffset = 0
var toolCall dto.ToolCallResponse
if len(openAIResponse.Choices) > 0 && len(openAIResponse.Choices[0].Delta.ToolCalls) > 0 {
toolCall = openAIResponse.Choices[0].Delta.ToolCalls[0]
} else {
first := openAIResponse.GetFirstToolCall()
if first != nil {
toolCall = *first
} else {
toolCall = dto.ToolCallResponse{}
}
}
resp := &dto.ClaudeResponse{
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Id: toolCall.ID,
Type: "tool_use",
Name: toolCall.Function.Name,
Input: map[string]interface{}{},
},
}
resp.SetIndex(0)
claudeResponses = append(claudeResponses, resp)
// 首块包含工具 delta,则追加 input_json_delta
if toolCall.Function.Arguments != "" {
idx := 0
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "input_json_delta",
PartialJson: &toolCall.Function.Arguments,
},
})
}
} else {
}
// 判断首个响应是否存在内容(非标准的 OpenAI 响应)
if len(openAIResponse.Choices) > 0 {
reasoning := openAIResponse.Choices[0].Delta.GetReasoningContent()
content := openAIResponse.Choices[0].Delta.GetContentString()
if reasoning != "" {
if state.LastMessagesType != convmeta.LastMessageTypeThinking {
stopOpenBlocksAndAdvance()
}
idx := state.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "thinking",
Thinking: kitutil.GetPointer[string](""),
},
})
idx2 := idx
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx2,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "thinking_delta",
Thinking: &reasoning,
},
})
state.LastMessagesType = convmeta.LastMessageTypeThinking
} else if content != "" {
if state.LastMessagesType != convmeta.LastMessageTypeText {
stopOpenBlocksAndAdvance()
}
idx := state.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "text",
Text: kitutil.GetPointer[string](""),
},
})
idx2 := idx
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx2,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "text_delta",
Text: kitutil.GetPointer[string](content),
},
})
state.LastMessagesType = convmeta.LastMessageTypeText
}
}
// 如果首块就带 finish_reason,需要立即发送停止块
if len(openAIResponse.Choices) > 0 && openAIResponse.Choices[0].FinishReason != nil && *openAIResponse.Choices[0].FinishReason != "" {
state.FinishReason = *openAIResponse.Choices[0].FinishReason
stopOpenBlocks()
oaiUsage := openAIResponse.Usage
if oaiUsage == nil {
oaiUsage = state.Usage
}
if oaiUsage != nil {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
Delta: &dto.ClaudeMediaMessage{
StopReason: kitutil.GetPointer[string](stopReasonOpenAI2Claude(state.FinishReason)),
},
})
}
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_stop",
})
state.Done = true
}
return claudeResponses
}
if len(openAIResponse.Choices) == 0 {
// Some OpenAI-compatible upstreams end with a usage-only SSE chunk.
oaiUsage := openAIResponse.Usage
if oaiUsage == nil {
oaiUsage = state.Usage
}
if oaiUsage != nil {
stopOpenBlocks()
stopReason := stopReasonOpenAI2Claude(state.FinishReason)
if stopReason == "" {
stopReason = "end_turn"
}
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
Delta: &dto.ClaudeMediaMessage{
StopReason: kitutil.GetPointer[string](stopReason),
},
})
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_stop",
})
state.Done = true
}
return claudeResponses
} else {
chosenChoice := openAIResponse.Choices[0]
doneChunk := chosenChoice.FinishReason != nil && *chosenChoice.FinishReason != ""
if doneChunk {
state.FinishReason = *chosenChoice.FinishReason
oaiUsage := openAIResponse.Usage
if oaiUsage == nil {
oaiUsage = state.Usage
// Some upstreams emit finish_reason first, then send a final usage-only chunk.
// Defer closing until usage is available so the final message_delta carries it.
return claudeResponses
}
}
var claudeResponse dto.ClaudeResponse
var isEmpty bool
claudeResponse.Type = "content_block_delta"
if len(chosenChoice.Delta.ToolCalls) > 0 {
toolCalls := chosenChoice.Delta.ToolCalls
if state.LastMessagesType != convmeta.LastMessageTypeTools {
stopOpenBlocksAndAdvance()
state.ToolCallBaseIndex = state.Index
state.ToolCallMaxIndexOffset = 0
}
state.LastMessagesType = convmeta.LastMessageTypeTools
base := state.ToolCallBaseIndex
maxOffset := state.ToolCallMaxIndexOffset
for i, toolCall := range toolCalls {
offset := 0
if toolCall.Index != nil {
offset = *toolCall.Index
} else {
offset = i
}
if offset > maxOffset {
maxOffset = offset
}
blockIndex := base + offset
idx := blockIndex
if toolCall.Function.Name != "" {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Id: toolCall.ID,
Type: "tool_use",
Name: toolCall.Function.Name,
Input: map[string]interface{}{},
},
})
}
if len(toolCall.Function.Arguments) > 0 {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "input_json_delta",
PartialJson: &toolCall.Function.Arguments,
},
})
}
}
state.ToolCallMaxIndexOffset = maxOffset
state.Index = base + maxOffset
} else {
reasoning := chosenChoice.Delta.GetReasoningContent()
textContent := chosenChoice.Delta.GetContentString()
if reasoning != "" || textContent != "" {
if reasoning != "" {
if state.LastMessagesType != convmeta.LastMessageTypeThinking {
stopOpenBlocksAndAdvance()
idx := state.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "thinking",
Thinking: kitutil.GetPointer[string](""),
},
})
}
state.LastMessagesType = convmeta.LastMessageTypeThinking
claudeResponse.Delta = &dto.ClaudeMediaMessage{
Type: "thinking_delta",
Thinking: &reasoning,
}
} else {
if state.LastMessagesType != convmeta.LastMessageTypeText {
stopOpenBlocksAndAdvance()
idx := state.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "text",
Text: kitutil.GetPointer[string](""),
},
})
}
state.LastMessagesType = convmeta.LastMessageTypeText
claudeResponse.Delta = &dto.ClaudeMediaMessage{
Type: "text_delta",
Text: kitutil.GetPointer[string](textContent),
}
}
} else {
isEmpty = true
}
}
claudeResponse.Index = kitutil.GetPointer[int](state.Index)
if !isEmpty && claudeResponse.Delta != nil {
claudeResponses = append(claudeResponses, &claudeResponse)
}
if doneChunk || state.Done {
stopOpenBlocks()
oaiUsage := openAIResponse.Usage
if oaiUsage == nil {
oaiUsage = state.Usage
}
if oaiUsage != nil {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
Delta: &dto.ClaudeMediaMessage{
StopReason: kitutil.GetPointer[string](stopReasonOpenAI2Claude(state.FinishReason)),
},
})
}
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_stop",
})
state.Done = true
return claudeResponses
}
}
return claudeResponses
}
func ResponseOpenAI2Claude(openAIResponse *dto.OpenAITextResponse, info convmeta.Meta) *dto.ClaudeResponse {
var stopReason string
contents := make([]dto.ClaudeMediaMessage, 0)
claudeResponse := &dto.ClaudeResponse{
Id: openAIResponse.Id,
Type: "message",
Role: "assistant",
Model: openAIResponse.Model,
}
for _, choice := range openAIResponse.Choices {
stopReason = stopReasonOpenAI2Claude(choice.FinishReason)
textContent := choice.Message.StringContent()
toolCalls := choice.Message.ParseToolCalls()
if textContent != "" || len(toolCalls) == 0 {
claudeContent := dto.ClaudeMediaMessage{}
claudeContent.Type = "text"
claudeContent.SetText(textContent)
contents = append(contents, claudeContent)
}
for _, toolUse := range toolCalls {
claudeContent := dto.ClaudeMediaMessage{}
claudeContent.Type = "tool_use"
claudeContent.Id = toolUse.ID
claudeContent.Name = toolUse.Function.Name
mapParams := map[string]interface{}{}
if strings.TrimSpace(toolUse.Function.Arguments) != "" {
var parsed map[string]interface{}
if err := kitutil.Unmarshal([]byte(toolUse.Function.Arguments), &parsed); err == nil && parsed != nil {
mapParams = parsed
}
}
claudeContent.Input = mapParams
contents = append(contents, claudeContent)
}
}
claudeResponse.Content = contents
claudeResponse.StopReason = stopReason
claudeResponse.Usage = buildClaudeUsageFromOpenAIUsage(&openAIResponse.Usage)
return claudeResponse
}
func stopReasonOpenAI2Claude(reason string) string {
return reasonmap.OpenAIFinishReasonToClaudeStopReason(reason)
}
@@ -0,0 +1,219 @@
package oaichat
import (
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResponseOpenAI2ClaudeToolUseInputIsObject(t *testing.T) {
tests := []struct {
name string
args string
want map[string]interface{}
}{
{name: "object", args: `{"q":"x"}`, want: map[string]interface{}{"q": "x"}},
{name: "empty", args: "", want: map[string]interface{}{}},
{name: "invalid", args: "{", want: map[string]interface{}{}},
{name: "null", args: "null", want: map[string]interface{}{}},
{name: "array", args: `["x"]`, want: map[string]interface{}{}},
{name: "string", args: `"x"`, want: map[string]interface{}{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msg := dto.Message{Role: "assistant"}
msg.SetToolCalls([]dto.ToolCallRequest{
{
ID: "call_1",
Type: "function",
Function: dto.FunctionRequest{
Name: "lookup",
Arguments: tt.args,
},
},
})
resp := ResponseOpenAI2Claude(&dto.OpenAITextResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.OpenAITextResponseChoice{
{Message: msg, FinishReason: "tool_calls"},
},
}, nil)
require.Len(t, resp.Content, 1)
assert.Equal(t, "tool_use", resp.Content[0].Type)
assert.Equal(t, tt.want, resp.Content[0].Input)
})
}
}
func TestResponseOpenAI2ClaudeUsageCarriesOpenAIBillingUsage(t *testing.T) {
resp := ResponseOpenAI2Claude(&dto.OpenAITextResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.OpenAITextResponseChoice{
{Message: dto.Message{Role: "assistant", Content: "hello"}, FinishReason: "stop"},
},
Usage: dto.Usage{
PromptTokens: 11,
CompletionTokens: 5,
TotalTokens: 16,
},
}, nil)
require.NotNil(t, resp.Usage)
assert.Equal(t, 11, resp.Usage.InputTokens)
assert.Equal(t, 5, resp.Usage.OutputTokens)
require.NotNil(t, resp.Usage.BillingUsage)
require.NotNil(t, resp.Usage.BillingUsage.OpenAIUsage)
assert.Equal(t, dto.BillingUsageSourceOAIChat, resp.Usage.BillingUsage.Source)
assert.Equal(t, dto.BillingUsageSemanticOpenAI, resp.Usage.BillingUsage.Semantic)
assert.Equal(t, 11, resp.Usage.BillingUsage.OpenAIUsage.PromptTokens)
assert.Equal(t, 5, resp.Usage.BillingUsage.OpenAIUsage.CompletionTokens)
assert.Equal(t, 16, resp.Usage.BillingUsage.OpenAIUsage.TotalTokens)
assert.Nil(t, resp.Usage.BillingUsage.OpenAIUsage.BillingUsage)
}
func TestBuildClaudeUsageFromOpenAICacheWriteUsage(t *testing.T) {
usage := buildClaudeUsageFromOpenAIUsage(&dto.Usage{
PromptTokens: 3619,
CompletionTokens: 36,
TotalTokens: 3655,
PromptTokensDetails: dto.InputTokenDetails{
CachedTokens: 2921,
CacheWriteTokens: 3616,
},
})
require.NotNil(t, usage)
// Claude semantics reports input_tokens excluding cache read/write; the
// overlapping unadjusted prefixes drive the remainder negative, clamp to 0.
assert.Equal(t, 0, usage.InputTokens)
assert.Equal(t, 2921, usage.CacheReadInputTokens)
assert.Equal(t, 3616, usage.CacheCreationInputTokens)
assert.Equal(t, 36, usage.OutputTokens)
require.NotNil(t, usage.BillingUsage)
require.NotNil(t, usage.BillingUsage.OpenAIUsage)
assert.Equal(t, dto.BillingUsageSemanticOpenAI, usage.BillingUsage.Semantic)
assert.Equal(t, 3616, usage.BillingUsage.OpenAIUsage.PromptTokensDetails.CacheWriteTokens)
}
func TestStreamResponseOpenAI2ClaudeClosesTextThinkingAndToolBlocks(t *testing.T) {
info := &convmeta.Values{
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{
LastMessagesType: convmeta.LastMessageTypeNone,
},
}
info.SendResponseCount = 1
textResponses := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{
{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
Content: ptr("hello"),
},
},
},
}, info)
require.Len(t, textResponses, 3)
assert.Equal(t, "message_start", textResponses[0].Type)
assert.Equal(t, "content_block_start", textResponses[1].Type)
assert.Equal(t, 0, textResponses[1].GetIndex())
assert.Equal(t, "content_block_delta", textResponses[2].Type)
info.SendResponseCount = 2
thinkingResponses := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{
{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
ReasoningContent: ptr("thinking"),
},
},
},
}, info)
require.Len(t, thinkingResponses, 3)
assert.Equal(t, "content_block_stop", thinkingResponses[0].Type)
assert.Equal(t, 0, thinkingResponses[0].GetIndex())
assert.Equal(t, "content_block_start", thinkingResponses[1].Type)
assert.Equal(t, 1, thinkingResponses[1].GetIndex())
assert.Equal(t, "thinking", thinkingResponses[1].ContentBlock.Type)
assert.Equal(t, "content_block_delta", thinkingResponses[2].Type)
info.SendResponseCount = 3
toolResponses := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{
{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
ToolCalls: []dto.ToolCallResponse{
{
Index: ptr(0),
ID: "call_1",
Type: "function",
Function: dto.FunctionResponse{
Name: "lookup",
Arguments: `{"q":"x"}`,
},
},
},
},
},
},
}, info)
require.Len(t, toolResponses, 3)
assert.Equal(t, "content_block_stop", toolResponses[0].Type)
assert.Equal(t, 1, toolResponses[0].GetIndex())
assert.Equal(t, "content_block_start", toolResponses[1].Type)
assert.Equal(t, 2, toolResponses[1].GetIndex())
assert.Equal(t, "tool_use", toolResponses[1].ContentBlock.Type)
assert.Equal(t, "content_block_delta", toolResponses[2].Type)
info.SendResponseCount = 4
finishResponses := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{
{FinishReason: ptr("tool_calls")},
},
Usage: &dto.Usage{
PromptTokens: 7,
CompletionTokens: 3,
TotalTokens: 10,
},
}, info)
require.Len(t, finishResponses, 3)
assert.Equal(t, "content_block_stop", finishResponses[0].Type)
assert.Equal(t, 2, finishResponses[0].GetIndex())
assert.Equal(t, "message_delta", finishResponses[1].Type)
assert.Equal(t, "tool_use", *finishResponses[1].Delta.StopReason)
require.NotNil(t, finishResponses[1].Usage)
require.NotNil(t, finishResponses[1].Usage.BillingUsage)
require.NotNil(t, finishResponses[1].Usage.BillingUsage.OpenAIUsage)
assert.Equal(t, 7, finishResponses[1].Usage.BillingUsage.OpenAIUsage.PromptTokens)
assert.Equal(t, 3, finishResponses[1].Usage.BillingUsage.OpenAIUsage.CompletionTokens)
assert.Equal(t, "message_stop", finishResponses[2].Type)
}
func TestNormalizeCacheCreationSplit(t *testing.T) {
cache5m, cache1h := NormalizeCacheCreationSplit(10, 3, 2)
assert.Equal(t, 8, cache5m)
assert.Equal(t, 2, cache1h)
cache5m, cache1h = NormalizeCacheCreationSplit(3, 5, 1)
assert.Equal(t, 5, cache5m)
assert.Equal(t, 1, cache1h)
}
func ptr[T any](value T) *T {
return &value
}
@@ -0,0 +1,411 @@
package oaichat
import (
"errors"
"fmt"
"strings"
"context"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
sharedgemini "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/gemini"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto.GeneralOpenAIRequest, info convmeta.Meta) (*dto.GeminiChatRequest, error) {
opts := convmeta.OptionsOf(info)
geminiRequest := dto.GeminiChatRequest{
Contents: make([]dto.GeminiChatContent, 0, len(textRequest.Messages)),
GenerationConfig: dto.GeminiChatGenerationConfig{
Temperature: textRequest.Temperature,
},
}
if textRequest.TopP != nil && *textRequest.TopP > 0 {
geminiRequest.GenerationConfig.TopP = kitutil.GetPointer(*textRequest.TopP)
}
if maxTokens := textRequest.GetMaxTokens(); maxTokens > 0 {
geminiRequest.GenerationConfig.MaxOutputTokens = kitutil.GetPointer(maxTokens)
}
if textRequest.Seed != nil && *textRequest.Seed != 0 {
geminiRequest.GenerationConfig.Seed = kitutil.GetPointer(int64(*textRequest.Seed))
}
upstreamModelName := textRequest.Model
if modelName := convmeta.UpstreamModelName(info); modelName != "" {
upstreamModelName = modelName
}
if opts.Gemini.SupportsImagineModel(upstreamModelName) {
geminiRequest.GenerationConfig.ResponseModalities = []string{
"TEXT",
"IMAGE",
}
}
if stopSequences := sharedgemini.ParseStopSequences(textRequest.Stop); len(stopSequences) > 0 {
if len(stopSequences) > 5 {
stopSequences = stopSequences[:5]
}
geminiRequest.GenerationConfig.StopSequences = stopSequences
}
adaptorWithExtraBody := false
if len(textRequest.ExtraBody) > 0 {
var extraBody map[string]interface{}
if err := kitutil.Unmarshal(textRequest.ExtraBody, &extraBody); err != nil {
return nil, fmt.Errorf("invalid extra body: %w", err)
}
if googleBody, ok := extraBody["google"].(map[string]interface{}); ok {
if !strings.HasSuffix(upstreamModelName, "-nothinking") {
adaptorWithExtraBody = true
if _, hasErrorParam := googleBody["thinkingConfig"]; hasErrorParam {
return nil, errors.New("extra_body.google.thinkingConfig is not supported, use extra_body.google.thinking_config instead")
}
if thinkingConfig, ok := googleBody["thinking_config"].(map[string]interface{}); ok {
if _, hasErrorParam := thinkingConfig["thinkingBudget"]; hasErrorParam {
return nil, errors.New("extra_body.google.thinking_config.thinkingBudget is not supported, use extra_body.google.thinking_config.thinking_budget instead")
}
var hasThinkingConfig bool
var tempThinkingConfig dto.GeminiThinkingConfig
if thinkingBudget, exists := thinkingConfig["thinking_budget"]; exists {
switch v := thinkingBudget.(type) {
case float64:
budgetInt := int(v)
tempThinkingConfig.ThinkingBudget = kitutil.GetPointer(budgetInt)
tempThinkingConfig.IncludeThoughts = budgetInt > 0
hasThinkingConfig = true
default:
return nil, errors.New("extra_body.google.thinking_config.thinking_budget must be an integer")
}
}
if includeThoughts, exists := thinkingConfig["include_thoughts"]; exists {
if v, ok := includeThoughts.(bool); ok {
tempThinkingConfig.IncludeThoughts = v
hasThinkingConfig = true
} else {
return nil, errors.New("extra_body.google.thinking_config.include_thoughts must be a boolean")
}
}
if thinkingLevel, exists := thinkingConfig["thinking_level"]; exists {
if v, ok := thinkingLevel.(string); ok {
tempThinkingConfig.ThinkingLevel = v
hasThinkingConfig = true
} else {
return nil, errors.New("extra_body.google.thinking_config.thinking_level must be a string")
}
}
if hasThinkingConfig {
if geminiRequest.GenerationConfig.ThinkingConfig == nil {
geminiRequest.GenerationConfig.ThinkingConfig = &tempThinkingConfig
} else {
if tempThinkingConfig.ThinkingBudget != nil {
geminiRequest.GenerationConfig.ThinkingConfig.ThinkingBudget = tempThinkingConfig.ThinkingBudget
}
geminiRequest.GenerationConfig.ThinkingConfig.IncludeThoughts = tempThinkingConfig.IncludeThoughts
if tempThinkingConfig.ThinkingLevel != "" {
geminiRequest.GenerationConfig.ThinkingConfig.ThinkingLevel = tempThinkingConfig.ThinkingLevel
}
}
}
}
}
if _, hasErrorParam := googleBody["imageConfig"]; hasErrorParam {
return nil, errors.New("extra_body.google.imageConfig is not supported, use extra_body.google.image_config instead")
}
if imageConfig, ok := googleBody["image_config"].(map[string]interface{}); ok {
if _, hasErrorParam := imageConfig["aspectRatio"]; hasErrorParam {
return nil, errors.New("extra_body.google.image_config.aspectRatio is not supported, use extra_body.google.image_config.aspect_ratio instead")
}
if _, hasErrorParam := imageConfig["imageSize"]; hasErrorParam {
return nil, errors.New("extra_body.google.image_config.imageSize is not supported, use extra_body.google.image_config.image_size instead")
}
geminiImageConfig := make(map[string]interface{})
if aspectRatio, ok := imageConfig["aspect_ratio"]; ok {
geminiImageConfig["aspectRatio"] = aspectRatio
}
if imageSize, ok := imageConfig["image_size"]; ok {
geminiImageConfig["imageSize"] = imageSize
}
if len(geminiImageConfig) > 0 {
imageConfigBytes, err := kitutil.Marshal(geminiImageConfig)
if err != nil {
return nil, fmt.Errorf("failed to marshal image_config: %w", err)
}
geminiRequest.GenerationConfig.ImageConfig = imageConfigBytes
}
}
}
}
if !adaptorWithExtraBody {
sharedgemini.ApplyThinkingConfig(&geminiRequest, info, textRequest)
}
var safetySettings []dto.GeminiChatSafetySettings
for _, category := range sharedgemini.SafetySettingCategories {
threshold := opts.Gemini.SafetySettingFor(category)
if threshold == "" {
continue
}
safetySettings = append(safetySettings, dto.GeminiChatSafetySettings{
Category: category,
Threshold: threshold,
})
}
if len(safetySettings) > 0 {
geminiRequest.SafetySettings = safetySettings
}
if textRequest.Tools != nil {
functions := make([]dto.FunctionRequest, 0, len(textRequest.Tools))
googleSearch := false
codeExecution := false
urlContext := false
for _, tool := range textRequest.Tools {
if tool.Function.Name == "googleSearch" {
googleSearch = true
continue
}
if tool.Function.Name == "codeExecution" {
codeExecution = true
continue
}
if tool.Function.Name == "urlContext" {
urlContext = true
continue
}
if tool.Function.Parameters != nil {
if params, ok := tool.Function.Parameters.(map[string]interface{}); ok {
if props, hasProps := params["properties"].(map[string]interface{}); hasProps && len(props) == 0 {
tool.Function.Parameters = nil
}
}
}
tool.Function.Parameters = sharedgemini.CleanFunctionParameters(tool.Function.Parameters)
functions = append(functions, tool.Function)
}
geminiTools := geminiRequest.GetTools()
if codeExecution {
geminiTools = append(geminiTools, dto.GeminiChatTool{
CodeExecution: make(map[string]string),
})
}
if googleSearch {
geminiTools = append(geminiTools, dto.GeminiChatTool{
GoogleSearch: make(map[string]string),
})
}
if urlContext {
geminiTools = append(geminiTools, dto.GeminiChatTool{
URLContext: make(map[string]string),
})
}
if len(functions) > 0 {
geminiTools = append(geminiTools, dto.GeminiChatTool{
FunctionDeclarations: functions,
})
}
geminiRequest.SetTools(geminiTools)
if textRequest.ToolChoice != nil {
geminiRequest.ToolConfig = sharedgemini.OpenAIToolChoiceToConfig(textRequest.ToolChoice)
}
}
if textRequest.ResponseFormat != nil && (textRequest.ResponseFormat.Type == "json_schema" || textRequest.ResponseFormat.Type == "json_object") {
geminiRequest.GenerationConfig.ResponseMimeType = "application/json"
if len(textRequest.ResponseFormat.JsonSchema) > 0 {
var jsonSchema dto.FormatJsonSchema
if err := kitutil.Unmarshal(textRequest.ResponseFormat.JsonSchema, &jsonSchema); err == nil {
cleanedSchema := sharedgemini.RemoveAdditionalProperties(jsonSchema.Schema, 0)
geminiRequest.GenerationConfig.ResponseSchema = cleanedSchema
}
}
}
toolCallIDs := make(map[string]string)
var systemContent []string
for _, message := range textRequest.Messages {
if message.Role == "system" || message.Role == "developer" {
systemContent = append(systemContent, message.StringContent())
continue
}
if message.Role == "tool" || message.Role == "function" {
if len(geminiRequest.Contents) == 0 || geminiRequest.Contents[len(geminiRequest.Contents)-1].Role == "model" {
geminiRequest.Contents = append(geminiRequest.Contents, dto.GeminiChatContent{
Role: "user",
})
}
parts := &geminiRequest.Contents[len(geminiRequest.Contents)-1].Parts
name := ""
if message.Name != nil {
name = *message.Name
} else if val, exists := toolCallIDs[message.ToolCallId]; exists {
name = val
}
var contentMap map[string]interface{}
contentStr := message.StringContent()
if err := kitutil.Unmarshal([]byte(contentStr), &contentMap); err != nil {
var contentSlice []interface{}
if err := kitutil.Unmarshal([]byte(contentStr), &contentSlice); err == nil {
contentMap = map[string]interface{}{"result": contentSlice}
} else {
contentMap = map[string]interface{}{"content": contentStr}
}
}
functionResp := &dto.GeminiFunctionResponse{
Name: name,
Response: contentMap,
}
*parts = append(*parts, dto.GeminiPart{
FunctionResponse: functionResp,
})
continue
}
var parts []dto.GeminiPart
content := dto.GeminiChatContent{
Role: message.Role,
}
shouldAttachThoughtSignature := (message.Role == "assistant" || message.Role == "model") && sharedgemini.ShouldAttachThoughtSignature(opts)
signatureAttached := false
if message.ToolCalls != nil {
for _, call := range message.ParseToolCalls() {
args := map[string]interface{}{}
if call.Function.Arguments != "" {
if kitutil.Unmarshal([]byte(call.Function.Arguments), &args) != nil {
return nil, fmt.Errorf("invalid arguments for function %s, args: %s", call.Function.Name, call.Function.Arguments)
}
}
toolCall := dto.GeminiPart{
FunctionCall: &dto.FunctionCall{
FunctionName: call.Function.Name,
Arguments: args,
},
}
if shouldAttachThoughtSignature && !signatureAttached && sharedgemini.AttachFunctionCallThoughtSignature(opts, &toolCall) {
signatureAttached = true
}
parts = append(parts, toolCall)
toolCallIDs[call.ID] = call.Function.Name
}
}
openaiContent := message.ParseContent()
for _, part := range openaiContent {
if part.Type == dto.ContentTypeText {
if part.Text == "" {
continue
}
text := part.Text
hasMarkdownImage := false
for {
startIdx := strings.Index(text, "![")
if startIdx == -1 {
break
}
bracketIdx := strings.Index(text[startIdx:], "](data:")
if bracketIdx == -1 {
break
}
bracketIdx += startIdx
closeIdx := strings.Index(text[bracketIdx+2:], ")")
if closeIdx == -1 {
break
}
closeIdx += bracketIdx + 2
hasMarkdownImage = true
if startIdx > 0 {
textBefore := text[:startIdx]
if textBefore != "" {
parts = append(parts, dto.GeminiPart{
Text: textBefore,
})
}
}
dataURL := text[bracketIdx+2 : closeIdx]
format, base64String, err := relaymedia.DecodeBase64FileData(dataURL)
if err != nil {
return nil, fmt.Errorf("decode markdown base64 image data failed: %s", err.Error())
}
imgPart := dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: format,
Data: base64String,
},
}
if shouldAttachThoughtSignature {
sharedgemini.AttachThoughtSignatureBypass(opts, &imgPart)
}
parts = append(parts, imgPart)
text = text[closeIdx+1:]
}
if !hasMarkdownImage {
parts = append(parts, dto.GeminiPart{
Text: part.Text,
})
}
} else {
source := part.ToFileSource()
if source == nil {
continue
}
base64Data, mimeType, err := relaymedia.ResolveBase64Data(c, source, "formatting image for Gemini")
if err != nil {
return nil, fmt.Errorf("get file data from '%s' failed: %w", source.GetIdentifier(), err)
}
if _, ok := sharedgemini.SupportedMimeTypes[strings.ToLower(mimeType)]; !ok {
return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", mimeType, source.GetIdentifier(), sharedgemini.SupportedMimeTypesList())
}
parts = append(parts, dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: mimeType,
Data: base64Data,
},
})
}
}
if shouldAttachThoughtSignature && !signatureAttached && len(parts) > 0 {
sharedgemini.AttachFirstTextThoughtSignature(opts, parts)
}
content.Parts = parts
if content.Role == "assistant" {
content.Role = "model"
}
if len(content.Parts) > 0 {
geminiRequest.Contents = append(geminiRequest.Contents, content)
}
}
if len(systemContent) > 0 {
geminiRequest.SystemInstructions = &dto.GeminiChatContent{
Parts: []dto.GeminiPart{
{
Text: strings.Join(systemContent, "\n"),
},
},
}
}
return &geminiRequest, nil
}
@@ -0,0 +1,230 @@
package oaichat
import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
// ResponseOpenAI2Gemini 将 OpenAI 响应转换为 Gemini 格式
func ResponseOpenAI2Gemini(openAIResponse *dto.OpenAITextResponse, info convmeta.Meta) *dto.GeminiChatResponse {
totalTokens := openAIResponse.TotalTokens
if totalTokens == 0 {
totalTokens = openAIResponse.PromptTokens + openAIResponse.CompletionTokens
}
geminiResponse := &dto.GeminiChatResponse{
Candidates: make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices)),
HasUsageMetadata: true,
UsageMetadata: dto.GeminiUsageMetadata{
PromptTokenCount: openAIResponse.PromptTokens,
CandidatesTokenCount: openAIResponse.CompletionTokens,
TotalTokenCount: totalTokens,
BillingUsage: openAIBillingUsageFromUsage(&openAIResponse.Usage),
},
}
if metadata, ok := geminiBillingMetadataFromOpenAIUsage(&openAIResponse.Usage); ok {
geminiResponse.UsageMetadata = metadata
}
for _, choice := range openAIResponse.Choices {
candidate := dto.GeminiChatCandidate{
Index: int64(choice.Index),
SafetyRatings: []dto.GeminiChatSafetyRating{},
}
// 设置结束原因
var finishReason string
switch choice.FinishReason {
case "stop":
finishReason = "STOP"
case "length":
finishReason = "MAX_TOKENS"
case "content_filter":
finishReason = "SAFETY"
case "tool_calls":
finishReason = "STOP"
default:
finishReason = "STOP"
}
candidate.FinishReason = &finishReason
// 转换消息内容
content := dto.GeminiChatContent{
Role: "model",
Parts: make([]dto.GeminiPart, 0),
}
textContent := choice.Message.StringContent()
if textContent != "" {
part := dto.GeminiPart{
Text: textContent,
}
content.Parts = append(content.Parts, part)
}
toolCalls := choice.Message.ParseToolCalls()
for _, toolCall := range toolCalls {
var args map[string]interface{}
if toolCall.Function.Arguments != "" {
if err := kitutil.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil {
args = map[string]interface{}{"arguments": toolCall.Function.Arguments}
}
} else {
args = make(map[string]interface{})
}
part := dto.GeminiPart{
FunctionCall: &dto.FunctionCall{
FunctionName: toolCall.Function.Name,
Arguments: args,
},
}
content.Parts = append(content.Parts, part)
}
candidate.Content = content
geminiResponse.Candidates = append(geminiResponse.Candidates, candidate)
}
return geminiResponse
}
// StreamResponseOpenAI2Gemini 将 OpenAI 流式响应转换为 Gemini 格式
func StreamResponseOpenAI2Gemini(openAIResponse *dto.ChatCompletionsStreamResponse, info convmeta.Meta) *dto.GeminiChatResponse {
// 检查是否有实际内容或结束标志
hasContent := false
hasFinishReason := false
for _, choice := range openAIResponse.Choices {
if len(choice.Delta.GetContentString()) > 0 || (choice.Delta.ToolCalls != nil && len(choice.Delta.ToolCalls) > 0) {
hasContent = true
}
if choice.FinishReason != nil {
hasFinishReason = true
}
}
// 如果没有实际内容且没有结束标志,跳过。主要针对 openai 流响应开头的空数据
if !hasContent && !hasFinishReason {
return nil
}
estimatePromptTokens := 0
if info != nil {
estimatePromptTokens = info.GetEstimatePromptTokens()
}
geminiResponse := &dto.GeminiChatResponse{
Candidates: make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices)),
HasUsageMetadata: true,
UsageMetadata: dto.GeminiUsageMetadata{
PromptTokenCount: estimatePromptTokens,
CandidatesTokenCount: 0, // 流式响应中可能没有完整的 usage 信息
TotalTokenCount: estimatePromptTokens,
},
}
if openAIResponse.Usage != nil {
geminiResponse.UsageMetadata.PromptTokenCount = openAIResponse.Usage.PromptTokens
geminiResponse.UsageMetadata.CandidatesTokenCount = openAIResponse.Usage.CompletionTokens
geminiResponse.UsageMetadata.TotalTokenCount = openAIResponse.Usage.TotalTokens
geminiResponse.UsageMetadata.BillingUsage = openAIBillingUsageFromUsage(openAIResponse.Usage)
if metadata, ok := geminiBillingMetadataFromOpenAIUsage(openAIResponse.Usage); ok {
geminiResponse.UsageMetadata = metadata
}
}
for _, choice := range openAIResponse.Choices {
candidate := dto.GeminiChatCandidate{
Index: int64(choice.Index),
SafetyRatings: []dto.GeminiChatSafetyRating{},
}
// 设置结束原因
if choice.FinishReason != nil {
var finishReason string
switch *choice.FinishReason {
case "stop":
finishReason = "STOP"
case "length":
finishReason = "MAX_TOKENS"
case "content_filter":
finishReason = "SAFETY"
case "tool_calls":
finishReason = "STOP"
default:
finishReason = "STOP"
}
candidate.FinishReason = &finishReason
}
// 转换消息内容
content := dto.GeminiChatContent{
Role: "model",
Parts: make([]dto.GeminiPart, 0),
}
// 处理工具调用
if choice.Delta.ToolCalls != nil {
for _, toolCall := range choice.Delta.ToolCalls {
// 解析参数
var args map[string]interface{}
if toolCall.Function.Arguments != "" {
if err := kitutil.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil {
args = map[string]interface{}{"arguments": toolCall.Function.Arguments}
}
} else {
args = make(map[string]interface{})
}
part := dto.GeminiPart{
FunctionCall: &dto.FunctionCall{
FunctionName: toolCall.Function.Name,
Arguments: args,
},
}
content.Parts = append(content.Parts, part)
}
} else {
// 处理文本内容
textContent := choice.Delta.GetContentString()
if textContent != "" {
part := dto.GeminiPart{
Text: textContent,
}
content.Parts = append(content.Parts, part)
}
}
candidate.Content = content
geminiResponse.Candidates = append(geminiResponse.Candidates, candidate)
}
return geminiResponse
}
func geminiBillingMetadataFromOpenAIUsage(usage *dto.Usage) (dto.GeminiUsageMetadata, bool) {
if usage == nil || usage.BillingUsage == nil || usage.BillingUsage.GeminiUsageMetadata == nil {
return dto.GeminiUsageMetadata{}, false
}
if usage.BillingUsage.Source != dto.BillingUsageSourceGeminiChat && usage.BillingUsage.Semantic != dto.BillingUsageSemanticGemini {
return dto.GeminiUsageMetadata{}, false
}
billingUsage := dto.CloneBillingUsage(usage.BillingUsage)
if billingUsage == nil || billingUsage.GeminiUsageMetadata == nil {
return dto.GeminiUsageMetadata{}, false
}
return *billingUsage.GeminiUsageMetadata, true
}
func openAIBillingUsageFromUsage(usage *dto.Usage) *dto.BillingUsage {
if usage == nil {
return nil
}
if existingBillingUsage := dto.CloneBillingUsage(usage.BillingUsage); existingBillingUsage != nil && existingBillingUsage.OpenAIUsage != nil {
if existingBillingUsage.Source == dto.BillingUsageSourceOAIChat ||
existingBillingUsage.Source == dto.BillingUsageSourceOAIResponses ||
existingBillingUsage.Semantic == dto.BillingUsageSemanticOpenAI {
return existingBillingUsage
}
}
return dto.NewOpenAIChatBillingUsage(usage)
}
@@ -0,0 +1,112 @@
package oaichat
import (
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResponseOpenAI2GeminiMapsTextToolFinishReasonAndUsage(t *testing.T) {
msg := dto.Message{
Role: "assistant",
Content: "hello",
}
msg.SetToolCalls([]dto.ToolCallRequest{
{
ID: "call_1",
Type: "function",
Function: dto.FunctionRequest{
Name: "lookup",
Arguments: `{"q":"x"}`,
},
},
})
resp := ResponseOpenAI2Gemini(&dto.OpenAITextResponse{
Model: "gpt-test",
Choices: []dto.OpenAITextResponseChoice{
{
Index: 2,
Message: msg,
FinishReason: "length",
},
},
Usage: dto.Usage{
PromptTokens: 11,
CompletionTokens: 5,
TotalTokens: 16,
},
}, nil)
assert.Equal(t, 11, resp.UsageMetadata.PromptTokenCount)
assert.Equal(t, 5, resp.UsageMetadata.CandidatesTokenCount)
assert.Equal(t, 16, resp.UsageMetadata.TotalTokenCount)
require.NotNil(t, resp.UsageMetadata.BillingUsage)
require.NotNil(t, resp.UsageMetadata.BillingUsage.OpenAIUsage)
assert.Equal(t, dto.BillingUsageSourceOAIChat, resp.UsageMetadata.BillingUsage.Source)
assert.Equal(t, dto.BillingUsageSemanticOpenAI, resp.UsageMetadata.BillingUsage.Semantic)
assert.Equal(t, 11, resp.UsageMetadata.BillingUsage.OpenAIUsage.PromptTokens)
assert.Equal(t, 5, resp.UsageMetadata.BillingUsage.OpenAIUsage.CompletionTokens)
assert.Equal(t, 16, resp.UsageMetadata.BillingUsage.OpenAIUsage.TotalTokens)
assert.Nil(t, resp.UsageMetadata.BillingUsage.OpenAIUsage.BillingUsage)
require.Len(t, resp.Candidates, 1)
assert.Equal(t, int64(2), resp.Candidates[0].Index)
require.NotNil(t, resp.Candidates[0].FinishReason)
assert.Equal(t, "MAX_TOKENS", *resp.Candidates[0].FinishReason)
require.Len(t, resp.Candidates[0].Content.Parts, 2)
assert.Equal(t, "hello", resp.Candidates[0].Content.Parts[0].Text)
require.NotNil(t, resp.Candidates[0].Content.Parts[1].FunctionCall)
assert.Equal(t, "lookup", resp.Candidates[0].Content.Parts[1].FunctionCall.FunctionName)
assert.Equal(t, map[string]interface{}{"q": "x"}, resp.Candidates[0].Content.Parts[1].FunctionCall.Arguments)
}
func TestStreamResponseOpenAI2GeminiMapsToolCallFinishReasonAndUsage(t *testing.T) {
resp := StreamResponseOpenAI2Gemini(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{
{
Index: 1,
FinishReason: geminiRespPtr("tool_calls"),
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
ToolCalls: []dto.ToolCallResponse{
{
Type: "function",
Function: dto.FunctionResponse{
Name: "lookup",
Arguments: `{"q":"x"}`,
},
},
},
},
},
},
Usage: &dto.Usage{
PromptTokens: 13,
CompletionTokens: 8,
TotalTokens: 21,
},
}, &convmeta.Values{})
require.NotNil(t, resp)
assert.Equal(t, 13, resp.UsageMetadata.PromptTokenCount)
assert.Equal(t, 8, resp.UsageMetadata.CandidatesTokenCount)
assert.Equal(t, 21, resp.UsageMetadata.TotalTokenCount)
require.NotNil(t, resp.UsageMetadata.BillingUsage)
require.NotNil(t, resp.UsageMetadata.BillingUsage.OpenAIUsage)
assert.Equal(t, 13, resp.UsageMetadata.BillingUsage.OpenAIUsage.PromptTokens)
assert.Equal(t, 8, resp.UsageMetadata.BillingUsage.OpenAIUsage.CompletionTokens)
require.Len(t, resp.Candidates, 1)
assert.Equal(t, int64(1), resp.Candidates[0].Index)
require.NotNil(t, resp.Candidates[0].FinishReason)
assert.Equal(t, "STOP", *resp.Candidates[0].FinishReason)
require.Len(t, resp.Candidates[0].Content.Parts, 1)
require.NotNil(t, resp.Candidates[0].Content.Parts[0].FunctionCall)
assert.Equal(t, "lookup", resp.Candidates[0].Content.Parts[0].FunctionCall.FunctionName)
assert.Equal(t, map[string]interface{}{"q": "x"}, resp.Candidates[0].Content.Parts[0].FunctionCall.Arguments)
}
func geminiRespPtr[T any](value T) *T {
return &value
}
@@ -0,0 +1,402 @@
package oaichat
import (
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/samber/lo"
)
func normalizeChatImageURLToString(v any) any {
switch vv := v.(type) {
case string:
return vv
case map[string]any:
if url := kitutil.Interface2String(vv["url"]); url != "" {
return url
}
return v
case dto.MessageImageUrl:
if vv.Url != "" {
return vv.Url
}
return v
case *dto.MessageImageUrl:
if vv != nil && vv.Url != "" {
return vv.Url
}
return v
default:
return v
}
}
func convertChatResponseFormatToResponsesText(reqFormat *dto.ResponseFormat) json.RawMessage {
if reqFormat == nil || strings.TrimSpace(reqFormat.Type) == "" {
return nil
}
format := map[string]any{
"type": reqFormat.Type,
}
if reqFormat.Type == "json_schema" && len(reqFormat.JsonSchema) > 0 {
var chatSchema map[string]any
if err := kitutil.Unmarshal(reqFormat.JsonSchema, &chatSchema); err == nil {
for key, value := range chatSchema {
if key == "type" {
continue
}
format[key] = value
}
if nested, ok := format["json_schema"].(map[string]any); ok {
for key, value := range nested {
if _, exists := format[key]; !exists {
format[key] = value
}
}
delete(format, "json_schema")
}
} else {
format["json_schema"] = reqFormat.JsonSchema
}
}
textRaw, _ := kitutil.Marshal(map[string]any{
"format": format,
})
return textRaw
}
func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*dto.OpenAIResponsesRequest, error) {
if req == nil {
return nil, errors.New("request is nil")
}
if req.Model == "" {
return nil, errors.New("model is required")
}
if lo.FromPtrOr(req.N, 1) > 1 {
return nil, fmt.Errorf("n>1 is not supported in responses compatibility mode")
}
var instructionsParts []string
inputItems := make([]map[string]any, 0, len(req.Messages))
for _, msg := range req.Messages {
role := strings.TrimSpace(msg.Role)
if role == "" {
continue
}
if role == "tool" || role == "function" {
callID := strings.TrimSpace(msg.ToolCallId)
var output any
if msg.Content == nil {
output = ""
} else if msg.IsStringContent() {
output = msg.StringContent()
} else {
if b, err := kitutil.Marshal(msg.Content); err == nil {
output = string(b)
} else {
output = fmt.Sprintf("%v", msg.Content)
}
}
if callID == "" {
inputItems = append(inputItems, map[string]any{
"role": "user",
"content": fmt.Sprintf("[tool_output_missing_call_id] %v", output),
})
continue
}
inputItems = append(inputItems, map[string]any{
"type": "function_call_output",
"call_id": callID,
"output": output,
})
continue
}
// Prefer mapping system/developer messages into `instructions`.
if role == "system" || role == "developer" {
if msg.Content == nil {
continue
}
if msg.IsStringContent() {
if s := strings.TrimSpace(msg.StringContent()); s != "" {
instructionsParts = append(instructionsParts, s)
}
continue
}
parts := msg.ParseContent()
var sb strings.Builder
for _, part := range parts {
if part.Type == dto.ContentTypeText && strings.TrimSpace(part.Text) != "" {
if sb.Len() > 0 {
sb.WriteString("\n")
}
sb.WriteString(part.Text)
}
}
if s := strings.TrimSpace(sb.String()); s != "" {
instructionsParts = append(instructionsParts, s)
}
continue
}
item := map[string]any{
"role": role,
}
if msg.Content == nil {
item["content"] = ""
inputItems = append(inputItems, item)
if role == "assistant" {
for _, tc := range msg.ParseToolCalls() {
if strings.TrimSpace(tc.ID) == "" {
continue
}
if tc.Type != "" && tc.Type != "function" {
continue
}
name := strings.TrimSpace(tc.Function.Name)
if name == "" {
continue
}
inputItems = append(inputItems, map[string]any{
"type": "function_call",
"call_id": tc.ID,
"name": name,
"arguments": tc.Function.Arguments,
})
}
}
continue
}
if msg.IsStringContent() {
item["content"] = msg.StringContent()
inputItems = append(inputItems, item)
if role == "assistant" {
for _, tc := range msg.ParseToolCalls() {
if strings.TrimSpace(tc.ID) == "" {
continue
}
if tc.Type != "" && tc.Type != "function" {
continue
}
name := strings.TrimSpace(tc.Function.Name)
if name == "" {
continue
}
inputItems = append(inputItems, map[string]any{
"type": "function_call",
"call_id": tc.ID,
"name": name,
"arguments": tc.Function.Arguments,
})
}
}
continue
}
parts := msg.ParseContent()
contentParts := make([]map[string]any, 0, len(parts))
for _, part := range parts {
switch part.Type {
case dto.ContentTypeText:
textType := "input_text"
if role == "assistant" {
textType = "output_text"
}
contentParts = append(contentParts, map[string]any{
"type": textType,
"text": part.Text,
})
case dto.ContentTypeImageURL:
contentParts = append(contentParts, map[string]any{
"type": "input_image",
"image_url": normalizeChatImageURLToString(part.ImageUrl),
})
case dto.ContentTypeInputAudio:
contentParts = append(contentParts, map[string]any{
"type": "input_audio",
"input_audio": part.InputAudio,
})
case dto.ContentTypeFile:
contentParts = append(contentParts, map[string]any{
"type": "input_file",
"file": part.File,
})
case dto.ContentTypeVideoUrl:
contentParts = append(contentParts, map[string]any{
"type": "input_video",
"video_url": part.VideoUrl,
})
default:
contentParts = append(contentParts, map[string]any{
"type": part.Type,
})
}
}
item["content"] = contentParts
inputItems = append(inputItems, item)
if role == "assistant" {
for _, tc := range msg.ParseToolCalls() {
if strings.TrimSpace(tc.ID) == "" {
continue
}
if tc.Type != "" && tc.Type != "function" {
continue
}
name := strings.TrimSpace(tc.Function.Name)
if name == "" {
continue
}
inputItems = append(inputItems, map[string]any{
"type": "function_call",
"call_id": tc.ID,
"name": name,
"arguments": tc.Function.Arguments,
})
}
}
}
inputRaw, err := kitutil.Marshal(inputItems)
if err != nil {
return nil, err
}
var instructionsRaw json.RawMessage
if len(instructionsParts) > 0 {
instructions := strings.Join(instructionsParts, "\n\n")
instructionsRaw, _ = kitutil.Marshal(instructions)
}
var toolsRaw json.RawMessage
if req.Tools != nil {
tools := make([]map[string]any, 0, len(req.Tools))
for _, tool := range req.Tools {
switch tool.Type {
case "function":
tools = append(tools, map[string]any{
"type": "function",
"name": tool.Function.Name,
"description": tool.Function.Description,
"parameters": tool.Function.Parameters,
})
default:
// Best-effort: keep original tool shape for unknown types.
var m map[string]any
if b, err := kitutil.Marshal(tool); err == nil {
_ = kitutil.Unmarshal(b, &m)
}
if len(m) == 0 {
m = map[string]any{"type": tool.Type}
}
tools = append(tools, m)
}
}
toolsRaw, _ = kitutil.Marshal(tools)
}
var toolChoiceRaw json.RawMessage
if req.ToolChoice != nil {
switch v := req.ToolChoice.(type) {
case string:
toolChoiceRaw, _ = kitutil.Marshal(v)
default:
var m map[string]any
if b, err := kitutil.Marshal(v); err == nil {
_ = kitutil.Unmarshal(b, &m)
}
if m == nil {
toolChoiceRaw, _ = kitutil.Marshal(v)
} else if t, _ := m["type"].(string); t == "function" {
// Chat: {"type":"function","function":{"name":"..."}}
// Responses: {"type":"function","name":"..."}
if name, ok := m["name"].(string); ok && name != "" {
toolChoiceRaw, _ = kitutil.Marshal(map[string]any{
"type": "function",
"name": name,
})
} else if fn, ok := m["function"].(map[string]any); ok {
if name, ok := fn["name"].(string); ok && name != "" {
toolChoiceRaw, _ = kitutil.Marshal(map[string]any{
"type": "function",
"name": name,
})
} else {
toolChoiceRaw, _ = kitutil.Marshal(v)
}
} else {
toolChoiceRaw, _ = kitutil.Marshal(v)
}
} else {
toolChoiceRaw, _ = kitutil.Marshal(v)
}
}
}
var parallelToolCallsRaw json.RawMessage
if req.ParallelTooCalls != nil {
parallelToolCallsRaw, _ = kitutil.Marshal(*req.ParallelTooCalls)
}
textRaw := convertChatResponseFormatToResponsesText(req.ResponseFormat)
maxOutputTokens := lo.FromPtrOr(req.MaxTokens, uint(0))
maxCompletionTokens := lo.FromPtrOr(req.MaxCompletionTokens, uint(0))
if maxCompletionTokens > maxOutputTokens {
maxOutputTokens = maxCompletionTokens
}
// OpenAI Responses API rejects max_output_tokens < 16 when explicitly provided.
//if maxOutputTokens > 0 && maxOutputTokens < 16 {
// maxOutputTokens = 16
//}
var topP *float64
if req.TopP != nil {
topP = kitutil.GetPointer(lo.FromPtr(req.TopP))
}
out := &dto.OpenAIResponsesRequest{
Model: req.Model,
Input: inputRaw,
Instructions: instructionsRaw,
Stream: req.Stream,
Temperature: req.Temperature,
Text: textRaw,
ToolChoice: toolChoiceRaw,
Tools: toolsRaw,
TopP: topP,
User: req.User,
ParallelToolCalls: parallelToolCallsRaw,
Store: req.Store,
Metadata: req.Metadata,
}
if req.MaxTokens != nil || req.MaxCompletionTokens != nil {
out.MaxOutputTokens = lo.ToPtr(maxOutputTokens)
}
if req.ReasoningEffort != "" {
out.Reasoning = &dto.Reasoning{
Effort: req.ReasoningEffort,
Summary: "detailed",
}
}
return out, nil
}
@@ -0,0 +1,62 @@
package oaichat
import (
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestChatCompletionsRequestToResponsesRequestInstructionsAndTools(t *testing.T) {
req := &dto.GeneralOpenAIRequest{
Model: "gpt-test",
N: lo.ToPtr(1),
Messages: []dto.Message{
{Role: "system", Content: "system rules"},
{Role: "developer", Content: "developer rules"},
{Role: "user", Content: []any{
map[string]any{"type": "text", "text": "look"},
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://example.test/a.png"}},
}},
assistantMessageWithTool("partial text", "call_1", "lookup", `{"q":"x"}`),
{Role: "tool", ToolCallId: "call_1", Content: "tool result"},
},
}
got, err := ChatCompletionsRequestToResponsesRequest(req)
require.NoError(t, err)
assert.Equal(t, "gpt-test", got.Model)
assert.Equal(t, `"system rules\n\ndeveloper rules"`, string(got.Instructions))
assert.Equal(t, "input_image", gjson.GetBytes(got.Input, "0.content.1.type").String())
assert.Equal(t, "function_call", gjson.GetBytes(got.Input, "2.type").String())
assert.Equal(t, "call_1", gjson.GetBytes(got.Input, "2.call_id").String())
assert.Equal(t, "function_call_output", gjson.GetBytes(got.Input, "3.type").String())
}
func TestChatCompletionsRequestToResponsesRequestRejectsMultipleChoices(t *testing.T) {
_, err := ChatCompletionsRequestToResponsesRequest(&dto.GeneralOpenAIRequest{
Model: "gpt-test",
N: lo.ToPtr(2),
})
require.Error(t, err)
assert.Contains(t, err.Error(), "n>1")
}
func assistantMessageWithTool(content string, id string, name string, args string) dto.Message {
msg := dto.Message{Role: "assistant", Content: content}
msg.SetToolCalls([]dto.ToolCallRequest{
{
ID: id,
Type: "function",
Function: dto.FunctionRequest{
Name: name,
Arguments: args,
},
},
})
return msg
}
@@ -0,0 +1,232 @@
package oaichat
import (
"errors"
"fmt"
"strings"
"time"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
const (
chatFinishReasonLength = "length"
chatFinishReasonContentFilter = "content_filter"
responsesEventCreated = "response.created"
responsesEventCompleted = "response.completed"
responsesEventIncomplete = "response.incomplete"
responsesEventOutputTextDelta = "response.output_text.delta"
responsesEventOutputItemAdded = "response.output_item.added"
responsesEventOutputItemDone = "response.output_item.done"
responsesEventFunctionArgsDelta = "response.function_call_arguments.delta"
responsesEventFunctionArgsDone = "response.function_call_arguments.done"
responsesEventReasoningSummaryDelta = "response.reasoning_summary_text.delta"
responsesEventReasoningSummaryDone = "response.reasoning_summary_text.done"
responsesOutputTypeFunctionCall = "function_call"
responsesOutputTypeMessage = "message"
responsesOutputTypeReasoning = "reasoning"
responsesIncompleteReasonContentFilter = "content_filter"
responsesIncompleteReasonMaxTokens = "max_output_tokens"
)
func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id string) (*dto.OpenAIResponsesResponse, *dto.Usage, error) {
if resp == nil {
return nil, nil, errors.New("response is nil")
}
usage := UsageFromChatUsage(&resp.Usage)
out := &dto.OpenAIResponsesResponse{
ID: id,
Object: "response",
CreatedAt: chatCreatedAt(resp.Created),
Status: []byte(`"completed"`),
Model: resp.Model,
Output: make([]dto.ResponsesOutput, 0),
Usage: usage,
}
if len(resp.Choices) == 0 {
return out, usage, nil
}
choice := resp.Choices[0]
if status, details := ResponsesStatusFromChatFinishReason(choice.FinishReason); status != "" {
out.Status = []byte(fmt.Sprintf("%q", status))
out.IncompleteDetails = details
}
if text := choice.Message.StringContent(); text != "" {
out.Output = append(out.Output, dto.ResponsesOutput{
Type: responsesOutputTypeMessage,
ID: fmt.Sprintf("%s_msg_0", id),
Status: responseOutputStatus(out),
Role: "assistant",
Content: []dto.ResponsesOutputContent{
{
Type: "output_text",
Text: text,
Annotations: []interface{}{},
},
},
})
}
if reasoning := choice.Message.GetReasoningContent(); reasoning != "" {
out.Output = append(out.Output, dto.ResponsesOutput{
Type: responsesOutputTypeReasoning,
ID: fmt.Sprintf("%s_reasoning_0", id),
Status: responseOutputStatus(out),
Content: []dto.ResponsesOutputContent{
{
Type: "summary_text",
Text: reasoning,
},
},
})
}
for i, toolCall := range choice.Message.ParseToolCalls() {
toolOutput, err := chatToolCallToResponsesOutput(toolCall, id, i, responseOutputStatus(out))
if err != nil {
return nil, nil, err
}
out.Output = append(out.Output, toolOutput)
}
return out, usage, nil
}
func ResponsesStatusFromChatFinishReason(finishReason string) (string, *dto.IncompleteDetails) {
switch strings.TrimSpace(finishReason) {
case chatFinishReasonLength:
return "incomplete", &dto.IncompleteDetails{Reason: responsesIncompleteReasonMaxTokens}
case chatFinishReasonContentFilter:
return "incomplete", &dto.IncompleteDetails{Reason: responsesIncompleteReasonContentFilter}
default:
return "completed", nil
}
}
func UsageFromChatUsage(src *dto.Usage) *dto.Usage {
usage := &dto.Usage{}
if src == nil {
return usage
}
usage.UsageSemantic = src.UsageSemantic
usage.UsageSource = src.UsageSource
usage.BillingUsage = dto.CloneBillingUsage(src.BillingUsage)
if usage.BillingUsage == nil {
usage.BillingUsage = dto.NewOpenAIChatBillingUsage(src)
}
usage.Cost = src.Cost
if src.PromptTokens != 0 {
usage.PromptTokens = src.PromptTokens
usage.InputTokens = src.PromptTokens
}
if src.CompletionTokens != 0 {
usage.CompletionTokens = src.CompletionTokens
usage.OutputTokens = src.CompletionTokens
}
if src.TotalTokens != 0 {
usage.TotalTokens = src.TotalTokens
} else {
usage.TotalTokens = usage.InputTokens + usage.OutputTokens
}
if src.PromptTokensDetails.CachedTokens != 0 ||
src.PromptTokensDetails.ImageTokens != 0 ||
src.PromptTokensDetails.AudioTokens != 0 ||
src.PromptTokensDetails.CachedCreationTokens != 0 ||
src.PromptTokensDetails.CacheWriteTokens != 0 ||
src.PromptTokensDetails.TextTokens != 0 {
details := src.PromptTokensDetails
usage.InputTokensDetails = &details
}
if src.CompletionTokenDetails.ReasoningTokens != 0 ||
src.CompletionTokenDetails.TextTokens != 0 ||
src.CompletionTokenDetails.AudioTokens != 0 ||
src.CompletionTokenDetails.ImageTokens != 0 {
usage.CompletionTokenDetails = src.CompletionTokenDetails
}
usage.ClaudeCacheCreation5mTokens = src.ClaudeCacheCreation5mTokens
usage.ClaudeCacheCreation1hTokens = src.ClaudeCacheCreation1hTokens
return usage
}
func responseOutputStatus(resp *dto.OpenAIResponsesResponse) string {
if resp == nil || responseStatusString(resp) != "incomplete" {
return "completed"
}
return "incomplete"
}
func responseStatusString(resp *dto.OpenAIResponsesResponse) string {
if resp == nil || len(resp.Status) == 0 {
return ""
}
var status string
_ = kitutil.Unmarshal(resp.Status, &status)
return strings.TrimSpace(status)
}
func chatToolCallToResponsesOutput(toolCall dto.ToolCallRequest, responseID string, index int, status string) (dto.ResponsesOutput, error) {
callID := strings.TrimSpace(toolCall.ID)
if callID == "" {
callID = fmt.Sprintf("%s_call_%d", responseID, index)
}
if toolCall.Type == "" || toolCall.Type == "function" {
return dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: callID,
Status: status,
CallId: callID,
Name: toolCall.Function.Name,
Arguments: chatArgumentsRawMessage(toolCall.Function.Arguments),
}, nil
}
return dto.ResponsesOutput{
Type: toolCall.Type,
ID: callID,
Status: status,
CallId: callID,
Arguments: toolCall.Custom,
}, nil
}
func chatArgumentsRawMessage(arguments string) []byte {
raw, err := kitutil.Marshal(arguments)
if err != nil {
return []byte(`""`)
}
return raw
}
func chatCreatedAt(created any) int {
switch v := created.(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
case float32:
return int(v)
case string:
if parsed := kitutil.String2Int(v); parsed != 0 {
return parsed
}
}
return int(time.Now().Unix())
}
func responsesStreamEvent(eventType string, payload dto.ResponsesStreamResponse) ChatToResponsesStreamEvent {
payload.Type = eventType
return ChatToResponsesStreamEvent{
Type: eventType,
Payload: payload,
}
}
func intPtr(v int) *int {
return &v
}
@@ -0,0 +1,140 @@
package oaichat
import (
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestChatCompletionsResponseToResponsesPreservesTextToolCallsAndUsage(t *testing.T) {
chat := &dto.OpenAITextResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Created: 456,
Choices: []dto.OpenAITextResponseChoice{
{
Message: assistantMessageWithTool("I will call.", "call_1", "lookup", `{"q":"x"}`),
FinishReason: "tool_calls",
},
},
Usage: dto.Usage{PromptTokens: 3, CompletionTokens: 5, TotalTokens: 8},
}
resp, usage, err := ChatCompletionsResponseToResponsesResponse(chat, "resp_1")
require.NoError(t, err)
require.NotNil(t, usage)
assert.Equal(t, "resp_1", resp.ID)
assert.Equal(t, "response", resp.Object)
assert.Equal(t, `"completed"`, string(resp.Status))
assert.Equal(t, 3, resp.Usage.InputTokens)
assert.Equal(t, 5, resp.Usage.OutputTokens)
require.Len(t, resp.Output, 2)
assert.Equal(t, responsesOutputTypeMessage, resp.Output[0].Type)
assert.Equal(t, "I will call.", resp.Output[0].Content[0].Text)
assert.Equal(t, responsesOutputTypeFunctionCall, resp.Output[1].Type)
assert.Equal(t, "call_1", resp.Output[1].CallId)
assert.Equal(t, "lookup", resp.Output[1].Name)
assert.Equal(t, `"{\"q\":\"x\"}"`, string(resp.Output[1].Arguments))
}
func TestChatCompletionsResponseToResponsesMapsIncompleteFinishReasons(t *testing.T) {
tests := []struct {
name string
finishReason string
wantReason string
}{
{name: "length", finishReason: "length", wantReason: responsesIncompleteReasonMaxTokens},
{name: "content filter", finishReason: "content_filter", wantReason: responsesIncompleteReasonContentFilter},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, _, err := ChatCompletionsResponseToResponsesResponse(&dto.OpenAITextResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.OpenAITextResponseChoice{
{
Message: dto.Message{Role: "assistant", Content: "partial"},
FinishReason: tt.finishReason,
},
},
}, "resp_1")
require.NoError(t, err)
assert.Equal(t, `"incomplete"`, string(resp.Status))
require.NotNil(t, resp.IncompleteDetails)
assert.Equal(t, tt.wantReason, resp.IncompleteDetails.Reason)
require.Len(t, resp.Output, 1)
assert.Equal(t, "incomplete", resp.Output[0].Status)
})
}
}
func TestChatCompletionsStreamToResponsesEventsAggregatesUsageAndToolArgs(t *testing.T) {
state := NewChatToResponsesStreamState("resp_1", "gpt-test")
state.Created = 123
toolIndex := 0
var events []ChatToResponsesStreamEvent
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Created: 123,
Choices: []dto.ChatCompletionsStreamResponseChoice{
{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Role: "assistant"}},
},
})...)
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{
{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: lo.ToPtr("hello")}},
},
})...)
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{
{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{
{Index: &toolIndex, ID: "call_1", Type: "function", Function: dto.FunctionResponse{Name: "lookup"}},
}}},
},
})...)
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{
{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{
{Index: &toolIndex, Function: dto.FunctionResponse{Arguments: `{"q":"x"}`}},
}}},
},
})...)
finishReason := "tool_calls"
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{
{Index: 0, FinishReason: &finishReason},
},
})...)
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
Usage: &dto.Usage{PromptTokens: 2, CompletionTokens: 4, TotalTokens: 6},
})...)
events = append(events, FinalizeChatCompletionsStreamToResponses(state)...)
require.Len(t, events, 10)
assert.Equal(t, responsesEventCreated, events[0].Type)
assert.Equal(t, responsesEventOutputTextDelta, events[2].Type)
assert.Equal(t, "hello", events[2].Payload.Delta)
assert.Equal(t, responsesEventFunctionArgsDelta, events[4].Type)
assert.Equal(t, `{"q":"x"}`, events[4].Payload.Delta)
assert.Equal(t, responsesEventCompleted, events[9].Type)
require.NotNil(t, events[9].Payload.Response)
assert.Equal(t, 6, events[9].Payload.Response.Usage.TotalTokens)
require.Len(t, events[9].Payload.Response.Output, 2)
assert.Equal(t, "hello", events[9].Payload.Response.Output[0].Content[0].Text)
assert.Equal(t, `"{\"q\":\"x\"}"`, string(events[9].Payload.Response.Output[1].Arguments))
}
func mustResponsesEventsFromChatChunk(t *testing.T, state *ChatToResponsesStreamState, chunk *dto.ChatCompletionsStreamResponse) []ChatToResponsesStreamEvent {
t.Helper()
events, err := ChatCompletionsStreamChunkToResponsesEvents(chunk, state)
require.NoError(t, err)
return events
}
@@ -0,0 +1,417 @@
package oaichat
import (
"fmt"
"sort"
"strings"
"time"
"github.com/QuantumNous/new-api/relaykit/dto"
)
type ChatToResponsesStreamEvent struct {
Type string
Payload dto.ResponsesStreamResponse
}
type ChatToResponsesStreamState struct {
ID string
Model string
Created int64
Usage *dto.Usage
status string
incompleteDetails *dto.IncompleteDetails
sentCreated bool
textOutputIndex int
textStarted bool
textDone bool
reasoningIndex int
reasoningStarted bool
reasoningDone bool
finalized bool
nextOutputIndex int
toolsByIndex map[int]*chatToResponsesStreamTool
outputOrder []chatToResponsesOutputRef
text strings.Builder
reasoning strings.Builder
}
type chatToResponsesStreamTool struct {
ChatIndex int
OutputIndex int
ID string
Name string
Arguments strings.Builder
Done bool
}
type chatToResponsesOutputRef struct {
Kind string
ToolIndex int
}
func NewChatToResponsesStreamState(id string, model string) *ChatToResponsesStreamState {
return &ChatToResponsesStreamState{
ID: id,
Model: model,
Created: time.Now().Unix(),
Usage: &dto.Usage{},
status: "completed",
textOutputIndex: -1,
reasoningIndex: -1,
toolsByIndex: make(map[int]*chatToResponsesStreamTool),
}
}
func ChatCompletionsStreamChunkToResponsesEvents(chunk *dto.ChatCompletionsStreamResponse, state *ChatToResponsesStreamState) ([]ChatToResponsesStreamEvent, error) {
if chunk == nil || state == nil {
return nil, nil
}
if state.ID == "" {
state.ID = chunk.Id
}
if state.Model == "" {
state.Model = chunk.Model
}
if state.Created == 0 {
state.Created = chunk.Created
}
if chunk.Usage != nil {
state.Usage = UsageFromChatUsage(chunk.Usage)
}
events := make([]ChatToResponsesStreamEvent, 0)
if !state.sentCreated {
state.sentCreated = true
events = append(events, responsesStreamEvent(responsesEventCreated, dto.ResponsesStreamResponse{
Type: responsesEventCreated,
Response: state.createdResponse(),
}))
}
for _, choice := range chunk.Choices {
if choice.Delta.GetReasoningContent() != "" {
events = append(events, state.appendReasoningDelta(choice.Delta.GetReasoningContent())...)
}
if choice.Delta.GetContentString() != "" {
events = append(events, state.appendTextDelta(choice.Delta.GetContentString())...)
}
for _, toolCall := range choice.Delta.ToolCalls {
toolEvents, err := state.appendToolCallDelta(toolCall)
if err != nil {
return nil, err
}
events = append(events, toolEvents...)
}
if choice.FinishReason != nil && strings.TrimSpace(*choice.FinishReason) != "" {
state.applyFinishReason(*choice.FinishReason)
events = append(events, state.doneDeltaEvents()...)
}
}
return events, nil
}
func FinalizeChatCompletionsStreamToResponses(state *ChatToResponsesStreamState) []ChatToResponsesStreamEvent {
if state == nil || state.finalized {
return nil
}
events := state.doneDeltaEvents()
state.finalized = true
resp := state.finalResponse()
eventType := responsesEventCompleted
if state.status == "incomplete" {
eventType = responsesEventIncomplete
}
events = append(events, responsesStreamEvent(eventType, dto.ResponsesStreamResponse{
Type: eventType,
Response: resp,
}))
return events
}
func (s *ChatToResponsesStreamState) UsageText() string {
if s == nil {
return ""
}
return s.text.String()
}
func (s *ChatToResponsesStreamState) appendTextDelta(delta string) []ChatToResponsesStreamEvent {
events := make([]ChatToResponsesStreamEvent, 0, 2)
if !s.textStarted {
s.textStarted = true
s.textOutputIndex = s.nextIndex("message", -1)
events = append(events, responsesStreamEvent(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: intPtr(s.textOutputIndex),
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeMessage,
ID: s.messageID(),
Status: "in_progress",
Role: "assistant",
Content: []dto.ResponsesOutputContent{},
},
}))
}
s.text.WriteString(delta)
events = append(events, responsesStreamEvent(responsesEventOutputTextDelta, dto.ResponsesStreamResponse{
Type: responsesEventOutputTextDelta,
OutputIndex: intPtr(s.textOutputIndex),
ContentIndex: intPtr(0),
Delta: delta,
ItemID: s.messageID(),
}))
return events
}
func (s *ChatToResponsesStreamState) appendReasoningDelta(delta string) []ChatToResponsesStreamEvent {
events := make([]ChatToResponsesStreamEvent, 0, 2)
if !s.reasoningStarted {
s.reasoningStarted = true
s.reasoningIndex = s.nextIndex("reasoning", -1)
events = append(events, responsesStreamEvent(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: intPtr(s.reasoningIndex),
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeReasoning,
ID: s.reasoningID(),
Status: "in_progress",
Content: []dto.ResponsesOutputContent{},
},
}))
}
s.reasoning.WriteString(delta)
events = append(events, responsesStreamEvent(responsesEventReasoningSummaryDelta, dto.ResponsesStreamResponse{
Type: responsesEventReasoningSummaryDelta,
OutputIndex: intPtr(s.reasoningIndex),
SummaryIndex: intPtr(0),
Delta: delta,
ItemID: s.reasoningID(),
}))
return events
}
func (s *ChatToResponsesStreamState) appendToolCallDelta(toolCall dto.ToolCallResponse) ([]ChatToResponsesStreamEvent, error) {
chatIndex := 0
if toolCall.Index != nil {
chatIndex = *toolCall.Index
}
tool := s.toolsByIndex[chatIndex]
events := make([]ChatToResponsesStreamEvent, 0, 2)
if tool == nil {
tool = &chatToResponsesStreamTool{
ChatIndex: chatIndex,
OutputIndex: s.nextIndex("tool", chatIndex),
ID: strings.TrimSpace(toolCall.ID),
Name: strings.TrimSpace(toolCall.Function.Name),
}
if tool.ID == "" {
tool.ID = fmt.Sprintf("%s_call_%d", s.ID, chatIndex)
}
s.toolsByIndex[chatIndex] = tool
events = append(events, responsesStreamEvent(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.ID,
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: tool.ID,
Status: "in_progress",
CallId: tool.ID,
Name: tool.Name,
Arguments: []byte(`""`),
},
}))
}
if strings.TrimSpace(toolCall.ID) != "" {
tool.ID = strings.TrimSpace(toolCall.ID)
}
if strings.TrimSpace(toolCall.Function.Name) != "" {
tool.Name = strings.TrimSpace(toolCall.Function.Name)
}
if toolCall.Function.Arguments != "" {
tool.Arguments.WriteString(toolCall.Function.Arguments)
events = append(events, responsesStreamEvent(responsesEventFunctionArgsDelta, dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDelta,
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.ID,
Delta: toolCall.Function.Arguments,
}))
}
return events, nil
}
func (s *ChatToResponsesStreamState) doneDeltaEvents() []ChatToResponsesStreamEvent {
events := make([]ChatToResponsesStreamEvent, 0)
status := s.outputStatus()
if s.textStarted && !s.textDone {
s.textDone = true
events = append(events, responsesStreamEvent("response.output_text.done", dto.ResponsesStreamResponse{
Type: "response.output_text.done",
OutputIndex: intPtr(s.textOutputIndex),
ContentIndex: intPtr(0),
ItemID: s.messageID(),
}))
events = append(events, responsesStreamEvent(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemDone,
OutputIndex: intPtr(s.textOutputIndex),
Item: s.messageOutput(status),
}))
}
if s.reasoningStarted && !s.reasoningDone {
s.reasoningDone = true
events = append(events, responsesStreamEvent(responsesEventReasoningSummaryDone, dto.ResponsesStreamResponse{
Type: responsesEventReasoningSummaryDone,
OutputIndex: intPtr(s.reasoningIndex),
SummaryIndex: intPtr(0),
ItemID: s.reasoningID(),
Part: &dto.ResponsesReasoningSummaryPart{
Type: "summary_text",
Text: s.reasoning.String(),
},
}))
events = append(events, responsesStreamEvent(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemDone,
OutputIndex: intPtr(s.reasoningIndex),
Item: s.reasoningOutput(status),
}))
}
for _, tool := range s.sortedTools() {
if tool.Done {
continue
}
tool.Done = true
events = append(events, responsesStreamEvent(responsesEventFunctionArgsDone, dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDone,
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.ID,
}))
events = append(events, responsesStreamEvent(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemDone,
OutputIndex: intPtr(tool.OutputIndex),
Item: s.toolOutput(tool, status),
}))
}
return events
}
func (s *ChatToResponsesStreamState) applyFinishReason(finishReason string) {
if status, details := ResponsesStatusFromChatFinishReason(finishReason); status != "" {
s.status = status
s.incompleteDetails = details
}
}
func (s *ChatToResponsesStreamState) finalResponse() *dto.OpenAIResponsesResponse {
output := make([]dto.ResponsesOutput, 0, len(s.outputOrder))
status := s.outputStatus()
for _, ref := range s.outputOrder {
switch ref.Kind {
case "message":
output = append(output, *s.messageOutput(status))
case "reasoning":
output = append(output, *s.reasoningOutput(status))
case "tool":
if tool := s.toolsByIndex[ref.ToolIndex]; tool != nil {
output = append(output, *s.toolOutput(tool, status))
}
}
}
return &dto.OpenAIResponsesResponse{
ID: s.ID,
Object: "response",
CreatedAt: int(s.Created),
Status: []byte(fmt.Sprintf("%q", s.status)),
IncompleteDetails: s.incompleteDetails,
Model: s.Model,
Output: output,
Usage: s.Usage,
}
}
func (s *ChatToResponsesStreamState) createdResponse() *dto.OpenAIResponsesResponse {
return &dto.OpenAIResponsesResponse{
ID: s.ID,
Object: "response",
CreatedAt: int(s.Created),
Status: []byte(`"in_progress"`),
Model: s.Model,
Output: []dto.ResponsesOutput{},
}
}
func (s *ChatToResponsesStreamState) nextIndex(kind string, toolIndex int) int {
index := s.nextOutputIndex
s.nextOutputIndex++
s.outputOrder = append(s.outputOrder, chatToResponsesOutputRef{Kind: kind, ToolIndex: toolIndex})
return index
}
func (s *ChatToResponsesStreamState) sortedTools() []*chatToResponsesStreamTool {
indexes := make([]int, 0, len(s.toolsByIndex))
for index := range s.toolsByIndex {
indexes = append(indexes, index)
}
sort.Ints(indexes)
tools := make([]*chatToResponsesStreamTool, 0, len(indexes))
for _, index := range indexes {
tools = append(tools, s.toolsByIndex[index])
}
return tools
}
func (s *ChatToResponsesStreamState) outputStatus() string {
if s.status == "incomplete" {
return "incomplete"
}
return "completed"
}
func (s *ChatToResponsesStreamState) messageID() string {
return fmt.Sprintf("%s_msg_0", s.ID)
}
func (s *ChatToResponsesStreamState) reasoningID() string {
return fmt.Sprintf("%s_reasoning_0", s.ID)
}
func (s *ChatToResponsesStreamState) messageOutput(status string) *dto.ResponsesOutput {
return &dto.ResponsesOutput{
Type: responsesOutputTypeMessage,
ID: s.messageID(),
Status: status,
Role: "assistant",
Content: []dto.ResponsesOutputContent{
{
Type: "output_text",
Text: s.text.String(),
Annotations: []interface{}{},
},
},
}
}
func (s *ChatToResponsesStreamState) reasoningOutput(status string) *dto.ResponsesOutput {
return &dto.ResponsesOutput{
Type: responsesOutputTypeReasoning,
ID: s.reasoningID(),
Status: status,
Content: []dto.ResponsesOutputContent{
{
Type: "summary_text",
Text: s.reasoning.String(),
},
},
}
}
func (s *ChatToResponsesStreamState) toolOutput(tool *chatToResponsesStreamTool, status string) *dto.ResponsesOutput {
return &dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: tool.ID,
Status: status,
CallId: tool.ID,
Name: tool.Name,
Arguments: chatArgumentsRawMessage(tool.Arguments.String()),
}
}
@@ -0,0 +1,263 @@
package oairesponses
import (
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
func openAIResponsesRequestFromAny(request any) (*dto.OpenAIResponsesRequest, error) {
responsesRequest, ok := request.(*dto.OpenAIResponsesRequest)
if !ok {
if value, ok := request.(dto.OpenAIResponsesRequest); ok {
responsesRequest = &value
}
}
if responsesRequest == nil {
return nil, fmt.Errorf("expected OpenAI responses request, got %T", request)
}
return responsesRequest, nil
}
func OpenAIResponsesRequestFromAny(request any) (*dto.OpenAIResponsesRequest, error) {
return openAIResponsesRequestFromAny(request)
}
func responsesInputItems(raw []byte) ([]map[string]any, error) {
if !rawJSONPresent(raw) {
return nil, nil
}
switch kitutil.GetJsonType(raw) {
case "string":
input, err := responsesJSONString(raw)
if err != nil {
return nil, fmt.Errorf("invalid input string: %w", err)
}
return []map[string]any{
{
"role": "user",
"content": input,
},
}, nil
case "array":
var items []map[string]any
if err := kitutil.Unmarshal(raw, &items); err != nil {
return nil, fmt.Errorf("invalid input array: %w", err)
}
return items, nil
default:
return nil, fmt.Errorf("unsupported responses input type %q", kitutil.GetJsonType(raw))
}
}
func InputItems(raw []byte) ([]map[string]any, error) {
return responsesInputItems(raw)
}
func responsesContentParts(content any) ([]map[string]any, error) {
switch typed := content.(type) {
case nil:
return nil, nil
case string:
return []map[string]any{{"type": "input_text", "text": typed}}, nil
case []map[string]any:
return typed, nil
case []any:
parts := make([]map[string]any, 0, len(typed))
for _, item := range typed {
switch part := item.(type) {
case string:
parts = append(parts, map[string]any{"type": "input_text", "text": part})
case map[string]any:
parts = append(parts, part)
default:
raw, err := kitutil.Marshal(part)
if err != nil {
return nil, err
}
parts = append(parts, map[string]any{"type": "input_text", "text": string(raw)})
}
}
return parts, nil
default:
raw, err := kitutil.Marshal(typed)
if err != nil {
return nil, err
}
return []map[string]any{{"type": "input_text", "text": string(raw)}}, nil
}
}
func ContentParts(content any) ([]map[string]any, error) {
return responsesContentParts(content)
}
func responsesRequestFunctionDeclarations(raw []byte) ([]dto.FunctionRequest, error) {
if !rawJSONPresent(raw) {
return nil, nil
}
var tools []map[string]any
if err := kitutil.Unmarshal(raw, &tools); err != nil {
return nil, fmt.Errorf("invalid tools: %w", err)
}
functions := make([]dto.FunctionRequest, 0, len(tools))
for _, tool := range tools {
if strings.TrimSpace(kitutil.Interface2String(tool["type"])) != "function" {
continue
}
name := strings.TrimSpace(kitutil.Interface2String(tool["name"]))
if name == "" {
continue
}
functions = append(functions, dto.FunctionRequest{
Name: name,
Description: kitutil.Interface2String(tool["description"]),
Parameters: tool["parameters"],
})
}
return functions, nil
}
func RequestFunctionDeclarations(raw []byte) ([]dto.FunctionRequest, error) {
return responsesRequestFunctionDeclarations(raw)
}
func responsesReasoningEffort(req *dto.OpenAIResponsesRequest) string {
if req == nil || req.Reasoning == nil {
return ""
}
return req.Reasoning.Effort
}
func ReasoningEffort(req *dto.OpenAIResponsesRequest) string {
return responsesReasoningEffort(req)
}
func responsesObjectValue(value any, fallbackKey string) map[string]any {
switch typed := value.(type) {
case nil:
return map[string]any{}
case map[string]any:
return typed
case string:
var object map[string]any
if err := kitutil.Unmarshal([]byte(typed), &object); err == nil {
return object
}
var array []any
if err := kitutil.Unmarshal([]byte(typed), &array); err == nil {
return map[string]any{fallbackKey: array}
}
return map[string]any{fallbackKey: typed}
case []any:
return map[string]any{fallbackKey: typed}
default:
return map[string]any{fallbackKey: typed}
}
}
func ObjectValue(value any, fallbackKey string) map[string]any {
return responsesObjectValue(value, fallbackKey)
}
func responsesGeminiResponseMap(value any) map[string]interface{} {
switch typed := value.(type) {
case nil:
return map[string]interface{}{}
case map[string]any:
return typed
case string:
var object map[string]interface{}
if err := kitutil.Unmarshal([]byte(typed), &object); err == nil {
return object
}
var array []interface{}
if err := kitutil.Unmarshal([]byte(typed), &array); err == nil {
return map[string]interface{}{"result": array}
}
return map[string]interface{}{"content": typed}
case []any:
return map[string]interface{}{"result": typed}
default:
return map[string]interface{}{"content": typed}
}
}
func GeminiResponseMap(value any) map[string]interface{} {
return responsesGeminiResponseMap(value)
}
func responsesParallelToolCalls(raw []byte) *bool {
if !rawJSONPresent(raw) || kitutil.GetJsonType(raw) != "boolean" {
return nil
}
var parallelToolCalls bool
if err := kitutil.Unmarshal(raw, &parallelToolCalls); err != nil {
return nil
}
return &parallelToolCalls
}
func ParallelToolCalls(raw []byte) *bool {
return responsesParallelToolCalls(raw)
}
func ContentPartToFileSource(part map[string]any) types.FileSource {
partType := strings.TrimSpace(kitutil.Interface2String(part["type"]))
var data string
var mimeType string
switch partType {
case "input_image":
data, mimeType = responsesPartDataAndMime(part, "image_url", "url")
case "input_file":
data, mimeType = responsesPartDataAndMime(part, "file", "file_data", "file_url", "url")
case "input_audio":
data, mimeType = responsesPartDataAndMime(part, "input_audio", "data", "url")
if mimeType == "" {
if payload, ok := part["input_audio"].(map[string]any); ok {
if format := strings.TrimSpace(kitutil.Interface2String(payload["format"])); format != "" {
mimeType = "audio/" + format
}
}
}
case "input_video":
data, mimeType = responsesPartDataAndMime(part, "video_url", "url")
}
if data == "" {
return nil
}
return types.NewFileSourceFromData(data, mimeType)
}
func responsesPartDataAndMime(part map[string]any, keys ...string) (string, string) {
mimeType := strings.TrimSpace(kitutil.Interface2String(part["mime_type"]))
for _, key := range keys {
value, ok := part[key]
if !ok {
continue
}
switch typed := value.(type) {
case string:
if typed != "" {
return typed, mimeType
}
case map[string]any:
if mimeType == "" {
mimeType = strings.TrimSpace(kitutil.Interface2String(typed["mime_type"]))
}
for _, nestedKey := range []string{"url", "file_data", "file_url", "data"} {
if data := strings.TrimSpace(kitutil.Interface2String(typed[nestedKey])); data != "" {
return data, mimeType
}
}
}
}
return "", mimeType
}
@@ -0,0 +1,329 @@
package oairesponses
import (
"fmt"
"strings"
"context"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
func convertOpenAIResponsesRequestToClaudeMessages(c context.Context, info convmeta.Meta, request any) (any, error) {
responsesRequest, err := OpenAIResponsesRequestFromAny(request)
if err != nil {
return nil, err
}
return OpenAIResponsesRequestToClaudeMessages(c, info, responsesRequest)
}
func OpenAIResponsesRequestToClaudeMessages(c context.Context, info convmeta.Meta, req *dto.OpenAIResponsesRequest) (*dto.ClaudeRequest, error) {
if req == nil {
return nil, fmt.Errorf("request is nil")
}
if req.Model == "" {
return nil, fmt.Errorf("model is required")
}
if err := ValidateRequestChatUnsupportedFields(req); err != nil {
return nil, err
}
claudeRequest := &dto.ClaudeRequest{
Model: req.Model,
Temperature: req.Temperature,
TopP: req.TopP,
Stream: req.Stream,
}
if req.MaxOutputTokens != nil && *req.MaxOutputTokens > 0 {
claudeRequest.MaxTokens = kitutil.GetPointer(*req.MaxOutputTokens)
}
if claudeRequest.MaxTokens == nil || *claudeRequest.MaxTokens == 0 {
if defaultMaxTokens, configured := convmeta.OptionsOf(info).Claude.DefaultMaxTokensFor(req.Model); configured {
value := uint(defaultMaxTokens)
claudeRequest.MaxTokens = &value
}
}
functions, err := RequestFunctionDeclarations(req.Tools)
if err != nil {
return nil, err
}
if len(functions) > 0 {
claudeRequest.Tools = responsesFunctionDeclarationsToClaudeTools(functions)
}
toolChoice, err := RequestToolChoiceToChat(req.ToolChoice)
if err != nil {
return nil, err
}
if toolChoice != nil || RawJSONPresent(req.ParallelToolCalls) {
claudeRequest.ToolChoice = sharedclaude.MapOpenAIToolChoice(toolChoice, ParallelToolCalls(req.ParallelToolCalls))
}
applyResponsesReasoningToClaude(req, claudeRequest)
systemMessages := make([]dto.ClaudeMediaMessage, 0)
if RawJSONPresent(req.Instructions) {
instructions, err := JSONString(req.Instructions)
if err != nil {
return nil, fmt.Errorf("invalid instructions: %w", err)
}
if strings.TrimSpace(instructions) != "" {
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
Type: "text",
Text: kitutil.GetPointer(instructions),
})
}
}
inputItems, err := InputItems(req.Input)
if err != nil {
return nil, err
}
for _, item := range inputItems {
itemType := strings.TrimSpace(kitutil.Interface2String(item["type"]))
switch itemType {
case ResponsesInputTypeFunctionCall:
claudeRequest.Messages = appendClaudeToolUse(claudeRequest.Messages, responsesFunctionCallItemToClaudeToolUse(item, "arguments"))
case ResponsesInputTypeCustomToolCall:
claudeRequest.Messages = appendClaudeToolUse(claudeRequest.Messages, responsesFunctionCallItemToClaudeToolUse(item, "input"))
case ResponsesInputTypeFunctionCallOutput, ResponsesInputTypeCustomToolOutput:
claudeRequest.Messages = appendClaudeToolResult(claudeRequest.Messages, responsesFunctionOutputItemToClaudeToolResult(item))
default:
role := responsesClaudeRole(item)
parts, err := responsesInputContentToClaudeMediaMessages(c, item["content"])
if err != nil {
return nil, err
}
if role == "system" {
systemMessages = append(systemMessages, parts...)
continue
}
if len(parts) == 0 {
parts = []dto.ClaudeMediaMessage{
{
Type: "text",
Text: kitutil.GetPointer("..."),
},
}
}
claudeRequest.Messages = append(claudeRequest.Messages, dto.ClaudeMessage{
Role: role,
Content: parts,
})
}
}
if len(systemMessages) > 0 {
claudeRequest.System = systemMessages
}
claudeRequest.Messages = ensureClaudeMessagesStartWithUser(claudeRequest.Messages)
// Checked last so every injection path has had its chance to satisfy the
// required field.
if claudeRequest.MaxTokens == nil {
return nil, sharedclaude.ErrMissingMaxTokens
}
return claudeRequest, nil
}
func responsesFunctionDeclarationsToClaudeTools(functions []dto.FunctionRequest) []any {
tools := make([]any, 0, len(functions))
for _, function := range functions {
tools = append(tools, &dto.Tool{
Name: function.Name,
Description: function.Description,
InputSchema: responsesFunctionParametersToClaudeInputSchema(function.Parameters),
})
}
return tools
}
func responsesFunctionParametersToClaudeInputSchema(parameters any) map[string]interface{} {
if params, ok := parameters.(map[string]any); ok {
schema := make(map[string]interface{}, len(params))
for key, value := range params {
schema[key] = value
}
if schema["type"] == nil {
schema["type"] = "object"
}
if schema["properties"] == nil {
schema["properties"] = map[string]interface{}{}
}
return schema
}
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
}
}
func applyResponsesReasoningToClaude(req *dto.OpenAIResponsesRequest, claudeRequest *dto.ClaudeRequest) {
effort := ReasoningEffort(req)
switch effort {
case "low":
claudeRequest.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: kitutil.GetPointer(1280),
}
case "medium":
claudeRequest.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: kitutil.GetPointer(2048),
}
case "high":
claudeRequest.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: kitutil.GetPointer(4096),
}
}
}
func responsesInputContentToClaudeMediaMessages(c context.Context, content any) ([]dto.ClaudeMediaMessage, error) {
contentParts, err := ContentParts(content)
if err != nil {
return nil, err
}
parts := make([]dto.ClaudeMediaMessage, 0, len(contentParts))
for _, contentPart := range contentParts {
partType := strings.TrimSpace(kitutil.Interface2String(contentPart["type"]))
switch partType {
case "input_text", "output_text", "text":
text := kitutil.Interface2String(contentPart["text"])
if text != "" {
parts = append(parts, dto.ClaudeMediaMessage{
Type: "text",
Text: kitutil.GetPointer(text),
})
}
case "input_image", "input_file", "input_audio", "input_video":
source := ContentPartToFileSource(contentPart)
if source == nil {
continue
}
base64Data, mimeType, err := relaymedia.ResolveBase64Data(c, source, "formatting Responses input for Claude")
if err != nil {
return nil, fmt.Errorf("get file data failed: %s", err.Error())
}
claudePart := dto.ClaudeMediaMessage{
Source: &dto.ClaudeMessageSource{
Type: "base64",
MediaType: mimeType,
Data: base64Data,
},
}
if strings.HasPrefix(mimeType, "application/pdf") {
claudePart.Type = "document"
} else {
claudePart.Type = "image"
}
parts = append(parts, claudePart)
}
}
return parts, nil
}
func responsesFunctionCallItemToClaudeToolUse(item map[string]any, inputKey string) dto.ClaudeMediaMessage {
return dto.ClaudeMediaMessage{
Type: "tool_use",
Id: CallID(item),
Name: strings.TrimSpace(kitutil.Interface2String(item["name"])),
Input: ObjectValue(item[inputKey], inputKey),
}
}
func responsesFunctionOutputItemToClaudeToolResult(item map[string]any) dto.ClaudeMediaMessage {
return dto.ClaudeMediaMessage{
Type: "tool_result",
ToolUseId: CallID(item),
Content: responsesToolOutputValue(item["output"]),
}
}
func responsesToolOutputValue(value any) any {
if value == nil {
return ""
}
return value
}
func appendClaudeToolUse(messages []dto.ClaudeMessage, toolUse dto.ClaudeMediaMessage) []dto.ClaudeMessage {
if len(messages) > 0 && messages[len(messages)-1].Role == "assistant" {
last := messages[len(messages)-1]
parts := claudeMessageContentParts(last.Content)
parts = append(parts, toolUse)
last.Content = parts
messages[len(messages)-1] = last
return messages
}
return append(messages, dto.ClaudeMessage{
Role: "assistant",
Content: []dto.ClaudeMediaMessage{toolUse},
})
}
func appendClaudeToolResult(messages []dto.ClaudeMessage, toolResult dto.ClaudeMediaMessage) []dto.ClaudeMessage {
if len(messages) > 0 && messages[len(messages)-1].Role == "user" {
last := messages[len(messages)-1]
parts := claudeMessageContentParts(last.Content)
parts = append(parts, toolResult)
last.Content = parts
messages[len(messages)-1] = last
return messages
}
return append(messages, dto.ClaudeMessage{
Role: "user",
Content: []dto.ClaudeMediaMessage{toolResult},
})
}
func claudeMessageContentParts(content any) []dto.ClaudeMediaMessage {
switch typed := content.(type) {
case []dto.ClaudeMediaMessage:
return typed
case string:
if typed == "" {
return nil
}
return []dto.ClaudeMediaMessage{
{
Type: "text",
Text: kitutil.GetPointer(typed),
},
}
default:
parts, _ := kitutil.Any2Type[[]dto.ClaudeMediaMessage](content)
return parts
}
}
func responsesClaudeRole(item map[string]any) string {
switch strings.TrimSpace(kitutil.Interface2String(item["role"])) {
case "assistant":
return "assistant"
case "system", "developer":
return "system"
default:
return "user"
}
}
func ensureClaudeMessagesStartWithUser(messages []dto.ClaudeMessage) []dto.ClaudeMessage {
if len(messages) == 0 || messages[0].Role == "user" {
return messages
}
return append([]dto.ClaudeMessage{
{
Role: "user",
Content: []dto.ClaudeMediaMessage{
{
Type: "text",
Text: kitutil.GetPointer("..."),
},
},
},
}, messages...)
}
@@ -0,0 +1,309 @@
package oairesponses
import (
"fmt"
"strings"
"context"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
sharedgemini "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/gemini"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
func convertOpenAIResponsesRequestToGeminiChat(c context.Context, info convmeta.Meta, request any) (any, error) {
responsesRequest, err := OpenAIResponsesRequestFromAny(request)
if err != nil {
return nil, err
}
prepared, err := PrepareOpenAIResponsesRequest(*responsesRequest)
if err != nil {
return nil, err
}
return OpenAIResponsesRequestToGeminiChat(c, &prepared, info)
}
func OpenAIResponsesRequestToGeminiChat(c context.Context, req *dto.OpenAIResponsesRequest, info convmeta.Meta) (*dto.GeminiChatRequest, error) {
opts := convmeta.OptionsOf(info)
if req == nil {
return nil, fmt.Errorf("request is nil")
}
if req.Model == "" {
return nil, fmt.Errorf("model is required")
}
if err := ValidateRequestChatUnsupportedFields(req); err != nil {
return nil, err
}
geminiRequest := &dto.GeminiChatRequest{
GenerationConfig: dto.GeminiChatGenerationConfig{
Temperature: req.Temperature,
},
}
if req.TopP != nil && *req.TopP > 0 {
geminiRequest.GenerationConfig.TopP = kitutil.GetPointer(*req.TopP)
}
if req.MaxOutputTokens != nil && *req.MaxOutputTokens > 0 {
geminiRequest.GenerationConfig.MaxOutputTokens = kitutil.GetPointer(*req.MaxOutputTokens)
}
upstreamModelName := req.Model
if modelName := convmeta.UpstreamModelName(info); modelName != "" {
upstreamModelName = modelName
}
if opts.Gemini.SupportsImagineModel(upstreamModelName) {
geminiRequest.GenerationConfig.ResponseModalities = []string{"TEXT", "IMAGE"}
}
if err := applyResponsesTextToGemini(req.Text, geminiRequest); err != nil {
return nil, err
}
sharedgemini.ApplyThinkingConfig(geminiRequest, info, dto.GeneralOpenAIRequest{
Model: req.Model,
MaxCompletionTokens: req.MaxOutputTokens,
ReasoningEffort: ReasoningEffort(req),
})
var safetySettings []dto.GeminiChatSafetySettings
for _, category := range sharedgemini.SafetySettingCategories {
threshold := opts.Gemini.SafetySettingFor(category)
if threshold == "" {
continue
}
safetySettings = append(safetySettings, dto.GeminiChatSafetySettings{
Category: category,
Threshold: threshold,
})
}
if len(safetySettings) > 0 {
geminiRequest.SafetySettings = safetySettings
}
functions, err := RequestFunctionDeclarations(req.Tools)
if err != nil {
return nil, err
}
for i := range functions {
if params, ok := functions[i].Parameters.(map[string]interface{}); ok {
if props, hasProps := params["properties"].(map[string]interface{}); hasProps && len(props) == 0 {
functions[i].Parameters = nil
continue
}
}
functions[i].Parameters = sharedgemini.CleanFunctionParameters(functions[i].Parameters)
}
if len(functions) > 0 {
geminiRequest.SetTools([]dto.GeminiChatTool{
{FunctionDeclarations: functions},
})
}
toolChoice, err := RequestToolChoiceToChat(req.ToolChoice)
if err != nil {
return nil, err
}
if toolChoice != nil {
geminiRequest.ToolConfig = sharedgemini.OpenAIToolChoiceToConfig(toolChoice)
}
systemTexts := make([]string, 0)
if RawJSONPresent(req.Instructions) {
instructions, err := JSONString(req.Instructions)
if err != nil {
return nil, fmt.Errorf("invalid instructions: %w", err)
}
if strings.TrimSpace(instructions) != "" {
systemTexts = append(systemTexts, instructions)
}
}
inputItems, err := InputItems(req.Input)
if err != nil {
return nil, err
}
callNames := make(map[string]string)
for _, item := range inputItems {
itemType := strings.TrimSpace(kitutil.Interface2String(item["type"]))
switch itemType {
case ResponsesInputTypeFunctionCall:
part, callID, err := responsesFunctionCallItemToGeminiPart(item)
if err != nil {
return nil, err
}
sharedgemini.AttachFunctionCallThoughtSignature(opts, &part)
if callID != "" {
callNames[callID] = part.FunctionCall.FunctionName
}
appendGeminiContentPart(geminiRequest, "model", part)
case ResponsesInputTypeFunctionCallOutput:
part := responsesFunctionOutputItemToGeminiPart(item, callNames)
appendGeminiContentPart(geminiRequest, "user", part)
default:
role := responsesGeminiRole(item)
parts, err := responsesInputContentToGeminiParts(c, item["content"])
if err != nil {
return nil, err
}
if role == "system" {
for _, part := range parts {
if part.Text != "" {
systemTexts = append(systemTexts, part.Text)
}
}
continue
}
if len(parts) > 0 {
geminiRequest.Contents = append(geminiRequest.Contents, dto.GeminiChatContent{
Role: role,
Parts: parts,
})
}
}
}
if len(systemTexts) > 0 {
geminiRequest.SystemInstructions = &dto.GeminiChatContent{
Parts: []dto.GeminiPart{{Text: strings.Join(systemTexts, "\n")}},
}
}
return geminiRequest, nil
}
func applyResponsesTextToGemini(raw []byte, geminiRequest *dto.GeminiChatRequest) error {
responseFormat, err := RequestTextToChatResponseFormat(raw)
if err != nil {
return err
}
if responseFormat == nil || (responseFormat.Type != "json_schema" && responseFormat.Type != "json_object") {
return nil
}
geminiRequest.GenerationConfig.ResponseMimeType = "application/json"
if len(responseFormat.JsonSchema) == 0 {
return nil
}
var jsonSchema dto.FormatJsonSchema
if err := kitutil.Unmarshal(responseFormat.JsonSchema, &jsonSchema); err != nil {
return nil
}
geminiRequest.GenerationConfig.ResponseSchema = sharedgemini.RemoveAdditionalProperties(jsonSchema.Schema, 0)
return nil
}
func responsesInputContentToGeminiParts(c context.Context, content any) ([]dto.GeminiPart, error) {
contentParts, err := ContentParts(content)
if err != nil {
return nil, err
}
parts := make([]dto.GeminiPart, 0, len(contentParts))
for _, contentPart := range contentParts {
nextParts, err := responsesContentPartToGeminiParts(c, contentPart)
if err != nil {
return nil, err
}
parts = append(parts, nextParts...)
}
return parts, nil
}
func responsesContentPartToGeminiParts(c context.Context, part map[string]any) ([]dto.GeminiPart, error) {
partType := strings.TrimSpace(kitutil.Interface2String(part["type"]))
switch partType {
case "input_text", "output_text", "text":
text := kitutil.Interface2String(part["text"])
if text == "" {
return nil, nil
}
return []dto.GeminiPart{{Text: text}}, nil
case "input_image", "input_file", "input_audio", "input_video":
source := ContentPartToFileSource(part)
if source == nil {
return nil, nil
}
base64Data, mimeType, err := relaymedia.ResolveBase64Data(c, source, "formatting Responses input for Gemini")
if err != nil {
return nil, fmt.Errorf("get file data from '%s' failed: %w", source.GetIdentifier(), err)
}
if _, ok := sharedgemini.SupportedMimeTypes[strings.ToLower(mimeType)]; !ok {
return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", mimeType, source.GetIdentifier(), sharedgemini.SupportedMimeTypesList())
}
return []dto.GeminiPart{
{
InlineData: &dto.GeminiInlineData{
MimeType: mimeType,
Data: base64Data,
},
},
}, nil
default:
return nil, nil
}
}
func responsesFunctionCallItemToGeminiPart(item map[string]any) (dto.GeminiPart, string, error) {
name := strings.TrimSpace(kitutil.Interface2String(item["name"]))
if name == "" {
return dto.GeminiPart{}, "", fmt.Errorf("function_call item is missing name")
}
callID := CallID(item)
return dto.GeminiPart{
FunctionCall: &dto.FunctionCall{
FunctionName: name,
Arguments: ObjectValue(item["arguments"], "arguments"),
},
}, callID, nil
}
func responsesFunctionOutputItemToGeminiPart(item map[string]any, callNames map[string]string) dto.GeminiPart {
callID := CallID(item)
name := strings.TrimSpace(kitutil.Interface2String(item["name"]))
if name == "" {
name = callNames[callID]
}
return dto.GeminiPart{
FunctionResponse: &dto.GeminiFunctionResponse{
Name: name,
Response: GeminiResponseMap(item["output"]),
},
}
}
func appendGeminiContentPart(req *dto.GeminiChatRequest, role string, part dto.GeminiPart) {
if len(req.Contents) > 0 && req.Contents[len(req.Contents)-1].Role == role {
if role == "model" && part.FunctionCall != nil {
parts := req.Contents[len(req.Contents)-1].Parts
insertAt := 0
for insertAt < len(parts) && parts[insertAt].FunctionCall != nil {
insertAt++
}
parts = append(parts, dto.GeminiPart{})
copy(parts[insertAt+1:], parts[insertAt:])
parts[insertAt] = part
req.Contents[len(req.Contents)-1].Parts = parts
return
}
req.Contents[len(req.Contents)-1].Parts = append(req.Contents[len(req.Contents)-1].Parts, part)
return
}
req.Contents = append(req.Contents, dto.GeminiChatContent{
Role: role,
Parts: []dto.GeminiPart{part},
})
}
func responsesGeminiRole(item map[string]any) string {
switch strings.TrimSpace(kitutil.Interface2String(item["role"])) {
case "assistant":
return "model"
case "system", "developer":
return "system"
case "model":
return "model"
default:
return "user"
}
}
@@ -0,0 +1,101 @@
package oairesponses
import (
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
const (
geminiResponsesInputTypeCustomToolCall = "custom_tool_call"
geminiResponsesInputTypeCustomToolCallOutput = "custom_tool_call_output"
geminiResponsesInputTypeFunctionCallOutput = "function_call_output"
)
const (
ResponsesInputTypeCustomToolCallOutput = geminiResponsesInputTypeCustomToolCallOutput
)
func PrepareOpenAIResponsesRequest(request dto.OpenAIResponsesRequest) (dto.OpenAIResponsesRequest, error) {
tools, err := filterGeminiResponsesTools(request.Tools)
if err != nil {
return request, err
}
request.Tools = tools
input, err := filterGeminiResponsesInput(request.Input)
if err != nil {
return request, err
}
request.Input = input
return request, nil
}
func filterGeminiResponsesTools(raw []byte) ([]byte, error) {
if !geminiRawJSONPresent(raw) || kitutil.GetJsonType(raw) != "array" {
return raw, nil
}
var tools []map[string]any
if err := kitutil.Unmarshal(raw, &tools); err != nil {
return nil, err
}
filtered := make([]map[string]any, 0, len(tools))
for _, tool := range tools {
if strings.TrimSpace(kitutil.Interface2String(tool["type"])) != "function" {
continue
}
filtered = append(filtered, tool)
}
if len(filtered) == 0 {
return nil, nil
}
return kitutil.Marshal(filtered)
}
func filterGeminiResponsesInput(raw []byte) ([]byte, error) {
if !geminiRawJSONPresent(raw) || kitutil.GetJsonType(raw) != "array" {
return raw, nil
}
var items []map[string]any
if err := kitutil.Unmarshal(raw, &items); err != nil {
return nil, err
}
skippedCustomCallIDs := make(map[string]struct{})
for _, item := range items {
if strings.TrimSpace(kitutil.Interface2String(item["type"])) != geminiResponsesInputTypeCustomToolCall {
continue
}
if callID := strings.TrimSpace(kitutil.Interface2String(item["call_id"])); callID != "" {
skippedCustomCallIDs[callID] = struct{}{}
}
}
filtered := make([]map[string]any, 0, len(items))
for _, item := range items {
itemType := strings.TrimSpace(kitutil.Interface2String(item["type"]))
switch itemType {
case geminiResponsesInputTypeCustomToolCall, geminiResponsesInputTypeCustomToolCallOutput:
continue
case geminiResponsesInputTypeFunctionCallOutput:
if _, ok := skippedCustomCallIDs[strings.TrimSpace(kitutil.Interface2String(item["call_id"]))]; ok {
continue
}
}
filtered = append(filtered, item)
}
return kitutil.Marshal(filtered)
}
func geminiRawJSONPresent(raw []byte) bool {
if len(raw) == 0 {
return false
}
return kitutil.GetJsonType(raw) != "null"
}
@@ -0,0 +1,553 @@
package oairesponses
import (
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
const (
responsesInputTypeFunctionCall = "function_call"
responsesInputTypeFunctionCallOutput = "function_call_output"
responsesInputTypeCustomToolCall = "custom_tool_call"
responsesInputTypeCustomToolOutput = "custom_tool_call_output"
)
const (
ResponsesInputTypeFunctionCall = responsesInputTypeFunctionCall
ResponsesInputTypeFunctionCallOutput = responsesInputTypeFunctionCallOutput
ResponsesInputTypeCustomToolCall = responsesInputTypeCustomToolCall
ResponsesInputTypeCustomToolOutput = responsesInputTypeCustomToolOutput
)
func ResponsesRequestToChatCompletionsRequest(req *dto.OpenAIResponsesRequest) (*dto.GeneralOpenAIRequest, error) {
if req == nil {
return nil, errors.New("request is nil")
}
if req.Model == "" {
return nil, errors.New("model is required")
}
if err := validateResponsesRequestChatUnsupportedFields(req); err != nil {
return nil, err
}
messages, err := responsesRequestMessagesToChat(req)
if err != nil {
return nil, err
}
tools, err := responsesRequestToolsToChat(req.Tools)
if err != nil {
return nil, err
}
toolChoice, err := responsesRequestToolChoiceToChat(req.ToolChoice)
if err != nil {
return nil, err
}
responseFormat, err := responsesRequestTextToChatResponseFormat(req.Text)
if err != nil {
return nil, err
}
out := &dto.GeneralOpenAIRequest{
Model: req.Model,
Messages: messages,
Stream: req.Stream,
StreamOptions: req.StreamOptions,
MaxCompletionTokens: req.MaxOutputTokens,
Temperature: req.Temperature,
TopP: req.TopP,
TopLogProbs: req.TopLogProbs,
ResponseFormat: responseFormat,
Tools: tools,
ToolChoice: toolChoice,
User: req.User,
Store: req.Store,
Metadata: req.Metadata,
SafetyIdentifier: req.SafetyIdentifier,
PromptCacheRetention: req.PromptCacheRetention,
EnableThinking: req.EnableThinking,
}
if req.Reasoning != nil {
out.ReasoningEffort = req.Reasoning.Effort
}
if req.ServiceTier != "" {
out.ServiceTier, _ = kitutil.Marshal(req.ServiceTier)
}
if len(req.ParallelToolCalls) > 0 && kitutil.GetJsonType(req.ParallelToolCalls) == "boolean" {
var parallelToolCalls bool
if err := kitutil.Unmarshal(req.ParallelToolCalls, &parallelToolCalls); err == nil {
out.ParallelTooCalls = &parallelToolCalls
}
}
if len(req.PromptCacheKey) > 0 && kitutil.GetJsonType(req.PromptCacheKey) == "string" {
var promptCacheKey string
if err := kitutil.Unmarshal(req.PromptCacheKey, &promptCacheKey); err == nil {
out.PromptCacheKey = promptCacheKey
}
}
return out, nil
}
func validateResponsesRequestChatUnsupportedFields(req *dto.OpenAIResponsesRequest) error {
unsupported := make([]string, 0, 4)
if rawJSONPresent(req.Conversation) {
unsupported = append(unsupported, "conversation")
}
if strings.TrimSpace(req.PreviousResponseID) != "" {
unsupported = append(unsupported, "previous_response_id")
}
if rawJSONPresent(req.Prompt) {
unsupported = append(unsupported, "prompt")
}
if rawJSONPresent(req.ContextManagement) {
unsupported = append(unsupported, "context_management")
}
if len(unsupported) > 0 {
return fmt.Errorf("responses to chat conversion does not support stateful fields: %s", strings.Join(unsupported, ", "))
}
return nil
}
func ValidateRequestChatUnsupportedFields(req *dto.OpenAIResponsesRequest) error {
return validateResponsesRequestChatUnsupportedFields(req)
}
func responsesRequestMessagesToChat(req *dto.OpenAIResponsesRequest) ([]dto.Message, error) {
messages := make([]dto.Message, 0)
if rawJSONPresent(req.Instructions) {
instructions, err := responsesJSONString(req.Instructions)
if err != nil {
return nil, fmt.Errorf("invalid instructions: %w", err)
}
if strings.TrimSpace(instructions) != "" {
messages = append(messages, dto.Message{Role: "system", Content: instructions})
}
}
if !rawJSONPresent(req.Input) {
return messages, nil
}
switch kitutil.GetJsonType(req.Input) {
case "string":
input, err := responsesJSONString(req.Input)
if err != nil {
return nil, fmt.Errorf("invalid input string: %w", err)
}
messages = append(messages, dto.Message{Role: "user", Content: input})
return messages, nil
case "array":
var items []map[string]any
if err := kitutil.Unmarshal(req.Input, &items); err != nil {
return nil, fmt.Errorf("invalid input array: %w", err)
}
for _, item := range items {
nextMessages, err := responsesInputItemToChatMessages(item, messages)
if err != nil {
return nil, err
}
messages = nextMessages
}
return messages, nil
default:
return nil, fmt.Errorf("unsupported responses input type %q", kitutil.GetJsonType(req.Input))
}
}
func responsesInputItemToChatMessages(item map[string]any, messages []dto.Message) ([]dto.Message, error) {
itemType := strings.TrimSpace(kitutil.Interface2String(item["type"]))
switch itemType {
case responsesInputTypeFunctionCall:
toolCall, err := responsesFunctionCallItemToChatToolCall(item)
if err != nil {
return nil, err
}
return appendToolCallToLastAssistant(messages, toolCall), nil
case responsesInputTypeCustomToolCall:
toolCall, err := responsesCustomToolCallItemToChatToolCall(item)
if err != nil {
return nil, err
}
return appendToolCallToLastAssistant(messages, toolCall), nil
case responsesInputTypeFunctionCallOutput:
callID := strings.TrimSpace(kitutil.Interface2String(item["call_id"]))
content := responseToolOutputToChatContent(item["output"])
return append(messages, dto.Message{Role: "tool", ToolCallId: callID, Content: content}), nil
}
role := strings.TrimSpace(kitutil.Interface2String(item["role"]))
if role == "" {
role = "user"
}
content, err := responsesInputContentToChatContent(item["content"])
if err != nil {
return nil, err
}
return append(messages, dto.Message{Role: role, Content: content}), nil
}
func responsesInputContentToChatContent(content any) (any, error) {
if content == nil {
return "", nil
}
switch value := content.(type) {
case string:
return value, nil
case []any:
return responsesContentPartsToChatContent(value)
case []map[string]any:
parts := make([]any, 0, len(value))
for _, part := range value {
parts = append(parts, part)
}
return responsesContentPartsToChatContent(parts)
default:
return content, nil
}
}
func responsesContentPartsToChatContent(parts []any) (any, error) {
chatParts := make([]any, 0, len(parts))
var textOnly strings.Builder
onlyText := true
for _, rawPart := range parts {
part, ok := rawPart.(map[string]any)
if !ok {
onlyText = false
chatParts = append(chatParts, rawPart)
continue
}
partType := strings.TrimSpace(kitutil.Interface2String(part["type"]))
switch partType {
case "input_text", "output_text", "text":
text := kitutil.Interface2String(part["text"])
textOnly.WriteString(text)
chatParts = append(chatParts, map[string]any{
"type": dto.ContentTypeText,
"text": text,
})
case "input_image":
onlyText = false
chatParts = append(chatParts, map[string]any{
"type": dto.ContentTypeImageURL,
"image_url": responsesImagePartToChatImageURL(part),
})
case "input_file":
onlyText = false
chatParts = append(chatParts, map[string]any{
"type": dto.ContentTypeFile,
"file": responsesFilePartToChatFile(part),
})
case "input_audio":
onlyText = false
chatParts = append(chatParts, map[string]any{
"type": dto.ContentTypeInputAudio,
"input_audio": responsesPartPayload(part, "input_audio"),
})
case "input_video":
onlyText = false
chatParts = append(chatParts, map[string]any{
"type": dto.ContentTypeVideoUrl,
"video_url": responsesVideoPartToChatVideoURL(part),
})
default:
onlyText = false
chatParts = append(chatParts, part)
}
}
if onlyText {
return textOnly.String(), nil
}
return chatParts, nil
}
func responsesFunctionCallItemToChatToolCall(item map[string]any) (dto.ToolCallRequest, error) {
name := strings.TrimSpace(kitutil.Interface2String(item["name"]))
if name == "" {
return dto.ToolCallRequest{}, errors.New("function_call item is missing name")
}
return dto.ToolCallRequest{
ID: responsesCallID(item),
Type: "function",
Function: dto.FunctionRequest{
Name: name,
Arguments: responsesArgumentsString(item["arguments"]),
},
}, nil
}
func responsesCustomToolCallItemToChatToolCall(item map[string]any) (dto.ToolCallRequest, error) {
raw, err := kitutil.Marshal(item)
if err != nil {
return dto.ToolCallRequest{}, err
}
return dto.ToolCallRequest{
ID: responsesCallID(item),
Type: dto.CustomType,
Custom: raw,
Function: dto.FunctionRequest{
Name: strings.TrimSpace(kitutil.Interface2String(item["name"])),
Arguments: responsesArgumentsString(item["input"]),
},
}, nil
}
func appendToolCallToLastAssistant(messages []dto.Message, toolCall dto.ToolCallRequest) []dto.Message {
if len(messages) == 0 || messages[len(messages)-1].Role != "assistant" {
messages = append(messages, dto.Message{Role: "assistant"})
}
idx := len(messages) - 1
toolCalls := messages[idx].ParseToolCalls()
toolCalls = append(toolCalls, toolCall)
toolCallsRaw, _ := kitutil.Marshal(toolCalls)
messages[idx].ToolCalls = toolCallsRaw
return messages
}
func responsesRequestToolsToChat(raw json.RawMessage) ([]dto.ToolCallRequest, error) {
if !rawJSONPresent(raw) {
return nil, nil
}
var tools []map[string]any
if err := kitutil.Unmarshal(raw, &tools); err != nil {
return nil, fmt.Errorf("invalid tools: %w", err)
}
out := make([]dto.ToolCallRequest, 0, len(tools))
for _, tool := range tools {
toolType := strings.TrimSpace(kitutil.Interface2String(tool["type"]))
if toolType == "function" {
out = append(out, dto.ToolCallRequest{
Type: "function",
Function: dto.FunctionRequest{
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Description: kitutil.Interface2String(tool["description"]),
Parameters: tool["parameters"],
},
})
continue
}
rawTool, err := kitutil.Marshal(tool)
if err != nil {
return nil, err
}
out = append(out, dto.ToolCallRequest{
Type: toolType,
Custom: rawTool,
})
}
return out, nil
}
func responsesRequestToolChoiceToChat(raw json.RawMessage) (any, error) {
if !rawJSONPresent(raw) {
return nil, nil
}
if kitutil.GetJsonType(raw) == "string" {
var choice string
if err := kitutil.Unmarshal(raw, &choice); err != nil {
return nil, fmt.Errorf("invalid tool_choice: %w", err)
}
return choice, nil
}
var choice map[string]any
if err := kitutil.Unmarshal(raw, &choice); err != nil {
return nil, fmt.Errorf("invalid tool_choice: %w", err)
}
if kitutil.Interface2String(choice["type"]) == "function" {
name := strings.TrimSpace(kitutil.Interface2String(choice["name"]))
if name != "" {
return map[string]any{
"type": "function",
"function": map[string]any{
"name": name,
},
}, nil
}
}
return choice, nil
}
func RequestToolChoiceToChat(raw json.RawMessage) (any, error) {
return responsesRequestToolChoiceToChat(raw)
}
func responsesRequestTextToChatResponseFormat(raw json.RawMessage) (*dto.ResponseFormat, error) {
if !rawJSONPresent(raw) {
return nil, nil
}
var textConfig map[string]any
if err := kitutil.Unmarshal(raw, &textConfig); err != nil {
return nil, fmt.Errorf("invalid text config: %w", err)
}
format, ok := textConfig["format"].(map[string]any)
if !ok {
return nil, nil
}
formatType := strings.TrimSpace(kitutil.Interface2String(format["type"]))
if formatType == "" {
return nil, nil
}
out := &dto.ResponseFormat{Type: formatType}
if formatType == "json_schema" {
schemaRaw, err := kitutil.Marshal(format)
if err != nil {
return nil, err
}
out.JsonSchema = schemaRaw
}
return out, nil
}
func RequestTextToChatResponseFormat(raw json.RawMessage) (*dto.ResponseFormat, error) {
return responsesRequestTextToChatResponseFormat(raw)
}
func responsesImagePartToChatImageURL(part map[string]any) any {
if imageURL, ok := part["image_url"]; ok {
return imageURL
}
imageURL := map[string]any{}
for _, key := range []string{"url", "file_id", "detail"} {
if value, ok := part[key]; ok {
imageURL[key] = value
}
}
if len(imageURL) == 0 {
return part
}
return imageURL
}
func responsesFilePartToChatFile(part map[string]any) any {
if file, ok := part["file"]; ok {
return file
}
file := map[string]any{}
for _, key := range []string{"file_id", "file_data", "filename", "file_url"} {
if value, ok := part[key]; ok {
file[key] = value
}
}
if len(file) == 0 {
return part
}
return file
}
func responsesVideoPartToChatVideoURL(part map[string]any) any {
if videoURL, ok := part["video_url"]; ok {
if videoURLMap, ok := videoURL.(map[string]any); ok {
if url := kitutil.Interface2String(videoURLMap["url"]); url != "" {
return url
}
}
return videoURL
}
if url := kitutil.Interface2String(part["url"]); url != "" {
return url
}
return responsesPartPayload(part, "video_url")
}
func responsesPartPayload(part map[string]any, key string) any {
if value, ok := part[key]; ok {
return value
}
payload := make(map[string]any, len(part))
for k, value := range part {
if k == "type" {
continue
}
payload[k] = value
}
return payload
}
func responsesCallID(item map[string]any) string {
callID := strings.TrimSpace(kitutil.Interface2String(item["call_id"]))
if callID != "" {
return callID
}
return strings.TrimSpace(kitutil.Interface2String(item["id"]))
}
func CallID(item map[string]any) string {
return responsesCallID(item)
}
func responsesArgumentsString(value any) string {
switch v := value.(type) {
case nil:
return ""
case string:
return v
default:
raw, err := kitutil.Marshal(v)
if err != nil {
return kitutil.Interface2String(v)
}
return string(raw)
}
}
func responseToolOutputToChatContent(value any) any {
switch v := value.(type) {
case nil:
return ""
case string:
return v
default:
raw, err := kitutil.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(raw)
}
}
func responsesJSONString(raw json.RawMessage) (string, error) {
if kitutil.GetJsonType(raw) != "string" {
return string(raw), nil
}
var value string
if err := kitutil.Unmarshal(raw, &value); err != nil {
return "", err
}
return value, nil
}
func rawJSONPresent(raw json.RawMessage) bool {
if len(raw) == 0 {
return false
}
return kitutil.GetJsonType(raw) != "null"
}
func JSONString(raw json.RawMessage) (string, error) {
return responsesJSONString(raw)
}
func RawJSONPresent(raw json.RawMessage) bool {
return rawJSONPresent(raw)
}
@@ -0,0 +1,270 @@
package oairesponses
import (
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestResponsesRequestToChatCompletionsRequestInstructionsAndScalarInput(t *testing.T) {
stream := true
temperature := 0.0
topP := 0.9
maxOutputTokens := uint(128)
parallelToolCalls := true
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
Model: "gpt-test",
Instructions: mustRawMessage(t, "system rules"),
Input: mustRawMessage(t, "hello"),
Stream: &stream,
StreamOptions: &dto.StreamOptions{IncludeUsage: true},
MaxOutputTokens: &maxOutputTokens,
Temperature: &temperature,
TopP: &topP,
User: mustRawMessage(t, "user-1"),
Store: mustRawMessage(t, false),
Metadata: mustRawMessage(t, map[string]any{"trace": "abc"}),
ParallelToolCalls: mustRawMessage(t, parallelToolCalls),
PromptCacheKey: mustRawMessage(t, "cache-key"),
PromptCacheRetention: mustRawMessage(t, "24h"),
Reasoning: &dto.Reasoning{Effort: "medium"},
})
require.NoError(t, err)
assert.Equal(t, "gpt-test", got.Model)
require.Len(t, got.Messages, 2)
assert.Equal(t, dto.Message{Role: "system", Content: "system rules"}, got.Messages[0])
assert.Equal(t, dto.Message{Role: "user", Content: "hello"}, got.Messages[1])
assert.Same(t, &stream, got.Stream)
require.NotNil(t, got.StreamOptions)
assert.True(t, got.StreamOptions.IncludeUsage)
assert.Equal(t, maxOutputTokens, lo.FromPtr(got.MaxCompletionTokens))
assert.Equal(t, 0.0, lo.FromPtr(got.Temperature))
assert.Equal(t, 0.9, lo.FromPtr(got.TopP))
assert.True(t, lo.FromPtr(got.ParallelTooCalls))
assert.Equal(t, "cache-key", got.PromptCacheKey)
assert.Equal(t, "medium", got.ReasoningEffort)
assert.Equal(t, `"user-1"`, string(got.User))
assert.Equal(t, `false`, string(got.Store))
assert.Equal(t, "abc", gjson.GetBytes(got.Metadata, "trace").String())
}
func TestResponsesRequestToChatCompletionsRequestMultimodalInput(t *testing.T) {
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
Model: "gpt-test",
Input: mustRawMessage(t, []map[string]any{
{
"role": "user",
"content": []map[string]any{
{"type": "input_text", "text": "look"},
{"type": "input_image", "image_url": "https://example.test/a.png", "detail": "low"},
{"type": "input_file", "file_id": "file_1", "filename": "a.txt"},
{"type": "input_audio", "input_audio": map[string]any{"data": "abc", "format": "wav"}},
{"type": "input_video", "video_url": map[string]any{"url": "https://example.test/v.mp4"}},
},
},
}),
})
require.NoError(t, err)
require.Len(t, got.Messages, 1)
assert.Equal(t, "user", got.Messages[0].Role)
parts := got.Messages[0].ParseContent()
require.Len(t, parts, 5)
assert.Equal(t, dto.ContentTypeText, parts[0].Type)
assert.Equal(t, "look", parts[0].Text)
assert.Equal(t, dto.ContentTypeImageURL, parts[1].Type)
assert.Equal(t, "https://example.test/a.png", parts[1].GetImageMedia().Url)
assert.Equal(t, dto.ContentTypeFile, parts[2].Type)
assert.Equal(t, "file_1", parts[2].GetFile().FileId)
assert.Equal(t, dto.ContentTypeInputAudio, parts[3].Type)
assert.Equal(t, "wav", parts[3].GetInputAudio().Format)
assert.Equal(t, dto.ContentTypeVideoUrl, parts[4].Type)
assert.Equal(t, "https://example.test/v.mp4", parts[4].GetVideoUrl().Url)
}
func TestResponsesRequestToChatCompletionsRequestAssistantTextAndFunctionCallCoexist(t *testing.T) {
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
Model: "gpt-test",
Input: mustRawMessage(t, []map[string]any{
{
"role": "assistant",
"content": []map[string]any{
{"type": "output_text", "text": "I will call."},
},
},
{
"type": "function_call",
"call_id": "call_1",
"name": "lookup",
"arguments": map[string]any{"q": "x"},
},
{
"type": "function_call_output",
"call_id": "call_1",
"output": map[string]any{"ok": true},
},
}),
})
require.NoError(t, err)
require.Len(t, got.Messages, 2)
assert.Equal(t, "assistant", got.Messages[0].Role)
assert.Equal(t, "I will call.", got.Messages[0].StringContent())
toolCalls := got.Messages[0].ParseToolCalls()
require.Len(t, toolCalls, 1)
assert.Equal(t, "call_1", toolCalls[0].ID)
assert.Equal(t, "function", toolCalls[0].Type)
assert.Equal(t, "lookup", toolCalls[0].Function.Name)
assert.JSONEq(t, `{"q":"x"}`, toolCalls[0].Function.Arguments)
assert.Equal(t, "tool", got.Messages[1].Role)
assert.Equal(t, "call_1", got.Messages[1].ToolCallId)
assert.JSONEq(t, `{"ok":true}`, got.Messages[1].StringContent())
}
func TestResponsesRequestToChatCompletionsRequestOnlyFunctionCallCreatesAssistant(t *testing.T) {
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
Model: "gpt-test",
Input: mustRawMessage(t, []map[string]any{
{
"type": "function_call",
"call_id": "call_1",
"name": "lookup",
"arguments": `{"q":"x"}`,
},
}),
})
require.NoError(t, err)
require.Len(t, got.Messages, 1)
assert.Equal(t, "assistant", got.Messages[0].Role)
assert.Nil(t, got.Messages[0].Content)
toolCalls := got.Messages[0].ParseToolCalls()
require.Len(t, toolCalls, 1)
assert.Equal(t, `{"q":"x"}`, toolCalls[0].Function.Arguments)
}
func TestResponsesRequestToChatCompletionsRequestToolsToolChoiceAndTextFormat(t *testing.T) {
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
Model: "gpt-test",
Input: mustRawMessage(t, "hello"),
Tools: mustRawMessage(t, []map[string]any{
{
"type": "function",
"name": "lookup",
"description": "Lookup data",
"parameters": map[string]any{
"type": "object",
"properties": map[string]any{
"q": map[string]any{"type": "string"},
},
},
},
}),
ToolChoice: mustRawMessage(t, map[string]any{
"type": "function",
"name": "lookup",
}),
Text: mustRawMessage(t, map[string]any{
"format": map[string]any{
"type": "json_schema",
"name": "answer",
"schema": map[string]any{"type": "object"},
"strict": true,
},
}),
})
require.NoError(t, err)
require.Len(t, got.Tools, 1)
assert.Equal(t, "function", got.Tools[0].Type)
assert.Equal(t, "lookup", got.Tools[0].Function.Name)
assert.Equal(t, "Lookup data", got.Tools[0].Function.Description)
assert.Equal(t, "object", got.Tools[0].Function.Parameters.(map[string]any)["type"])
assert.Equal(t, map[string]any{
"type": "function",
"function": map[string]any{
"name": "lookup",
},
}, got.ToolChoice)
require.NotNil(t, got.ResponseFormat)
assert.Equal(t, "json_schema", got.ResponseFormat.Type)
assert.Equal(t, "answer", gjson.GetBytes(got.ResponseFormat.JsonSchema, "name").String())
assert.True(t, gjson.GetBytes(got.ResponseFormat.JsonSchema, "strict").Bool())
}
func TestResponsesRequestToChatCompletionsRequestCustomToolCallPreservesRawShape(t *testing.T) {
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
Model: "gpt-test",
Input: mustRawMessage(t, []map[string]any{
{
"type": "custom_tool_call",
"call_id": "call_custom",
"name": "apply_patch",
"input": "patch body",
},
}),
})
require.NoError(t, err)
require.Len(t, got.Messages, 1)
toolCalls := got.Messages[0].ParseToolCalls()
require.Len(t, toolCalls, 1)
assert.Equal(t, dto.CustomType, toolCalls[0].Type)
assert.Equal(t, "call_custom", toolCalls[0].ID)
assert.Equal(t, "apply_patch", toolCalls[0].Function.Name)
assert.Equal(t, "patch body", toolCalls[0].Function.Arguments)
assert.Equal(t, "custom_tool_call", gjson.GetBytes(toolCalls[0].Custom, "type").String())
assert.Equal(t, "patch body", gjson.GetBytes(toolCalls[0].Custom, "input").String())
}
func TestResponsesRequestToChatCompletionsRequestRejectsStatefulFields(t *testing.T) {
tests := []struct {
name string
req *dto.OpenAIResponsesRequest
want string
}{
{
name: "conversation",
req: &dto.OpenAIResponsesRequest{Model: "gpt-test", Conversation: mustRawMessage(t, "conv_1")},
want: "conversation",
},
{
name: "previous response",
req: &dto.OpenAIResponsesRequest{Model: "gpt-test", PreviousResponseID: "resp_1"},
want: "previous_response_id",
},
{
name: "prompt",
req: &dto.OpenAIResponsesRequest{Model: "gpt-test", Prompt: mustRawMessage(t, map[string]any{"id": "pmpt_1"})},
want: "prompt",
},
{
name: "context management",
req: &dto.OpenAIResponsesRequest{Model: "gpt-test", ContextManagement: mustRawMessage(t, map[string]any{"type": "auto"})},
want: "context_management",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ResponsesRequestToChatCompletionsRequest(tt.req)
require.Error(t, err)
assert.Contains(t, err.Error(), tt.want)
assert.Contains(t, err.Error(), "stateful fields")
})
}
}
func mustRawMessage(t *testing.T, value any) []byte {
t.Helper()
raw, err := kitutil.Marshal(value)
require.NoError(t, err)
return raw
}
@@ -0,0 +1,290 @@
package oairesponses
import (
"errors"
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
const (
responsesEventCreated = "response.created"
responsesEventCompleted = "response.completed"
responsesEventDone = "response.done"
responsesEventIncomplete = "response.incomplete"
responsesEventFailed = "response.failed"
responsesEventError = "response.error"
responsesEventOutputTextDelta = "response.output_text.delta"
responsesEventOutputItemAdded = "response.output_item.added"
responsesEventOutputItemDone = "response.output_item.done"
responsesEventFunctionArgsDelta = "response.function_call_arguments.delta"
responsesEventFunctionArgsDone = "response.function_call_arguments.done"
responsesEventCustomToolInputDelta = "response.custom_tool_call_input.delta"
responsesEventCustomToolInputDone = "response.custom_tool_call_input.done"
responsesEventReasoningSummaryDelta = "response.reasoning_summary_text.delta"
responsesEventReasoningSummaryDone = "response.reasoning_summary_text.done"
responsesEventReasoningTextDelta = "response.reasoning_text.delta"
responsesEventReasoningTextDone = "response.reasoning_text.done"
responsesOutputTypeFunctionCall = "function_call"
responsesOutputTypeCustomToolCall = "custom_tool_call"
responsesOutputTypeMessage = "message"
responsesOutputTypeReasoning = "reasoning"
responsesIncompleteReasonContentFilter = "content_filter"
responsesIncompleteReasonMaxTokens = "max_output_tokens"
)
func ResponsesFinishReasonFromStatus(resp *dto.OpenAIResponsesResponse) (string, bool) {
if resp == nil {
return "", false
}
status := responseStatusString(resp)
if status != "incomplete" {
return "", false
}
reason := ""
if resp.IncompleteDetails != nil {
reason = strings.TrimSpace(resp.IncompleteDetails.Reason)
}
if reason == responsesIncompleteReasonContentFilter {
return "content_filter", true
}
return "length", true
}
func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesResponse, id string) (*dto.OpenAITextResponse, *dto.Usage, error) {
if resp == nil {
return nil, nil, errors.New("response is nil")
}
text := ExtractOutputTextFromResponses(resp)
reasoning := ExtractReasoningTextFromResponses(resp)
usage := UsageFromResponsesUsage(resp.Usage)
created := resp.CreatedAt
var toolCalls []dto.ToolCallResponse
if len(resp.Output) > 0 {
for _, out := range resp.Output {
if !isResponsesToolOutputType(out.Type) {
continue
}
name := strings.TrimSpace(out.Name)
if name == "" {
continue
}
callId := strings.TrimSpace(out.CallId)
if callId == "" {
callId = strings.TrimSpace(out.ID)
}
toolCalls = append(toolCalls, dto.ToolCallResponse{
ID: callId,
Type: "function",
Function: dto.FunctionResponse{
Name: name,
Arguments: out.ArgumentsString(),
},
})
}
}
finishReason := "stop"
if mappedReason, ok := ResponsesFinishReasonFromStatus(resp); ok {
finishReason = mappedReason
} else if len(toolCalls) > 0 {
finishReason = "tool_calls"
}
msg := dto.Message{
Role: "assistant",
Content: text,
}
if reasoning != "" {
msg.ReasoningContent = &reasoning
}
if len(toolCalls) > 0 {
msg.SetToolCalls(toolCalls)
}
out := &dto.OpenAITextResponse{
Id: id,
Object: "chat.completion",
Created: created,
Model: resp.Model,
Choices: []dto.OpenAITextResponseChoice{
{
Index: 0,
Message: msg,
FinishReason: finishReason,
},
},
Usage: *usage,
}
return out, usage, nil
}
func UsageFromResponsesUsage(src *dto.Usage) *dto.Usage {
usage := &dto.Usage{}
if src == nil {
return usage
}
usage.UsageSemantic = src.UsageSemantic
usage.UsageSource = src.UsageSource
usage.BillingUsage = dto.CloneBillingUsage(src.BillingUsage)
if usage.BillingUsage == nil {
usage.BillingUsage = dto.NewOpenAIResponsesBillingUsage(src)
}
usage.Cost = src.Cost
if src.InputTokens != 0 {
usage.PromptTokens = src.InputTokens
usage.InputTokens = src.InputTokens
}
if src.OutputTokens != 0 {
usage.CompletionTokens = src.OutputTokens
usage.OutputTokens = src.OutputTokens
}
if src.TotalTokens != 0 {
usage.TotalTokens = src.TotalTokens
} else {
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
}
if src.InputTokensDetails != nil {
usage.PromptTokensDetails.CachedTokens = src.InputTokensDetails.CachedTokens
usage.PromptTokensDetails.CachedCreationTokens = src.InputTokensDetails.CachedCreationTokens
usage.PromptTokensDetails.CacheWriteTokens = src.InputTokensDetails.CacheWriteTokens
usage.PromptTokensDetails.TextTokens = src.InputTokensDetails.TextTokens
usage.PromptTokensDetails.ImageTokens = src.InputTokensDetails.ImageTokens
usage.PromptTokensDetails.AudioTokens = src.InputTokensDetails.AudioTokens
}
if src.CompletionTokenDetails.ReasoningTokens != 0 ||
src.CompletionTokenDetails.TextTokens != 0 ||
src.CompletionTokenDetails.AudioTokens != 0 ||
src.CompletionTokenDetails.ImageTokens != 0 {
usage.CompletionTokenDetails.ReasoningTokens = src.CompletionTokenDetails.ReasoningTokens
usage.CompletionTokenDetails.TextTokens = src.CompletionTokenDetails.TextTokens
usage.CompletionTokenDetails.AudioTokens = src.CompletionTokenDetails.AudioTokens
usage.CompletionTokenDetails.ImageTokens = src.CompletionTokenDetails.ImageTokens
}
usage.ClaudeCacheCreation5mTokens = src.ClaudeCacheCreation5mTokens
usage.ClaudeCacheCreation1hTokens = src.ClaudeCacheCreation1hTokens
return usage
}
func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
if resp == nil || len(resp.Output) == 0 {
return ""
}
var sb strings.Builder
// Prefer assistant message outputs.
for _, out := range resp.Output {
if out.Type != "message" {
continue
}
if out.Role != "" && out.Role != "assistant" {
continue
}
for _, c := range out.Content {
if c.Type == "output_text" && c.Text != "" {
sb.WriteString(c.Text)
}
}
}
if sb.Len() > 0 {
return sb.String()
}
for _, out := range resp.Output {
for _, c := range out.Content {
if c.Text != "" {
sb.WriteString(c.Text)
}
}
}
return sb.String()
}
func ExtractReasoningTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
if resp == nil || len(resp.Output) == 0 {
return ""
}
var sb strings.Builder
for _, out := range resp.Output {
if out.Type != responsesOutputTypeReasoning {
continue
}
for _, c := range out.Content {
if c.Text != "" {
sb.WriteString(c.Text)
}
}
}
return sb.String()
}
func responseStatusString(resp *dto.OpenAIResponsesResponse) string {
if resp == nil || len(resp.Status) == 0 {
return ""
}
var status string
_ = kitutil.Unmarshal(resp.Status, &status)
return strings.TrimSpace(status)
}
func ensureIncompleteResponse(resp *dto.OpenAIResponsesResponse) *dto.OpenAIResponsesResponse {
if resp == nil {
resp = &dto.OpenAIResponsesResponse{}
}
if len(resp.Status) == 0 {
resp.Status = []byte(`"incomplete"`)
}
return resp
}
func isResponsesToolOutputType(outputType string) bool {
return outputType == responsesOutputTypeFunctionCall || outputType == responsesOutputTypeCustomToolCall
}
func responseStreamEventItemID(event *dto.ResponsesStreamResponse) string {
if event == nil {
return ""
}
if event.Item != nil {
if itemID := strings.TrimSpace(event.Item.ID); itemID != "" {
return itemID
}
}
return strings.TrimSpace(event.ItemID)
}
func fallbackToolKey(itemID string, callID string, outputIndex *int) string {
if outputIndex != nil {
return fmt.Sprintf("output:%d", *outputIndex)
}
if strings.TrimSpace(itemID) != "" {
return "item:" + strings.TrimSpace(itemID)
}
if strings.TrimSpace(callID) != "" {
return "call:" + strings.TrimSpace(callID)
}
return ""
}
func fallbackCallID(event *dto.ResponsesStreamResponse) string {
if event == nil {
return ""
}
if strings.TrimSpace(event.ItemID) != "" {
return strings.TrimSpace(event.ItemID)
}
if event.OutputIndex != nil {
return fmt.Sprintf("call_output_%d", *event.OutputIndex)
}
return ""
}
@@ -0,0 +1,448 @@
package oairesponses
import (
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResponsesResponseToChatCompletionsPreservesTextAndToolCalls(t *testing.T) {
resp := &dto.OpenAIResponsesResponse{
ID: "resp_1",
CreatedAt: 123,
Model: "gpt-test",
Status: []byte(`"completed"`),
Output: []dto.ResponsesOutput{
{
Type: responsesOutputTypeMessage,
Role: "assistant",
Content: []dto.ResponsesOutputContent{
{Type: "output_text", Text: "I will call a tool."},
},
},
{
Type: responsesOutputTypeFunctionCall,
ID: "fc_1",
CallId: "call_1",
Name: "lookup",
Arguments: []byte(`{"q":"x"}`),
},
},
Usage: &dto.Usage{InputTokens: 3, OutputTokens: 4, TotalTokens: 7},
}
chat, usage, err := ResponsesResponseToChatCompletionsResponse(resp, "chatcmpl_1")
require.NoError(t, err)
require.NotNil(t, usage)
require.Len(t, chat.Choices, 1)
assert.Equal(t, "tool_calls", chat.Choices[0].FinishReason)
assert.Equal(t, "I will call a tool.", chat.Choices[0].Message.StringContent())
toolCalls := chat.Choices[0].Message.ParseToolCalls()
require.Len(t, toolCalls, 1)
assert.Equal(t, "call_1", toolCalls[0].ID)
assert.Equal(t, "lookup", toolCalls[0].Function.Name)
assert.Equal(t, `{"q":"x"}`, toolCalls[0].Function.Arguments)
assert.Equal(t, 7, usage.TotalTokens)
}
func TestResponsesResponseToChatCompletionsPreservesReasoningSummary(t *testing.T) {
resp := &dto.OpenAIResponsesResponse{
ID: "resp_1",
Model: "gpt-test",
Status: []byte(`"completed"`),
Output: []dto.ResponsesOutput{
{
Type: responsesOutputTypeReasoning,
Content: []dto.ResponsesOutputContent{
{Type: "summary_text", Text: "first summary"},
{Type: "summary_text", Text: "\n\nsecond summary"},
},
},
{
Type: responsesOutputTypeMessage,
Role: "assistant",
Content: []dto.ResponsesOutputContent{
{Type: "output_text", Text: "final"},
},
},
},
}
chat, _, err := ResponsesResponseToChatCompletionsResponse(resp, "chatcmpl_1")
require.NoError(t, err)
assert.Equal(t, "first summary\n\nsecond summary", chat.Choices[0].Message.GetReasoningContent())
assert.Equal(t, "final", chat.Choices[0].Message.StringContent())
}
func TestResponsesFinishReasonFromIncompleteStatus(t *testing.T) {
tests := []struct {
name string
reason string
want string
}{
{name: "max output", reason: responsesIncompleteReasonMaxTokens, want: "length"},
{name: "content filter", reason: responsesIncompleteReasonContentFilter, want: "content_filter"},
{name: "unknown", reason: "other", want: "length"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := ResponsesFinishReasonFromStatus(&dto.OpenAIResponsesResponse{
Status: []byte(`"incomplete"`),
IncompleteDetails: &dto.IncompleteDetails{Reason: tt.reason},
})
require.True(t, ok)
assert.Equal(t, tt.want, got)
})
}
}
func TestResponsesStreamEventToChatChunksUsesOutputIndexForToolArguments(t *testing.T) {
state := newTestResponsesStreamState()
outputIndex := 1
var chunks []dto.ChatCompletionsStreamResponse
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{Type: responsesEventCreated})...)
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{Type: responsesEventOutputTextDelta, Delta: "text before tool"})...)
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDelta,
OutputIndex: &outputIndex,
Delta: `{"cmd":"ls"}`,
})...)
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: &outputIndex,
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: "fc_1",
CallId: "call_1",
Name: "exec",
},
})...)
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventCompleted,
Response: &dto.OpenAIResponsesResponse{
Status: []byte(`"completed"`),
Usage: &dto.Usage{InputTokens: 1, OutputTokens: 2, TotalTokens: 3},
},
})...)
require.Len(t, chunks, 4)
assert.Equal(t, "assistant", chunks[0].Choices[0].Delta.Role)
assert.Equal(t, "text before tool", chunks[1].Choices[0].Delta.GetContentString())
tool := chunks[2].Choices[0].Delta.ToolCalls[0]
require.NotNil(t, tool.Index)
assert.Equal(t, 0, *tool.Index)
assert.Equal(t, "call_1", tool.ID)
assert.Equal(t, "exec", tool.Function.Name)
assert.Equal(t, `{"cmd":"ls"}`, tool.Function.Arguments)
require.NotNil(t, chunks[3].Choices[0].FinishReason)
assert.Equal(t, "tool_calls", *chunks[3].Choices[0].FinishReason)
assert.Equal(t, 3, state.Usage.TotalTokens)
}
func TestResponsesStreamEventToChatChunksDoesNotDuplicatePendingArgsWithOutputIndexAndItemID(t *testing.T) {
state := newTestResponsesStreamState()
outputIndex := 1
var chunks []dto.ChatCompletionsStreamResponse
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{Type: responsesEventCreated})...)
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDelta,
OutputIndex: &outputIndex,
ItemID: "fc_1",
Delta: `{"q":"x"}`,
})...)
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: &outputIndex,
ItemID: "fc_1",
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: "fc_1",
CallId: "call_1",
Name: "lookup",
},
})...)
require.Len(t, chunks, 2)
tool := chunks[1].Choices[0].Delta.ToolCalls[0]
assert.Equal(t, "call_1", tool.ID)
assert.Equal(t, "lookup", tool.Function.Name)
assert.Equal(t, `{"q":"x"}`, tool.Function.Arguments)
assert.Empty(t, state.pendingArgsByOutputIndex)
assert.Empty(t, state.pendingArgsByItemID)
}
func TestResponsesStreamEventToChatChunksDrainsItemOnlyPendingArgsWhenOutputIndexArrives(t *testing.T) {
state := newTestResponsesStreamState()
outputIndex := 1
var chunks []dto.ChatCompletionsStreamResponse
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{Type: responsesEventCreated})...)
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDelta,
ItemID: "fc_1",
Delta: `{"q":"x"}`,
})...)
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: &outputIndex,
ItemID: "fc_1",
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
CallId: "call_1",
Name: "lookup",
},
})...)
require.Len(t, chunks, 2)
tool := chunks[1].Choices[0].Delta.ToolCalls[0]
assert.Equal(t, "call_1", tool.ID)
assert.Equal(t, "lookup", tool.Function.Name)
assert.Equal(t, `{"q":"x"}`, tool.Function.Arguments)
assert.Empty(t, state.pendingArgsByOutputIndex)
assert.Empty(t, state.pendingArgsByItemID)
}
func TestResponsesStreamEventToChatChunksCustomToolAndReasoning(t *testing.T) {
state := newTestResponsesStreamState()
outputIndex := 0
chunks := mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventReasoningTextDelta,
Delta: "thinking",
})
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: &outputIndex,
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeCustomToolCall,
ID: "ct_1",
CallId: "call_custom",
Name: "apply_patch",
},
})...)
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventCustomToolInputDelta,
OutputIndex: &outputIndex,
Delta: "patch body",
})...)
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventIncomplete,
Response: &dto.OpenAIResponsesResponse{
IncompleteDetails: &dto.IncompleteDetails{Reason: responsesIncompleteReasonContentFilter},
},
})...)
require.Len(t, chunks, 5)
assert.Equal(t, "thinking", chunks[1].Choices[0].Delta.GetReasoningContent())
assert.Equal(t, "apply_patch", chunks[2].Choices[0].Delta.ToolCalls[0].Function.Name)
assert.Equal(t, "patch body", chunks[3].Choices[0].Delta.ToolCalls[0].Function.Arguments)
require.NotNil(t, chunks[4].Choices[0].FinishReason)
assert.Equal(t, "content_filter", *chunks[4].Choices[0].FinishReason)
}
func TestResponsesStreamEventToChatChunksUsesTerminalDoneOutput(t *testing.T) {
state := newTestResponsesStreamState()
chunks := mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventDone,
Response: &dto.OpenAIResponsesResponse{
Status: []byte(`"completed"`),
Output: []dto.ResponsesOutput{
{
Type: responsesOutputTypeMessage,
Role: "assistant",
Content: []dto.ResponsesOutputContent{
{Type: "output_text", Text: "terminal text"},
},
},
{
Type: responsesOutputTypeFunctionCall,
ID: "fc_1",
CallId: "call_1",
Name: "lookup",
Arguments: []byte(`{"q":"x"}`),
},
},
},
})
require.Len(t, chunks, 4)
assert.Equal(t, "assistant", chunks[0].Choices[0].Delta.Role)
assert.Equal(t, "terminal text", chunks[1].Choices[0].Delta.GetContentString())
tool := chunks[2].Choices[0].Delta.ToolCalls[0]
assert.Equal(t, "lookup", tool.Function.Name)
assert.Equal(t, `{"q":"x"}`, tool.Function.Arguments)
require.NotNil(t, chunks[3].Choices[0].FinishReason)
assert.Equal(t, "tool_calls", *chunks[3].Choices[0].FinishReason)
}
func TestResponsesStreamEventToChatChunksDoesNotResendToolOnTerminalOutput(t *testing.T) {
state := newTestResponsesStreamState()
outputIndex := 0
var chunks []dto.ChatCompletionsStreamResponse
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{Type: responsesEventCreated})...)
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: &outputIndex,
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: "fc_1",
CallId: "call_1",
Name: "lookup",
},
})...)
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDelta,
OutputIndex: &outputIndex,
Delta: `{"q":"x"}`,
})...)
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventCompleted,
Response: &dto.OpenAIResponsesResponse{
Status: []byte(`"completed"`),
Output: []dto.ResponsesOutput{
{
Type: responsesOutputTypeFunctionCall,
ID: "fc_1",
CallId: "call_1",
Name: "lookup",
Arguments: []byte(`{"q":"x"}`),
},
},
},
})...)
totalArgs := ""
toolIndexes := map[int]bool{}
var finishReason string
for _, chunk := range chunks {
for _, choice := range chunk.Choices {
for _, tc := range choice.Delta.ToolCalls {
require.NotNil(t, tc.Index)
toolIndexes[*tc.Index] = true
totalArgs += tc.Function.Arguments
}
if choice.FinishReason != nil {
finishReason = *choice.FinishReason
}
}
}
assert.Equal(t, map[int]bool{0: true}, toolIndexes)
assert.Equal(t, `{"q":"x"}`, totalArgs)
assert.Equal(t, "tool_calls", finishReason)
}
func TestFinalizeResponsesToChatStreamFlushesPendingDeltaOnlyArguments(t *testing.T) {
state := newTestResponsesStreamState()
outputIndex := 2
_, err := ResponsesStreamEventToChatChunks(&dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDelta,
OutputIndex: &outputIndex,
Delta: `{"pending":true}`,
}, state)
require.NoError(t, err)
chunks := FinalizeResponsesToChatStream(state)
require.Len(t, chunks, 3)
tool := chunks[1].Choices[0].Delta.ToolCalls[0]
assert.Equal(t, "call_output_2", tool.ID)
assert.Equal(t, `{"pending":true}`, tool.Function.Arguments)
require.NotNil(t, chunks[2].Choices[0].FinishReason)
assert.Equal(t, "tool_calls", *chunks[2].Choices[0].FinishReason)
}
func TestResponsesStreamEventToChatChunksFailedEventReturnsError(t *testing.T) {
_, err := ResponsesStreamEventToChatChunks(&dto.ResponsesStreamResponse{Type: responsesEventFailed}, newTestResponsesStreamState())
require.Error(t, err)
}
func TestResponsesBufferedAccumulatorSupplementsEmptyTerminalOutput(t *testing.T) {
acc := NewResponsesBufferedAccumulator()
outputIndex := 1
acc.ProcessEvent(&dto.ResponsesStreamResponse{Type: responsesEventOutputTextDelta, Delta: "buffered text"})
acc.ProcessEvent(&dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: &outputIndex,
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: "fc_1",
CallId: "call_1",
Name: "lookup",
},
})
acc.ProcessEvent(&dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDelta,
OutputIndex: &outputIndex,
Delta: `{"q":"x"}`,
})
resp := &dto.OpenAIResponsesResponse{
Status: []byte(`"completed"`),
Model: "gpt-test",
}
acc.SupplementResponseOutput(resp)
chat, _, err := ResponsesResponseToChatCompletionsResponse(resp, "chatcmpl_1")
require.NoError(t, err)
assert.Equal(t, "buffered text", chat.Choices[0].Message.StringContent())
toolCalls := chat.Choices[0].Message.ParseToolCalls()
require.Len(t, toolCalls, 1)
assert.Equal(t, `{"q":"x"}`, toolCalls[0].Function.Arguments)
}
func TestResponsesBufferedAccumulatorDoesNotDuplicatePendingArgsWithOutputIndexAndItemID(t *testing.T) {
acc := NewResponsesBufferedAccumulator()
outputIndex := 1
acc.ProcessEvent(&dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDelta,
OutputIndex: &outputIndex,
ItemID: "fc_1",
Delta: `{"q":"x"}`,
})
acc.ProcessEvent(&dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: &outputIndex,
ItemID: "fc_1",
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: "fc_1",
CallId: "call_1",
Name: "lookup",
},
})
resp := &dto.OpenAIResponsesResponse{
Status: []byte(`"completed"`),
Model: "gpt-test",
}
acc.SupplementResponseOutput(resp)
chat, _, err := ResponsesResponseToChatCompletionsResponse(resp, "chatcmpl_1")
require.NoError(t, err)
toolCalls := chat.Choices[0].Message.ParseToolCalls()
require.Len(t, toolCalls, 1)
assert.Equal(t, `{"q":"x"}`, toolCalls[0].Function.Arguments)
assert.Empty(t, acc.pendingByOutputIndex)
assert.Empty(t, acc.pendingByItemID)
}
func newTestResponsesStreamState() *ResponsesToChatStreamState {
state := NewResponsesToChatStreamState("gpt-test", false)
state.ID = "chatcmpl_test"
state.Created = 123
return state
}
func mustStreamChunks(t *testing.T, state *ResponsesToChatStreamState, event *dto.ResponsesStreamResponse) []dto.ChatCompletionsStreamResponse {
t.Helper()
chunks, err := ResponsesStreamEventToChatChunks(event, state)
require.NoError(t, err)
return chunks
}
@@ -0,0 +1,719 @@
package oairesponses
import (
"fmt"
"sort"
"strings"
"time"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
type ResponsesToChatStreamState struct {
ID string
Model string
Created int64
IncludeUsage bool
Usage *dto.Usage
sentStart bool
finalized bool
hasSentText bool
sawToolCall bool
hasSentReasoning bool
needsReasoningSummaryBreak bool
nextToolIndex int
toolByKey map[string]*responsesStreamTool
outputIndexToKey map[int]string
itemIDToKey map[string]string
callIDToKey map[string]string
pendingArgsByOutputIndex map[int]string
pendingArgsByItemID map[string]string
usageText strings.Builder
}
type responsesStreamTool struct {
Key string
CallID string
ItemID string
Name string
Arguments string
Index int
Sent bool
NameSent bool
ArgsSentAt int
}
func NewResponsesToChatStreamState(model string, includeUsage bool) *ResponsesToChatStreamState {
return &ResponsesToChatStreamState{
Model: model,
Created: time.Now().Unix(),
IncludeUsage: includeUsage,
Usage: &dto.Usage{},
toolByKey: make(map[string]*responsesStreamTool),
outputIndexToKey: make(map[int]string),
itemIDToKey: make(map[string]string),
callIDToKey: make(map[string]string),
pendingArgsByOutputIndex: make(map[int]string),
pendingArgsByItemID: make(map[string]string),
}
}
func (s *ResponsesToChatStreamState) UsageText() string {
if s == nil {
return ""
}
return s.usageText.String()
}
func ResponsesStreamEventToChatChunks(event *dto.ResponsesStreamResponse, state *ResponsesToChatStreamState) ([]dto.ChatCompletionsStreamResponse, error) {
if event == nil || state == nil {
return nil, nil
}
switch event.Type {
case responsesEventCreated:
state.applyResponseMetadata(event.Response)
return state.ensureStart(), nil
case responsesEventReasoningSummaryDelta, responsesEventReasoningTextDelta:
return state.reasoningDelta(event.Delta), nil
case responsesEventReasoningSummaryDone, responsesEventReasoningTextDone:
if state.hasSentReasoning {
state.needsReasoningSummaryBreak = true
}
return nil, nil
case responsesEventOutputTextDelta:
return state.textDelta(event.Delta), nil
case responsesEventOutputItemAdded, responsesEventOutputItemDone:
if event.Item == nil || !isResponsesToolOutputType(event.Item.Type) {
return nil, nil
}
return state.toolItem(event), nil
case responsesEventFunctionArgsDelta, responsesEventCustomToolInputDelta:
return state.toolArgumentsDelta(event), nil
case responsesEventFunctionArgsDone, responsesEventCustomToolInputDone:
return state.flushPendingTool(event), nil
case responsesEventCompleted, responsesEventDone, responsesEventIncomplete:
response := event.Response
if event.Type == responsesEventIncomplete {
response = ensureIncompleteResponse(response)
}
state.applyResponseMetadata(response)
chunks := state.terminalOutputChunks(response)
chunks = append(chunks, state.finalize(response)...)
return chunks, nil
case responsesEventFailed, responsesEventError:
return nil, fmt.Errorf("responses stream error: %s", event.Type)
default:
return nil, nil
}
}
func FinalizeResponsesToChatStream(state *ResponsesToChatStreamState) []dto.ChatCompletionsStreamResponse {
if state == nil {
return nil
}
return state.finalize(nil)
}
func (s *ResponsesToChatStreamState) applyResponseMetadata(response *dto.OpenAIResponsesResponse) {
if response == nil {
return
}
if response.ID != "" && s.ID == "" {
s.ID = response.ID
}
if response.Model != "" {
s.Model = response.Model
}
if response.CreatedAt != 0 {
s.Created = int64(response.CreatedAt)
}
if response.Usage != nil {
s.Usage = UsageFromResponsesUsage(response.Usage)
}
}
func (s *ResponsesToChatStreamState) ensureStart() []dto.ChatCompletionsStreamResponse {
if s.sentStart {
return nil
}
s.sentStart = true
return []dto.ChatCompletionsStreamResponse{s.makeChunk(dto.ChatCompletionsStreamResponseChoiceDelta{
Role: "assistant",
Content: kitutil.GetPointer(""),
}, nil)}
}
func (s *ResponsesToChatStreamState) textDelta(delta string) []dto.ChatCompletionsStreamResponse {
if delta == "" {
return nil
}
s.usageText.WriteString(delta)
s.hasSentText = true
chunks := s.ensureStart()
chunks = append(chunks, s.makeChunk(dto.ChatCompletionsStreamResponseChoiceDelta{
Content: &delta,
}, nil))
return chunks
}
func (s *ResponsesToChatStreamState) terminalOutputChunks(response *dto.OpenAIResponsesResponse) []dto.ChatCompletionsStreamResponse {
if s == nil || response == nil || len(response.Output) == 0 {
return nil
}
var chunks []dto.ChatCompletionsStreamResponse
for i := range response.Output {
out := &response.Output[i]
switch {
case out.Type == responsesOutputTypeMessage && !s.hasSentText:
var text strings.Builder
for _, c := range out.Content {
if c.Type == "output_text" && c.Text != "" {
text.WriteString(c.Text)
}
}
chunks = append(chunks, s.textDelta(text.String())...)
case out.Type == responsesOutputTypeReasoning && !s.hasSentReasoning:
var reasoning strings.Builder
for _, c := range out.Content {
if c.Text != "" {
reasoning.WriteString(c.Text)
}
}
chunks = append(chunks, s.reasoningDelta(reasoning.String())...)
case isResponsesToolOutputType(out.Type):
chunks = append(chunks, s.toolItem(&dto.ResponsesStreamResponse{Item: out})...)
}
}
return chunks
}
func (s *ResponsesToChatStreamState) reasoningDelta(delta string) []dto.ChatCompletionsStreamResponse {
if delta == "" {
return nil
}
if s.needsReasoningSummaryBreak {
if strings.HasPrefix(delta, "\n\n") {
s.needsReasoningSummaryBreak = false
} else if strings.HasPrefix(delta, "\n") {
delta = "\n" + delta
s.needsReasoningSummaryBreak = false
} else {
delta = "\n\n" + delta
s.needsReasoningSummaryBreak = false
}
}
s.usageText.WriteString(delta)
chunks := s.ensureStart()
chunks = append(chunks, s.makeChunk(dto.ChatCompletionsStreamResponseChoiceDelta{
ReasoningContent: &delta,
}, nil))
s.hasSentReasoning = true
return chunks
}
func (s *ResponsesToChatStreamState) toolItem(event *dto.ResponsesStreamResponse) []dto.ChatCompletionsStreamResponse {
tool := s.ensureToolForEvent(event)
if tool == nil {
return nil
}
args := event.Item.ArgumentsString()
if args != "" {
tool.Arguments = args
}
return s.toolDelta(tool, "")
}
func (s *ResponsesToChatStreamState) toolArgumentsDelta(event *dto.ResponsesStreamResponse) []dto.ChatCompletionsStreamResponse {
if event.Delta == "" {
return nil
}
tool := s.findToolForEvent(event)
if tool == nil {
if event.OutputIndex != nil {
s.pendingArgsByOutputIndex[*event.OutputIndex] += event.Delta
} else if itemID := strings.TrimSpace(event.ItemID); itemID != "" {
s.pendingArgsByItemID[itemID] += event.Delta
}
return nil
}
tool.Arguments += event.Delta
return s.toolDelta(tool, event.Delta)
}
func (s *ResponsesToChatStreamState) flushPendingTool(event *dto.ResponsesStreamResponse) []dto.ChatCompletionsStreamResponse {
tool := s.findToolForEvent(event)
if tool == nil {
tool = s.ensureFallbackToolForEvent(event)
}
if tool == nil {
return nil
}
return s.toolDelta(tool, "")
}
func (s *ResponsesToChatStreamState) ensureToolForEvent(event *dto.ResponsesStreamResponse) *responsesStreamTool {
if event == nil || event.Item == nil {
return nil
}
key := s.keyForEvent(event)
if key == "" {
key = fallbackToolKey(event.Item.ID, event.Item.CallId, event.OutputIndex)
}
if key == "" {
return nil
}
tool := s.toolByKey[key]
if tool == nil {
if itemID := responseStreamEventItemID(event); itemID != "" {
if existingKey := s.itemIDToKey[itemID]; existingKey != "" {
tool = s.toolByKey[existingKey]
}
}
if tool == nil {
if callID := strings.TrimSpace(event.Item.CallId); callID != "" {
if existingKey := s.callIDToKey[callID]; existingKey != "" {
tool = s.toolByKey[existingKey]
}
}
}
if tool != nil {
s.toolByKey[key] = tool
}
}
if tool == nil {
tool = &responsesStreamTool{Key: key, Index: s.nextToolIndex}
s.nextToolIndex++
s.toolByKey[key] = tool
}
if event.OutputIndex != nil {
s.outputIndexToKey[*event.OutputIndex] = key
if pending := s.pendingArgsByOutputIndex[*event.OutputIndex]; pending != "" {
tool.Arguments += pending
delete(s.pendingArgsByOutputIndex, *event.OutputIndex)
}
}
if itemID := responseStreamEventItemID(event); itemID != "" {
tool.ItemID = itemID
s.itemIDToKey[itemID] = key
if pending := s.pendingArgsByItemID[itemID]; pending != "" {
tool.Arguments += pending
delete(s.pendingArgsByItemID, itemID)
}
}
if callID := strings.TrimSpace(event.Item.CallId); callID != "" {
tool.CallID = callID
s.callIDToKey[callID] = key
} else if tool.CallID == "" {
tool.CallID = strings.TrimSpace(event.Item.ID)
}
if name := strings.TrimSpace(event.Item.Name); name != "" {
tool.Name = name
}
return tool
}
func (s *ResponsesToChatStreamState) findToolForEvent(event *dto.ResponsesStreamResponse) *responsesStreamTool {
if event == nil {
return nil
}
if event.OutputIndex != nil {
if key := s.outputIndexToKey[*event.OutputIndex]; key != "" {
return s.toolByKey[key]
}
}
if itemID := strings.TrimSpace(event.ItemID); itemID != "" {
if key := s.itemIDToKey[itemID]; key != "" {
return s.toolByKey[key]
}
}
if event.Item != nil {
if key := s.keyForEvent(event); key != "" {
return s.toolByKey[key]
}
}
return nil
}
func (s *ResponsesToChatStreamState) ensureFallbackToolForEvent(event *dto.ResponsesStreamResponse) *responsesStreamTool {
if event == nil {
return nil
}
key := ""
if event.OutputIndex != nil {
key = fmt.Sprintf("output:%d", *event.OutputIndex)
}
if key == "" && strings.TrimSpace(event.ItemID) != "" {
key = "item:" + strings.TrimSpace(event.ItemID)
}
if key == "" {
return nil
}
tool := s.toolByKey[key]
if tool == nil {
tool = &responsesStreamTool{
Key: key,
Index: s.nextToolIndex,
CallID: fallbackCallID(event),
}
s.nextToolIndex++
s.toolByKey[key] = tool
}
if event.OutputIndex != nil {
s.outputIndexToKey[*event.OutputIndex] = key
if pending := s.pendingArgsByOutputIndex[*event.OutputIndex]; pending != "" {
tool.Arguments += pending
delete(s.pendingArgsByOutputIndex, *event.OutputIndex)
}
}
if itemID := responseStreamEventItemID(event); itemID != "" {
tool.ItemID = itemID
s.itemIDToKey[itemID] = key
if pending := s.pendingArgsByItemID[itemID]; pending != "" {
tool.Arguments += pending
delete(s.pendingArgsByItemID, itemID)
}
}
return tool
}
func (s *ResponsesToChatStreamState) toolDelta(tool *responsesStreamTool, explicitDelta string) []dto.ChatCompletionsStreamResponse {
if tool == nil {
return nil
}
argsDelta := explicitDelta
if argsDelta == "" && len(tool.Arguments) > tool.ArgsSentAt {
argsDelta = tool.Arguments[tool.ArgsSentAt:]
}
if tool.Sent && argsDelta == "" && (tool.Name == "" || tool.NameSent) {
return nil
}
chunks := s.ensureStart()
callID := strings.TrimSpace(tool.CallID)
if callID == "" {
callID = tool.Key
}
responseTool := dto.ToolCallResponse{
ID: callID,
Type: "function",
Function: dto.FunctionResponse{
Arguments: argsDelta,
},
}
responseTool.SetIndex(tool.Index)
if !tool.NameSent && tool.Name != "" {
responseTool.Function.Name = tool.Name
tool.NameSent = true
}
if !tool.Sent {
tool.Sent = true
}
if argsDelta != "" {
tool.ArgsSentAt += len(argsDelta)
s.usageText.WriteString(argsDelta)
}
if responseTool.Function.Name != "" {
s.usageText.WriteString(responseTool.Function.Name)
}
chunks = append(chunks, s.makeChunk(dto.ChatCompletionsStreamResponseChoiceDelta{
ToolCalls: []dto.ToolCallResponse{responseTool},
}, nil))
s.sawToolCall = true
return chunks
}
func (s *ResponsesToChatStreamState) finalize(response *dto.OpenAIResponsesResponse) []dto.ChatCompletionsStreamResponse {
if s.finalized {
return nil
}
s.finalized = true
chunks := s.flushAllPendingTools()
chunks = append(chunks, s.ensureStart()...)
finishReason := "stop"
if mappedReason, ok := ResponsesFinishReasonFromStatus(response); ok {
finishReason = mappedReason
} else if s.sawToolCall {
finishReason = "tool_calls"
}
chunks = append(chunks, s.makeChunk(dto.ChatCompletionsStreamResponseChoiceDelta{}, &finishReason))
if s.IncludeUsage && s.Usage != nil {
chunks = append(chunks, dto.ChatCompletionsStreamResponse{
Id: s.ID,
Object: "chat.completion.chunk",
Created: s.Created,
Model: s.Model,
Choices: make([]dto.ChatCompletionsStreamResponseChoice, 0),
Usage: s.Usage,
})
}
return chunks
}
func (s *ResponsesToChatStreamState) flushAllPendingTools() []dto.ChatCompletionsStreamResponse {
keys := make([]string, 0, len(s.toolByKey)+len(s.pendingArgsByOutputIndex)+len(s.pendingArgsByItemID))
seen := make(map[string]bool)
for key := range s.toolByKey {
keys = append(keys, key)
seen[key] = true
}
for outputIndex := range s.pendingArgsByOutputIndex {
key := fmt.Sprintf("output:%d", outputIndex)
if !seen[key] {
keys = append(keys, key)
seen[key] = true
}
}
for itemID := range s.pendingArgsByItemID {
key := "item:" + itemID
if !seen[key] {
keys = append(keys, key)
seen[key] = true
}
}
sort.Strings(keys)
var chunks []dto.ChatCompletionsStreamResponse
for _, key := range keys {
tool := s.toolByKey[key]
if tool == nil {
callID := strings.TrimPrefix(key, "item:")
if strings.HasPrefix(key, "output:") {
callID = "call_output_" + strings.TrimPrefix(key, "output:")
}
tool = &responsesStreamTool{
Key: key,
Index: s.nextToolIndex,
CallID: callID,
}
s.nextToolIndex++
s.toolByKey[key] = tool
}
if strings.HasPrefix(key, "output:") {
var outputIndex int
if _, err := fmt.Sscanf(key, "output:%d", &outputIndex); err == nil {
tool.Arguments += s.pendingArgsByOutputIndex[outputIndex]
delete(s.pendingArgsByOutputIndex, outputIndex)
}
}
if strings.HasPrefix(key, "item:") {
itemID := strings.TrimPrefix(key, "item:")
tool.Arguments += s.pendingArgsByItemID[itemID]
delete(s.pendingArgsByItemID, itemID)
}
chunks = append(chunks, s.toolDelta(tool, "")...)
}
return chunks
}
func (s *ResponsesToChatStreamState) makeChunk(delta dto.ChatCompletionsStreamResponseChoiceDelta, finishReason *string) dto.ChatCompletionsStreamResponse {
return dto.ChatCompletionsStreamResponse{
Id: s.ID,
Object: "chat.completion.chunk",
Created: s.Created,
Model: s.Model,
Choices: []dto.ChatCompletionsStreamResponseChoice{
{
Index: 0,
Delta: delta,
FinishReason: finishReason,
},
},
}
}
func (s *ResponsesToChatStreamState) keyForEvent(event *dto.ResponsesStreamResponse) string {
if event == nil {
return ""
}
if event.OutputIndex != nil {
return fmt.Sprintf("output:%d", *event.OutputIndex)
}
if event.Item != nil {
if itemID := strings.TrimSpace(event.Item.ID); itemID != "" {
return "item:" + itemID
}
if callID := strings.TrimSpace(event.Item.CallId); callID != "" {
return "call:" + callID
}
}
if itemID := strings.TrimSpace(event.ItemID); itemID != "" {
return "item:" + itemID
}
return ""
}
type ResponsesBufferedAccumulator struct {
text strings.Builder
reasoning strings.Builder
tools []*responsesBufferedTool
outputIndexToToolIdx map[int]int
itemIDToToolIdx map[string]int
pendingByOutputIndex map[int]string
pendingByItemID map[string]string
}
type responsesBufferedTool struct {
CallID string
ItemID string
Name string
Arguments strings.Builder
}
func NewResponsesBufferedAccumulator() *ResponsesBufferedAccumulator {
return &ResponsesBufferedAccumulator{
outputIndexToToolIdx: make(map[int]int),
itemIDToToolIdx: make(map[string]int),
pendingByOutputIndex: make(map[int]string),
pendingByItemID: make(map[string]string),
}
}
func (a *ResponsesBufferedAccumulator) ProcessEvent(event *dto.ResponsesStreamResponse) {
if a == nil || event == nil {
return
}
switch event.Type {
case responsesEventOutputTextDelta:
a.text.WriteString(event.Delta)
case responsesEventReasoningSummaryDelta, responsesEventReasoningTextDelta:
a.reasoning.WriteString(event.Delta)
case responsesEventOutputItemAdded, responsesEventOutputItemDone:
if event.Item != nil && isResponsesToolOutputType(event.Item.Type) {
tool := a.ensureTool(event)
if args := event.Item.ArgumentsString(); args != "" {
tool.Arguments.Reset()
tool.Arguments.WriteString(args)
}
}
case responsesEventFunctionArgsDelta, responsesEventCustomToolInputDelta:
if idx, ok := a.findToolIndex(event); ok {
a.tools[idx].Arguments.WriteString(event.Delta)
return
}
if event.OutputIndex != nil {
a.pendingByOutputIndex[*event.OutputIndex] += event.Delta
} else if itemID := strings.TrimSpace(event.ItemID); itemID != "" {
a.pendingByItemID[itemID] += event.Delta
}
}
}
func (a *ResponsesBufferedAccumulator) SupplementResponseOutput(resp *dto.OpenAIResponsesResponse) {
if a == nil || resp == nil || len(resp.Output) > 0 {
return
}
resp.Output = a.BuildOutput()
}
func (a *ResponsesBufferedAccumulator) BuildOutput() []dto.ResponsesOutput {
if a == nil {
return nil
}
out := make([]dto.ResponsesOutput, 0, 2+len(a.tools))
if a.reasoning.Len() > 0 {
out = append(out, dto.ResponsesOutput{
Type: responsesOutputTypeReasoning,
Content: []dto.ResponsesOutputContent{
{Type: "summary_text", Text: a.reasoning.String()},
},
})
}
if a.text.Len() > 0 {
out = append(out, dto.ResponsesOutput{
Type: responsesOutputTypeMessage,
Role: "assistant",
Content: []dto.ResponsesOutputContent{
{Type: "output_text", Text: a.text.String()},
},
})
}
for _, tool := range a.tools {
if tool == nil {
continue
}
argsRaw, _ := kitutil.Marshal(tool.Arguments.String())
out = append(out, dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: tool.ItemID,
CallId: tool.CallID,
Name: tool.Name,
Arguments: argsRaw,
})
}
return out
}
func (a *ResponsesBufferedAccumulator) ensureTool(event *dto.ResponsesStreamResponse) *responsesBufferedTool {
if idx, ok := a.findToolIndex(event); ok {
tool := a.tools[idx]
a.applyToolMetadata(tool, event)
return tool
}
tool := &responsesBufferedTool{}
a.applyToolMetadata(tool, event)
idx := len(a.tools)
a.tools = append(a.tools, tool)
if event.OutputIndex != nil {
a.outputIndexToToolIdx[*event.OutputIndex] = idx
if pending := a.pendingByOutputIndex[*event.OutputIndex]; pending != "" {
tool.Arguments.WriteString(pending)
delete(a.pendingByOutputIndex, *event.OutputIndex)
}
}
if tool.ItemID != "" {
a.itemIDToToolIdx[tool.ItemID] = idx
if pending := a.pendingByItemID[tool.ItemID]; pending != "" {
tool.Arguments.WriteString(pending)
delete(a.pendingByItemID, tool.ItemID)
}
}
return tool
}
func (a *ResponsesBufferedAccumulator) applyToolMetadata(tool *responsesBufferedTool, event *dto.ResponsesStreamResponse) {
if tool == nil || event == nil || event.Item == nil {
return
}
if itemID := strings.TrimSpace(event.Item.ID); itemID != "" {
tool.ItemID = itemID
}
if callID := strings.TrimSpace(event.Item.CallId); callID != "" {
tool.CallID = callID
} else if tool.CallID == "" {
tool.CallID = strings.TrimSpace(event.Item.ID)
}
if name := strings.TrimSpace(event.Item.Name); name != "" {
tool.Name = name
}
}
func (a *ResponsesBufferedAccumulator) findToolIndex(event *dto.ResponsesStreamResponse) (int, bool) {
if event == nil {
return 0, false
}
if event.OutputIndex != nil {
if idx, ok := a.outputIndexToToolIdx[*event.OutputIndex]; ok {
return idx, true
}
}
itemID := strings.TrimSpace(event.ItemID)
if itemID == "" && event.Item != nil {
itemID = strings.TrimSpace(event.Item.ID)
}
if itemID != "" {
idx, ok := a.itemIDToToolIdx[itemID]
return idx, ok
}
return 0, false
}
@@ -0,0 +1,9 @@
package claude
func NormalizeCacheCreationSplit(totalTokens int, tokens5m int, tokens1h int) (int, int) {
remainder := totalTokens - tokens5m - tokens1h
if remainder < 0 {
remainder = 0
}
return tokens5m + remainder, tokens1h
}
@@ -0,0 +1,10 @@
package claude
import "errors"
// ErrMissingMaxTokens is returned when an OpenAI-format request carries no
// usable max_tokens and no Options.Claude.DefaultMaxTokens hook is
// configured. The Claude Messages API rejects requests without max_tokens
// (400 "max_tokens: Field required"), so conversion fails loudly instead of
// emitting a request the upstream is guaranteed to refuse.
var ErrMissingMaxTokens = errors.New("claude messages request requires max_tokens: set max_tokens on the request or configure Options.Claude.DefaultMaxTokens")
@@ -0,0 +1,46 @@
package claude
import "github.com/QuantumNous/new-api/relaykit/dto"
func MapOpenAIToolChoice(toolChoice any, parallelToolCalls *bool) *dto.ClaudeToolChoice {
var claudeToolChoice *dto.ClaudeToolChoice
if toolChoiceStr, ok := toolChoice.(string); ok {
switch toolChoiceStr {
case "auto":
claudeToolChoice = &dto.ClaudeToolChoice{
Type: "auto",
}
case "required":
claudeToolChoice = &dto.ClaudeToolChoice{
Type: "any",
}
case "none":
claudeToolChoice = &dto.ClaudeToolChoice{
Type: "none",
}
}
} else if toolChoiceMap, ok := toolChoice.(map[string]interface{}); ok {
if function, ok := toolChoiceMap["function"].(map[string]interface{}); ok {
if toolName, ok := function["name"].(string); ok {
claudeToolChoice = &dto.ClaudeToolChoice{
Type: "tool",
Name: toolName,
}
}
}
}
if parallelToolCalls != nil {
if claudeToolChoice == nil {
claudeToolChoice = &dto.ClaudeToolChoice{
Type: "auto",
}
}
if claudeToolChoice.Type != "none" {
claudeToolChoice.DisableParallelToolUse = !*parallelToolCalls
}
}
return claudeToolChoice
}
@@ -0,0 +1,267 @@
package gemini
import (
"strconv"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
var SupportedMimeTypes = map[string]bool{
"application/pdf": true,
"audio/mpeg": true,
"audio/mp3": true,
"audio/wav": true,
"image/png": true,
"image/jpeg": true,
"image/jpg": true,
"image/webp": true,
"image/heic": true,
"image/heif": true,
"text/plain": true,
"video/mov": true,
"video/mpeg": true,
"video/mp4": true,
"video/mpg": true,
"video/avi": true,
"video/wmv": true,
"video/mpegps": true,
"video/flv": true,
}
var SafetySettingCategories = []string{
"HARM_CATEGORY_HARASSMENT",
"HARM_CATEGORY_HATE_SPEECH",
"HARM_CATEGORY_SEXUALLY_EXPLICIT",
"HARM_CATEGORY_DANGEROUS_CONTENT",
}
const ThoughtSignatureBypassValue = "context_engineering_is_the_way_to_go"
const (
pro25MinBudget = 128
pro25MaxBudget = 32768
flash25MaxBudget = 24576
flash25LiteMinBudget = 512
flash25LiteMaxBudget = 24576
)
func ShouldAttachThoughtSignature(opts *convmeta.Options) bool {
return opts != nil && opts.Gemini.FunctionCallThoughtSignatureEnabled
}
func AttachThoughtSignatureBypass(opts *convmeta.Options, part *dto.GeminiPart) bool {
if part == nil || len(part.ThoughtSignature) > 0 || !ShouldAttachThoughtSignature(opts) {
return false
}
part.ThoughtSignature = []byte(strconv.Quote(ThoughtSignatureBypassValue))
return true
}
func AttachFunctionCallThoughtSignature(opts *convmeta.Options, part *dto.GeminiPart) bool {
if part == nil || !HasFunctionCallContent(part.FunctionCall) {
return false
}
return AttachThoughtSignatureBypass(opts, part)
}
func AttachFirstTextThoughtSignature(opts *convmeta.Options, parts []dto.GeminiPart) bool {
if !ShouldAttachThoughtSignature(opts) {
return false
}
for i := range parts {
if parts[i].Text != "" && len(parts[i].ThoughtSignature) == 0 {
parts[i].ThoughtSignature = []byte(strconv.Quote(ThoughtSignatureBypassValue))
return true
}
}
return false
}
func ApplyThinkingConfig(geminiRequest *dto.GeminiChatRequest, info convmeta.Meta, oaiRequest ...dto.GeneralOpenAIRequest) {
opts := convmeta.OptionsOf(info)
if geminiRequest == nil || info == nil || !opts.Gemini.ThinkingAdapterEnabled {
return
}
modelName := convmeta.UpstreamModelName(info)
isNew25Pro := strings.HasPrefix(modelName, "gemini-2.5-pro") &&
!strings.HasPrefix(modelName, "gemini-2.5-pro-preview-05-06") &&
!strings.HasPrefix(modelName, "gemini-2.5-pro-preview-03-25")
if strings.Contains(modelName, "-thinking-") {
parts := strings.SplitN(modelName, "-thinking-", 2)
if len(parts) == 2 && parts[1] != "" {
if budgetTokens, err := strconv.Atoi(parts[1]); err == nil {
clampedBudget := clampThinkingBudget(modelName, budgetTokens)
geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
ThinkingBudget: kitutil.GetPointer(clampedBudget),
IncludeThoughts: true,
}
}
}
} else if strings.HasSuffix(modelName, "-thinking") {
unsupportedModels := []string{
"gemini-2.5-pro-preview-05-06",
"gemini-2.5-pro-preview-03-25",
}
isUnsupported := false
for _, unsupportedModel := range unsupportedModels {
if strings.HasPrefix(modelName, unsupportedModel) {
isUnsupported = true
break
}
}
if isUnsupported {
geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
IncludeThoughts: true,
}
} else {
geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
IncludeThoughts: true,
}
if geminiRequest.GenerationConfig.MaxOutputTokens != nil && *geminiRequest.GenerationConfig.MaxOutputTokens > 0 {
budgetTokens := opts.Gemini.ThinkingAdapterBudgetTokensPercentage * float64(*geminiRequest.GenerationConfig.MaxOutputTokens)
clampedBudget := clampThinkingBudget(modelName, int(budgetTokens))
geminiRequest.GenerationConfig.ThinkingConfig.ThinkingBudget = kitutil.GetPointer(clampedBudget)
} else if len(oaiRequest) > 0 {
geminiRequest.GenerationConfig.ThinkingConfig.ThinkingBudget = kitutil.GetPointer(clampThinkingBudgetByEffort(modelName, oaiRequest[0].ReasoningEffort))
}
}
} else if strings.HasSuffix(modelName, "-nothinking") {
if !isNew25Pro {
geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
ThinkingBudget: kitutil.GetPointer(0),
}
}
} else if _, level, ok := reasoning.TrimEffortSuffix(modelName); ok && level != "" {
geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
IncludeThoughts: true,
ThinkingLevel: level,
}
info.SetReasoningEffort(level)
}
}
func ParseStopSequences(stop any) []string {
if stop == nil {
return nil
}
switch v := stop.(type) {
case string:
if v != "" {
return []string{v}
}
case []string:
return v
case []interface{}:
sequences := make([]string, 0, len(v))
for _, item := range v {
if str, ok := item.(string); ok && str != "" {
sequences = append(sequences, str)
}
}
return sequences
}
return nil
}
func HasFunctionCallContent(call *dto.FunctionCall) bool {
if call == nil {
return false
}
if strings.TrimSpace(call.FunctionName) != "" {
return true
}
switch v := call.Arguments.(type) {
case nil:
return false
case string:
return strings.TrimSpace(v) != ""
case map[string]interface{}:
return len(v) > 0
case []interface{}:
return len(v) > 0
default:
return true
}
}
func SupportedMimeTypesList() []string {
keys := make([]string, 0, len(SupportedMimeTypes))
for key := range SupportedMimeTypes {
keys = append(keys, key)
}
return keys
}
func isNew25ProModel(modelName string) bool {
return strings.HasPrefix(modelName, "gemini-2.5-pro") &&
!strings.HasPrefix(modelName, "gemini-2.5-pro-preview-05-06") &&
!strings.HasPrefix(modelName, "gemini-2.5-pro-preview-03-25")
}
func is25FlashLiteModel(modelName string) bool {
return strings.HasPrefix(modelName, "gemini-2.5-flash-lite")
}
func clampThinkingBudget(modelName string, budget int) int {
isNew25Pro := isNew25ProModel(modelName)
is25FlashLite := is25FlashLiteModel(modelName)
if is25FlashLite {
if budget < flash25LiteMinBudget {
return flash25LiteMinBudget
}
if budget > flash25LiteMaxBudget {
return flash25LiteMaxBudget
}
} else if isNew25Pro {
if budget < pro25MinBudget {
return pro25MinBudget
}
if budget > pro25MaxBudget {
return pro25MaxBudget
}
} else {
if budget < 0 {
return 0
}
if budget > flash25MaxBudget {
return flash25MaxBudget
}
}
return budget
}
func clampThinkingBudgetByEffort(modelName string, effort string) int {
isNew25Pro := isNew25ProModel(modelName)
is25FlashLite := is25FlashLiteModel(modelName)
maxBudget := 0
if is25FlashLite {
maxBudget = flash25LiteMaxBudget
}
if isNew25Pro {
maxBudget = pro25MaxBudget
} else {
maxBudget = flash25MaxBudget
}
switch effort {
case "high":
maxBudget = maxBudget * 80 / 100
case "medium":
maxBudget = maxBudget * 50 / 100
case "low":
maxBudget = maxBudget * 20 / 100
case "minimal":
maxBudget = maxBudget * 5 / 100
}
return clampThinkingBudget(modelName, maxBudget)
}
@@ -0,0 +1,256 @@
package gemini
import (
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
)
var geminiOpenAPISchemaAllowedFields = map[string]struct{}{
"anyOf": {},
"default": {},
"description": {},
"enum": {},
"example": {},
"format": {},
"items": {},
"maxItems": {},
"maxLength": {},
"maxProperties": {},
"maximum": {},
"minItems": {},
"minLength": {},
"minProperties": {},
"minimum": {},
"nullable": {},
"pattern": {},
"properties": {},
"propertyOrdering": {},
"required": {},
"title": {},
"type": {},
}
const geminiFunctionSchemaMaxDepth = 64
func CleanFunctionParameters(params interface{}) interface{} {
return cleanGeminiFunctionParametersWithDepth(params, 0)
}
func cleanGeminiFunctionParametersWithDepth(params interface{}, depth int) interface{} {
if params == nil {
return nil
}
if depth >= geminiFunctionSchemaMaxDepth {
return cleanGeminiFunctionParametersShallow(params)
}
switch v := params.(type) {
case map[string]interface{}:
cleanedMap := make(map[string]interface{}, len(v))
for key, val := range v {
if _, ok := geminiOpenAPISchemaAllowedFields[key]; ok {
cleanedMap[key] = val
}
}
normalizeGeminiSchemaTypeAndNullable(cleanedMap)
if props, ok := cleanedMap["properties"].(map[string]interface{}); ok && props != nil {
cleanedProps := make(map[string]interface{})
for propName, propValue := range props {
cleanedProps[propName] = cleanGeminiFunctionParametersWithDepth(propValue, depth+1)
}
cleanedMap["properties"] = cleanedProps
}
if items, ok := cleanedMap["items"].(map[string]interface{}); ok && items != nil {
cleanedMap["items"] = cleanGeminiFunctionParametersWithDepth(items, depth+1)
}
if itemsArray, ok := cleanedMap["items"].([]interface{}); ok && len(itemsArray) > 0 {
cleanedMap["items"] = cleanGeminiFunctionParametersWithDepth(itemsArray[0], depth+1)
}
if nested, ok := cleanedMap["anyOf"].([]interface{}); ok && nested != nil {
cleanedNested := make([]interface{}, len(nested))
for i, item := range nested {
cleanedNested[i] = cleanGeminiFunctionParametersWithDepth(item, depth+1)
}
cleanedMap["anyOf"] = cleanedNested
}
return cleanedMap
case []interface{}:
cleanedArray := make([]interface{}, len(v))
for i, item := range v {
cleanedArray[i] = cleanGeminiFunctionParametersWithDepth(item, depth+1)
}
return cleanedArray
default:
return params
}
}
func cleanGeminiFunctionParametersShallow(params interface{}) interface{} {
switch v := params.(type) {
case map[string]interface{}:
cleanedMap := make(map[string]interface{}, len(v))
for key, val := range v {
if _, ok := geminiOpenAPISchemaAllowedFields[key]; ok {
cleanedMap[key] = val
}
}
normalizeGeminiSchemaTypeAndNullable(cleanedMap)
delete(cleanedMap, "properties")
delete(cleanedMap, "items")
delete(cleanedMap, "anyOf")
return cleanedMap
case []interface{}:
return []interface{}{}
default:
return params
}
}
func normalizeGeminiSchemaTypeAndNullable(schema map[string]interface{}) {
rawType, ok := schema["type"]
if !ok || rawType == nil {
return
}
normalize := func(t string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(t)) {
case "object":
return "OBJECT", false
case "array":
return "ARRAY", false
case "string":
return "STRING", false
case "integer":
return "INTEGER", false
case "number":
return "NUMBER", false
case "boolean":
return "BOOLEAN", false
case "null":
return "", true
default:
return t, false
}
}
switch typed := rawType.(type) {
case string:
normalized, isNull := normalize(typed)
if isNull {
schema["nullable"] = true
delete(schema, "type")
return
}
schema["type"] = normalized
case []interface{}:
nullable := false
var chosen string
for _, item := range typed {
if value, ok := item.(string); ok {
normalized, isNull := normalize(value)
if isNull {
nullable = true
continue
}
if chosen == "" {
chosen = normalized
}
}
}
if nullable {
schema["nullable"] = true
}
if chosen != "" {
schema["type"] = chosen
} else {
delete(schema, "type")
}
}
}
func RemoveAdditionalProperties(schema interface{}, depth int) interface{} {
if depth >= 5 {
return schema
}
value, ok := schema.(map[string]interface{})
if !ok || len(value) == 0 {
return schema
}
delete(value, "title")
delete(value, "$schema")
if typeVal, exists := value["type"]; !exists || (typeVal != "object" && typeVal != "array") {
return schema
}
switch value["type"] {
case "object":
delete(value, "additionalProperties")
if properties, ok := value["properties"].(map[string]interface{}); ok {
for key, nested := range properties {
properties[key] = RemoveAdditionalProperties(nested, depth+1)
}
}
for _, field := range []string{"allOf", "anyOf", "oneOf"} {
if nested, ok := value[field].([]interface{}); ok {
for i, item := range nested {
nested[i] = RemoveAdditionalProperties(item, depth+1)
}
}
}
case "array":
if items, ok := value["items"].(map[string]interface{}); ok {
value["items"] = RemoveAdditionalProperties(items, depth+1)
}
}
return value
}
func OpenAIToolChoiceToConfig(toolChoice any) *dto.ToolConfig {
if toolChoice == nil {
return nil
}
if toolChoiceStr, ok := toolChoice.(string); ok {
config := &dto.ToolConfig{
FunctionCallingConfig: &dto.FunctionCallingConfig{},
}
switch toolChoiceStr {
case "auto":
config.FunctionCallingConfig.Mode = "AUTO"
case "none":
config.FunctionCallingConfig.Mode = "NONE"
case "required":
config.FunctionCallingConfig.Mode = "ANY"
default:
config.FunctionCallingConfig.Mode = "AUTO"
}
return config
}
if toolChoiceMap, ok := toolChoice.(map[string]interface{}); ok {
if toolChoiceMap["type"] == "function" {
config := &dto.ToolConfig{
FunctionCallingConfig: &dto.FunctionCallingConfig{
Mode: "ANY",
},
}
if function, ok := toolChoiceMap["function"].(map[string]interface{}); ok {
if name, ok := function["name"].(string); ok && name != "" {
config.FunctionCallingConfig.AllowedFunctionNames = []string{name}
}
}
return config
}
return nil
}
return nil
}
+86
View File
@@ -0,0 +1,86 @@
// Package kitutil holds the dependency-free helpers shared by the conversion
// kit packages (dto, types, relayconvert). It moved out of the host's common
// package as part of the relaykit extraction; common re-exports these for
// host code.
package kitutil
import (
"bytes"
"encoding/json"
"io"
"unsafe"
)
func Unmarshal(data []byte, v any) error {
return json.Unmarshal(data, v)
}
func UnmarshalJsonStr(data string, v any) error {
return json.Unmarshal(StringToByteSlice(data), v)
}
func DecodeJson(reader io.Reader, v any) error {
return json.NewDecoder(reader).Decode(v)
}
func Marshal(v any) ([]byte, error) {
return json.Marshal(v)
}
func GetJsonType(data json.RawMessage) string {
trimmed := bytes.TrimSpace(data)
if len(trimmed) == 0 {
return "unknown"
}
firstChar := trimmed[0]
switch firstChar {
case '{':
return "object"
case '[':
return "array"
case '"':
return "string"
case 't', 'f':
return "boolean"
case 'n':
return "null"
default:
return "number"
}
}
// JsonRawMessageToString returns JSON strings as their decoded value and other JSON values as raw text.
func JsonRawMessageToString(data json.RawMessage) string {
trimmed := bytes.TrimSpace(data)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
return ""
}
if trimmed[0] != '"' {
return string(trimmed)
}
var value string
if err := Unmarshal(trimmed, &value); err != nil {
return string(trimmed)
}
return value
}
func StringToByteSlice(s string) []byte {
tmp1 := (*[2]uintptr)(unsafe.Pointer(&s))
tmp2 := [3]uintptr{tmp1[0], tmp1[1], tmp1[1]}
return *(*[]byte)(unsafe.Pointer(&tmp2))
}
func Any2Type[T any](data any) (T, error) {
var zero T
bytes, err := json.Marshal(data)
if err != nil {
return zero, err
}
var res T
err = json.Unmarshal(bytes, &res)
if err != nil {
return zero, err
}
return res, nil
}
+65
View File
@@ -0,0 +1,65 @@
package kitutil
import (
"fmt"
"os"
"sync/atomic"
)
// Kit packages log rare data-shape anomalies through these hooks. The host
// redirects them into its logging system at startup; standalone relaykit users
// get stderr defaults.
type LogFunc func(message string)
var (
logInfo atomic.Pointer[LogFunc]
logError atomic.Pointer[LogFunc]
logSystemError atomic.Pointer[LogFunc]
)
func SetLogging(info LogFunc, errorFn LogFunc) {
if info != nil {
logInfo.Store(&info)
}
if errorFn != nil {
logError.Store(&errorFn)
}
}
// SetSystemErrorLogging configures the hook for internal converter failures.
func SetSystemErrorLogging(errorFn LogFunc) {
if errorFn != nil {
logSystemError.Store(&errorFn)
}
}
func LogInfo(message string) {
if fn := logInfo.Load(); fn != nil {
(*fn)(message)
return
}
fmt.Fprintf(os.Stderr, "[relaykit] %s\n", message)
}
func LogError(message string) {
if fn := logError.Load(); fn != nil {
(*fn)(message)
return
}
fmt.Fprintf(os.Stderr, "[relaykit] ERROR %s\n", message)
}
// LogSystemError reports an internal converter failure through its dedicated
// hook, keeping it distinct from malformed request-data diagnostics.
func LogSystemError(message string) {
if fn := logSystemError.Load(); fn != nil {
(*fn)(message)
return
}
fmt.Fprintf(os.Stderr, "[relaykit] SYSTEM ERROR %s\n", message)
}
// Debug reports whether verbose kit diagnostics are enabled. The host sets
// this once at startup (new-api mirrors common.DebugEnabled into it).
var Debug atomic.Bool
+31
View File
@@ -0,0 +1,31 @@
package kitutil
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestErrorHooksRemainDistinct(t *testing.T) {
previousError := logError.Load()
previousSystemError := logSystemError.Load()
t.Cleanup(func() {
logError.Store(previousError)
logSystemError.Store(previousSystemError)
})
var ordinaryMessages []string
var systemMessages []string
SetLogging(nil, func(message string) {
ordinaryMessages = append(ordinaryMessages, message)
})
SetSystemErrorLogging(func(message string) {
systemMessages = append(systemMessages, message)
})
LogError("invalid dto")
LogSystemError("converter failure")
assert.Equal(t, []string{"invalid dto"}, ordinaryMessages)
assert.Equal(t, []string{"converter failure"}, systemMessages)
}
+134
View File
@@ -0,0 +1,134 @@
package kitutil
import (
"net/url"
"regexp"
"strings"
)
var (
maskURLPattern = regexp.MustCompile(`(http|https)://[^\s/$.?#].[^\s]*`)
maskDomainPattern = regexp.MustCompile(`\b(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\b`)
maskIPPattern = regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`)
// maskApiKeyPattern matches patterns like 'api_key:xxx' or "api_key:xxx" to mask the API key value
maskApiKeyPattern = regexp.MustCompile(`(['"]?)api_key:([^\s'"]+)(['"]?)`)
)
// maskHostTail returns the tail parts of a domain/host that should be preserved.
// It keeps 2 parts for likely country-code TLDs (e.g., co.uk, com.cn), otherwise keeps only the TLD.
func maskHostTail(parts []string) []string {
if len(parts) < 2 {
return parts
}
lastPart := parts[len(parts)-1]
secondLastPart := parts[len(parts)-2]
if len(lastPart) == 2 && len(secondLastPart) <= 3 {
// Likely country code TLD like co.uk, com.cn
return []string{secondLastPart, lastPart}
}
return []string{lastPart}
}
// maskHostForURL collapses subdomains and keeps only masked prefix + preserved tail.
// Example: api.openai.com -> ***.com, sub.domain.co.uk -> ***.co.uk
func maskHostForURL(host string) string {
parts := strings.Split(host, ".")
if len(parts) < 2 {
return "***"
}
tail := maskHostTail(parts)
return "***." + strings.Join(tail, ".")
}
// maskHostForPlainDomain masks a plain domain and reflects subdomain depth with multiple ***.
// Example: openai.com -> ***.com, api.openai.com -> ***.***.com, sub.domain.co.uk -> ***.***.co.uk
func maskHostForPlainDomain(domain string) string {
parts := strings.Split(domain, ".")
if len(parts) < 2 {
return domain
}
tail := maskHostTail(parts)
numStars := len(parts) - len(tail)
if numStars < 1 {
numStars = 1
}
stars := strings.TrimSuffix(strings.Repeat("***.", numStars), ".")
return stars + "." + strings.Join(tail, ".")
}
// MaskSensitiveInfo masks sensitive information like URLs, IPs, and domain names in a string
// Example:
// http://example.com -> http://***.com
// https://api.test.org/v1/users/123?key=secret -> https://***.org/***/***/?key=***
// https://sub.domain.co.uk/path/to/resource -> https://***.co.uk/***/***
// 192.168.1.1 -> ***.***.***.***
// openai.com -> ***.com
// www.openai.com -> ***.***.com
// api.openai.com -> ***.***.com
func MaskSensitiveInfo(str string) string {
// Mask URLs
str = maskURLPattern.ReplaceAllStringFunc(str, func(urlStr string) string {
u, err := url.Parse(urlStr)
if err != nil {
return urlStr
}
host := u.Host
if host == "" {
return urlStr
}
// Mask host with unified logic
maskedHost := maskHostForURL(host)
result := u.Scheme + "://" + maskedHost
// Mask path
if u.Path != "" && u.Path != "/" {
pathParts := strings.Split(strings.Trim(u.Path, "/"), "/")
maskedPathParts := make([]string, len(pathParts))
for i := range pathParts {
if pathParts[i] != "" {
maskedPathParts[i] = "***"
}
}
if len(maskedPathParts) > 0 {
result += "/" + strings.Join(maskedPathParts, "/")
}
} else if u.Path == "/" {
result += "/"
}
// Mask query parameters
if u.RawQuery != "" {
values, err := url.ParseQuery(u.RawQuery)
if err != nil {
// If can't parse query, just mask the whole query string
result += "?***"
} else {
maskedParams := make([]string, 0, len(values))
for key := range values {
maskedParams = append(maskedParams, key+"=***")
}
if len(maskedParams) > 0 {
result += "?" + strings.Join(maskedParams, "&")
}
}
}
return result
})
// Mask domain names without protocol (like openai.com, www.openai.com)
str = maskDomainPattern.ReplaceAllStringFunc(str, func(domain string) string {
return maskHostForPlainDomain(domain)
})
// Mask IP addresses
str = maskIPPattern.ReplaceAllString(str, "***.***.***.***")
// Mask API keys (e.g., "api_key:AIzaSyAAAaUooTUni8AdaOkSRMda30n_Q4vrV70" -> "api_key:***")
str = maskApiKeyPattern.ReplaceAllString(str, "${1}api_key:***${3}")
return str
}
+52
View File
@@ -0,0 +1,52 @@
package kitutil
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
)
func GetPointer[T any](v T) *T {
return &v
}
func Interface2String(inter interface{}) string {
switch inter.(type) {
case string:
return inter.(string)
case int:
return fmt.Sprintf("%d", inter.(int))
case float64:
return strconv.FormatFloat(inter.(float64), 'f', -1, 64)
case bool:
if inter.(bool) {
return "true"
} else {
return "false"
}
case nil:
return ""
}
return fmt.Sprintf("%v", inter)
}
func String2Int(str string) int {
num, err := strconv.Atoi(str)
if err != nil {
return 0
}
return num
}
func GetUUID() string {
code := uuid.New().String()
code = strings.Replace(code, "-", "", -1)
return code
}
func GetTimestamp() int64 {
return time.Now().Unix()
}
+9
View File
@@ -0,0 +1,9 @@
package relayconvert
import relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
type MediaResolver = relaymedia.MediaResolver
func SetMediaResolver(resolver MediaResolver) {
relaymedia.SetMediaResolver(resolver)
}
+51
View File
@@ -0,0 +1,51 @@
package reasoning
import (
"strings"
"github.com/samber/lo"
)
var EffortSuffixes = []string{"-max", "-xhigh", "-high", "-medium", "-low", "-minimal"}
var OpenAIEffortSuffixes = []string{"-high", "-minimal", "-low", "-medium", "-none", "-xhigh"}
var DeepSeekV4EffortSuffixes = []string{"-none", "-max"}
// TrimEffortSuffix -> modelName level(low) exists
func TrimEffortSuffix(modelName string) (string, string, bool) {
return TrimEffortSuffixWithSuffixes(modelName, EffortSuffixes)
}
func TrimEffortSuffixWithSuffixes(modelName string, suffixes []string) (string, string, bool) {
suffix, found := lo.Find(suffixes, func(s string) bool {
return strings.HasSuffix(modelName, s)
})
if !found {
return modelName, "", false
}
return strings.TrimSuffix(modelName, suffix), strings.TrimPrefix(suffix, "-"), true
}
func ParseOpenAIReasoningEffortFromModelSuffix(modelName string) (string, string) {
baseModel, effort, ok := TrimEffortSuffixWithSuffixes(modelName, OpenAIEffortSuffixes)
if !ok {
return "", modelName
}
return effort, baseModel
}
func ParseDeepSeekV4ThinkingSuffix(modelName string) (baseModel string, thinkingType string, effort string, ok bool) {
baseModel, suffix, ok := TrimEffortSuffixWithSuffixes(modelName, DeepSeekV4EffortSuffixes)
if !ok || !strings.HasPrefix(baseModel, "deepseek-v4-") {
return modelName, "", "", false
}
switch suffix {
case "none":
return baseModel, "disabled", "", true
case "max":
return baseModel, "enabled", "max", true
default:
return modelName, "", "", false
}
}
+48
View File
@@ -0,0 +1,48 @@
package relayconvert
import (
"context"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
claudemessages "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/claude_messages"
geminichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/gemini_chat"
oaichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/oai_chat"
oairesponses "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/oai_responses"
sharedgemini "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/gemini"
)
func ClaudeMessagesRequestToOpenAIChat(claudeRequest dto.ClaudeRequest, info convmeta.Meta) (*dto.GeneralOpenAIRequest, error) {
return claudemessages.ClaudeMessagesRequestToOpenAIChat(claudeRequest, info)
}
func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, textRequest dto.GeneralOpenAIRequest) (*dto.ClaudeRequest, error) {
return oaichat.OpenAIChatRequestToClaudeMessages(c, info, textRequest)
}
func GeminiGenerateContentRequestToOpenAIChat(geminiRequest *dto.GeminiChatRequest, info convmeta.Meta) (*dto.GeneralOpenAIRequest, error) {
return geminichat.GeminiGenerateContentRequestToOpenAIChat(geminiRequest, info)
}
func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto.GeneralOpenAIRequest, info convmeta.Meta) (*dto.GeminiChatRequest, error) {
return oaichat.OpenAIChatRequestToGeminiGenerateContent(c, textRequest, info)
}
func ApplyGeminiThinkingConfig(geminiRequest *dto.GeminiChatRequest, info convmeta.Meta, oaiRequest ...dto.GeneralOpenAIRequest) {
sharedgemini.ApplyThinkingConfig(geminiRequest, info, oaiRequest...)
}
func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*dto.OpenAIResponsesRequest, error) {
return oaichat.ChatCompletionsRequestToResponsesRequest(req)
}
func ResponsesRequestToChatCompletionsRequest(req *dto.OpenAIResponsesRequest) (*dto.GeneralOpenAIRequest, error) {
return oairesponses.ResponsesRequestToChatCompletionsRequest(req)
}
func OpenAIResponsesRequestToClaudeMessages(c context.Context, info convmeta.Meta, req *dto.OpenAIResponsesRequest) (*dto.ClaudeRequest, error) {
return oairesponses.OpenAIResponsesRequestToClaudeMessages(c, info, req)
}
func OpenAIResponsesRequestToGeminiChat(c context.Context, req *dto.OpenAIResponsesRequest, info convmeta.Meta) (*dto.GeminiChatRequest, error) {
return oairesponses.OpenAIResponsesRequestToGeminiChat(c, req, info)
}
+499
View File
@@ -0,0 +1,499 @@
package relayconvert
import (
"errors"
"fmt"
"reflect"
"strings"
"sync"
"context"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
claudemessages "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/claude_messages"
geminichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/gemini_chat"
oaichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/oai_chat"
oairesponses "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/oai_responses"
"github.com/QuantumNous/new-api/relaykit/types"
)
type RequestConverterFunc func(c context.Context, info convmeta.Meta, request any) (any, error)
type RequestConverterQuality string
const (
RequestConverterQualityGood RequestConverterQuality = "good"
RequestConverterQualityFair RequestConverterQuality = "fair"
RequestConverterQualityDiscouraged RequestConverterQuality = "discouraged"
)
type RequestStep struct {
Converter string
From types.RelayFormat
To types.RelayFormat
}
type RequestResult struct {
Value any
From types.RelayFormat
To types.RelayFormat
Converter string
Quality RequestConverterQuality
Steps []RequestStep
}
type RequestConverterSpec struct {
ID string
From types.RelayFormat
To types.RelayFormat
Quality RequestConverterQuality
Convert RequestConverterFunc
StepConverters []string
}
type requestConverterRoute struct {
from types.RelayFormat
to types.RelayFormat
}
var (
requestConverterMu sync.RWMutex
requestConverters = make(map[string]RequestConverterSpec)
requestConverterRoutes = make(map[requestConverterRoute]string)
requestConverterDirectRoutes = make(map[requestConverterRoute]string)
)
const (
requestConverterClaudeToGemini = "claude_messages_to_gemini_generate_content"
requestConverterClaudeToResponses = "claude_messages_to_openai_responses"
requestConverterGeminiToClaude = "gemini_generate_content_to_claude_messages"
requestConverterGeminiToResponses = "gemini_generate_content_to_openai_responses"
requestConverterResponsesToClaude = "openai_responses_to_claude_messages"
)
const (
ConverterNone = "none"
ConverterClaudeMessagesToOpenAIChat = "anthropic_messages_to_openai_chat_completions"
ConverterOpenAIChatToClaudeMessages = "openai_chat_completions_to_anthropic_messages"
ConverterOpenAIChatToOpenAIResponses = "openai_chat_completions_to_openai_responses"
ConverterOpenAIResponsesToOpenAIChat = "openai_responses_to_openai_chat_completions"
ConverterOpenAIResponsesToGemini = "openai_responses_to_gemini_generate_content"
ConverterGeminiContentToOpenAIChat = "gemini_generate_content_to_openai_chat_completions"
ConverterOpenAIChatToGeminiContent = "openai_chat_completions_to_gemini_generate_content"
)
func registerBuiltinRequestConverter(spec RequestConverterSpec) {
spec.ID = strings.TrimSpace(spec.ID)
if spec.ID == "" {
panic("request converter ID is required")
}
if spec.From == "" || spec.To == "" {
panic(fmt.Sprintf("request converter %q must declare from and to formats", spec.ID))
}
if spec.Quality == "" {
panic(fmt.Sprintf("request converter %q must declare quality", spec.ID))
}
if spec.Convert == nil && len(spec.StepConverters) == 0 {
panic(fmt.Sprintf("request converter %q must declare convert or step converters", spec.ID))
}
if spec.Convert != nil && len(spec.StepConverters) > 0 {
panic(fmt.Sprintf("request converter %q cannot declare convert and step converters together", spec.ID))
}
if _, exists := requestConverters[spec.ID]; exists {
panic(fmt.Sprintf("request converter %q is already registered", spec.ID))
}
route := requestConverterRoute{from: spec.From, to: spec.To}
if existingID, exists := requestConverterRoutes[route]; exists {
panic(fmt.Sprintf("request converter route from %s to %s is already registered by %q", spec.From, spec.To, existingID))
}
if len(spec.StepConverters) > 0 {
stepConverters := make([]string, 0, len(spec.StepConverters))
current := spec.From
for _, converterID := range spec.StepConverters {
step, ok := requestConverters[converterID]
if !ok {
panic(fmt.Sprintf("request converter %q references unknown step converter %q", spec.ID, converterID))
}
if step.Convert == nil || len(step.StepConverters) > 0 {
panic(fmt.Sprintf("request converter %q step %q must be a direct converter", spec.ID, converterID))
}
if step.From != current {
panic(fmt.Sprintf("request converter %q step %q expects %s after %s", spec.ID, converterID, step.From, current))
}
stepConverters = append(stepConverters, converterID)
current = step.To
}
if current != spec.To {
panic(fmt.Sprintf("request converter %q ends at %s, expected %s", spec.ID, current, spec.To))
}
spec.StepConverters = stepConverters
}
requestConverters[spec.ID] = spec
requestConverterRoutes[route] = spec.ID
if len(spec.StepConverters) == 0 {
requestConverterDirectRoutes[route] = spec.ID
}
}
func LookupRequestConverter(converter string) (RequestConverterSpec, bool) {
requestConverterMu.RLock()
defer requestConverterMu.RUnlock()
spec, ok := requestConverters[strings.TrimSpace(converter)]
if !ok {
return RequestConverterSpec{}, false
}
return cloneRequestConverterSpec(spec), true
}
func ConvertRequest(c context.Context, info convmeta.Meta, target types.RelayFormat, request any) (*RequestResult, error) {
from, err := inferRequestRelayFormat(request)
if err != nil {
return nil, err
}
if target == "" {
return nil, errors.New("target relay format is required")
}
if from == target {
return &RequestResult{
Value: request,
From: from,
To: target,
}, nil
}
spec, ok := lookupRequestRoute(from, target)
if !ok {
return nil, fmt.Errorf("request converter from %s to %s is not registered", from, target)
}
return executeRequestSpec(c, info, from, target, request, spec)
}
func ConvertRequestVia(c context.Context, info convmeta.Meta, request any, path ...types.RelayFormat) (*RequestResult, error) {
from, err := inferRequestRelayFormat(request)
if err != nil {
return nil, err
}
if len(path) == 0 {
return nil, errors.New("request conversion path is required")
}
targets := make([]types.RelayFormat, 0, len(path))
for _, format := range path {
if format == "" {
return nil, errors.New("request conversion path contains empty relay format")
}
targets = append(targets, format)
}
if targets[0] == from {
targets = targets[1:]
}
if len(targets) == 0 {
return &RequestResult{
Value: request,
From: from,
To: from,
}, nil
}
steps := make([]RequestConverterSpec, 0, len(targets))
current := from
for _, target := range targets {
spec, ok := lookupRequestDirectRoute(current, target)
if !ok {
return nil, fmt.Errorf("request converter from %s to %s is not registered", current, target)
}
steps = append(steps, spec)
current = target
}
return executeRequestSteps(c, info, from, targets[len(targets)-1], request, "", "", steps)
}
func ConvertRequestByID(c context.Context, info convmeta.Meta, converter string, request any) (*RequestResult, error) {
from, err := inferRequestRelayFormat(request)
if err != nil {
return nil, err
}
spec, ok := LookupRequestConverter(converter)
if !ok {
return nil, fmt.Errorf("request converter %q is not registered", strings.TrimSpace(converter))
}
if spec.From != "" && spec.From != from {
return nil, fmt.Errorf("request converter %q expects %s request, got %s", spec.ID, spec.From, from)
}
return executeRequestSpec(c, info, from, spec.To, request, spec)
}
func executeRequestSpec(c context.Context, info convmeta.Meta, from types.RelayFormat, target types.RelayFormat, request any, spec RequestConverterSpec) (*RequestResult, error) {
steps, err := expandRequestConverterSteps(spec)
if err != nil {
return nil, err
}
return executeRequestSteps(c, info, from, target, request, spec.ID, spec.Quality, steps)
}
func executeRequestSteps(c context.Context, info convmeta.Meta, from types.RelayFormat, target types.RelayFormat, request any, converter string, quality RequestConverterQuality, specs []RequestConverterSpec) (*RequestResult, error) {
current := request
steps := make([]RequestStep, 0, len(specs))
for _, spec := range specs {
var err error
current, err = prepareRequestForStep(current, spec, target)
if err != nil {
return nil, err
}
var step RequestStep
current, step, err = executeRequestStep(c, info, spec, current)
if err != nil {
return nil, err
}
steps = append(steps, step)
}
converters := make([]string, 0, len(steps))
for _, step := range steps {
converters = append(converters, step.Converter)
}
if converter == "" {
converter = strings.Join(converters, ",")
}
return &RequestResult{
Value: current,
From: from,
To: target,
Converter: converter,
Quality: quality,
Steps: steps,
}, nil
}
func expandRequestConverterSteps(spec RequestConverterSpec) ([]RequestConverterSpec, error) {
if len(spec.StepConverters) == 0 {
if spec.Convert == nil {
return nil, fmt.Errorf("request converter %q has no registered implementation", spec.ID)
}
return []RequestConverterSpec{spec}, nil
}
if spec.Convert != nil {
return nil, fmt.Errorf("request converter %q cannot mix direct and step conversion", spec.ID)
}
steps := make([]RequestConverterSpec, 0, len(spec.StepConverters))
current := spec.From
for _, converterID := range spec.StepConverters {
step, ok := LookupRequestConverter(converterID)
if !ok {
return nil, fmt.Errorf("request converter %q references missing step converter %q", spec.ID, converterID)
}
if step.Convert == nil || len(step.StepConverters) > 0 {
return nil, fmt.Errorf("request converter %q step %q is not a direct converter", spec.ID, converterID)
}
if step.From != current {
return nil, fmt.Errorf("request converter %q step %q expects %s request, got %s", spec.ID, converterID, step.From, current)
}
steps = append(steps, step)
current = step.To
}
if current != spec.To {
return nil, fmt.Errorf("request converter %q ends at %s, expected %s", spec.ID, current, spec.To)
}
return steps, nil
}
func executeRequestStep(c context.Context, info convmeta.Meta, spec RequestConverterSpec, request any) (any, RequestStep, error) {
if spec.Convert == nil {
return nil, RequestStep{}, fmt.Errorf("request converter %q has no registered implementation", spec.ID)
}
value, err := spec.Convert(c, info, request)
if err != nil {
return nil, RequestStep{}, err
}
if info != nil {
info.AppendRequestConversion(spec.To)
}
return value, RequestStep{
Converter: spec.ID,
From: spec.From,
To: spec.To,
}, nil
}
func prepareRequestForStep(request any, spec RequestConverterSpec, finalTarget types.RelayFormat) (any, error) {
if spec.From != types.RelayFormatOpenAIResponses || finalTarget != types.RelayFormatGemini {
return request, nil
}
responsesRequest, ok := request.(*dto.OpenAIResponsesRequest)
if !ok {
if value, ok := request.(dto.OpenAIResponsesRequest); ok {
responsesRequest = &value
}
}
if responsesRequest == nil {
return nil, fmt.Errorf("expected OpenAI responses request, got %T", request)
}
prepared, err := oairesponses.PrepareOpenAIResponsesRequest(*responsesRequest)
if err != nil {
return nil, err
}
return &prepared, nil
}
func lookupRequestRoute(from types.RelayFormat, to types.RelayFormat) (RequestConverterSpec, bool) {
requestConverterMu.RLock()
defer requestConverterMu.RUnlock()
converterID, ok := requestConverterRoutes[requestConverterRoute{from: from, to: to}]
if !ok {
return RequestConverterSpec{}, false
}
spec, ok := requestConverters[converterID]
return cloneRequestConverterSpec(spec), ok
}
func lookupRequestDirectRoute(from types.RelayFormat, to types.RelayFormat) (RequestConverterSpec, bool) {
requestConverterMu.RLock()
defer requestConverterMu.RUnlock()
converterID, ok := requestConverterDirectRoutes[requestConverterRoute{from: from, to: to}]
if !ok {
return RequestConverterSpec{}, false
}
spec, ok := requestConverters[converterID]
return cloneRequestConverterSpec(spec), ok
}
func cloneRequestConverterSpec(spec RequestConverterSpec) RequestConverterSpec {
if len(spec.StepConverters) > 0 {
spec.StepConverters = append([]string{}, spec.StepConverters...)
}
return spec
}
func inferRequestRelayFormat(request any) (types.RelayFormat, error) {
if isNilRequest(request) {
return "", errors.New("request is nil")
}
format, ok := convmeta.GuessRelayFormatFromRequest(request)
if !ok {
return "", fmt.Errorf("unsupported request type %T", request)
}
return format, nil
}
func isNilRequest(request any) bool {
if request == nil {
return true
}
value := reflect.ValueOf(request)
switch value.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
return value.IsNil()
default:
return false
}
}
func convertChatRequestToResponses(_ context.Context, _ convmeta.Meta, request any) (any, error) {
chatRequest, ok := request.(*dto.GeneralOpenAIRequest)
if !ok {
if value, ok := request.(dto.GeneralOpenAIRequest); ok {
chatRequest = &value
}
}
if chatRequest == nil {
return nil, fmt.Errorf("expected OpenAI chat completions request, got %T", request)
}
return oaichat.ChatCompletionsRequestToResponsesRequest(chatRequest)
}
func convertClaudeRequestToOpenAI(_ context.Context, info convmeta.Meta, request any) (any, error) {
claudeRequest, ok := request.(*dto.ClaudeRequest)
if !ok {
if value, ok := request.(dto.ClaudeRequest); ok {
claudeRequest = &value
}
}
if claudeRequest == nil {
return nil, fmt.Errorf("expected Anthropic Messages request, got %T", request)
}
return claudemessages.ClaudeMessagesRequestToOpenAIChat(*claudeRequest, info)
}
func convertOpenAIRequestToClaude(c context.Context, info convmeta.Meta, request any) (any, error) {
openAIRequest, ok := request.(*dto.GeneralOpenAIRequest)
if !ok {
if value, ok := request.(dto.GeneralOpenAIRequest); ok {
openAIRequest = &value
}
}
if openAIRequest == nil {
return nil, fmt.Errorf("expected OpenAI chat completions request, got %T", request)
}
return oaichat.OpenAIChatRequestToClaudeMessages(c, info, *openAIRequest)
}
func convertGeminiRequestToOpenAI(_ context.Context, info convmeta.Meta, request any) (any, error) {
geminiRequest, ok := request.(*dto.GeminiChatRequest)
if !ok {
if value, ok := request.(dto.GeminiChatRequest); ok {
geminiRequest = &value
}
}
if geminiRequest == nil {
return nil, fmt.Errorf("expected Gemini generateContent request, got %T", request)
}
return geminichat.GeminiGenerateContentRequestToOpenAIChat(geminiRequest, info)
}
func convertOpenAIRequestToGemini(c context.Context, info convmeta.Meta, request any) (any, error) {
openAIRequest, ok := request.(*dto.GeneralOpenAIRequest)
if !ok {
if value, ok := request.(dto.GeneralOpenAIRequest); ok {
openAIRequest = &value
}
}
if openAIRequest == nil {
return nil, fmt.Errorf("expected OpenAI chat completions request, got %T", request)
}
return oaichat.OpenAIChatRequestToGeminiGenerateContent(c, *openAIRequest, info)
}
func convertOpenAIResponsesRequestToClaudeMessages(c context.Context, info convmeta.Meta, request any) (any, error) {
responsesRequest, err := oairesponses.OpenAIResponsesRequestFromAny(request)
if err != nil {
return nil, err
}
return oairesponses.OpenAIResponsesRequestToClaudeMessages(c, info, responsesRequest)
}
func convertOpenAIResponsesRequestToGeminiChat(c context.Context, info convmeta.Meta, request any) (any, error) {
responsesRequest, err := oairesponses.OpenAIResponsesRequestFromAny(request)
if err != nil {
return nil, err
}
prepared, err := oairesponses.PrepareOpenAIResponsesRequest(*responsesRequest)
if err != nil {
return nil, err
}
return oairesponses.OpenAIResponsesRequestToGeminiChat(c, &prepared, info)
}
func convertResponsesRequestToChat(_ context.Context, _ convmeta.Meta, request any) (any, error) {
responsesRequest, ok := request.(*dto.OpenAIResponsesRequest)
if !ok {
if value, ok := request.(dto.OpenAIResponsesRequest); ok {
responsesRequest = &value
}
}
if responsesRequest == nil {
return nil, fmt.Errorf("expected OpenAI responses request, got %T", request)
}
return oairesponses.ResponsesRequestToChatCompletionsRequest(responsesRequest)
}
@@ -0,0 +1,703 @@
package relayconvert
import (
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
sharedgemini "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/gemini"
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"
)
func TestRequestConverterRegistryListsSupportedTextConverters(t *testing.T) {
tests := []struct {
converter string
from types.RelayFormat
to types.RelayFormat
quality RequestConverterQuality
stepConverters []string
advancedCustom bool
}{
{converter: ConverterClaudeMessagesToOpenAIChat, from: types.RelayFormatClaude, to: types.RelayFormatOpenAI, quality: RequestConverterQualityFair, advancedCustom: true},
{converter: ConverterGeminiContentToOpenAIChat, from: types.RelayFormatGemini, to: types.RelayFormatOpenAI, quality: RequestConverterQualityFair, advancedCustom: true},
{converter: ConverterOpenAIChatToClaudeMessages, from: types.RelayFormatOpenAI, to: types.RelayFormatClaude, quality: RequestConverterQualityFair, advancedCustom: true},
{converter: ConverterOpenAIChatToGeminiContent, from: types.RelayFormatOpenAI, to: types.RelayFormatGemini, quality: RequestConverterQualityFair, advancedCustom: true},
{converter: ConverterOpenAIChatToOpenAIResponses, from: types.RelayFormatOpenAI, to: types.RelayFormatOpenAIResponses, quality: RequestConverterQualityGood, advancedCustom: true},
{converter: ConverterOpenAIResponsesToOpenAIChat, from: types.RelayFormatOpenAIResponses, to: types.RelayFormatOpenAI, quality: RequestConverterQualityGood, advancedCustom: true},
{
converter: requestConverterClaudeToGemini,
from: types.RelayFormatClaude,
to: types.RelayFormatGemini,
quality: RequestConverterQualityDiscouraged,
stepConverters: []string{
ConverterClaudeMessagesToOpenAIChat,
ConverterOpenAIChatToGeminiContent,
},
},
{
converter: requestConverterClaudeToResponses,
from: types.RelayFormatClaude,
to: types.RelayFormatOpenAIResponses,
quality: RequestConverterQualityFair,
stepConverters: []string{
ConverterClaudeMessagesToOpenAIChat,
ConverterOpenAIChatToOpenAIResponses,
},
},
{
converter: requestConverterGeminiToClaude,
from: types.RelayFormatGemini,
to: types.RelayFormatClaude,
quality: RequestConverterQualityDiscouraged,
stepConverters: []string{
ConverterGeminiContentToOpenAIChat,
ConverterOpenAIChatToClaudeMessages,
},
},
{
converter: requestConverterGeminiToResponses,
from: types.RelayFormatGemini,
to: types.RelayFormatOpenAIResponses,
quality: RequestConverterQualityFair,
stepConverters: []string{
ConverterGeminiContentToOpenAIChat,
ConverterOpenAIChatToOpenAIResponses,
},
},
{
converter: requestConverterResponsesToClaude,
from: types.RelayFormatOpenAIResponses,
to: types.RelayFormatClaude,
quality: RequestConverterQualityFair,
},
{
converter: ConverterOpenAIResponsesToGemini,
from: types.RelayFormatOpenAIResponses,
to: types.RelayFormatGemini,
quality: RequestConverterQualityFair,
advancedCustom: true,
},
}
require.Len(t, requestConverters, len(tests))
for _, tt := range tests {
t.Run(tt.converter, func(t *testing.T) {
spec, ok := LookupRequestConverter(tt.converter)
require.True(t, ok)
assert.Equal(t, tt.converter, spec.ID)
assert.Equal(t, tt.from, spec.From)
assert.Equal(t, tt.to, spec.To)
assert.Equal(t, tt.quality, spec.Quality)
assert.Equal(t, tt.stepConverters, spec.StepConverters)
if len(tt.stepConverters) == 0 {
assert.NotNil(t, spec.Convert)
} else {
assert.Nil(t, spec.Convert)
}
assert.Equal(t, tt.advancedCustom, dto.IsAdvancedCustomConverterAllowed(tt.converter))
})
}
}
func TestConvertRequestToTargetRecordsConversionChain(t *testing.T) {
info := &convmeta.Values{
ConversionChain: []types.RelayFormat{types.RelayFormatOpenAI},
}
req := &dto.GeneralOpenAIRequest{
Model: "gpt-test",
Messages: []dto.Message{
{Role: "user", Content: "hello"},
},
}
result, err := ConvertRequest(nil, info, types.RelayFormatOpenAIResponses, req)
require.NoError(t, err)
require.IsType(t, &dto.OpenAIResponsesRequest{}, result.Value)
assert.Equal(t, types.RelayFormatOpenAI, result.From)
assert.Equal(t, types.RelayFormat(types.RelayFormatOpenAIResponses), result.To)
assert.Equal(t, ConverterOpenAIChatToOpenAIResponses, result.Converter)
assert.Equal(t, RequestConverterQualityGood, result.Quality)
assert.Equal(t, []RequestStep{
{
Converter: ConverterOpenAIChatToOpenAIResponses,
From: types.RelayFormatOpenAI,
To: types.RelayFormatOpenAIResponses,
},
}, result.Steps)
assert.Equal(t, []types.RelayFormat{types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses}, info.ConversionChain)
}
func TestConvertRequestPlansMultiHopPath(t *testing.T) {
info := &convmeta.Values{
ConversionChain: []types.RelayFormat{types.RelayFormatClaude},
}
req := &dto.ClaudeRequest{
Model: "claude-test",
Messages: []dto.ClaudeMessage{
{Role: "user", Content: "hello"},
},
}
result, err := ConvertRequest(nil, info, types.RelayFormatOpenAIResponses, req)
require.NoError(t, err)
require.IsType(t, &dto.OpenAIResponsesRequest{}, result.Value)
assert.Equal(t, types.RelayFormat(types.RelayFormatClaude), result.From)
assert.Equal(t, types.RelayFormat(types.RelayFormatOpenAIResponses), result.To)
assert.Equal(t, requestConverterClaudeToResponses, result.Converter)
assert.Equal(t, RequestConverterQualityFair, result.Quality)
assert.Equal(t, []RequestStep{
{
Converter: ConverterClaudeMessagesToOpenAIChat,
From: types.RelayFormatClaude,
To: types.RelayFormatOpenAI,
},
{
Converter: ConverterOpenAIChatToOpenAIResponses,
From: types.RelayFormatOpenAI,
To: types.RelayFormatOpenAIResponses,
},
}, result.Steps)
assert.Equal(t, []types.RelayFormat{types.RelayFormatClaude, types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses}, info.ConversionChain)
}
func TestConvertRequestViaExecutesExplicitPath(t *testing.T) {
info := &convmeta.Values{
ConversionChain: []types.RelayFormat{types.RelayFormatOpenAI},
}
req := &dto.GeneralOpenAIRequest{
Model: "gpt-test",
Messages: []dto.Message{
{Role: "user", Content: "hello"},
},
}
result, err := ConvertRequestVia(nil, info, req, types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses)
require.NoError(t, err)
require.IsType(t, &dto.OpenAIResponsesRequest{}, result.Value)
assert.Equal(t, []RequestStep{
{
Converter: ConverterOpenAIChatToOpenAIResponses,
From: types.RelayFormatOpenAI,
To: types.RelayFormatOpenAIResponses,
},
}, result.Steps)
assert.Equal(t, []types.RelayFormat{types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses}, info.ConversionChain)
}
func TestConvertRequestResponsesToGeminiAppliesResponsesPreprocess(t *testing.T) {
info := &convmeta.Values{
ConversionChain: []types.RelayFormat{types.RelayFormatOpenAIResponses},
ChannelMetaAttached: true,
UpstreamModelName: "gemini-test",
}
req := &dto.OpenAIResponsesRequest{
Model: "gemini-test",
Input: mustRawMessage(t, []map[string]any{
{
"role": "user",
"content": "next turn",
},
{
"type": "custom_tool_call",
"call_id": "call_custom",
"name": "apply_patch",
"input": "patch body",
},
{
"type": "custom_tool_call_output",
"call_id": "call_custom",
"output": "ok",
},
{
"type": "function_call_output",
"call_id": "call_custom",
"output": "legacy custom output",
},
}),
Tools: mustRawMessage(t, []map[string]any{
{"type": "custom", "name": "apply_patch"},
}),
}
result, err := ConvertRequest(nil, info, types.RelayFormatGemini, req)
require.NoError(t, err)
geminiReq, ok := result.Value.(*dto.GeminiChatRequest)
require.True(t, ok)
assert.Empty(t, geminiReq.GetTools())
require.Len(t, geminiReq.Contents, 1)
assert.Equal(t, "user", geminiReq.Contents[0].Role)
require.Len(t, geminiReq.Contents[0].Parts, 1)
assert.Equal(t, "next turn", geminiReq.Contents[0].Parts[0].Text)
assert.Equal(t, ConverterOpenAIResponsesToGemini, result.Converter)
assert.Equal(t, RequestConverterQualityFair, result.Quality)
assert.Equal(t, []RequestStep{
{
Converter: ConverterOpenAIResponsesToGemini,
From: types.RelayFormatOpenAIResponses,
To: types.RelayFormatGemini,
},
}, result.Steps)
assert.Equal(t, []types.RelayFormat{types.RelayFormatOpenAIResponses, types.RelayFormatGemini}, info.ConversionChain)
}
func TestConvertRequestResponsesToGeminiUsesDirectConverter(t *testing.T) {
info := &convmeta.Values{
Options: &convmeta.Options{Gemini: convmeta.GeminiOptions{FunctionCallThoughtSignatureEnabled: true}},
ConversionChain: []types.RelayFormat{types.RelayFormatOpenAIResponses},
ChannelMetaAttached: true,
UpstreamModelName: "gemini-test",
}
maxOutputTokens := uint(256)
req := &dto.OpenAIResponsesRequest{
Model: "gemini-test",
Instructions: mustRawMessage(t, "system rules"),
MaxOutputTokens: &maxOutputTokens,
Input: mustRawMessage(t, []map[string]any{
{
"role": "assistant",
"content": []map[string]any{
{"type": "output_text", "text": "I will call."},
},
},
{
"type": "function_call",
"call_id": "call_1",
"name": "lookup",
"arguments": map[string]any{"q": "x"},
},
{
"type": "function_call_output",
"call_id": "call_1",
"output": map[string]any{"ok": true},
},
}),
Tools: mustRawMessage(t, []map[string]any{
{
"type": "function",
"name": "lookup",
"description": "Lookup data",
"parameters": map[string]any{
"type": "object",
"additionalProperties": false,
"propertyNames": map[string]any{"pattern": "^[a-z]+$"},
"properties": map[string]any{
"q": map[string]any{
"type": "string",
"exclusiveMinimum": 0,
},
"filters": map[string]any{
"type": "array",
"items": map[string]any{
"type": "object",
"additionalProperties": true,
"properties": map[string]any{
"name": map[string]any{"type": "string"},
},
},
},
},
},
},
}),
Text: mustRawMessage(t, map[string]any{
"format": map[string]any{
"type": "json_schema",
"name": "answer",
"schema": map[string]any{"type": "object"},
},
}),
}
result, err := ConvertRequest(nil, info, types.RelayFormatGemini, req)
require.NoError(t, err)
geminiReq, ok := result.Value.(*dto.GeminiChatRequest)
require.True(t, ok)
assert.Equal(t, ConverterOpenAIResponsesToGemini, result.Converter)
assert.Equal(t, []RequestStep{
{
Converter: ConverterOpenAIResponsesToGemini,
From: types.RelayFormatOpenAIResponses,
To: types.RelayFormatGemini,
},
}, result.Steps)
assert.Equal(t, []types.RelayFormat{types.RelayFormatOpenAIResponses, types.RelayFormatGemini}, info.ConversionChain)
require.NotNil(t, geminiReq.SystemInstructions)
require.Len(t, geminiReq.SystemInstructions.Parts, 1)
assert.Equal(t, "system rules", geminiReq.SystemInstructions.Parts[0].Text)
assert.Equal(t, "application/json", geminiReq.GenerationConfig.ResponseMimeType)
assert.Equal(t, maxOutputTokens, *geminiReq.GenerationConfig.MaxOutputTokens)
tools := geminiReq.GetTools()
require.Len(t, tools, 1)
functions, err := kitutil.Any2Type[[]dto.FunctionRequest](tools[0].FunctionDeclarations)
require.NoError(t, err)
require.Len(t, functions, 1)
assert.Equal(t, "lookup", functions[0].Name)
params, ok := functions[0].Parameters.(map[string]any)
require.True(t, ok)
assert.Equal(t, "OBJECT", params["type"])
assert.NotContains(t, params, "additionalProperties")
assert.NotContains(t, params, "propertyNames")
properties, ok := params["properties"].(map[string]any)
require.True(t, ok)
queryParam, ok := properties["q"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "STRING", queryParam["type"])
assert.NotContains(t, queryParam, "exclusiveMinimum")
filterParam, ok := properties["filters"].(map[string]any)
require.True(t, ok)
filterItems, ok := filterParam["items"].(map[string]any)
require.True(t, ok)
assert.NotContains(t, filterItems, "additionalProperties")
require.Len(t, geminiReq.Contents, 2)
assert.Equal(t, "model", geminiReq.Contents[0].Role)
require.Len(t, geminiReq.Contents[0].Parts, 2)
functionCall := geminiReq.Contents[0].Parts[0].FunctionCall
require.NotNil(t, functionCall)
assert.Equal(t, "lookup", functionCall.FunctionName)
assert.Equal(t, map[string]any{"q": "x"}, functionCall.Arguments)
var thoughtSignature string
require.NoError(t, kitutil.Unmarshal(geminiReq.Contents[0].Parts[0].ThoughtSignature, &thoughtSignature))
assert.Equal(t, sharedgemini.ThoughtSignatureBypassValue, thoughtSignature)
assert.Equal(t, "I will call.", geminiReq.Contents[0].Parts[1].Text)
assert.Equal(t, "user", geminiReq.Contents[1].Role)
require.Len(t, geminiReq.Contents[1].Parts, 1)
functionResponse := geminiReq.Contents[1].Parts[0].FunctionResponse
require.NotNil(t, functionResponse)
assert.Equal(t, "lookup", functionResponse.Name)
assert.Equal(t, true, functionResponse.Response["ok"])
assert.Empty(t, geminiReq.Contents[1].Parts[0].ThoughtSignature)
}
func TestConvertRequestResponsesToGeminiSkipsThoughtSignatureWhenDisabled(t *testing.T) {
info := &convmeta.Values{
Options: &convmeta.Options{Gemini: convmeta.GeminiOptions{FunctionCallThoughtSignatureEnabled: false}},
ConversionChain: []types.RelayFormat{types.RelayFormatOpenAIResponses},
ChannelMetaAttached: true,
UpstreamModelName: "gemini-test",
}
req := &dto.OpenAIResponsesRequest{
Model: "gemini-test",
Input: mustRawMessage(t, []map[string]any{
{
"type": "function_call",
"call_id": "call_1",
"name": "lookup",
"arguments": map[string]any{"q": "x"},
},
}),
Tools: mustRawMessage(t, []map[string]any{
{"type": "function", "name": "lookup", "parameters": map[string]any{"type": "object"}},
}),
}
result, err := ConvertRequest(nil, info, types.RelayFormatGemini, req)
require.NoError(t, err)
geminiReq, ok := result.Value.(*dto.GeminiChatRequest)
require.True(t, ok)
require.Len(t, geminiReq.Contents, 1)
require.Len(t, geminiReq.Contents[0].Parts, 1)
require.NotNil(t, geminiReq.Contents[0].Parts[0].FunctionCall)
assert.Empty(t, geminiReq.Contents[0].Parts[0].ThoughtSignature)
}
func TestConvertRequestOpenAIChatToGeminiAddsThoughtSignatureForAdvancedCustom(t *testing.T) {
assistantMessage := dto.Message{Role: "assistant", Content: ""}
assistantMessage.SetToolCalls([]dto.ToolCallRequest{
{
ID: "call_1",
Type: "function",
Function: dto.FunctionRequest{
Name: "lookup",
Arguments: `{"q":"x"}`,
},
},
})
info := &convmeta.Values{
Options: &convmeta.Options{Gemini: convmeta.GeminiOptions{FunctionCallThoughtSignatureEnabled: true}},
ConversionChain: []types.RelayFormat{types.RelayFormatOpenAI},
ChannelMetaAttached: true,
ChannelType: 58, // advanced-custom in the host
UpstreamModelName: "gemini-test",
}
req := &dto.GeneralOpenAIRequest{
Model: "gemini-test",
Messages: []dto.Message{
{Role: "user", Content: "hi"},
assistantMessage,
{Role: "tool", ToolCallId: "call_1", Content: `{"ok":true}`},
},
Tools: []dto.ToolCallRequest{
{
Type: "function",
Function: dto.FunctionRequest{
Name: "lookup",
Parameters: map[string]any{"type": "object"},
},
},
},
}
result, err := ConvertRequest(nil, info, types.RelayFormatGemini, req)
require.NoError(t, err)
geminiReq, ok := result.Value.(*dto.GeminiChatRequest)
require.True(t, ok)
require.Len(t, geminiReq.Contents, 3)
assert.Equal(t, "model", geminiReq.Contents[1].Role)
require.Len(t, geminiReq.Contents[1].Parts, 1)
require.NotNil(t, geminiReq.Contents[1].Parts[0].FunctionCall)
var thoughtSignature string
require.NoError(t, kitutil.Unmarshal(geminiReq.Contents[1].Parts[0].ThoughtSignature, &thoughtSignature))
assert.Equal(t, sharedgemini.ThoughtSignatureBypassValue, thoughtSignature)
}
func TestConvertRequestResponsesToClaudeUsesDirectConverter(t *testing.T) {
info := &convmeta.Values{
ConversionChain: []types.RelayFormat{types.RelayFormatOpenAIResponses},
}
stream := true
parallelToolCalls := false
maxOutputTokens := uint(512)
req := &dto.OpenAIResponsesRequest{
Model: "claude-test",
Instructions: mustRawMessage(t, "system rules"),
Stream: &stream,
MaxOutputTokens: &maxOutputTokens,
ParallelToolCalls: mustRawMessage(t, parallelToolCalls),
Reasoning: &dto.Reasoning{Effort: "medium"},
Input: mustRawMessage(t, []map[string]any{
{
"role": "user",
"content": "question",
},
{
"role": "assistant",
"content": []map[string]any{
{"type": "output_text", "text": "I will call."},
},
},
{
"type": "function_call",
"call_id": "call_1",
"name": "lookup",
"arguments": map[string]any{"q": "x"},
},
{
"type": "function_call_output",
"call_id": "call_1",
"output": map[string]any{"ok": true},
},
}),
Tools: mustRawMessage(t, []map[string]any{
{
"type": "function",
"name": "lookup",
"description": "Lookup data",
"parameters": map[string]any{
"type": "object",
"properties": map[string]any{
"q": map[string]any{"type": "string"},
},
},
},
}),
}
result, err := ConvertRequest(nil, info, types.RelayFormatClaude, req)
require.NoError(t, err)
claudeReq, ok := result.Value.(*dto.ClaudeRequest)
require.True(t, ok)
assert.Equal(t, requestConverterResponsesToClaude, result.Converter)
assert.Equal(t, []RequestStep{
{
Converter: requestConverterResponsesToClaude,
From: types.RelayFormatOpenAIResponses,
To: types.RelayFormatClaude,
},
}, result.Steps)
assert.Equal(t, []types.RelayFormat{types.RelayFormatOpenAIResponses, types.RelayFormatClaude}, info.ConversionChain)
system, err := kitutil.Any2Type[[]dto.ClaudeMediaMessage](claudeReq.System)
require.NoError(t, err)
require.Len(t, system, 1)
assert.Equal(t, "system rules", system[0].GetText())
require.NotNil(t, claudeReq.Stream)
assert.True(t, *claudeReq.Stream)
assert.Equal(t, maxOutputTokens, *claudeReq.MaxTokens)
require.NotNil(t, claudeReq.Thinking)
assert.Equal(t, "enabled", claudeReq.Thinking.Type)
assert.Equal(t, 2048, claudeReq.Thinking.GetBudgetTokens())
tools, err := kitutil.Any2Type[[]*dto.Tool](claudeReq.Tools)
require.NoError(t, err)
require.Len(t, tools, 1)
assert.Equal(t, "lookup", tools[0].Name)
require.Len(t, claudeReq.Messages, 3)
assert.Equal(t, "user", claudeReq.Messages[0].Role)
userParts, err := claudeReq.Messages[0].ParseContent()
require.NoError(t, err)
require.Len(t, userParts, 1)
assert.Equal(t, "question", userParts[0].GetText())
assert.Equal(t, "assistant", claudeReq.Messages[1].Role)
assistantParts, err := claudeReq.Messages[1].ParseContent()
require.NoError(t, err)
require.Len(t, assistantParts, 2)
assert.Equal(t, "I will call.", assistantParts[0].GetText())
assert.Equal(t, "tool_use", assistantParts[1].Type)
assert.Equal(t, "call_1", assistantParts[1].Id)
assert.Equal(t, "lookup", assistantParts[1].Name)
assert.Equal(t, map[string]any{"q": "x"}, assistantParts[1].Input)
assert.Equal(t, "user", claudeReq.Messages[2].Role)
toolResultParts, err := claudeReq.Messages[2].ParseContent()
require.NoError(t, err)
require.Len(t, toolResultParts, 1)
assert.Equal(t, "tool_result", toolResultParts[0].Type)
assert.Equal(t, "call_1", toolResultParts[0].ToolUseId)
assert.Equal(t, map[string]any{"ok": true}, toolResultParts[0].Content)
}
func TestConvertRequestViaResponsesToGeminiStillUsesDirectSteps(t *testing.T) {
info := &convmeta.Values{
ConversionChain: []types.RelayFormat{types.RelayFormatOpenAIResponses},
ChannelMetaAttached: true,
UpstreamModelName: "gemini-test",
}
req := &dto.OpenAIResponsesRequest{
Model: "gemini-test",
Input: mustRawMessage(t, []map[string]any{
{
"role": "user",
"content": "hello",
},
}),
}
result, err := ConvertRequestVia(nil, info, req, types.RelayFormatOpenAI, types.RelayFormatGemini)
require.NoError(t, err)
require.IsType(t, &dto.GeminiChatRequest{}, result.Value)
assert.Equal(t, ConverterOpenAIResponsesToOpenAIChat+","+ConverterOpenAIChatToGeminiContent, result.Converter)
assert.Equal(t, []RequestStep{
{
Converter: ConverterOpenAIResponsesToOpenAIChat,
From: types.RelayFormatOpenAIResponses,
To: types.RelayFormatOpenAI,
},
{
Converter: ConverterOpenAIChatToGeminiContent,
From: types.RelayFormatOpenAI,
To: types.RelayFormatGemini,
},
}, result.Steps)
}
func TestConvertRequestByIDDeduplicatesConversionChain(t *testing.T) {
info := &convmeta.Values{
ConversionChain: []types.RelayFormat{types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses},
}
req := &dto.GeneralOpenAIRequest{
Model: "gpt-test",
Messages: []dto.Message{
{Role: "user", Content: "hello"},
},
}
result, err := ConvertRequestByID(nil, info, ConverterOpenAIChatToOpenAIResponses, req)
require.NoError(t, err)
require.IsType(t, &dto.OpenAIResponsesRequest{}, result.Value)
require.Len(t, result.Steps, 1)
assert.Equal(t, []types.RelayFormat{types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses}, info.ConversionChain)
}
func TestConvertRequestByIDExecutesMultiHopConverter(t *testing.T) {
info := &convmeta.Values{
ConversionChain: []types.RelayFormat{types.RelayFormatClaude},
}
req := &dto.ClaudeRequest{
Model: "claude-test",
Messages: []dto.ClaudeMessage{
{Role: "user", Content: "hello"},
},
}
result, err := ConvertRequestByID(nil, info, requestConverterClaudeToResponses, req)
require.NoError(t, err)
require.IsType(t, &dto.OpenAIResponsesRequest{}, result.Value)
assert.Equal(t, requestConverterClaudeToResponses, result.Converter)
assert.Equal(t, RequestConverterQualityFair, result.Quality)
assert.Equal(t, []RequestStep{
{
Converter: ConverterClaudeMessagesToOpenAIChat,
From: types.RelayFormatClaude,
To: types.RelayFormatOpenAI,
},
{
Converter: ConverterOpenAIChatToOpenAIResponses,
From: types.RelayFormatOpenAI,
To: types.RelayFormatOpenAIResponses,
},
}, result.Steps)
assert.Equal(t, []types.RelayFormat{types.RelayFormatClaude, types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses}, info.ConversionChain)
}
func TestConvertRequestRejectsUnsupportedConverterAndNilRequest(t *testing.T) {
_, err := ConvertRequestByID(nil, &convmeta.Values{}, "missing_converter", &dto.GeneralOpenAIRequest{Model: "gpt-test"})
require.Error(t, err)
assert.Contains(t, err.Error(), "not registered")
_, err = ConvertRequest(nil, &convmeta.Values{}, types.RelayFormatOpenAIResponses, (*dto.GeneralOpenAIRequest)(nil))
require.Error(t, err)
assert.Contains(t, err.Error(), "request is nil")
}
func TestConvertRequestByIDRejectsWrongSourceFormat(t *testing.T) {
_, err := ConvertRequestByID(
nil,
&convmeta.Values{},
ConverterOpenAIChatToOpenAIResponses,
&dto.ClaudeRequest{Model: "claude-test"},
)
require.Error(t, err)
assert.Contains(t, err.Error(), "expects openai request")
}
func TestConvertRequestRejectsUnregisteredExplicitPath(t *testing.T) {
_, err := ConvertRequest(
nil,
&convmeta.Values{},
types.RelayFormatEmbedding,
&dto.ClaudeRequest{Model: "claude-test"},
)
require.Error(t, err)
assert.Contains(t, err.Error(), "from claude to embedding is not registered")
}
func mustRawMessage(t *testing.T, value any) []byte {
t.Helper()
raw, err := kitutil.Marshal(value)
require.NoError(t, err)
return raw
}
+141
View File
@@ -0,0 +1,141 @@
package relayconvert
import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
claudemessages "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/claude_messages"
geminichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/gemini_chat"
oaichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/oai_chat"
oairesponses "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/oai_responses"
)
type ClaudeResponseInfo = claudemessages.ClaudeResponseInfo
type ChatToResponsesStreamEvent = oaichat.ChatToResponsesStreamEvent
type ChatToResponsesStreamState = oaichat.ChatToResponsesStreamState
type ResponsesToChatStreamState = oairesponses.ResponsesToChatStreamState
type ResponsesBufferedAccumulator = oairesponses.ResponsesBufferedAccumulator
func NormalizeCacheCreationSplit(totalTokens int, tokens5m int, tokens1h int) (int, int) {
return oaichat.NormalizeCacheCreationSplit(totalTokens, tokens5m, tokens1h)
}
func ResponseOpenAI2Claude(openAIResponse *dto.OpenAITextResponse, info convmeta.Meta) *dto.ClaudeResponse {
return oaichat.ResponseOpenAI2Claude(openAIResponse, info)
}
func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamResponse, info convmeta.Meta) []*dto.ClaudeResponse {
return oaichat.StreamResponseOpenAI2Claude(openAIResponse, info)
}
func StopReasonClaudeToOpenAI(reason string) string {
return claudemessages.StopReasonClaudeToOpenAI(reason)
}
func StreamResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.ChatCompletionsStreamResponse {
return claudemessages.StreamResponseClaude2OpenAI(claudeResponse)
}
func ResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.OpenAITextResponse {
return claudemessages.ResponseClaude2OpenAI(claudeResponse)
}
func UsageFromClaudeAPIUsage(usage *dto.ClaudeUsage) *dto.Usage {
return claudemessages.UsageFromClaudeAPIUsage(usage)
}
func UsageFromClaudeUsage(usage *dto.Usage) *dto.Usage {
return claudemessages.UsageFromClaudeUsage(usage)
}
func BuildMessageDeltaPatchUsage(claudeResponse *dto.ClaudeResponse, claudeInfo *ClaudeResponseInfo) *dto.ClaudeUsage {
return claudemessages.BuildMessageDeltaPatchUsage(claudeResponse, claudeInfo)
}
func PatchClaudeMessageDeltaUsageData(data string, usage *dto.ClaudeUsage) string {
return claudemessages.PatchClaudeMessageDeltaUsageData(data, usage)
}
func FormatClaudeResponseInfo(claudeResponse *dto.ClaudeResponse, oaiResponse *dto.ChatCompletionsStreamResponse, claudeInfo *ClaudeResponseInfo) bool {
return claudemessages.FormatClaudeResponseInfo(claudeResponse, oaiResponse, claudeInfo)
}
func ResponseOpenAI2Gemini(openAIResponse *dto.OpenAITextResponse, info convmeta.Meta) *dto.GeminiChatResponse {
return oaichat.ResponseOpenAI2Gemini(openAIResponse, info)
}
func StreamResponseOpenAI2Gemini(openAIResponse *dto.ChatCompletionsStreamResponse, info convmeta.Meta) *dto.GeminiChatResponse {
return oaichat.StreamResponseOpenAI2Gemini(openAIResponse, info)
}
func UsageFromGeminiMetadata(metadata *dto.GeminiUsageMetadata, fallbackPromptTokens int) *dto.Usage {
return geminichat.UsageFromGeminiMetadata(metadata, fallbackPromptTokens)
}
func ResponseGeminiChat2OpenAI(id string, created int64, response *dto.GeminiChatResponse) *dto.OpenAITextResponse {
return geminichat.ResponseGeminiChat2OpenAI(id, created, response)
}
func StreamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*dto.ChatCompletionsStreamResponse, bool) {
return geminichat.StreamResponseGeminiChat2OpenAI(geminiResponse)
}
func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id string) (*dto.OpenAIResponsesResponse, *dto.Usage, error) {
return oaichat.ChatCompletionsResponseToResponsesResponse(resp, id)
}
func ResponsesStatusFromChatFinishReason(finishReason string) (string, *dto.IncompleteDetails) {
return oaichat.ResponsesStatusFromChatFinishReason(finishReason)
}
func UsageFromChatUsage(src *dto.Usage) *dto.Usage {
return oaichat.UsageFromChatUsage(src)
}
func NewChatToResponsesStreamState(id string, model string) *ChatToResponsesStreamState {
return oaichat.NewChatToResponsesStreamState(id, model)
}
func ChatCompletionsStreamChunkToResponsesEvents(chunk *dto.ChatCompletionsStreamResponse, state *ChatToResponsesStreamState) ([]ChatToResponsesStreamEvent, error) {
return oaichat.ChatCompletionsStreamChunkToResponsesEvents(chunk, state)
}
func FinalizeChatCompletionsStreamToResponses(state *ChatToResponsesStreamState) []ChatToResponsesStreamEvent {
return oaichat.FinalizeChatCompletionsStreamToResponses(state)
}
func ResponsesFinishReasonFromStatus(resp *dto.OpenAIResponsesResponse) (string, bool) {
return oairesponses.ResponsesFinishReasonFromStatus(resp)
}
func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesResponse, id string) (*dto.OpenAITextResponse, *dto.Usage, error) {
return oairesponses.ResponsesResponseToChatCompletionsResponse(resp, id)
}
func UsageFromResponsesUsage(src *dto.Usage) *dto.Usage {
return oairesponses.UsageFromResponsesUsage(src)
}
func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
return oairesponses.ExtractOutputTextFromResponses(resp)
}
func ExtractReasoningTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
return oairesponses.ExtractReasoningTextFromResponses(resp)
}
func NewResponsesToChatStreamState(model string, includeUsage bool) *ResponsesToChatStreamState {
return oairesponses.NewResponsesToChatStreamState(model, includeUsage)
}
func ResponsesStreamEventToChatChunks(event *dto.ResponsesStreamResponse, state *ResponsesToChatStreamState) ([]dto.ChatCompletionsStreamResponse, error) {
return oairesponses.ResponsesStreamEventToChatChunks(event, state)
}
func FinalizeResponsesToChatStream(state *ResponsesToChatStreamState) []dto.ChatCompletionsStreamResponse {
return oairesponses.FinalizeResponsesToChatStream(state)
}
func NewResponsesBufferedAccumulator() *ResponsesBufferedAccumulator {
return oairesponses.NewResponsesBufferedAccumulator()
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,673 @@
package relayconvert
import (
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLookupBuiltinResponseConverters(t *testing.T) {
tests := []struct {
lookupID string
id string
from types.RelayFormat
to types.RelayFormat
quality ResponseConverterQuality
stepConverters []string
}{
{lookupID: ResponseConverterOAIChatToOAIResponses, id: ConverterOpenAIChatToOpenAIResponses, from: types.RelayFormatOpenAI, to: types.RelayFormatOpenAIResponses, quality: ResponseConverterQualityGood},
{lookupID: ResponseConverterOAIResponsesToOAIChat, id: ConverterOpenAIResponsesToOpenAIChat, from: types.RelayFormatOpenAIResponses, to: types.RelayFormatOpenAI, quality: ResponseConverterQualityGood},
{lookupID: ResponseConverterOAIChatToClaudeMessages, id: ConverterOpenAIChatToClaudeMessages, from: types.RelayFormatOpenAI, to: types.RelayFormatClaude, quality: ResponseConverterQualityFair},
{lookupID: ResponseConverterOAIChatToGeminiChat, id: ConverterOpenAIChatToGeminiContent, from: types.RelayFormatOpenAI, to: types.RelayFormatGemini, quality: ResponseConverterQualityFair},
{lookupID: ResponseConverterClaudeMessagesToOAIChat, id: ConverterClaudeMessagesToOpenAIChat, from: types.RelayFormatClaude, to: types.RelayFormatOpenAI, quality: ResponseConverterQualityFair},
{lookupID: ResponseConverterGeminiChatToOAIChat, id: ConverterGeminiContentToOpenAIChat, from: types.RelayFormatGemini, to: types.RelayFormatOpenAI, quality: ResponseConverterQualityFair},
{
lookupID: responseConverterClaudeToGemini,
id: requestConverterClaudeToGemini,
from: types.RelayFormatClaude,
to: types.RelayFormatGemini,
quality: ResponseConverterQualityDiscouraged,
stepConverters: []string{
ConverterClaudeMessagesToOpenAIChat,
ConverterOpenAIChatToGeminiContent,
},
},
{
lookupID: responseConverterClaudeToResponses,
id: requestConverterClaudeToResponses,
from: types.RelayFormatClaude,
to: types.RelayFormatOpenAIResponses,
quality: ResponseConverterQualityFair,
stepConverters: []string{
ConverterClaudeMessagesToOpenAIChat,
ConverterOpenAIChatToOpenAIResponses,
},
},
{
lookupID: responseConverterGeminiToClaude,
id: requestConverterGeminiToClaude,
from: types.RelayFormatGemini,
to: types.RelayFormatClaude,
quality: ResponseConverterQualityDiscouraged,
stepConverters: []string{
ConverterGeminiContentToOpenAIChat,
ConverterOpenAIChatToClaudeMessages,
},
},
{
lookupID: responseConverterGeminiToResponses,
id: requestConverterGeminiToResponses,
from: types.RelayFormatGemini,
to: types.RelayFormatOpenAIResponses,
quality: ResponseConverterQualityFair,
stepConverters: []string{
ConverterGeminiContentToOpenAIChat,
ConverterOpenAIChatToOpenAIResponses,
},
},
{
lookupID: responseConverterResponsesToClaude,
id: requestConverterResponsesToClaude,
from: types.RelayFormatOpenAIResponses,
to: types.RelayFormatClaude,
quality: ResponseConverterQualityFair,
stepConverters: []string{
ConverterOpenAIResponsesToOpenAIChat,
ConverterOpenAIChatToClaudeMessages,
},
},
{
lookupID: responseConverterResponsesToGemini,
id: ConverterOpenAIResponsesToGemini,
from: types.RelayFormatOpenAIResponses,
to: types.RelayFormatGemini,
quality: ResponseConverterQualityFair,
stepConverters: []string{
ConverterOpenAIResponsesToOpenAIChat,
ConverterOpenAIChatToGeminiContent,
},
},
}
for _, tt := range tests {
t.Run(tt.lookupID, func(t *testing.T) {
spec, ok := LookupResponseConverter(tt.lookupID)
require.True(t, ok)
assert.Equal(t, tt.id, spec.ID)
assert.Equal(t, tt.from, spec.From)
assert.Equal(t, tt.to, spec.To)
assert.Equal(t, tt.quality, spec.Quality)
assert.Equal(t, tt.stepConverters, spec.StepConverters)
if len(tt.stepConverters) == 0 {
assert.NotNil(t, spec.Convert)
} else {
assert.Nil(t, spec.Convert)
}
})
}
_, ok := LookupResponseConverter("missing")
assert.False(t, ok)
}
func TestConvertResponseRejectsNilAndUnsupportedRoute(t *testing.T) {
_, err := ConvertResponse(nil, nil, types.RelayFormatOpenAI, (*dto.OpenAITextResponse)(nil))
require.Error(t, err)
_, err = ConvertResponse(nil, nil, types.RelayFormatEmbedding, &dto.OpenAITextResponse{})
require.Error(t, err)
}
func TestConvertResponseDirectConverters(t *testing.T) {
chat := textRegistryChatResponse()
info := &convmeta.Values{ChannelMetaAttached: true, UpstreamModelName: "gemini-test"}
toResponses, err := ConvertResponse(nil, info, types.RelayFormatOpenAIResponses, chat)
require.NoError(t, err)
assert.Equal(t, ConverterOpenAIChatToOpenAIResponses, toResponses.Converter)
assert.Equal(t, ResponseConverterQualityGood, toResponses.Quality)
assert.Equal(t, types.RelayFormatOpenAI, toResponses.From)
assert.Equal(t, types.RelayFormat(types.RelayFormatOpenAIResponses), toResponses.To)
assert.Equal(t, []ResponseStep{{Converter: ConverterOpenAIChatToOpenAIResponses, From: types.RelayFormatOpenAI, To: types.RelayFormatOpenAIResponses}}, toResponses.Steps)
require.IsType(t, &dto.OpenAIResponsesResponse{}, toResponses.Value)
assert.Equal(t, 9, toResponses.Usage.TotalTokens)
require.NotNil(t, toResponses.Usage.BillingUsage)
require.NotNil(t, toResponses.Usage.BillingUsage.OpenAIUsage)
assert.Equal(t, dto.BillingUsageSourceOAIChat, toResponses.Usage.BillingUsage.Source)
assert.Equal(t, 4, toResponses.Usage.BillingUsage.OpenAIUsage.PromptTokens)
responses := &dto.OpenAIResponsesResponse{
ID: "resp_1",
CreatedAt: 123,
Model: "gpt-test",
Status: []byte(`"completed"`),
Output: []dto.ResponsesOutput{
{
Type: "message",
Role: "assistant",
Content: []dto.ResponsesOutputContent{
{Type: "output_text", Text: "hello"},
},
},
},
Usage: &dto.Usage{InputTokens: 4, OutputTokens: 6, TotalTokens: 10},
}
toChat, err := ConvertResponse(nil, info, types.RelayFormatOpenAI, responses)
require.NoError(t, err)
assert.Equal(t, ConverterOpenAIResponsesToOpenAIChat, toChat.Converter)
assert.Equal(t, ResponseConverterQualityGood, toChat.Quality)
require.IsType(t, &dto.OpenAITextResponse{}, toChat.Value)
assert.Equal(t, 10, toChat.Usage.TotalTokens)
require.NotNil(t, toChat.Usage.BillingUsage)
require.NotNil(t, toChat.Usage.BillingUsage.OpenAIUsage)
assert.Equal(t, dto.BillingUsageSourceOAIResponses, toChat.Usage.BillingUsage.Source)
assert.Equal(t, 4, toChat.Usage.BillingUsage.OpenAIUsage.InputTokens)
toClaude, err := ConvertResponse(nil, info, types.RelayFormatClaude, chat)
require.NoError(t, err)
assert.Equal(t, ConverterOpenAIChatToClaudeMessages, toClaude.Converter)
assert.Equal(t, ResponseConverterQualityFair, toClaude.Quality)
require.IsType(t, &dto.ClaudeResponse{}, toClaude.Value)
assert.Equal(t, 9, toClaude.Usage.TotalTokens)
require.NotNil(t, toClaude.Usage.BillingUsage)
require.NotNil(t, toClaude.Usage.BillingUsage.OpenAIUsage)
claudeValue := toClaude.Value.(*dto.ClaudeResponse)
require.NotNil(t, claudeValue.Usage)
require.NotNil(t, claudeValue.Usage.BillingUsage)
require.NotNil(t, claudeValue.Usage.BillingUsage.OpenAIUsage)
toGemini, err := ConvertResponse(nil, info, types.RelayFormatGemini, chat)
require.NoError(t, err)
assert.Equal(t, ConverterOpenAIChatToGeminiContent, toGemini.Converter)
assert.Equal(t, ResponseConverterQualityFair, toGemini.Quality)
require.IsType(t, &dto.GeminiChatResponse{}, toGemini.Value)
assert.Equal(t, 9, toGemini.Usage.TotalTokens)
require.NotNil(t, toGemini.Usage.BillingUsage)
require.NotNil(t, toGemini.Usage.BillingUsage.OpenAIUsage)
geminiValue := toGemini.Value.(*dto.GeminiChatResponse)
require.NotNil(t, geminiValue.UsageMetadata.BillingUsage)
require.NotNil(t, geminiValue.UsageMetadata.BillingUsage.OpenAIUsage)
}
func TestConvertResponseMultiHopConverters(t *testing.T) {
responses := textRegistryResponsesResponse()
toClaude, err := ConvertResponse(nil, &convmeta.Values{}, types.RelayFormatClaude, responses)
require.NoError(t, err)
assert.Equal(t, requestConverterResponsesToClaude, toClaude.Converter)
assert.Equal(t, ResponseConverterQualityFair, toClaude.Quality)
assert.Equal(t, []ResponseStep{
{Converter: ConverterOpenAIResponsesToOpenAIChat, From: types.RelayFormatOpenAIResponses, To: types.RelayFormatOpenAI},
{Converter: ConverterOpenAIChatToClaudeMessages, From: types.RelayFormatOpenAI, To: types.RelayFormatClaude},
}, toClaude.Steps)
require.IsType(t, &dto.ClaudeResponse{}, toClaude.Value)
claudeValue := toClaude.Value.(*dto.ClaudeResponse)
require.Len(t, claudeValue.Content, 2)
assert.Equal(t, "text", claudeValue.Content[0].Type)
assert.Equal(t, "tool_use", claudeValue.Content[1].Type)
assert.Equal(t, "lookup", claudeValue.Content[1].Name)
assert.Equal(t, map[string]interface{}{"q": "x"}, claudeValue.Content[1].Input)
assert.Equal(t, 11, toClaude.Usage.TotalTokens)
toGemini, err := ConvertResponse(nil, &convmeta.Values{ChannelMetaAttached: true, UpstreamModelName: "gemini-test"}, types.RelayFormatGemini, responses)
require.NoError(t, err)
assert.Equal(t, ConverterOpenAIResponsesToGemini, toGemini.Converter)
assert.Equal(t, ResponseConverterQualityFair, toGemini.Quality)
assert.Equal(t, []ResponseStep{
{Converter: ConverterOpenAIResponsesToOpenAIChat, From: types.RelayFormatOpenAIResponses, To: types.RelayFormatOpenAI},
{Converter: ConverterOpenAIChatToGeminiContent, From: types.RelayFormatOpenAI, To: types.RelayFormatGemini},
}, toGemini.Steps)
require.IsType(t, &dto.GeminiChatResponse{}, toGemini.Value)
geminiValue := toGemini.Value.(*dto.GeminiChatResponse)
require.Len(t, geminiValue.Candidates, 1)
require.Len(t, geminiValue.Candidates[0].Content.Parts, 2)
assert.Equal(t, "hello", geminiValue.Candidates[0].Content.Parts[0].Text)
require.NotNil(t, geminiValue.Candidates[0].Content.Parts[1].FunctionCall)
assert.Equal(t, "lookup", geminiValue.Candidates[0].Content.Parts[1].FunctionCall.FunctionName)
assert.Equal(t, map[string]interface{}{"q": "x"}, geminiValue.Candidates[0].Content.Parts[1].FunctionCall.Arguments)
assert.Equal(t, 11, toGemini.Usage.TotalTokens)
}
func TestConvertResponseByIDExecutesMultiHopAndChecksSource(t *testing.T) {
responses := textRegistryResponsesResponse()
result, err := ConvertResponseByID(nil, nil, responseConverterResponsesToGemini, responses)
require.NoError(t, err)
assert.Equal(t, ConverterOpenAIResponsesToGemini, result.Converter)
assert.Equal(t, []ResponseStep{
{Converter: ConverterOpenAIResponsesToOpenAIChat, From: types.RelayFormatOpenAIResponses, To: types.RelayFormatOpenAI},
{Converter: ConverterOpenAIChatToGeminiContent, From: types.RelayFormatOpenAI, To: types.RelayFormatGemini},
}, result.Steps)
_, err = ConvertResponseByID(nil, nil, responseConverterResponsesToGemini, textRegistryChatResponse())
require.Error(t, err)
}
func TestConvertResponseProviderToOAIChatUsage(t *testing.T) {
claude := &dto.ClaudeResponse{
Id: "msg_1",
Type: "message",
Role: "assistant",
Model: "claude-test",
StopReason: "end_turn",
Content: []dto.ClaudeMediaMessage{
{Type: "tool_use", Id: "toolu_1", Name: "lookup", Input: map[string]interface{}{"q": "x"}},
},
Usage: &dto.ClaudeUsage{
InputTokens: 10,
CacheReadInputTokens: 3,
CacheCreationInputTokens: 4,
OutputTokens: 5,
CacheCreation: &dto.ClaudeCacheCreationUsage{
Ephemeral5mInputTokens: 1,
Ephemeral1hInputTokens: 3,
},
},
}
toChat, err := ConvertResponse(nil, nil, types.RelayFormatOpenAI, claude)
require.NoError(t, err)
assert.Equal(t, ConverterClaudeMessagesToOpenAIChat, toChat.Converter)
require.IsType(t, &dto.OpenAITextResponse{}, toChat.Value)
assert.Equal(t, 17, toChat.Usage.PromptTokens)
assert.Equal(t, 5, toChat.Usage.CompletionTokens)
assert.Equal(t, 22, toChat.Usage.TotalTokens)
assert.Equal(t, 3, toChat.Usage.PromptTokensDetails.CachedTokens)
assert.Equal(t, 4, toChat.Usage.PromptTokensDetails.CachedCreationTokens)
assert.Equal(t, 4, toChat.Usage.PromptTokensDetails.CacheWriteTokens)
require.NotNil(t, toChat.Usage.BillingUsage)
require.NotNil(t, toChat.Usage.BillingUsage.ClaudeUsage)
assert.Equal(t, dto.BillingUsageSourceClaudeMessages, toChat.Usage.BillingUsage.Source)
assert.Equal(t, dto.BillingUsageSemanticAnthropic, toChat.Usage.BillingUsage.Semantic)
assert.Equal(t, 10, toChat.Usage.BillingUsage.ClaudeUsage.InputTokens)
assert.Equal(t, 3, toChat.Usage.BillingUsage.ClaudeUsage.CacheReadInputTokens)
assert.Equal(t, 4, toChat.Usage.BillingUsage.ClaudeUsage.CacheCreationInputTokens)
assert.Equal(t, 5, toChat.Usage.BillingUsage.ClaudeUsage.OutputTokens)
chatValue := toChat.Value.(*dto.OpenAITextResponse)
require.Len(t, chatValue.Choices, 1)
require.Len(t, chatValue.Choices[0].Message.ParseToolCalls(), 1)
assert.JSONEq(t, `{"q":"x"}`, chatValue.Choices[0].Message.ParseToolCalls()[0].Function.Arguments)
gemini := &dto.GeminiChatResponse{
Candidates: []dto.GeminiChatCandidate{
{
Content: dto.GeminiChatContent{
Parts: []dto.GeminiPart{
{Text: "hello"},
{FunctionCall: &dto.FunctionCall{FunctionName: "lookup", Arguments: map[string]interface{}{"q": "x"}}},
},
},
},
},
UsageMetadata: dto.GeminiUsageMetadata{
PromptTokenCount: 7,
ToolUsePromptTokenCount: 2,
CandidatesTokenCount: 5,
ThoughtsTokenCount: 3,
TotalTokenCount: 17,
CachedContentTokenCount: 4,
PromptTokensDetails: []dto.GeminiPromptTokensDetails{
{Modality: "TEXT", TokenCount: 5},
{Modality: "IMAGE", TokenCount: 1},
},
ToolUsePromptTokensDetails: []dto.GeminiPromptTokensDetails{
{Modality: "AUDIO", TokenCount: 3},
},
CandidatesTokensDetails: []dto.GeminiPromptTokensDetails{
{Modality: "TEXT", TokenCount: 4},
{Modality: "IMAGE", TokenCount: 1},
},
},
}
toChat, err = ConvertResponse(nil, &convmeta.Values{ChannelMetaAttached: true, UpstreamModelName: "gemini-test"}, types.RelayFormatOpenAI, gemini)
require.NoError(t, err)
assert.Equal(t, ConverterGeminiContentToOpenAIChat, toChat.Converter)
require.IsType(t, &dto.OpenAITextResponse{}, toChat.Value)
assert.Equal(t, 9, toChat.Usage.PromptTokens)
assert.Equal(t, 8, toChat.Usage.CompletionTokens)
assert.Equal(t, 17, toChat.Usage.TotalTokens)
assert.Equal(t, 3, toChat.Usage.CompletionTokenDetails.ReasoningTokens)
assert.Equal(t, 4, toChat.Usage.PromptTokensDetails.CachedTokens)
assert.Equal(t, 5, toChat.Usage.PromptTokensDetails.TextTokens)
assert.Equal(t, 3, toChat.Usage.PromptTokensDetails.AudioTokens)
assert.Equal(t, 1, toChat.Usage.PromptTokensDetails.ImageTokens)
assert.Equal(t, 4, toChat.Usage.CompletionTokenDetails.TextTokens)
assert.Equal(t, 1, toChat.Usage.CompletionTokenDetails.ImageTokens)
require.NotNil(t, toChat.Usage.BillingUsage)
require.NotNil(t, toChat.Usage.BillingUsage.GeminiUsageMetadata)
assert.Equal(t, dto.BillingUsageSourceGeminiChat, toChat.Usage.BillingUsage.Source)
assert.Equal(t, dto.BillingUsageSemanticGemini, toChat.Usage.BillingUsage.Semantic)
assert.Equal(t, 7, toChat.Usage.BillingUsage.GeminiUsageMetadata.PromptTokenCount)
assert.Equal(t, 2, toChat.Usage.BillingUsage.GeminiUsageMetadata.ToolUsePromptTokenCount)
assert.Equal(t, 17, toChat.Usage.BillingUsage.GeminiUsageMetadata.TotalTokenCount)
}
func TestConvertResponsePreservesBillingUsageAcrossChatResponsesBridge(t *testing.T) {
chat := textRegistryChatResponse()
chat.Usage.BillingUsage = dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{
InputTokens: 10,
CacheReadInputTokens: 3,
CacheCreationInputTokens: 4,
OutputTokens: 5,
})
toResponses, err := ConvertResponse(nil, nil, types.RelayFormatOpenAIResponses, chat)
require.NoError(t, err)
require.NotNil(t, toResponses.Usage.BillingUsage)
require.NotNil(t, toResponses.Usage.BillingUsage.ClaudeUsage)
assert.Equal(t, 10, toResponses.Usage.BillingUsage.ClaudeUsage.InputTokens)
responsesValue := toResponses.Value.(*dto.OpenAIResponsesResponse)
toChat, err := ConvertResponse(nil, nil, types.RelayFormatOpenAI, responsesValue)
require.NoError(t, err)
require.NotNil(t, toChat.Usage.BillingUsage)
require.NotNil(t, toChat.Usage.BillingUsage.ClaudeUsage)
assert.Equal(t, 4, toChat.Usage.BillingUsage.ClaudeUsage.CacheCreationInputTokens)
}
func TestConvertResponseUsesBillingUsageWhenRestoringNativeTargets(t *testing.T) {
chat := textRegistryChatResponse()
chat.Usage.BillingUsage = dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{
InputTokens: 10,
CacheReadInputTokens: 3,
CacheCreationInputTokens: 4,
OutputTokens: 5,
})
toClaude, err := ConvertResponse(nil, nil, types.RelayFormatClaude, chat)
require.NoError(t, err)
claudeValue := toClaude.Value.(*dto.ClaudeResponse)
require.NotNil(t, claudeValue.Usage)
assert.Equal(t, 10, claudeValue.Usage.InputTokens)
assert.Equal(t, 3, claudeValue.Usage.CacheReadInputTokens)
assert.Equal(t, 4, claudeValue.Usage.CacheCreationInputTokens)
assert.Equal(t, 5, claudeValue.Usage.OutputTokens)
chat.Usage.BillingUsage = dto.NewGeminiChatBillingUsage(&dto.GeminiUsageMetadata{
PromptTokenCount: 7,
ToolUsePromptTokenCount: 2,
CandidatesTokenCount: 5,
ThoughtsTokenCount: 3,
TotalTokenCount: 17,
})
toGemini, err := ConvertResponse(nil, nil, types.RelayFormatGemini, chat)
require.NoError(t, err)
geminiValue := toGemini.Value.(*dto.GeminiChatResponse)
assert.Equal(t, 7, geminiValue.UsageMetadata.PromptTokenCount)
assert.Equal(t, 2, geminiValue.UsageMetadata.ToolUsePromptTokenCount)
assert.Equal(t, 5, geminiValue.UsageMetadata.CandidatesTokenCount)
assert.Equal(t, 3, geminiValue.UsageMetadata.ThoughtsTokenCount)
assert.Equal(t, 17, geminiValue.UsageMetadata.TotalTokenCount)
}
func TestConvertStreamResponseDirectConverters(t *testing.T) {
info := &convmeta.Values{
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{
LastMessagesType: convmeta.LastMessageTypeNone,
},
}
info.SendResponseCount = 1
finishReason := "stop"
result, err := ConvertStreamResponse(nil, info, types.RelayFormatClaude, &dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{
{
FinishReason: &finishReason,
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
Content: respPtr("hello"),
},
},
},
Usage: &dto.Usage{PromptTokens: 2, CompletionTokens: 3, TotalTokens: 5},
})
require.NoError(t, err)
assert.True(t, result.Stream)
assert.Equal(t, ConverterOpenAIChatToClaudeMessages, result.Converter)
require.IsType(t, []*dto.ClaudeResponse{}, result.Value)
assert.Equal(t, 5, result.Usage.TotalTokens)
result, err = ConvertStreamResponse(nil, &convmeta.Values{ChannelMetaAttached: true, UpstreamModelName: "gemini-test"}, types.RelayFormatOpenAI, &dto.GeminiChatResponse{
Candidates: []dto.GeminiChatCandidate{{Content: dto.GeminiChatContent{Parts: []dto.GeminiPart{{Text: "hello"}}}}},
UsageMetadata: dto.GeminiUsageMetadata{
PromptTokenCount: 1,
CandidatesTokenCount: 2,
TotalTokenCount: 3,
},
})
require.NoError(t, err)
assert.True(t, result.Stream)
assert.Equal(t, ConverterGeminiContentToOpenAIChat, result.Converter)
require.IsType(t, &dto.ChatCompletionsStreamResponse{}, result.Value)
assert.Equal(t, 3, result.Usage.TotalTokens)
}
func TestConvertStreamResponseStatefulDirectConverters(t *testing.T) {
chatState, err := NewResponseStreamState(types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses, ResponseStreamOptions{
ID: "resp_1",
Model: "gpt-test",
})
require.NoError(t, err)
chatResults, err := ConvertStreamResponseChunk(nil, nil, chatState, &dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{
{Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: respPtr("hello")}},
},
Usage: &dto.Usage{PromptTokens: 2, CompletionTokens: 3, TotalTokens: 5},
})
require.NoError(t, err)
require.NotEmpty(t, chatResults)
assert.Equal(t, ConverterOpenAIChatToOpenAIResponses, chatResults[0].Converter)
assert.Equal(t, []ResponseStep{{Converter: ConverterOpenAIChatToOpenAIResponses, From: types.RelayFormatOpenAI, To: types.RelayFormatOpenAIResponses}}, chatResults[0].Steps)
assert.Equal(t, 5, chatState.Usage().TotalTokens)
finalResults, err := FinalizeStreamResponse(nil, nil, chatState)
require.NoError(t, err)
require.NotEmpty(t, finalResults)
lastEvent, ok := finalResults[len(finalResults)-1].Value.(ChatToResponsesStreamEvent)
require.True(t, ok)
assert.Equal(t, "response.completed", lastEvent.Type)
responsesState, err := NewResponseStreamState(types.RelayFormatOpenAIResponses, types.RelayFormatOpenAI, ResponseStreamOptions{
ID: "chatcmpl_1",
Model: "gpt-test",
})
require.NoError(t, err)
responsesResults, err := ConvertStreamResponseChunk(nil, nil, responsesState, &dto.ResponsesStreamResponse{
Type: "response.output_text.delta",
Delta: "hello",
})
require.NoError(t, err)
require.NotEmpty(t, responsesResults)
assert.Equal(t, ConverterOpenAIResponsesToOpenAIChat, responsesResults[0].Converter)
assert.Equal(t, []ResponseStep{{Converter: ConverterOpenAIResponsesToOpenAIChat, From: types.RelayFormatOpenAIResponses, To: types.RelayFormatOpenAI}}, responsesResults[0].Steps)
require.IsType(t, dto.ChatCompletionsStreamResponse{}, responsesResults[len(responsesResults)-1].Value)
}
func TestConvertStreamResponseStatefulMultiHopResponsesToClaude(t *testing.T) {
info := &convmeta.Values{
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{
LastMessagesType: convmeta.LastMessageTypeNone,
},
}
state, err := NewResponseStreamState(types.RelayFormatOpenAIResponses, types.RelayFormatClaude, ResponseStreamOptions{
ID: "chatcmpl_1",
Model: "gpt-test",
})
require.NoError(t, err)
results, err := ConvertStreamResponseChunk(nil, info, state, &dto.ResponsesStreamResponse{
Type: "response.output_text.delta",
Delta: "hello",
})
require.NoError(t, err)
require.NotEmpty(t, results)
assert.Equal(t, requestConverterResponsesToClaude, results[0].Converter)
assert.Equal(t, []ResponseStep{
{Converter: ConverterOpenAIResponsesToOpenAIChat, From: types.RelayFormatOpenAIResponses, To: types.RelayFormatOpenAI},
{Converter: ConverterOpenAIChatToClaudeMessages, From: types.RelayFormatOpenAI, To: types.RelayFormatClaude},
}, results[0].Steps)
var sawTextDelta bool
for _, result := range results {
claudeResponse, ok := result.Value.(*dto.ClaudeResponse)
if !ok || claudeResponse == nil {
continue
}
if claudeResponse.Type == "content_block_delta" && claudeResponse.Delta != nil && claudeResponse.Delta.Text != nil && *claudeResponse.Delta.Text == "hello" {
sawTextDelta = true
}
}
assert.True(t, sawTextDelta)
state.SetUsage(&dto.Usage{PromptTokens: 2, CompletionTokens: 3, TotalTokens: 5})
_, err = FinalizeStreamResponse(nil, info, state)
require.NoError(t, err)
assert.Equal(t, 5, state.Usage().TotalTokens)
}
func TestResponseUsageMatrixChatAndResponsesDetails(t *testing.T) {
chat := textRegistryChatResponse()
chat.Usage = dto.Usage{
PromptTokens: 10,
CompletionTokens: 5,
TotalTokens: 20,
PromptTokensDetails: dto.InputTokenDetails{
CachedTokens: 3,
CachedCreationTokens: 2,
CacheWriteTokens: 6,
TextTokens: 4,
AudioTokens: 1,
ImageTokens: 5,
},
CompletionTokenDetails: dto.OutputTokenDetails{
ReasoningTokens: 2,
TextTokens: 2,
AudioTokens: 1,
ImageTokens: 2,
},
}
result, err := ConvertResponse(nil, nil, types.RelayFormatOpenAIResponses, chat)
require.NoError(t, err)
assert.Equal(t, 10, result.Usage.InputTokens)
assert.Equal(t, 5, result.Usage.OutputTokens)
assert.Equal(t, 20, result.Usage.TotalTokens)
require.NotNil(t, result.Usage.InputTokensDetails)
assert.Equal(t, 3, result.Usage.InputTokensDetails.CachedTokens)
assert.Equal(t, 2, result.Usage.InputTokensDetails.CachedCreationTokens)
assert.Equal(t, 6, result.Usage.InputTokensDetails.CacheWriteTokens)
assert.Equal(t, 4, result.Usage.InputTokensDetails.TextTokens)
assert.Equal(t, 1, result.Usage.InputTokensDetails.AudioTokens)
assert.Equal(t, 5, result.Usage.InputTokensDetails.ImageTokens)
assert.Equal(t, 2, result.Usage.CompletionTokenDetails.ReasoningTokens)
assert.Equal(t, 2, result.Usage.CompletionTokenDetails.TextTokens)
assert.Equal(t, 1, result.Usage.CompletionTokenDetails.AudioTokens)
assert.Equal(t, 2, result.Usage.CompletionTokenDetails.ImageTokens)
responses := &dto.OpenAIResponsesResponse{
ID: "resp_1",
Status: []byte(`"completed"`),
Model: "gpt-test",
Output: []dto.ResponsesOutput{},
CreatedAt: 123,
Usage: &dto.Usage{
InputTokens: 12,
OutputTokens: 8,
TotalTokens: 21,
InputTokensDetails: &dto.InputTokenDetails{
CachedTokens: 4,
CachedCreationTokens: 1,
CacheWriteTokens: 7,
TextTokens: 5,
AudioTokens: 2,
ImageTokens: 1,
},
CompletionTokenDetails: dto.OutputTokenDetails{
ReasoningTokens: 3,
TextTokens: 4,
AudioTokens: 1,
ImageTokens: 3,
},
},
}
result, err = ConvertResponse(nil, nil, types.RelayFormatOpenAI, responses)
require.NoError(t, err)
assert.Equal(t, 12, result.Usage.PromptTokens)
assert.Equal(t, 8, result.Usage.CompletionTokens)
assert.Equal(t, 21, result.Usage.TotalTokens)
assert.Equal(t, 4, result.Usage.PromptTokensDetails.CachedTokens)
assert.Equal(t, 1, result.Usage.PromptTokensDetails.CachedCreationTokens)
assert.Equal(t, 7, result.Usage.PromptTokensDetails.CacheWriteTokens)
assert.Equal(t, 5, result.Usage.PromptTokensDetails.TextTokens)
assert.Equal(t, 2, result.Usage.PromptTokensDetails.AudioTokens)
assert.Equal(t, 1, result.Usage.PromptTokensDetails.ImageTokens)
assert.Equal(t, 3, result.Usage.CompletionTokenDetails.ReasoningTokens)
assert.Equal(t, 4, result.Usage.CompletionTokenDetails.TextTokens)
assert.Equal(t, 1, result.Usage.CompletionTokenDetails.AudioTokens)
assert.Equal(t, 3, result.Usage.CompletionTokenDetails.ImageTokens)
}
func textRegistryChatResponse() *dto.OpenAITextResponse {
msg := dto.Message{
Role: "assistant",
Content: "hello",
}
msg.SetToolCalls([]dto.ToolCallRequest{
{
ID: "call_1",
Type: "function",
Function: dto.FunctionRequest{
Name: "lookup",
Arguments: `{"q":"x"}`,
},
},
})
return &dto.OpenAITextResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Created: 123,
Choices: []dto.OpenAITextResponseChoice{
{
Index: 0,
Message: msg,
FinishReason: "tool_calls",
},
},
Usage: dto.Usage{PromptTokens: 4, CompletionTokens: 5, TotalTokens: 9},
}
}
func textRegistryResponsesResponse() *dto.OpenAIResponsesResponse {
return &dto.OpenAIResponsesResponse{
ID: "resp_1",
CreatedAt: 123,
Model: "gpt-test",
Status: []byte(`"completed"`),
Output: []dto.ResponsesOutput{
{
Type: "message",
Role: "assistant",
Content: []dto.ResponsesOutputContent{
{Type: "output_text", Text: "hello"},
},
},
{
Type: "function_call",
ID: "call_1",
CallId: "call_1",
Name: "lookup",
Arguments: []byte(`{"q":"x"}`),
},
},
Usage: &dto.Usage{InputTokens: 4, OutputTokens: 7, TotalTokens: 11},
}
}
func respPtr[T any](value T) *T {
return &value
}
@@ -0,0 +1,94 @@
package relayconvert
import (
"context"
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOpenAIToGeminiSafetySettings(t *testing.T) {
converters := []struct {
name string
convert func(t *testing.T, meta convmeta.Meta) *dto.GeminiChatRequest
}{
{
name: "chat completions",
convert: func(t *testing.T, meta convmeta.Meta) *dto.GeminiChatRequest {
t.Helper()
got, err := OpenAIChatRequestToGeminiGenerateContent(context.Background(), dto.GeneralOpenAIRequest{
Model: "gemini-test",
Messages: []dto.Message{
{Role: "user", Content: "hello"},
},
}, meta)
require.NoError(t, err)
return got
},
},
{
name: "responses",
convert: func(t *testing.T, meta convmeta.Meta) *dto.GeminiChatRequest {
t.Helper()
got, err := OpenAIResponsesRequestToGeminiChat(context.Background(), &dto.OpenAIResponsesRequest{
Model: "gemini-test",
Input: []byte(`"hello"`),
}, meta)
require.NoError(t, err)
return got
},
},
}
for _, converter := range converters {
t.Run(converter.name, func(t *testing.T) {
t.Run("nil meta", func(t *testing.T) {
got := converter.convert(t, nil)
assert.Empty(t, got.SafetySettings)
body, err := kitutil.Marshal(got)
require.NoError(t, err)
assert.NotContains(t, string(body), `"safetySettings"`)
})
t.Run("zero options", func(t *testing.T) {
got := converter.convert(t, &convmeta.Values{})
assert.Empty(t, got.SafetySettings)
body, err := kitutil.Marshal(got)
require.NoError(t, err)
assert.NotContains(t, string(body), `"safetySettings"`)
})
t.Run("empty thresholds", func(t *testing.T) {
got := converter.convert(t, &convmeta.Values{Options: &convmeta.Options{
Gemini: convmeta.GeminiOptions{
SafetySetting: func(category string) string {
if category == "HARM_CATEGORY_HARASSMENT" {
return "BLOCK_NONE"
}
return ""
},
},
}})
assert.Equal(t, []dto.GeminiChatSafetySettings{
{Category: "HARM_CATEGORY_HARASSMENT", Threshold: "BLOCK_NONE"},
}, got.SafetySettings)
})
t.Run("nonempty thresholds", func(t *testing.T) {
got := converter.convert(t, &convmeta.Values{Options: &convmeta.Options{
Gemini: convmeta.GeminiOptions{
SafetySetting: func(string) string { return "OFF" },
},
}})
require.Len(t, got.SafetySettings, 4)
for _, setting := range got.SafetySettings {
assert.Equal(t, "OFF", setting.Threshold)
}
})
})
}
}
@@ -0,0 +1,94 @@
{
"contents": [
{
"role": "user",
"parts": [
{
"text": "What is in this image?"
},
{
"inlineData": {
"mimeType": "image/png",
"data": "aGVsbG8="
}
}
]
},
{
"role": "model",
"parts": [
{
"functionCall": {
"name": "get_weather",
"args": {
"city": "Paris"
}
},
"thoughtSignature": "context_engineering_is_the_way_to_go"
}
]
},
{
"role": "user",
"parts": [
{
"functionResponse": {
"name": "get_weather",
"response": {
"content": "15 degrees"
}
}
}
]
}
],
"safetySettings": [
{
"category": "HARM_CATEGORY_HARASSMENT",
"threshold": "OFF"
},
{
"category": "HARM_CATEGORY_HATE_SPEECH",
"threshold": "OFF"
},
{
"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
"threshold": "OFF"
},
{
"category": "HARM_CATEGORY_DANGEROUS_CONTENT",
"threshold": "OFF"
}
],
"generationConfig": {
"maxOutputTokens": 1024
},
"tools": [
{
"functionDeclarations": [
{
"description": "Get weather by city",
"name": "get_weather",
"parameters": {
"properties": {
"city": {
"type": "STRING"
}
},
"required": [
"city"
],
"type": "OBJECT"
}
}
]
}
],
"systemInstruction": {
"parts": [
{
"text": "You are a helpful assistant."
}
]
}
}
@@ -0,0 +1,67 @@
{
"model": "claude-test",
"messages": [
{
"role": "system",
"content": "You are a helpful assistant."
},
{
"role": "user",
"content": [
{
"type": "text",
"text": "What is in this image?"
},
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,aGVsbG8=",
"MimeType": ""
}
}
]
},
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "toolu_abc",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\":\"Paris\"}"
}
}
]
},
{
"role": "tool",
"content": "15 degrees",
"name": "get_weather",
"tool_call_id": "toolu_abc"
}
],
"stream": true,
"max_tokens": 1024,
"tools": [
{
"type": "function",
"function": {
"description": "Get weather by city",
"name": "get_weather",
"parameters": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
}
}
}
]
}
@@ -0,0 +1,54 @@
{
"model": "claude-test",
"input": [
{
"content": [
{
"text": "What is in this image?",
"type": "input_text"
},
{
"image_url": "data:image/png;base64,aGVsbG8=",
"type": "input_image"
}
],
"role": "user"
},
{
"content": "",
"role": "assistant"
},
{
"arguments": "{\"city\":\"Paris\"}",
"call_id": "toolu_abc",
"name": "get_weather",
"type": "function_call"
},
{
"call_id": "toolu_abc",
"output": "15 degrees",
"type": "function_call_output"
}
],
"instructions": "You are a helpful assistant.",
"max_output_tokens": 1024,
"stream": true,
"tools": [
{
"description": "Get weather by city",
"name": "get_weather",
"parameters": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"type": "function"
}
]
}
@@ -0,0 +1,61 @@
{
"model": "upstream-model",
"system": [
{
"type": "text",
"text": "You are a helpful assistant."
}
],
"messages": [
{
"role": "user",
"content": []
},
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "..."
},
{
"type": "tool_use",
"id": "call_1",
"name": "get_weather",
"input": {
"city": "Paris"
}
}
]
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"content": "{\"result\":\"15 degrees\"}",
"tool_use_id": "call_0"
}
]
}
],
"max_tokens": 1024,
"temperature": 0.7,
"tools": [
{
"name": "get_weather",
"description": "Get weather by city",
"input_schema": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
}
}
]
}
@@ -0,0 +1,68 @@
{
"model": "upstream-model",
"messages": [
{
"role": "system",
"content": "You are a helpful assistant."
},
{
"role": "user",
"content": [
{
"type": "text",
"text": "What is in this image?"
},
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,aGVsbG8=",
"detail": "auto",
"MimeType": "image/png"
}
}
]
},
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\":\"Paris\"}"
}
}
]
},
{
"role": "tool",
"content": "{\"result\":\"15 degrees\"}",
"tool_call_id": "call_0"
}
],
"stream": false,
"max_tokens": 1024,
"temperature": 0.7,
"tools": [
{
"type": "function",
"function": {
"description": "Get weather by city",
"name": "get_weather",
"parameters": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
}
}
}
]
}
@@ -0,0 +1,55 @@
{
"model": "upstream-model",
"input": [
{
"content": [
{
"text": "What is in this image?",
"type": "input_text"
},
{
"image_url": "data:image/png;base64,aGVsbG8=",
"type": "input_image"
}
],
"role": "user"
},
{
"content": "",
"role": "assistant"
},
{
"arguments": "{\"city\":\"Paris\"}",
"call_id": "call_1",
"name": "get_weather",
"type": "function_call"
},
{
"call_id": "call_0",
"output": "{\"result\":\"15 degrees\"}",
"type": "function_call_output"
}
],
"instructions": "You are a helpful assistant.",
"max_output_tokens": 1024,
"stream": false,
"temperature": 0.7,
"tools": [
{
"description": "Get weather by city",
"name": "get_weather",
"parameters": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"type": "function"
}
]
}
@@ -0,0 +1,70 @@
{
"model": "gpt-test",
"system": [
{
"type": "text",
"text": "You are a helpful assistant."
}
],
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": "What is in this image?"
},
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": "aGVsbG8="
}
}
]
},
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "call_abc",
"name": "get_weather",
"input": {
"city": "Paris"
}
}
]
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"content": "15 degrees",
"tool_use_id": "call_abc"
}
]
}
],
"max_tokens": 1024,
"stream": true,
"tools": [
{
"name": "get_weather",
"description": "Get weather by city",
"input_schema": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
}
}
]
}
@@ -0,0 +1,94 @@
{
"contents": [
{
"role": "user",
"parts": [
{
"text": "What is in this image?"
},
{
"inlineData": {
"mimeType": "image/png",
"data": "aGVsbG8="
}
}
]
},
{
"role": "model",
"parts": [
{
"functionCall": {
"name": "get_weather",
"args": {
"city": "Paris"
}
},
"thoughtSignature": "context_engineering_is_the_way_to_go"
}
]
},
{
"role": "user",
"parts": [
{
"functionResponse": {
"name": "get_weather",
"response": {
"content": "15 degrees"
}
}
}
]
}
],
"safetySettings": [
{
"category": "HARM_CATEGORY_HARASSMENT",
"threshold": "OFF"
},
{
"category": "HARM_CATEGORY_HATE_SPEECH",
"threshold": "OFF"
},
{
"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
"threshold": "OFF"
},
{
"category": "HARM_CATEGORY_DANGEROUS_CONTENT",
"threshold": "OFF"
}
],
"generationConfig": {
"maxOutputTokens": 1024
},
"tools": [
{
"functionDeclarations": [
{
"description": "Get weather by city",
"name": "get_weather",
"parameters": {
"properties": {
"city": {
"type": "STRING"
}
},
"required": [
"city"
],
"type": "OBJECT"
}
}
]
}
],
"systemInstruction": {
"parts": [
{
"text": "You are a helpful assistant."
}
]
}
}
@@ -0,0 +1,63 @@
{
"model": "gpt-test",
"messages": [
{
"role": "system",
"content": "You are a helpful assistant."
},
{
"role": "user",
"content": [
{
"text": "What is in this image?",
"type": "text"
},
{
"image_url": "https://example.com/cat.png",
"type": "image_url"
}
]
},
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_abc",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\":\"Paris\"}"
}
}
]
},
{
"role": "tool",
"content": "15 degrees",
"tool_call_id": "call_abc"
}
],
"stream": true,
"max_completion_tokens": 1024,
"tools": [
{
"type": "function",
"function": {
"description": "Get weather by city",
"name": "get_weather",
"parameters": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
}
}
}
]
}
@@ -0,0 +1,81 @@
{
"model": "gpt-test",
"system": [
{
"type": "text",
"text": "You are a helpful assistant."
}
],
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": "What is in this image?"
},
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": "aGVsbG8="
}
}
]
},
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "..."
},
{
"type": "tool_use",
"id": "call_abc",
"name": "get_weather",
"input": {
"city": "Paris"
}
}
]
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"content": "15 degrees",
"tool_use_id": "call_abc"
}
]
},
{
"role": "user",
"content": "Summarize."
}
],
"max_tokens": 1024,
"stream": true,
"tools": [
{
"name": "get_weather",
"description": "Get weather by city",
"input_schema": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
}
}
],
"tool_choice": {
"type": "auto"
}
}
@@ -0,0 +1,107 @@
{
"contents": [
{
"role": "user",
"parts": [
{
"text": "What is in this image?"
},
{
"inlineData": {
"mimeType": "image/png",
"data": "aGVsbG8="
}
}
]
},
{
"role": "model",
"parts": [
{
"functionCall": {
"name": "get_weather",
"args": {
"city": "Paris"
}
},
"thoughtSignature": "context_engineering_is_the_way_to_go"
}
]
},
{
"role": "user",
"parts": [
{
"functionResponse": {
"name": "get_weather",
"response": {
"content": "15 degrees"
}
}
}
]
},
{
"role": "user",
"parts": [
{
"text": "Summarize."
}
]
}
],
"safetySettings": [
{
"category": "HARM_CATEGORY_HARASSMENT",
"threshold": "OFF"
},
{
"category": "HARM_CATEGORY_HATE_SPEECH",
"threshold": "OFF"
},
{
"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
"threshold": "OFF"
},
{
"category": "HARM_CATEGORY_DANGEROUS_CONTENT",
"threshold": "OFF"
}
],
"generationConfig": {
"maxOutputTokens": 1024
},
"tools": [
{
"functionDeclarations": [
{
"description": "Get weather by city",
"name": "get_weather",
"parameters": {
"properties": {
"city": {
"type": "STRING"
}
},
"required": [
"city"
],
"type": "OBJECT"
}
}
]
}
],
"toolConfig": {
"functionCallingConfig": {
"mode": "AUTO"
}
},
"systemInstruction": {
"parts": [
{
"text": "You are a helpful assistant."
}
]
}
}
@@ -0,0 +1,59 @@
{
"model": "gpt-test",
"input": [
{
"content": [
{
"text": "What is in this image?",
"type": "input_text"
},
{
"image_url": "https://example.com/cat.png",
"type": "input_image"
}
],
"role": "user"
},
{
"content": "",
"role": "assistant"
},
{
"arguments": "{\"city\":\"Paris\"}",
"call_id": "call_abc",
"name": "get_weather",
"type": "function_call"
},
{
"call_id": "call_abc",
"output": "15 degrees",
"type": "function_call_output"
},
{
"content": "Summarize.",
"role": "user"
}
],
"instructions": "You are a helpful assistant.",
"max_output_tokens": 1024,
"stream": true,
"tool_choice": "auto",
"tools": [
{
"description": "Get weather by city",
"name": "get_weather",
"parameters": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"type": "function"
}
]
}
@@ -0,0 +1,66 @@
{
"candidates": [
{
"content": {
"role": "model",
"parts": [
{
"text": "The answer is 42."
},
{
"functionCall": {
"name": "get_weather",
"args": {
"city": "Paris"
}
}
}
]
},
"finishReason": "STOP",
"index": 0,
"safetyRatings": []
}
],
"usageMetadata": {
"promptTokenCount": 15,
"toolUsePromptTokenCount": 0,
"candidatesTokenCount": 5,
"totalTokenCount": 20,
"thoughtsTokenCount": 0,
"cachedContentTokenCount": 0,
"promptTokensDetails": null,
"toolUsePromptTokensDetails": null,
"candidatesTokensDetails": null,
"billing_usage": {
"source": "oai_chat",
"semantic": "openai",
"openai_usage": {
"prompt_tokens": 15,
"completion_tokens": 5,
"total_tokens": 20,
"usage_semantic": "openai",
"usage_source": "anthropic",
"prompt_tokens_details": {
"cached_tokens": 3,
"cached_creation_tokens": 2,
"cache_write_tokens": 2,
"text_tokens": 0,
"audio_tokens": 0,
"image_tokens": 0
},
"completion_tokens_details": {
"text_tokens": 0,
"audio_tokens": 0,
"image_tokens": 0,
"reasoning_tokens": 0
},
"input_tokens": 15,
"output_tokens": 0,
"input_tokens_details": null,
"claude_cache_creation_5_m_tokens": 2,
"claude_cache_creation_1_h_tokens": 0
}
}
}
}
@@ -0,0 +1,64 @@
{
"id": "msg_fixed",
"model": "claude-test",
"object": "chat.completion",
"created": 0,
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "The answer is 42.",
"tool_calls": [
{
"id": "toolu_abc",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\":\"Paris\"}"
}
}
]
},
"finish_reason": "tool_calls"
}
],
"usage": {
"prompt_tokens": 15,
"completion_tokens": 5,
"total_tokens": 20,
"usage_semantic": "openai",
"usage_source": "anthropic",
"billing_usage": {
"source": "claude_messages",
"semantic": "anthropic",
"claude_usage": {
"input_tokens": 10,
"cache_creation_input_tokens": 2,
"cache_read_input_tokens": 3,
"output_tokens": 5,
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0
}
},
"prompt_tokens_details": {
"cached_tokens": 3,
"cached_creation_tokens": 2,
"cache_write_tokens": 2,
"text_tokens": 0,
"audio_tokens": 0,
"image_tokens": 0
},
"completion_tokens_details": {
"text_tokens": 0,
"audio_tokens": 0,
"image_tokens": 0,
"reasoning_tokens": 0
},
"input_tokens": 15,
"output_tokens": 0,
"input_tokens_details": null,
"claude_cache_creation_5_m_tokens": 2,
"claude_cache_creation_1_h_tokens": 0
}
}
@@ -0,0 +1,92 @@
{
"id": "msg_fixed",
"object": "response",
"created_at": 0,
"status": "completed",
"instructions": null,
"max_output_tokens": 0,
"model": "claude-test",
"output": [
{
"type": "message",
"id": "msg_fixed_msg_0",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "The answer is 42.",
"annotations": []
}
],
"quality": "",
"size": ""
},
{
"type": "function_call",
"id": "toolu_abc",
"status": "completed",
"role": "",
"content": null,
"quality": "",
"size": "",
"call_id": "toolu_abc",
"name": "get_weather",
"arguments": "{\"city\":\"Paris\"}"
}
],
"parallel_tool_calls": false,
"previous_response_id": null,
"reasoning": null,
"store": false,
"temperature": 0,
"tool_choice": null,
"tools": null,
"top_p": 0,
"truncation": null,
"usage": {
"prompt_tokens": 15,
"completion_tokens": 5,
"total_tokens": 20,
"usage_semantic": "openai",
"usage_source": "anthropic",
"billing_usage": {
"source": "claude_messages",
"semantic": "anthropic",
"claude_usage": {
"input_tokens": 10,
"cache_creation_input_tokens": 2,
"cache_read_input_tokens": 3,
"output_tokens": 5,
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0
}
},
"prompt_tokens_details": {
"cached_tokens": 0,
"text_tokens": 0,
"audio_tokens": 0,
"image_tokens": 0
},
"completion_tokens_details": {
"text_tokens": 0,
"audio_tokens": 0,
"image_tokens": 0,
"reasoning_tokens": 0
},
"input_tokens": 15,
"output_tokens": 5,
"input_tokens_details": {
"cached_tokens": 3,
"cached_creation_tokens": 2,
"cache_write_tokens": 2,
"text_tokens": 0,
"audio_tokens": 0,
"image_tokens": 0
},
"claude_cache_creation_5_m_tokens": 2,
"claude_cache_creation_1_h_tokens": 0
},
"user": null,
"metadata": null
}

Some files were not shown because too many files have changed in this diff Show More