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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
// TODO:ensure 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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"`)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package dto
|
||||
|
||||
type PlayGroundRequest struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package dto
|
||||
|
||||
type SensitiveResponse struct {
|
||||
SensitiveWords []string `json:"sensitive_words"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
@@ -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 推送
|
||||
)
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
}}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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("
|
||||
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("
|
||||
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, ¶llelToolCalls); err != nil {
|
||||
return nil
|
||||
}
|
||||
return ¶llelToolCalls
|
||||
}
|
||||
|
||||
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, ¶llelToolCalls); err == nil {
|
||||
out.ParallelTooCalls = ¶llelToolCalls
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+54
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+55
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+70
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+94
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+63
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+59
@@ -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
|
||||
}
|
||||
}
|
||||
+92
@@ -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
Reference in New Issue
Block a user