feat: publish v1.0.0-rc.36-custom.1
Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 9m20s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 55m7s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s

This commit is contained in:
Codex committed 2026-09-11 05:07:48 +08:00
commit bb7968b612
2381 files changed
+551494

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
package relayconvert
// boundary_test.go enforces the relaykit extraction dependency boundary
// (plans/relaykit-extraction-plan.md): packages that will move into the
// relaykit module must not grow imports of host-only packages. Entries in
// allowedViolations are the known couplings scheduled for removal in
// Phase 1/2 — shrink this list, never grow it.
import (
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
"testing"
)
const modulePrefix = "github.com/QuantumNous/new-api/"
// Packages (relative to the relaykit module root) covered by the boundary.
var kitDirs = []string{
"relayconvert",
"dto",
"types",
"reasonmap",
}
// Import prefixes forbidden inside the kit module: the entire host module
// (everything outside relaykit/) and gin.
var forbiddenPrefixes = []string{
modulePrefix,
"github.com/gin-gonic/gin",
}
// hostModuleExceptions are host-prefix imports that are actually the kit's
// own packages (the kit module path nests under the host path).
const kitModulePrefix = modulePrefix + "relaykit/"
// Known pre-existing couplings, removed phase by phase. Key: "dir|import".
// All initial violations have been cleared; keep the map so future
// exemptions (if ever needed) are explicit and reviewed.
var allowedViolations = map[string]bool{}
func TestRelaykitBoundary(t *testing.T) {
root := repoRoot(t)
fset := token.NewFileSet()
for _, dir := range kitDirs {
err := filepath.WalkDir(filepath.Join(root, dir), func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
if err != nil {
return err
}
for _, imp := range file.Imports {
importPath := strings.Trim(imp.Path.Value, `"`)
for _, prefix := range forbiddenPrefixes {
if importPath != prefix && !strings.HasPrefix(importPath, prefix) {
continue
}
if strings.HasPrefix(importPath, kitModulePrefix) {
continue
}
if allowedViolations[dir+"|"+importPath] {
continue
}
rel, _ := filepath.Rel(root, path)
t.Errorf("%s imports %q — forbidden inside future relaykit package %s (see plans/relaykit-extraction-plan.md)", rel, importPath, dir)
}
}
return nil
})
if err != nil {
t.Fatalf("walking %s: %v", dir, err)
}
}
}
func repoRoot(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("go.mod not found above test directory")
}
dir = parent
}
}
@@ -0,0 +1,210 @@
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"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"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)
})
t.Run("client zero same as absent, hook fills", func(t *testing.T) {
clientMaxTokens := uint(0)
got, err := converter.convert(t, claudeDefaultsMeta(func(string) int { return 512 }), &clientMaxTokens)
require.NoError(t, err)
require.NotNil(t, got.MaxTokens)
assert.Equal(t, uint(512), *got.MaxTokens)
})
t.Run("client zero same as absent, no hook fails", func(t *testing.T) {
clientMaxTokens := uint(0)
got, err := converter.convert(t, &convmeta.Values{}, &clientMaxTokens)
require.ErrorIs(t, err, sharedclaude.ErrMissingMaxTokens)
assert.Nil(t, got)
})
})
}
}
// 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) {
_, intent, found, err := reasoning.ParseClaudeModelSuffix("claude-test-thinking", true)
require.NoError(t, err)
require.True(t, found)
meta := &convmeta.Values{
ReasoningConversion: reasoning.StateFromIntent(intent),
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 TestOpenAIChatRequestToClaudeMessagesOmitsEmptyTools(t *testing.T) {
maxTokens := uint(16)
tests := []struct {
name string
request dto.GeneralOpenAIRequest
wantTools bool
}{
{
name: "omitted tools",
request: dto.GeneralOpenAIRequest{
Model: "claude-test",
MaxTokens: &maxTokens,
Messages: []dto.Message{{Role: "user", Content: "hi"}},
},
},
{
name: "explicit empty tools",
request: dto.GeneralOpenAIRequest{
Model: "claude-test",
MaxTokens: &maxTokens,
Messages: []dto.Message{{Role: "user", Content: "hi"}},
Tools: []dto.ToolCallRequest{},
},
},
{
name: "function tool",
request: dto.GeneralOpenAIRequest{
Model: "claude-test",
MaxTokens: &maxTokens,
Messages: []dto.Message{{Role: "user", Content: "hi"}},
Tools: []dto.ToolCallRequest{{
Type: "function",
Function: dto.FunctionRequest{
Name: "get_weather",
Description: "Get weather by city",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{"city": map[string]any{"type": "string"}},
"required": []any{"city"},
},
},
}},
},
wantTools: true,
},
{
name: "web search only",
request: dto.GeneralOpenAIRequest{
Model: "claude-test",
MaxTokens: &maxTokens,
Messages: []dto.Message{{Role: "user", Content: "hi"}},
WebSearchOptions: &dto.WebSearchOptions{SearchContextSize: "low"},
},
wantTools: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := OpenAIChatRequestToClaudeMessages(context.Background(), &convmeta.Values{}, test.request)
require.NoError(t, err)
body, err := kitutil.Marshal(got)
require.NoError(t, err)
if test.wantTools {
assert.NotNil(t, got.Tools)
assert.Contains(t, string(body), `"tools":`)
return
}
assert.Nil(t, got.Tools)
assert.NotContains(t, string(body), `"tools":`)
})
}
}
func claudeDefaultsMeta(defaultMaxTokens func(string) int) convmeta.Meta {
return &convmeta.Values{Options: &convmeta.Options{
Claude: convmeta.ClaudeOptions{DefaultMaxTokens: defaultMaxTokens},
}}
}
+31
View File
@@ -0,0 +1,31 @@
package convmeta
import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
)
// GuessRelayFormatFromRequest infers the relay format from a request DTO's
// concrete type. Moved from relay/common (which keeps a delegating alias).
func GuessRelayFormatFromRequest(req any) (types.RelayFormat, bool) {
switch req.(type) {
case *dto.GeneralOpenAIRequest, dto.GeneralOpenAIRequest:
return types.RelayFormatOpenAI, true
case *dto.OpenAIResponsesRequest, dto.OpenAIResponsesRequest:
return types.RelayFormatOpenAIResponses, true
case *dto.ClaudeRequest, dto.ClaudeRequest:
return types.RelayFormatClaude, true
case *dto.GeminiChatRequest, dto.GeminiChatRequest:
return types.RelayFormatGemini, true
case *dto.EmbeddingRequest, dto.EmbeddingRequest:
return types.RelayFormatEmbedding, true
case *dto.RerankRequest, dto.RerankRequest:
return types.RelayFormatRerank, true
case *dto.ImageRequest, dto.ImageRequest:
return types.RelayFormatOpenAIImage, true
case *dto.AudioRequest, dto.AudioRequest:
return types.RelayFormatOpenAIAudio, true
default:
return "", false
}
}
+249
View File
@@ -0,0 +1,249 @@
// 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)
// ReasoningState returns the suffix-derived reasoning intent attached at
// the host entry layer. Standalone callers that do not set it receive nil;
// converters then use only explicit request fields.
ReasoningState() *dto.ReasoningConversionState
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
ToolCalls []*ClaudeStreamToolCall
ToolCallByIndex map[int]*ClaudeStreamToolCall
ToolCallByID map[string]*ClaudeStreamToolCall
}
// ClaudeStreamToolCall tracks one OpenAI tool_calls entry while it is encoded
// as a Claude tool_use content block. Chat tool indexes and Claude content
// block indexes are separate domains, so the mapping must remain explicit.
type ClaudeStreamToolCall struct {
BlockIndex int
ID string
Name string
PendingArguments string
Started bool
}
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
ReasoningConversion *dto.ReasoningConversionState
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) ReasoningState() *dto.ReasoningConversionState {
if v == nil {
return nil
}
return v.ReasoningConversion
}
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()
}
// ReasoningStateOf is a nil-safe reader for Meta.ReasoningState.
func ReasoningStateOf(m Meta) *dto.ReasoningConversionState {
if m == nil {
return nil
}
return m.ReasoningState()
}
@@ -0,0 +1,39 @@
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.Nil(t, meta.ReasoningState())
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))
}
+109
View File
@@ -0,0 +1,109 @@
package convmeta
import "github.com/QuantumNous/new-api/relaykit/types"
// 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
// ToolLossPolicy controls whether a cross-protocol conversion may omit or
// approximate built-in-tool semantics. The zero value uses the allow
// policy: conversion succeeds and every loss is returned as a diagnostic.
// safe/strict rejection is request-phase opt-in only; response and stream
// conversion never reject regardless of this field.
ToolLossPolicy types.ConversionLossPolicy
// 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
// PreserveEffortTail reports real model IDs whose names already end in an
// effort-like token (for example qwen-max). Nil means "never preserve".
PreserveEffortTail func(modelName string) bool
}
type ClaudeOptions struct {
// ThinkingAdapterEnabled controls whether suffix-derived reasoning intent
// is rendered onto Claude thinking / output_config. Suffix parsing itself
// is the host entry layer's job (standalone users call Parse* themselves).
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
// WebSearchToolVersion selects the Claude hosted web-search tool version
// emitted by cross-protocol conversion. Empty keeps the compatibility
// baseline web_search_20250305.
WebSearchToolVersion string
}
type GeminiOptions struct {
// ThinkingAdapterEnabled controls whether suffix-derived reasoning intent
// is rendered onto Gemini thinkingConfig. Suffix parsing itself is the
// host entry layer's job (standalone users call Parse* themselves).
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)
}
func (o *Options) ShouldPreserveEffortTail(modelName string) bool {
return o != nil && o.PreserveEffortTail != nil && o.PreserveEffortTail(modelName)
}
func (o *Options) EffectiveToolLossPolicy() types.ConversionLossPolicy {
if o == nil || o.ToolLossPolicy == "" {
return types.ConversionLossPolicyAllow
}
return o.ToolLossPolicy
}
+464
View File
@@ -0,0 +1,464 @@
package relayconvert
// golden_test.go pins the byte-level output of selected public conversion
// routes. 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: cd relaykit && GOWORK=off go test ./relayconvert -run TestGolden -update")
require.Equal(t, string(want), string(got), "conversion output drifted from golden snapshot %s", path)
}
func checkStreamEventsGolden(t *testing.T, name string, events []any) {
t.Helper()
got := marshalGolden(t, map[string]any{"events": events})
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
}
wantData, err := os.ReadFile(path)
require.NoError(t, err, "golden file missing, run: cd relaykit && GOWORK=off go test ./relayconvert -run TestGolden -update")
var wantSnapshot map[string]json.RawMessage
require.NoError(t, json.Unmarshal(wantData, &wantSnapshot))
wantEvents, ok := wantSnapshot["events"]
require.True(t, ok, "stream golden snapshot %s has no events", path)
want := marshalGolden(t, map[string]json.RawMessage{"events": wantEvents})
require.Equal(t, string(want), string(got), "conversion events 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)
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.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()
fromFormats := []types.RelayFormat{
types.RelayFormatOpenAI,
types.RelayFormatOpenAIResponses,
}
toFormats := []types.RelayFormat{
types.RelayFormatOpenAI,
types.RelayFormatClaude,
types.RelayFormatOpenAIResponses,
}
for _, from := range fromFormats {
for _, to := range toFormats {
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 || to == types.RelayFormatGemini ||
(from == types.RelayFormatOpenAI && to == types.RelayFormatClaude) {
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)
}
// Billing usage has private, cross-module acceptance coverage. Keep
// the public golden focused on client-visible stream events.
checkStreamEventsGolden(t, name, outputs)
})
}
}
}
// ---------------------------------------------------------------------------
// 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,58 @@
package claudemessages
import (
"encoding/json"
"fmt"
"strings"
"unicode/utf8"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
func claudeCitationsToChat(raw json.RawMessage, text string, textOffset int) ([]any, error) {
if len(raw) == 0 {
return nil, nil
}
var citations []map[string]any
if err := kitutil.Unmarshal(raw, &citations); err != nil {
return nil, fmt.Errorf("invalid Claude citations: %w", err)
}
annotations := make([]any, 0, len(citations))
for _, citation := range citations {
url := strings.TrimSpace(kitutil.Interface2String(citation["url"]))
if url == "" {
continue
}
converted := map[string]any{
"url": url,
"title": strings.TrimSpace(kitutil.Interface2String(citation["title"])),
}
citedText := kitutil.Interface2String(citation["cited_text"])
if citedText != "" {
converted["cited_text"] = citedText
if before, _, ok := strings.Cut(text, citedText); ok {
startIndex := textOffset + utf8.RuneCountInString(before)
converted["start_index"] = startIndex
converted["end_index"] = startIndex + utf8.RuneCountInString(citedText)
}
}
if encryptedIndex := kitutil.Interface2String(citation["encrypted_index"]); encryptedIndex != "" {
converted["encrypted_index"] = encryptedIndex
}
if converted["title"] == "" {
delete(converted, "title")
}
annotations = append(annotations, map[string]any{
"type": "url_citation",
"url_citation": converted,
})
}
return annotations, nil
}
func marshalChatAnnotations(annotations []any) (json.RawMessage, error) {
if len(annotations) == 0 {
return nil, nil
}
return kitutil.Marshal(annotations)
}
@@ -0,0 +1,66 @@
package claudemessages
import (
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMessageStartZeroOutputSidecarRemainsRefreshable(t *testing.T) {
t.Parallel()
info := &ClaudeResponseInfo{Usage: &dto.Usage{}}
ok := FormatClaudeResponseInfo(&dto.ClaudeResponse{
Type: "message_start",
Message: &dto.ClaudeMediaMessage{
Id: "msg_1",
Model: "claude-test",
Usage: &dto.ClaudeUsage{
InputTokens: 10,
OutputTokens: 0,
BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{
InputTokens: 10,
OutputTokens: 0,
}),
},
},
}, nil, info)
require.True(t, ok)
ok = FormatClaudeResponseInfo(&dto.ClaudeResponse{
Type: "message_delta",
Usage: &dto.ClaudeUsage{
OutputTokens: 42,
},
}, nil, info)
require.True(t, ok)
require.NotNil(t, info.Usage.BillingUsage)
require.NotNil(t, info.Usage.BillingUsage.ClaudeUsage)
assert.Equal(t, 42, info.Usage.BillingUsage.ClaudeUsage.OutputTokens)
}
func TestTerminalSidecarRemainsAuthoritativeAgainstFinalize(t *testing.T) {
t.Parallel()
info := &ClaudeResponseInfo{Usage: &dto.Usage{}}
ok := FormatClaudeResponseInfo(&dto.ClaudeResponse{
Type: "message_delta",
Usage: &dto.ClaudeUsage{
InputTokens: 10,
OutputTokens: 7,
BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{
InputTokens: 10,
OutputTokens: 7,
}),
},
}, nil, info)
require.True(t, ok)
require.NotNil(t, info.Usage.BillingUsage)
require.NotNil(t, info.Usage.BillingUsage.ClaudeUsage)
info.Usage.CompletionTokens = 99
FinalizeClaudeStreamBillingUsage(info)
assert.Equal(t, 7, info.Usage.BillingUsage.ClaudeUsage.OutputTokens)
}
@@ -0,0 +1,239 @@
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"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
const (
webSearchMaxUsesLow = 1
webSearchMaxUsesMedium = 5
webSearchMaxUsesHigh = 10
)
type openRouterRequestReasoning struct {
Enabled *bool `json:"enabled,omitempty"`
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)
}
reasoningIntent, effectiveEffort, err := claudeRequestReasoningIntent(&claudeRequest, info)
if err != nil {
return nil, reasoning.AsClientError(err)
}
isOpenRouter := convmeta.OptionsOf(info).OpenRouterDialect
if isOpenRouter {
if effort := claudeRequest.GetEfforts(); effort != "" {
effortBytes, _ := kitutil.Marshal(effort)
openAIRequest.Verbosity = effortBytes
}
if !reasoningIntent.IsEmpty() {
var reasoningConfig openRouterRequestReasoning
disabled := reasoningIntent.Mode == reasoning.ModeDisabled || reasoningIntent.Effort == reasoning.EffortNone
enabled := !disabled
reasoningConfig.Enabled = &enabled
if enabled && reasoningIntent.BudgetTokens != nil && reasoningIntent.Mode != reasoning.ModeAdaptive {
reasoningConfig = openRouterRequestReasoning{
Enabled: &enabled,
MaxTokens: *reasoningIntent.BudgetTokens,
}
} else if enabled {
reasoningConfig.Effort = string(reasoning.EffectiveEffort(reasoningIntent))
}
if reasoningIntent.IncludeThoughts != nil {
reasoningConfig.Exclude = !*reasoningIntent.IncludeThoughts
}
reasoningJSON, err := kitutil.Marshal(reasoningConfig)
if err != nil {
return nil, fmt.Errorf("failed to marshal reasoning: %w", err)
}
openAIRequest.Reasoning = reasoningJSON
}
} else {
if err := reasoning.ApplyToOpenAIChat(&openAIRequest, reasoningIntent); err != nil {
return nil, reasoning.AsClientError(err)
}
if info != nil {
// Keep the outgoing -thinking suffix so a cascaded downstream
// new-api can recover reasoning intent from the model name. This
// is an emission-side policy, not converter-side suffix parsing.
thinkingSuffix := "-thinking"
if strings.HasSuffix(info.GetOriginModelName(), thinkingSuffix) &&
!strings.HasSuffix(openAIRequest.Model, thinkingSuffix) {
openAIRequest.Model = openAIRequest.Model + thinkingSuffix
}
}
}
if info != nil && effectiveEffort != "" {
info.SetReasoningEffort(string(effectiveEffort))
}
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 {
var systemStr strings.Builder
for _, system := range systems {
if system.Text != nil {
systemStr.WriteString(*system.Text)
}
}
openAIMessage.SetStringContent(systemStr.String())
}
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 any) string {
b, err := kitutil.Marshal(v)
if err != nil {
return "{}"
}
return string(b)
}
@@ -0,0 +1,564 @@
package claudemessages
import (
"encoding/json"
"fmt"
"strings"
"unicode/utf8"
"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
// Only snapshots synthesized from partial display usage may be refreshed by
// later display deltas. Serialized BillingUsage always remains authoritative.
billingUsageSynthesized 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)
annotations, err := claudeCitationsToChat(claudeResponse.ContentBlock.Citations, *claudeResponse.ContentBlock.Text, 0)
if err == nil {
choice.Delta.Annotations, _ = marshalChatAnnotations(annotations)
}
}
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
case "citations_delta":
if len(claudeResponse.Delta.Citation) > 0 {
raw, _ := kitutil.Marshal([]json.RawMessage{claudeResponse.Delta.Citation})
annotations, err := claudeCitationsToChat(raw, "", 0)
if err == nil {
choice.Delta.Annotations, _ = marshalChatAnnotations(annotations)
}
}
}
}
} 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
}
// ClaudeToChatStreamState translates Anthropic content block indexes into the
// independent, dense index space used by Chat Completions tool_calls. Text and
// thinking blocks therefore do not create holes in the downstream tool array.
type ClaudeToChatStreamState struct {
toolIndexByContentBlock map[int]int
blockTypeByContentBlock map[int]string
nextToolIndex int
}
func NewClaudeToChatStreamState() *ClaudeToChatStreamState {
return &ClaudeToChatStreamState{
toolIndexByContentBlock: make(map[int]int),
blockTypeByContentBlock: make(map[int]string),
}
}
func (s *ClaudeToChatStreamState) ConvertChunk(claudeResponse *dto.ClaudeResponse) (*dto.ChatCompletionsStreamResponse, error) {
if s == nil {
return nil, fmt.Errorf("Claude-to-Chat stream state is required")
}
if claudeResponse == nil {
return nil, nil
}
if s.toolIndexByContentBlock == nil {
s.toolIndexByContentBlock = make(map[int]int)
}
if s.blockTypeByContentBlock == nil {
s.blockTypeByContentBlock = make(map[int]string)
}
converted := *claudeResponse
switch claudeResponse.Type {
case "content_block_start":
if claudeResponse.ContentBlock == nil {
break
}
blockType := strings.TrimSpace(claudeResponse.ContentBlock.Type)
if blockType == "" {
break
}
if claudeResponse.Index == nil {
return nil, fmt.Errorf("Claude content block stream start is missing index")
}
contentBlockIndex := *claudeResponse.Index
s.blockTypeByContentBlock[contentBlockIndex] = blockType
if blockType != "tool_use" {
if isClaudeHostedToolStreamBlock(blockType) {
return nil, nil
}
break
}
toolIndex, exists := s.toolIndexByContentBlock[contentBlockIndex]
if !exists {
toolIndex = s.nextToolIndex
s.nextToolIndex++
s.toolIndexByContentBlock[contentBlockIndex] = toolIndex
}
converted.Index = kitutil.GetPointer(toolIndex)
case "content_block_delta":
if claudeResponse.Delta == nil || claudeResponse.Delta.Type != "input_json_delta" {
break
}
if claudeResponse.Index == nil {
return nil, fmt.Errorf("Claude tool-use stream delta is missing content block index")
}
if claudeResponse.Delta.PartialJson == nil {
return nil, fmt.Errorf("Claude tool-use stream delta is missing partial JSON")
}
contentBlockIndex := *claudeResponse.Index
toolIndex, exists := s.toolIndexByContentBlock[contentBlockIndex]
if !exists {
if isClaudeHostedToolStreamBlock(s.blockTypeByContentBlock[contentBlockIndex]) {
return nil, nil
}
return nil, fmt.Errorf("Claude tool-use stream delta references unknown content block index %d", contentBlockIndex)
}
converted.Index = kitutil.GetPointer(toolIndex)
case "content_block_stop":
if claudeResponse.Index != nil {
delete(s.blockTypeByContentBlock, *claudeResponse.Index)
}
}
return StreamResponseClaude2OpenAI(&converted), nil
}
func isClaudeHostedToolStreamBlock(blockType string) bool {
switch blockType {
case "server_tool_use", "mcp_tool_use", "web_search_tool_result", "mcp_tool_result", "code_execution_tool_result", "web_fetch_tool_result":
return true
default:
return false
}
}
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 strings.Builder
responseTextOffset := 0
var responseThinking string
if len(claudeResponse.Content) > 0 {
if claudeResponse.Content[0].Thinking != nil {
responseThinking = *claudeResponse.Content[0].Thinking
}
}
tools := make([]dto.ToolCallResponse, 0)
thinkingContent := ""
annotations := make([]any, 0)
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":
text := message.GetText()
offset := responseTextOffset
responseText.WriteString(text)
responseTextOffset += utf8.RuneCountInString(text)
converted, err := claudeCitationsToChat(message.Citations, text, offset)
if err == nil {
annotations = append(annotations, converted...)
}
}
}
choice := dto.OpenAITextResponseChoice{
Index: 0,
Message: dto.Message{
Role: "assistant",
},
FinishReason: StopReasonClaudeToOpenAI(claudeResponse.StopReason),
}
choice.SetStringContent(responseText.String())
if encodedAnnotations, err := marshalChatAnnotations(annotations); err == nil && len(encodedAnnotations) > 0 {
choice.Message.Annotations = encodedAnnotations
}
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 updateClaudeStreamBillingUsage(claudeUsage *dto.ClaudeUsage, claudeInfo *ClaudeResponseInfo, terminal bool) {
if claudeUsage == nil || claudeInfo == nil || claudeInfo.Usage == nil {
return
}
if billingUsage := dto.CloneBillingUsage(claudeUsage.BillingUsage); billingUsage != nil {
claudeInfo.Usage.BillingUsage = billingUsage
if terminal || claudeUsage.OutputTokens > 0 {
claudeInfo.billingUsageSynthesized = false
return
}
claudeInfo.billingUsageSynthesized = true
return
}
if claudeInfo.Usage.BillingUsage != nil && !claudeInfo.billingUsageSynthesized {
return
}
claudeInfo.Usage.BillingUsage = claudeBillingUsageFromSemanticUsage(claudeInfo.Usage)
claudeInfo.billingUsageSynthesized = claudeInfo.Usage.BillingUsage != nil
}
// FinalizeClaudeStreamBillingUsage refreshes only a locally synthesized
// snapshot after the host has applied its missing-usage fallback. A snapshot
// received on the wire remains authoritative and is never rewritten.
func FinalizeClaudeStreamBillingUsage(claudeInfo *ClaudeResponseInfo) {
if claudeInfo == nil || claudeInfo.Usage == nil {
return
}
if claudeInfo.Usage.BillingUsage != nil && !claudeInfo.billingUsageSynthesized {
return
}
billingUsage := claudeBillingUsageFromSemanticUsage(claudeInfo.Usage)
if billingUsage != nil && !claudeInfo.Done {
billingUsage.Estimated = true
}
claudeInfo.Usage.BillingUsage = billingUsage
claudeInfo.billingUsageSynthesized = billingUsage != nil
}
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 {
messageUsage := claudeResponse.Message.Usage
claudeInfo.Usage.PromptTokens = messageUsage.InputTokens
claudeInfo.Usage.UsageSemantic = "anthropic"
claudeInfo.Usage.PromptTokensDetails.CachedTokens = messageUsage.CacheReadInputTokens
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = messageUsage.CacheCreationInputTokens
claudeInfo.Usage.ClaudeCacheCreation5mTokens = messageUsage.GetCacheCreation5mTokens()
claudeInfo.Usage.ClaudeCacheCreation1hTokens = messageUsage.GetCacheCreation1hTokens()
claudeInfo.Usage.CompletionTokens = messageUsage.OutputTokens
updateClaudeStreamBillingUsage(messageUsage, claudeInfo, false)
}
} 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
updateClaudeStreamBillingUsage(claudeResponse.Usage, claudeInfo, true)
}
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,194 @@
package claudemessages
import (
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
oaichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/oai_chat"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
// ClaudeHostedStreamBridge keeps Anthropic server-executed tool blocks out of
// the Chat Completions pivot. Anthropic streams a hosted call's input after the
// content_block_start event, so the bridge owns those input_json_delta events
// until content_block_stop and only then starts the Responses output item.
type ClaudeHostedStreamBridge struct {
pending map[int]*claudeHostedStreamCall
}
type claudeHostedStreamCall struct {
blockType string
id string
name string
serverName string
caller []byte
startInput []byte
input strings.Builder
}
func NewClaudeHostedStreamBridge() *ClaudeHostedStreamBridge {
return &ClaudeHostedStreamBridge{pending: make(map[int]*claudeHostedStreamCall)}
}
// Convert consumes provider-hosted stream frames and reports whether the frame
// must be skipped by the ordinary Claude-to-Chat converter.
func (b *ClaudeHostedStreamBridge) Convert(response *dto.ClaudeResponse, state *oaichat.ChatToResponsesStreamState) ([]oaichat.ChatToResponsesStreamEvent, bool, error) {
if response == nil || state == nil {
return nil, false, nil
}
if b == nil {
return nil, false, fmt.Errorf("Claude hosted stream bridge is required")
}
if b.pending == nil {
b.pending = make(map[int]*claudeHostedStreamCall)
}
index := response.GetIndex()
switch response.Type {
case "content_block_start":
if response.ContentBlock == nil {
return nil, false, nil
}
block := response.ContentBlock
blockType := strings.TrimSpace(block.Type)
switch blockType {
case "server_tool_use", "mcp_tool_use":
if _, exists := b.pending[index]; exists {
return nil, true, fmt.Errorf("duplicate Claude hosted-tool content block index %d", index)
}
if blockType == "mcp_tool_use" && (strings.TrimSpace(block.Name) == "" || strings.TrimSpace(block.ServerName) == "") {
return nil, true, fmt.Errorf("Claude MCP tool use must include name and server_name")
}
if _, err := claudeHostedCallOutputType(blockType, block.Name); err != nil {
return nil, true, err
}
pending := &claudeHostedStreamCall{
blockType: blockType,
id: block.Id,
name: block.Name,
serverName: block.ServerName,
caller: append([]byte(nil), block.Caller...),
}
// Non-stream-shaped gateways occasionally include the complete input
// on the start frame. Preserve it as a fallback, while streamed deltas
// replace the placeholder at block completion.
if block.Input != nil {
input, err := kitutil.Marshal(block.Input)
if err != nil {
return nil, true, fmt.Errorf("marshal Claude hosted-tool input: %w", err)
}
if string(input) != "{}" && string(input) != "null" {
pending.startInput = input
}
}
b.pending[index] = pending
return nil, true, nil
case "web_search_tool_result", "mcp_tool_result":
outputType, err := claudeHostedResultOutputType(blockType)
if err != nil {
return nil, true, err
}
var result []byte
if outputType != "web_search_call" {
result, err = kitutil.Marshal(block.Content)
if err != nil {
return nil, true, fmt.Errorf("marshal Claude hosted-tool result: %w", err)
}
}
events, err := state.CompleteHostedTool(oaichat.HostedToolStreamResult{
Type: outputType,
ID: block.ToolUseId,
Result: result,
ErrorCode: claudeHostedResultErrorCode(block.Content, block.ErrorCode),
IsError: block.IsError != nil && *block.IsError,
})
return events, true, err
default:
return nil, false, nil
}
case "content_block_delta":
pending := b.pending[index]
if pending == nil {
return nil, false, nil
}
if response.Delta != nil && response.Delta.Type == "input_json_delta" && response.Delta.PartialJson != nil {
pending.input.WriteString(*response.Delta.PartialJson)
}
return nil, true, nil
case "content_block_stop":
pending := b.pending[index]
if pending == nil {
return nil, false, nil
}
delete(b.pending, index)
action := []byte(pending.input.String())
if len(action) == 0 {
action = pending.startInput
}
if len(action) == 0 {
action = []byte("{}")
}
outputType, err := claudeHostedCallOutputType(pending.blockType, pending.name)
if err != nil {
return nil, true, err
}
events, err := state.StartHostedTool(oaichat.HostedToolStreamStart{
Type: outputType,
ID: pending.id,
Name: pending.name,
Action: action,
Caller: pending.caller,
ServerLabel: pending.serverName,
})
return events, true, err
default:
return nil, false, nil
}
}
func claudeHostedCallOutputType(blockType string, name string) (string, error) {
if blockType == "mcp_tool_use" {
return "mcp_call", nil
}
switch strings.TrimSpace(name) {
case "web_search":
return "web_search_call", nil
case "code_execution":
return "", fmt.Errorf("Claude code_execution has no valid OpenAI Responses mapping without a container_id")
case "web_fetch":
return "", fmt.Errorf("Claude web_fetch has no valid OpenAI Responses hosted-tool mapping")
default:
return "", fmt.Errorf("unknown Claude server tool %q cannot be represented as an OpenAI Responses hosted tool", name)
}
}
func claudeHostedResultOutputType(blockType string) (string, error) {
switch blockType {
case "web_search_tool_result":
return "web_search_call", nil
case "mcp_tool_result":
return "mcp_call", nil
case "code_execution_tool_result":
return "", fmt.Errorf("Claude code_execution result has no valid OpenAI Responses mapping without a container_id")
case "web_fetch_tool_result":
return "", fmt.Errorf("Claude web_fetch result has no valid OpenAI Responses hosted-tool mapping")
default:
return "", fmt.Errorf("unknown Claude hosted-tool result %q", blockType)
}
}
func claudeHostedResultErrorCode(content any, fallback string) string {
if strings.TrimSpace(fallback) != "" {
return strings.TrimSpace(fallback)
}
value, ok := content.(map[string]any)
if !ok {
return ""
}
contentType := strings.TrimSpace(kitutil.Interface2String(value["type"]))
if !strings.HasSuffix(contentType, "_error") {
return ""
}
return strings.TrimSpace(kitutil.Interface2String(value["error_code"]))
}
@@ -0,0 +1,326 @@
package claudemessages
import (
"encoding/json"
"errors"
"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"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
func ClaudeMessagesRequestToOpenAIResponses(claudeRequest dto.ClaudeRequest, info convmeta.Meta) (*dto.OpenAIResponsesRequest, error) {
if strings.TrimSpace(claudeRequest.Model) == "" {
return nil, errors.New("model is required")
}
input, err := claudeMessagesToResponsesInput(claudeRequest.Messages)
if err != nil {
return nil, err
}
instructions, err := claudeSystemToResponsesInstructions(&claudeRequest)
if err != nil {
return nil, err
}
tools, err := claudeToolsToResponsesTools(claudeRequest.Tools)
if err != nil {
return nil, err
}
toolChoice, parallelToolCalls, err := claudeToolChoiceToResponses(claudeRequest.ToolChoice)
if err != nil {
return nil, err
}
// Claude context_management is an object containing protocol-specific edit
// strategies. Responses expects an array of compaction entries, so copying
// the raw Claude value would produce an invalid upstream request.
responsesRequest := &dto.OpenAIResponsesRequest{
Model: claudeRequest.Model,
Input: input,
Instructions: instructions,
Metadata: append(json.RawMessage(nil), claudeRequest.Metadata...),
ServiceTier: claudeRequest.ServiceTier,
Stream: claudeRequest.Stream,
Temperature: claudeRequest.Temperature,
Tools: tools,
ToolChoice: toolChoice,
ParallelToolCalls: parallelToolCalls,
TopP: claudeRequest.TopP,
}
if info != nil && !convmeta.OptionsOf(info).OpenRouterDialect {
// Keep the outgoing -thinking suffix so a cascaded downstream new-api
// can recover reasoning intent from the model name. This is an
// emission-side policy, not converter-side suffix parsing.
thinkingSuffix := "-thinking"
if strings.HasSuffix(info.GetOriginModelName(), thinkingSuffix) && !strings.HasSuffix(responsesRequest.Model, thinkingSuffix) {
responsesRequest.Model += thinkingSuffix
}
}
if claudeRequest.MaxTokens != nil {
maxOutputTokens := *claudeRequest.MaxTokens
responsesRequest.MaxOutputTokens = &maxOutputTokens
} else if claudeRequest.MaxTokensToSample != nil {
maxOutputTokens := *claudeRequest.MaxTokensToSample
responsesRequest.MaxOutputTokens = &maxOutputTokens
}
reasoningIntent, effectiveEffort, err := claudeRequestReasoningIntent(&claudeRequest, info)
if err != nil {
return nil, reasoning.AsClientError(err)
}
if err := reasoning.ApplyToOpenAIResponses(responsesRequest, reasoningIntent); err != nil {
return nil, reasoning.AsClientError(err)
}
if info != nil && effectiveEffort != "" {
info.SetReasoningEffort(string(effectiveEffort))
}
return responsesRequest, nil
}
func claudeRequestReasoningIntent(claudeRequest *dto.ClaudeRequest, info convmeta.Meta) (reasoning.Intent, reasoning.Effort, error) {
reasoningIntent, err := reasoning.FromClaude(claudeRequest)
if err != nil {
return reasoning.Intent{}, "", err
}
sourceModel := claudeRequest.Model
if info != nil && info.GetOriginModelName() != "" {
sourceModel = info.GetOriginModelName()
}
if suffix := reasoning.IntentFromState(convmeta.ReasoningStateOf(info)); !suffix.IsEmpty() {
reasoningIntent, err = reasoning.MergeExplicitAndSuffix(reasoningIntent, suffix, sourceModel)
if err != nil {
return reasoning.Intent{}, "", err
}
}
reasoningIntent = reasoning.ResolveClaudeDefault(sourceModel, reasoningIntent)
return reasoningIntent, reasoning.EffectiveEffort(reasoningIntent), nil
}
func claudeSystemToResponsesInstructions(request *dto.ClaudeRequest) (json.RawMessage, error) {
if request == nil || request.System == nil {
return nil, nil
}
if request.IsStringSystem() {
return kitutil.Marshal(request.GetStringSystem())
}
var instructions strings.Builder
systemBlocks, err := kitutil.Any2Type[[]dto.ClaudeMediaMessage](request.System)
if err != nil {
return nil, fmt.Errorf("invalid Claude system content: %w", err)
}
for _, block := range systemBlocks {
if block.Type == "text" || block.Type == "input_text" || block.Type == "" {
instructions.WriteString(block.GetText())
}
}
if instructions.Len() == 0 {
return nil, nil
}
return kitutil.Marshal(instructions.String())
}
func claudeMessagesToResponsesInput(messages []dto.ClaudeMessage) (json.RawMessage, error) {
input := make([]map[string]any, 0, len(messages))
for messageIndex := range messages {
message := messages[messageIndex]
role := strings.TrimSpace(message.Role)
if role == "" {
continue
}
if message.IsStringContent() {
input = append(input, map[string]any{
"role": role,
"content": message.GetStringContent(),
})
continue
}
blocks, err := message.ParseContent()
if err != nil {
return nil, fmt.Errorf("messages[%d].content: %w", messageIndex, err)
}
contentParts := make([]map[string]any, 0, len(blocks))
flushContent := func() {
if len(contentParts) == 0 {
return
}
input = append(input, map[string]any{
"role": role,
"content": contentParts,
})
contentParts = nil
}
for blockIndex := range blocks {
block := blocks[blockIndex]
switch block.Type {
case "text", "input_text":
partType := "input_text"
if role == "assistant" {
partType = "output_text"
}
contentParts = append(contentParts, map[string]any{
"type": partType,
"text": block.GetText(),
})
case "image":
if source := claudeSourceURL(block.Source); source != "" {
contentParts = append(contentParts, map[string]any{
"type": "input_image",
"image_url": source,
})
}
case "document":
if source := claudeSourceURL(block.Source); source != "" {
contentParts = append(contentParts, map[string]any{
"type": "input_file",
"file_data": source,
})
}
case "tool_use":
flushContent()
arguments, err := kitutil.Marshal(block.Input)
if err != nil {
return nil, fmt.Errorf("messages[%d].content[%d].input: %w", messageIndex, blockIndex, err)
}
if block.Input == nil {
arguments = []byte("{}")
}
input = append(input, map[string]any{
"type": "function_call",
"call_id": block.Id,
"name": block.Name,
"arguments": string(arguments),
})
case "tool_result":
flushContent()
output, err := claudeToolResultToResponsesOutput(block.Content)
if err != nil {
return nil, fmt.Errorf("messages[%d].content[%d].content: %w", messageIndex, blockIndex, err)
}
input = append(input, map[string]any{
"type": "function_call_output",
"call_id": block.ToolUseId,
"output": output,
})
}
}
flushContent()
}
return kitutil.Marshal(input)
}
func claudeToolsToResponsesTools(value any) (json.RawMessage, error) {
if value == nil {
return nil, nil
}
tools, err := kitutil.Any2Type[[]dto.Tool](value)
if err != nil {
return nil, fmt.Errorf("invalid Claude tools: %w", err)
}
converted := make([]map[string]any, 0, len(tools))
for _, tool := range tools {
function := map[string]any{
"type": "function",
"name": tool.Name,
"description": tool.Description,
"parameters": tool.InputSchema,
}
if tool.Strict != nil {
function["strict"] = *tool.Strict
}
converted = append(converted, function)
}
return kitutil.Marshal(converted)
}
func claudeToolChoiceToResponses(value any) (json.RawMessage, json.RawMessage, error) {
if value == nil {
return nil, nil, nil
}
choice, err := kitutil.Any2Type[dto.ClaudeToolChoice](value)
if err != nil {
return nil, nil, fmt.Errorf("invalid Claude tool_choice: %w", err)
}
var converted any
switch choice.Type {
case "", "auto":
converted = "auto"
case "any":
converted = "required"
case "none":
converted = "none"
case "tool":
converted = map[string]any{"type": "function", "name": choice.Name}
default:
return nil, nil, fmt.Errorf("unsupported Claude tool_choice type %q", choice.Type)
}
toolChoice, err := kitutil.Marshal(converted)
if err != nil {
return nil, nil, err
}
var parallelToolCalls json.RawMessage
if choice.DisableParallelToolUse && choice.Type != "none" {
parallelToolCalls, err = kitutil.Marshal(false)
if err != nil {
return nil, nil, err
}
}
return toolChoice, parallelToolCalls, nil
}
func claudeToolResultToResponsesOutput(content any) (any, error) {
if content == nil {
return "", nil
}
if text, ok := content.(string); ok {
return text, nil
}
blocks, err := kitutil.Any2Type[[]dto.ClaudeMediaMessage](content)
if err != nil {
return content, nil
}
parts := make([]map[string]any, 0, len(blocks))
for _, block := range blocks {
switch block.Type {
case "text", "input_text":
parts = append(parts, map[string]any{"type": "input_text", "text": block.GetText()})
case "image":
if source := claudeSourceURL(block.Source); source != "" {
parts = append(parts, map[string]any{"type": "input_image", "image_url": source})
}
case "document":
if source := claudeSourceURL(block.Source); source != "" {
parts = append(parts, map[string]any{"type": "input_file", "file_data": source})
}
}
}
if len(parts) == 0 {
return content, nil
}
return parts, nil
}
func claudeSourceURL(source *dto.ClaudeMessageSource) string {
if source == nil {
return ""
}
if strings.TrimSpace(source.Url) != "" {
return source.Url
}
data := kitutil.Interface2String(source.Data)
if data == "" {
return ""
}
if strings.HasPrefix(data, "data:") {
return data
}
return fmt.Sprintf("data:%s;base64,%s", source.MediaType, data)
}
@@ -0,0 +1,56 @@
package convdiag
import (
"context"
"reflect"
"github.com/QuantumNous/new-api/relaykit/types"
)
type collectorKey struct{}
type Collector struct {
diagnostics []types.ConversionDiagnostic
}
func WithCollector(ctx context.Context) (context.Context, *Collector) {
if isNilContext(ctx) {
ctx = context.Background()
}
if collector, _ := ctx.Value(collectorKey{}).(*Collector); collector != nil {
return ctx, collector
}
collector := &Collector{}
return context.WithValue(ctx, collectorKey{}, collector), collector
}
func Add(ctx context.Context, diagnostics ...types.ConversionDiagnostic) {
if isNilContext(ctx) || len(diagnostics) == 0 {
return
}
collector, _ := ctx.Value(collectorKey{}).(*Collector)
if collector == nil {
return
}
collector.diagnostics = append(collector.diagnostics, diagnostics...)
}
func isNilContext(ctx context.Context) bool {
if ctx == nil {
return true
}
value := reflect.ValueOf(ctx)
switch value.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice:
return value.IsNil()
default:
return false
}
}
func (c *Collector) Diagnostics() []types.ConversionDiagnostic {
if c == nil || len(c.diagnostics) == 0 {
return nil
}
return append([]types.ConversionDiagnostic(nil), c.diagnostics...)
}
@@ -0,0 +1,367 @@
package geminichat
import (
"fmt"
"strings"
"unicode/utf8"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
type geminiGroundingChunk struct {
Web *geminiGroundingSource `json:"web,omitempty"`
RetrievedContext *geminiGroundingSource `json:"retrievedContext,omitempty"`
}
type geminiGroundingSource struct {
URI string `json:"uri,omitempty"`
Title string `json:"title,omitempty"`
}
type geminiGroundingSupport struct {
Segment struct {
PartIndex *int `json:"partIndex,omitempty"`
StartIndex int `json:"startIndex,omitempty"`
EndIndex int `json:"endIndex,omitempty"`
Text string `json:"text,omitempty"`
} `json:"segment"`
GroundingChunkIndices []int `json:"groundingChunkIndices"`
}
type renderedGeminiPart struct {
text string
startByte int
}
type streamedGeminiPartSpan struct {
partStartByte int
partEndByte int
renderedStartByte int
}
type streamedGeminiPart struct {
text strings.Builder
spans []streamedGeminiPartSpan
}
// geminiGroundingStreamCandidate retains the protocol state needed to resolve
// grounding metadata emitted after the text it describes. Gemini's streaming
// contract makes grounding chunk indexes cumulative across response chunks and
// keeps segment offsets relative to the accumulated candidate part.
type geminiGroundingStreamCandidate struct {
rendered strings.Builder
parts map[int]*streamedGeminiPart
chunks []geminiGroundingChunk
}
// GroundingWebSearchQueries returns the distinct hosted-search queries that
// Gemini reports for a response. The provider may repeat metadata across
// candidates or stream chunks, so callers can safely accumulate this result
// without manufacturing duplicate Responses tool calls.
func GroundingWebSearchQueries(response *dto.GeminiChatResponse) []string {
if response == nil {
return nil
}
queries := make([]string, 0)
seen := make(map[string]struct{})
for candidateIndex := range response.Candidates {
metadata := response.Candidates[candidateIndex].GroundingMetadata
if metadata == nil {
continue
}
for _, query := range metadata.WebSearchQueries {
query = strings.TrimSpace(query)
if query == "" {
continue
}
if _, exists := seen[query]; exists {
continue
}
seen[query] = struct{}{}
queries = append(queries, query)
}
}
return queries
}
func groundingAnnotationsToChat(metadata *dto.GeminiGroundingMetadata, content dto.GeminiChatContent, rendered string) []byte {
if metadata == nil || len(metadata.GroundingChunks) == 0 || len(metadata.GroundingSupports) == 0 {
return nil
}
var chunks []geminiGroundingChunk
if err := kitutil.Unmarshal(metadata.GroundingChunks, &chunks); err != nil {
return nil
}
var supports []geminiGroundingSupport
if err := kitutil.Unmarshal(metadata.GroundingSupports, &supports); err != nil {
return nil
}
parts := locateRenderedGeminiParts(content, rendered)
textPartCount := 0
soleTextPart := -1
for index := range parts {
if parts[index].startByte < 0 {
continue
}
textPartCount++
soleTextPart = index
}
annotations := make([]any, 0)
seen := make(map[string]struct{})
for _, support := range supports {
partIndex := soleTextPart
if support.Segment.PartIndex != nil {
partIndex = *support.Segment.PartIndex
} else if textPartCount != 1 {
continue
}
if partIndex < 0 || partIndex >= len(parts) || parts[partIndex].startByte < 0 {
continue
}
part := parts[partIndex]
start, end, ok := groundingRuneRange(rendered, part, support.Segment.StartIndex, support.Segment.EndIndex)
if !ok {
continue
}
if support.Segment.Text != "" && part.text[support.Segment.StartIndex:support.Segment.EndIndex] != support.Segment.Text {
continue
}
annotations = appendGroundingAnnotations(annotations, chunks, support, start, end, "", seen)
}
return marshalGroundingAnnotations(annotations)
}
func newGeminiGroundingStreamCandidate() *geminiGroundingStreamCandidate {
return &geminiGroundingStreamCandidate{parts: make(map[int]*streamedGeminiPart)}
}
func (s *geminiGroundingStreamCandidate) appendContent(content dto.GeminiChatContent, rendered string) {
if s == nil {
return
}
if s.parts == nil {
s.parts = make(map[int]*streamedGeminiPart)
}
renderedParts := locateRenderedGeminiParts(content, rendered)
renderedBase := s.rendered.Len()
for index := range content.Parts {
partContent := content.Parts[index]
text := partContent.Text
if text == "" || partContent.Thought {
continue
}
part := s.parts[index]
if part == nil {
part = &streamedGeminiPart{}
s.parts[index] = part
}
partStart := part.text.Len()
part.text.WriteString(text)
// A standalone newline is intentionally omitted by the existing Gemini
// renderer. Keep it in the source part so later byte offsets stay correct,
// but do not claim that it has a corresponding rendered span.
if text == "\n" || index >= len(renderedParts) || renderedParts[index].startByte < 0 {
continue
}
renderedStart := renderedBase + renderedParts[index].startByte
part.spans = append(part.spans, streamedGeminiPartSpan{
partStartByte: partStart,
partEndByte: partStart + len(text),
renderedStartByte: renderedStart,
})
}
s.rendered.WriteString(rendered)
}
func (s *geminiGroundingStreamCandidate) appendGroundingChunks(metadata *dto.GeminiGroundingMetadata) {
if s == nil || metadata == nil || len(metadata.GroundingChunks) == 0 {
return
}
var chunks []geminiGroundingChunk
if err := kitutil.Unmarshal(metadata.GroundingChunks, &chunks); err != nil {
return
}
s.chunks = append(s.chunks, chunks...)
}
func (s *geminiGroundingStreamCandidate) groundingAnnotations(
metadata *dto.GeminiGroundingMetadata,
candidateIndex int64,
seen map[string]struct{},
) []byte {
if s == nil || metadata == nil {
return nil
}
s.appendGroundingChunks(metadata)
if len(s.chunks) == 0 || len(metadata.GroundingSupports) == 0 {
return nil
}
var supports []geminiGroundingSupport
if err := kitutil.Unmarshal(metadata.GroundingSupports, &supports); err != nil {
return nil
}
annotations := make([]any, 0)
keyPrefix := fmt.Sprintf("%d:", candidateIndex)
for _, support := range supports {
partIndex, ok := s.groundingPartIndex(support)
if !ok {
continue
}
start, end, ok := s.groundingRuneRange(partIndex, support.Segment.StartIndex, support.Segment.EndIndex)
if !ok {
continue
}
part := s.parts[partIndex]
if support.Segment.Text != "" && part.text.String()[support.Segment.StartIndex:support.Segment.EndIndex] != support.Segment.Text {
continue
}
annotations = appendGroundingAnnotations(annotations, s.chunks, support, start, end, keyPrefix, seen)
}
return marshalGroundingAnnotations(annotations)
}
func (s *geminiGroundingStreamCandidate) groundingPartIndex(support geminiGroundingSupport) (int, bool) {
if support.Segment.PartIndex != nil {
partIndex := *support.Segment.PartIndex
part := s.parts[partIndex]
return partIndex, part != nil && len(part.spans) > 0
}
solePartIndex := -1
for partIndex, part := range s.parts {
if part == nil || len(part.spans) == 0 {
continue
}
if solePartIndex >= 0 {
return 0, false
}
solePartIndex = partIndex
}
return solePartIndex, solePartIndex >= 0
}
func (s *geminiGroundingStreamCandidate) groundingRuneRange(partIndex int, startByte int, endByte int) (int, int, bool) {
if s == nil {
return 0, 0, false
}
part := s.parts[partIndex]
if part == nil {
return 0, 0, false
}
partText := part.text.String()
if startByte < 0 || endByte <= startByte || endByte > len(partText) {
return 0, 0, false
}
if !utf8.ValidString(partText[:startByte]) || !utf8.ValidString(partText[:endByte]) {
return 0, 0, false
}
renderedStart, renderedEnd := -1, -1
for _, span := range part.spans {
if renderedStart < 0 && startByte >= span.partStartByte && startByte < span.partEndByte {
renderedStart = span.renderedStartByte + startByte - span.partStartByte
}
if endByte > span.partStartByte && endByte <= span.partEndByte {
renderedEnd = span.renderedStartByte + endByte - span.partStartByte
}
}
if renderedStart < 0 || renderedEnd <= renderedStart {
return 0, 0, false
}
rendered := s.rendered.String()
if renderedEnd > len(rendered) || rendered[renderedStart:renderedEnd] != partText[startByte:endByte] {
return 0, 0, false
}
if !utf8.ValidString(rendered[:renderedStart]) || !utf8.ValidString(rendered[:renderedEnd]) {
return 0, 0, false
}
return utf8.RuneCountInString(rendered[:renderedStart]), utf8.RuneCountInString(rendered[:renderedEnd]), true
}
func appendGroundingAnnotations(
annotations []any,
chunks []geminiGroundingChunk,
support geminiGroundingSupport,
start int,
end int,
keyPrefix string,
seen map[string]struct{},
) []any {
for _, chunkIndex := range support.GroundingChunkIndices {
if chunkIndex < 0 || chunkIndex >= len(chunks) {
continue
}
source := chunks[chunkIndex].Web
if source == nil {
source = chunks[chunkIndex].RetrievedContext
}
if source == nil || source.URI == "" {
continue
}
key := fmt.Sprintf("%s%d:%d:%s", keyPrefix, start, end, source.URI)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
annotations = append(annotations, map[string]any{
"type": "url_citation",
"url_citation": map[string]any{
"start_index": start,
"end_index": end,
"url": source.URI,
"title": source.Title,
},
})
}
return annotations
}
func marshalGroundingAnnotations(annotations []any) []byte {
if len(annotations) == 0 {
return nil
}
encoded, err := kitutil.Marshal(annotations)
if err != nil {
return nil
}
return encoded
}
func locateRenderedGeminiParts(content dto.GeminiChatContent, rendered string) []renderedGeminiPart {
parts := make([]renderedGeminiPart, len(content.Parts))
cursor := 0
for index := range content.Parts {
part := content.Parts[index]
text := part.Text
parts[index] = renderedGeminiPart{text: text, startByte: -1}
if text == "" || part.Thought || cursor > len(rendered) {
continue
}
relative := strings.Index(rendered[cursor:], text)
if relative < 0 {
continue
}
start := cursor + relative
parts[index].startByte = start
cursor = start + len(text)
}
return parts
}
func groundingRuneRange(rendered string, part renderedGeminiPart, startByte int, endByte int) (int, int, bool) {
if startByte < 0 || endByte <= startByte || endByte > len(part.text) {
return 0, 0, false
}
if !utf8.ValidString(part.text[:startByte]) || !utf8.ValidString(part.text[:endByte]) {
return 0, 0, false
}
partStartRunes := utf8.RuneCountInString(rendered[:part.startByte])
start := partStartRunes + utf8.RuneCountInString(part.text[:startByte])
end := partStartRunes + utf8.RuneCountInString(part.text[:endByte])
return start, end, true
}
@@ -0,0 +1,300 @@
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"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
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),
}
reasoningIntent, err := reasoning.FromGemini(geminiRequest)
if err != nil {
return nil, reasoning.AsClientError(err)
}
sourceModelName := modelName
if info != nil && info.GetOriginModelName() != "" {
sourceModelName = info.GetOriginModelName()
}
baseSourceModel := sourceModelName
opts := convmeta.OptionsOf(info)
preserveSuffix := opts.ShouldPreserveThinkingSuffix(sourceModelName)
if !preserveSuffix {
if suffix := reasoning.IntentFromState(convmeta.ReasoningStateOf(info)); !suffix.IsEmpty() {
reasoningIntent, err = reasoning.MergeExplicitAndSuffix(reasoningIntent, suffix, sourceModelName)
if err != nil {
return nil, reasoning.AsClientError(err)
}
}
}
if baseSourceModel != "" && geminiRequest.GenerationConfig.ThinkingConfig != nil {
_, err = reasoning.ValidateGeminiThinkingConfig(baseSourceModel, geminiRequest.GenerationConfig.ThinkingConfig)
if err != nil {
return nil, reasoning.AsClientError(err)
}
}
reasoningIntent = reasoning.ResolveGeminiDefault(baseSourceModel, reasoningIntent)
effectiveEffort := reasoning.EffectiveEffort(reasoningIntent)
if err := reasoning.ApplyToOpenAIChat(openaiRequest, reasoningIntent); err != nil {
return nil, reasoning.AsClientError(err)
}
if effectiveEffort != "" && info != nil {
info.SetReasoningEffort(string(effectiveEffort))
}
callHistory := newGeminiFunctionCallHistory(geminiRequest.Contents)
var messages []dto.Message
for _, content := range geminiRequest.Contents {
message := dto.Message{
Role: convertGeminiRoleToOpenAI(content.Role),
}
var mediaContents []dto.MediaContent
var toolCalls []dto.ToolCallRequest
var reasoningTexts []string
for _, part := range content.Parts {
if part.Text != "" {
if part.Thought {
reasoningTexts = append(reasoningTexts, part.Text)
continue
}
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: callHistory.add(part.FunctionCall),
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: callHistory.match(part.FunctionResponse),
}
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(reasoningTexts) > 0 {
reasoningContent := strings.Join(reasoningTexts, "\n")
message.ReasoningContent = &reasoningContent
}
if len(message.ParseContent()) > 0 || len(message.ToolCalls) > 0 || message.ReasoningContent != nil {
messages = append(messages, message)
}
}
openaiRequest.Messages = messages
if geminiRequest.GenerationConfig.Temperature != nil {
openaiRequest.Temperature = geminiRequest.GenerationConfig.Temperature
}
if geminiRequest.GenerationConfig.TopP != nil {
openaiRequest.TopP = kitutil.GetPointer(*geminiRequest.GenerationConfig.TopP)
}
if geminiRequest.GenerationConfig.TopK != nil {
openaiRequest.TopK = kitutil.GetPointer(int(*geminiRequest.GenerationConfig.TopK))
}
if geminiRequest.GenerationConfig.MaxOutputTokens != nil {
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 {
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
}
type geminiPendingFunctionCall struct {
id string
name string
}
// geminiFunctionCallHistory keeps legacy Gemini histories without call IDs
// correlated across content boundaries. Named matching permits results for
// different parallel functions to arrive out of order; same-name calls use
// their original call order because old payloads contain no stronger identity.
type geminiFunctionCallHistory struct {
reservedIDs map[string]struct{}
pending []geminiPendingFunctionCall
nextID int
}
func newGeminiFunctionCallHistory(contents []dto.GeminiChatContent) *geminiFunctionCallHistory {
history := &geminiFunctionCallHistory{
reservedIDs: make(map[string]struct{}),
nextID: 1,
}
for _, content := range contents {
for _, part := range content.Parts {
if part.FunctionCall != nil && part.FunctionCall.ID != "" {
history.reservedIDs[part.FunctionCall.ID] = struct{}{}
}
if part.FunctionResponse != nil {
if id := kitutil.JsonRawMessageToString(part.FunctionResponse.ID); id != "" {
history.reservedIDs[id] = struct{}{}
}
}
}
}
return history
}
func (h *geminiFunctionCallHistory) add(call *dto.FunctionCall) string {
id := call.ID
if id == "" {
id = h.newFallbackID()
}
h.pending = append(h.pending, geminiPendingFunctionCall{id: id, name: call.FunctionName})
return id
}
func (h *geminiFunctionCallHistory) match(response *dto.GeminiFunctionResponse) string {
if id := kitutil.JsonRawMessageToString(response.ID); id != "" {
h.removePendingByID(id)
return id
}
for i, call := range h.pending {
if response.Name != "" && call.name != response.Name {
continue
}
h.pending = append(h.pending[:i], h.pending[i+1:]...)
return call.id
}
return h.newFallbackID()
}
func (h *geminiFunctionCallHistory) removePendingByID(id string) {
for i, call := range h.pending {
if call.id != id {
continue
}
h.pending = append(h.pending[:i], h.pending[i+1:]...)
return
}
}
func (h *geminiFunctionCallHistory) newFallbackID() string {
for {
id := fmt.Sprintf("call_%d", h.nextID)
h.nextID++
if _, exists := h.reservedIDs[id]; exists {
continue
}
h.reservedIDs[id] = struct{}{}
return id
}
}
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,739 @@
package geminichat
import (
"fmt"
"sort"
"strconv"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
func UsageFromGeminiMetadata(metadata *dto.GeminiUsageMetadata, fallbackPromptTokens int) *dto.Usage {
if metadata == nil {
if fallbackPromptTokens <= 0 {
return nil
}
usage := &dto.Usage{PromptTokens: fallbackPromptTokens}
usage.PromptTokensDetails.TextTokens = fallbackPromptTokens
return usage
}
promptTokens := metadata.PromptTokenCount + metadata.ToolUsePromptTokenCount
if promptTokens <= 0 && fallbackPromptTokens > 0 {
promptTokens = fallbackPromptTokens
}
usage := &dto.Usage{
PromptTokens: promptTokens,
CompletionTokens: metadata.CandidatesTokenCount + metadata.ThoughtsTokenCount,
TotalTokens: metadata.TotalTokenCount,
BillingUsage: dto.CloneBillingUsage(metadata.BillingUsage),
}
if usage.BillingUsage == nil {
usage.BillingUsage = dto.NewGeminiChatBillingUsage(metadata)
}
usage.CompletionTokenDetails.ReasoningTokens = metadata.ThoughtsTokenCount
usage.PromptTokensDetails.CachedTokens = metadata.CachedContentTokenCount
for _, detail := range metadata.PromptTokensDetails {
if detail.Modality == "AUDIO" {
usage.PromptTokensDetails.AudioTokens += detail.TokenCount
} else if detail.Modality == "IMAGE" {
usage.PromptTokensDetails.ImageTokens += detail.TokenCount
} else if detail.Modality == "TEXT" {
usage.PromptTokensDetails.TextTokens += detail.TokenCount
}
}
for _, detail := range metadata.ToolUsePromptTokensDetails {
if detail.Modality == "AUDIO" {
usage.PromptTokensDetails.AudioTokens += detail.TokenCount
} else if detail.Modality == "IMAGE" {
usage.PromptTokensDetails.ImageTokens += detail.TokenCount
} else if detail.Modality == "TEXT" {
usage.PromptTokensDetails.TextTokens += detail.TokenCount
}
}
for _, detail := range metadata.CandidatesTokensDetails {
switch detail.Modality {
case "IMAGE":
usage.CompletionTokenDetails.ImageTokens += detail.TokenCount
case "AUDIO":
usage.CompletionTokenDetails.AudioTokens += detail.TokenCount
case "TEXT":
usage.CompletionTokenDetails.TextTokens += detail.TokenCount
}
}
if usage.TotalTokens > 0 && usage.CompletionTokens <= 0 {
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
}
if usage.PromptTokens > 0 && usage.PromptTokensDetails.TextTokens == 0 && usage.PromptTokensDetails.AudioTokens == 0 {
usage.PromptTokensDetails.TextTokens = usage.PromptTokens
}
return usage
}
func ResponseGeminiChat2OpenAI(id string, created int64, response *dto.GeminiChatResponse) *dto.OpenAITextResponse {
fullTextResponse := dto.OpenAITextResponse{
Id: id,
Object: "chat.completion",
Created: created,
Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)),
}
isToolCall := false
for _, candidate := range response.Candidates {
choice := dto.OpenAITextResponseChoice{
Index: int(candidate.Index),
Message: dto.Message{
Role: "assistant",
Content: "",
},
FinishReason: types.FinishReasonStop,
}
if len(candidate.Content.Parts) > 0 {
var content strings.Builder
var inlineGrow int
for _, part := range candidate.Content.Parts {
if part.InlineData != nil {
inlineGrow += len(part.InlineData.MimeType) + len(part.InlineData.Data) + 32
}
}
if inlineGrow > 0 {
content.Grow(inlineGrow)
}
appended := 0
writeSep := func() {
if appended > 0 {
content.WriteByte('\n')
}
appended++
}
var toolCalls []dto.ToolCallResponse
for _, part := range candidate.Content.Parts {
if part.InlineData != nil {
if strings.HasPrefix(part.InlineData.MimeType, "image") {
writeSep()
content.WriteString("![image](data:")
content.WriteString(part.InlineData.MimeType)
content.WriteString(";base64,")
content.WriteString(part.InlineData.Data)
content.WriteByte(')')
} else {
writeSep()
content.WriteString("[media](data:")
content.WriteString(part.InlineData.MimeType)
content.WriteString(";base64,")
content.WriteString(part.InlineData.Data)
content.WriteByte(')')
}
} else if part.FunctionCall != nil {
choice.FinishReason = types.FinishReasonToolCalls
if call := geminiResponseToolCall(&part); call != nil {
toolCalls = append(toolCalls, *call)
}
} else if part.Thought {
choice.Message.ReasoningContent = &part.Text
} else {
if part.ExecutableCode != nil {
writeSep()
content.WriteString("```")
content.WriteString(part.ExecutableCode.Language)
content.WriteByte('\n')
content.WriteString(part.ExecutableCode.Code)
content.WriteString("\n```")
} else if part.CodeExecutionResult != nil {
writeSep()
content.WriteString("```output\n")
content.WriteString(part.CodeExecutionResult.Output)
content.WriteString("\n```")
} else if part.Text != "\n" {
writeSep()
content.WriteString(part.Text)
}
}
}
if len(toolCalls) > 0 {
choice.Message.SetToolCalls(toolCalls)
isToolCall = true
}
choice.Message.SetStringContent(content.String())
}
if candidate.FinishReason != nil {
switch *candidate.FinishReason {
case "STOP":
choice.FinishReason = types.FinishReasonStop
case "MAX_TOKENS":
choice.FinishReason = types.FinishReasonLength
case "SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII", "OTHER":
choice.FinishReason = types.FinishReasonContentFilter
default:
choice.FinishReason = types.FinishReasonContentFilter
}
}
if isToolCall {
choice.FinishReason = types.FinishReasonToolCalls
}
choice.Message.Annotations = groundingAnnotationsToChat(candidate.GroundingMetadata, candidate.Content, choice.Message.StringContent())
fullTextResponse.Choices = append(fullTextResponse.Choices, choice)
}
return &fullTextResponse
}
func StreamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*dto.ChatCompletionsStreamResponse, bool) {
choices := make([]dto.ChatCompletionsStreamResponseChoice, 0, len(geminiResponse.Candidates))
isStop := false
for _, candidate := range geminiResponse.Candidates {
if candidate.FinishReason != nil && *candidate.FinishReason == "STOP" {
isStop = true
candidate.FinishReason = nil
}
choice := dto.ChatCompletionsStreamResponseChoice{
Index: int(candidate.Index),
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{},
}
var content strings.Builder
var inlineGrow int
for _, part := range candidate.Content.Parts {
if part.InlineData != nil {
inlineGrow += len(part.InlineData.MimeType) + len(part.InlineData.Data) + 32
}
}
if inlineGrow > 0 {
content.Grow(inlineGrow)
}
appended := 0
writeSep := func() {
if appended > 0 {
content.WriteByte('\n')
}
appended++
}
isTools := false
isThought := false
if candidate.FinishReason != nil {
switch *candidate.FinishReason {
case "STOP":
choice.FinishReason = &types.FinishReasonStop
case "MAX_TOKENS":
choice.FinishReason = &types.FinishReasonLength
case "SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII", "OTHER":
choice.FinishReason = &types.FinishReasonContentFilter
default:
choice.FinishReason = &types.FinishReasonContentFilter
}
}
for _, part := range candidate.Content.Parts {
if part.InlineData != nil {
if strings.HasPrefix(part.InlineData.MimeType, "image") {
writeSep()
content.WriteString("![image](data:")
content.WriteString(part.InlineData.MimeType)
content.WriteString(";base64,")
content.WriteString(part.InlineData.Data)
content.WriteByte(')')
}
} else if part.FunctionCall != nil {
isTools = true
if call := geminiResponseToolCall(&part); call != nil {
call.SetIndex(len(choice.Delta.ToolCalls))
choice.Delta.ToolCalls = append(choice.Delta.ToolCalls, *call)
}
} else if part.Thought {
isThought = true
writeSep()
content.WriteString(part.Text)
} else {
if part.ExecutableCode != nil {
writeSep()
content.WriteString("```")
content.WriteString(part.ExecutableCode.Language)
content.WriteByte('\n')
content.WriteString(part.ExecutableCode.Code)
content.WriteString("\n```\n")
} else if part.CodeExecutionResult != nil {
writeSep()
content.WriteString("```output\n")
content.WriteString(part.CodeExecutionResult.Output)
content.WriteString("\n```\n")
} else if part.Text != "\n" {
writeSep()
content.WriteString(part.Text)
}
}
}
if isThought {
choice.Delta.SetReasoningContent(content.String())
} else {
choice.Delta.SetContentString(content.String())
}
if isTools {
choice.FinishReason = &types.FinishReasonToolCalls
}
choice.Delta.Annotations = groundingAnnotationsToChat(candidate.GroundingMetadata, candidate.Content, content.String())
choices = append(choices, choice)
}
response := dto.ChatCompletionsStreamResponse{
Object: "chat.completion.chunk",
Choices: choices,
}
// Only attach usage the chunk actually reported. Do not fall back to a
// local prompt estimate — converters treat this as first-frame truth.
if metadata := geminiResponse.GetUsageMetadata(); dto.HasGeminiUsageMetadataTokens(metadata) {
response.Usage = UsageFromGeminiMetadata(metadata, 0)
}
return &response, isStop
}
type GeminiToChatStreamState struct {
id string
created int64
sawToolCall bool
finishEmitted bool
latestUsage *dto.Usage
// Gemini generateContent streams complete function calls. Keep their
// occurrence indexes monotonic because chunk-local indexes restart at zero.
nextToolIndexByCandidate map[int64]int
toolIndexByCandidateID map[int64]map[string]int
partialToolByCandidate map[int64]*geminiPartialToolCall
groundingByCandidate map[int64]*geminiGroundingStreamCandidate
sentGroundingAnnotations map[string]struct{}
}
type geminiPartialToolCall struct {
id string
name string
arguments map[string]interface{}
}
type geminiPartialArgPathSegment struct {
member string
index int
isIndex bool
}
const maxGeminiPartialArgArrayIndex = 4095
func NewGeminiToChatStreamState(id string, created int64) *GeminiToChatStreamState {
id = strings.TrimSpace(id)
if id == "" {
id = fmt.Sprintf("chatcmpl-%s", kitutil.GetUUID())
}
if created == 0 {
created = kitutil.GetTimestamp()
}
return &GeminiToChatStreamState{
id: id,
created: created,
nextToolIndexByCandidate: make(map[int64]int),
toolIndexByCandidateID: make(map[int64]map[string]int),
partialToolByCandidate: make(map[int64]*geminiPartialToolCall),
groundingByCandidate: make(map[int64]*geminiGroundingStreamCandidate),
sentGroundingAnnotations: make(map[string]struct{}),
}
}
func (s *GeminiToChatStreamState) ConvertChunk(geminiResponse *dto.GeminiChatResponse, model string, usage *dto.Usage) ([]*dto.ChatCompletionsStreamResponse, error) {
if s == nil || geminiResponse == nil {
return nil, nil
}
if s.groundingByCandidate == nil {
s.groundingByCandidate = make(map[int64]*geminiGroundingStreamCandidate)
}
if s.sentGroundingAnnotations == nil {
s.sentGroundingAnnotations = make(map[string]struct{})
}
if s.nextToolIndexByCandidate == nil {
s.nextToolIndexByCandidate = make(map[int64]int)
}
if s.toolIndexByCandidateID == nil {
s.toolIndexByCandidateID = make(map[int64]map[string]int)
}
if s.partialToolByCandidate == nil {
s.partialToolByCandidate = make(map[int64]*geminiPartialToolCall)
}
var err error
geminiResponse, err = s.preparePartialFunctionCalls(geminiResponse)
if err != nil {
return nil, err
}
hasNonStopFinish := false
for _, candidate := range geminiResponse.Candidates {
if candidate.FinishReason != nil && *candidate.FinishReason != "" && *candidate.FinishReason != "STOP" {
hasNonStopFinish = true
break
}
}
response, isStop := StreamResponseGeminiChat2OpenAI(geminiResponse)
if response == nil {
return nil, nil
}
response.Id = s.id
response.Created = s.created
response.Model = model
response.Usage = usage
for index := range geminiResponse.Candidates {
if index >= len(response.Choices) {
break
}
candidate := &geminiResponse.Candidates[index]
choice := &response.Choices[index]
for toolIndex := range choice.Delta.ToolCalls {
callID := strings.TrimSpace(choice.Delta.ToolCalls[toolIndex].ID)
indexesByID := s.toolIndexByCandidateID[candidate.Index]
if indexesByID == nil {
indexesByID = make(map[string]int)
s.toolIndexByCandidateID[candidate.Index] = indexesByID
}
stableIndex, exists := indexesByID[callID]
if callID == "" || !exists {
stableIndex = s.nextToolIndexByCandidate[candidate.Index]
s.nextToolIndexByCandidate[candidate.Index] = stableIndex + 1
if callID != "" {
indexesByID[callID] = stableIndex
}
}
choice.Delta.ToolCalls[toolIndex].SetIndex(stableIndex)
}
grounding := s.groundingByCandidate[candidate.Index]
if grounding == nil {
grounding = newGeminiGroundingStreamCandidate()
s.groundingByCandidate[candidate.Index] = grounding
}
grounding.appendContent(candidate.Content, response.Choices[index].Delta.GetContentString())
response.Choices[index].Delta.Annotations = grounding.groundingAnnotations(
candidate.GroundingMetadata,
candidate.Index,
s.sentGroundingAnnotations,
)
}
if response.IsToolCall() {
s.sawToolCall = true
if !hasNonStopFinish {
for i := range response.Choices {
if response.Choices[i].FinishReason != nil && *response.Choices[i].FinishReason == types.FinishReasonToolCalls {
response.Choices[i].FinishReason = nil
}
}
}
}
if usage != nil {
s.latestUsage = usage
}
for _, choice := range response.Choices {
if choice.FinishReason != nil && *choice.FinishReason != "" {
s.finishEmitted = true
break
}
}
responses := []*dto.ChatCompletionsStreamResponse{response}
if isStop && !s.finishEmitted {
responses = append(responses, s.terminalChunk(model))
}
return responses, nil
}
func (s *GeminiToChatStreamState) Finalize(model string) ([]*dto.ChatCompletionsStreamResponse, error) {
if s == nil {
return nil, nil
}
if len(s.partialToolByCandidate) > 0 {
candidateIndexes := make([]int64, 0, len(s.partialToolByCandidate))
for candidateIndex := range s.partialToolByCandidate {
candidateIndexes = append(candidateIndexes, candidateIndex)
}
sort.Slice(candidateIndexes, func(i, j int) bool {
return candidateIndexes[i] < candidateIndexes[j]
})
candidateIndex := candidateIndexes[0]
partial := s.partialToolByCandidate[candidateIndex]
return nil, fmt.Errorf("Gemini stream ended with an incomplete function call for candidate %d (id %q, name %q)", candidateIndex, partial.id, partial.name)
}
if s.finishEmitted {
return nil, nil
}
return []*dto.ChatCompletionsStreamResponse{s.terminalChunk(model)}, nil
}
func (s *GeminiToChatStreamState) Usage() *dto.Usage {
if s == nil {
return nil
}
return s.latestUsage
}
func (s *GeminiToChatStreamState) preparePartialFunctionCalls(response *dto.GeminiChatResponse) (*dto.GeminiChatResponse, error) {
prepared := *response
prepared.Candidates = append([]dto.GeminiChatCandidate(nil), response.Candidates...)
for candidateIndex := range prepared.Candidates {
candidate := &prepared.Candidates[candidateIndex]
parts := make([]dto.GeminiPart, 0, len(candidate.Content.Parts))
for _, part := range candidate.Content.Parts {
call := part.FunctionCall
if call == nil || (s.partialToolByCandidate[candidate.Index] == nil && call.WillContinue == nil && len(call.PartialArgs) == 0) {
parts = append(parts, part)
continue
}
completed, ready, err := s.appendPartialFunctionCall(candidate.Index, call)
if err != nil {
return nil, fmt.Errorf("reconstruct Gemini streamed function arguments: %w", err)
}
if ready {
part.FunctionCall = completed
parts = append(parts, part)
}
}
candidate.Content.Parts = parts
}
return &prepared, nil
}
func (s *GeminiToChatStreamState) appendPartialFunctionCall(candidateIndex int64, call *dto.FunctionCall) (*dto.FunctionCall, bool, error) {
current := s.partialToolByCandidate[candidateIndex]
if current == nil {
current = &geminiPartialToolCall{arguments: make(map[string]interface{})}
s.partialToolByCandidate[candidateIndex] = current
}
if id := strings.TrimSpace(call.ID); id != "" {
if current.id != "" && current.id != id {
return nil, false, fmt.Errorf("candidate %d function call changed id from %q to %q", candidateIndex, current.id, id)
}
current.id = id
}
if name := strings.TrimSpace(call.FunctionName); name != "" {
if current.name != "" && current.name != name {
return nil, false, fmt.Errorf("candidate %d function call changed name from %q to %q", candidateIndex, current.name, name)
}
current.name = name
}
for _, partial := range call.PartialArgs {
path, err := parseGeminiPartialArgPath(partial.JSONPath)
if err != nil {
return nil, false, err
}
value, present := geminiPartialArgValue(partial)
if !present {
continue
}
updated, err := setGeminiPartialArgValue(current.arguments, path, value, partial.StringValue != nil)
if err != nil {
return nil, false, fmt.Errorf("set partial argument %q: %w", partial.JSONPath, err)
}
arguments, ok := updated.(map[string]interface{})
if !ok {
return nil, false, fmt.Errorf("partial argument path %q replaced the arguments object", partial.JSONPath)
}
current.arguments = arguments
}
if call.WillContinue != nil && *call.WillContinue {
return nil, false, nil
}
if current.name == "" {
return nil, false, fmt.Errorf("candidate %d completed a partial function call without a name", candidateIndex)
}
completed := &dto.FunctionCall{ID: current.id, FunctionName: current.name, Arguments: current.arguments}
delete(s.partialToolByCandidate, candidateIndex)
return completed, true, nil
}
func parseGeminiPartialArgPath(jsonPath string) ([]geminiPartialArgPathSegment, error) {
path := strings.TrimSpace(jsonPath)
if path == "" || path[0] != '$' {
return nil, fmt.Errorf("unsupported Gemini partial argument path %q", jsonPath)
}
segments := make([]geminiPartialArgPathSegment, 0)
for offset := 1; offset < len(path); {
switch path[offset] {
case '.':
offset++
start := offset
for offset < len(path) && path[offset] != '.' && path[offset] != '[' {
offset++
}
if start == offset {
return nil, fmt.Errorf("empty member in Gemini partial argument path %q", jsonPath)
}
member := path[start:offset]
if strings.ContainsAny(member, "]*?") {
return nil, fmt.Errorf("unsupported member %q in Gemini partial argument path", member)
}
segments = append(segments, geminiPartialArgPathSegment{member: member})
case '[':
offset++
if offset >= len(path) {
return nil, fmt.Errorf("unterminated selector in Gemini partial argument path %q", jsonPath)
}
if path[offset] == '\'' || path[offset] == '"' {
member, next, err := parseGeminiPartialArgMember(path, offset)
if err != nil {
return nil, fmt.Errorf("invalid Gemini partial argument path %q: %w", jsonPath, err)
}
offset = next
if offset >= len(path) || path[offset] != ']' {
return nil, fmt.Errorf("unterminated member selector in Gemini partial argument path %q", jsonPath)
}
offset++
segments = append(segments, geminiPartialArgPathSegment{member: member})
continue
}
start := offset
for offset < len(path) && path[offset] >= '0' && path[offset] <= '9' {
offset++
}
if start == offset || offset >= len(path) || path[offset] != ']' {
return nil, fmt.Errorf("unsupported selector in Gemini partial argument path %q", jsonPath)
}
index, err := strconv.Atoi(path[start:offset])
if err != nil {
return nil, fmt.Errorf("invalid array index in Gemini partial argument path %q: %w", jsonPath, err)
}
if index > maxGeminiPartialArgArrayIndex {
return nil, fmt.Errorf("array index %d exceeds Gemini partial argument materialization limit %d", index, maxGeminiPartialArgArrayIndex)
}
offset++
segments = append(segments, geminiPartialArgPathSegment{index: index, isIndex: true})
default:
return nil, fmt.Errorf("unsupported selector at offset %d in Gemini partial argument path %q", offset, jsonPath)
}
}
if len(segments) == 0 {
return nil, fmt.Errorf("Gemini partial argument path %q targets the arguments root", jsonPath)
}
return segments, nil
}
func geminiPartialArgValue(partial dto.GeminiPartialArg) (any, bool) {
switch {
case partial.StringValue != nil:
return *partial.StringValue, true
case partial.NumberValue != nil:
return *partial.NumberValue, true
case partial.BoolValue != nil:
return *partial.BoolValue, true
case partial.NullValue != nil:
return nil, true
default:
return nil, false
}
}
func parseGeminiPartialArgMember(path string, offset int) (string, int, error) {
quote := path[offset]
start := offset
offset++
for offset < len(path) {
if path[offset] == '\\' {
offset += 2
continue
}
if path[offset] == quote {
raw := path[start : offset+1]
if quote == '\'' {
raw = `"` + strings.ReplaceAll(strings.ReplaceAll(raw[1:len(raw)-1], `"`, `\"`), `\'`, `'`) + `"`
}
var member string
if err := kitutil.Unmarshal([]byte(raw), &member); err != nil {
return "", 0, err
}
return member, offset + 1, nil
}
offset++
}
return "", 0, fmt.Errorf("unterminated quoted member")
}
func setGeminiPartialArgValue(current any, path []geminiPartialArgPathSegment, value any, appendString bool) (any, error) {
if len(path) == 0 {
if appendString {
if existing, ok := current.(string); ok {
return existing + value.(string), nil
}
}
return value, nil
}
segment := path[0]
if segment.isIndex {
var array []interface{}
switch typed := current.(type) {
case nil:
array = make([]interface{}, segment.index+1)
case []interface{}:
array = typed
if len(array) <= segment.index {
array = append(array, make([]interface{}, segment.index-len(array)+1)...)
}
default:
return nil, fmt.Errorf("array index %d traverses %T", segment.index, current)
}
updated, err := setGeminiPartialArgValue(array[segment.index], path[1:], value, appendString)
if err != nil {
return nil, err
}
array[segment.index] = updated
return array, nil
}
var object map[string]interface{}
switch typed := current.(type) {
case nil:
object = make(map[string]interface{})
case map[string]interface{}:
object = typed
default:
return nil, fmt.Errorf("member %q traverses %T", segment.member, current)
}
updated, err := setGeminiPartialArgValue(object[segment.member], path[1:], value, appendString)
if err != nil {
return nil, err
}
object[segment.member] = updated
return object, nil
}
func (s *GeminiToChatStreamState) terminalChunk(model string) *dto.ChatCompletionsStreamResponse {
finishReason := types.FinishReasonStop
if s.sawToolCall {
finishReason = types.FinishReasonToolCalls
}
s.finishEmitted = true
return &dto.ChatCompletionsStreamResponse{
Id: s.id,
Object: "chat.completion.chunk",
Created: s.created,
Model: model,
Choices: []dto.ChatCompletionsStreamResponseChoice{
{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{},
FinishReason: &finishReason,
},
},
Usage: s.latestUsage,
}
}
func geminiResponseToolCall(item *dto.GeminiPart) *dto.ToolCallResponse {
argsBytes, err := kitutil.Marshal(item.FunctionCall.Arguments)
if err != nil {
return nil
}
callID := strings.TrimSpace(item.FunctionCall.ID)
if callID == "" {
callID = fmt.Sprintf("call_%s", kitutil.GetUUID())
}
return &dto.ToolCallResponse{
ID: callID,
Type: "function",
Function: dto.FunctionResponse{
Arguments: string(argsBytes),
Name: item.FunctionCall.FunctionName,
},
}
}
@@ -0,0 +1,71 @@
package geminichat
import (
"fmt"
"github.com/QuantumNous/new-api/relaykit/dto"
oaichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/oai_chat"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
// GeminiHostedStreamBridge accumulates grounding metadata until the provider
// stream ends. Gemini commonly reports the search queries after emitting the
// answer text; delaying the synthetic Responses item keeps one complete,
// canonical action instead of emitting partial or duplicate tool calls.
type GeminiHostedStreamBridge struct {
queries []string
seen map[string]struct{}
}
func NewGeminiHostedStreamBridge() *GeminiHostedStreamBridge {
return &GeminiHostedStreamBridge{seen: make(map[string]struct{})}
}
func (b *GeminiHostedStreamBridge) Observe(response *dto.GeminiChatResponse) {
if b == nil {
return
}
if b.seen == nil {
b.seen = make(map[string]struct{})
}
for _, query := range GroundingWebSearchQueries(response) {
if _, exists := b.seen[query]; exists {
continue
}
b.seen[query] = struct{}{}
b.queries = append(b.queries, query)
}
}
func (b *GeminiHostedStreamBridge) Finalize(state *oaichat.ChatToResponsesStreamState) ([]oaichat.ChatToResponsesStreamEvent, error) {
if b == nil || len(b.queries) == 0 {
return nil, nil
}
if state == nil {
return nil, fmt.Errorf("Chat-to-Responses stream state is required")
}
action, err := kitutil.Marshal(map[string]any{
"type": "search",
"queries": append([]string(nil), b.queries...),
})
if err != nil {
return nil, fmt.Errorf("marshal Gemini web-search action: %w", err)
}
callID := fmt.Sprintf("ws_%s", kitutil.GetUUID())
events, err := state.StartHostedTool(oaichat.HostedToolStreamStart{
Type: "web_search_call",
ID: callID,
Action: action,
})
if err != nil {
return nil, err
}
completed, err := state.CompleteHostedTool(oaichat.HostedToolStreamResult{
Type: "web_search_call",
ID: callID,
})
if err != nil {
return nil, err
}
return append(events, completed...), nil
}
@@ -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,81 @@
package oaichat
import (
"encoding/json"
"strings"
"unicode/utf8"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
func chatAnnotationsToClaude(raw json.RawMessage, text string) []json.RawMessage {
if len(raw) == 0 {
return nil
}
var annotations []map[string]any
if err := kitutil.Unmarshal(raw, &annotations); err != nil {
return nil
}
citations := make([]json.RawMessage, 0, len(annotations))
for _, annotation := range annotations {
if strings.TrimSpace(kitutil.Interface2String(annotation["type"])) != "url_citation" {
continue
}
citation, ok := annotation["url_citation"].(map[string]any)
if !ok {
citation = annotation
}
url := strings.TrimSpace(kitutil.Interface2String(citation["url"]))
if url == "" {
continue
}
converted := map[string]any{
"type": "web_search_result_location",
"url": url,
"title": strings.TrimSpace(kitutil.Interface2String(citation["title"])),
}
if citedText := kitutil.Interface2String(citation["cited_text"]); citedText != "" {
converted["cited_text"] = citedText
} else if citedText := citedTextFromAnnotation(text, citation); citedText != "" {
converted["cited_text"] = citedText
}
if encryptedIndex := kitutil.Interface2String(citation["encrypted_index"]); encryptedIndex != "" {
converted["encrypted_index"] = encryptedIndex
}
if converted["title"] == "" {
delete(converted, "title")
}
encoded, err := kitutil.Marshal(converted)
if err == nil {
citations = append(citations, encoded)
}
}
return citations
}
func citedTextFromAnnotation(text string, citation map[string]any) string {
start, startOK := annotationIndex(citation["start_index"])
end, endOK := annotationIndex(citation["end_index"])
if !startOK || !endOK || start < 0 || end <= start {
return ""
}
if end > utf8.RuneCountInString(text) {
return ""
}
runes := []rune(text)
return string(runes[start:end])
}
func annotationIndex(value any) (int, bool) {
switch number := value.(type) {
case float64:
return int(number), number >= 0 && number == float64(int(number))
case int:
return number, number >= 0
case json.Number:
parsed, err := number.Int64()
return int(parsed), err == nil && parsed >= 0
default:
return 0, false
}
}
@@ -0,0 +1,324 @@
package oaichat
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"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
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 _, ok := tool.Function.Parameters.(map[string]any); !ok && tool.Type != "function" {
continue
}
claudeTools = append(claudeTools, &dto.Tool{
Name: tool.Function.Name,
Description: tool.Function.Description,
InputSchema: sharedclaude.FunctionParametersToInputSchema(tool.Function.Parameters),
})
}
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]any
if err := kitutil.Unmarshal(textRequest.WebSearchOptions.UserLocation, &userLocationMap); err == nil {
if approximateData, ok := userLocationMap["approximate"].(map[string]any); 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
}
claudeTools = append(claudeTools, &webSearchTool)
}
claudeRequest := dto.ClaudeRequest{
Model: textRequest.Model,
StopSequences: nil,
Temperature: textRequest.Temperature,
}
if len(claudeTools) > 0 {
claudeRequest.Tools = claudeTools
}
if textRequest.MaxCompletionTokens != nil && *textRequest.MaxCompletionTokens > 0 {
claudeRequest.MaxTokens = kitutil.GetPointer(*textRequest.MaxCompletionTokens)
} else if textRequest.MaxTokens != nil && *textRequest.MaxTokens > 0 {
claudeRequest.MaxTokens = kitutil.GetPointer(*textRequest.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
}
}
sourceReasoning, err := reasoning.FromOpenAIChat(&textRequest)
if err != nil {
return nil, reasoning.AsClientError(err)
}
if err := sharedclaude.ApplyReasoning(c, &claudeRequest, info, sourceReasoning, true); err != nil {
return nil, reasoning.AsClientError(err)
}
if claudeRequest.MaxTokens == nil {
if defaultMaxTokens, configured := opts.Claude.DefaultMaxTokensFor(claudeRequest.Model); configured {
value := uint(defaultMaxTokens)
claudeRequest.MaxTokens = &value
}
}
if textRequest.Stop != nil {
switch stop := textRequest.Stop.(type) {
case string:
claudeRequest.StopSequences = []string{stop}
case []any:
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 _, message := range textRequest.Messages {
switch message.Role {
case "":
message.Role = "user"
case "developer":
message.Role = "system"
case "function":
if message.ToolCallId != "" {
message.Role = "tool"
} else {
message.Role = "user"
}
case "tool":
if message.ToolCallId == "" {
message.Role = "user"
}
case "system", "user", "assistant":
default:
message.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(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
placeholderUserMessage := dto.ClaudeMessage{
Role: "user",
Content: []dto.ClaudeMediaMessage{
{
Type: "text",
Text: kitutil.GetPointer[string]("..."),
},
},
}
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" {
claudeMessages = append(claudeMessages, placeholderUserMessage)
}
}
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(claudeMessages) == 0 && len(systemMessages) > 0 {
claudeMessages = append(claudeMessages, placeholderUserMessage)
}
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,81 @@
package oaichat
import (
"context"
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOpenAIChatRequestToClaudeMessagesNormalizesToolInputSchema(t *testing.T) {
tests := []struct {
name string
parameters any
wantSchema map[string]any
}{
{
name: "omitted parameters",
parameters: nil,
wantSchema: map[string]any{
"type": "object",
"properties": map[string]any{},
},
},
{
name: "missing type and properties",
parameters: map[string]any{
"additionalProperties": false,
},
wantSchema: map[string]any{
"type": "object",
"properties": map[string]any{},
"additionalProperties": false,
},
},
{
name: "non-string type",
parameters: map[string]any{
"type": 123,
"properties": map[string]any{},
},
wantSchema: map[string]any{
"type": 123,
"properties": map[string]any{},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
maxTokens := uint(1024)
got, err := OpenAIChatRequestToClaudeMessages(context.Background(), nil, dto.GeneralOpenAIRequest{
Model: "claude-test",
MaxTokens: &maxTokens,
Messages: []dto.Message{
{Role: "user", Content: "Call the tool."},
},
Tools: []dto.ToolCallRequest{
{
Type: "function",
Function: dto.FunctionRequest{
Name: "get_current_time",
Description: "Get the current time",
Parameters: tt.parameters,
},
},
},
})
require.NoError(t, err)
tools, ok := got.Tools.([]any)
require.True(t, ok)
require.Len(t, tools, 1)
tool, ok := tools[0].(*dto.Tool)
require.True(t, ok)
assert.Equal(t, "get_current_time", tool.Name)
assert.Equal(t, tt.wantSchema, tool.InputSchema)
})
}
}
@@ -0,0 +1,503 @@
package oaichat
import (
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/reasonmap"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
func generateStopBlock(index int) *dto.ClaudeResponse {
return &dto.ClaudeResponse{
Type: "content_block_stop",
Index: kitutil.GetPointer[int](index),
}
}
func stopOpenBlocks(state *convmeta.ClaudeConvertInfo) []*dto.ClaudeResponse {
if state == nil {
return nil
}
switch state.LastMessagesType {
case convmeta.LastMessageTypeText, convmeta.LastMessageTypeThinking:
return []*dto.ClaudeResponse{generateStopBlock(state.Index)}
case convmeta.LastMessageTypeTools:
if len(state.ToolCalls) == 0 {
responses := make([]*dto.ClaudeResponse, 0, state.ToolCallMaxIndexOffset+1)
for offset := 0; offset <= state.ToolCallMaxIndexOffset; offset++ {
responses = append(responses, generateStopBlock(state.ToolCallBaseIndex+offset))
}
return responses
}
responses := make([]*dto.ClaudeResponse, 0, len(state.ToolCalls))
for _, tool := range state.ToolCalls {
if tool != nil && tool.Started {
responses = append(responses, generateStopBlock(tool.BlockIndex))
}
}
return responses
default:
return nil
}
}
func startPendingToolBlocks(state *convmeta.ClaudeConvertInfo) []*dto.ClaudeResponse {
if state == nil || state.LastMessagesType != convmeta.LastMessageTypeTools {
return nil
}
responses := make([]*dto.ClaudeResponse, 0)
for _, tool := range state.ToolCalls {
if tool == nil || tool.Started || tool.Name == "" {
continue
}
if tool.ID == "" {
tool.ID = fmt.Sprintf("toolu_%s", kitutil.GetUUID())
}
idx := tool.BlockIndex
responses = append(responses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Id: tool.ID,
Type: "tool_use",
Name: tool.Name,
Input: map[string]any{},
},
})
tool.Started = true
if tool.PendingArguments != "" {
arguments := tool.PendingArguments
responses = append(responses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "input_json_delta",
PartialJson: &arguments,
},
})
tool.PendingArguments = ""
}
}
return responses
}
func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
return sharedclaude.UsageFromOpenAI(oaiUsage)
}
func clientVisibleClaudeStreamUsage(state *convmeta.ClaudeConvertInfo, incoming *dto.Usage) *dto.ClaudeUsage {
prior := buildClaudeUsageFromOpenAIUsage(state.Usage)
converted := buildClaudeUsageFromOpenAIUsage(incoming)
if incoming != nil {
state.Usage = dto.MergeUsageNonZero(state.Usage, incoming)
}
if prior == nil {
return converted
}
if converted == nil {
return prior
}
return dto.MergeClaudeUsageNonZero(prior, converted)
}
func NormalizeCacheCreationSplit(totalTokens int, tokens5m int, tokens1h int) (int, int) {
return sharedclaude.NormalizeCacheCreationSplit(totalTokens, tokens5m, 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.
appendStopOpenBlocks := func() {
claudeResponses = append(claudeResponses, startPendingToolBlocks(state)...)
claudeResponses = append(claudeResponses, stopOpenBlocks(state)...)
}
// 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
}
appendStopOpenBlocks()
switch state.LastMessagesType {
case convmeta.LastMessageTypeTools:
state.Index = state.ToolCallBaseIndex + len(state.ToolCalls)
state.ToolCallBaseIndex = 0
state.ToolCallMaxIndexOffset = 0
state.ToolCalls = nil
state.ToolCallByIndex = nil
state.ToolCallByID = nil
default:
state.Index++
}
state.LastMessagesType = convmeta.LastMessageTypeNone
}
appendCitationDeltas := func(raw []byte) {
citations := chatAnnotationsToClaude(raw, "")
if len(citations) == 0 {
return
}
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
}
for _, citation := range citations {
idx := state.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "citations_delta",
Citation: citation,
},
})
}
}
if info.GetSendResponseCount() == 1 {
// Client-visible Claude stream usage matches billing merge: first
// frame records first, a later non-zero field overrides, and a later
// zero/missing field never erases a first-frame positive. Anthropic
// clients treat message_delta as authoritative for the fields it
// carries, including a corrected input_tokens.
startUsage := &dto.ClaudeUsage{
InputTokens: info.GetEstimatePromptTokens(),
OutputTokens: 0,
}
if openAIResponse.Usage != nil && dto.HasOpenAIUsageTokens(openAIResponse.Usage) {
if real := buildClaudeUsageFromOpenAIUsage(openAIResponse.Usage); real != nil {
startUsage = real
}
state.Usage = dto.MergeUsageNonZero(state.Usage, openAIResponse.Usage)
}
msg := &dto.ClaudeMediaMessage{
Id: openAIResponse.Id,
Model: openAIResponse.Model,
Type: "message",
Role: "assistant",
Usage: startUsage,
}
msg.SetContent(make([]any, 0))
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_start",
Message: msg,
})
}
if len(openAIResponse.Choices) == 0 {
// Some OpenAI-compatible upstreams end with a usage-only SSE chunk.
oaiUsage := clientVisibleClaudeStreamUsage(state, openAIResponse.Usage)
if oaiUsage != nil {
appendStopOpenBlocks()
stopReason := stopReasonOpenAI2Claude(state.FinishReason)
if stopReason == "" {
stopReason = "end_turn"
}
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: 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
}
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.ToolCalls = nil
state.ToolCallByIndex = make(map[int]*convmeta.ClaudeStreamToolCall)
state.ToolCallByID = make(map[string]*convmeta.ClaudeStreamToolCall)
}
state.LastMessagesType = convmeta.LastMessageTypeTools
if state.ToolCallByIndex == nil {
state.ToolCallByIndex = make(map[int]*convmeta.ClaudeStreamToolCall)
}
if state.ToolCallByID == nil {
state.ToolCallByID = make(map[string]*convmeta.ClaudeStreamToolCall)
}
for i, toolCall := range toolCalls {
toolIndex := i
if toolCall.Index != nil {
toolIndex = *toolCall.Index
}
incomingID := strings.TrimSpace(toolCall.ID)
var tool *convmeta.ClaudeStreamToolCall
if incomingID != "" {
tool = state.ToolCallByID[incomingID]
}
if tool == nil {
tool = state.ToolCallByIndex[toolIndex]
}
if tool != nil && incomingID != "" && tool.ID != "" && tool.ID != incomingID {
tool = nil
}
if tool == nil {
tool = &convmeta.ClaudeStreamToolCall{
BlockIndex: state.ToolCallBaseIndex + len(state.ToolCalls),
}
state.ToolCalls = append(state.ToolCalls, tool)
}
state.ToolCallByIndex[toolIndex] = tool
if tool.ID == "" && incomingID != "" {
tool.ID = incomingID
state.ToolCallByID[incomingID] = tool
}
if tool.Name == "" && strings.TrimSpace(toolCall.Function.Name) != "" {
tool.Name = strings.TrimSpace(toolCall.Function.Name)
}
if !tool.Started {
tool.PendingArguments += toolCall.Function.Arguments
}
idx := tool.BlockIndex
if !tool.Started && tool.ID != "" && tool.Name != "" {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Id: tool.ID,
Type: "tool_use",
Name: tool.Name,
Input: map[string]any{},
},
})
tool.Started = true
if tool.PendingArguments != "" {
arguments := tool.PendingArguments
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "input_json_delta",
PartialJson: &arguments,
},
})
tool.PendingArguments = ""
}
continue
}
if tool.Started && toolCall.Function.Arguments != "" {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "input_json_delta",
PartialJson: &toolCall.Function.Arguments,
},
})
}
}
state.ToolCallMaxIndexOffset = len(state.ToolCalls) - 1
if len(state.ToolCalls) > 0 {
state.Index = state.ToolCallBaseIndex + len(state.ToolCalls) - 1
}
} 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)
}
appendCitationDeltas(chosenChoice.Delta.Annotations)
if doneChunk || state.Done {
oaiUsage := clientVisibleClaudeStreamUsage(state, openAIResponse.Usage)
if oaiUsage == nil {
// Some upstreams emit finish_reason first, then send a final usage-only chunk.
// Keep content blocks open until usage is available so the terminal message_delta
// can carry both usage and the final stop reason.
return claudeResponses
}
appendStopOpenBlocks()
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: 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 FinalizeStreamResponseOpenAI2Claude(info convmeta.Meta) []*dto.ClaudeResponse {
if info == nil {
info = &convmeta.Values{}
}
state := info.EnsureClaudeConvertInfo()
if state.Done {
return nil
}
stopReason := stopReasonOpenAI2Claude(state.FinishReason)
if stopReason == "" {
stopReason = "end_turn"
}
responses := startPendingToolBlocks(state)
responses = append(responses, stopOpenBlocks(state)...)
responses = append(responses,
&dto.ClaudeResponse{
Type: "message_delta",
Usage: clientVisibleClaudeStreamUsage(state, nil),
Delta: &dto.ClaudeMediaMessage{
StopReason: kitutil.GetPointer[string](stopReason),
},
},
&dto.ClaudeResponse{Type: "message_stop"},
)
state.Done = true
return responses
}
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)
reasoningContent := choice.Message.GetReasoningContent()
textContent := choice.Message.StringContent()
toolCalls := choice.Message.ParseToolCalls()
if reasoningContent != "" {
claudeContent := dto.ClaudeMediaMessage{Type: "thinking"}
claudeContent.Thinking = kitutil.GetPointer(reasoningContent)
contents = append(contents, claudeContent)
}
if textContent != "" || (reasoningContent == "" && len(toolCalls) == 0) {
claudeContent := dto.ClaudeMediaMessage{}
claudeContent.Type = "text"
claudeContent.SetText(textContent)
citations := chatAnnotationsToClaude(choice.Message.Annotations, textContent)
if len(citations) > 0 {
claudeContent.Citations, _ = kitutil.Marshal(citations)
}
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]any{}
if strings.TrimSpace(toolUse.Function.Arguments) != "" {
var parsed map[string]any
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,424 @@
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]any
}{
{name: "object", args: `{"q":"x"}`, want: map[string]any{"q": "x"}},
{name: "empty", args: "", want: map[string]any{}},
{name: "invalid", args: "{", want: map[string]any{}},
{name: "null", args: "null", want: map[string]any{}},
{name: "array", args: `["x"]`, want: map[string]any{}},
{name: "string", args: `"x"`, want: map[string]any{}},
}
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 TestResponseOpenAI2ClaudePreservesReasoningBeforeText(t *testing.T) {
message := dto.Message{Role: "assistant", Content: "final answer"}
message.ReasoningContent = ptr("considering the request")
resp := ResponseOpenAI2Claude(&dto.OpenAITextResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.OpenAITextResponseChoice{
{Message: message, FinishReason: "stop"},
},
}, nil)
require.Len(t, resp.Content, 2)
assert.Equal(t, "thinking", resp.Content[0].Type)
require.NotNil(t, resp.Content[0].Thinking)
assert.Equal(t, "considering the request", *resp.Content[0].Thinking)
assert.Equal(t, "text", resp.Content[1].Type)
assert.Equal(t, "final answer", resp.Content[1].GetText())
}
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 TestStreamResponseOpenAI2ClaudeFirstFrameUsesUpstreamUsageWhenPresent(t *testing.T) {
info := &convmeta.Values{
EstimatePromptTokens: 32,
SendResponseCount: 1,
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{LastMessagesType: convmeta.LastMessageTypeNone},
}
responses := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("hello")},
}},
Usage: &dto.Usage{PromptTokens: 29, CompletionTokens: 0, TotalTokens: 29},
}, info)
require.NotEmpty(t, responses)
require.Equal(t, "message_start", responses[0].Type)
require.NotNil(t, responses[0].Message)
require.NotNil(t, responses[0].Message.Usage)
assert.Equal(t, 29, responses[0].Message.Usage.InputTokens)
}
func TestStreamResponseOpenAI2ClaudeMessageDeltaCorrectsEstimatedFirstFrame(t *testing.T) {
info := &convmeta.Values{
EstimatePromptTokens: 32,
SendResponseCount: 1,
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{LastMessagesType: convmeta.LastMessageTypeNone},
}
first := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("hello")},
}},
}, info)
require.NotEmpty(t, first)
require.Equal(t, "message_start", first[0].Type)
require.NotNil(t, first[0].Message.Usage)
assert.Equal(t, 32, first[0].Message.Usage.InputTokens)
info.SendResponseCount = 2
finish := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
FinishReason: ptr("stop"),
}},
Usage: &dto.Usage{PromptTokens: 29, CompletionTokens: 4, TotalTokens: 33},
}, info)
var delta *dto.ClaudeResponse
for _, resp := range finish {
if resp.Type == "message_delta" {
delta = resp
break
}
}
require.NotNil(t, delta)
require.NotNil(t, delta.Usage)
assert.Equal(t, 29, delta.Usage.InputTokens)
assert.Equal(t, 4, delta.Usage.OutputTokens)
}
func TestStreamResponseOpenAI2ClaudeMessageDeltaDoesNotZeroFirstFrameCache(t *testing.T) {
info := &convmeta.Values{
EstimatePromptTokens: 8,
SendResponseCount: 1,
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{LastMessagesType: convmeta.LastMessageTypeNone},
}
first := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("hello")},
}},
Usage: &dto.Usage{
PromptTokens: 40,
CompletionTokens: 0,
TotalTokens: 40,
PromptTokensDetails: dto.InputTokenDetails{
CachedTokens: 20,
CachedCreationTokens: 10,
},
},
}, info)
require.Equal(t, "message_start", first[0].Type)
require.NotNil(t, first[0].Message.Usage)
assert.Equal(t, 20, first[0].Message.Usage.CacheReadInputTokens)
assert.Equal(t, 10, first[0].Message.Usage.CacheCreationInputTokens)
info.SendResponseCount = 2
finish := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
FinishReason: ptr("stop"),
}},
Usage: &dto.Usage{PromptTokens: 29, CompletionTokens: 4, TotalTokens: 33},
}, info)
var delta *dto.ClaudeResponse
for _, resp := range finish {
if resp.Type == "message_delta" {
delta = resp
break
}
}
require.NotNil(t, delta)
require.NotNil(t, delta.Usage)
assert.Equal(t, 29, delta.Usage.InputTokens)
assert.Equal(t, 20, delta.Usage.CacheReadInputTokens)
assert.Equal(t, 10, delta.Usage.CacheCreationInputTokens)
}
func TestStreamResponseOpenAI2ClaudeGeminiBillingUsageOnStartAndDelta(t *testing.T) {
info := &convmeta.Values{
EstimatePromptTokens: 4994,
SendResponseCount: 1,
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{LastMessagesType: convmeta.LastMessageTypeNone},
}
firstUsage := &dto.Usage{
PromptTokens: 3868,
CompletionTokens: 0,
TotalTokens: 3868,
BillingUsage: dto.NewGeminiChatBillingUsage(&dto.GeminiUsageMetadata{
PromptTokenCount: 3868,
TotalTokenCount: 3868,
}),
}
first := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("hello")},
}},
Usage: firstUsage,
}, info)
require.NotEmpty(t, first)
require.Equal(t, "message_start", first[0].Type)
require.NotNil(t, first[0].Message)
require.NotNil(t, first[0].Message.Usage)
assert.Equal(t, 3868, first[0].Message.Usage.InputTokens)
require.NotNil(t, first[0].Message.Usage.BillingUsage)
assert.Equal(t, dto.BillingUsageSourceGeminiChat, first[0].Message.Usage.BillingUsage.Source)
assert.Equal(t, dto.BillingUsageSemanticGemini, first[0].Message.Usage.BillingUsage.Semantic)
require.NotNil(t, first[0].Message.Usage.BillingUsage.GeminiUsageMetadata)
assert.Equal(t, 3868, first[0].Message.Usage.BillingUsage.GeminiUsageMetadata.PromptTokenCount)
info.SendResponseCount = 2
finish := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
FinishReason: ptr("stop"),
}},
Usage: &dto.Usage{
PromptTokens: 3868,
CompletionTokens: 12,
TotalTokens: 3880,
BillingUsage: dto.NewGeminiChatBillingUsage(&dto.GeminiUsageMetadata{
PromptTokenCount: 3868,
CandidatesTokenCount: 12,
TotalTokenCount: 3880,
}),
},
}, info)
var delta *dto.ClaudeResponse
for _, resp := range finish {
if resp.Type == "message_delta" {
delta = resp
break
}
}
require.NotNil(t, delta)
require.NotNil(t, delta.Usage)
assert.Equal(t, 3868, delta.Usage.InputTokens)
assert.Equal(t, 12, delta.Usage.OutputTokens)
require.NotNil(t, delta.Usage.BillingUsage)
assert.Equal(t, dto.BillingUsageSourceGeminiChat, delta.Usage.BillingUsage.Source)
assert.Equal(t, dto.BillingUsageSemanticGemini, delta.Usage.BillingUsage.Semantic)
require.NotNil(t, delta.Usage.BillingUsage.GeminiUsageMetadata)
assert.Equal(t, 3868, delta.Usage.BillingUsage.GeminiUsageMetadata.PromptTokenCount)
assert.Equal(t, 12, delta.Usage.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
}
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,379 @@
package oaichat
import (
"errors"
"fmt"
"math"
"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"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
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 {
geminiRequest.GenerationConfig.TopP = kitutil.GetPointer(*textRequest.TopP)
}
if textRequest.MaxCompletionTokens != nil {
geminiRequest.GenerationConfig.MaxOutputTokens = kitutil.GetPointer(*textRequest.MaxCompletionTokens)
} else if textRequest.MaxTokens != nil {
geminiRequest.GenerationConfig.MaxOutputTokens = kitutil.GetPointer(*textRequest.MaxTokens)
}
if textRequest.Seed != nil {
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
}
if len(textRequest.ExtraBody) > 0 {
var extraBody map[string]any
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]any); ok {
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]any); 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 {
v, ok := thinkingBudget.(float64)
maxInt := int(^uint(0) >> 1)
if !ok || math.IsNaN(v) || math.IsInf(v, 0) || math.Trunc(v) != v || v > float64(maxInt) || v < float64(-maxInt-1) {
return nil, errors.New("extra_body.google.thinking_config.thinking_budget must be an integer")
}
budgetInt := int(v)
tempThinkingConfig.ThinkingBudget = kitutil.GetPointer(budgetInt)
hasThinkingConfig = true
}
if includeThoughts, exists := thinkingConfig["include_thoughts"]; exists {
if v, ok := includeThoughts.(bool); ok {
tempThinkingConfig.IncludeThoughts = kitutil.GetPointer(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 {
geminiRequest.GenerationConfig.ThinkingConfig = &tempThinkingConfig
}
}
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]any); 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]any)
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 err := sharedgemini.ApplyThinkingConfig(&geminiRequest, info, textRequest); err != nil {
return nil, reasoning.AsClientError(err)
}
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))
for _, tool := range textRequest.Tools {
if tool.Function.Parameters != nil {
if params, ok := tool.Function.Parameters.(map[string]any); ok {
if props, hasProps := params["properties"].(map[string]any); 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 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]any
contentStr := message.StringContent()
if err := kitutil.Unmarshal([]byte(contentStr), &contentMap); err != nil {
var contentSlice []any
if err := kitutil.Unmarshal([]byte(contentStr), &contentSlice); err == nil {
contentMap = map[string]any{"result": contentSlice}
} else {
contentMap = map[string]any{"content": contentStr}
}
}
functionResp := &dto.GeminiFunctionResponse{
Name: name,
Response: contentMap,
}
if message.ToolCallId != "" {
id, err := kitutil.Marshal(message.ToolCallId)
if err != nil {
return nil, fmt.Errorf("failed to marshal function response ID: %w", err)
}
functionResp.ID = id
}
*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]any{}
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{
ID: call.ID,
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,558 @@
package oaichat
import (
"fmt"
"sort"
"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"
)
type ChatToGeminiStreamState struct {
toolsByChoice map[int][]*chatToGeminiStreamTool
toolByIndex map[chatToGeminiStreamToolKey]*chatToGeminiStreamTool
toolByID map[chatToGeminiStreamToolIDKey]*chatToGeminiStreamTool
finishedChoices map[int]bool
seenChoices map[int]bool
usage *dto.Usage
usageEmitted bool
finalized bool
}
type chatToGeminiStreamToolKey struct {
ChoiceIndex int
ToolIndex int
}
type chatToGeminiStreamToolIDKey struct {
ChoiceIndex int
ID string
}
type chatToGeminiStreamTool struct {
ID string
Name string
Arguments strings.Builder
Emitted bool
}
func NewChatToGeminiStreamState() *ChatToGeminiStreamState {
return &ChatToGeminiStreamState{
toolsByChoice: make(map[int][]*chatToGeminiStreamTool),
toolByIndex: make(map[chatToGeminiStreamToolKey]*chatToGeminiStreamTool),
toolByID: make(map[chatToGeminiStreamToolIDKey]*chatToGeminiStreamTool),
finishedChoices: make(map[int]bool),
seenChoices: make(map[int]bool),
}
}
// 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 {
part := dto.GeminiPart{
FunctionCall: &dto.FunctionCall{
ID: toolCall.ID,
FunctionName: toolCall.Function.Name,
Arguments: geminiFunctionArguments(toolCall.Function.Arguments),
},
}
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 {
part := dto.GeminiPart{
FunctionCall: &dto.FunctionCall{
ID: toolCall.ID,
FunctionName: toolCall.Function.Name,
Arguments: geminiFunctionArguments(toolCall.Function.Arguments),
},
}
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
}
// ConvertChunk accumulates OpenAI tool-call deltas until their choice ends.
// Gemini functionCall parts are atomic, so emitting each OpenAI arguments
// fragment as a separate part would create duplicate calls with invalid input.
func (s *ChatToGeminiStreamState) ConvertChunk(openAIResponse *dto.ChatCompletionsStreamResponse, info convmeta.Meta) ([]*dto.GeminiChatResponse, error) {
if openAIResponse == nil {
return nil, nil
}
if s == nil {
return nil, fmt.Errorf("OpenAI chat to Gemini stream state is required")
}
if s.finalized {
return nil, fmt.Errorf("OpenAI chat to Gemini stream received data after finalization")
}
if s.toolsByChoice == nil {
s.toolsByChoice = make(map[int][]*chatToGeminiStreamTool)
}
if s.toolByIndex == nil {
s.toolByIndex = make(map[chatToGeminiStreamToolKey]*chatToGeminiStreamTool)
}
if s.toolByID == nil {
s.toolByID = make(map[chatToGeminiStreamToolIDKey]*chatToGeminiStreamTool)
}
if s.finishedChoices == nil {
s.finishedChoices = make(map[int]bool)
}
if s.seenChoices == nil {
s.seenChoices = make(map[int]bool)
}
if openAIResponse.Usage != nil {
s.usage = UsageFromChatUsage(openAIResponse.Usage)
}
candidates := make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices))
for _, choice := range openAIResponse.Choices {
s.seenChoices[choice.Index] = true
hasText := choice.Delta.GetContentString() != ""
hasToolDelta := len(choice.Delta.ToolCalls) > 0
hasFinish := choice.FinishReason != nil && strings.TrimSpace(*choice.FinishReason) != ""
if s.finishedChoices[choice.Index] {
if hasText || hasToolDelta {
return nil, fmt.Errorf("OpenAI chat choice %d received data after completion", choice.Index)
}
continue
}
for position, toolCall := range choice.Delta.ToolCalls {
if toolCall.Index == nil {
toolCall.SetIndex(position)
}
if err := s.appendToolCallDelta(choice.Index, toolCall); err != nil {
return nil, err
}
}
candidate := dto.GeminiChatCandidate{
Index: int64(choice.Index),
SafetyRatings: []dto.GeminiChatSafetyRating{},
Content: dto.GeminiChatContent{
Role: "model",
Parts: make([]dto.GeminiPart, 0),
},
}
if hasText {
candidate.Content.Parts = append(candidate.Content.Parts, dto.GeminiPart{Text: choice.Delta.GetContentString()})
}
if hasFinish {
parts, err := s.finishChoice(choice.Index)
if err != nil {
return nil, err
}
candidate.Content.Parts = append(candidate.Content.Parts, parts...)
finishReason := geminiFinishReason(*choice.FinishReason)
candidate.FinishReason = &finishReason
s.finishedChoices[choice.Index] = true
}
if len(candidate.Content.Parts) > 0 || candidate.FinishReason != nil {
candidates = append(candidates, candidate)
}
}
if len(candidates) == 0 {
if openAIResponse.Usage != nil && len(s.finishedChoices) > 0 {
s.usageEmitted = true
return []*dto.GeminiChatResponse{newGeminiStreamResponse(nil, s.usage, info)}, nil
}
return nil, nil
}
if openAIResponse.Usage != nil {
s.usageEmitted = true
}
return []*dto.GeminiChatResponse{newGeminiStreamResponse(candidates, openAIResponse.Usage, info)}, nil
}
// Finalize emits any calls left pending when an upstream closes without a
// finish-reason chunk. Calling Finalize more than once is safe.
func (s *ChatToGeminiStreamState) Finalize(info convmeta.Meta) ([]*dto.GeminiChatResponse, error) {
if s == nil || s.finalized {
return nil, nil
}
choiceIndexes := make(map[int]struct{})
for choiceIndex, tools := range s.toolsByChoice {
for _, tool := range tools {
if !tool.Emitted {
choiceIndexes[choiceIndex] = struct{}{}
break
}
}
}
for choiceIndex := range s.seenChoices {
if !s.finishedChoices[choiceIndex] {
choiceIndexes[choiceIndex] = struct{}{}
}
}
orderedChoices := make([]int, 0, len(choiceIndexes))
for choiceIndex := range choiceIndexes {
orderedChoices = append(orderedChoices, choiceIndex)
}
sort.Ints(orderedChoices)
candidates := make([]dto.GeminiChatCandidate, 0, len(orderedChoices))
for _, choiceIndex := range orderedChoices {
parts, err := s.finishChoice(choiceIndex)
if err != nil {
return nil, err
}
finishReason := "STOP"
candidates = append(candidates, dto.GeminiChatCandidate{
Index: int64(choiceIndex),
FinishReason: &finishReason,
SafetyRatings: []dto.GeminiChatSafetyRating{},
Content: dto.GeminiChatContent{
Role: "model",
Parts: parts,
},
})
}
if len(candidates) == 0 {
s.finalized = true
if s.usage == nil || s.usageEmitted {
return nil, nil
}
s.usageEmitted = true
return []*dto.GeminiChatResponse{newGeminiStreamResponse(nil, s.usage, info)}, nil
}
s.finalized = true
s.usageEmitted = s.usage != nil
return []*dto.GeminiChatResponse{newGeminiStreamResponse(candidates, s.usage, info)}, nil
}
func (s *ChatToGeminiStreamState) Usage() *dto.Usage {
if s == nil || s.usage == nil {
return nil
}
return UsageFromChatUsage(s.usage)
}
func (s *ChatToGeminiStreamState) SetUsage(usage *dto.Usage) {
if s == nil || usage == nil {
return
}
s.usage = UsageFromChatUsage(usage)
}
func (s *ChatToGeminiStreamState) StreamUsage() *dto.Usage {
return s.Usage()
}
func (s *ChatToGeminiStreamState) SetStreamUsage(usage *dto.Usage) {
s.SetUsage(usage)
}
func (s *ChatToGeminiStreamState) appendToolCallDelta(choiceIndex int, toolCall dto.ToolCallResponse) error {
toolIndex := 0
if toolCall.Index != nil {
toolIndex = *toolCall.Index
}
if toolIndex < 0 {
return fmt.Errorf("OpenAI chat choice %d has negative tool-call index %d", choiceIndex, toolIndex)
}
key := chatToGeminiStreamToolKey{ChoiceIndex: choiceIndex, ToolIndex: toolIndex}
incomingID := strings.TrimSpace(toolCall.ID)
var tool *chatToGeminiStreamTool
if incomingID != "" {
tool = s.toolByID[chatToGeminiStreamToolIDKey{ChoiceIndex: choiceIndex, ID: incomingID}]
}
if tool == nil {
tool = s.toolByIndex[key]
}
if tool != nil && incomingID != "" && tool.ID != "" && tool.ID != incomingID {
tool = nil
}
if tool == nil {
tool = &chatToGeminiStreamTool{}
s.toolsByChoice[choiceIndex] = append(s.toolsByChoice[choiceIndex], tool)
}
s.toolByIndex[key] = tool
// Compatibility gateways may reset a source index for the next occurrence.
// Once identity changes, keep the new occurrence active for later metadata-free deltas.
if tool.Emitted {
return fmt.Errorf("OpenAI chat choice %d tool-call index %d received data after completion", choiceIndex, toolIndex)
}
if incomingID != "" {
if tool.ID != "" && tool.ID != incomingID {
return fmt.Errorf("OpenAI chat choice %d tool-call index %d changed id from %q to %q", choiceIndex, toolIndex, tool.ID, incomingID)
}
tool.ID = incomingID
s.toolByID[chatToGeminiStreamToolIDKey{ChoiceIndex: choiceIndex, ID: incomingID}] = tool
}
incomingName := strings.TrimSpace(toolCall.Function.Name)
if incomingName != "" {
if tool.Name != "" && tool.Name != incomingName {
return fmt.Errorf("OpenAI chat choice %d tool-call index %d changed name from %q to %q", choiceIndex, toolIndex, tool.Name, incomingName)
}
tool.Name = incomingName
}
tool.Arguments.WriteString(toolCall.Function.Arguments)
return nil
}
func (s *ChatToGeminiStreamState) finishChoice(choiceIndex int) ([]dto.GeminiPart, error) {
tools := s.toolsByChoice[choiceIndex]
pending := make([]*chatToGeminiStreamTool, 0, len(tools))
for _, tool := range tools {
if !tool.Emitted {
pending = append(pending, tool)
}
}
parts := make([]dto.GeminiPart, 0, len(pending))
for _, tool := range pending {
if tool.Name == "" {
return nil, fmt.Errorf("OpenAI chat choice %d has a tool call without a function name", choiceIndex)
}
parts = append(parts, dto.GeminiPart{FunctionCall: &dto.FunctionCall{
ID: tool.ID,
FunctionName: tool.Name,
Arguments: geminiFunctionArguments(tool.Arguments.String()),
}})
}
for _, tool := range pending {
tool.Emitted = true
}
return parts, nil
}
func newGeminiStreamResponse(candidates []dto.GeminiChatCandidate, usage *dto.Usage, info convmeta.Meta) *dto.GeminiChatResponse {
if candidates == nil {
candidates = make([]dto.GeminiChatCandidate, 0)
}
estimatePromptTokens := 0
if info != nil {
estimatePromptTokens = info.GetEstimatePromptTokens()
}
response := &dto.GeminiChatResponse{
Candidates: candidates,
HasUsageMetadata: true,
UsageMetadata: dto.GeminiUsageMetadata{
PromptTokenCount: estimatePromptTokens,
TotalTokenCount: estimatePromptTokens,
},
}
if usage == nil {
return response
}
response.UsageMetadata.PromptTokenCount = usage.PromptTokens
response.UsageMetadata.CandidatesTokenCount = usage.CompletionTokens
response.UsageMetadata.TotalTokenCount = usage.TotalTokens
response.UsageMetadata.BillingUsage = openAIBillingUsageFromUsage(usage)
if metadata, ok := geminiBillingMetadataFromOpenAIUsage(usage); ok {
response.UsageMetadata = metadata
}
return response
}
func geminiFunctionArguments(raw string) map[string]any {
if strings.TrimSpace(raw) == "" || strings.TrimSpace(raw) == "null" {
return map[string]any{}
}
var args map[string]any
if err := kitutil.Unmarshal([]byte(raw), &args); err == nil && args != nil {
return args
}
// Preserve historically accepted malformed/non-object input without
// emitting a non-object Gemini args value.
return map[string]any{"arguments": raw}
}
func geminiFinishReason(finishReason string) string {
switch strings.TrimSpace(finishReason) {
case "length":
return "MAX_TOKENS"
case "content_filter":
return "SAFETY"
default:
return "STOP"
}
}
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
}
metadata := *billingUsage.GeminiUsageMetadata
// Restore the sidecar marker on the restored native payload so the next
// hop keeps settling on the original dialect (including Estimated).
metadata.BillingUsage = dto.CloneBillingUsage(usage.BillingUsage)
return metadata, true
}
func openAIBillingUsageFromUsage(usage *dto.Usage) *dto.BillingUsage {
if usage == nil {
return nil
}
// An existing sidecar snapshots the original provider usage; carry it
// across this bridge unchanged regardless of its dialect. Only synthesize
// an OpenAI snapshot when no sidecar exists yet.
if existingBillingUsage := dto.CloneBillingUsage(usage.BillingUsage); existingBillingUsage != nil {
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]any{"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]any{"q": "x"}, resp.Candidates[0].Content.Parts[0].FunctionCall.Arguments)
}
func geminiRespPtr[T any](value T) *T {
return &value
}
@@ -0,0 +1,424 @@
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/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"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))
if req.MaxCompletionTokens != nil {
maxOutputTokens = *req.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))
}
var frequencyPenaltyRaw, presencePenaltyRaw json.RawMessage
if req.FrequencyPenalty != nil {
frequencyPenaltyRaw, _ = kitutil.Marshal(req.FrequencyPenalty)
}
if req.PresencePenalty != nil {
presencePenaltyRaw, _ = kitutil.Marshal(req.PresencePenalty)
}
var promptCacheKeyRaw json.RawMessage
if req.PromptCacheKey != "" {
promptCacheKeyRaw, err = kitutil.Marshal(req.PromptCacheKey)
if err != nil {
return nil, fmt.Errorf("marshal prompt_cache_key: %w", err)
}
}
out := &dto.OpenAIResponsesRequest{
Model: req.Model,
Input: inputRaw,
Instructions: instructionsRaw,
Stream: req.Stream,
Temperature: req.Temperature,
Text: textRaw,
ToolChoice: toolChoiceRaw,
Tools: toolsRaw,
TopP: topP,
FrequencyPenalty: frequencyPenaltyRaw,
PresencePenalty: presencePenaltyRaw,
User: req.User,
ParallelToolCalls: parallelToolCallsRaw,
Store: req.Store,
Metadata: req.Metadata,
PromptCacheKey: promptCacheKeyRaw,
EnableThinking: req.EnableThinking,
ThinkingBudget: req.ThinkingBudget,
}
if req.MaxTokens != nil || req.MaxCompletionTokens != nil {
out.MaxOutputTokens = lo.ToPtr(maxOutputTokens)
}
reasoningIntent, err := reasoning.FromOpenAIChat(req)
if err != nil {
return nil, reasoning.AsClientError(err)
}
if err := reasoning.ApplyToOpenAIResponses(out, reasoningIntent); err != nil {
return nil, reasoning.AsClientError(err)
}
return out, nil
}
@@ -0,0 +1,177 @@
package oaichat
import (
"encoding/json"
"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 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 TestChatCompletionsRequestToResponsesRequestPreservesPromptCacheKey(t *testing.T) {
t.Run("present", func(t *testing.T) {
key := "session-\"quoted\"\\path\n世界"
got, err := ChatCompletionsRequestToResponsesRequest(&dto.GeneralOpenAIRequest{
Model: "gpt-test",
Messages: []dto.Message{{Role: "user", Content: "hello"}},
PromptCacheKey: key,
})
require.NoError(t, err)
keyRaw, err := kitutil.Marshal(key)
require.NoError(t, err)
assert.Equal(t, keyRaw, []byte(got.PromptCacheKey))
encoded, err := kitutil.Marshal(got)
require.NoError(t, err)
assert.Equal(t, key, gjson.GetBytes(encoded, "prompt_cache_key").String())
})
t.Run("absent", func(t *testing.T) {
got, err := ChatCompletionsRequestToResponsesRequest(&dto.GeneralOpenAIRequest{
Model: "gpt-test",
Messages: []dto.Message{{Role: "user", Content: "hello"}},
})
require.NoError(t, err)
encoded, err := kitutil.Marshal(got)
require.NoError(t, err)
assert.False(t, gjson.GetBytes(encoded, "prompt_cache_key").Exists())
})
}
func TestChatCompletionsRequestToResponsesRequestPreservesQwenThinkingBudget(t *testing.T) {
tests := []struct {
name string
budget json.RawMessage
want int64
}{
{name: "positive budget", budget: json.RawMessage(`128`), want: 128},
{name: "zero budget", budget: json.RawMessage(`0`), want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := &dto.GeneralOpenAIRequest{
Model: "qwen-plus",
EnableThinking: json.RawMessage(`true`),
ThinkingBudget: tt.budget,
Messages: []dto.Message{
{Role: "user", Content: "hello"},
},
}
got, err := ChatCompletionsRequestToResponsesRequest(req)
require.NoError(t, err)
assert.Equal(t, tt.budget, got.ThinkingBudget)
encoded, err := kitutil.Marshal(got)
require.NoError(t, err)
assert.True(t, gjson.GetBytes(encoded, "enable_thinking").Bool())
value := gjson.GetBytes(encoded, "thinking_budget")
assert.True(t, value.Exists())
assert.Equal(t, tt.want, value.Int())
})
}
}
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 TestChatCompletionsRequestToResponsesRequestPreservesPenalties(t *testing.T) {
tests := []struct {
name string
frequency *float64
frequencyWant json.RawMessage
presence *float64
presenceWant json.RawMessage
}{
{
name: "positive values",
frequency: lo.ToPtr(0.5),
frequencyWant: json.RawMessage(`0.5`),
presence: lo.ToPtr(1.5),
presenceWant: json.RawMessage(`1.5`),
},
{
name: "explicit zero values",
frequency: lo.ToPtr(0.0),
frequencyWant: json.RawMessage(`0`),
presence: lo.ToPtr(0.0),
presenceWant: json.RawMessage(`0`),
},
{
name: "unset stays nil",
frequency: nil,
presence: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ChatCompletionsRequestToResponsesRequest(&dto.GeneralOpenAIRequest{
Model: "gpt-test",
Messages: []dto.Message{{Role: "user", Content: "hello"}},
FrequencyPenalty: tt.frequency,
PresencePenalty: tt.presence,
})
require.NoError(t, err)
assert.Equal(t, tt.frequencyWant, got.FrequencyPenalty)
assert.Equal(t, tt.presenceWant, got.PresencePenalty)
})
}
}
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,269 @@
package oaichat
import (
"errors"
"fmt"
"maps"
"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"
responsesEventOutputTextAnnotationAdded = "response.output_text.annotation.added"
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 reasoning := choice.Message.GetReasoningContent(); reasoning != "" {
out.Output = append(out.Output, dto.ResponsesOutput{
Type: responsesOutputTypeReasoning,
ID: fmt.Sprintf("%s_reasoning_0", id),
Status: responseOutputStatus(out),
Summary: []dto.ResponsesReasoningSummaryPart{
{
Type: "summary_text",
Text: reasoning,
},
},
})
}
if text := choice.Message.StringContent(); text != "" {
annotations, err := chatAnnotationsToResponses(choice.Message.Annotations)
if err != nil {
return nil, nil, err
}
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: annotations,
},
},
})
}
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 chatAnnotationsToResponses(raw []byte) ([]any, error) {
if len(raw) == 0 {
return []any{}, nil
}
var annotations []map[string]any
if err := kitutil.Unmarshal(raw, &annotations); err != nil {
return nil, fmt.Errorf("invalid Chat annotations: %w", err)
}
converted := make([]any, 0, len(annotations))
for _, annotation := range annotations {
if strings.TrimSpace(kitutil.Interface2String(annotation["type"])) != "url_citation" {
converted = append(converted, annotation)
continue
}
citation, ok := annotation["url_citation"].(map[string]any)
if !ok {
converted = append(converted, annotation)
continue
}
flattened := make(map[string]any, len(citation)+1)
flattened["type"] = "url_citation"
maps.Copy(flattened, citation)
converted = append(converted, flattened)
}
return converted, 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
}
func stringPtr(v string) *string {
return &v
}
@@ -0,0 +1,161 @@
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 TestChatCompletionsResponseToResponsesEmitsReasoningSummaryBeforeText(t *testing.T) {
message := dto.Message{Role: "assistant", Content: "final answer"}
message.ReasoningContent = lo.ToPtr("thinking summary")
resp, _, err := ChatCompletionsResponseToResponsesResponse(&dto.OpenAITextResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.OpenAITextResponseChoice{
{Message: message, FinishReason: "stop"},
},
}, "resp_1")
require.NoError(t, err)
require.Len(t, resp.Output, 2)
assert.Equal(t, responsesOutputTypeReasoning, resp.Output[0].Type)
require.Len(t, resp.Output[0].Summary, 1)
assert.Equal(t, "thinking summary", resp.Output[0].Summary[0].Text)
assert.Empty(t, resp.Output[0].Content)
assert.Equal(t, responsesOutputTypeMessage, resp.Output[1].Type)
assert.Equal(t, "final answer", resp.Output[1].Content[0].Text)
}
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,862 @@
package oaichat
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
type ChatToResponsesStreamEvent struct {
Type string
Payload dto.ResponsesStreamResponse
}
type ChatToResponsesStreamState struct {
ID string
Model string
Created int64
Usage *dto.Usage
// EmitSequenceNumber enables the required sequence_number field for current
// Responses API SSE consumers while preserving the legacy relaykit default.
EmitSequenceNumber bool
status string
incompleteDetails *dto.IncompleteDetails
sentCreated bool
textOutputIndex int
textStarted bool
textDone bool
reasoningIndex int
reasoningStarted bool
reasoningDone bool
finalized bool
nextSequenceNumber int
nextOutputIndex int
toolsByIndex map[int]*chatToResponsesStreamTool
hostedByID map[string]*chatToResponsesHostedTool
outputOrder []chatToResponsesOutputRef
text strings.Builder
annotations []any
reasoning strings.Builder
}
type chatToResponsesStreamTool struct {
ChatIndex int
OutputIndex int
ItemID string
CallID string
Name string
Arguments strings.Builder
Done bool
}
type chatToResponsesOutputRef struct {
Kind string
ToolIndex int
HostedID string
}
// HostedToolStreamStart describes a provider-hosted tool call that is already
// being executed upstream. It is intentionally separate from function calls:
// hosted calls have their own Responses lifecycle and result fields.
type HostedToolStreamStart struct {
Type string
ID string
Name string
Action []byte
Caller []byte
ServerLabel string
}
// HostedToolStreamResult completes a previously started hosted tool call.
type HostedToolStreamResult struct {
Type string
ID string
Result []byte
ErrorCode string
IsError bool
}
type chatToResponsesHostedTool struct {
OutputIndex int
Output dto.ResponsesOutput
Done bool
}
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),
hostedByID: make(map[string]*chatToResponsesHostedTool),
}
}
func (s *ChatToResponsesStreamState) StreamUsage() *dto.Usage {
if s == nil {
return nil
}
return s.Usage
}
func (s *ChatToResponsesStreamState) SetStreamUsage(usage *dto.Usage) {
if s != nil && usage != nil {
s.Usage = UsageFromChatUsage(usage)
}
}
func (s *ChatToResponsesStreamState) StartHostedTool(start HostedToolStreamStart) ([]ChatToResponsesStreamEvent, error) {
if s == nil {
return nil, fmt.Errorf("Chat-to-Responses stream state is required")
}
start.ID = strings.TrimSpace(start.ID)
if start.ID == "" {
return nil, fmt.Errorf("hosted-tool stream call is missing an id")
}
if _, exists := s.hostedByID[start.ID]; exists {
return nil, fmt.Errorf("duplicate hosted-tool stream call id %q", start.ID)
}
if hostedEventPrefix(start.Type) == "" {
return nil, fmt.Errorf("unsupported Responses hosted-tool output type %q", start.Type)
}
caller := strings.TrimSpace(string(start.Caller))
if caller != "" && caller != "null" {
return nil, fmt.Errorf("Responses %s cannot preserve Claude hosted-tool caller provenance", start.Type)
}
tool := &chatToResponsesHostedTool{
Output: dto.ResponsesOutput{
Type: start.Type,
ID: start.ID,
Status: "in_progress",
},
}
switch start.Type {
case "web_search_call":
action, err := dto.NormalizeResponsesWebSearchAction(start.Action)
if err != nil {
return nil, err
}
tool.Output.Action = action
case "code_interpreter_call":
return nil, fmt.Errorf("cannot map provider code execution to Responses code_interpreter_call without a container_id")
case "mcp_call":
if strings.TrimSpace(start.Name) == "" || strings.TrimSpace(start.ServerLabel) == "" {
return nil, fmt.Errorf("Responses MCP call requires name and server_label")
}
arguments, err := hostedJSONString(start.Action)
if err != nil {
return nil, fmt.Errorf("encode Responses MCP arguments: %w", err)
}
tool.Output.Name = start.Name
tool.Output.ServerLabel = start.ServerLabel
tool.Output.Arguments = arguments
}
outputIndex := s.nextHostedIndex(start.ID)
tool.OutputIndex = outputIndex
s.hostedByID[start.ID] = tool
events := s.ensureCreated()
addedItem := cloneHostedOutput(&tool.Output)
if start.Type == "mcp_call" {
addedItem.Arguments = json.RawMessage(`""`)
}
events = append(events,
s.event(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
OutputIndex: intPtr(outputIndex),
ItemID: start.ID,
Item: addedItem,
}),
s.event(hostedEventPrefix(start.Type)+".in_progress", dto.ResponsesStreamResponse{
OutputIndex: intPtr(outputIndex),
ItemID: start.ID,
}),
)
if start.Type == "web_search_call" {
events = append(events, s.event(hostedEventPrefix(start.Type)+".searching", dto.ResponsesStreamResponse{
OutputIndex: intPtr(outputIndex),
ItemID: start.ID,
}))
}
if start.Type == "mcp_call" {
arguments := dto.ResponsesArgumentsString(tool.Output.Arguments)
events = append(events,
s.event("response.mcp_call_arguments.delta", dto.ResponsesStreamResponse{
OutputIndex: intPtr(outputIndex),
ItemID: start.ID,
Delta: arguments,
}),
s.event("response.mcp_call_arguments.done", dto.ResponsesStreamResponse{
OutputIndex: intPtr(outputIndex),
ItemID: start.ID,
Arguments: kitutil.GetPointer(arguments),
}),
)
}
return events, nil
}
func (s *ChatToResponsesStreamState) CompleteHostedTool(result HostedToolStreamResult) ([]ChatToResponsesStreamEvent, error) {
if s == nil {
return nil, fmt.Errorf("Chat-to-Responses stream state is required")
}
result.ID = strings.TrimSpace(result.ID)
tool := s.hostedByID[result.ID]
if tool == nil {
return nil, fmt.Errorf("hosted-tool result references unknown call %q", result.ID)
}
if tool.Done {
return nil, fmt.Errorf("duplicate hosted-tool result for call %q", result.ID)
}
if result.Type != "" && result.Type != tool.Output.Type {
return nil, fmt.Errorf("hosted-tool result type %q does not match call type %q", result.Type, tool.Output.Type)
}
failed := result.IsError || strings.TrimSpace(result.ErrorCode) != ""
tool.Output.Status = "completed"
switch tool.Output.Type {
case "web_search_call":
// Responses exposes only the action and lifecycle status on a
// web_search_call. Claude's opaque result payload cannot be emitted
// as a top-level `results` field.
case "code_interpreter_call":
return nil, fmt.Errorf("Responses code_interpreter_call is not supported without a container_id")
case "mcp_call":
output, err := hostedResultString(result.Result)
if err != nil {
return nil, fmt.Errorf("encode Responses MCP output: %w", err)
}
tool.Output.Output = output
}
if failed {
tool.Output.Status = "failed"
errorValue := result.ErrorCode
if errorValue == "" {
errorValue = "hosted tool execution failed"
}
if tool.Output.Type == "mcp_call" {
encoded, err := kitutil.Marshal(errorValue)
if err != nil {
return nil, fmt.Errorf("marshal hosted-tool error: %w", err)
}
tool.Output.ItemError = encoded
tool.Output.Output = nil
}
}
tool.Done = true
events := make([]ChatToResponsesStreamEvent, 0, 2)
if eventType := hostedTerminalEvent(tool.Output.Type, failed); eventType != "" {
events = append(events, s.event(eventType, dto.ResponsesStreamResponse{
OutputIndex: intPtr(tool.OutputIndex),
ItemID: result.ID,
}))
}
events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
OutputIndex: intPtr(tool.OutputIndex),
ItemID: result.ID,
Item: cloneHostedOutput(&tool.Output),
}))
return events, nil
}
// Fail emits a terminal Responses error using the same event allocator as the
// rest of the stream, so callers never have to append a JSON HTTP error to an
// already-started SSE response.
func (s *ChatToResponsesStreamState) Fail(code string, message string, param string) []ChatToResponsesStreamEvent {
if s == nil || s.finalized {
return nil
}
code = strings.TrimSpace(code)
if code == "" {
code = "server_error"
}
message = strings.TrimSpace(message)
if message == "" {
message = "upstream response stream failed"
}
s.status = "failed"
events := s.ensureCreated()
events = append(events, s.doneDeltaEvents()...)
s.finalized = true
events = append(events, s.event("error", dto.ResponsesStreamResponse{
Code: code,
Message: message,
Param: param,
}))
response := s.finalResponse()
response.Error = map[string]any{
"code": code,
"message": message,
}
events = append(events, s.event("response.failed", dto.ResponsesStreamResponse{
Response: response,
}))
return events
}
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 := state.ensureCreated()
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())...)
}
if len(choice.Delta.Annotations) > 0 {
annotationEvents, err := state.appendAnnotationDelta(choice.Delta.Annotations)
if err != nil {
return nil, err
}
events = append(events, annotationEvents...)
}
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 (s *ChatToResponsesStreamState) ensureCreated() []ChatToResponsesStreamEvent {
if s.sentCreated {
return nil
}
s.sentCreated = true
return []ChatToResponsesStreamEvent{s.event(responsesEventCreated, dto.ResponsesStreamResponse{
Type: responsesEventCreated,
Response: s.createdResponse(),
})}
}
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, state.event(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 := s.startText()
s.text.WriteString(delta)
events = append(events, s.event(responsesEventOutputTextDelta, dto.ResponsesStreamResponse{
Type: responsesEventOutputTextDelta,
OutputIndex: intPtr(s.textOutputIndex),
ContentIndex: intPtr(0),
Delta: delta,
ItemID: s.messageID(),
}))
return events
}
func (s *ChatToResponsesStreamState) startText() []ChatToResponsesStreamEvent {
if !s.textStarted {
s.textStarted = true
s.textOutputIndex = s.nextIndex("message", -1)
return []ChatToResponsesStreamEvent{s.event(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{},
},
})}
}
return nil
}
func (s *ChatToResponsesStreamState) appendAnnotationDelta(raw []byte) ([]ChatToResponsesStreamEvent, error) {
annotations, err := chatAnnotationsToResponses(raw)
if err != nil {
return nil, err
}
events := s.startText()
for _, annotation := range annotations {
annotationJSON, err := kitutil.Marshal(annotation)
if err != nil {
return nil, fmt.Errorf("marshal Responses annotation: %w", err)
}
annotationIndex := len(s.annotations)
s.annotations = append(s.annotations, annotation)
events = append(events, s.event(responsesEventOutputTextAnnotationAdded, dto.ResponsesStreamResponse{
Type: responsesEventOutputTextAnnotationAdded,
OutputIndex: intPtr(s.textOutputIndex),
ContentIndex: intPtr(0),
AnnotationIndex: intPtr(annotationIndex),
Annotation: annotationJSON,
ItemID: s.messageID(),
}))
}
return events, nil
}
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, s.event(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: intPtr(s.reasoningIndex),
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeReasoning,
ID: s.reasoningID(),
Status: "in_progress",
Summary: []dto.ResponsesReasoningSummaryPart{},
},
}))
}
s.reasoning.WriteString(delta)
events = append(events, s.event(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
}
incomingID := strings.TrimSpace(toolCall.ID)
tool := s.toolsByIndex[chatIndex]
events := make([]ChatToResponsesStreamEvent, 0, 2)
if tool == nil {
tool = &chatToResponsesStreamTool{
ChatIndex: chatIndex,
OutputIndex: s.nextIndex("tool", chatIndex),
CallID: incomingID,
Name: strings.TrimSpace(toolCall.Function.Name),
}
tool.ItemID = incomingID
if tool.ItemID == "" {
tool.ItemID = fmt.Sprintf("%s_call_%d", s.ID, chatIndex)
}
s.toolsByIndex[chatIndex] = tool
events = append(events, s.event(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.ItemID,
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: tool.ItemID,
Status: "in_progress",
CallId: tool.callID(),
Name: tool.Name,
Arguments: []byte(`""`),
},
}))
}
if tool.Done {
return nil, fmt.Errorf("tool-call stream index %d received data after completion", chatIndex)
}
if incomingID != "" {
if tool.CallID != "" && tool.CallID != incomingID {
return nil, fmt.Errorf("tool-call stream index %d changed id from %q to %q", chatIndex, tool.CallID, incomingID)
}
tool.CallID = incomingID
}
incomingName := strings.TrimSpace(toolCall.Function.Name)
if incomingName != "" {
if tool.Name != "" && tool.Name != incomingName {
return nil, fmt.Errorf("tool-call stream index %d changed name from %q to %q", chatIndex, tool.Name, incomingName)
}
tool.Name = incomingName
}
if toolCall.Function.Arguments != "" {
tool.Arguments.WriteString(toolCall.Function.Arguments)
events = append(events, s.event(responsesEventFunctionArgsDelta, dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDelta,
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.ItemID,
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
textDone := dto.ResponsesStreamResponse{
Type: "response.output_text.done",
OutputIndex: intPtr(s.textOutputIndex),
ContentIndex: intPtr(0),
ItemID: s.messageID(),
}
if s.EmitSequenceNumber {
textDone.Text = kitutil.GetPointer(s.text.String())
}
events = append(events, s.event("response.output_text.done", textDone))
events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemDone,
OutputIndex: intPtr(s.textOutputIndex),
Item: s.messageOutput(status),
}))
}
if s.reasoningStarted && !s.reasoningDone {
s.reasoningDone = true
reasoningDone := dto.ResponsesStreamResponse{
Type: responsesEventReasoningSummaryDone,
OutputIndex: intPtr(s.reasoningIndex),
SummaryIndex: intPtr(0),
ItemID: s.reasoningID(),
Part: &dto.ResponsesReasoningSummaryPart{
Type: "summary_text",
Text: s.reasoning.String(),
},
}
if s.EmitSequenceNumber {
reasoningDone.Text = kitutil.GetPointer(s.reasoning.String())
reasoningDone.Part = nil
}
events = append(events, s.event(responsesEventReasoningSummaryDone, reasoningDone))
events = append(events, s.event(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
argumentsDone := dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDone,
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.ItemID,
}
if s.EmitSequenceNumber {
argumentsDone.Arguments = kitutil.GetPointer(tool.Arguments.String())
argumentsDone.Name = tool.Name
}
events = append(events, s.event(responsesEventFunctionArgsDone, argumentsDone))
events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemDone,
OutputIndex: intPtr(tool.OutputIndex),
Item: s.toolOutput(tool, status),
}))
}
for _, ref := range s.outputOrder {
if ref.Kind != "hosted" {
continue
}
tool := s.hostedByID[ref.HostedID]
if tool == nil || tool.Done {
continue
}
if s.status != "failed" {
s.status = "incomplete"
}
tool.Done = true
tool.Output.Status = "incomplete"
if s.status == "failed" {
tool.Output.Status = "failed"
errorValue, err := kitutil.Marshal("provider stream failed before hosted-tool result")
if err == nil && tool.Output.Type == "mcp_call" {
tool.Output.ItemError = errorValue
tool.Output.Output = nil
}
if eventType := hostedTerminalEvent(tool.Output.Type, true); eventType != "" {
events = append(events, s.event(eventType, dto.ResponsesStreamResponse{
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.Output.ID,
}))
}
}
events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.Output.ID,
Item: cloneHostedOutput(&tool.Output),
}))
}
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))
}
case "hosted":
if tool := s.hostedByID[ref.HostedID]; tool != nil {
output = append(output, *cloneHostedOutput(&tool.Output))
}
}
}
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) nextHostedIndex(id string) int {
index := s.nextOutputIndex
s.nextOutputIndex++
s.outputOrder = append(s.outputOrder, chatToResponsesOutputRef{Kind: "hosted", HostedID: id})
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" || s.status == "failed" {
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 {
annotations := s.annotations
if annotations == nil {
annotations = []any{}
}
return &dto.ResponsesOutput{
Type: responsesOutputTypeMessage,
ID: s.messageID(),
Status: status,
Role: "assistant",
Content: []dto.ResponsesOutputContent{
{
Type: "output_text",
Text: s.text.String(),
Annotations: annotations,
},
},
}
}
func (s *ChatToResponsesStreamState) reasoningOutput(status string) *dto.ResponsesOutput {
return &dto.ResponsesOutput{
Type: responsesOutputTypeReasoning,
ID: s.reasoningID(),
Status: status,
Summary: []dto.ResponsesReasoningSummaryPart{
{
Type: "summary_text",
Text: s.reasoning.String(),
},
},
}
}
func (s *ChatToResponsesStreamState) toolOutput(tool *chatToResponsesStreamTool, status string) *dto.ResponsesOutput {
return &dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: tool.ItemID,
Status: status,
CallId: tool.callID(),
Name: tool.Name,
Arguments: chatArgumentsRawMessage(tool.Arguments.String()),
}
}
func (t *chatToResponsesStreamTool) callID() string {
if t == nil {
return ""
}
if t.CallID == "" {
return t.ItemID
}
return t.CallID
}
func hostedEventPrefix(outputType string) string {
switch outputType {
case "web_search_call":
return "response.web_search_call"
case "mcp_call":
return "response.mcp_call"
default:
return ""
}
}
func hostedTerminalEvent(outputType string, failed bool) string {
prefix := hostedEventPrefix(outputType)
if prefix == "" {
return ""
}
if !failed {
return prefix + ".completed"
}
// OpenAI currently defines a dedicated failed lifecycle event for MCP.
// Web search and code interpreter surface failure on output_item.done.
if outputType == "mcp_call" {
return prefix + ".failed"
}
return ""
}
func hostedJSONString(value []byte) (json.RawMessage, error) {
if len(value) == 0 {
return json.RawMessage(`""`), nil
}
if !kitutil.Valid(value) {
return nil, fmt.Errorf("invalid JSON payload")
}
encoded, err := kitutil.Marshal(string(value))
if err != nil {
return nil, err
}
return encoded, nil
}
func hostedResultString(value []byte) (json.RawMessage, error) {
if len(value) == 0 {
return json.RawMessage(`""`), nil
}
if !kitutil.Valid(value) {
return nil, fmt.Errorf("invalid JSON payload")
}
if kitutil.GetJsonType(value) == "string" {
return append(json.RawMessage(nil), value...), nil
}
return hostedJSONString(value)
}
func cloneHostedOutput(output *dto.ResponsesOutput) *dto.ResponsesOutput {
if output == nil {
return nil
}
clone := *output
clone.Action = append([]byte(nil), output.Action...)
clone.Arguments = append([]byte(nil), output.Arguments...)
clone.Code = append([]byte(nil), output.Code...)
clone.Results = append([]byte(nil), output.Results...)
clone.Outputs = append([]byte(nil), output.Outputs...)
clone.Output = append([]byte(nil), output.Output...)
clone.ItemError = append([]byte(nil), output.ItemError...)
clone.Caller = append([]byte(nil), output.Caller...)
return &clone
}
func (s *ChatToResponsesStreamState) event(eventType string, payload dto.ResponsesStreamResponse) ChatToResponsesStreamEvent {
if s.EmitSequenceNumber {
sequenceNumber := s.nextSequenceNumber
s.nextSequenceNumber++
payload.SequenceNumber = &sequenceNumber
}
return responsesStreamEvent(eventType, payload)
}
@@ -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]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{"result": array}
}
return map[string]any{"content": typed}
case []any:
return map[string]any{"result": typed}
default:
return map[string]any{"content": typed}
}
}
func GeminiResponseMap(value any) map[string]any {
return responsesGeminiResponseMap(value)
}
func responsesParallelToolCalls(raw []byte) *bool {
if !rawJSONPresent(raw) || kitutil.GetJsonType(raw) != "boolean" {
return nil
}
var parallelToolCalls bool
if err := kitutil.Unmarshal(raw, &parallelToolCalls); err != nil {
return nil
}
return &parallelToolCalls
}
func ParallelToolCalls(raw []byte) *bool {
return responsesParallelToolCalls(raw)
}
func ContentPartToFileSource(part map[string]any) types.FileSource {
partType := strings.TrimSpace(kitutil.Interface2String(part["type"]))
var data string
var mimeType string
switch partType {
case "input_image":
data, mimeType = responsesPartDataAndMime(part, "image_url", "url")
case "input_file":
data, mimeType = responsesPartDataAndMime(part, "file", "file_data", "file_url", "url")
case "input_audio":
data, mimeType = responsesPartDataAndMime(part, "input_audio", "data", "url")
if mimeType == "" {
if payload, ok := part["input_audio"].(map[string]any); ok {
if format := strings.TrimSpace(kitutil.Interface2String(payload["format"])); format != "" {
mimeType = "audio/" + format
}
}
}
case "input_video":
data, mimeType = responsesPartDataAndMime(part, "video_url", "url")
}
if data == "" {
return nil
}
return types.NewFileSourceFromData(data, mimeType)
}
func responsesPartDataAndMime(part map[string]any, keys ...string) (string, string) {
mimeType := strings.TrimSpace(kitutil.Interface2String(part["mime_type"]))
for _, key := range keys {
value, ok := part[key]
if !ok {
continue
}
switch typed := value.(type) {
case string:
if typed != "" {
return typed, mimeType
}
case map[string]any:
if mimeType == "" {
mimeType = strings.TrimSpace(kitutil.Interface2String(typed["mime_type"]))
}
for _, nestedKey := range []string{"url", "file_data", "file_url", "data"} {
if data := strings.TrimSpace(kitutil.Interface2String(typed[nestedKey])); data != "" {
return data, mimeType
}
}
}
}
return "", mimeType
}
@@ -0,0 +1,304 @@
package oairesponses
import (
"context"
"fmt"
"strings"
"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"
)
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)
}
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))
}
sourceReasoning, err := reasoning.FromOpenAIResponses(req)
if err != nil {
return nil, reasoning.AsClientError(err)
}
if err := sharedclaude.ApplyReasoning(c, claudeRequest, info, sourceReasoning, true); err != nil {
return nil, reasoning.AsClientError(err)
}
if claudeRequest.MaxTokens == nil {
if defaultMaxTokens, configured := convmeta.OptionsOf(info).Claude.DefaultMaxTokensFor(claudeRequest.Model); configured {
value := uint(defaultMaxTokens)
claudeRequest.MaxTokens = &value
}
}
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:
sourceRole := strings.TrimSpace(kitutil.Interface2String(item["role"]))
role := responsesClaudeRole(sourceRole)
parts, err := responsesInputContentToClaudeMediaMessages(c, item["content"])
if err != nil {
return nil, err
}
if sourceRole == "" && len(parts) == 0 {
continue
}
if role == "system" {
for _, part := range parts {
if part.Type == "text" {
systemMessages = append(systemMessages, part)
}
}
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
}
if len(claudeRequest.Messages) > 0 || len(systemMessages) > 0 {
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: sharedclaude.FunctionParametersToInputSchema(function.Parameters),
})
}
return tools
}
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(role string) string {
switch 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,156 @@
package oairesponses
import (
"encoding/json"
"errors"
"strings"
"unicode/utf8"
"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"
)
func ResponsesResponseToClaudeMessagesResponse(resp *dto.OpenAIResponsesResponse) (*dto.ClaudeResponse, *dto.Usage, error) {
if resp == nil {
return nil, nil, errors.New("response is nil")
}
usage := UsageFromResponsesUsage(resp.Usage)
claudeResponse := &dto.ClaudeResponse{
Id: resp.ID,
Type: "message",
Role: "assistant",
Model: resp.Model,
Usage: sharedclaude.UsageFromOpenAI(usage),
}
sawToolCall := false
for index := range resp.Output {
output := resp.Output[index]
if output.Type == responsesOutputTypeMessage && output.Role != "" && output.Role != "assistant" {
continue
}
switch output.Type {
case responsesOutputTypeReasoning:
if thinking := reasoningOutputText(&output); thinking != "" {
claudeResponse.Content = append(claudeResponse.Content, dto.ClaudeMediaMessage{
Type: "thinking",
Thinking: kitutil.GetPointer(thinking),
})
}
case responsesOutputTypeMessage:
for _, content := range output.Content {
if content.Type != "output_text" {
continue
}
block := dto.ClaudeMediaMessage{Type: "text", Text: kitutil.GetPointer(content.Text)}
if citations := responsesAnnotationsToClaude(content.Annotations, content.Text); len(citations) > 0 {
block.Citations, _ = kitutil.Marshal(citations)
}
claudeResponse.Content = append(claudeResponse.Content, block)
}
case responsesOutputTypeFunctionCall, responsesOutputTypeCustomToolCall:
sawToolCall = true
callID := strings.TrimSpace(output.CallId)
if callID == "" {
callID = strings.TrimSpace(output.ID)
}
claudeResponse.Content = append(claudeResponse.Content, dto.ClaudeMediaMessage{
Type: "tool_use",
Id: callID,
Name: output.Name,
Input: responsesArgumentsToClaudeInput(output.ArgumentsString()),
})
}
}
if len(claudeResponse.Content) == 0 {
claudeResponse.Content = []dto.ClaudeMediaMessage{{Type: "text", Text: kitutil.GetPointer("")}}
}
claudeResponse.StopReason = responsesClaudeStopReason(resp, sawToolCall)
return claudeResponse, usage, nil
}
func responsesArgumentsToClaudeInput(arguments string) map[string]any {
input := make(map[string]any)
if strings.TrimSpace(arguments) == "" {
return input
}
if err := kitutil.Unmarshal([]byte(arguments), &input); err == nil && input != nil {
return input
}
return map[string]any{"input": arguments}
}
func responsesClaudeStopReason(resp *dto.OpenAIResponsesResponse, sawToolCall bool) string {
if finishReason, ok := ResponsesFinishReasonFromStatus(resp); ok {
return reasonmap.OpenAIFinishReasonToClaudeStopReason(finishReason)
}
if sawToolCall {
return "tool_use"
}
return "end_turn"
}
func responsesAnnotationsToClaude(annotations []any, text string) []json.RawMessage {
citations := make([]json.RawMessage, 0, len(annotations))
for _, rawAnnotation := range annotations {
annotation, err := kitutil.Any2Type[map[string]any](rawAnnotation)
if err != nil || strings.TrimSpace(kitutil.Interface2String(annotation["type"])) != "url_citation" {
continue
}
citation := annotation
if nested, ok := annotation["url_citation"].(map[string]any); ok {
citation = nested
}
url := strings.TrimSpace(kitutil.Interface2String(citation["url"]))
if url == "" {
continue
}
converted := map[string]any{
"type": "web_search_result_location",
"url": url,
"title": strings.TrimSpace(kitutil.Interface2String(citation["title"])),
}
if citedText := kitutil.Interface2String(citation["cited_text"]); citedText != "" {
converted["cited_text"] = citedText
} else if citedText := responsesCitedText(text, citation); citedText != "" {
converted["cited_text"] = citedText
}
if encryptedIndex := kitutil.Interface2String(citation["encrypted_index"]); encryptedIndex != "" {
converted["encrypted_index"] = encryptedIndex
}
if converted["title"] == "" {
delete(converted, "title")
}
encoded, err := kitutil.Marshal(converted)
if err == nil {
citations = append(citations, encoded)
}
}
return citations
}
func responsesCitedText(text string, citation map[string]any) string {
start, startOK := responsesAnnotationIndex(citation["start_index"])
end, endOK := responsesAnnotationIndex(citation["end_index"])
if !startOK || !endOK || start < 0 || end <= start || end > utf8.RuneCountInString(text) {
return ""
}
runes := []rune(text)
return string(runes[start:end])
}
func responsesAnnotationIndex(value any) (int, bool) {
switch number := value.(type) {
case float64:
return int(number), number >= 0 && number == float64(int(number))
case int:
return number, number >= 0
case json.Number:
parsed, err := number.Int64()
return int(parsed), err == nil && parsed >= 0
default:
return 0, false
}
}
@@ -0,0 +1,500 @@
package oairesponses
import (
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
const responsesEventOutputTextDone = "response.output_text.done"
type ResponsesToClaudeStreamState struct {
ID string
Model string
Usage *dto.Usage
sentMessageStart bool
done bool
sawToolCall bool
nextBlockIndex int
blocks []*responsesClaudeStreamBlock
byOutputIndex map[int]*responsesClaudeStreamBlock
byItemID map[string]*responsesClaudeStreamBlock
lastByKind map[string]*responsesClaudeStreamBlock
usageText strings.Builder
}
type responsesClaudeStreamBlock struct {
Index int
Kind string
ItemID string
CallID string
Name string
Started bool
Stopped bool
Value strings.Builder
SentBytes int
AnnotationCount int
NeedsReasoningBreak bool
}
func NewResponsesToClaudeStreamState(id string, model string) *ResponsesToClaudeStreamState {
return &ResponsesToClaudeStreamState{
ID: strings.TrimSpace(id),
Model: strings.TrimSpace(model),
byOutputIndex: make(map[int]*responsesClaudeStreamBlock),
byItemID: make(map[string]*responsesClaudeStreamBlock),
lastByKind: make(map[string]*responsesClaudeStreamBlock),
}
}
func (s *ResponsesToClaudeStreamState) UsageText() string {
if s == nil {
return ""
}
return s.usageText.String()
}
func (s *ResponsesToClaudeStreamState) Done() bool {
return s != nil && s.done
}
func (s *ResponsesToClaudeStreamState) SetUsage(usage *dto.Usage) {
if s != nil && usage != nil {
s.Usage = usage
}
}
func (s *ResponsesToClaudeStreamState) StreamUsage() *dto.Usage {
if s == nil {
return nil
}
return s.Usage
}
func (s *ResponsesToClaudeStreamState) SetStreamUsage(usage *dto.Usage) {
s.SetUsage(usage)
}
func (s *ResponsesToClaudeStreamState) ConvertChunk(event *dto.ResponsesStreamResponse, estimatedInputTokens int) ([]*dto.ClaudeResponse, *dto.Usage, error) {
if s == nil {
return nil, nil, nil
}
if event == nil || s.done {
return nil, s.Usage, nil
}
s.applyResponseMetadata(event.Response)
switch event.Type {
case responsesEventCreated:
return s.ensureMessageStart(estimatedInputTokens), s.Usage, nil
case responsesEventReasoningSummaryDelta, responsesEventReasoningTextDelta:
block, err := s.ensureBlock(event, "thinking")
if err != nil {
return nil, s.Usage, err
}
delta := event.Delta
if block.NeedsReasoningBreak && delta != "" {
delta = separatedResponsesDelta(delta)
block.NeedsReasoningBreak = false
}
return s.appendDelta(block, delta, estimatedInputTokens), s.Usage, nil
case responsesEventReasoningSummaryDone, responsesEventReasoningTextDone:
block, err := s.ensureBlock(event, "thinking")
if err != nil {
return nil, s.Usage, err
}
var responses []*dto.ClaudeResponse
if event.Text != nil {
responses = append(responses, s.mergeFinalValue(block, *event.Text, estimatedInputTokens)...)
}
if block.Value.Len() > 0 {
block.NeedsReasoningBreak = true
}
return responses, s.Usage, nil
case responsesEventOutputTextDelta:
block, err := s.ensureBlock(event, "text")
if err != nil {
return nil, s.Usage, err
}
return s.appendDelta(block, event.Delta, estimatedInputTokens), s.Usage, nil
case responsesEventOutputTextDone:
block, err := s.ensureBlock(event, "text")
if err != nil {
return nil, s.Usage, err
}
if event.Text == nil {
return nil, s.Usage, nil
}
return s.mergeFinalValue(block, *event.Text, estimatedInputTokens), s.Usage, nil
case responsesEventOutputTextAnnotationAdded:
block, err := s.ensureBlock(event, "text")
if err != nil {
return nil, s.Usage, err
}
var annotation any
if err := kitutil.Unmarshal(event.Annotation, &annotation); err != nil {
return nil, s.Usage, fmt.Errorf("invalid Responses stream annotation: %w", err)
}
return s.appendAnnotations(block, []any{annotation}, estimatedInputTokens, false), s.Usage, nil
case responsesEventOutputItemAdded, responsesEventOutputItemDone:
responses, err := s.applyOutputItem(event, estimatedInputTokens, event.Type == responsesEventOutputItemDone)
return responses, s.Usage, err
case responsesEventFunctionArgsDelta, responsesEventCustomToolInputDelta:
block, err := s.ensureBlock(event, "tool_use")
if err != nil {
return nil, s.Usage, err
}
return s.appendDelta(block, event.Delta, estimatedInputTokens), s.Usage, nil
case responsesEventFunctionArgsDone, responsesEventCustomToolInputDone:
block, err := s.ensureBlock(event, "tool_use")
if err != nil {
return nil, s.Usage, err
}
if event.Arguments == nil {
return nil, s.Usage, nil
}
return s.mergeFinalValue(block, *event.Arguments, estimatedInputTokens), s.Usage, nil
case responsesEventCompleted, responsesEventDone, responsesEventIncomplete:
responses, err := s.finish(event.Response, estimatedInputTokens)
return responses, s.Usage, err
case responsesEventFailed, responsesEventError:
message := strings.TrimSpace(event.Message)
if message == "" {
message = event.Type
}
return nil, s.Usage, fmt.Errorf("responses stream error: %s", message)
default:
return nil, s.Usage, nil
}
}
func (s *ResponsesToClaudeStreamState) Finalize(estimatedInputTokens int) ([]*dto.ClaudeResponse, error) {
if s == nil || s.done {
return nil, nil
}
return s.finish(nil, estimatedInputTokens)
}
func (s *ResponsesToClaudeStreamState) applyResponseMetadata(response *dto.OpenAIResponsesResponse) {
if s == nil || response == nil {
return
}
if response.ID != "" {
s.ID = response.ID
}
if response.Model != "" {
s.Model = response.Model
}
if response.Usage != nil {
s.Usage = dto.MergeUsageNonZero(s.Usage, UsageFromResponsesUsage(response.Usage))
}
}
func (s *ResponsesToClaudeStreamState) ensureMessageStart(estimatedInputTokens int) []*dto.ClaudeResponse {
if s.sentMessageStart {
return nil
}
s.sentMessageStart = true
inputTokens := estimatedInputTokens
if s.Usage != nil {
if usage := sharedclaude.UsageFromOpenAI(s.Usage); usage != nil {
inputTokens = usage.InputTokens
}
}
message := &dto.ClaudeMediaMessage{
Id: s.ID,
Type: "message",
Role: "assistant",
Model: s.Model,
Usage: &dto.ClaudeUsage{InputTokens: inputTokens},
}
message.SetContent(make([]any, 0))
return []*dto.ClaudeResponse{{Type: "message_start", Message: message}}
}
func (s *ResponsesToClaudeStreamState) ensureBlock(event *dto.ResponsesStreamResponse, kind string) (*responsesClaudeStreamBlock, error) {
block := s.findBlock(event)
if block == nil {
if last := s.lastByKind[kind]; last != nil && !last.Stopped && event.OutputIndex == nil && responseStreamEventItemID(event) == "" {
block = last
}
}
if block == nil {
block = &responsesClaudeStreamBlock{Index: s.nextBlockIndex, Kind: kind}
s.nextBlockIndex++
s.blocks = append(s.blocks, block)
}
if block.Kind == "" {
block.Kind = kind
}
if block.Kind != kind {
return nil, fmt.Errorf("Responses output item changed from %s to %s", block.Kind, kind)
}
s.applyBlockMetadata(block, event)
s.lastByKind[kind] = block
return block, nil
}
func (s *ResponsesToClaudeStreamState) findBlock(event *dto.ResponsesStreamResponse) *responsesClaudeStreamBlock {
if event == nil {
return nil
}
if event.OutputIndex != nil {
if block := s.byOutputIndex[*event.OutputIndex]; block != nil {
return block
}
}
if itemID := responseStreamEventItemID(event); itemID != "" {
return s.byItemID[itemID]
}
return nil
}
func (s *ResponsesToClaudeStreamState) applyBlockMetadata(block *responsesClaudeStreamBlock, event *dto.ResponsesStreamResponse) {
if block == nil || event == nil {
return
}
if event.OutputIndex != nil {
s.byOutputIndex[*event.OutputIndex] = block
}
if itemID := responseStreamEventItemID(event); itemID != "" {
block.ItemID = itemID
s.byItemID[itemID] = block
}
if event.Item == nil {
return
}
if callID := strings.TrimSpace(event.Item.CallId); callID != "" {
block.CallID = callID
} else if block.CallID == "" {
block.CallID = strings.TrimSpace(event.Item.ID)
}
if name := strings.TrimSpace(event.Item.Name); name != "" {
block.Name = name
}
}
func (s *ResponsesToClaudeStreamState) startBlock(block *responsesClaudeStreamBlock, estimatedInputTokens int) []*dto.ClaudeResponse {
if block == nil || block.Started || block.Stopped {
return nil
}
var content dto.ClaudeMediaMessage
switch block.Kind {
case "text":
content = dto.ClaudeMediaMessage{Type: "text", Text: kitutil.GetPointer("")}
case "thinking":
content = dto.ClaudeMediaMessage{Type: "thinking", Thinking: kitutil.GetPointer("")}
case "tool_use":
if block.Name == "" {
return nil
}
callID := block.CallID
if callID == "" {
callID = block.ItemID
}
content = dto.ClaudeMediaMessage{Type: "tool_use", Id: callID, Name: block.Name, Input: map[string]any{}}
s.sawToolCall = true
default:
return nil
}
block.Started = true
responses := s.ensureMessageStart(estimatedInputTokens)
index := block.Index
responses = append(responses, &dto.ClaudeResponse{Type: "content_block_start", Index: &index, ContentBlock: &content})
return responses
}
func (s *ResponsesToClaudeStreamState) appendDelta(block *responsesClaudeStreamBlock, delta string, estimatedInputTokens int) []*dto.ClaudeResponse {
if block == nil || block.Stopped || delta == "" {
return nil
}
block.Value.WriteString(delta)
return s.flushBlock(block, estimatedInputTokens)
}
func (s *ResponsesToClaudeStreamState) mergeFinalValue(block *responsesClaudeStreamBlock, finalValue string, estimatedInputTokens int) []*dto.ClaudeResponse {
if block == nil || block.Stopped {
return nil
}
current := block.Value.String()
if current == "" {
block.Value.WriteString(finalValue)
} else if strings.HasPrefix(finalValue, current) {
block.Value.WriteString(finalValue[len(current):])
}
return s.flushBlock(block, estimatedInputTokens)
}
func (s *ResponsesToClaudeStreamState) flushBlock(block *responsesClaudeStreamBlock, estimatedInputTokens int) []*dto.ClaudeResponse {
if block == nil || block.Stopped {
return nil
}
responses := s.startBlock(block, estimatedInputTokens)
if !block.Started {
return responses
}
value := block.Value.String()
if block.SentBytes >= len(value) {
return responses
}
delta := value[block.SentBytes:]
block.SentBytes = len(value)
s.usageText.WriteString(delta)
index := block.Index
media := &dto.ClaudeMediaMessage{}
switch block.Kind {
case "text":
media.Type = "text_delta"
media.Text = &delta
case "thinking":
media.Type = "thinking_delta"
media.Thinking = &delta
case "tool_use":
media.Type = "input_json_delta"
media.PartialJson = &delta
}
responses = append(responses, &dto.ClaudeResponse{Type: "content_block_delta", Index: &index, Delta: media})
return responses
}
func (s *ResponsesToClaudeStreamState) stopBlock(block *responsesClaudeStreamBlock, estimatedInputTokens int) []*dto.ClaudeResponse {
if block == nil || block.Stopped {
return nil
}
responses := s.flushBlock(block, estimatedInputTokens)
responses = append(responses, s.startBlock(block, estimatedInputTokens)...)
if !block.Started {
return responses
}
block.Stopped = true
index := block.Index
return append(responses, &dto.ClaudeResponse{Type: "content_block_stop", Index: &index})
}
func (s *ResponsesToClaudeStreamState) applyOutputItem(event *dto.ResponsesStreamResponse, estimatedInputTokens int, stop bool) ([]*dto.ClaudeResponse, error) {
if event == nil || event.Item == nil {
return nil, nil
}
item := event.Item
var kind string
switch item.Type {
case responsesOutputTypeReasoning:
kind = "thinking"
case responsesOutputTypeMessage:
if item.Role != "" && item.Role != "assistant" {
return nil, nil
}
kind = "text"
case responsesOutputTypeFunctionCall, responsesOutputTypeCustomToolCall:
kind = "tool_use"
default:
return nil, nil
}
block, err := s.ensureBlock(event, kind)
if err != nil {
return nil, err
}
var responses []*dto.ClaudeResponse
switch kind {
case "thinking":
responses = append(responses, s.mergeFinalValue(block, reasoningOutputText(item), estimatedInputTokens)...)
case "text":
var text strings.Builder
var annotations []any
for _, content := range item.Content {
if content.Type != "output_text" {
continue
}
text.WriteString(content.Text)
annotations = append(annotations, content.Annotations...)
}
responses = append(responses, s.mergeFinalValue(block, text.String(), estimatedInputTokens)...)
responses = append(responses, s.appendAnnotations(block, annotations, estimatedInputTokens, true)...)
case "tool_use":
responses = append(responses, s.mergeFinalValue(block, item.ArgumentsString(), estimatedInputTokens)...)
}
if stop {
responses = append(responses, s.stopBlock(block, estimatedInputTokens)...)
}
return responses, nil
}
func (s *ResponsesToClaudeStreamState) appendAnnotations(block *responsesClaudeStreamBlock, annotations []any, estimatedInputTokens int, snapshot bool) []*dto.ClaudeResponse {
if block == nil || block.Kind != "text" || block.Stopped || len(annotations) == 0 {
return nil
}
remaining := annotations
if snapshot {
if len(annotations) <= block.AnnotationCount {
return nil
}
remaining = annotations[block.AnnotationCount:]
block.AnnotationCount = len(annotations)
} else {
block.AnnotationCount += len(annotations)
}
citations := responsesAnnotationsToClaude(remaining, block.Value.String())
if len(citations) == 0 {
return nil
}
responses := s.startBlock(block, estimatedInputTokens)
index := block.Index
for _, citation := range citations {
responses = append(responses, &dto.ClaudeResponse{
Type: "content_block_delta",
Index: &index,
Delta: &dto.ClaudeMediaMessage{Type: "citations_delta", Citation: citation},
})
}
return responses
}
func (s *ResponsesToClaudeStreamState) finish(response *dto.OpenAIResponsesResponse, estimatedInputTokens int) ([]*dto.ClaudeResponse, error) {
if s.done {
return nil, nil
}
s.applyResponseMetadata(response)
responses := make([]*dto.ClaudeResponse, 0)
if response != nil {
for outputIndex := range response.Output {
index := outputIndex
item := response.Output[outputIndex]
event := &dto.ResponsesStreamResponse{OutputIndex: &index, ItemID: item.ID, Item: &item}
itemResponses, err := s.applyOutputItem(event, estimatedInputTokens, true)
if err != nil {
return nil, err
}
responses = append(responses, itemResponses...)
}
}
for _, block := range s.blocks {
responses = append(responses, s.stopBlock(block, estimatedInputTokens)...)
}
responses = append(responses, s.ensureMessageStart(estimatedInputTokens)...)
stopReason := responsesClaudeStopReason(response, s.sawToolCall)
usage := sharedclaude.UsageFromOpenAI(s.Usage)
responses = append(responses,
&dto.ClaudeResponse{
Type: "message_delta",
Usage: usage,
Delta: &dto.ClaudeMediaMessage{StopReason: &stopReason},
},
&dto.ClaudeResponse{Type: "message_stop"},
)
s.done = true
return responses, nil
}
func separatedResponsesDelta(delta string) string {
if strings.HasPrefix(delta, "\n\n") {
return delta
}
if strings.HasPrefix(delta, "\n") {
return "\n" + delta
}
return "\n\n" + delta
}
@@ -0,0 +1,130 @@
package oairesponses
import (
"strings"
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResponsesToClaudeStreamDoesNotRepeatBlocksFromDoneAndCompletedEvents(t *testing.T) {
state := NewResponsesToClaudeStreamState("", "")
arguments := `{"q":"x"}`
argumentRaw, err := kitutil.Marshal(arguments)
require.NoError(t, err)
statusRaw, err := kitutil.Marshal("completed")
require.NoError(t, err)
reasoningItem := dto.ResponsesOutput{
Type: responsesOutputTypeReasoning,
ID: "rs_1",
Summary: []dto.ResponsesReasoningSummaryPart{{Type: "summary_text", Text: "plan"}},
}
messageItem := dto.ResponsesOutput{
Type: responsesOutputTypeMessage,
ID: "msg_1",
Role: "assistant",
Content: []dto.ResponsesOutputContent{{Type: "output_text", Text: "hello"}},
}
toolItem := dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: "fc_1",
CallId: "call_1",
Name: "lookup",
Arguments: argumentRaw,
}
events := []*dto.ResponsesStreamResponse{
{Type: responsesEventCreated, Response: &dto.OpenAIResponsesResponse{ID: "resp_1", Model: "gpt-test"}},
{Type: responsesEventOutputItemAdded, OutputIndex: kitutil.GetPointer(0), ItemID: reasoningItem.ID, Item: &dto.ResponsesOutput{Type: reasoningItem.Type, ID: reasoningItem.ID}},
{Type: responsesEventReasoningSummaryDelta, OutputIndex: kitutil.GetPointer(0), ItemID: reasoningItem.ID, Delta: "plan"},
{Type: responsesEventReasoningSummaryDone, OutputIndex: kitutil.GetPointer(0), ItemID: reasoningItem.ID, Text: kitutil.GetPointer("plan")},
{Type: responsesEventOutputItemDone, OutputIndex: kitutil.GetPointer(0), ItemID: reasoningItem.ID, Item: &reasoningItem},
{Type: responsesEventOutputItemAdded, OutputIndex: kitutil.GetPointer(1), ItemID: messageItem.ID, Item: &dto.ResponsesOutput{Type: messageItem.Type, ID: messageItem.ID, Role: "assistant"}},
{Type: responsesEventOutputTextDelta, OutputIndex: kitutil.GetPointer(1), ItemID: messageItem.ID, Delta: "hello"},
{Type: responsesEventOutputTextDone, OutputIndex: kitutil.GetPointer(1), ItemID: messageItem.ID, Text: kitutil.GetPointer("hello")},
{Type: responsesEventOutputItemDone, OutputIndex: kitutil.GetPointer(1), ItemID: messageItem.ID, Item: &messageItem},
{Type: responsesEventOutputItemAdded, OutputIndex: kitutil.GetPointer(2), ItemID: toolItem.ID, Item: &dto.ResponsesOutput{Type: toolItem.Type, ID: toolItem.ID, CallId: toolItem.CallId, Name: toolItem.Name}},
{Type: responsesEventFunctionArgsDelta, OutputIndex: kitutil.GetPointer(2), ItemID: toolItem.ID, Delta: `{"q":`},
{Type: responsesEventFunctionArgsDelta, OutputIndex: kitutil.GetPointer(2), ItemID: toolItem.ID, Delta: `"x"}`},
{Type: responsesEventFunctionArgsDone, OutputIndex: kitutil.GetPointer(2), ItemID: toolItem.ID, Arguments: &arguments},
{Type: responsesEventOutputItemDone, OutputIndex: kitutil.GetPointer(2), ItemID: toolItem.ID, Item: &toolItem},
{
Type: responsesEventCompleted,
Response: &dto.OpenAIResponsesResponse{
ID: "resp_1",
Model: "gpt-test",
Status: statusRaw,
Output: []dto.ResponsesOutput{reasoningItem, messageItem, toolItem},
Usage: &dto.Usage{InputTokens: 11, OutputTokens: 7, TotalTokens: 18},
},
},
}
var output []*dto.ClaudeResponse
for _, event := range events {
converted, _, err := state.ConvertChunk(event, 9)
require.NoError(t, err)
output = append(output, converted...)
}
starts := responsesOfType(output, "content_block_start")
stops := responsesOfType(output, "content_block_stop")
require.Len(t, responsesOfType(output, "message_start"), 1)
require.Len(t, starts, 3)
require.Len(t, stops, 3)
require.Len(t, responsesOfType(output, "message_delta"), 1)
require.Len(t, responsesOfType(output, "message_stop"), 1)
assert.Equal(t, []int{0, 1, 2}, []int{starts[0].GetIndex(), starts[1].GetIndex(), starts[2].GetIndex()})
assert.Equal(t, []string{"thinking", "text", "tool_use"}, []string{starts[0].ContentBlock.Type, starts[1].ContentBlock.Type, starts[2].ContentBlock.Type})
assert.Equal(t, "plan", joinedClaudeDeltas(output, "thinking_delta"))
assert.Equal(t, "hello", joinedClaudeDeltas(output, "text_delta"))
assert.Equal(t, arguments, joinedClaudeDeltas(output, "input_json_delta"))
messageDelta := responsesOfType(output, "message_delta")[0]
require.NotNil(t, messageDelta.Delta.StopReason)
assert.Equal(t, "tool_use", *messageDelta.Delta.StopReason)
finalized, err := state.Finalize(9)
require.NoError(t, err)
assert.Empty(t, finalized)
repeated, _, err := state.ConvertChunk(events[len(events)-1], 9)
require.NoError(t, err)
assert.Empty(t, repeated)
}
func responsesOfType(responses []*dto.ClaudeResponse, responseType string) []*dto.ClaudeResponse {
filtered := make([]*dto.ClaudeResponse, 0)
for _, response := range responses {
if response != nil && response.Type == responseType {
filtered = append(filtered, response)
}
}
return filtered
}
func joinedClaudeDeltas(responses []*dto.ClaudeResponse, deltaType string) string {
var result strings.Builder
for _, response := range responses {
if response == nil || response.Type != "content_block_delta" || response.Delta == nil || response.Delta.Type != deltaType {
continue
}
switch deltaType {
case "thinking_delta":
if response.Delta.Thinking != nil {
result.WriteString(*response.Delta.Thinking)
}
case "text_delta":
if response.Delta.Text != nil {
result.WriteString(*response.Delta.Text)
}
case "input_json_delta":
if response.Delta.PartialJson != nil {
result.WriteString(*response.Delta.PartialJson)
}
}
}
return result.String()
}
@@ -0,0 +1,331 @@
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"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
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 {
geminiRequest.GenerationConfig.TopP = kitutil.GetPointer(*req.TopP)
}
if req.MaxOutputTokens != nil {
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
}
reasoningIntent, err := reasoning.FromOpenAIResponses(req)
if err != nil {
return nil, reasoning.AsClientError(err)
}
var reasoningPivot dto.GeneralOpenAIRequest
if err := reasoning.ApplyToOpenAIChat(&reasoningPivot, reasoningIntent); err != nil {
return nil, reasoning.AsClientError(err)
}
reasoningPivot.Model = req.Model
reasoningPivot.MaxCompletionTokens = req.MaxOutputTokens
if err := sharedgemini.ApplyThinkingConfig(geminiRequest, info, reasoningPivot); err != nil {
return nil, reasoning.AsClientError(err)
}
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]any); ok {
if props, hasProps := params["properties"].(map[string]any); 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, err := responsesFunctionOutputItemToGeminiPart(item, callNames)
if err != nil {
return nil, err
}
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{
ID: callID,
FunctionName: name,
Arguments: ObjectValue(item["arguments"], "arguments"),
},
}, callID, nil
}
func responsesFunctionOutputItemToGeminiPart(item map[string]any, callNames map[string]string) (dto.GeminiPart, error) {
callID := CallID(item)
name := strings.TrimSpace(kitutil.Interface2String(item["name"]))
if name == "" {
name = callNames[callID]
}
response := &dto.GeminiFunctionResponse{
Name: name,
Response: GeminiResponseMap(item["output"]),
}
if callID != "" {
id, err := kitutil.Marshal(callID)
if err != nil {
return dto.GeminiPart{}, fmt.Errorf("failed to marshal function response ID: %w", err)
}
response.ID = id
}
return dto.GeminiPart{
FunctionResponse: response,
}, nil
}
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,577 @@
package oairesponses
import (
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
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,
ThinkingBudget: req.ThinkingBudget,
}
out.FrequencyPenalty, err = responsesRawFloat(req.FrequencyPenalty)
if err != nil {
return nil, fmt.Errorf("invalid frequency_penalty: %w", err)
}
out.PresencePenalty, err = responsesRawFloat(req.PresencePenalty)
if err != nil {
return nil, fmt.Errorf("invalid presence_penalty: %w", err)
}
if reasoningIntent, err := reasoning.FromOpenAIResponses(req); err != nil {
return nil, reasoning.AsClientError(err)
} else if err := reasoning.ApplyToOpenAIChat(out, reasoningIntent); err != nil {
return nil, reasoning.AsClientError(err)
}
if req.ServiceTier != "" {
out.ServiceTier, _ = kitutil.Marshal(req.ServiceTier)
}
if len(req.ParallelToolCalls) > 0 && kitutil.GetJsonType(req.ParallelToolCalls) == "boolean" {
var parallelToolCalls bool
if err := kitutil.Unmarshal(req.ParallelToolCalls, &parallelToolCalls); err == nil {
out.ParallelTooCalls = &parallelToolCalls
}
}
if len(req.PromptCacheKey) > 0 && kitutil.GetJsonType(req.PromptCacheKey) == "string" {
var promptCacheKey string
if err := kitutil.Unmarshal(req.PromptCacheKey, &promptCacheKey); err == nil {
out.PromptCacheKey = promptCacheKey
}
}
return out, nil
}
func validateResponsesRequestChatUnsupportedFields(req *dto.OpenAIResponsesRequest) error {
unsupported := make([]string, 0, 4)
if rawJSONPresent(req.Conversation) {
unsupported = append(unsupported, "conversation")
}
if strings.TrimSpace(req.PreviousResponseID) != "" {
unsupported = append(unsupported, "previous_response_id")
}
if rawJSONPresent(req.Prompt) {
unsupported = append(unsupported, "prompt")
}
if rawJSONPresent(req.ContextManagement) {
unsupported = append(unsupported, "context_management")
}
if len(unsupported) > 0 {
return fmt.Errorf("responses to chat conversion does not support stateful fields: %s", strings.Join(unsupported, ", "))
}
return nil
}
func ValidateRequestChatUnsupportedFields(req *dto.OpenAIResponsesRequest) error {
return validateResponsesRequestChatUnsupportedFields(req)
}
func responsesRequestMessagesToChat(req *dto.OpenAIResponsesRequest) ([]dto.Message, error) {
messages := make([]dto.Message, 0)
if rawJSONPresent(req.Instructions) {
instructions, err := responsesJSONString(req.Instructions)
if err != nil {
return nil, fmt.Errorf("invalid instructions: %w", err)
}
if strings.TrimSpace(instructions) != "" {
messages = append(messages, dto.Message{Role: "system", Content: instructions})
}
}
if !rawJSONPresent(req.Input) {
return messages, nil
}
switch kitutil.GetJsonType(req.Input) {
case "string":
input, err := responsesJSONString(req.Input)
if err != nil {
return nil, fmt.Errorf("invalid input string: %w", err)
}
messages = append(messages, dto.Message{Role: "user", Content: input})
return messages, nil
case "array":
var items []map[string]any
if err := kitutil.Unmarshal(req.Input, &items); err != nil {
return nil, fmt.Errorf("invalid input array: %w", err)
}
for _, item := range items {
nextMessages, err := responsesInputItemToChatMessages(item, messages)
if err != nil {
return nil, err
}
messages = nextMessages
}
return messages, nil
default:
return nil, fmt.Errorf("unsupported responses input type %q", kitutil.GetJsonType(req.Input))
}
}
func responsesInputItemToChatMessages(item map[string]any, messages []dto.Message) ([]dto.Message, error) {
itemType := strings.TrimSpace(kitutil.Interface2String(item["type"]))
switch itemType {
case responsesInputTypeFunctionCall:
toolCall, err := responsesFunctionCallItemToChatToolCall(item)
if err != nil {
return nil, err
}
return appendToolCallToLastAssistant(messages, toolCall), nil
case responsesInputTypeCustomToolCall:
toolCall, err := responsesCustomToolCallItemToChatToolCall(item)
if err != nil {
return nil, err
}
return appendToolCallToLastAssistant(messages, toolCall), nil
case responsesInputTypeFunctionCallOutput:
callID := strings.TrimSpace(kitutil.Interface2String(item["call_id"]))
content := responseToolOutputToChatContent(item["output"])
return append(messages, dto.Message{Role: "tool", ToolCallId: callID, Content: content}), nil
}
role := strings.TrimSpace(kitutil.Interface2String(item["role"]))
if role == "" {
role = "user"
}
content, err := responsesInputContentToChatContent(item["content"])
if err != nil {
return nil, err
}
return append(messages, dto.Message{Role: role, Content: content}), nil
}
func responsesInputContentToChatContent(content any) (any, error) {
if content == nil {
return "", nil
}
switch value := content.(type) {
case string:
return value, nil
case []any:
return responsesContentPartsToChatContent(value)
case []map[string]any:
parts := make([]any, 0, len(value))
for _, part := range value {
parts = append(parts, part)
}
return responsesContentPartsToChatContent(parts)
default:
return content, nil
}
}
func responsesContentPartsToChatContent(parts []any) (any, error) {
chatParts := make([]any, 0, len(parts))
var textOnly strings.Builder
onlyText := true
for _, rawPart := range parts {
part, ok := rawPart.(map[string]any)
if !ok {
onlyText = false
chatParts = append(chatParts, rawPart)
continue
}
partType := strings.TrimSpace(kitutil.Interface2String(part["type"]))
switch partType {
case "input_text", "output_text", "text":
text := kitutil.Interface2String(part["text"])
textOnly.WriteString(text)
chatParts = append(chatParts, map[string]any{
"type": dto.ContentTypeText,
"text": text,
})
case "input_image":
onlyText = false
chatParts = append(chatParts, map[string]any{
"type": dto.ContentTypeImageURL,
"image_url": responsesImagePartToChatImageURL(part),
})
case "input_file":
onlyText = false
chatParts = append(chatParts, map[string]any{
"type": dto.ContentTypeFile,
"file": responsesFilePartToChatFile(part),
})
case "input_audio":
onlyText = false
chatParts = append(chatParts, map[string]any{
"type": dto.ContentTypeInputAudio,
"input_audio": responsesPartPayload(part, "input_audio"),
})
case "input_video":
onlyText = false
chatParts = append(chatParts, map[string]any{
"type": dto.ContentTypeVideoUrl,
"video_url": responsesVideoPartToChatVideoURL(part),
})
default:
onlyText = false
chatParts = append(chatParts, part)
}
}
if onlyText {
return textOnly.String(), nil
}
return chatParts, nil
}
func responsesFunctionCallItemToChatToolCall(item map[string]any) (dto.ToolCallRequest, error) {
name := strings.TrimSpace(kitutil.Interface2String(item["name"]))
if name == "" {
return dto.ToolCallRequest{}, errors.New("function_call item is missing name")
}
return dto.ToolCallRequest{
ID: responsesCallID(item),
Type: "function",
Function: dto.FunctionRequest{
Name: name,
Arguments: responsesArgumentsString(item["arguments"]),
},
}, nil
}
func responsesCustomToolCallItemToChatToolCall(item map[string]any) (dto.ToolCallRequest, error) {
raw, err := kitutil.Marshal(item)
if err != nil {
return dto.ToolCallRequest{}, err
}
return dto.ToolCallRequest{
ID: responsesCallID(item),
Type: dto.CustomType,
Custom: raw,
Function: dto.FunctionRequest{
Name: strings.TrimSpace(kitutil.Interface2String(item["name"])),
Arguments: responsesArgumentsString(item["input"]),
},
}, nil
}
func appendToolCallToLastAssistant(messages []dto.Message, toolCall dto.ToolCallRequest) []dto.Message {
if len(messages) == 0 || messages[len(messages)-1].Role != "assistant" {
messages = append(messages, dto.Message{Role: "assistant"})
}
idx := len(messages) - 1
toolCalls := messages[idx].ParseToolCalls()
toolCalls = append(toolCalls, toolCall)
toolCallsRaw, _ := kitutil.Marshal(toolCalls)
messages[idx].ToolCalls = toolCallsRaw
return messages
}
func responsesRequestToolsToChat(raw json.RawMessage) ([]dto.ToolCallRequest, error) {
if !rawJSONPresent(raw) {
return nil, nil
}
var tools []map[string]any
if err := kitutil.Unmarshal(raw, &tools); err != nil {
return nil, fmt.Errorf("invalid tools: %w", err)
}
out := make([]dto.ToolCallRequest, 0, len(tools))
for _, tool := range tools {
toolType := strings.TrimSpace(kitutil.Interface2String(tool["type"]))
if toolType == "function" {
out = append(out, dto.ToolCallRequest{
Type: "function",
Function: dto.FunctionRequest{
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Description: kitutil.Interface2String(tool["description"]),
Parameters: tool["parameters"],
},
})
continue
}
rawTool, err := kitutil.Marshal(tool)
if err != nil {
return nil, err
}
out = append(out, dto.ToolCallRequest{
Type: toolType,
Custom: rawTool,
})
}
return out, nil
}
func responsesRequestToolChoiceToChat(raw json.RawMessage) (any, error) {
if !rawJSONPresent(raw) {
return nil, nil
}
if kitutil.GetJsonType(raw) == "string" {
var choice string
if err := kitutil.Unmarshal(raw, &choice); err != nil {
return nil, fmt.Errorf("invalid tool_choice: %w", err)
}
return choice, nil
}
var choice map[string]any
if err := kitutil.Unmarshal(raw, &choice); err != nil {
return nil, fmt.Errorf("invalid tool_choice: %w", err)
}
if kitutil.Interface2String(choice["type"]) == "function" {
name := strings.TrimSpace(kitutil.Interface2String(choice["name"]))
if name != "" {
return map[string]any{
"type": "function",
"function": map[string]any{
"name": name,
},
}, nil
}
}
return choice, nil
}
func RequestToolChoiceToChat(raw json.RawMessage) (any, error) {
return responsesRequestToolChoiceToChat(raw)
}
func responsesRequestTextToChatResponseFormat(raw json.RawMessage) (*dto.ResponseFormat, error) {
if !rawJSONPresent(raw) {
return nil, nil
}
var textConfig map[string]any
if err := kitutil.Unmarshal(raw, &textConfig); err != nil {
return nil, fmt.Errorf("invalid text config: %w", err)
}
format, ok := textConfig["format"].(map[string]any)
if !ok {
return nil, nil
}
formatType := strings.TrimSpace(kitutil.Interface2String(format["type"]))
if formatType == "" {
return nil, nil
}
out := &dto.ResponseFormat{Type: formatType}
if formatType == "json_schema" {
schemaRaw, err := kitutil.Marshal(format)
if err != nil {
return nil, err
}
out.JsonSchema = schemaRaw
}
return out, nil
}
func RequestTextToChatResponseFormat(raw json.RawMessage) (*dto.ResponseFormat, error) {
return responsesRequestTextToChatResponseFormat(raw)
}
func responsesImagePartToChatImageURL(part map[string]any) any {
if imageURL, ok := part["image_url"]; ok {
return imageURL
}
imageURL := map[string]any{}
for _, key := range []string{"url", "file_id", "detail"} {
if value, ok := part[key]; ok {
imageURL[key] = value
}
}
if len(imageURL) == 0 {
return part
}
return imageURL
}
func responsesFilePartToChatFile(part map[string]any) any {
if file, ok := part["file"]; ok {
return file
}
file := map[string]any{}
for _, key := range []string{"file_id", "file_data", "filename", "file_url"} {
if value, ok := part[key]; ok {
file[key] = value
}
}
if len(file) == 0 {
return part
}
return file
}
func responsesVideoPartToChatVideoURL(part map[string]any) any {
if videoURL, ok := part["video_url"]; ok {
if videoURLMap, ok := videoURL.(map[string]any); ok {
if url := kitutil.Interface2String(videoURLMap["url"]); url != "" {
return url
}
}
return videoURL
}
if url := kitutil.Interface2String(part["url"]); url != "" {
return url
}
return responsesPartPayload(part, "video_url")
}
func responsesPartPayload(part map[string]any, key string) any {
if value, ok := part[key]; ok {
return value
}
payload := make(map[string]any, len(part))
for k, value := range part {
if k == "type" {
continue
}
payload[k] = value
}
return payload
}
func responsesCallID(item map[string]any) string {
callID := strings.TrimSpace(kitutil.Interface2String(item["call_id"]))
if callID != "" {
return callID
}
return strings.TrimSpace(kitutil.Interface2String(item["id"]))
}
func CallID(item map[string]any) string {
return responsesCallID(item)
}
func responsesArgumentsString(value any) string {
switch v := value.(type) {
case nil:
return ""
case string:
return v
default:
raw, err := kitutil.Marshal(v)
if err != nil {
return kitutil.Interface2String(v)
}
return string(raw)
}
}
func responseToolOutputToChatContent(value any) any {
switch v := value.(type) {
case nil:
return ""
case string:
return v
default:
raw, err := kitutil.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(raw)
}
}
func responsesRawFloat(raw json.RawMessage) (*float64, error) {
if !rawJSONPresent(raw) {
return nil, nil
}
var value float64
if err := kitutil.Unmarshal(raw, &value); err != nil {
return nil, err
}
return &value, nil
}
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,358 @@
package oairesponses
import (
"encoding/json"
"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 TestResponsesRequestToChatCompletionsRequestPreservesQwenThinkingBudget(t *testing.T) {
tests := []struct {
name string
budget json.RawMessage
want int64
}{
{name: "positive budget", budget: json.RawMessage(`128`), want: 128},
{name: "zero budget", budget: json.RawMessage(`0`), want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
Model: "qwen-plus",
Input: mustRawMessage(t, "hello"),
EnableThinking: json.RawMessage(`true`),
ThinkingBudget: tt.budget,
})
require.NoError(t, err)
assert.Equal(t, tt.budget, got.ThinkingBudget)
encoded, err := kitutil.Marshal(got)
require.NoError(t, err)
assert.True(t, gjson.GetBytes(encoded, "enable_thinking").Bool())
value := gjson.GetBytes(encoded, "thinking_budget")
assert.True(t, value.Exists())
assert.Equal(t, tt.want, value.Int())
})
}
}
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 TestResponsesRequestToChatCompletionsRequestPreservesPenalties(t *testing.T) {
tests := []struct {
name string
frequencyRaw json.RawMessage
frequencyWant *float64
presenceRaw json.RawMessage
presenceWant *float64
}{
{
name: "positive values",
frequencyRaw: json.RawMessage(`0.5`),
frequencyWant: lo.ToPtr(0.5),
presenceRaw: json.RawMessage(`1.5`),
presenceWant: lo.ToPtr(1.5),
},
{
name: "explicit zero values",
frequencyRaw: json.RawMessage(`0.0`),
frequencyWant: lo.ToPtr(0.0),
presenceRaw: json.RawMessage(`0.0`),
presenceWant: lo.ToPtr(0.0),
},
{
name: "unset stays nil",
frequencyRaw: nil,
presenceRaw: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
Model: "gpt-test",
Input: mustRawMessage(t, "hello"),
FrequencyPenalty: tt.frequencyRaw,
PresencePenalty: tt.presenceRaw,
})
require.NoError(t, err)
assert.Equal(t, tt.frequencyWant, got.FrequencyPenalty)
assert.Equal(t, tt.presenceWant, got.PresencePenalty)
})
}
}
func TestResponsesRequestToChatCompletionsRequestRejectsMalformedPenalty(t *testing.T) {
_, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
Model: "gpt-test",
Input: mustRawMessage(t, "hello"),
FrequencyPenalty: json.RawMessage(`"not-a-number"`),
})
require.Error(t, err)
assert.Contains(t, err.Error(), "frequency_penalty")
}
func mustRawMessage(t *testing.T, value any) []byte {
t.Helper()
raw, err := kitutil.Marshal(value)
require.NoError(t, err)
return raw
}
@@ -0,0 +1,399 @@
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"
responsesEventOutputTextAnnotationAdded = "response.output_text.annotation.added"
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 annotations, err := responsesAnnotationsToChat(resp); err != nil {
return nil, nil, err
} else if len(annotations) > 0 {
msg.Annotations = annotations
}
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 responsesAnnotationsToChat(resp *dto.OpenAIResponsesResponse) ([]byte, error) {
annotations := make([]any, 0)
for _, output := range resp.Output {
if output.Type != responsesOutputTypeMessage {
continue
}
for _, content := range output.Content {
for _, annotation := range content.Annotations {
converted, err := responseAnnotationToChat(annotation)
if err != nil {
return nil, err
}
annotations = append(annotations, converted)
}
}
}
if len(annotations) == 0 {
return nil, nil
}
return kitutil.Marshal(annotations)
}
func responseAnnotationToChat(annotation any) (map[string]any, error) {
value, ok := annotation.(map[string]any)
if !ok {
converted, err := kitutil.Any2Type[map[string]any](annotation)
if err != nil {
return nil, fmt.Errorf("invalid Responses annotation: %w", err)
}
value = converted
}
if strings.TrimSpace(kitutil.Interface2String(value["type"])) != "url_citation" {
return value, nil
}
citation := make(map[string]any, len(value)-1)
for key, item := range value {
if key != "type" {
citation[key] = item
}
}
return map[string]any{
"type": "url_citation",
"url_citation": citation,
}, nil
}
func UsageFromResponsesUsage(src *dto.Usage) *dto.Usage {
return usageFromResponsesUsage(src, true)
}
// NormalizeResponsesUsage maps Responses usage into the shared accounting
// shape without creating a BillingUsage snapshot. Native Responses handlers
// use it so passthrough traffic preserves an existing snapshot but does not
// introduce a conversion sidecar solely for local settlement.
func NormalizeResponsesUsage(src *dto.Usage) *dto.Usage {
return usageFromResponsesUsage(src, false)
}
func usageFromResponsesUsage(src *dto.Usage, createBillingSnapshot bool) *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 && createBillingSnapshot {
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
}
var outputText strings.Builder
for _, c := range out.Content {
if c.Type == "output_text" && c.Text != "" {
outputText.WriteString(c.Text)
}
}
appendSeparatedText(&sb, outputText.String())
}
if sb.Len() > 0 {
return sb.String()
}
for _, out := range resp.Output {
var outputText strings.Builder
for _, c := range out.Content {
if c.Text != "" {
outputText.WriteString(c.Text)
}
}
appendSeparatedText(&sb, outputText.String())
}
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
}
appendSeparatedText(&sb, reasoningOutputText(&out))
}
return sb.String()
}
func reasoningOutputText(output *dto.ResponsesOutput) string {
if output == nil {
return ""
}
var text strings.Builder
hasContentText := false
for _, part := range output.Content {
if part.Text != "" {
hasContentText = true
break
}
}
if hasContentText {
for _, part := range output.Content {
appendSeparatedText(&text, part.Text)
}
return text.String()
}
for _, part := range output.Summary {
appendSeparatedText(&text, part.Text)
}
return text.String()
}
func appendSeparatedText(builder *strings.Builder, text string) {
if builder == nil || text == "" {
return
}
if builder.Len() > 0 {
current := builder.String()
trailingNewlines := 0
for index := len(current) - 1; index >= 0 && trailingNewlines < 2 && current[index] == '\n'; index-- {
trailingNewlines++
}
leadingNewlines := 0
for leadingNewlines < len(text) && leadingNewlines < 2 && text[leadingNewlines] == '\n' {
leadingNewlines++
}
for missing := 2 - trailingNewlines - leadingNewlines; missing > 0; missing-- {
builder.WriteByte('\n')
}
}
builder.WriteString(text)
}
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,547 @@
package oairesponses
import (
"strings"
"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,
Summary: []dto.ResponsesReasoningSummaryPart{
{Type: "summary_text", Text: "first summary"},
{Type: "summary_text", Text: "second 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 TestResponsesResponseToChatCompletionsSeparatesInterleavedOutputItems(t *testing.T) {
resp := &dto.OpenAIResponsesResponse{
ID: "resp_1",
Model: "gpt-test",
Status: []byte(`"completed"`),
Output: interleavedReasoningAndTextOutput(),
}
chat, _, err := ResponsesResponseToChatCompletionsResponse(resp, "chatcmpl_1")
require.NoError(t, err)
assert.Equal(t, "**Planning file inspection**\n\n**Clarifying environment task requirements**", chat.Choices[0].Message.GetReasoningContent())
assert.Equal(t, "I’ll inspect the starter repository.\n\nWhat would you like me to build?", 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"}`),
},
},
},
})...)
var totalArgs strings.Builder
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.WriteString(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.String())
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 TestResponsesBufferedAccumulatorPreservesInterleavedReasoningAndTextItems(t *testing.T) {
acc := NewResponsesBufferedAccumulator()
events := []dto.ResponsesStreamResponse{
bufferedOutputItemAdded(0, "rs_1", responsesOutputTypeReasoning),
{Type: responsesEventReasoningSummaryDelta, OutputIndex: intPointer(0), ItemID: "rs_1", Delta: "**Planning file inspection**"},
bufferedOutputItemAdded(1, "msg_1", responsesOutputTypeMessage),
{Type: responsesEventOutputTextDelta, OutputIndex: intPointer(1), ItemID: "msg_1", Delta: "I’ll inspect the starter repository."},
bufferedOutputItemAdded(2, "rs_2", responsesOutputTypeReasoning),
{Type: responsesEventReasoningSummaryDelta, OutputIndex: intPointer(2), ItemID: "rs_2", Delta: "**Clarifying environment task requirements**"},
bufferedOutputItemAdded(3, "msg_2", responsesOutputTypeMessage),
{Type: responsesEventOutputTextDelta, OutputIndex: intPointer(3), ItemID: "msg_2", Delta: "What would you like me to build?"},
}
for index := range events {
acc.ProcessEvent(&events[index])
}
output := acc.BuildOutput()
require.Len(t, output, 4)
assert.Equal(t, []string{
responsesOutputTypeReasoning,
responsesOutputTypeMessage,
responsesOutputTypeReasoning,
responsesOutputTypeMessage,
}, []string{output[0].Type, output[1].Type, output[2].Type, output[3].Type})
assert.Equal(t, "**Planning file inspection**", output[0].Summary[0].Text)
assert.Equal(t, "I’ll inspect the starter repository.", output[1].Content[0].Text)
assert.Equal(t, "**Clarifying environment task requirements**", output[2].Summary[0].Text)
assert.Equal(t, "What would you like me to build?", output[3].Content[0].Text)
}
func TestResponsesStreamTerminalOutputPreservesInterleavedReasoningAndTextItems(t *testing.T) {
state := newTestResponsesStreamState()
chunks, err := ResponsesStreamEventToChatChunks(&dto.ResponsesStreamResponse{
Type: responsesEventCompleted,
Response: &dto.OpenAIResponsesResponse{
Status: []byte(`"completed"`),
Output: interleavedReasoningAndTextOutput(),
},
}, state)
require.NoError(t, err)
var deltas []string
for _, chunk := range chunks {
if len(chunk.Choices) == 0 {
continue
}
delta := chunk.Choices[0].Delta
if delta.ReasoningContent != nil {
deltas = append(deltas, "thinking:"+*delta.ReasoningContent)
}
if delta.Content != nil && *delta.Content != "" {
deltas = append(deltas, "text:"+*delta.Content)
}
}
assert.Equal(t, []string{
"thinking:**Planning file inspection**",
"text:I’ll inspect the starter repository.",
"thinking:**Clarifying environment task requirements**",
"text:What would you like me to build?",
}, deltas)
}
func bufferedOutputItemAdded(outputIndex int, itemID string, itemType string) dto.ResponsesStreamResponse {
return dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: &outputIndex,
ItemID: itemID,
Item: &dto.ResponsesOutput{ID: itemID, Type: itemType},
}
}
func interleavedReasoningAndTextOutput() []dto.ResponsesOutput {
return []dto.ResponsesOutput{
{Type: responsesOutputTypeReasoning, Summary: []dto.ResponsesReasoningSummaryPart{{Type: "summary_text", Text: "**Planning file inspection**"}}},
{Type: responsesOutputTypeMessage, Role: "assistant", Content: []dto.ResponsesOutputContent{{Type: "output_text", Text: "I’ll inspect the starter repository."}}},
{Type: responsesOutputTypeReasoning, Summary: []dto.ResponsesReasoningSummaryPart{{Type: "summary_text", Text: "**Clarifying environment task requirements**"}}},
{Type: responsesOutputTypeMessage, Role: "assistant", Content: []dto.ResponsesOutputContent{{Type: "output_text", Text: "What would you like me to build?"}}},
}
}
func intPointer(value int) *int {
return &value
}
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,934 @@
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
sentAnnotationCount int
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) StreamUsage() *dto.Usage {
if s == nil {
return nil
}
return s.Usage
}
func (s *ResponsesToChatStreamState) SetStreamUsage(usage *dto.Usage) {
if s != nil && usage != nil {
s.Usage = usage
}
}
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 responsesEventOutputTextAnnotationAdded:
return state.annotationRawDelta(event.Annotation)
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, err := state.terminalOutputChunks(response)
if err != nil {
return nil, err
}
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 = dto.MergeUsageNonZero(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, error) {
if s == nil || response == nil || len(response.Output) == 0 {
return nil, nil
}
var chunks []dto.ChatCompletionsStreamResponse
hadSentText := s.hasSentText
hadSentReasoning := s.hasSentReasoning
annotationOffset := 0
for i := range response.Output {
out := &response.Output[i]
switch {
case out.Type == responsesOutputTypeMessage && !hadSentText:
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())...)
annotationChunks, err := s.remainingAnnotationChunks(out, annotationOffset)
if err != nil {
return nil, err
}
chunks = append(chunks, annotationChunks...)
annotationOffset += responsesOutputAnnotationCount(out)
case out.Type == responsesOutputTypeMessage:
annotationChunks, err := s.remainingAnnotationChunks(out, annotationOffset)
if err != nil {
return nil, err
}
chunks = append(chunks, annotationChunks...)
annotationOffset += responsesOutputAnnotationCount(out)
case out.Type == responsesOutputTypeReasoning && !hadSentReasoning:
chunks = append(chunks, s.reasoningDelta(reasoningOutputText(out))...)
case isResponsesToolOutputType(out.Type):
chunks = append(chunks, s.toolItem(&dto.ResponsesStreamResponse{Item: out})...)
}
}
return chunks, nil
}
func (s *ResponsesToChatStreamState) annotationRawDelta(raw []byte) ([]dto.ChatCompletionsStreamResponse, error) {
if len(raw) == 0 {
return nil, nil
}
var annotation map[string]any
if err := kitutil.Unmarshal(raw, &annotation); err != nil {
return nil, fmt.Errorf("invalid Responses stream annotation: %w", err)
}
return s.annotationDelta(annotation)
}
func (s *ResponsesToChatStreamState) annotationDelta(annotation any) ([]dto.ChatCompletionsStreamResponse, error) {
converted, err := responseAnnotationToChat(annotation)
if err != nil {
return nil, err
}
raw, err := kitutil.Marshal([]any{converted})
if err != nil {
return nil, fmt.Errorf("marshal Chat annotation: %w", err)
}
s.sentAnnotationCount++
chunks := s.ensureStart()
chunks = append(chunks, s.makeChunk(dto.ChatCompletionsStreamResponseChoiceDelta{
Annotations: raw,
}, nil))
return chunks, nil
}
func (s *ResponsesToChatStreamState) remainingAnnotationChunks(output *dto.ResponsesOutput, offset int) ([]dto.ChatCompletionsStreamResponse, error) {
if output == nil {
return nil, nil
}
annotations := make([]any, 0)
for _, content := range output.Content {
annotations = append(annotations, content.Annotations...)
}
start := max(s.sentAnnotationCount-offset, 0)
if start >= len(annotations) {
return nil, nil
}
var chunks []dto.ChatCompletionsStreamResponse
for _, annotation := range annotations[start:] {
converted, err := s.annotationDelta(annotation)
if err != nil {
return nil, err
}
chunks = append(chunks, converted...)
}
return chunks, nil
}
func responsesOutputAnnotationCount(output *dto.ResponsesOutput) int {
count := 0
for _, content := range output.Content {
count += len(content.Annotations)
}
return count
}
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 after, ok := strings.CutPrefix(key, "output:"); ok {
callID = "call_output_" + after
}
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 after, ok := strings.CutPrefix(key, "item:"); ok {
itemID := after
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 {
items []*responsesBufferedItem
outputIndexToItemIdx map[int]int
itemIDToItemIdx map[string]int
lastUnindexedItemIdx int
tools []*responsesBufferedTool
outputIndexToToolIdx map[int]int
itemIDToToolIdx map[string]int
pendingByOutputIndex map[int]string
pendingByItemID map[string]string
}
type responsesBufferedItem struct {
Type string
ID string
Text strings.Builder
Annotations []any
ToolIndex int
NeedsReasoningBreak bool
}
type responsesBufferedTool struct {
CallID string
ItemID string
Name string
Arguments strings.Builder
}
func NewResponsesBufferedAccumulator() *ResponsesBufferedAccumulator {
return &ResponsesBufferedAccumulator{
outputIndexToItemIdx: make(map[int]int),
itemIDToItemIdx: make(map[string]int),
lastUnindexedItemIdx: -1,
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:
item := a.ensureItem(event, responsesOutputTypeMessage)
item.Text.WriteString(event.Delta)
case responsesEventOutputTextAnnotationAdded:
item := a.ensureItem(event, responsesOutputTypeMessage)
var annotation any
if err := kitutil.Unmarshal(event.Annotation, &annotation); err == nil && annotation != nil {
item.Annotations = append(item.Annotations, annotation)
}
case responsesEventReasoningSummaryDelta, responsesEventReasoningTextDelta:
item := a.ensureItem(event, responsesOutputTypeReasoning)
if item.NeedsReasoningBreak {
appendSeparatedText(&item.Text, event.Delta)
item.NeedsReasoningBreak = false
} else {
item.Text.WriteString(event.Delta)
}
case responsesEventReasoningSummaryDone, responsesEventReasoningTextDone:
item := a.ensureItem(event, responsesOutputTypeReasoning)
if item.Text.Len() == 0 && event.Text != nil {
item.Text.WriteString(*event.Text)
}
if item.Text.Len() > 0 {
item.NeedsReasoningBreak = true
}
case responsesEventOutputItemAdded, responsesEventOutputItemDone:
if event.Item == nil {
return
}
switch {
case event.Item.Type == responsesOutputTypeReasoning:
item := a.ensureItem(event, event.Item.Type)
if item.Text.Len() == 0 {
item.Text.WriteString(reasoningOutputText(event.Item))
}
case event.Item.Type == responsesOutputTypeMessage:
item := a.ensureItem(event, event.Item.Type)
seedText := item.Text.Len() == 0
seedAnnotations := len(item.Annotations) == 0
for _, content := range event.Item.Content {
if content.Type == "output_text" {
if seedText {
item.Text.WriteString(content.Text)
}
if seedAnnotations {
item.Annotations = append(item.Annotations, content.Annotations...)
}
}
}
case 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, len(a.items))
for _, item := range a.items {
if item == nil {
continue
}
switch item.Type {
case responsesOutputTypeReasoning:
if item.Text.Len() == 0 {
continue
}
out = append(out, dto.ResponsesOutput{
Type: item.Type,
ID: item.ID,
Summary: []dto.ResponsesReasoningSummaryPart{
{Type: "summary_text", Text: item.Text.String()},
},
})
case responsesOutputTypeMessage:
if item.Text.Len() == 0 {
continue
}
out = append(out, dto.ResponsesOutput{
Type: item.Type,
ID: item.ID,
Role: "assistant",
Content: []dto.ResponsesOutputContent{
{Type: "output_text", Text: item.Text.String(), Annotations: item.Annotations},
},
})
case responsesOutputTypeFunctionCall, responsesOutputTypeCustomToolCall:
if item.ToolIndex < 0 || item.ToolIndex >= len(a.tools) || a.tools[item.ToolIndex] == nil {
continue
}
tool := a.tools[item.ToolIndex]
argsRaw, _ := kitutil.Marshal(tool.Arguments.String())
out = append(out, dto.ResponsesOutput{
Type: item.Type,
ID: tool.ItemID,
CallId: tool.CallID,
Name: tool.Name,
Arguments: argsRaw,
})
}
}
return out
}
func (a *ResponsesBufferedAccumulator) ensureItem(event *dto.ResponsesStreamResponse, itemType string) *responsesBufferedItem {
if idx, ok := a.findItemIndex(event); ok {
item := a.items[idx]
if item.Type == "" {
item.Type = itemType
}
a.applyItemMetadata(idx, item, event)
return item
}
if event != nil && event.OutputIndex == nil && responseStreamEventItemID(event) == "" && a.lastUnindexedItemIdx >= 0 {
item := a.items[a.lastUnindexedItemIdx]
if item != nil && item.Type == itemType {
return item
}
}
item := &responsesBufferedItem{Type: itemType, ToolIndex: -1}
idx := len(a.items)
a.items = append(a.items, item)
a.lastUnindexedItemIdx = idx
a.applyItemMetadata(idx, item, event)
return item
}
func (a *ResponsesBufferedAccumulator) applyItemMetadata(idx int, item *responsesBufferedItem, event *dto.ResponsesStreamResponse) {
if item == nil || event == nil {
return
}
if event.OutputIndex != nil {
a.outputIndexToItemIdx[*event.OutputIndex] = idx
}
if itemID := responseStreamEventItemID(event); itemID != "" {
item.ID = itemID
a.itemIDToItemIdx[itemID] = idx
}
}
func (a *ResponsesBufferedAccumulator) findItemIndex(event *dto.ResponsesStreamResponse) (int, bool) {
if event == nil {
return 0, false
}
if event.OutputIndex != nil {
if idx, ok := a.outputIndexToItemIdx[*event.OutputIndex]; ok {
return idx, true
}
}
if itemID := responseStreamEventItemID(event); itemID != "" {
idx, ok := a.itemIDToItemIdx[itemID]
return idx, ok
}
return 0, false
}
func (a *ResponsesBufferedAccumulator) ensureTool(event *dto.ResponsesStreamResponse) *responsesBufferedTool {
if idx, ok := a.findToolIndex(event); ok {
tool := a.tools[idx]
a.applyToolMetadata(tool, event)
item := a.ensureItem(event, event.Item.Type)
item.ToolIndex = idx
return tool
}
tool := &responsesBufferedTool{}
a.applyToolMetadata(tool, event)
idx := len(a.tools)
a.tools = append(a.tools, tool)
item := a.ensureItem(event, event.Item.Type)
item.ToolIndex = idx
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,168 @@
package claude
import (
"context"
"fmt"
"math"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/relaykit/types"
)
func ApplyReasoning(ctx context.Context, req *dto.ClaudeRequest, info convmeta.Meta, source reasoning.Intent, crossProtocol bool) error {
if req == nil {
return nil
}
opts := convmeta.OptionsOf(info)
baseModel := req.Model
capabilityModel := baseModel
suffix := reasoning.IntentFromState(convmeta.ReasoningStateOf(info))
preserveSuffix := opts.ShouldPreserveThinkingSuffix(req.Model)
if info != nil && opts.ShouldPreserveThinkingSuffix(info.GetOriginModelName()) {
preserveSuffix = true
}
if preserveSuffix {
suffix = reasoning.Intent{}
}
// A native Claude request without a host modifier is already in the target
// protocol, including Claude-compatible proxies that keep native controls
// instead of applying Anthropic model rules. Read portable effort for
// accounting metadata, but do not run the capability renderer or rewrite
// provider-native controls.
if !crossProtocol && source.IsEmpty() && suffix.IsEmpty() {
if info != nil {
effort := req.GetEfforts()
if effort == "" && req.Thinking != nil {
switch {
case req.Thinking.Type == "disabled":
effort = string(reasoning.EffortNone)
case req.Thinking.BudgetTokens != nil:
effort = string(reasoning.EffortFromBudget(*req.Thinking.BudgetTokens))
case req.Thinking.Type == "enabled" || req.Thinking.Type == "adaptive":
effort = string(reasoning.EffortHigh)
}
}
info.SetReasoningEffort(effort)
}
return nil
}
native, err := reasoning.FromClaude(req)
if err != nil {
return err
}
explicit, err := reasoning.MergeExplicit(native, source, req.Model)
if err != nil {
return err
}
if info != nil && !reasoning.IsKnownClaudeModel(capabilityModel) && reasoning.IsKnownClaudeModel(info.GetOriginModelName()) {
capabilityModel = info.GetOriginModelName()
}
intent, err := reasoning.MergeExplicitAndSuffix(explicit, suffix, req.Model)
if err != nil {
return err
}
knownClaudeModel := reasoning.IsKnownClaudeModel(capabilityModel)
if !knownClaudeModel && intent.Mode == reasoning.ModeAdaptive {
// Cross-protocol pivots cannot safely assume that an unknown
// Claude-compatible model implements Anthropic's adaptive mode. Render
// the broadly supported manual form while retaining the requested
// strength. Native Claude requests took the passthrough path above.
intent.Mode = reasoning.ModeEnabled
if intent.Effort == "" {
intent.Effort = reasoning.EffortHigh
}
}
if req.MaxTokens == nil && intent.HasStrength() {
// Adapter-provided defaults may be raised to accommodate an exact
// cross-protocol budget. Explicit client max_tokens values are never
// expanded and remain subject to the renderer's strict validation.
minimum := uint(1280)
if configuredDefault, configured := opts.Claude.DefaultMaxTokensFor(capabilityModel); configured && configuredDefault > 0 {
minimum = uint(configuredDefault)
}
if reasoning.ClaudeUsesManualThinking(capabilityModel, intent) && *intent.BudgetTokens >= 0 {
if *intent.BudgetTokens == math.MaxInt {
return fmt.Errorf("thinking budget is too large to derive max_tokens")
}
required := uint(*intent.BudgetTokens) + 1
const maxDerivedTokens = uint(math.MaxInt32 / 2)
if required > maxDerivedTokens {
return fmt.Errorf("thinking budget %d exceeds the supported conversion limit", *intent.BudgetTokens)
}
if minimum < required {
minimum = required
}
}
req.MaxTokens = &minimum
}
rendered, err := reasoning.RenderClaude(capabilityModel, intent, req.MaxTokens, opts.Claude.ThinkingAdapterBudgetTokensPercentage)
if err != nil {
return err
}
convdiag.Add(ctx, rendered.Diagnostics...)
req.Model = baseModel
if rendered.Thinking != nil {
req.Thinking = rendered.Thinking
}
if rendered.OutputEffort != "" {
outputConfig := make(map[string]any)
if len(req.OutputConfig) > 0 {
if kitutil.GetJsonType(req.OutputConfig) != "object" {
return fmt.Errorf("Claude output_config must be a JSON object")
}
if err := kitutil.Unmarshal(req.OutputConfig, &outputConfig); err != nil {
return fmt.Errorf("invalid Claude output_config: %w", err)
}
if outputConfig == nil {
outputConfig = make(map[string]any)
}
}
outputConfig["effort"] = string(rendered.OutputEffort)
encoded, err := kitutil.Marshal(outputConfig)
if err != nil {
return fmt.Errorf("failed to marshal Claude output_config: %w", err)
}
req.OutputConfig = encoded
}
if rendered.ClearSampling {
if req.Temperature != nil || req.TopP != nil || req.TopK != nil {
convdiag.Add(ctx, types.ConversionDiagnostic{
Code: "claude_sampling_removed",
Path: "temperature/top_p/top_k",
Message: fmt.Sprintf("model %q does not accept sampling controls with the selected thinking mode", capabilityModel),
Severity: types.ConversionDiagnosticWarning,
To: types.RelayFormatClaude,
})
}
req.Temperature = nil
req.TopP = nil
req.TopK = nil
} else if rendered.ConstrainThinkingSampling {
removedSampling := req.Temperature != nil || req.TopK != nil || req.TopP != nil && (*req.TopP < 0.95 || *req.TopP > 1)
req.Temperature = nil
req.TopK = nil
if req.TopP != nil && (*req.TopP < 0.95 || *req.TopP > 1) {
req.TopP = nil
}
if removedSampling {
convdiag.Add(ctx, types.ConversionDiagnostic{
Code: "claude_sampling_constrained",
Path: "temperature/top_p/top_k",
Message: fmt.Sprintf("model %q accepts only top_p between 0.95 and 1 with manual thinking", capabilityModel),
Severity: types.ConversionDiagnosticWarning,
To: types.RelayFormatClaude,
})
}
}
if info != nil && rendered.EffectiveEffort != "" {
info.SetReasoningEffort(string(rendered.EffectiveEffort))
}
return nil
}
@@ -0,0 +1,16 @@
package claude
func FunctionParametersToInputSchema(parameters any) map[string]any {
params, _ := parameters.(map[string]any)
schema := make(map[string]any, len(params)+2)
for key, value := range params {
schema[key] = value
}
if schema["type"] == nil {
schema["type"] = "object"
}
if schema["properties"] == nil {
schema["properties"] = map[string]any{}
}
return schema
}
@@ -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,52 @@
package claude
import "github.com/QuantumNous/new-api/relaykit/dto"
func UsageFromOpenAI(usage *dto.Usage) *dto.ClaudeUsage {
if usage == nil {
return nil
}
// An existing sidecar snapshots the original provider usage; carry it
// across this bridge unchanged regardless of its dialect. Only synthesize
// an OpenAI snapshot when no sidecar exists yet.
existingBillingUsage := dto.CloneBillingUsage(usage.BillingUsage)
if existingBillingUsage != nil && existingBillingUsage.ClaudeUsage != nil &&
(existingBillingUsage.Source == dto.BillingUsageSourceClaudeMessages || existingBillingUsage.Semantic == dto.BillingUsageSemanticAnthropic) {
result := existingBillingUsage.ClaudeUsage
result.BillingUsage = dto.CloneBillingUsage(usage.BillingUsage)
return result
}
billingUsage := existingBillingUsage
if billingUsage == nil {
billingUsage = dto.NewOpenAIChatBillingUsage(usage)
}
cacheCreation5m, cacheCreation1h := NormalizeCacheCreationSplit(
usage.PromptTokensDetails.CachedCreationTokens,
usage.ClaudeCacheCreation5mTokens,
usage.ClaudeCacheCreation1hTokens,
)
cacheCreationTokens := usage.PromptTokensDetails.CacheCreationTokensTotal()
inputTokens := usage.PromptTokens
if usage.UsageSemantic != dto.BillingUsageSemanticAnthropic {
// OpenAI-style prompt/input totals include cache reads and writes, while
// Claude reports both separately from input_tokens.
inputTokens = usage.PromptTokens - usage.PromptTokensDetails.CachedTokens - cacheCreationTokens
if inputTokens < 0 {
inputTokens = 0
}
}
result := &dto.ClaudeUsage{
InputTokens: inputTokens,
OutputTokens: usage.CompletionTokens,
CacheCreationInputTokens: cacheCreationTokens,
CacheReadInputTokens: usage.PromptTokensDetails.CachedTokens,
BillingUsage: billingUsage,
}
if cacheCreation5m > 0 || cacheCreation1h > 0 {
result.CacheCreation = &dto.ClaudeCacheCreationUsage{
Ephemeral5mInputTokens: cacheCreation5m,
Ephemeral1hInputTokens: cacheCreation1h,
}
}
return result
}
@@ -0,0 +1,250 @@
package gemini
import (
"fmt"
"strconv"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"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"
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) error {
opts := convmeta.OptionsOf(info)
if geminiRequest == nil {
return nil
}
modelName := convmeta.UpstreamModelName(info)
var source reasoning.Intent
crossProtocol := len(oaiRequest) > 0
if len(oaiRequest) > 0 {
if modelName == "" {
modelName = oaiRequest[0].Model
}
var err error
source, err = reasoning.FromOpenAIChat(&oaiRequest[0])
if err != nil {
return err
}
}
baseModel := modelName
suffix := reasoning.IntentFromState(convmeta.ReasoningStateOf(info))
preserveSuffix := opts.ShouldPreserveThinkingSuffix(modelName)
if info != nil && opts.ShouldPreserveThinkingSuffix(info.GetOriginModelName()) {
preserveSuffix = true
}
if preserveSuffix {
suffix = reasoning.Intent{}
}
// Native Gemini requests already use the target protocol. Without a host
// modifier, read portable effort metadata without running the capability
// renderer or rewriting provider-native controls.
if !crossProtocol && suffix.IsEmpty() {
if info != nil {
effort := ""
if config := geminiRequest.GenerationConfig.ThinkingConfig; config != nil {
effort = config.ThinkingLevel
if effort == "" && config.ThinkingBudget != nil {
effort = string(reasoning.EffortFromBudget(*config.ThinkingBudget))
}
}
info.SetReasoningEffort(effort)
}
return nil
}
native, err := reasoning.FromGemini(geminiRequest)
if err != nil {
return err
}
source = reasoning.ResolveGeminiEnabledDefault(baseModel, source, geminiRequest.GenerationConfig.MaxOutputTokens)
if native.HasStrength() && source.HasStrength() {
equivalent, compareErr := reasoning.EquivalentGeminiStrength(baseModel, native, source)
if compareErr != nil {
return compareErr
}
if !equivalent {
nativeEffort := reasoning.EffectiveEffort(native)
sourceEffort := reasoning.EffectiveEffort(source)
return fmt.Errorf("%w for model %q: Gemini thinking_config effort %q differs from standard effort %q", reasoning.ErrEffortConflict, modelName, nativeEffort, sourceEffort)
}
// Native Gemini configuration is the lossless representation. Once the
// two controls are equivalent, retain only portable visibility metadata
// from the standard representation.
if native.IncludeThoughts == nil {
native.IncludeThoughts = source.IncludeThoughts
}
source = reasoning.Intent{}
}
explicit, err := reasoning.MergeExplicit(native, source, modelName)
if err != nil {
return err
}
if explicit.HasStrength() && suffix.HasStrength() {
equivalent, compareErr := reasoning.EquivalentGeminiStrength(baseModel, explicit, suffix)
if compareErr != nil {
return compareErr
}
if equivalent {
if explicit.IncludeThoughts == nil {
explicit.IncludeThoughts = suffix.IncludeThoughts
}
suffix = reasoning.Intent{}
}
}
requested, err := reasoning.MergeExplicitAndSuffix(explicit, suffix, modelName)
if err != nil {
return err
}
requested = reasoning.ResolveGeminiEnabledDefault(baseModel, requested, geminiRequest.GenerationConfig.MaxOutputTokens)
if native.HasStrength() && !suffix.HasStrength() {
if explicit.IncludeThoughts != nil {
geminiRequest.GenerationConfig.ThinkingConfig.IncludeThoughts = explicit.IncludeThoughts
}
effort, err := reasoning.ValidateGeminiThinkingConfig(baseModel, geminiRequest.GenerationConfig.ThinkingConfig)
if err != nil {
return err
}
if info != nil && effort != "" {
info.SetReasoningEffort(string(effort))
}
return nil
}
if requested.IsEmpty() {
return nil
}
rendered, err := reasoning.RenderGemini(
baseModel,
requested,
geminiRequest.GenerationConfig.MaxOutputTokens,
opts.Gemini.ThinkingAdapterBudgetTokensPercentage,
)
if err != nil {
return err
}
geminiRequest.GenerationConfig.ThinkingConfig = rendered.Config
if info != nil && rendered.EffectiveEffort != "" {
info.SetReasoningEffort(string(rendered.EffectiveEffort))
}
return nil
}
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
}
@@ -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,997 @@
package toolconv
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
const maxClaudeWebSearchUses = 1000
func ExtractRequest(format types.RelayFormat, request any) (any, Set, error) {
switch format {
case types.RelayFormatOpenAI:
return extractOpenAIChatRequest(request)
case types.RelayFormatOpenAIResponses:
return extractOpenAIResponsesRequest(request)
case types.RelayFormatClaude:
return extractClaudeRequest(request)
case types.RelayFormatGemini:
return extractGeminiRequest(request)
default:
return request, Set{Source: format}, nil
}
}
func extractOpenAIChatRequest(request any) (any, Set, error) {
source, ok := request.(*dto.GeneralOpenAIRequest)
if !ok {
value, valueOK := request.(dto.GeneralOpenAIRequest)
if !valueOK {
return nil, Set{}, fmt.Errorf("expected OpenAI chat completions request, got %T", request)
}
source = &value
}
set := Set{Source: types.RelayFormatOpenAI}
set.ParallelAllowed = source.ParallelTooCalls
if len(source.Functions) > 0 {
var functions []dto.FunctionRequest
if err := kitutil.Unmarshal(source.Functions, &functions); err != nil {
return nil, Set{}, fmt.Errorf("invalid legacy functions: %w", err)
}
for _, function := range functions {
set.Definitions = append(set.Definitions, Definition{
Kind: KindFunction,
Execution: ExecutionClient,
Function: &Function{Name: function.Name, Description: function.Description, Parameters: function.Parameters, Strict: function.Strict},
})
}
}
for index, tool := range source.Tools {
if tool.Type == "function" || tool.Type == "" {
if definition, ok := decodeOpenAIChatPseudoHostedTool(tool.Function.Name); ok {
set.Definitions = append(set.Definitions, definition)
continue
}
set.Definitions = append(set.Definitions, Definition{
Kind: KindFunction,
Execution: ExecutionClient,
Function: &Function{
Name: tool.Function.Name,
Description: tool.Function.Description,
Parameters: tool.Function.Parameters,
Strict: tool.Function.Strict,
},
})
continue
}
if len(tool.Custom) == 0 {
return nil, Set{}, fmt.Errorf("tools[%d] has unsupported type %q without a native payload", index, tool.Type)
}
definition, err := decodeOpenAIResponsesDefinition(tool.Custom)
if err != nil {
return nil, Set{}, fmt.Errorf("tools[%d]: %w", index, err)
}
set.Definitions = append(set.Definitions, definition)
}
if source.WebSearchOptions != nil {
webSearch := &WebSearch{
SearchContextSize: source.WebSearchOptions.SearchContextSize,
}
location, err := decodeOpenAIChatLocation(source.WebSearchOptions.UserLocation)
if err != nil {
return nil, Set{}, err
}
webSearch.Location = location
set.Definitions = append(set.Definitions, Definition{
Kind: KindWebSearch,
Execution: ExecutionServer,
NativeType: "web_search_options",
WebSearch: webSearch,
})
}
if choice, err := decodeOpenAIChatChoice(source.ToolChoice); err != nil {
return nil, Set{}, err
} else if choice != nil {
set.Choice = choice
}
if len(source.FunctionCall) > 0 {
legacyChoice, err := decodeLegacyOpenAIFunctionChoice(source.FunctionCall)
if err != nil {
return nil, Set{}, err
}
if set.Choice != nil && legacyChoice != nil {
return nil, Set{}, fmt.Errorf("tool_choice and legacy function_call cannot both be converted")
}
set.Choice = legacyChoice
}
clone := *source
clone.Tools = nil
clone.ToolChoice = nil
clone.WebSearchOptions = nil
clone.Functions = nil
clone.FunctionCall = nil
clone.ParallelTooCalls = nil
return &clone, set, nil
}
func extractOpenAIResponsesRequest(request any) (any, Set, error) {
source, ok := request.(*dto.OpenAIResponsesRequest)
if !ok {
value, valueOK := request.(dto.OpenAIResponsesRequest)
if !valueOK {
return nil, Set{}, fmt.Errorf("expected OpenAI Responses request, got %T", request)
}
source = &value
}
set := Set{Source: types.RelayFormatOpenAIResponses}
set.ParallelAllowed = rawBoolPointer(source.ParallelToolCalls)
if len(source.Tools) > 0 {
var rawTools []json.RawMessage
if err := kitutil.Unmarshal(source.Tools, &rawTools); err != nil {
return nil, Set{}, fmt.Errorf("invalid Responses tools: %w", err)
}
for index, rawTool := range rawTools {
definition, err := decodeOpenAIResponsesDefinition(rawTool)
if err != nil {
return nil, Set{}, fmt.Errorf("tools[%d]: %w", index, err)
}
set.Definitions = append(set.Definitions, definition)
}
}
choice, err := decodeOpenAIResponsesChoice(source.ToolChoice)
if err != nil {
return nil, Set{}, err
}
set.Choice = choice
clone := *source
clone.Tools = nil
clone.ToolChoice = nil
clone.ParallelToolCalls = nil
sanitizedInput, history, err := extractOpenAIResponsesHostedHistory(source.Input)
if err != nil {
return nil, Set{}, err
}
clone.Input = sanitizedInput
set.History = history
return &clone, set, nil
}
func extractClaudeRequest(request any) (any, Set, error) {
source, ok := request.(*dto.ClaudeRequest)
if !ok {
value, valueOK := request.(dto.ClaudeRequest)
if !valueOK {
return nil, Set{}, fmt.Errorf("expected Claude Messages request, got %T", request)
}
source = &value
}
set := Set{Source: types.RelayFormatClaude}
if source.ToolChoice != nil {
rawChoice, rawErr := rawJSON(source.ToolChoice)
if rawErr == nil {
var choiceMap map[string]any
if kitutil.Unmarshal(rawChoice, &choiceMap) == nil {
if disabled, ok := choiceMap["disable_parallel_tool_use"].(bool); ok {
allowed := !disabled
set.ParallelAllowed = &allowed
}
}
}
}
if source.Tools != nil {
rawTools, err := rawJSON(source.Tools)
if err != nil {
return nil, Set{}, fmt.Errorf("invalid Claude tools: %w", err)
}
var tools []json.RawMessage
if err := kitutil.Unmarshal(rawTools, &tools); err != nil {
return nil, Set{}, fmt.Errorf("invalid Claude tools: %w", err)
}
for index, rawTool := range tools {
definition, err := decodeClaudeDefinition(rawTool)
if err != nil {
return nil, Set{}, fmt.Errorf("tools[%d]: %w", index, err)
}
set.Definitions = append(set.Definitions, definition)
}
}
choice, err := decodeClaudeChoice(source.ToolChoice, set.Definitions)
if err != nil {
return nil, Set{}, err
}
set.Choice = choice
clone := *source
clone.Tools = nil
clone.ToolChoice = nil
clone.Messages, set.History, err = extractClaudeHostedHistory(source.Messages)
if err != nil {
return nil, Set{}, err
}
return &clone, set, nil
}
func extractGeminiRequest(request any) (any, Set, error) {
source, ok := request.(*dto.GeminiChatRequest)
if !ok {
value, valueOK := request.(dto.GeminiChatRequest)
if !valueOK {
return nil, Set{}, fmt.Errorf("expected Gemini generateContent request, got %T", request)
}
source = &value
}
set := Set{Source: types.RelayFormatGemini}
if source.ToolConfig != nil {
set.NativeToolConfig, _ = rawJSON(source.ToolConfig)
}
if len(source.Tools) > 0 {
var tools []json.RawMessage
if err := kitutil.Unmarshal(source.Tools, &tools); err != nil {
return nil, Set{}, fmt.Errorf("invalid Gemini tools: %w", err)
}
for index, rawTool := range tools {
definitions, err := decodeGeminiDefinitions(rawTool)
if err != nil {
return nil, Set{}, fmt.Errorf("tools[%d]: %w", index, err)
}
for definitionIndex := range definitions {
definitions[definitionIndex].Group = index
}
set.Definitions = append(set.Definitions, definitions...)
}
}
set.Choice = decodeGeminiChoice(source.ToolConfig)
clone := *source
clone.Tools = nil
clone.ToolConfig = nil
return &clone, set, nil
}
func decodeOpenAIResponsesDefinition(raw json.RawMessage) (Definition, error) {
var tool map[string]any
if err := kitutil.Unmarshal(raw, &tool); err != nil {
return Definition{}, err
}
toolType := strings.TrimSpace(kitutil.Interface2String(tool["type"]))
if toolType == "function" {
return Definition{
Kind: KindFunction,
Execution: ExecutionClient,
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Raw: cloneRaw(raw),
Function: &Function{
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Description: kitutil.Interface2String(tool["description"]),
Parameters: tool["parameters"],
Strict: boolPointer(tool, "strict"),
},
}, nil
}
if isOpenAIResponsesWebSearchType(toolType) {
webSearch := &WebSearch{
SearchContextSize: strings.TrimSpace(kitutil.Interface2String(tool["search_context_size"])),
ExternalWebAccess: boolPointer(tool, "external_web_access"),
}
if value, exists := tool["return_token_budget"]; exists {
encoded, err := rawJSON(value)
if err != nil {
return Definition{}, err
}
webSearch.ReturnTokenBudget = encoded
}
if filters, ok := tool["filters"].(map[string]any); ok {
webSearch.AllowedDomains = stringSlice(filters["allowed_domains"])
}
if location, ok := tool["user_location"].(map[string]any); ok {
webSearch.Location = locationFromMap(location)
}
return Definition{
Kind: KindWebSearch,
Execution: ExecutionServer,
NativeType: toolType,
WebSearch: webSearch,
Raw: cloneRaw(raw),
}, nil
}
return Definition{
Kind: kindFromNativeType(toolType),
Execution: executionFromNativeType(toolType),
NativeType: toolType,
Raw: cloneRaw(raw),
}, nil
}
func decodeClaudeDefinition(raw json.RawMessage) (Definition, error) {
var tool map[string]any
if err := kitutil.Unmarshal(raw, &tool); err != nil {
return Definition{}, err
}
toolType := strings.TrimSpace(kitutil.Interface2String(tool["type"]))
if strings.HasPrefix(toolType, "web_search") {
if !isVersionedClaudeWebSearchType(toolType) {
return Definition{}, fmt.Errorf("invalid Claude web-search tool version %q", toolType)
}
if !isKnownClaudeWebSearchType(toolType) {
return Definition{
Kind: KindNative,
Execution: ExecutionServer,
NativeType: toolType,
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Raw: cloneRaw(raw),
}, nil
}
toolName := strings.TrimSpace(kitutil.Interface2String(tool["name"]))
if toolName != "web_search" {
return Definition{}, fmt.Errorf("Claude web-search tool name must be %q", "web_search")
}
webSearch := &WebSearch{
AllowedDomains: stringSlice(tool["allowed_domains"]),
BlockedDomains: stringSlice(tool["blocked_domains"]),
AllowedCallers: stringSlice(tool["allowed_callers"]),
ResponseInclusion: strings.TrimSpace(kitutil.Interface2String(tool["response_inclusion"])),
}
if _, exists := tool["max_uses"]; exists {
var fields struct {
MaxUses *int `json:"max_uses"`
}
if err := kitutil.Unmarshal(raw, &fields); err != nil || fields.MaxUses == nil {
return Definition{}, fmt.Errorf("max_uses must be a JSON integer")
}
if *fields.MaxUses <= 0 || *fields.MaxUses > maxClaudeWebSearchUses {
return Definition{}, fmt.Errorf("max_uses must be between 1 and %d", maxClaudeWebSearchUses)
}
webSearch.MaxUses = fields.MaxUses
}
if len(webSearch.AllowedDomains) > 0 && len(webSearch.BlockedDomains) > 0 {
return Definition{}, fmt.Errorf("allowed_domains and blocked_domains are mutually exclusive")
}
if webSearch.ResponseInclusion != "" && !claudeWebSearchSupportsResponseInclusion(toolType) {
return Definition{}, fmt.Errorf("response_inclusion requires Claude web_search_20260318")
}
if location, ok := tool["user_location"].(map[string]any); ok {
webSearch.Location = locationFromMap(location)
}
return Definition{
Kind: KindWebSearch,
Execution: ExecutionServer,
NativeType: toolType,
Name: toolName,
WebSearch: webSearch,
Raw: cloneRaw(raw),
}, nil
}
if toolType == "" {
return Definition{
Kind: KindFunction,
Execution: ExecutionClient,
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Raw: cloneRaw(raw),
Function: &Function{
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Description: kitutil.Interface2String(tool["description"]),
Parameters: tool["input_schema"],
Strict: boolPointer(tool, "strict"),
},
}, nil
}
return Definition{
Kind: kindFromNativeType(toolType),
Execution: executionFromNativeType(toolType),
NativeType: toolType,
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Raw: cloneRaw(raw),
}, nil
}
func decodeGeminiDefinitions(raw json.RawMessage) ([]Definition, error) {
var tool map[string]any
if err := kitutil.Unmarshal(raw, &tool); err != nil {
return nil, err
}
definitions := make([]Definition, 0)
if functions, ok := tool["functionDeclarations"].([]any); ok {
for _, value := range functions {
function, ok := value.(map[string]any)
if !ok {
continue
}
parameters := function["parameters"]
parametersJSONSchema, hasParametersJSONSchema := function["parametersJsonSchema"]
if parameters != nil && hasParametersJSONSchema && parametersJSONSchema != nil {
return nil, fmt.Errorf("function %q declares both parameters and parametersJsonSchema", strings.TrimSpace(kitutil.Interface2String(function["name"])))
}
if parameters == nil && hasParametersJSONSchema {
parameters = parametersJSONSchema
}
functionRaw, err := rawJSON(map[string]any{"functionDeclarations": []any{value}})
if err != nil {
return nil, err
}
definitions = append(definitions, Definition{
Kind: KindFunction,
Execution: ExecutionClient,
Name: strings.TrimSpace(kitutil.Interface2String(function["name"])),
Raw: functionRaw,
Function: &Function{
Name: strings.TrimSpace(kitutil.Interface2String(function["name"])),
Description: kitutil.Interface2String(function["description"]),
Parameters: parameters,
},
})
}
}
for key := range tool {
var kind Kind
var nativeType string
switch key {
case "functionDeclarations":
continue
case "googleSearch":
kind, nativeType = KindWebSearch, "googleSearch"
case "googleSearchRetrieval":
kind, nativeType = KindWebSearch, "googleSearchRetrieval"
case "enterpriseWebSearch":
kind, nativeType = KindWebSearch, "enterpriseWebSearch"
case "googleMaps":
kind, nativeType = KindNative, "googleMaps"
case "codeExecution":
kind, nativeType = KindCodeExecution, "codeExecution"
case "urlContext":
kind, nativeType = KindURLContext, "urlContext"
case "fileSearch":
kind, nativeType = KindFileSearch, "fileSearch"
case "computerUse":
kind, nativeType = KindComputerUse, "computerUse"
case "retrieval":
kind, nativeType = KindFileSearch, "retrieval"
default:
kind, nativeType = KindNative, key
}
keyRaw, err := rawJSON(map[string]any{key: tool[key]})
if err != nil {
return nil, err
}
definition := Definition{
Kind: kind,
Execution: ExecutionServer,
NativeType: nativeType,
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Raw: keyRaw,
}
if kind == KindWebSearch {
definition.WebSearch = &WebSearch{}
}
definitions = append(definitions, definition)
}
return definitions, nil
}
func decodeLegacyOpenAIFunctionChoice(raw json.RawMessage) (*Choice, error) {
if len(raw) == 0 {
return nil, nil
}
if kitutil.GetJsonType(raw) == "string" {
var value string
if err := kitutil.Unmarshal(raw, &value); err != nil {
return nil, err
}
return choiceFromString(value), nil
}
var value map[string]any
if err := kitutil.Unmarshal(raw, &value); err != nil {
return nil, fmt.Errorf("invalid legacy function_call: %w", err)
}
name := strings.TrimSpace(kitutil.Interface2String(value["name"]))
if name == "" {
return nil, fmt.Errorf("legacy function_call requires name")
}
return &Choice{Mode: ChoiceNamed, Kind: KindFunction, Name: name}, nil
}
func rawBoolPointer(raw json.RawMessage) *bool {
if len(raw) == 0 || kitutil.GetJsonType(raw) != "boolean" {
return nil
}
var value bool
if kitutil.Unmarshal(raw, &value) != nil {
return nil
}
return &value
}
// decodeOpenAIChatPseudoHostedTool recognizes the OpenAI Chat dialect that
// declares Gemini hosted tools as function definitions named googleSearch,
// codeExecution, or urlContext. The names are the historical public contract;
// recognition lives here so every target format goes through the same hosted
// ToolDefinition pipeline.
func decodeOpenAIChatPseudoHostedTool(name string) (Definition, bool) {
switch name {
case "googleSearch":
return Definition{
Kind: KindWebSearch,
Execution: ExecutionServer,
NativeType: "googleSearch",
Name: "googleSearch",
WebSearch: &WebSearch{},
}, true
case "codeExecution":
return Definition{
Kind: KindCodeExecution,
Execution: ExecutionServer,
NativeType: "codeExecution",
Name: "codeExecution",
}, true
case "urlContext":
return Definition{
Kind: KindURLContext,
Execution: ExecutionServer,
NativeType: "urlContext",
Name: "urlContext",
}, true
default:
return Definition{}, false
}
}
func decodeOpenAIChatLocation(raw json.RawMessage) (*ApproximateLocation, error) {
if len(raw) == 0 {
return nil, nil
}
var wrapper map[string]any
if err := kitutil.Unmarshal(raw, &wrapper); err != nil {
return nil, fmt.Errorf("invalid web_search_options.user_location: %w", err)
}
location, ok := wrapper["approximate"].(map[string]any)
if !ok {
return nil, nil
}
return locationFromMap(location), nil
}
func decodeOpenAIChatChoice(value any) (*Choice, error) {
if value == nil {
return nil, nil
}
if text, ok := value.(string); ok {
return choiceFromString(text), nil
}
raw, err := rawJSON(value)
if err != nil {
return nil, fmt.Errorf("invalid Chat tool_choice: %w", err)
}
var choice map[string]any
if err := kitutil.Unmarshal(raw, &choice); err != nil {
return nil, fmt.Errorf("invalid Chat tool_choice: %w", err)
}
if strings.TrimSpace(kitutil.Interface2String(choice["type"])) != "function" {
return &Choice{Mode: ChoiceOpaque, Raw: cloneRaw(raw)}, nil
}
function, _ := choice["function"].(map[string]any)
name := strings.TrimSpace(kitutil.Interface2String(function["name"]))
if name == "" {
return nil, fmt.Errorf("Chat function tool_choice requires function.name")
}
return &Choice{Mode: ChoiceNamed, Kind: KindFunction, Name: name}, nil
}
func decodeOpenAIResponsesChoice(raw json.RawMessage) (*Choice, error) {
if len(raw) == 0 {
return nil, nil
}
if kitutil.GetJsonType(raw) == "string" {
var text string
if err := kitutil.Unmarshal(raw, &text); err != nil {
return nil, err
}
return choiceFromString(text), nil
}
var value map[string]any
if err := kitutil.Unmarshal(raw, &value); err != nil {
return nil, fmt.Errorf("invalid Responses tool_choice: %w", err)
}
toolType := strings.TrimSpace(kitutil.Interface2String(value["type"]))
if toolType == "function" {
name := strings.TrimSpace(kitutil.Interface2String(value["name"]))
if name == "" {
return nil, fmt.Errorf("Responses function tool_choice requires name")
}
return &Choice{Mode: ChoiceNamed, Kind: KindFunction, Name: name}, nil
}
if isOpenAIResponsesWebSearchType(toolType) {
return &Choice{Mode: ChoiceNamed, Kind: KindWebSearch, Name: "web_search", NativeType: toolType, Raw: cloneRaw(raw)}, nil
}
return &Choice{Mode: ChoiceOpaque, Kind: kindFromNativeType(toolType), NativeType: toolType, Raw: cloneRaw(raw)}, nil
}
func decodeClaudeChoice(value any, definitions []Definition) (*Choice, error) {
if value == nil {
return nil, nil
}
raw, err := rawJSON(value)
if err != nil {
return nil, fmt.Errorf("invalid Claude tool_choice: %w", err)
}
var choice map[string]any
if err := kitutil.Unmarshal(raw, &choice); err != nil {
return nil, fmt.Errorf("invalid Claude tool_choice: %w", err)
}
choiceType := strings.TrimSpace(kitutil.Interface2String(choice["type"]))
var decoded *Choice
switch choiceType {
case "auto":
decoded = &Choice{Mode: ChoiceAuto}
case "none":
decoded = &Choice{Mode: ChoiceNone}
case "any":
decoded = &Choice{Mode: ChoiceRequired}
case "tool":
name := strings.TrimSpace(kitutil.Interface2String(choice["name"]))
kind := KindNative
matches := 0
for _, definition := range definitions {
definitionName := definition.Name
if definition.Kind == KindFunction && definition.Function != nil {
definitionName = definition.Function.Name
}
if definitionName != name {
continue
}
matches++
kind = definition.Kind
}
if matches > 1 {
return nil, fmt.Errorf("Claude tool_choice name %q is ambiguous across %d definitions", name, matches)
}
decoded = &Choice{Mode: ChoiceNamed, Kind: kind, Name: name}
default:
decoded = &Choice{Mode: ChoiceOpaque}
}
if disabled, ok := choice["disable_parallel_tool_use"].(bool); ok {
decoded.DisableParallelToolUse = &disabled
}
decoded.Raw = cloneRaw(raw)
return decoded, nil
}
func decodeGeminiChoice(config *dto.ToolConfig) *Choice {
if config == nil || config.FunctionCallingConfig == nil {
return nil
}
functionConfig := config.FunctionCallingConfig
switch strings.ToUpper(strings.TrimSpace(string(functionConfig.Mode))) {
case "NONE":
return &Choice{Mode: ChoiceNone}
case "ANY":
if len(functionConfig.AllowedFunctionNames) == 1 {
return &Choice{Mode: ChoiceNamed, Kind: KindFunction, Name: functionConfig.AllowedFunctionNames[0]}
}
return &Choice{
Mode: ChoiceRequired,
Kind: KindFunction,
AllowedNames: append([]string(nil), functionConfig.AllowedFunctionNames...),
}
case "", "AUTO":
return &Choice{Mode: ChoiceAuto}
default:
raw, _ := rawJSON(functionConfig)
return &Choice{Mode: ChoiceOpaque, Raw: raw}
}
}
func choiceFromString(value string) *Choice {
switch strings.ToLower(strings.TrimSpace(value)) {
case "none":
return &Choice{Mode: ChoiceNone}
case "required", "any":
return &Choice{Mode: ChoiceRequired}
case "auto":
return &Choice{Mode: ChoiceAuto}
default:
raw, _ := rawJSON(value)
return &Choice{Mode: ChoiceOpaque, Raw: raw}
}
}
func isOpenAIResponsesWebSearchType(toolType string) bool {
switch toolType {
case "web_search", "web_search_2025_08_26", "web_search_preview", "web_search_preview_2025_03_11":
return true
default:
return false
}
}
func claudeWebSearchSupportsResponseInclusion(toolType string) bool {
return toolType == "web_search_20260318"
}
func isKnownClaudeWebSearchType(toolType string) bool {
switch toolType {
case "web_search_20250305", "web_search_20260209", "web_search_20260318":
return true
default:
return false
}
}
func isVersionedClaudeWebSearchType(toolType string) bool {
const prefix = "web_search_"
version := strings.TrimPrefix(toolType, prefix)
if !strings.HasPrefix(toolType, prefix) || len(version) != 8 {
return false
}
_, err := strconv.ParseUint(version, 10, 32)
return err == nil
}
func locationFromMap(value map[string]any) *ApproximateLocation {
if len(value) == 0 {
return nil
}
location := &ApproximateLocation{
City: strings.TrimSpace(kitutil.Interface2String(value["city"])),
Region: strings.TrimSpace(kitutil.Interface2String(value["region"])),
Country: strings.TrimSpace(kitutil.Interface2String(value["country"])),
Timezone: strings.TrimSpace(kitutil.Interface2String(value["timezone"])),
}
if location.City == "" && location.Region == "" && location.Country == "" && location.Timezone == "" {
return nil
}
return location
}
func boolPointer(value map[string]any, key string) *bool {
raw, exists := value[key]
if !exists {
return nil
}
parsed, ok := raw.(bool)
if !ok {
return nil
}
return &parsed
}
func stringSlice(value any) []string {
items, ok := value.([]any)
if !ok {
if strings, stringsOK := value.([]string); stringsOK {
return append([]string(nil), strings...)
}
return nil
}
result := make([]string, 0, len(items))
for _, item := range items {
if text, ok := item.(string); ok && strings.TrimSpace(text) != "" {
result = append(result, text)
}
}
return result
}
func rawJSON(value any) (json.RawMessage, error) {
switch raw := value.(type) {
case json.RawMessage:
return cloneRaw(raw), nil
case []byte:
return cloneRaw(raw), nil
default:
encoded, err := kitutil.Marshal(value)
return json.RawMessage(encoded), err
}
}
func cloneRaw(raw []byte) json.RawMessage {
return append(json.RawMessage(nil), raw...)
}
func kindFromNativeType(toolType string) Kind {
switch {
case toolType == "file_search":
return KindFileSearch
case strings.HasPrefix(toolType, "web_fetch"):
return KindWebFetch
case toolType == "code_interpreter", strings.HasPrefix(toolType, "code_execution"):
return KindCodeExecution
case strings.Contains(toolType, "computer"):
return KindComputerUse
case toolType == "url_context":
return KindURLContext
case toolType == "mcp", toolType == "mcp_toolset":
return KindMCP
case toolType == "image_generation":
return KindImage
default:
return KindNative
}
}
func executionFromNativeType(toolType string) Execution {
if strings.HasPrefix(toolType, "computer_") || strings.HasPrefix(toolType, "bash_") || strings.HasPrefix(toolType, "text_editor_") || strings.HasPrefix(toolType, "memory_") {
return ExecutionClient
}
return ExecutionServer
}
func extractOpenAIResponsesHostedHistory(input json.RawMessage) (json.RawMessage, []HostedHistoryItem, error) {
if len(input) == 0 || kitutil.GetJsonType(input) != "array" {
return input, nil, nil
}
var rawItems []json.RawMessage
if err := kitutil.Unmarshal(input, &rawItems); err != nil {
return nil, nil, fmt.Errorf("invalid Responses input: %w", err)
}
filtered := make([]json.RawMessage, 0, len(rawItems))
var history []HostedHistoryItem
for index, rawItem := range rawItems {
var item map[string]any
if err := kitutil.Unmarshal(rawItem, &item); err != nil {
return nil, nil, fmt.Errorf("input[%d]: %w", index, err)
}
itemType := strings.TrimSpace(kitutil.Interface2String(item["type"]))
if !isResponsesHostedHistoryType(itemType) {
filtered = append(filtered, rawItem)
continue
}
status := strings.TrimSpace(kitutil.Interface2String(item["status"]))
action := rawMapValue(item, "action")
results := firstRawMapValue(item, "results", "sources", "output")
if itemType == "mcp_call" {
action = rawMapValue(item, "arguments")
output := rawMapValue(item, "output")
itemError := rawMapValue(item, "error")
results = output
if rawJSONPresent(itemError) {
results = itemError
status = "failed"
}
}
history = append(history, HostedHistoryItem{
Kind: hostedKindFromResponsesType(itemType),
NativeType: itemType,
Role: strings.TrimSpace(kitutil.Interface2String(item["role"])),
MessageIndex: index,
Sequence: index,
ID: strings.TrimSpace(kitutil.Interface2String(item["id"])),
CallID: strings.TrimSpace(kitutil.Interface2String(item["call_id"])),
Name: strings.TrimSpace(kitutil.Interface2String(item["name"])),
ServerName: strings.TrimSpace(kitutil.Interface2String(item["server_label"])),
Status: status,
Action: action,
Results: results,
Caller: rawMapValue(item, "caller"),
Raw: cloneRaw(rawItem),
})
}
if len(history) == 0 {
return input, nil, nil
}
encoded, err := kitutil.Marshal(filtered)
if err != nil {
return nil, nil, err
}
return encoded, history, nil
}
func isResponsesHostedHistoryType(itemType string) bool {
switch strings.TrimSpace(itemType) {
case "web_search_call", "file_search_call", "code_interpreter_call", "computer_call", "computer_call_output", "image_generation_call", "local_shell_call", "local_shell_call_output", "apply_patch_call", "apply_patch_call_output", "mcp_call", "mcp_list_tools", "mcp_approval_request", "mcp_approval_response":
return true
default:
return false
}
}
func extractClaudeHostedHistory(messages []dto.ClaudeMessage) ([]dto.ClaudeMessage, []HostedHistoryItem, error) {
clonedMessages := make([]dto.ClaudeMessage, 0, len(messages))
var history []HostedHistoryItem
for messageIndex := range messages {
message := messages[messageIndex]
if message.IsStringContent() {
clonedMessages = append(clonedMessages, message)
continue
}
rawContent, err := rawJSON(message.Content)
if err != nil {
return nil, nil, fmt.Errorf("messages[%d].content: %w", messageIndex, err)
}
var blocks []json.RawMessage
if err := kitutil.Unmarshal(rawContent, &blocks); err != nil {
return nil, nil, fmt.Errorf("messages[%d].content: %w", messageIndex, err)
}
filtered := make([]any, 0, len(blocks))
historyStart := len(history)
for blockIndex, rawBlock := range blocks {
var block map[string]any
if err := kitutil.Unmarshal(rawBlock, &block); err != nil {
return nil, nil, fmt.Errorf("messages[%d].content[%d]: %w", messageIndex, blockIndex, err)
}
blockType := strings.TrimSpace(kitutil.Interface2String(block["type"]))
if blockType != "server_tool_use" && blockType != "mcp_tool_use" && !isClaudeHostedToolBlock(blockType) {
filtered = append(filtered, block)
continue
}
name := strings.TrimSpace(kitutil.Interface2String(block["name"]))
kind := hostedKindFromClaudeCall(blockType, name)
if strings.HasSuffix(blockType, "_tool_result") {
kind = hostedKindFromClaudeResult(blockType)
}
results := rawMapValue(block, "content")
status := "in_progress"
if strings.HasSuffix(blockType, "_tool_result") {
status = "completed"
isError, _ := block["is_error"].(bool)
failed, _ := claudeHostedResultFailure(
blockType,
results,
&isError,
strings.TrimSpace(kitutil.Interface2String(block["error_code"])),
)
if failed {
status = "failed"
}
}
history = append(history, HostedHistoryItem{
Kind: kind,
NativeType: blockType,
Role: message.Role,
MessageIndex: messageIndex,
BlockIndex: blockIndex,
Sequence: len(history),
ID: strings.TrimSpace(kitutil.Interface2String(block["id"])),
CallID: strings.TrimSpace(kitutil.Interface2String(block["tool_use_id"])),
Name: name,
ServerName: strings.TrimSpace(kitutil.Interface2String(block["server_name"])),
Status: status,
Action: rawMapValue(block, "input"),
Results: results,
Caller: rawMapValue(block, "caller"),
Raw: cloneRaw(rawBlock),
})
}
if len(filtered) > 0 {
for index := historyStart; index < len(history); index++ {
history[index].MessageHasRegular = true
}
message.Content = filtered
clonedMessages = append(clonedMessages, message)
}
}
return clonedMessages, history, nil
}
func rawMapValue(value map[string]any, key string) json.RawMessage {
item, exists := value[key]
if !exists {
return nil
}
encoded, err := kitutil.Marshal(item)
if err != nil {
return nil
}
return encoded
}
func firstRawMapValue(value map[string]any, keys ...string) json.RawMessage {
for _, key := range keys {
if raw := rawMapValue(value, key); len(raw) > 0 {
return raw
}
}
return nil
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,171 @@
package toolconv
import (
"encoding/json"
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
// claudeWebSearchInputFromResponses narrows the richer Responses action union
// to the only operation exposed by Claude's web-search server tool: one query.
func claudeWebSearchInputFromResponses(raw json.RawMessage) (map[string]any, error) {
canonical, err := dto.NormalizeResponsesWebSearchAction(raw)
if err != nil {
return nil, err
}
var action struct {
Type string `json:"type"`
Query string `json:"query"`
Queries []string `json:"queries"`
}
if err := kitutil.Unmarshal(canonical, &action); err != nil {
return nil, fmt.Errorf("decode normalized Responses web-search action: %w", err)
}
if action.Type != "search" {
return nil, fmt.Errorf("Responses web-search action %q has no Claude equivalent", action.Type)
}
queries := make([]string, 0, len(action.Queries)+1)
for _, query := range action.Queries {
query = strings.TrimSpace(query)
if query != "" {
queries = append(queries, query)
}
}
deprecatedQuery := strings.TrimSpace(action.Query)
if len(queries) == 0 && deprecatedQuery != "" {
queries = append(queries, deprecatedQuery)
} else if deprecatedQuery != "" && (len(queries) != 1 || queries[0] != deprecatedQuery) {
return nil, fmt.Errorf("Responses web-search action contains conflicting query and queries fields")
}
if len(queries) != 1 {
return nil, fmt.Errorf("Claude web search requires exactly one query, got %d", len(queries))
}
return map[string]any{"query": queries[0]}, nil
}
// responsesMCPArgumentsFromClaude converts Claude's JSON-object input into the
// JSON string required by a Responses mcp_call.arguments field.
func responsesMCPArgumentsFromClaude(raw json.RawMessage) (json.RawMessage, error) {
trimmed := strings.TrimSpace(string(raw))
if trimmed == "" || kitutil.GetJsonType(raw) != "object" {
return nil, fmt.Errorf("Claude MCP input must be a JSON object")
}
var object map[string]json.RawMessage
if err := kitutil.Unmarshal(raw, &object); err != nil {
return nil, fmt.Errorf("decode Claude MCP input: %w", err)
}
encoded, err := kitutil.Marshal(trimmed)
if err != nil {
return nil, fmt.Errorf("encode Responses MCP arguments: %w", err)
}
return encoded, nil
}
// claudeMCPInputFromResponses decodes the outer Responses JSON string and
// validates that its contents satisfy Claude's JSON-object input contract.
func claudeMCPInputFromResponses(raw json.RawMessage) (any, error) {
var encoded string
if len(raw) == 0 || kitutil.GetJsonType(raw) != "string" {
return nil, fmt.Errorf("Responses MCP arguments must be a JSON string")
}
if err := kitutil.Unmarshal(raw, &encoded); err != nil {
return nil, fmt.Errorf("decode Responses MCP arguments string: %w", err)
}
encoded = strings.TrimSpace(encoded)
if encoded == "" || kitutil.GetJsonType(json.RawMessage(encoded)) != "object" {
return nil, fmt.Errorf("Responses MCP arguments must contain a JSON object")
}
var input map[string]any
if err := kitutil.Unmarshal([]byte(encoded), &input); err != nil {
return nil, fmt.Errorf("decode Responses MCP arguments object: %w", err)
}
return input, nil
}
// responsesMCPStringFromClaudeContent maps the Claude result shapes that can
// be represented without changing their meaning. A single text block is the
// structured form of a plain MCP text result; other block arrays can contain
// media/resources that a Responses string cannot faithfully preserve.
func responsesMCPStringFromClaudeContent(raw json.RawMessage) (json.RawMessage, bool, error) {
switch kitutil.GetJsonType(raw) {
case "string":
var value string
if err := kitutil.Unmarshal(raw, &value); err != nil {
return nil, false, fmt.Errorf("decode Claude MCP result string: %w", err)
}
return append(json.RawMessage(nil), raw...), false, nil
case "array":
var blocks []map[string]json.RawMessage
if err := kitutil.Unmarshal(raw, &blocks); err != nil {
return nil, false, fmt.Errorf("decode Claude MCP result blocks: %w", err)
}
if len(blocks) == 0 {
encoded, err := kitutil.Marshal("")
return encoded, true, err
}
if len(blocks) != 1 {
return nil, false, fmt.Errorf("Responses MCP output cannot preserve %d Claude content blocks", len(blocks))
}
var blockType string
if err := kitutil.Unmarshal(blocks[0]["type"], &blockType); err != nil || blockType != "text" {
return nil, false, fmt.Errorf("Responses MCP output can only preserve a Claude text result block")
}
var text string
if err := kitutil.Unmarshal(blocks[0]["text"], &text); err != nil {
return nil, false, fmt.Errorf("decode Claude MCP text result: %w", err)
}
encoded, err := kitutil.Marshal(text)
if err != nil {
return nil, false, fmt.Errorf("encode Responses MCP output: %w", err)
}
return encoded, true, nil
default:
return nil, false, fmt.Errorf("Responses MCP output cannot preserve Claude result type %q", kitutil.GetJsonType(raw))
}
}
func claudeMCPContentFromResponsesString(raw json.RawMessage) (string, error) {
if len(raw) == 0 || kitutil.GetJsonType(raw) != "string" {
return "", fmt.Errorf("Responses MCP output/error must be a JSON string")
}
var content string
if err := kitutil.Unmarshal(raw, &content); err != nil {
return "", fmt.Errorf("decode Responses MCP output/error string: %w", err)
}
return content, nil
}
func claudeHostedResultFailure(blockType string, content json.RawMessage, explicitError *bool, explicitCode string) (bool, string) {
if explicitError != nil && *explicitError {
return true, strings.TrimSpace(explicitCode)
}
if strings.TrimSpace(explicitCode) != "" {
return true, strings.TrimSpace(explicitCode)
}
if !strings.HasSuffix(strings.TrimSpace(blockType), "_tool_result") || kitutil.GetJsonType(content) != "object" {
return false, ""
}
var resultError struct {
Type string `json:"type"`
ErrorCode string `json:"error_code"`
}
if kitutil.Unmarshal(content, &resultError) != nil {
return false, ""
}
if !strings.HasSuffix(strings.TrimSpace(resultError.Type), "_error") && strings.TrimSpace(resultError.ErrorCode) == "" {
return false, ""
}
return true, strings.TrimSpace(resultError.ErrorCode)
}
func responsesMCPErrorFromClaudeContent(raw json.RawMessage, errorCode string) (json.RawMessage, bool, error) {
if errorCode = strings.TrimSpace(errorCode); errorCode != "" {
encoded, err := kitutil.Marshal(errorCode)
return encoded, false, err
}
return responsesMCPStringFromClaudeContent(raw)
}
@@ -0,0 +1,118 @@
package toolconv
import (
"encoding/json"
"github.com/QuantumNous/new-api/relaykit/types"
)
type Kind string
const (
KindFunction Kind = "function"
KindWebSearch Kind = "web_search"
KindFileSearch Kind = "file_search"
KindWebFetch Kind = "web_fetch"
KindCodeExecution Kind = "code_execution"
KindComputerUse Kind = "computer_use"
KindURLContext Kind = "url_context"
KindMCP Kind = "mcp"
KindImage Kind = "image_generation"
KindNative Kind = "native"
)
type Execution string
const (
ExecutionClient Execution = "client"
ExecutionServer Execution = "server"
)
type Function struct {
Name string
Description string
Parameters any
Strict *bool
}
type ApproximateLocation struct {
City string
Region string
Country string
Timezone string
}
type WebSearch struct {
Location *ApproximateLocation
AllowedDomains []string
BlockedDomains []string
SearchContextSize string
MaxUses *int
AllowedCallers []string
ResponseInclusion string
ExternalWebAccess *bool
ReturnTokenBudget json.RawMessage
}
type Definition struct {
Kind Kind
Execution Execution
NativeType string
Name string
Function *Function
WebSearch *WebSearch
Raw json.RawMessage
Group int
}
type ChoiceMode string
const (
ChoiceAuto ChoiceMode = "auto"
ChoiceNone ChoiceMode = "none"
ChoiceRequired ChoiceMode = "required"
ChoiceNamed ChoiceMode = "named"
ChoiceOpaque ChoiceMode = "opaque"
)
type Choice struct {
Mode ChoiceMode
Kind Kind
Name string
AllowedNames []string
NativeType string
DisableParallelToolUse *bool
Raw json.RawMessage
}
type Set struct {
Source types.RelayFormat
Definitions []Definition
Choice *Choice
ParallelAllowed *bool
NativeToolConfig json.RawMessage
History []HostedHistoryItem
}
func (s Set) Empty() bool {
return len(s.Definitions) == 0 && s.Choice == nil && s.ParallelAllowed == nil && len(s.NativeToolConfig) == 0 && len(s.History) == 0
}
type HostedHistoryItem struct {
Kind Kind
NativeType string
Role string
MessageIndex int
BlockIndex int
MessageHasRegular bool
Sequence int
ID string
CallID string
Name string
ServerName string
Status string
Action json.RawMessage
Results json.RawMessage
Caller json.RawMessage
Raw json.RawMessage
}
@@ -0,0 +1,103 @@
package toolconv
import (
"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/QuantumNous/new-api/relaykit/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func geminiCodeExecutionRequest(t *testing.T) *dto.GeminiChatRequest {
t.Helper()
tools, err := kitutil.Marshal([]map[string]any{{"codeExecution": map[string]any{}}})
require.NoError(t, err)
return &dto.GeminiChatRequest{
Contents: []dto.GeminiChatContent{
{Role: "user", Parts: []dto.GeminiPart{{Text: "run this"}}},
},
Tools: tools,
}
}
func hasDiagnosticCode(diagnostics []types.ConversionDiagnostic, code string) bool {
for _, diagnostic := range diagnostics {
if diagnostic.Code == code {
return true
}
}
return false
}
func TestDefaultPolicyAllowsGeminiCodeExecutionToOpenAI(t *testing.T) {
t.Parallel()
_, set, err := ExtractRequest(types.RelayFormatGemini, geminiCodeExecutionRequest(t))
require.NoError(t, err)
target := &dto.GeneralOpenAIRequest{
Model: "gpt-4o",
Messages: []dto.Message{{Role: "user", Content: "run this"}},
}
out, diagnostics, err := AttachRequest(types.RelayFormatOpenAI, target, set, &convmeta.Options{})
require.NoError(t, err)
require.NotNil(t, out)
assert.True(t, hasDiagnosticCode(diagnostics, "unsupported_hosted_tool"))
assert.Equal(t, types.ConversionLossPolicyAllow, (&convmeta.Options{}).EffectiveToolLossPolicy())
}
func TestResponsePhaseNeverRejectsEvenUnderStrictPolicy(t *testing.T) {
t.Parallel()
text := "hello"
resp := &dto.ClaudeResponse{
Type: "message",
Role: "assistant",
StopReason: "pause_turn",
Content: []dto.ClaudeMediaMessage{
{Type: "redacted_thinking", Data: "secret"},
{Type: "text", Text: &text},
},
}
diagnostics := InspectResponse(types.RelayFormatClaude, types.RelayFormatOpenAI, resp)
require.True(t, hasDiagnosticCode(diagnostics, "continuation_state_lost"))
require.Error(t, types.RejectConversionLoss(types.ConversionLossPolicyStrict, diagnostics))
_, hosted, err := ExtractHostedResponse(types.RelayFormatClaude, resp)
require.NoError(t, err)
out, _, err := AttachHostedResponse(
types.RelayFormatOpenAI,
&dto.OpenAITextResponse{},
hosted,
&convmeta.Options{ToolLossPolicy: types.ConversionLossPolicyStrict},
)
require.NoError(t, err)
require.NotNil(t, out)
}
func TestSafePolicyRejectsRequestPhaseHostedToolLoss(t *testing.T) {
t.Parallel()
_, set, err := ExtractRequest(types.RelayFormatGemini, geminiCodeExecutionRequest(t))
require.NoError(t, err)
target := &dto.GeneralOpenAIRequest{
Model: "gpt-4o",
Messages: []dto.Message{{Role: "user", Content: "run this"}},
}
_, diagnostics, err := AttachRequest(
types.RelayFormatOpenAI,
target,
set,
&convmeta.Options{ToolLossPolicy: types.ConversionLossPolicySafe},
)
require.Error(t, err)
var loss *types.ConversionLossError
require.ErrorAs(t, err, &loss)
require.NotEmpty(t, loss.Diagnostics)
assert.True(t, hasDiagnosticCode(loss.Diagnostics, "unsupported_hosted_tool"))
assert.True(t, hasDiagnosticCode(diagnostics, "unsupported_hosted_tool"))
}
@@ -0,0 +1,515 @@
package toolconv
import (
"encoding/json"
"fmt"
"strings"
"unicode/utf8"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
// InspectResponse reports protocol information that the current response
// converters cannot faithfully express. It keeps loss handling centralized so
// direct and multi-step routes behave consistently.
func InspectResponse(from types.RelayFormat, to types.RelayFormat, response any) []types.ConversionDiagnostic {
if from == to {
return nil
}
var diagnostics []types.ConversionDiagnostic
switch value := response.(type) {
case *dto.ClaudeResponse:
diagnostics = inspectClaudeResponse(value, to)
case dto.ClaudeResponse:
diagnostics = inspectClaudeResponse(&value, to)
case *dto.OpenAIResponsesResponse:
diagnostics = inspectOpenAIResponsesResponse(value, to)
case dto.OpenAIResponsesResponse:
diagnostics = inspectOpenAIResponsesResponse(&value, to)
case *dto.ResponsesStreamResponse:
diagnostics = inspectOpenAIResponsesStreamResponse(value)
case dto.ResponsesStreamResponse:
diagnostics = inspectOpenAIResponsesStreamResponse(&value)
case *dto.GeminiChatResponse:
diagnostics = inspectGeminiResponse(value, to)
case dto.GeminiChatResponse:
diagnostics = inspectGeminiResponse(&value, to)
}
for index := range diagnostics {
diagnostics[index].From = from
diagnostics[index].To = to
}
return diagnostics
}
// InspectStreamResponse avoids treating a single Gemini streaming chunk as a
// complete grounding document. Gemini grounding chunk indexes and segment
// offsets are cumulative across the stream; the stateful converter validates
// and resolves them after accumulating prior chunks.
func InspectStreamResponse(from types.RelayFormat, to types.RelayFormat, response any) []types.ConversionDiagnostic {
if from != types.RelayFormatGemini {
return InspectResponse(from, to, response)
}
var value *dto.GeminiChatResponse
switch response := response.(type) {
case *dto.GeminiChatResponse:
value = response
case dto.GeminiChatResponse:
value = &response
default:
return InspectResponse(from, to, response)
}
if value == nil {
return nil
}
var diagnostics []types.ConversionDiagnostic
for index := range value.Candidates {
metadata := value.Candidates[index].GroundingMetadata
if metadata == nil {
continue
}
if len(metadata.WebSearchQueries) > 0 && to != types.RelayFormatOpenAIResponses {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("candidates[%d].groundingMetadata.webSearchQueries", index),
"web_search_call_unrepresentable",
"Gemini grounding confirms a hosted web search, but the target stream converter cannot produce an OpenAI Responses web_search_call lifecycle",
))
}
if len(metadata.WebSearchQueries) == 0 && len(metadata.RetrievalQueries) == 0 && len(metadata.SearchEntryPoint) == 0 && len(metadata.RetrievalMetadata) == 0 && len(metadata.SourceFlaggingUris) == 0 && metadata.GoogleMapsWidgetContextToken == "" {
continue
}
diagnostics = append(diagnostics, responsePresentationLoss(
fmt.Sprintf("candidates[%d].groundingMetadata", index),
"hosted_tool_metadata_reduced",
"Gemini grounding citations are preserved across stream chunks, but provider-specific search metadata has no target-protocol equivalent",
))
}
for index := range diagnostics {
diagnostics[index].From = from
diagnostics[index].To = to
}
return diagnostics
}
func inspectClaudeResponse(response *dto.ClaudeResponse, to types.RelayFormat) []types.ConversionDiagnostic {
if response == nil {
return nil
}
var diagnostics []types.ConversionDiagnostic
if to != types.RelayFormatOpenAIResponses && (response.StopReason == "pause_turn" || response.Delta != nil && response.Delta.StopReason != nil && *response.Delta.StopReason == "pause_turn") {
diagnostics = append(diagnostics, responseSemanticLoss(
"stop_reason",
"continuation_state_lost",
"Claude pause_turn requires protocol-native continuation state that the target response cannot preserve",
))
}
for index := range response.Content {
diagnostics = append(diagnostics, inspectClaudeContentBlock(&response.Content[index], fmt.Sprintf("content[%d]", index), to, false)...)
}
if response.ContentBlock != nil {
diagnostics = append(diagnostics, inspectClaudeContentBlock(response.ContentBlock, "content_block", to, true)...)
}
return diagnostics
}
func inspectClaudeContentBlock(block *dto.ClaudeMediaMessage, path string, to types.RelayFormat, stream bool) []types.ConversionDiagnostic {
if block == nil {
return nil
}
blockType := strings.TrimSpace(block.Type)
var diagnostics []types.ConversionDiagnostic
if isClaudeHostedToolBlock(blockType) {
kind := KindNative
if blockType == "server_tool_use" || blockType == "mcp_tool_use" {
kind = hostedKindFromClaudeCall(blockType, block.Name)
} else {
kind = hostedKindFromClaudeResult(blockType)
}
if to != types.RelayFormatOpenAIResponses || kind != KindWebSearch && kind != KindMCP {
diagnostics = append(diagnostics, responseSemanticLoss(
path,
"hosted_tool_unrepresentable",
fmt.Sprintf("%s cannot losslessly represent Claude hosted-tool response block %q", to, blockType),
))
} else if blockType == "server_tool_use" || blockType == "mcp_tool_use" {
if block.Id == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".id",
"hosted_tool_id_missing",
"Claude hosted-tool call has no id for pairing it with its result",
))
}
if kind == KindMCP && (block.Name == "" || block.ServerName == "") {
diagnostics = append(diagnostics, responseSemanticLoss(
path,
"mcp_identity_missing",
"Claude MCP tool use must include both name and server_name for Responses MCP mapping",
))
}
if rawJSONPresent(block.Caller) {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".caller",
"hosted_tool_caller_unrepresentable",
"OpenAI Responses web_search_call and mcp_call items cannot preserve Claude's hosted-tool caller provenance",
))
}
if !stream {
input, err := kitutil.Marshal(block.Input)
if err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".input",
"hosted_tool_input_invalid",
err.Error(),
))
} else if kind == KindMCP {
if _, err := responsesMCPArgumentsFromClaude(input); err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".input",
"mcp_arguments_unrepresentable",
err.Error(),
))
}
} else if kind == KindWebSearch {
if _, err := dto.NormalizeResponsesWebSearchAction(input); err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".input",
"web_search_action_unrepresentable",
err.Error(),
))
}
}
}
} else if block.ToolUseId == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".tool_use_id",
"hosted_tool_id_missing",
"Claude hosted-tool result has no tool_use_id for pairing it with its call",
))
}
if kind == KindMCP && blockType == "mcp_tool_result" {
content, err := kitutil.Marshal(block.Content)
if err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(path+".content", "mcp_result_unrepresentable", err.Error()))
} else {
failed, errorCode := claudeHostedResultFailure(blockType, content, block.IsError, block.ErrorCode)
var normalized bool
if failed {
_, normalized, err = responsesMCPErrorFromClaudeContent(content, errorCode)
} else {
_, normalized, err = responsesMCPStringFromClaudeContent(content)
}
if err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(path+".content", "mcp_result_unrepresentable", err.Error()))
} else if normalized {
diagnostics = append(diagnostics, responsePresentationLoss(
path+".content",
"mcp_text_result_normalized",
"Claude's single MCP text block is normalized to a Responses output string",
))
}
}
}
}
if blockType == "redacted_thinking" && block.Data != "" {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".data",
"continuation_state_lost",
"Claude encrypted thinking state cannot be represented by the target response",
))
}
return diagnostics
}
func isClaudeHostedToolBlock(blockType string) bool {
if blockType == "server_tool_use" || blockType == "mcp_tool_use" || blockType == "mcp_tool_result" {
return true
}
return strings.HasSuffix(blockType, "_tool_result")
}
func inspectOpenAIResponsesResponse(response *dto.OpenAIResponsesResponse, to types.RelayFormat) []types.ConversionDiagnostic {
if response == nil {
return nil
}
var diagnostics []types.ConversionDiagnostic
for index := range response.Output {
output := &response.Output[index]
if !isResponsesHostedOutput(output.Type) {
continue
}
kind := hostedKindFromResponsesType(output.Type)
if to != types.RelayFormatClaude || kind != KindWebSearch && kind != KindMCP {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d]", index),
"hosted_tool_unrepresentable",
fmt.Sprintf("%s cannot losslessly represent OpenAI Responses hosted-tool output %q", to, output.Type),
))
continue
}
if output.ID == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].id", index),
"hosted_tool_id_missing",
"hosted-tool output has no id for pairing the call with its result",
))
}
if kind == KindMCP {
if output.Name == "" || output.ServerLabel == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d]", index),
"mcp_identity_missing",
"Responses MCP output must include both name and server_label for Claude MCP mapping",
))
}
if output.ApprovalRequestID != "" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].approval_request_id", index),
"mcp_approval_state_unrepresentable",
"Claude MCP response blocks cannot preserve a Responses approval_request_id",
))
}
if _, err := claudeMCPInputFromResponses(output.Arguments); err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].arguments", index),
"mcp_arguments_unrepresentable",
err.Error(),
))
}
if rawJSONPresent(output.Output) && rawJSONPresent(output.ItemError) {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d]", index),
"mcp_result_ambiguous",
"Responses MCP output contains both output and error",
))
}
for _, field := range []struct {
name string
raw json.RawMessage
}{{name: "output", raw: output.Output}, {name: "error", raw: output.ItemError}} {
if !rawJSONPresent(field.raw) {
continue
}
if _, err := claudeMCPContentFromResponsesString(field.raw); err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].%s", index, field.name),
"mcp_result_unrepresentable",
err.Error(),
))
}
}
} else if kind == KindWebSearch {
if _, err := claudeWebSearchInputFromResponses(output.Action); err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].action", index),
"web_search_action_unrepresentable",
err.Error(),
))
}
}
if output.Status != "" && output.Status != "in_progress" && output.Status != "completed" && output.Status != "failed" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].status", index),
"hosted_tool_status_unrepresentable",
fmt.Sprintf("Claude cannot preserve hosted-tool status %q", output.Status),
))
}
if output.Status == "failed" && !rawJSONPresent(output.ItemError) && !rawJSONPresent(output.Output) && !rawJSONPresent(output.Results) {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].status", index),
"hosted_tool_error_missing",
"failed hosted-tool output has no error or output that Claude can preserve",
))
}
}
return diagnostics
}
func inspectOpenAIResponsesStreamResponse(response *dto.ResponsesStreamResponse) []types.ConversionDiagnostic {
if response == nil {
return nil
}
if response.Item != nil && isResponsesHostedOutput(response.Item.Type) {
return []types.ConversionDiagnostic{responseSemanticLoss(
"item",
"hosted_tool_event_unrepresentable",
fmt.Sprintf("OpenAI Responses hosted-tool output %q has no semantic target-protocol stream mapping", response.Item.Type),
)}
}
eventType := strings.TrimSpace(response.Type)
if strings.Contains(eventType, ".web_search_call.") ||
strings.Contains(eventType, ".file_search_call.") ||
strings.Contains(eventType, ".code_interpreter_call.") ||
strings.Contains(eventType, ".computer_tool_call.") ||
strings.Contains(eventType, ".image_generation_call.") ||
strings.Contains(eventType, ".mcp_call.") {
return []types.ConversionDiagnostic{responseSemanticLoss(
"type",
"hosted_tool_event_unrepresentable",
fmt.Sprintf("OpenAI Responses hosted-tool stream event %q has no semantic target-protocol mapping", eventType),
)}
}
return nil
}
func isResponsesHostedOutput(outputType string) bool {
switch strings.TrimSpace(outputType) {
case "", "message", "reasoning", "function_call", "custom_tool_call":
return false
default:
return true
}
}
func inspectGeminiResponse(response *dto.GeminiChatResponse, to types.RelayFormat) []types.ConversionDiagnostic {
if response == nil {
return nil
}
var diagnostics []types.ConversionDiagnostic
for index := range response.Candidates {
metadata := response.Candidates[index].GroundingMetadata
if metadata == nil {
continue
}
path := fmt.Sprintf("candidates[%d].groundingMetadata", index)
if len(metadata.WebSearchQueries) > 0 && to != types.RelayFormatOpenAIResponses {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".webSearchQueries",
"web_search_call_unrepresentable",
"Gemini grounding confirms a hosted web search, but the target converter cannot produce an OpenAI Responses web_search_call item",
))
}
diagnostics = append(diagnostics, inspectGeminiGroundingCitations(response.Candidates[index].Content, metadata, path)...)
if len(metadata.WebSearchQueries) == 0 && len(metadata.RetrievalQueries) == 0 && len(metadata.SearchEntryPoint) == 0 && len(metadata.RetrievalMetadata) == 0 && len(metadata.SourceFlaggingUris) == 0 && metadata.GoogleMapsWidgetContextToken == "" {
continue
}
diagnostics = append(diagnostics, responsePresentationLoss(
path,
"hosted_tool_metadata_reduced",
"Gemini grounding citations are preserved, but provider-specific search metadata has no target-protocol equivalent",
))
}
return diagnostics
}
type groundingSupportForInspection struct {
Segment struct {
PartIndex *int `json:"partIndex,omitempty"`
StartIndex int `json:"startIndex,omitempty"`
EndIndex int `json:"endIndex,omitempty"`
Text string `json:"text,omitempty"`
} `json:"segment"`
GroundingChunkIndices []int `json:"groundingChunkIndices"`
}
type groundingChunkForInspection struct {
Web *groundingSourceForInspection `json:"web,omitempty"`
RetrievedContext *groundingSourceForInspection `json:"retrievedContext,omitempty"`
}
type groundingSourceForInspection struct {
URI string `json:"uri,omitempty"`
}
func inspectGeminiGroundingCitations(content dto.GeminiChatContent, metadata *dto.GeminiGroundingMetadata, path string) []types.ConversionDiagnostic {
if len(metadata.GroundingSupports) == 0 {
return nil
}
var chunks []groundingChunkForInspection
if len(metadata.GroundingChunks) == 0 || kitutil.Unmarshal(metadata.GroundingChunks, &chunks) != nil {
return []types.ConversionDiagnostic{responseSemanticLoss(
path+".groundingChunks",
"grounding_source_invalid",
"Gemini grounding chunks are missing or cannot be decoded",
)}
}
var supports []groundingSupportForInspection
if err := kitutil.Unmarshal(metadata.GroundingSupports, &supports); err != nil {
return []types.ConversionDiagnostic{responseSemanticLoss(
path+".groundingSupports",
"grounding_citation_invalid",
fmt.Sprintf("Gemini grounding supports cannot be decoded: %v", err),
)}
}
textPartCount := 0
soleTextPart := -1
for index := range content.Parts {
if content.Parts[index].Text == "" || content.Parts[index].Thought {
continue
}
textPartCount++
soleTextPart = index
}
var diagnostics []types.ConversionDiagnostic
for index, support := range supports {
segmentPath := fmt.Sprintf("%s.groundingSupports[%d].segment", path, index)
hasSource := false
for _, chunkIndex := range support.GroundingChunkIndices {
if chunkIndex < 0 || chunkIndex >= len(chunks) {
continue
}
source := chunks[chunkIndex].Web
if source == nil {
source = chunks[chunkIndex].RetrievedContext
}
if source != nil && source.URI != "" {
hasSource = true
break
}
}
if !hasSource {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("%s.groundingSupports[%d].groundingChunkIndices", path, index),
"grounding_source_invalid",
"Gemini grounding support does not reference a valid source URI",
))
continue
}
partIndex := soleTextPart
if support.Segment.PartIndex != nil {
partIndex = *support.Segment.PartIndex
} else if textPartCount != 1 {
diagnostics = append(diagnostics, responseSemanticLoss(
segmentPath+".partIndex",
"grounding_part_ambiguous",
"Gemini grounding omitted partIndex while multiple text parts are present, so citation placement is ambiguous",
))
continue
}
if partIndex < 0 || partIndex >= len(content.Parts) || content.Parts[partIndex].Text == "" || content.Parts[partIndex].Thought {
diagnostics = append(diagnostics, responseSemanticLoss(
segmentPath+".partIndex",
"grounding_part_invalid",
fmt.Sprintf("Gemini grounding references non-text part %d", partIndex),
))
continue
}
partText := content.Parts[partIndex].Text
start, end := support.Segment.StartIndex, support.Segment.EndIndex
if start < 0 || end <= start || end > len(partText) || !utf8.ValidString(partText[:start]) || !utf8.ValidString(partText[:end]) {
diagnostics = append(diagnostics, responseSemanticLoss(
segmentPath,
"grounding_offset_invalid",
"Gemini grounding byte offsets do not identify valid UTF-8 boundaries in the referenced part",
))
continue
}
if support.Segment.Text != "" && partText[start:end] != support.Segment.Text {
diagnostics = append(diagnostics, responseSemanticLoss(
segmentPath+".text",
"grounding_text_mismatch",
"Gemini grounding segment text does not match the referenced part range",
))
}
}
return diagnostics
}
func responseSemanticLoss(path string, code string, message string) types.ConversionDiagnostic {
return types.ConversionDiagnostic{Code: code, Path: path, Message: message, Severity: types.ConversionDiagnosticError}
}
func responsePresentationLoss(path string, code string, message string) types.ConversionDiagnostic {
return types.ConversionDiagnostic{Code: code, Path: path, Message: message, Severity: types.ConversionDiagnosticWarning}
}
@@ -0,0 +1,816 @@
package toolconv
import (
"encoding/json"
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
geminichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/gemini_chat"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
type HostedResponseItem struct {
Kind Kind
NativeType string
ID string
CallID string
Name string
Status string
Position int
Action json.RawMessage
Results json.RawMessage
Sources json.RawMessage
Caller json.RawMessage
Arguments json.RawMessage
Output json.RawMessage
Error json.RawMessage
ServerName string
IsError *bool
ApprovalRequestID string
Tools json.RawMessage
ErrorCode string
Raw json.RawMessage
}
type HostedResponseSet struct {
Source types.RelayFormat
Items []HostedResponseItem
SourceLength int
RegularPositions []int
}
type positionedResponsesOutput struct {
position int
output dto.ResponsesOutput
}
type positionedClaudeBlocks struct {
position int
blocks []dto.ClaudeMediaMessage
}
func (s HostedResponseSet) Empty() bool {
return len(s.Items) == 0
}
// ExtractHostedResponse removes server-executed tool artifacts before a
// message converter sees them. The artifacts travel beside multi-step routes,
// just like request tool definitions, so a lossy Chat pivot cannot reclassify
// or discard them.
func ExtractHostedResponse(format types.RelayFormat, response any) (any, HostedResponseSet, error) {
switch format {
case types.RelayFormatClaude:
return extractClaudeHostedResponse(response)
case types.RelayFormatOpenAIResponses:
return extractOpenAIHostedResponse(response)
case types.RelayFormatGemini:
return extractGeminiHostedResponse(response)
default:
return response, HostedResponseSet{Source: format}, nil
}
}
func AttachHostedResponse(format types.RelayFormat, response any, set HostedResponseSet, options *convmeta.Options) (any, []types.ConversionDiagnostic, error) {
if set.Empty() {
return response, nil, nil
}
var (
value any
diagnostics []types.ConversionDiagnostic
err error
)
switch format {
case types.RelayFormatOpenAIResponses:
value, diagnostics, err = attachOpenAIHostedResponse(response, set)
case types.RelayFormatClaude:
value, diagnostics, err = attachClaudeHostedResponse(response, set)
default:
value = response
for index, item := range set.Items {
diagnostics = append(diagnostics, responsePresentationLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"hosted_tool_event_omitted",
fmt.Sprintf("%s cannot represent hosted-tool response %q", format, item.NativeType),
))
}
}
if err != nil {
return nil, diagnostics, err
}
for index := range diagnostics {
diagnostics[index].From = set.Source
diagnostics[index].To = format
}
return value, diagnostics, nil
}
func extractClaudeHostedResponse(response any) (any, HostedResponseSet, error) {
var source *dto.ClaudeResponse
switch value := response.(type) {
case *dto.ClaudeResponse:
source = value
case dto.ClaudeResponse:
source = &value
default:
return nil, HostedResponseSet{}, fmt.Errorf("expected Claude response, got %T", response)
}
clone := *source
clone.Content = make([]dto.ClaudeMediaMessage, 0, len(source.Content))
set := HostedResponseSet{Source: types.RelayFormatClaude, SourceLength: len(source.Content)}
for position := range source.Content {
block := source.Content[position]
blockType := strings.TrimSpace(block.Type)
switch {
case blockType == "server_tool_use" || blockType == "mcp_tool_use":
rawBlock, err := kitutil.Marshal(block)
if err != nil {
return nil, set, fmt.Errorf("content[%d]: %w", position, err)
}
action, err := kitutil.Marshal(block.Input)
if err != nil {
return nil, set, fmt.Errorf("content[%d].input: %w", position, err)
}
item := HostedResponseItem{
Kind: hostedKindFromClaudeCall(blockType, block.Name),
NativeType: blockType,
ID: block.Id,
CallID: block.Id,
Name: block.Name,
Status: "in_progress",
Position: position,
Action: action,
Caller: append(json.RawMessage(nil), block.Caller...),
ServerName: block.ServerName,
Raw: rawBlock,
}
set.Items = append(set.Items, item)
case isClaudeHostedToolBlock(blockType):
rawBlock, err := kitutil.Marshal(block)
if err != nil {
return nil, set, fmt.Errorf("content[%d]: %w", position, err)
}
results, err := kitutil.Marshal(block.Content)
if err != nil {
return nil, set, fmt.Errorf("content[%d].content: %w", position, err)
}
failed, errorCode := claudeHostedResultFailure(blockType, results, block.IsError, block.ErrorCode)
status := "completed"
isError := block.IsError
if failed {
status = "failed"
if isError == nil {
value := true
isError = &value
}
}
set.Items = append(set.Items, HostedResponseItem{
Kind: hostedKindFromClaudeResult(blockType),
NativeType: blockType,
ID: block.ToolUseId,
CallID: block.ToolUseId,
Status: status,
Position: position,
Results: results,
ErrorCode: errorCode,
IsError: isError,
Raw: rawBlock,
})
default:
clone.Content = append(clone.Content, block)
set.RegularPositions = append(set.RegularPositions, position)
}
}
return &clone, set, nil
}
func extractOpenAIHostedResponse(response any) (any, HostedResponseSet, error) {
var source *dto.OpenAIResponsesResponse
switch value := response.(type) {
case *dto.OpenAIResponsesResponse:
source = value
case dto.OpenAIResponsesResponse:
source = &value
default:
return nil, HostedResponseSet{}, fmt.Errorf("expected OpenAI Responses response, got %T", response)
}
clone := *source
clone.Output = make([]dto.ResponsesOutput, 0, len(source.Output))
set := HostedResponseSet{Source: types.RelayFormatOpenAIResponses, SourceLength: len(source.Output)}
for position := range source.Output {
output := source.Output[position]
if !isResponsesHostedOutput(output.Type) {
clone.Output = append(clone.Output, output)
set.RegularPositions = append(set.RegularPositions, position)
continue
}
rawOutput, err := kitutil.Marshal(output)
if err != nil {
return nil, set, fmt.Errorf("output[%d]: %w", position, err)
}
set.Items = append(set.Items, HostedResponseItem{
Kind: hostedKindFromResponsesType(output.Type),
NativeType: output.Type,
ID: output.ID,
CallID: output.CallId,
Name: output.Name,
Status: output.Status,
Position: position,
Action: append(json.RawMessage(nil), output.Action...),
Results: append(json.RawMessage(nil), output.Results...),
Sources: append(json.RawMessage(nil), output.Sources...),
Caller: append(json.RawMessage(nil), output.Caller...),
Arguments: append(json.RawMessage(nil), output.Arguments...),
Output: append(json.RawMessage(nil), output.Output...),
Error: append(json.RawMessage(nil), output.ItemError...),
ServerName: output.ServerLabel,
ApprovalRequestID: output.ApprovalRequestID,
Tools: append(json.RawMessage(nil), output.MCPTools...),
Raw: rawOutput,
})
}
return &clone, set, nil
}
func extractGeminiHostedResponse(response any) (any, HostedResponseSet, error) {
var source *dto.GeminiChatResponse
switch value := response.(type) {
case *dto.GeminiChatResponse:
source = value
case dto.GeminiChatResponse:
source = &value
default:
return nil, HostedResponseSet{}, fmt.Errorf("expected Gemini response, got %T", response)
}
queries := geminichat.GroundingWebSearchQueries(source)
if len(queries) == 0 {
return source, HostedResponseSet{Source: types.RelayFormatGemini}, nil
}
action, err := kitutil.Marshal(map[string]any{
"type": "search",
"queries": queries,
})
if err != nil {
return nil, HostedResponseSet{}, fmt.Errorf("marshal Gemini web-search action: %w", err)
}
// The Chat pivot emits the answer as the regular Responses output. Place
// the hosted call after that output, matching the stream bridge which only
// learns Gemini's queries once grounding metadata arrives near stream end.
set := HostedResponseSet{
Source: types.RelayFormatGemini,
SourceLength: 2,
RegularPositions: []int{0},
Items: []HostedResponseItem{{
Kind: KindWebSearch,
NativeType: "googleSearch",
ID: fmt.Sprintf("ws_%s", kitutil.GetUUID()),
Status: "completed",
Position: 1,
Action: action,
}},
}
return source, set, nil
}
func attachOpenAIHostedResponse(response any, set HostedResponseSet) (any, []types.ConversionDiagnostic, error) {
target, ok := response.(*dto.OpenAIResponsesResponse)
if !ok || target == nil {
return nil, nil, fmt.Errorf("expected OpenAI Responses response, got %T", response)
}
var diagnostics []types.ConversionDiagnostic
hostedOutput := make([]positionedResponsesOutput, 0, len(set.Items))
convertedByID := make(map[string]int, len(set.Items)*2)
for index, item := range set.Items {
if set.Source == types.RelayFormatOpenAIResponses && len(item.Raw) > 0 {
var output dto.ResponsesOutput
if err := kitutil.Unmarshal(item.Raw, &output); err != nil {
return nil, diagnostics, fmt.Errorf("hosted_tools[%d]: %w", index, err)
}
hostedOutput = append(hostedOutput, positionedResponsesOutput{position: item.Position, output: output})
continue
}
outputType := responsesTypeFromHostedKind(item.Kind)
if outputType == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"hosted_tool_unrepresentable",
fmt.Sprintf("OpenAI Responses has no lossless response mapping for %q", item.NativeType),
))
continue
}
if isClaudeHostedResult(item.NativeType) {
outputIndex, exists := convertedByID[item.CallID]
if !exists {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d].tool_use_id", index),
"hosted_tool_result_orphaned",
fmt.Sprintf("hosted-tool result references unknown call %q", item.CallID),
))
continue
}
output := &hostedOutput[outputIndex].output
output.Status = hostedCompletionStatus(item)
if item.Kind == KindMCP {
failed := hostedItemFailed(item)
var (
encoded json.RawMessage
normalized bool
err error
)
if failed {
encoded, normalized, err = responsesMCPErrorFromClaudeContent(item.Results, item.ErrorCode)
} else {
encoded, normalized, err = responsesMCPStringFromClaudeContent(item.Results)
}
if err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d].content", index),
"mcp_result_unrepresentable",
err.Error(),
))
continue
}
if failed {
output.Output = nil
output.ItemError = encoded
} else {
output.Output = encoded
output.ItemError = nil
}
if normalized {
diagnostics = append(diagnostics, responsePresentationLoss(
fmt.Sprintf("hosted_tools[%d].content", index),
"mcp_text_result_normalized",
"Claude's single MCP text block was normalized to a Responses output string",
))
}
} else if item.Kind == KindWebSearch && rawJSONPresent(item.Results) {
diagnostics = append(diagnostics, responsePresentationLoss(
fmt.Sprintf("hosted_tools[%d].content", index),
"web_search_result_omitted",
"Claude web-search result content is provider-private and has no field on an OpenAI Responses web_search_call; completion status and citations remain available",
))
}
continue
}
output := dto.ResponsesOutput{
Type: outputType,
ID: firstNonEmpty(item.ID, item.CallID),
Status: hostedCompletionStatus(item),
}
switch item.Kind {
case KindWebSearch:
action, err := dto.NormalizeResponsesWebSearchAction(item.Action)
if err != nil {
return nil, diagnostics, fmt.Errorf("hosted_tools[%d].action: %w", index, err)
}
output.Action = action
case KindMCP:
if strings.TrimSpace(item.Name) == "" || strings.TrimSpace(item.ServerName) == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"mcp_identity_missing",
"Claude MCP output requires both name and server_name for Responses mapping",
))
continue
}
output.CallId = item.CallID
output.Name = item.Name
output.Caller = append(json.RawMessage(nil), item.Caller...)
output.ServerLabel = item.ServerName
output.ApprovalRequestID = item.ApprovalRequestID
output.MCPTools = append(json.RawMessage(nil), item.Tools...)
arguments := item.Arguments
if len(arguments) == 0 {
arguments = item.Action
}
encodedArguments, argumentErr := responsesMCPArgumentsFromClaude(arguments)
if argumentErr != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d].input", index),
"mcp_arguments_unrepresentable",
argumentErr.Error(),
))
continue
}
output.Arguments = encodedArguments
}
outputIndex := len(hostedOutput)
for _, key := range []string{item.ID, item.CallID} {
if key != "" {
convertedByID[key] = outputIndex
}
}
hostedOutput = append(hostedOutput, positionedResponsesOutput{position: item.Position, output: output})
if set.Source != types.RelayFormatOpenAIResponses {
diagnostics = append(diagnostics, responsePresentationLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"hosted_tool_result_approximated",
"hosted-tool execution is preserved, but provider-specific result fields may differ",
))
}
}
merged, orderingDiagnostics := mergeResponsesOutput(target.Output, hostedOutput, set)
diagnostics = append(diagnostics, orderingDiagnostics...)
target.Output = merged
return target, diagnostics, nil
}
func attachClaudeHostedResponse(response any, set HostedResponseSet) (any, []types.ConversionDiagnostic, error) {
target, ok := response.(*dto.ClaudeResponse)
if !ok || target == nil {
return nil, nil, fmt.Errorf("expected Claude response, got %T", response)
}
var diagnostics []types.ConversionDiagnostic
hostedContent := make([]positionedClaudeBlocks, 0, len(set.Items))
for index, item := range set.Items {
if set.Source == types.RelayFormatClaude && len(item.Raw) > 0 {
var block dto.ClaudeMediaMessage
if err := kitutil.Unmarshal(item.Raw, &block); err != nil {
return nil, diagnostics, fmt.Errorf("hosted_tools[%d]: %w", index, err)
}
hostedContent = append(hostedContent, positionedClaudeBlocks{position: item.Position, blocks: []dto.ClaudeMediaMessage{block}})
continue
}
if set.Source == types.RelayFormatOpenAIResponses && item.Kind == KindWebSearch {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"web_search_response_unrepresentable",
"Responses web-search execution cannot reconstruct Claude's required encrypted web_search_tool_result continuation state",
))
continue
}
name := claudeNameFromHostedKind(item.Kind)
if item.Kind == KindMCP {
name = item.Name
}
if name == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"hosted_tool_unrepresentable",
fmt.Sprintf("Claude has no lossless response mapping for %q", item.NativeType),
))
continue
}
var input any = map[string]any{}
if item.Kind == KindWebSearch {
webInput, inputErr := claudeWebSearchInputFromResponses(item.Action)
if inputErr != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d].action", index),
"web_search_action_unrepresentable",
inputErr.Error(),
))
continue
}
input = webInput
} else if item.Kind == KindMCP {
mcpInput, inputErr := claudeMCPInputFromResponses(item.Arguments)
if inputErr != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d].arguments", index),
"mcp_arguments_unrepresentable",
inputErr.Error(),
))
continue
}
input = mcpInput
} else if len(item.Action) > 0 {
if err := kitutil.Unmarshal(item.Action, &input); err != nil {
return nil, diagnostics, fmt.Errorf("hosted_tools[%d].action: %w", index, err)
}
}
callType := "server_tool_use"
if item.Kind == KindMCP {
callType = "mcp_tool_use"
}
blocks := []dto.ClaudeMediaMessage{{
Type: callType,
Id: item.ID,
Name: name,
Input: input,
Caller: append(json.RawMessage(nil), item.Caller...),
ServerName: item.ServerName,
}}
result := item.Results
if item.Kind == KindMCP {
result = item.Output
if hostedItemFailed(item) && rawJSONPresent(item.Error) {
result = item.Error
}
} else if len(result) == 0 {
result = item.Sources
}
if len(result) > 0 && !(set.Source == types.RelayFormatOpenAIResponses && item.Kind == KindWebSearch) {
var content any
if item.Kind == KindMCP {
decoded, resultErr := claudeMCPContentFromResponsesString(result)
if resultErr != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d].output", index),
"mcp_result_unrepresentable",
resultErr.Error(),
))
continue
}
content = decoded
} else if err := kitutil.Unmarshal(result, &content); err != nil {
return nil, diagnostics, fmt.Errorf("hosted_tools[%d].results: %w", index, err)
}
isError := hostedItemFailed(item)
blocks = append(blocks, dto.ClaudeMediaMessage{
Type: claudeResultTypeFromHostedKind(item.Kind),
ToolUseId: item.ID,
Content: content,
IsError: &isError,
ErrorCode: item.ErrorCode,
})
} else if len(result) > 0 && item.Kind == KindWebSearch {
diagnostics = append(diagnostics, responsePresentationLoss(
fmt.Sprintf("hosted_tools[%d].results", index),
"web_search_result_omitted",
"Responses web-search source metadata cannot reconstruct Claude's encrypted web_search_tool_result",
))
} else if item.Kind == KindMCP && (item.Status == "completed" || item.Status == "failed") {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"mcp_result_missing",
fmt.Sprintf("Responses MCP output has status %q but no output or error", item.Status),
))
}
hostedContent = append(hostedContent, positionedClaudeBlocks{position: item.Position, blocks: blocks})
if set.Source != types.RelayFormatClaude {
diagnostics = append(diagnostics, responsePresentationLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"hosted_tool_result_approximated",
"hosted-tool execution is preserved, but provider-specific result fields may differ",
))
}
}
merged, orderingDiagnostics := mergeClaudeContent(target.Content, hostedContent, set)
diagnostics = append(diagnostics, orderingDiagnostics...)
target.Content = merged
return target, diagnostics, nil
}
func mergeResponsesOutput(regular []dto.ResponsesOutput, hosted []positionedResponsesOutput, set HostedResponseSet) ([]dto.ResponsesOutput, []types.ConversionDiagnostic) {
if len(hosted) == 0 {
return regular, nil
}
if len(regular) == len(set.RegularPositions) {
byPosition := make(map[int][]dto.ResponsesOutput, len(hosted))
for _, item := range hosted {
byPosition[item.position] = append(byPosition[item.position], item.output)
}
regularByPosition := make(map[int]dto.ResponsesOutput, len(regular))
for index, position := range set.RegularPositions {
regularByPosition[position] = regular[index]
}
merged := make([]dto.ResponsesOutput, 0, len(regular)+len(hosted))
for position := 0; position < set.SourceLength; position++ {
merged = append(merged, byPosition[position]...)
if output, exists := regularByPosition[position]; exists {
merged = append(merged, output)
}
}
return merged, nil
}
before, after, exact := hostedOutsideRegularRange(hostedPositions(hosted), set.RegularPositions)
if exact {
merged := make([]dto.ResponsesOutput, 0, len(regular)+len(hosted))
for _, item := range before {
merged = append(merged, hosted[item].output)
}
merged = append(merged, regular...)
for _, item := range after {
merged = append(merged, hosted[item].output)
}
return merged, nil
}
merged := make([]dto.ResponsesOutput, 0, len(regular)+len(hosted))
for _, item := range hosted {
merged = append(merged, item.output)
}
merged = append(merged, regular...)
return merged, []types.ConversionDiagnostic{responseSemanticLoss(
"output",
"hosted_tool_order_unrepresentable",
"hosted-tool items were interleaved with content that the target converter coalesced, so their original order cannot be reconstructed",
)}
}
func mergeClaudeContent(regular []dto.ClaudeMediaMessage, hosted []positionedClaudeBlocks, set HostedResponseSet) ([]dto.ClaudeMediaMessage, []types.ConversionDiagnostic) {
if len(hosted) == 0 {
return regular, nil
}
if len(regular) == len(set.RegularPositions) {
byPosition := make(map[int][]dto.ClaudeMediaMessage, len(hosted))
for _, item := range hosted {
byPosition[item.position] = append(byPosition[item.position], item.blocks...)
}
regularByPosition := make(map[int]dto.ClaudeMediaMessage, len(regular))
for index, position := range set.RegularPositions {
regularByPosition[position] = regular[index]
}
merged := make([]dto.ClaudeMediaMessage, 0, len(regular)+len(hosted)*2)
for position := 0; position < set.SourceLength; position++ {
merged = append(merged, byPosition[position]...)
if block, exists := regularByPosition[position]; exists {
merged = append(merged, block)
}
}
return merged, nil
}
before, after, exact := hostedOutsideRegularRange(claudeHostedPositions(hosted), set.RegularPositions)
if exact {
merged := make([]dto.ClaudeMediaMessage, 0, len(regular)+len(hosted)*2)
for _, item := range before {
merged = append(merged, hosted[item].blocks...)
}
merged = append(merged, regular...)
for _, item := range after {
merged = append(merged, hosted[item].blocks...)
}
return merged, nil
}
merged := make([]dto.ClaudeMediaMessage, 0, len(regular)+len(hosted)*2)
for _, item := range hosted {
merged = append(merged, item.blocks...)
}
merged = append(merged, regular...)
return merged, []types.ConversionDiagnostic{responseSemanticLoss(
"content",
"hosted_tool_order_unrepresentable",
"hosted-tool blocks were interleaved with content that the target converter coalesced, so their original order cannot be reconstructed",
)}
}
func hostedPositions(items []positionedResponsesOutput) []int {
positions := make([]int, len(items))
for index := range items {
positions[index] = items[index].position
}
return positions
}
func claudeHostedPositions(items []positionedClaudeBlocks) []int {
positions := make([]int, len(items))
for index := range items {
positions[index] = items[index].position
}
return positions
}
func hostedOutsideRegularRange(hosted []int, regular []int) (before []int, after []int, exact bool) {
if len(regular) == 0 {
indices := make([]int, len(hosted))
for index := range hosted {
indices[index] = index
}
return indices, nil, true
}
firstRegular, lastRegular := regular[0], regular[len(regular)-1]
for index, position := range hosted {
switch {
case position < firstRegular:
before = append(before, index)
case position > lastRegular:
after = append(after, index)
default:
return nil, nil, false
}
}
return before, after, true
}
func hostedKindFromClaudeCall(blockType string, name string) Kind {
if blockType == "mcp_tool_use" {
return KindMCP
}
switch strings.TrimSpace(name) {
case "web_search":
return KindWebSearch
case "web_fetch":
return KindWebFetch
case "code_execution":
return KindCodeExecution
default:
return KindNative
}
}
func hostedKindFromClaudeResult(blockType string) Kind {
switch strings.TrimSuffix(blockType, "_tool_result") {
case "web_search":
return KindWebSearch
case "web_fetch":
return KindWebFetch
case "code_execution":
return KindCodeExecution
case "mcp":
return KindMCP
default:
return KindNative
}
}
func hostedKindFromResponsesType(outputType string) Kind {
normalized := strings.TrimSpace(outputType)
normalized = strings.TrimSuffix(normalized, "_output")
normalized = strings.TrimSuffix(normalized, "_call")
switch normalized {
case "web_search":
return KindWebSearch
case "file_search":
return KindFileSearch
case "code_interpreter", "local_shell":
return KindCodeExecution
case "computer":
return KindComputerUse
case "image_generation":
return KindImage
case "mcp":
return KindMCP
default:
return KindNative
}
}
func responsesTypeFromHostedKind(kind Kind) string {
switch kind {
case KindWebSearch:
return "web_search_call"
case KindMCP:
return "mcp_call"
default:
return ""
}
}
func claudeNameFromHostedKind(kind Kind) string {
switch kind {
case KindWebSearch:
return "web_search"
case KindWebFetch:
return "web_fetch"
case KindCodeExecution:
return ""
case KindMCP:
return "mcp"
default:
return ""
}
}
func isClaudeHostedResult(nativeType string) bool {
return nativeType == "mcp_tool_result" || strings.HasSuffix(nativeType, "_tool_result")
}
func hostedCompletionStatus(item HostedResponseItem) string {
if hostedItemFailed(item) {
return "failed"
}
if item.Status != "" && item.Status != "in_progress" || len(item.Results) > 0 || len(item.Output) > 0 {
return "completed"
}
return "in_progress"
}
func hostedItemFailed(item HostedResponseItem) bool {
return item.Status == "failed" || item.ErrorCode != "" || rawJSONPresent(item.Error) || item.IsError != nil && *item.IsError
}
func hostedErrorValue(item HostedResponseItem) json.RawMessage {
if len(item.Error) > 0 {
return append(json.RawMessage(nil), item.Error...)
}
if item.ErrorCode == "" {
return nil
}
encoded, _ := kitutil.Marshal(item.ErrorCode)
return encoded
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
func rawJSONPresent(value json.RawMessage) bool {
normalized := strings.TrimSpace(string(value))
return normalized != "" && normalized != "null"
}
func claudeResultTypeFromHostedKind(kind Kind) string {
name := claudeNameFromHostedKind(kind)
if name == "mcp" {
return "mcp_tool_result"
}
return name + "_tool_result"
}
+135
View File
@@ -0,0 +1,135 @@
// 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"
)
// Codec is the JSON engine used by every kitutil JSON helper. The host may
// replace it once at startup via SetCodec; relaykit itself never depends on a
// third-party JSON library.
type Codec interface {
Marshal(v any) ([]byte, error)
Unmarshal(data []byte, v any) error
Decode(r io.Reader, v any) error
Valid(data []byte) bool
}
// stdCodec is the default Codec, backed by encoding/json. It is the only place
// in relaykit that calls the standard library's JSON functions directly.
type stdCodec struct{}
func (stdCodec) Marshal(v any) ([]byte, error) {
return json.Marshal(v)
}
func (stdCodec) Unmarshal(data []byte, v any) error {
return json.Unmarshal(data, v)
}
func (stdCodec) Decode(r io.Reader, v any) error {
return json.NewDecoder(r).Decode(v)
}
func (stdCodec) Valid(data []byte) bool {
return json.Valid(data)
}
var codec Codec = stdCodec{}
// SetCodec installs the JSON engine behind every kitutil JSON helper, which
// also covers the custom (Un)MarshalJSON methods on relaykit DTOs. Like
// SetLogging it is meant to be called once during host startup before any
// request is served; it is not synchronized against concurrent helper calls.
// A nil codec is ignored so the standard-library default stays in place.
func SetCodec(c Codec) {
if c == nil {
return
}
codec = c
}
func Unmarshal(data []byte, v any) error {
return codec.Unmarshal(data, v)
}
func UnmarshalJsonStr(data string, v any) error {
return codec.Unmarshal(StringToByteSlice(data), v)
}
func DecodeJson(reader io.Reader, v any) error {
return codec.Decode(reader, v)
}
func Marshal(v any) ([]byte, error) {
return codec.Marshal(v)
}
// Valid reports whether data is a syntactically valid JSON document.
func Valid(data []byte) bool {
return codec.Valid(data)
}
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
encoded, err := Marshal(data)
if err != nil {
return zero, err
}
var res T
err = Unmarshal(encoded, &res)
if err != nil {
return zero, err
}
return res, nil
}
@@ -0,0 +1,83 @@
package kitutil
import (
"encoding/json"
"io"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// recordingCodec notes which Codec method each helper reaches and delegates to
// the standard-library default so the helpers still produce real results.
type recordingCodec struct {
calls []string
}
func (c *recordingCodec) Marshal(v any) ([]byte, error) {
c.calls = append(c.calls, "Marshal")
return stdCodec{}.Marshal(v)
}
func (c *recordingCodec) Unmarshal(data []byte, v any) error {
c.calls = append(c.calls, "Unmarshal")
return stdCodec{}.Unmarshal(data, v)
}
func (c *recordingCodec) Decode(r io.Reader, v any) error {
c.calls = append(c.calls, "Decode")
return stdCodec{}.Decode(r, v)
}
func (c *recordingCodec) Valid(data []byte) bool {
c.calls = append(c.calls, "Valid")
return stdCodec{}.Valid(data)
}
func TestJSONHelpersRouteThroughInjectedCodec(t *testing.T) {
t.Cleanup(func() { SetCodec(stdCodec{}) })
fake := &recordingCodec{}
SetCodec(fake)
// A nil codec must not displace the installed one.
SetCodec(nil)
encoded, err := Marshal(map[string]int{"a": 1})
require.NoError(t, err)
assert.Equal(t, `{"a":1}`, string(encoded))
var fromBytes map[string]int
require.NoError(t, Unmarshal([]byte(`{"b":2}`), &fromBytes))
assert.Equal(t, map[string]int{"b": 2}, fromBytes)
var fromString map[string]int
require.NoError(t, UnmarshalJsonStr(`{"c":3}`, &fromString))
assert.Equal(t, map[string]int{"c": 3}, fromString)
var decoded map[string]int
require.NoError(t, DecodeJson(strings.NewReader(`{"d":4}`), &decoded))
assert.Equal(t, map[string]int{"d": 4}, decoded)
assert.True(t, Valid([]byte(`[]`)))
assert.False(t, Valid([]byte(`[`)))
converted, err := Any2Type[map[string]int](map[string]any{"e": 5})
require.NoError(t, err)
assert.Equal(t, map[string]int{"e": 5}, converted)
assert.Equal(t, "hello", JsonRawMessageToString(json.RawMessage(`"hello"`)))
assert.Equal(t, []string{
"Marshal", // Marshal
"Unmarshal", // Unmarshal
"Unmarshal", // UnmarshalJsonStr
"Decode", // DecodeJson
"Valid", // Valid (well-formed)
"Valid", // Valid (malformed)
"Marshal", // Any2Type encode
"Unmarshal", // Any2Type decode
"Unmarshal", // JsonRawMessageToString string literal
}, fake.calls)
}
+65
View File
@@ -0,0 +1,65 @@
package kitutil
import (
"fmt"
"os"
"sync/atomic"
)
// Kit packages log rare data-shape anomalies through these hooks. The host
// redirects them into its logging system at startup; standalone relaykit users
// get stderr defaults.
type LogFunc func(message string)
var (
logInfo atomic.Pointer[LogFunc]
logError atomic.Pointer[LogFunc]
logSystemError atomic.Pointer[LogFunc]
)
func SetLogging(info LogFunc, errorFn LogFunc) {
if info != nil {
logInfo.Store(&info)
}
if errorFn != nil {
logError.Store(&errorFn)
}
}
// SetSystemErrorLogging configures the hook for internal converter failures.
func SetSystemErrorLogging(errorFn LogFunc) {
if errorFn != nil {
logSystemError.Store(&errorFn)
}
}
func LogInfo(message string) {
if fn := logInfo.Load(); fn != nil {
(*fn)(message)
return
}
fmt.Fprintf(os.Stderr, "[relaykit] %s\n", message)
}
func LogError(message string) {
if fn := logError.Load(); fn != nil {
(*fn)(message)
return
}
fmt.Fprintf(os.Stderr, "[relaykit] ERROR %s\n", message)
}
// LogSystemError reports an internal converter failure through its dedicated
// hook, keeping it distinct from malformed request-data diagnostics.
func LogSystemError(message string) {
if fn := logSystemError.Load(); fn != nil {
(*fn)(message)
return
}
fmt.Fprintf(os.Stderr, "[relaykit] SYSTEM ERROR %s\n", message)
}
// Debug reports whether verbose kit diagnostics are enabled. The host sets
// this once at startup (new-api mirrors common.DebugEnabled into it).
var Debug atomic.Bool
+31
View File
@@ -0,0 +1,31 @@
package kitutil
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestErrorHooksRemainDistinct(t *testing.T) {
previousError := logError.Load()
previousSystemError := logSystemError.Load()
t.Cleanup(func() {
logError.Store(previousError)
logSystemError.Store(previousSystemError)
})
var ordinaryMessages []string
var systemMessages []string
SetLogging(nil, func(message string) {
ordinaryMessages = append(ordinaryMessages, message)
})
SetSystemErrorLogging(func(message string) {
systemMessages = append(systemMessages, message)
})
LogError("invalid dto")
LogSystemError("converter failure")
assert.Equal(t, []string{"invalid dto"}, ordinaryMessages)
assert.Equal(t, []string{"converter failure"}, systemMessages)
}
+131
View File
@@ -0,0 +1,131 @@
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 := max(len(parts)-len(tail), 1)
stars := strings.TrimSuffix(strings.Repeat("***.", numStars), ".")
return stars + "." + strings.Join(tail, ".")
}
// MaskSensitiveInfo masks sensitive information like URLs, IPs, and domain names in a string
// Example:
// http://example.com -> http://***.com
// https://api.test.org/v1/users/123?key=secret -> https://***.org/***/***/?key=***
// https://sub.domain.co.uk/path/to/resource -> https://***.co.uk/***/***
// 192.168.1.1 -> ***.***.***.***
// openai.com -> ***.com
// www.openai.com -> ***.***.com
// api.openai.com -> ***.***.com
func MaskSensitiveInfo(str string) string {
// Mask URLs
str = maskURLPattern.ReplaceAllStringFunc(str, func(urlStr string) string {
u, err := url.Parse(urlStr)
if err != nil {
return urlStr
}
host := u.Host
if host == "" {
return urlStr
}
// Mask host with unified logic
maskedHost := maskHostForURL(host)
result := u.Scheme + "://" + maskedHost
// Mask path
if u.Path != "" && u.Path != "/" {
pathParts := strings.Split(strings.Trim(u.Path, "/"), "/")
maskedPathParts := make([]string, len(pathParts))
for i := range pathParts {
if pathParts[i] != "" {
maskedPathParts[i] = "***"
}
}
if len(maskedPathParts) > 0 {
result += "/" + strings.Join(maskedPathParts, "/")
}
} else if u.Path == "/" {
result += "/"
}
// Mask query parameters
if u.RawQuery != "" {
values, err := url.ParseQuery(u.RawQuery)
if err != nil {
// If can't parse query, just mask the whole query string
result += "?***"
} else {
maskedParams := make([]string, 0, len(values))
for key := range values {
maskedParams = append(maskedParams, key+"=***")
}
if len(maskedParams) > 0 {
result += "?" + strings.Join(maskedParams, "&")
}
}
}
return result
})
// Mask domain names without protocol (like openai.com, www.openai.com)
str = maskDomainPattern.ReplaceAllStringFunc(str, func(domain string) string {
return maskHostForPlainDomain(domain)
})
// Mask IP addresses
str = maskIPPattern.ReplaceAllString(str, "***.***.***.***")
// Mask API keys (e.g., "api_key:AIzaSyAAAaUooTUni8AdaOkSRMda30n_Q4vrV70" -> "api_key:***")
str = maskApiKeyPattern.ReplaceAllString(str, "${1}api_key:***${3}")
return str
}
+52
View File
@@ -0,0 +1,52 @@
package kitutil
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
)
func GetPointer[T any](v T) *T {
return &v
}
func Interface2String(inter any) string {
switch inter.(type) {
case string:
return inter.(string)
case int:
return fmt.Sprintf("%d", inter.(int))
case float64:
return strconv.FormatFloat(inter.(float64), 'f', -1, 64)
case bool:
if inter.(bool) {
return "true"
} else {
return "false"
}
case nil:
return ""
}
return fmt.Sprintf("%v", inter)
}
func String2Int(str string) int {
num, err := strconv.Atoi(str)
if err != nil {
return 0
}
return num
}
func GetUUID() string {
code := uuid.New().String()
code = strings.Replace(code, "-", "", -1)
return code
}
func GetTimestamp() int64 {
return time.Now().Unix()
}
+9
View File
@@ -0,0 +1,9 @@
package relayconvert
import relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
type MediaResolver = relaymedia.MediaResolver
func SetMediaResolver(resolver MediaResolver) {
relaymedia.SetMediaResolver(resolver)
}
+366
View File
@@ -0,0 +1,366 @@
package reasoning
import (
"fmt"
"math"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
)
type ClaudeRender struct {
Thinking *dto.Thinking
OutputEffort Effort
EffectiveEffort Effort
ClearSampling bool
ConstrainThinkingSampling bool
Diagnostics []types.ConversionDiagnostic
}
type claudeCapabilities struct {
adaptive bool
supportsManual bool
defaultThinking bool
supportsDisable bool
supportsEffort bool
supportsXHigh bool
supportsMax bool
strictSampling bool
}
func claudeCapabilitiesFor(model string) claudeCapabilities {
model = strings.ToLower(model)
capabilities := claudeCapabilities{supportsManual: true, supportsDisable: true}
switch {
case strings.HasPrefix(model, "claude-fable-5"),
strings.HasPrefix(model, "claude-mythos-5"):
capabilities.adaptive = true
capabilities.supportsManual = false
capabilities.defaultThinking = true
capabilities.supportsDisable = false
capabilities.supportsXHigh = true
capabilities.supportsMax = true
capabilities.strictSampling = true
case strings.HasPrefix(model, "claude-mythos-preview"):
capabilities.adaptive = true
capabilities.defaultThinking = true
capabilities.supportsDisable = false
capabilities.supportsMax = true
capabilities.strictSampling = true
case strings.HasPrefix(model, "claude-opus-5"),
strings.HasPrefix(model, "claude-sonnet-5"),
strings.HasPrefix(model, "claude-opus-4-8"),
strings.HasPrefix(model, "claude-opus-4-7"):
capabilities.adaptive = true
capabilities.supportsManual = false
if strings.HasPrefix(model, "claude-opus-5") || strings.HasPrefix(model, "claude-sonnet-5") {
capabilities.defaultThinking = true
}
capabilities.supportsEffort = true
capabilities.supportsXHigh = true
capabilities.supportsMax = true
capabilities.strictSampling = true
case strings.HasPrefix(model, "claude-opus-4-6"),
strings.HasPrefix(model, "claude-sonnet-4-6"):
capabilities.adaptive = true
capabilities.supportsEffort = true
capabilities.supportsMax = true
case strings.HasPrefix(model, "claude-opus-4-5"):
capabilities.supportsEffort = true
}
return capabilities
}
func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPercentage float64) (ClaudeRender, error) {
disabledWithEffort := intent.Mode == ModeDisabled && intent.Effort != "" && intent.Effort != EffortNone
if disabledWithEffort {
effort, err := ParseEffort(string(intent.Effort))
if err != nil {
return ClaudeRender{}, err
}
intent.Effort = effort
} else {
var err error
intent, err = normalizeIntent(intent)
if err != nil {
return ClaudeRender{}, err
}
}
capabilities := claudeCapabilitiesFor(model)
diagnostics := make([]types.ConversionDiagnostic, 0, 1)
if disabledWithEffort {
diagnostics = append(diagnostics, claudeReasoningDiagnostic(
"claude_disabled_effort_ignored",
fmt.Sprintf("model %q cannot apply effort %q while thinking is disabled; the effort was ignored", model, intent.Effort),
))
}
if !intent.HasStrength() {
if intent.IncludeThoughts != nil && capabilities.adaptive && capabilities.defaultThinking {
thinking := &dto.Thinking{Type: "adaptive"}
if *intent.IncludeThoughts {
thinking.Display = "summarized"
} else {
thinking.Display = "omitted"
}
return ClaudeRender{
Thinking: thinking,
EffectiveEffort: EffortHigh,
ClearSampling: capabilities.strictSampling,
}, nil
}
if capabilities.defaultThinking {
return ClaudeRender{EffectiveEffort: EffortHigh, ClearSampling: capabilities.strictSampling}, nil
}
return ClaudeRender{ClearSampling: capabilities.strictSampling}, nil
}
if intent.Mode == ModeDisabled || intent.Effort == EffortNone {
if !capabilities.supportsDisable {
diagnostics = append(diagnostics, claudeReasoningDiagnostic(
"claude_thinking_disable_unsupported",
fmt.Sprintf("model %q cannot disable thinking; using the lowest representable thinking mode", model),
))
if capabilities.adaptive {
thinking := &dto.Thinking{Type: "adaptive"}
if intent.IncludeThoughts != nil {
if *intent.IncludeThoughts {
thinking.Display = "summarized"
} else {
thinking.Display = "omitted"
}
}
outputEffort := Effort("")
effectiveEffort := EffortHigh
if capabilities.supportsEffort {
outputEffort = EffortLow
effectiveEffort = EffortLow
}
return ClaudeRender{
Thinking: thinking,
OutputEffort: outputEffort,
EffectiveEffort: effectiveEffort,
ClearSampling: capabilities.strictSampling,
Diagnostics: diagnostics,
}, nil
}
return ClaudeRender{Diagnostics: diagnostics}, nil
}
return ClaudeRender{
Thinking: &dto.Thinking{Type: "disabled"},
EffectiveEffort: EffortNone,
ClearSampling: capabilities.strictSampling,
Diagnostics: diagnostics,
}, nil
}
preferManual := capabilities.supportsManual && intent.BudgetTokens != nil && intent.Mode != ModeAdaptive
if !capabilities.supportsManual && intent.BudgetTokens != nil && intent.Mode == ModeEnabled {
diagnostics = append(diagnostics, claudeReasoningDiagnostic(
"claude_budget_to_adaptive",
fmt.Sprintf("model %q uses adaptive thinking; budget_tokens was converted to an effort level", model),
))
}
if capabilities.adaptive && !preferManual {
effort := intent.Effort
if effort == "" && intent.BudgetTokens != nil {
effort = EffortFromBudget(*intent.BudgetTokens)
}
if effort == "" && intent.Mode == ModeEnabled {
effort = EffortHigh
}
normalizedEffort := normalizeClaudeEffort(effort, capabilities)
if effort != "" && normalizedEffort != effort {
diagnostics = append(diagnostics, claudeReasoningDiagnostic(
"claude_effort_adjusted",
fmt.Sprintf("model %q does not support effort %q; using %q", model, effort, normalizedEffort),
))
}
effort = normalizedEffort
effectiveEffort := effort
if effectiveEffort == "" && intent.Mode == ModeAdaptive {
effectiveEffort = EffortHigh
}
// Claude effort can be used without enabling thinking. Preserve that
// distinction for native Claude requests; OpenAI extractors explicitly
// mark reasoning efforts as ModeEnabled.
if intent.Mode == ModeUnset {
return ClaudeRender{
OutputEffort: effort,
EffectiveEffort: effectiveEffort,
ClearSampling: capabilities.strictSampling,
Diagnostics: diagnostics,
}, nil
}
thinking := &dto.Thinking{Type: "adaptive"}
if intent.IncludeThoughts != nil {
if *intent.IncludeThoughts {
thinking.Display = "summarized"
} else {
thinking.Display = "omitted"
}
}
return ClaudeRender{
Thinking: thinking,
OutputEffort: effort,
EffectiveEffort: effectiveEffort,
ClearSampling: capabilities.strictSampling,
ConstrainThinkingSampling: !capabilities.strictSampling,
Diagnostics: diagnostics,
}, nil
}
if intent.Mode == ModeAdaptive {
diagnostics = append(diagnostics, claudeReasoningDiagnostic(
"claude_adaptive_to_manual",
fmt.Sprintf("model %q does not support adaptive thinking; using manual thinking", model),
))
intent.Mode = ModeEnabled
if intent.Effort == "" {
intent.Effort = EffortHigh
}
}
if intent.Mode == ModeUnset {
return ClaudeRender{OutputEffort: intent.Effort, EffectiveEffort: intent.Effort}, nil
}
if maxTokens == nil {
return ClaudeRender{}, fmt.Errorf("max_tokens is required for manual Claude thinking")
}
if *maxTokens <= 1024 {
return ClaudeRender{}, fmt.Errorf("max_tokens must be greater than 1024 for manual Claude thinking")
}
if uint64(*maxTokens) > uint64(math.MaxInt) {
return ClaudeRender{}, fmt.Errorf("max_tokens is too large for a thinking budget")
}
budget := 0
if intent.BudgetTokens != nil && *intent.BudgetTokens >= 0 {
requestedBudget := *intent.BudgetTokens
budget = max(requestedBudget, 1024)
if uint(budget) >= *maxTokens {
budget = int(*maxTokens) - 1
}
if budget != requestedBudget {
diagnostics = append(diagnostics, claudeReasoningDiagnostic(
"claude_budget_adjusted",
fmt.Sprintf("model %q requires 1024 <= budget_tokens < max_tokens; adjusted %d to %d", model, requestedBudget, budget),
))
}
} else {
if intent.BudgetTokens != nil {
diagnostics = append(diagnostics, claudeReasoningDiagnostic(
"claude_dynamic_budget_converted",
fmt.Sprintf("model %q does not support a dynamic budget; derived a manual budget from reasoning effort", model),
))
}
percentage := effortPercentage(intent.Effort, adapterBudgetPercentage)
budget = max(int(*maxTokens)*percentage/100, 1024)
if uint(budget) >= *maxTokens {
budget = int(*maxTokens) - 1
}
}
effectiveEffort := intent.Effort
if intent.BudgetTokens != nil && !capabilities.supportsEffort {
effectiveEffort = EffortFromBudget(budget)
} else if effectiveEffort == "" {
effectiveEffort = EffortFromBudget(budget)
}
outputEffort := Effort("")
if capabilities.supportsEffort && intent.Effort != "" {
outputEffort = normalizeClaudeEffort(intent.Effort, capabilities)
effectiveEffort = outputEffort
}
thinking := &dto.Thinking{Type: "enabled", BudgetTokens: &budget}
if intent.IncludeThoughts != nil {
if *intent.IncludeThoughts {
thinking.Display = "summarized"
} else {
thinking.Display = "omitted"
}
}
return ClaudeRender{
Thinking: thinking,
OutputEffort: outputEffort,
EffectiveEffort: effectiveEffort,
ConstrainThinkingSampling: true,
Diagnostics: diagnostics,
}, nil
}
func claudeReasoningDiagnostic(code string, message string) types.ConversionDiagnostic {
return types.ConversionDiagnostic{
Code: code,
Path: "thinking",
Message: message,
Severity: types.ConversionDiagnosticWarning,
To: types.RelayFormatClaude,
}
}
// ClaudeUsesManualThinking reports whether an exact numeric budget is rendered
// as legacy extended thinking rather than being reduced to adaptive effort.
func ClaudeUsesManualThinking(model string, intent Intent) bool {
capabilities := claudeCapabilitiesFor(model)
return capabilities.supportsManual && intent.BudgetTokens != nil && intent.Mode != ModeAdaptive
}
func IsKnownClaudeModel(model string) bool {
return isKnownClaudeModel(model)
}
func ResolveClaudeDefault(model string, intent Intent) Intent {
if intent.HasStrength() || !claudeCapabilitiesFor(model).defaultThinking {
return intent
}
intent.Mode = ModeAdaptive
intent.Effort = EffortHigh
return intent
}
func normalizeClaudeEffort(effort Effort, capabilities claudeCapabilities) Effort {
switch effort {
case EffortMinimal:
return EffortLow
case EffortXHigh:
if capabilities.supportsXHigh {
return effort
}
if capabilities.supportsMax {
return EffortMax
}
return EffortHigh
case EffortMax:
if !capabilities.supportsMax {
return EffortHigh
}
}
return effort
}
func effortPercentage(effort Effort, adapterBudgetPercentage float64) int {
switch effort {
case EffortMinimal:
return 5
case EffortLow:
return 20
case EffortMedium:
return 50
case EffortHigh:
return 80
case EffortXHigh, EffortMax:
return 95
}
percentage := int(math.Round(adapterBudgetPercentage * 100))
if percentage <= 0 {
return 80
}
if percentage >= 100 {
return 99
}
return percentage
}
+374
View File
@@ -0,0 +1,374 @@
package reasoning
import (
"fmt"
"math"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
)
type GeminiRender struct {
Config *dto.GeminiThinkingConfig
EffectiveEffort Effort
}
type geminiThinkingKind int
const (
geminiThinkingUnknown geminiThinkingKind = iota
geminiThinkingNotConfigurable
geminiThinkingBudget
geminiThinkingLevel
)
type geminiCapabilities struct {
kind geminiThinkingKind
supportsDisable bool
supportsIncludeThoughts bool
minBudget int
maxBudget int
}
func geminiCapabilitiesFor(model string) geminiCapabilities {
model = strings.ToLower(model)
switch {
case strings.HasPrefix(model, "gemini-2.5-flash-native-audio"),
strings.HasPrefix(model, "gemini-live-2.5-flash-preview-native-audio"):
return geminiCapabilities{kind: geminiThinkingBudget, supportsDisable: true, maxBudget: 24576}
case strings.HasPrefix(model, "gemini-2.5-flash-image"),
strings.Contains(model, "-tts"),
strings.Contains(model, "-native-audio"),
strings.Contains(model, "-live"):
return geminiCapabilities{kind: geminiThinkingNotConfigurable}
case strings.HasPrefix(model, "gemini-3-pro-image"),
strings.HasPrefix(model, "nano-banana-pro"):
return geminiCapabilities{kind: geminiThinkingNotConfigurable, supportsIncludeThoughts: true}
case model == "gemini-flash-latest", model == "gemini-flash-lite-latest":
return geminiCapabilities{kind: geminiThinkingLevel}
case model == "gemini-pro-latest":
return geminiCapabilities{kind: geminiThinkingLevel}
case strings.HasPrefix(model, "gemini-2.5-pro"):
return geminiCapabilities{kind: geminiThinkingBudget, minBudget: 128, maxBudget: 32768}
case strings.HasPrefix(model, "gemini-2.5-flash-lite"):
return geminiCapabilities{kind: geminiThinkingBudget, supportsDisable: true, minBudget: 512, maxBudget: 24576}
case strings.HasPrefix(model, "gemini-2.5-"):
return geminiCapabilities{kind: geminiThinkingBudget, supportsDisable: true, maxBudget: 24576}
case strings.HasPrefix(model, "gemini-3"):
return geminiCapabilities{kind: geminiThinkingLevel}
default:
return geminiCapabilities{}
}
}
func RenderGemini(model string, intent Intent, maxOutputTokens *uint, adapterBudgetPercentage float64) (GeminiRender, error) {
intent, err := normalizeIntent(intent)
if err != nil {
return GeminiRender{}, err
}
if intent.IsEmpty() {
return GeminiRender{}, nil
}
capabilities := geminiCapabilitiesFor(model)
if capabilities.kind == geminiThinkingNotConfigurable {
if !intent.HasStrength() && capabilities.supportsIncludeThoughts {
return GeminiRender{Config: &dto.GeminiThinkingConfig{IncludeThoughts: intent.IncludeThoughts}, EffectiveEffort: EffortHigh}, nil
}
return GeminiRender{}, fmt.Errorf("model %q does not support configurable thinking", model)
}
if capabilities.kind == geminiThinkingUnknown {
if intent.HasStrength() {
return GeminiRender{}, fmt.Errorf("model %q does not have a known Gemini thinking configuration", model)
}
return GeminiRender{Config: &dto.GeminiThinkingConfig{IncludeThoughts: intent.IncludeThoughts}}, nil
}
config := &dto.GeminiThinkingConfig{IncludeThoughts: intent.IncludeThoughts}
if capabilities.kind == geminiThinkingBudget {
if intent.Mode == ModeDisabled || intent.Effort == EffortNone {
if !capabilities.supportsDisable {
return GeminiRender{}, fmt.Errorf("%w for model %q", ErrThinkingNotDisabled, model)
}
budget := 0
config.ThinkingBudget = &budget
return GeminiRender{Config: config, EffectiveEffort: EffortNone}, nil
}
budget := 0
hasBudget := false
if intent.BudgetTokens != nil {
budget = *intent.BudgetTokens
if intent.BudgetSource != SourceNative && budget != -1 {
budget = clampGeminiBudget(budget, capabilities)
}
hasBudget = true
} else if intent.Effort != "" {
budget = gemini25BudgetForEffort(intent.Effort)
hasBudget = true
} else if intent.Mode != ModeUnset && maxOutputTokens != nil && *maxOutputTokens > 0 {
if uint64(*maxOutputTokens) > uint64(math.MaxInt) {
return GeminiRender{}, fmt.Errorf("max_output_tokens is too large for a thinking budget")
}
percentage := adapterBudgetPercentage
if percentage <= 0 {
percentage = 0.6
} else if percentage > 1 {
percentage = 1
}
budget = int(math.Round(float64(*maxOutputTokens) * percentage))
budget = clampGeminiBudget(budget, capabilities)
hasBudget = true
}
if hasBudget {
if err := validateGeminiBudget(model, budget, capabilities); err != nil {
return GeminiRender{}, err
}
config.ThinkingBudget = &budget
}
effort := intent.Effort
if hasBudget {
effort = EffortFromBudget(budget)
} else if intent.Mode == ModeEnabled || intent.Mode == ModeAdaptive {
effort = geminiDefaultEffort(model)
}
return GeminiRender{Config: config, EffectiveEffort: effort}, nil
}
if intent.Mode == ModeDisabled || intent.Effort == EffortNone {
return GeminiRender{}, fmt.Errorf("%w for model %q", ErrThinkingNotDisabled, model)
}
effort := intent.Effort
if effort == "" && intent.BudgetTokens != nil {
effort = EffortFromBudget(*intent.BudgetTokens)
}
if effort != "" {
level, err := geminiLevelForEffort(model, effort)
if err != nil {
return GeminiRender{}, err
}
config.ThinkingLevel = level
effort = Effort(level)
} else if intent.Mode == ModeEnabled || intent.Mode == ModeAdaptive {
effort = geminiDefaultEffort(model)
}
return GeminiRender{Config: config, EffectiveEffort: effort}, nil
}
func geminiDefaultEffort(model string) Effort {
model = strings.ToLower(model)
switch {
case model == "gemini-flash-latest",
strings.HasPrefix(model, "gemini-3.5-flash") && !strings.HasPrefix(model, "gemini-3.5-flash-lite"),
strings.HasPrefix(model, "gemini-3.6-flash"):
return EffortMedium
case model == "gemini-flash-lite-latest",
strings.HasPrefix(model, "gemini-3.5-flash-lite"),
strings.HasPrefix(model, "gemini-3.1-flash-lite"):
return EffortMinimal
case model == "gemini-pro-latest",
strings.HasPrefix(model, "gemini-3.1-pro"),
strings.HasPrefix(model, "gemini-3-pro"),
strings.HasPrefix(model, "gemini-3-flash"):
return EffortHigh
default:
return ""
}
}
func ValidateGeminiThinkingConfig(model string, config *dto.GeminiThinkingConfig) (Effort, error) {
if config == nil {
return "", nil
}
intent, err := FromGemini(&dto.GeminiChatRequest{GenerationConfig: dto.GeminiChatGenerationConfig{ThinkingConfig: config}})
if err != nil {
return "", err
}
capabilities := geminiCapabilitiesFor(model)
if capabilities.kind == geminiThinkingNotConfigurable {
if !intent.HasStrength() && capabilities.supportsIncludeThoughts {
return EffortHigh, nil
}
return "", fmt.Errorf("model %q does not support configurable thinking", model)
}
if capabilities.kind == geminiThinkingUnknown {
return EffectiveEffort(intent), nil
}
if capabilities.kind == geminiThinkingBudget {
if config.ThinkingLevel != "" {
return "", fmt.Errorf("Gemini 2.5 model %q requires thinkingBudget, not thinkingLevel", model)
}
if config.ThinkingBudget != nil {
if err := validateGeminiBudget(model, *config.ThinkingBudget, capabilities); err != nil {
return "", err
}
}
return EffectiveEffort(intent), nil
}
if config.ThinkingBudget != nil {
return "", fmt.Errorf("Gemini 3 model %q requires thinkingLevel, not thinkingBudget", model)
}
if config.ThinkingLevel != "" {
level, err := geminiLevelForEffort(model, intent.Effort)
if err != nil {
return "", err
}
if level != config.ThinkingLevel {
return "", fmt.Errorf("thinkingLevel %q is not supported by model %q", config.ThinkingLevel, model)
}
return Effort(level), nil
}
return "", nil
}
// ResolveGeminiDefault materializes documented family defaults when a
// conversion targets another protocol. Dynamic 2.5 defaults retain their -1
// budget in the in-process pivot; Flash-Lite's default is explicitly off.
func ResolveGeminiDefault(model string, intent Intent) Intent {
if intent.HasStrength() {
return intent
}
capabilities := geminiCapabilitiesFor(model)
if capabilities.kind == geminiThinkingBudget {
if strings.HasPrefix(strings.ToLower(model), "gemini-2.5-flash-lite") {
budget := 0
intent.Mode = ModeDisabled
intent.Effort = EffortNone
intent.BudgetTokens = &budget
intent.BudgetSource = SourceNative
return intent
}
budget := -1
intent.Mode = ModeEnabled
intent.BudgetTokens = &budget
intent.BudgetSource = SourceNative
return intent
}
if capabilities.kind != geminiThinkingLevel {
return intent
}
effort := geminiDefaultEffort(model)
if effort == "" {
return intent
}
intent.Mode = ModeEnabled
intent.Effort = effort
return intent
}
// ResolveGeminiEnabledDefault fills the strength implied by an explicit
// enable-only control such as the legacy -thinking model alias.
func ResolveGeminiEnabledDefault(model string, intent Intent, maxOutputTokens *uint) Intent {
if intent.Mode != ModeEnabled || intent.Effort != "" || intent.BudgetTokens != nil {
return intent
}
capabilities := geminiCapabilitiesFor(model)
if capabilities.kind == geminiThinkingBudget {
if intent.Source == SourceSuffix && maxOutputTokens != nil && *maxOutputTokens > 0 {
return intent
}
budget := -1
intent.BudgetTokens = &budget
intent.BudgetSource = SourceSuffix
return intent
}
if capabilities.kind == geminiThinkingLevel {
intent.Effort = geminiDefaultEffort(model)
}
return intent
}
// EquivalentGeminiStrength compares two controls after applying the target
// model's budget/level mapping. This accepts distinct canonical labels that
// are identical on the Gemini wire (for example minimal and low on 2.5).
func EquivalentGeminiStrength(model string, left Intent, right Intent) (bool, error) {
leftRendered, err := RenderGemini(model, left, nil, 0)
if err != nil {
return false, err
}
rightRendered, err := RenderGemini(model, right, nil, 0)
if err != nil {
return false, err
}
if leftRendered.Config == nil || rightRendered.Config == nil {
return leftRendered.Config == nil && rightRendered.Config == nil, nil
}
leftConfig, rightConfig := leftRendered.Config, rightRendered.Config
if leftConfig.ThinkingLevel != rightConfig.ThinkingLevel {
return false, nil
}
if (leftConfig.ThinkingBudget == nil) != (rightConfig.ThinkingBudget == nil) {
return false, nil
}
return leftConfig.ThinkingBudget == nil || *leftConfig.ThinkingBudget == *rightConfig.ThinkingBudget, nil
}
func gemini25BudgetForEffort(effort Effort) int {
switch effort {
case EffortMinimal, EffortLow:
return 1024
case EffortMedium:
return 8192
case EffortHigh, EffortXHigh, EffortMax:
return 24576
default:
return 0
}
}
func geminiLevelForEffort(model string, effort Effort) (string, error) {
model = strings.ToLower(model)
switch {
case strings.HasPrefix(model, "gemini-3.1-flash-image"),
strings.HasPrefix(model, "gemini-3.1-flash-lite-image"):
if effort == EffortMinimal || effort == EffortLow {
return string(EffortMinimal), nil
}
return string(EffortHigh), nil
case (strings.HasPrefix(model, "gemini-3-pro") && !strings.HasPrefix(model, "gemini-3.1-pro")):
if effort == EffortMinimal || effort == EffortLow {
return string(EffortLow), nil
}
return string(EffortHigh), nil
case strings.HasPrefix(model, "gemini-3.1-pro"), model == "gemini-pro-latest":
if effort == EffortMinimal {
return string(EffortLow), nil
}
}
switch effort {
case EffortMinimal, EffortLow, EffortMedium, EffortHigh:
return string(effort), nil
case EffortXHigh, EffortMax:
return string(EffortHigh), nil
case EffortNone:
return "", fmt.Errorf("%w for model %q", ErrThinkingNotDisabled, model)
default:
return "", fmt.Errorf("%w %q for model %q", ErrUnsupportedEffort, effort, model)
}
}
func validateGeminiBudget(model string, budget int, capabilities geminiCapabilities) error {
if budget == -1 {
return nil
}
if budget == 0 {
if capabilities.supportsDisable {
return nil
}
return fmt.Errorf("%w for model %q", ErrThinkingNotDisabled, model)
}
if budget < capabilities.minBudget || budget > capabilities.maxBudget {
return fmt.Errorf("thinking budget %d is outside the supported range [%d,%d] for model %q", budget, capabilities.minBudget, capabilities.maxBudget, model)
}
return nil
}
func clampGeminiBudget(budget int, capabilities geminiCapabilities) int {
if budget < capabilities.minBudget {
return capabilities.minBudget
}
if budget > capabilities.maxBudget {
return capabilities.maxBudget
}
return budget
}
+599
View File
@@ -0,0 +1,599 @@
package reasoning
import (
"errors"
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
type Effort string
const (
EffortNone Effort = "none"
EffortMinimal Effort = "minimal"
EffortLow Effort = "low"
EffortMedium Effort = "medium"
EffortHigh Effort = "high"
EffortXHigh Effort = "xhigh"
EffortMax Effort = "max"
)
type Mode string
type Source string
// ClientError marks invalid user-supplied reasoning controls so host handlers
// can return a 4xx without classifying unrelated adapter failures as client
// errors.
type ClientError struct {
err error
}
func (e *ClientError) Error() string { return e.err.Error() }
func (e *ClientError) Unwrap() error { return e.err }
func AsClientError(err error) error {
if err == nil {
return nil
}
var clientErr *ClientError
if errors.As(err, &clientErr) {
return err
}
return &ClientError{err: err}
}
func IsClientError(err error) bool {
var clientErr *ClientError
return errors.As(err, &clientErr)
}
const (
ModeUnset Mode = ""
ModeEnabled Mode = "enabled"
ModeAdaptive Mode = "adaptive"
ModeDisabled Mode = "disabled"
)
const (
SourceExplicit Source = "explicit"
SourceNative Source = "native"
SourceSuffix Source = "suffix"
SourcePivot Source = "pivot"
)
var (
ErrEffortConflict = errors.New("reasoning settings conflict")
ErrUnsupportedEffort = errors.New("unsupported reasoning effort")
ErrThinkingNotDisabled = errors.New("thinking cannot be disabled")
)
// Intent is the protocol-independent part of a request's reasoning controls.
// Summary visibility is intentionally independent from reasoning strength.
type Intent struct {
Mode Mode
Effort Effort
BudgetTokens *int
IncludeThoughts *bool
Source Source
BudgetSource Source
}
func (i Intent) HasStrength() bool {
return i.Mode != ModeUnset || i.Effort != "" || i.BudgetTokens != nil
}
func (i Intent) IsEmpty() bool {
return !i.HasStrength() && i.IncludeThoughts == nil
}
// IntentFromState reconstructs a portable intent from host- or pivot-carried
// conversion state. A nil state is an empty intent.
func IntentFromState(state *dto.ReasoningConversionState) Intent {
if state == nil {
return Intent{}
}
return Intent{
Mode: Mode(state.Mode),
Effort: Effort(state.Effort),
BudgetTokens: state.BudgetTokens,
IncludeThoughts: state.IncludeThoughts,
Source: SourceSuffix,
BudgetSource: SourceSuffix,
}
}
// StateFromIntent copies the portable fields of an intent into conversion
// state. Empty intents produce nil so callers can omit the field.
func StateFromIntent(intent Intent) *dto.ReasoningConversionState {
if intent.IsEmpty() {
return nil
}
return &dto.ReasoningConversionState{
Mode: string(intent.Mode),
Effort: string(intent.Effort),
BudgetTokens: intent.BudgetTokens,
IncludeThoughts: intent.IncludeThoughts,
}
}
func ParseEffort(value string) (Effort, error) {
effort := Effort(strings.ToLower(strings.TrimSpace(value)))
if effort == "" {
return "", nil
}
switch effort {
case EffortNone, EffortMinimal, EffortLow, EffortMedium, EffortHigh, EffortXHigh, EffortMax:
return effort, nil
default:
return "", fmt.Errorf("%w: %q", ErrUnsupportedEffort, value)
}
}
func normalizeIntent(intent Intent) (Intent, error) {
effort, err := ParseEffort(string(intent.Effort))
if err != nil {
return Intent{}, err
}
intent.Effort = effort
switch intent.Mode {
case ModeUnset, ModeEnabled, ModeAdaptive, ModeDisabled:
default:
return Intent{}, fmt.Errorf("unsupported reasoning mode %q", intent.Mode)
}
if intent.BudgetTokens != nil {
budget := *intent.BudgetTokens
if budget < -1 {
return Intent{}, fmt.Errorf("thinking budget must be -1 or non-negative, got %d", budget)
}
if budget == 0 {
if intent.Mode == ModeEnabled || intent.Mode == ModeAdaptive || (intent.Effort != "" && intent.Effort != EffortNone) {
return Intent{}, fmt.Errorf("%w: zero budget disables thinking", ErrEffortConflict)
}
intent.Mode = ModeDisabled
intent.Effort = EffortNone
} else if intent.Mode == ModeDisabled || intent.Effort == EffortNone {
return Intent{}, fmt.Errorf("%w: a non-zero budget enables thinking", ErrEffortConflict)
} else if intent.Mode == ModeUnset {
intent.Mode = ModeEnabled
}
}
if intent.Effort == EffortNone {
if intent.Mode == ModeEnabled || intent.Mode == ModeAdaptive {
return Intent{}, fmt.Errorf("%w: effort none disables thinking", ErrEffortConflict)
}
intent.Mode = ModeDisabled
}
return intent, nil
}
// MergeExplicitAndSuffix combines structured request fields with a model-name
// alias. Contradictions are rejected because the alias may carry a distinct
// billing identity; silently choosing either side would make request semantics
// and accounting disagree.
func MergeExplicitAndSuffix(explicit Intent, suffix Intent, model string) (Intent, error) {
var err error
explicit, err = normalizeIntent(explicit)
if err != nil {
return Intent{}, err
}
suffix, err = normalizeIntent(suffix)
if err != nil {
return Intent{}, err
}
if !explicit.HasStrength() {
if explicit.IncludeThoughts != nil {
suffix.IncludeThoughts = explicit.IncludeThoughts
}
return suffix, nil
}
if !suffix.HasStrength() {
if explicit.IncludeThoughts == nil {
explicit.IncludeThoughts = suffix.IncludeThoughts
}
return explicit, nil
}
explicitDisabled := explicit.Mode == ModeDisabled || explicit.Effort == EffortNone
suffixDisabled := suffix.Mode == ModeDisabled || suffix.Effort == EffortNone
if explicitDisabled != suffixDisabled {
return Intent{}, fmt.Errorf("%w for model %q: explicit fields and model suffix disagree about whether thinking is enabled", ErrEffortConflict, model)
}
if !explicitDisabled && explicit.Effort != "" && suffix.Effort != "" && explicit.Effort != suffix.Effort {
return Intent{}, fmt.Errorf("%w for model %q: explicit effort %q differs from suffix effort %q", ErrEffortConflict, model, explicit.Effort, suffix.Effort)
}
if explicit.BudgetTokens != nil && suffix.BudgetTokens != nil && *explicit.BudgetTokens != *suffix.BudgetTokens {
return Intent{}, fmt.Errorf("%w for model %q: explicit budget %d differs from suffix budget %d", ErrEffortConflict, model, *explicit.BudgetTokens, *suffix.BudgetTokens)
}
if (explicit.Effort != "" && explicit.Effort != EffortNone && suffix.BudgetTokens != nil) ||
(explicit.BudgetTokens != nil && suffix.Effort != "" && suffix.Effort != EffortNone) {
return Intent{}, fmt.Errorf("%w for model %q: effort and an exact suffix budget cannot both select reasoning strength", ErrEffortConflict, model)
}
merged := suffix
if explicit.Mode != ModeUnset {
merged.Mode = explicit.Mode
}
if explicit.Effort != "" {
merged.Effort = explicit.Effort
}
if explicit.BudgetTokens != nil {
merged.BudgetTokens = explicit.BudgetTokens
merged.BudgetSource = explicit.BudgetSource
}
if explicit.IncludeThoughts != nil {
merged.IncludeThoughts = explicit.IncludeThoughts
}
return normalizeIntent(merged)
}
// MergeExplicit combines two structured representations of the same request.
// A numeric budget and an effort may coexist: Claude and OpenRouter expose both
// controls, and keeping both is what lets an in-memory OpenAI pivot preserve an
// exact budget for budget-based targets while retaining an effort for
// level-based targets.
func MergeExplicit(primary Intent, secondary Intent, model string) (Intent, error) {
var err error
primary, err = normalizeIntent(primary)
if err != nil {
return Intent{}, err
}
secondary, err = normalizeIntent(secondary)
if err != nil {
return Intent{}, err
}
if primary.IsEmpty() {
return secondary, nil
}
if secondary.IsEmpty() {
return primary, nil
}
primaryDisabled := primary.Mode == ModeDisabled || primary.Effort == EffortNone
secondaryDisabled := secondary.Mode == ModeDisabled || secondary.Effort == EffortNone
if primary.HasStrength() && secondary.HasStrength() && primaryDisabled != secondaryDisabled {
return Intent{}, fmt.Errorf("%w for model %q: explicit fields disagree about whether thinking is enabled", ErrEffortConflict, model)
}
if primary.Effort != "" && secondary.Effort != "" && primary.Effort != secondary.Effort {
return Intent{}, fmt.Errorf("%w for model %q: explicit efforts %q and %q differ", ErrEffortConflict, model, primary.Effort, secondary.Effort)
}
if primary.BudgetTokens != nil && secondary.BudgetTokens != nil && *primary.BudgetTokens != *secondary.BudgetTokens {
return Intent{}, fmt.Errorf("%w for model %q: explicit budgets %d and %d differ", ErrEffortConflict, model, *primary.BudgetTokens, *secondary.BudgetTokens)
}
merged := secondary
if primary.Mode != ModeUnset {
merged.Mode = primary.Mode
}
if primary.Effort != "" {
merged.Effort = primary.Effort
}
if primary.BudgetTokens != nil {
merged.BudgetTokens = primary.BudgetTokens
merged.BudgetSource = primary.BudgetSource
}
if primary.IncludeThoughts != nil {
merged.IncludeThoughts = primary.IncludeThoughts
}
return normalizeIntent(merged)
}
type openRouterReasoning struct {
Enabled *bool `json:"enabled,omitempty"`
Effort string `json:"effort,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
Exclude *bool `json:"exclude,omitempty"`
}
func FromOpenAIChat(req *dto.GeneralOpenAIRequest) (Intent, error) {
if req == nil {
return Intent{}, nil
}
var intent Intent
intent.Source = SourceExplicit
if req.ReasoningEffort != "" {
effort, err := ParseEffort(req.ReasoningEffort)
if err != nil {
return Intent{}, err
}
intent.Effort = effort
if effort == EffortNone {
intent.Mode = ModeDisabled
} else {
intent.Mode = ModeEnabled
}
}
if len(req.Reasoning) > 0 {
var raw openRouterReasoning
if err := kitutil.Unmarshal(req.Reasoning, &raw); err != nil {
return Intent{}, fmt.Errorf("invalid reasoning config: %w", err)
}
nested := Intent{BudgetTokens: raw.MaxTokens, Source: SourceExplicit, BudgetSource: SourceExplicit}
if raw.Enabled != nil {
if *raw.Enabled {
nested.Mode = ModeEnabled
} else {
nested.Mode = ModeDisabled
nested.Effort = EffortNone
}
}
if raw.Effort != "" {
effort, err := ParseEffort(raw.Effort)
if err != nil {
return Intent{}, err
}
nested.Effort = effort
if effort == EffortNone {
nested.Mode = ModeDisabled
} else if nested.Mode == ModeUnset {
nested.Mode = ModeEnabled
}
}
if raw.Exclude != nil {
include := !*raw.Exclude
nested.IncludeThoughts = &include
}
var err error
intent, err = MergeExplicit(intent, nested, req.Model)
if err != nil {
return Intent{}, err
}
}
if req.ReasoningConversion == nil {
return normalizeIntent(intent)
}
pivot := Intent{
Mode: Mode(req.ReasoningConversion.Mode),
Effort: Effort(req.ReasoningConversion.Effort),
BudgetTokens: req.ReasoningConversion.BudgetTokens,
IncludeThoughts: req.ReasoningConversion.IncludeThoughts,
Source: SourcePivot,
BudgetSource: SourcePivot,
}
if req.ReasoningEffort != "" {
pivotEffort := EffectiveEffort(pivot)
if Effort(req.ReasoningEffort) == pivotEffort {
intent.Effort = ""
intent.Mode = ModeUnset
}
}
return MergeExplicit(intent, pivot, req.Model)
}
// ApplyToOpenAIChat writes the portable portion of an intent to the OpenAI
// pivot. reasoning_effort carries level-based strength; a JSON-excluded DTO
// state retains exact budgets and summary visibility across in-process steps.
func ApplyToOpenAIChat(req *dto.GeneralOpenAIRequest, intent Intent) error {
if req == nil {
return nil
}
intent, err := normalizeIntent(intent)
if err != nil {
return err
}
if effort := EffectiveEffort(intent); effort != "" {
req.ReasoningEffort = string(effort)
}
if intent.IsEmpty() {
return nil
}
req.ReasoningConversion = &dto.ReasoningConversionState{
Mode: string(intent.Mode),
Effort: string(intent.Effort),
BudgetTokens: intent.BudgetTokens,
IncludeThoughts: intent.IncludeThoughts,
}
return nil
}
// ApplyToOpenAIResponses writes the portable portion of an intent directly to
// a Responses request. The JSON-excluded state retains exact provider-native
// controls for any later in-process conversion.
func ApplyToOpenAIResponses(req *dto.OpenAIResponsesRequest, intent Intent) error {
if req == nil {
return nil
}
intent, err := normalizeIntent(intent)
if err != nil {
return err
}
if effort := EffectiveEffort(intent); effort != "" {
summary := "detailed"
if effort == EffortNone || (intent.IncludeThoughts != nil && !*intent.IncludeThoughts) {
summary = ""
}
req.Reasoning = &dto.Reasoning{
Effort: string(effort),
Summary: summary,
}
}
if intent.IsEmpty() {
return nil
}
state := &dto.ReasoningConversionState{
Mode: string(intent.Mode),
Effort: string(intent.Effort),
BudgetTokens: intent.BudgetTokens,
IncludeThoughts: intent.IncludeThoughts,
}
req.ReasoningConversion = state
return nil
}
func FromOpenAIResponses(req *dto.OpenAIResponsesRequest) (Intent, error) {
if req == nil {
return Intent{}, nil
}
var intent Intent
if req.Reasoning != nil {
intent.Source = SourceExplicit
if req.Reasoning.Effort != "" {
effort, err := ParseEffort(req.Reasoning.Effort)
if err != nil {
return Intent{}, err
}
intent.Effort = effort
intent.Mode = ModeEnabled
if effort == EffortNone {
intent.Mode = ModeDisabled
}
}
if req.Reasoning.Summary != "" {
include := true
intent.IncludeThoughts = &include
}
}
if req.ReasoningConversion == nil {
return normalizeIntent(intent)
}
pivot := Intent{
Mode: Mode(req.ReasoningConversion.Mode),
Effort: Effort(req.ReasoningConversion.Effort),
BudgetTokens: req.ReasoningConversion.BudgetTokens,
IncludeThoughts: req.ReasoningConversion.IncludeThoughts,
Source: SourcePivot,
BudgetSource: SourcePivot,
}
if req.Reasoning != nil && req.Reasoning.Effort != "" {
pivotEffort := EffectiveEffort(pivot)
if Effort(req.Reasoning.Effort) == pivotEffort {
intent.Effort = ""
intent.Mode = ModeUnset
}
}
return MergeExplicit(intent, pivot, req.Model)
}
func FromClaude(req *dto.ClaudeRequest) (Intent, error) {
if req == nil {
return Intent{}, nil
}
var intent Intent
intent.Source = SourceNative
if req.Thinking != nil {
switch req.Thinking.Type {
case "", "enabled":
intent.Mode = ModeEnabled
case "adaptive":
intent.Mode = ModeAdaptive
case "disabled":
intent.Mode = ModeDisabled
intent.Effort = EffortNone
default:
return Intent{}, fmt.Errorf("unsupported Claude thinking type %q", req.Thinking.Type)
}
intent.BudgetTokens = req.Thinking.BudgetTokens
if req.Thinking.BudgetTokens != nil {
budget := *req.Thinking.BudgetTokens
if budget < 1024 {
return Intent{}, fmt.Errorf("Claude thinking budget_tokens must be at least 1024, got %d", budget)
}
if req.MaxTokens != nil && uint(budget) >= *req.MaxTokens {
return Intent{}, fmt.Errorf("Claude thinking budget_tokens must be less than max_tokens")
}
intent.BudgetSource = SourceNative
}
switch req.Thinking.Display {
case "summarized":
include := true
intent.IncludeThoughts = &include
case "omitted":
include := false
intent.IncludeThoughts = &include
}
}
if len(req.OutputConfig) > 0 {
var output dto.OutputConfigForEffort
if err := kitutil.Unmarshal(req.OutputConfig, &output); err != nil {
return Intent{}, fmt.Errorf("invalid Claude output_config: %w", err)
}
if output.Effort != "" {
effort, err := ParseEffort(output.Effort)
if err != nil {
return Intent{}, err
}
intent.Effort = effort
}
}
if intent.Mode == ModeDisabled && intent.Effort != "" && intent.Effort != EffortNone {
return intent, nil
}
return normalizeIntent(intent)
}
func FromGemini(req *dto.GeminiChatRequest) (Intent, error) {
if req == nil || req.GenerationConfig.ThinkingConfig == nil {
return Intent{}, nil
}
config := req.GenerationConfig.ThinkingConfig
if config.ThinkingBudget != nil && config.ThinkingLevel != "" {
return Intent{}, fmt.Errorf("%w: Gemini thinkingBudget and thinkingLevel cannot both be set", ErrEffortConflict)
}
intent := Intent{
BudgetTokens: config.ThinkingBudget,
IncludeThoughts: config.IncludeThoughts,
Source: SourceNative,
BudgetSource: SourceNative,
}
if config.ThinkingLevel != "" {
effort, err := ParseEffort(config.ThinkingLevel)
if err != nil {
return Intent{}, err
}
intent.Effort = effort
intent.Mode = ModeEnabled
}
return normalizeIntent(intent)
}
func EffectiveEffort(intent Intent) Effort {
intent, err := normalizeIntent(intent)
if err != nil {
return ""
}
if intent.Mode == ModeDisabled {
return EffortNone
}
if intent.Effort != "" {
return intent.Effort
}
if intent.BudgetTokens != nil {
return EffortFromBudget(*intent.BudgetTokens)
}
if intent.Mode == ModeEnabled || intent.Mode == ModeAdaptive {
return EffortHigh
}
return ""
}
func EffortFromBudget(budget int) Effort {
if budget == 0 {
return EffortNone
}
if budget < 0 {
return EffortHigh
}
if budget <= 1024 {
return EffortLow
}
if budget <= 8192 {
return EffortMedium
}
return EffortHigh
}
@@ -0,0 +1,172 @@
package reasoning
import (
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMergeExplicitAndSuffix(t *testing.T) {
t.Parallel()
budget1024 := 1024
budget2048 := 2048
tests := []struct {
name string
explicit Intent
suffix Intent
wantErr bool
wantMode Mode
wantEffort Effort
wantBudget *int
wantThoughts *bool
}{
{
name: "enabled plus matching effort merges",
explicit: Intent{Mode: ModeEnabled, Effort: EffortHigh},
suffix: Intent{Mode: ModeEnabled, Effort: EffortHigh, Source: SourceSuffix},
wantMode: ModeEnabled,
wantEffort: EffortHigh,
},
{
name: "enabled vs disabled conflict",
explicit: Intent{Mode: ModeEnabled, Effort: EffortHigh},
suffix: Intent{Mode: ModeDisabled, Effort: EffortNone, Source: SourceSuffix},
wantErr: true,
},
{
name: "different efforts conflict",
explicit: Intent{Mode: ModeEnabled, Effort: EffortLow},
suffix: Intent{Mode: ModeEnabled, Effort: EffortHigh, Source: SourceSuffix},
wantErr: true,
},
{
name: "different budgets conflict",
explicit: Intent{BudgetTokens: &budget1024},
suffix: Intent{BudgetTokens: &budget2048, Source: SourceSuffix, BudgetSource: SourceSuffix},
wantErr: true,
},
{
name: "effort versus exact suffix budget conflict",
explicit: Intent{Mode: ModeEnabled, Effort: EffortHigh},
suffix: Intent{BudgetTokens: &budget1024, Source: SourceSuffix, BudgetSource: SourceSuffix},
wantErr: true,
},
{
name: "suffix only is adopted",
suffix: Intent{Mode: ModeEnabled, Effort: EffortMedium, Source: SourceSuffix},
wantMode: ModeEnabled,
wantEffort: EffortMedium,
wantThoughts: nil,
},
{
name: "explicit include thoughts overlays empty suffix strength",
explicit: Intent{IncludeThoughts: boolPtr(false)},
suffix: Intent{Mode: ModeEnabled, Effort: EffortLow, Source: SourceSuffix},
wantMode: ModeEnabled,
wantEffort: EffortLow,
wantThoughts: boolPtr(false),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := MergeExplicitAndSuffix(tt.explicit, tt.suffix, "claude-opus-4-8")
if tt.wantErr {
require.Error(t, err)
assert.ErrorIs(t, err, ErrEffortConflict)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantMode, got.Mode)
assert.Equal(t, tt.wantEffort, got.Effort)
if tt.wantBudget != nil {
require.NotNil(t, got.BudgetTokens)
assert.Equal(t, *tt.wantBudget, *got.BudgetTokens)
}
if tt.wantThoughts != nil {
require.NotNil(t, got.IncludeThoughts)
assert.Equal(t, *tt.wantThoughts, *got.IncludeThoughts)
}
})
}
}
func TestIntentStateRoundTrip(t *testing.T) {
t.Parallel()
budget := 4096
include := true
intent := Intent{
Mode: ModeEnabled,
Effort: EffortHigh,
BudgetTokens: &budget,
IncludeThoughts: &include,
}
state := StateFromIntent(intent)
require.NotNil(t, state)
got := IntentFromState(state)
assert.Equal(t, intent.Mode, got.Mode)
assert.Equal(t, intent.Effort, got.Effort)
require.NotNil(t, got.BudgetTokens)
assert.Equal(t, budget, *got.BudgetTokens)
require.NotNil(t, got.IncludeThoughts)
assert.True(t, *got.IncludeThoughts)
assert.True(t, IntentFromState(nil).IsEmpty())
assert.Nil(t, StateFromIntent(Intent{}))
}
func boolPtr(v bool) *bool {
return &v
}
func TestOpenAIPivotRetainsExactStrengthAndBudget(t *testing.T) {
budget, include := 16384, false
for _, effort := range []Effort{EffortMax, EffortXHigh} {
t.Run(string(effort), func(t *testing.T) {
intent := Intent{Mode: ModeEnabled, Effort: effort, BudgetTokens: &budget, IncludeThoughts: &include}
chat := &dto.GeneralOpenAIRequest{}
require.NoError(t, ApplyToOpenAIChat(chat, intent))
assert.Equal(t, string(effort), chat.ReasoningEffort)
restored, err := FromOpenAIChat(chat)
require.NoError(t, err)
assert.Equal(t, effort, restored.Effort)
require.NotNil(t, restored.BudgetTokens)
assert.Equal(t, budget, *restored.BudgetTokens)
require.NotNil(t, restored.IncludeThoughts)
assert.False(t, *restored.IncludeThoughts)
responses := &dto.OpenAIResponsesRequest{}
require.NoError(t, ApplyToOpenAIResponses(responses, restored))
require.NotNil(t, responses.Reasoning)
assert.Equal(t, string(effort), responses.Reasoning.Effort)
restored, err = FromOpenAIResponses(responses)
require.NoError(t, err)
assert.Equal(t, effort, restored.Effort)
require.NotNil(t, restored.BudgetTokens)
assert.Equal(t, budget, *restored.BudgetTokens)
require.NotNil(t, restored.IncludeThoughts)
assert.False(t, *restored.IncludeThoughts)
})
}
}
func TestOpenAIPivotDoesNotTreatMaxAndXHighAsEquivalent(t *testing.T) {
intent := Intent{Mode: ModeEnabled, Effort: EffortMax}
chat := &dto.GeneralOpenAIRequest{}
require.NoError(t, ApplyToOpenAIChat(chat, intent))
chat.ReasoningEffort = "xhigh"
_, err := FromOpenAIChat(chat)
require.ErrorIs(t, err, ErrEffortConflict)
responses := &dto.OpenAIResponsesRequest{}
require.NoError(t, ApplyToOpenAIResponses(responses, intent))
responses.Reasoning.Effort = "xhigh"
_, err = FromOpenAIResponses(responses)
require.ErrorIs(t, err, ErrEffortConflict)
}
+282
View File
@@ -0,0 +1,282 @@
package reasoning
import (
"fmt"
"regexp"
"strconv"
"strings"
"github.com/samber/lo"
)
var EffortSuffixes = []string{"-max", "-xhigh", "-high", "-medium", "-low", "-minimal"}
var OpenAIEffortSuffixes = []string{"-max", "-xhigh", "-high", "-medium", "-low", "-minimal", "-none"}
var DeepSeekV4EffortSuffixes = []string{"-none", "-max"}
var (
legacyOpenAIModelPattern = regexp.MustCompile(`^(gpt-[a-z0-9][a-z0-9._-]*|o[1-9][a-z0-9._-]*)$`)
legacyClaudeModelPattern = regexp.MustCompile(`^claude-[a-z0-9][a-z0-9._-]*$`)
legacyGeminiModelPattern = regexp.MustCompile(`^gemini-[a-z0-9][a-z0-9._-]*$`)
)
type ModelModifier struct {
Key string
Value string
}
type ModelModifierSpec struct {
Raw string
Base string
Modifiers []ModelModifier
}
func (s ModelModifierSpec) HasModifiers() bool {
return len(s.Modifiers) > 0
}
// ParseModelModifiers removes only a contiguous trailing chain of @key:value
// segments. Other @ characters remain part of the opaque model name.
func ParseModelModifiers(modelName string) ModelModifierSpec {
spec := ModelModifierSpec{Raw: modelName, Base: modelName}
parts := strings.Split(modelName, "@")
if len(parts) < 2 {
return spec
}
firstModifier := len(parts)
for i := len(parts) - 1; i > 0; i-- {
key, value, ok := parseModelModifierSegment(parts[i])
if !ok {
break
}
firstModifier = i
spec.Modifiers = append([]ModelModifier{{Key: key, Value: value}}, spec.Modifiers...)
}
if firstModifier == len(parts) {
return spec
}
base := strings.Join(parts[:firstModifier], "@")
if base == "" {
return ModelModifierSpec{Raw: modelName, Base: modelName}
}
spec.Base = base
return spec
}
func parseModelModifierSegment(segment string) (string, string, bool) {
colon := strings.IndexByte(segment, ':')
if colon <= 0 {
return "", "", false
}
key := segment[:colon]
for i, r := range key {
letter := r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z'
if i == 0 && !letter || i > 0 && !letter && (r < '0' || r > '9') && r != '_' && r != '-' {
return "", "", false
}
}
return strings.ToLower(key), segment[colon+1:], true
}
// ParseThinkingModifier maps an explicit @thinking value onto a portable
// Intent. on/adaptive/off and integer budgets (including -1) are accepted;
// values below -1 are rejected.
func ParseThinkingModifier(raw string) (Intent, bool) {
value := strings.ToLower(strings.TrimSpace(raw))
switch value {
case "on":
return Intent{Mode: ModeEnabled, Source: SourceSuffix}, true
case "adaptive":
return Intent{Mode: ModeAdaptive, Source: SourceSuffix}, true
case "off":
return Intent{Mode: ModeDisabled, Effort: EffortNone, Source: SourceSuffix}, true
}
if budget, err := strconv.Atoi(value); err == nil {
if budget < -1 {
return Intent{}, false
}
if budget == 0 {
return Intent{Mode: ModeDisabled, Effort: EffortNone, Source: SourceSuffix}, true
}
return Intent{
Mode: ModeEnabled,
BudgetTokens: &budget,
Source: SourceSuffix,
BudgetSource: SourceSuffix,
}, true
}
return Intent{}, false
}
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
}
// ParseOpenAIReasoningEffortFromModelSuffix extracts an effort tail only from
// GPT and o-series model families. preserveEffortTail is consulted on the
// complete name first so real model IDs that already end in an effort word
// (for example gpt-5.1-codex-max) stay intact.
func ParseOpenAIReasoningEffortFromModelSuffix(modelName string, preserveEffortTail func(string) bool) (string, string) {
if preserveEffortTail != nil && preserveEffortTail(modelName) {
return "", modelName
}
baseModel, effort, ok := TrimEffortSuffixWithSuffixes(modelName, OpenAIEffortSuffixes)
if !ok || !legacyOpenAIModelPattern.MatchString(lastModelPathSegment(baseModel)) {
return "", modelName
}
return effort, baseModel
}
func ParseClaudeModelSuffix(modelName string, allowThinkingAlias bool) (string, Intent, bool, error) {
prefix, bare := splitModelNamespace(modelName)
if !strings.HasPrefix(bare, "claude-") {
return modelName, Intent{}, false, nil
}
base, intent, found, err := parseProviderModelSuffix(bare, "claude-", allowThinkingAlias, true)
if err != nil || !found || !legacyClaudeModelPattern.MatchString(base) {
return modelName, Intent{}, false, err
}
return prefix + base, intent, true, nil
}
func isKnownClaudeModel(modelName string) bool {
baseModel, _, _ := TrimEffortSuffixWithSuffixes(modelName, []string{"-max", "-xhigh", "-high", "-medium", "-low", "-minimal", "-none"})
if marker := strings.LastIndex(baseModel, "-thinking-"); marker >= 0 {
baseModel = baseModel[:marker]
} else {
baseModel = strings.TrimSuffix(strings.TrimSuffix(baseModel, "-thinking"), "-nothinking")
}
knownPrefixes := []string{
"claude-fable-5", "claude-mythos-5", "claude-mythos-preview",
"claude-opus-5", "claude-sonnet-5", "claude-opus-4-8",
"claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6",
"claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5",
"claude-opus-4-1", "claude-opus-4-", "claude-sonnet-4-",
"claude-3-7-sonnet",
}
for _, prefix := range knownPrefixes {
if strings.HasPrefix(baseModel, prefix) {
return true
}
}
return false
}
func ParseGeminiModelSuffix(modelName string, allowThinkingAlias bool) (string, Intent, bool, error) {
prefix, bare := splitModelNamespace(modelName)
if !strings.HasPrefix(bare, "gemini-") {
return modelName, Intent{}, false, nil
}
base, intent, found, err := parseProviderModelSuffix(bare, "gemini-", allowThinkingAlias, true)
if err != nil || !found || !legacyGeminiModelPattern.MatchString(base) {
return modelName, Intent{}, false, err
}
return prefix + base, intent, true, nil
}
// ParseKnownProviderModelSuffix extracts a canonical intent only when the
// origin identifies a provider family whose suffix vocabulary is defined by
// relaykit. Unknown OpenAI-compatible model names are deliberately untouched.
func ParseKnownProviderModelSuffix(modelName string, allowThinkingAlias bool) (string, Intent, bool, error) {
bare := lastModelPathSegment(modelName)
if strings.HasPrefix(bare, "claude-") {
return ParseClaudeModelSuffix(modelName, allowThinkingAlias)
}
if strings.HasPrefix(bare, "gemini-") {
return ParseGeminiModelSuffix(modelName, allowThinkingAlias)
}
return modelName, Intent{}, false, nil
}
func splitModelNamespace(modelName string) (string, string) {
if slash := strings.LastIndex(modelName, "/"); slash >= 0 {
return modelName[:slash+1], modelName[slash+1:]
}
return "", modelName
}
func lastModelPathSegment(modelName string) string {
_, bare := splitModelNamespace(modelName)
return bare
}
func TrimGeminiThinkingSuffix(modelName string) (string, bool) {
baseModel, _, ok, err := ParseGeminiModelSuffix(modelName, true)
return baseModel, ok && err == nil
}
func parseProviderModelSuffix(modelName string, requiredPrefix string, allowThinkingAlias bool, includeThoughts bool) (string, Intent, bool, error) {
if allowThinkingAlias {
if marker := strings.LastIndex(modelName, "-thinking-"); marker >= 0 {
baseModel := modelName[:marker]
if !strings.HasPrefix(baseModel, requiredPrefix) {
return modelName, Intent{}, false, nil
}
budget, err := strconv.Atoi(modelName[marker+len("-thinking-"):])
if err != nil {
return modelName, Intent{}, false, fmt.Errorf("invalid thinking budget suffix on model %q: %w", modelName, err)
}
intent := Intent{BudgetTokens: &budget, Source: SourceSuffix, BudgetSource: SourceSuffix}
if includeThoughts {
value := true
intent.IncludeThoughts = &value
}
return baseModel, intent, true, nil
}
if before, ok := strings.CutSuffix(modelName, "-nothinking"); ok {
baseModel := before
return baseModel, Intent{Mode: ModeDisabled, Effort: EffortNone, Source: SourceSuffix}, true, nil
}
if before, ok := strings.CutSuffix(modelName, "-thinking"); ok {
baseModel := before
intent := Intent{Mode: ModeEnabled, Source: SourceSuffix}
if includeThoughts {
value := true
intent.IncludeThoughts = &value
}
return baseModel, intent, true, nil
}
}
suffixes := []string{"-max", "-xhigh", "-high", "-medium", "-low", "-minimal", "-none"}
baseModel, rawEffort, ok := TrimEffortSuffixWithSuffixes(modelName, suffixes)
if !ok || !strings.HasPrefix(baseModel, requiredPrefix) {
return modelName, Intent{}, false, nil
}
effort, err := ParseEffort(rawEffort)
if err != nil {
return modelName, Intent{}, false, err
}
intent := Intent{Effort: effort, Mode: ModeEnabled, Source: SourceSuffix}
if effort == EffortNone {
intent.Mode = ModeDisabled
} else if includeThoughts {
value := true
intent.IncludeThoughts = &value
}
return baseModel, intent, true, nil
}
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,192 @@
package reasoning
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseGeminiModelSuffixNoThinkingDisablesReasoning(t *testing.T) {
t.Parallel()
base, intent, found, err := ParseGeminiModelSuffix("gemini-2.5-flash-nothinking", true)
require.NoError(t, err)
require.True(t, found)
assert.Equal(t, "gemini-2.5-flash", base)
assert.Equal(t, ModeDisabled, intent.Mode)
assert.Equal(t, EffortNone, intent.Effort)
assert.Equal(t, SourceSuffix, intent.Source)
}
func TestParseKnownProviderModelSuffix(t *testing.T) {
t.Parallel()
preserveQwenMax := func(name string) bool { return name == "qwen-max" || name == "vendor/qwen-max" }
tests := []struct {
name string
model string
allowThinkingAlias bool
wantBase string
wantFound bool
wantMode Mode
wantEffort Effort
wantBudget *int
wantErr bool
}{
{
name: "claude thinking alias",
model: "claude-3-7-sonnet-thinking",
allowThinkingAlias: true,
wantBase: "claude-3-7-sonnet",
wantFound: true,
wantMode: ModeEnabled,
},
{
name: "claude nothinking alias",
model: "claude-3-7-sonnet-nothinking",
allowThinkingAlias: true,
wantBase: "claude-3-7-sonnet",
wantFound: true,
wantMode: ModeDisabled,
wantEffort: EffortNone,
},
{
name: "claude thinking budget",
model: "claude-3-7-sonnet-thinking-8192",
allowThinkingAlias: true,
wantBase: "claude-3-7-sonnet",
wantFound: true,
wantBudget: intPtr(8192),
},
{
name: "claude effort tail",
model: "claude-opus-4-8-high",
allowThinkingAlias: true,
wantBase: "claude-opus-4-8",
wantFound: true,
wantMode: ModeEnabled,
wantEffort: EffortHigh,
},
{
name: "gemini thinking alias",
model: "gemini-2.5-flash-thinking",
allowThinkingAlias: true,
wantBase: "gemini-2.5-flash",
wantFound: true,
wantMode: ModeEnabled,
},
{
name: "malformed thinking budget",
model: "claude-3-7-sonnet-thinking-abc",
allowThinkingAlias: true,
wantErr: true,
},
{
name: "unknown openai-compatible name is untouched",
model: "gpt-4o-mini",
wantBase: "gpt-4o-mini",
wantFound: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
base, intent, found, err := ParseKnownProviderModelSuffix(tt.model, tt.allowThinkingAlias)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantFound, found)
assert.Equal(t, tt.wantBase, base)
assert.Equal(t, tt.wantMode, intent.Mode)
assert.Equal(t, tt.wantEffort, intent.Effort)
if tt.wantBudget != nil {
require.NotNil(t, intent.BudgetTokens)
assert.Equal(t, *tt.wantBudget, *intent.BudgetTokens)
} else {
assert.Nil(t, intent.BudgetTokens)
}
})
}
t.Run("openai effort tail", func(t *testing.T) {
t.Parallel()
effort, base := ParseOpenAIReasoningEffortFromModelSuffix("gpt-5.6-sol-high", nil)
assert.Equal(t, "high", effort)
assert.Equal(t, "gpt-5.6-sol", base)
})
t.Run("preserve effort tail on real model id", func(t *testing.T) {
t.Parallel()
effort, base := ParseOpenAIReasoningEffortFromModelSuffix("qwen-max", preserveQwenMax)
assert.Empty(t, effort)
assert.Equal(t, "qwen-max", base)
})
t.Run("preserve effort tail with vendor prefix", func(t *testing.T) {
t.Parallel()
effort, base := ParseOpenAIReasoningEffortFromModelSuffix("vendor/qwen-max", preserveQwenMax)
assert.Empty(t, effort)
assert.Equal(t, "vendor/qwen-max", base)
})
t.Run("preserve gpt-5.1-codex-max with callback", func(t *testing.T) {
t.Parallel()
preserve := func(name string) bool { return name == "gpt-5.1-codex-max" }
effort, base := ParseOpenAIReasoningEffortFromModelSuffix("gpt-5.1-codex-max", preserve)
assert.Empty(t, effort)
assert.Equal(t, "gpt-5.1-codex-max", base)
})
t.Run("splits gpt-5.1-codex-max without callback", func(t *testing.T) {
t.Parallel()
effort, base := ParseOpenAIReasoningEffortFromModelSuffix("gpt-5.1-codex-max", nil)
assert.Equal(t, "max", effort)
assert.Equal(t, "gpt-5.1-codex", base)
})
}
func TestParseThinkingModifier(t *testing.T) {
t.Parallel()
on, ok := ParseThinkingModifier("on")
require.True(t, ok)
assert.Equal(t, ModeEnabled, on.Mode)
adaptive, ok := ParseThinkingModifier("Adaptive")
require.True(t, ok)
assert.Equal(t, ModeAdaptive, adaptive.Mode)
off, ok := ParseThinkingModifier("off")
require.True(t, ok)
assert.Equal(t, ModeDisabled, off.Mode)
assert.Equal(t, EffortNone, off.Effort)
zero, ok := ParseThinkingModifier("0")
require.True(t, ok)
assert.Equal(t, ModeDisabled, zero.Mode)
budget, ok := ParseThinkingModifier("8192")
require.True(t, ok)
require.NotNil(t, budget.BudgetTokens)
assert.Equal(t, 8192, *budget.BudgetTokens)
assert.Equal(t, ModeEnabled, budget.Mode)
dynamic, ok := ParseThinkingModifier("-1")
require.True(t, ok)
require.NotNil(t, dynamic.BudgetTokens)
assert.Equal(t, -1, *dynamic.BudgetTokens)
_, ok = ParseThinkingModifier("-2")
assert.False(t, ok)
_, ok = ParseThinkingModifier("enabled")
assert.False(t, ok)
}
func intPtr(v int) *int {
return &v
}
+73
View File
@@ -0,0 +1,73 @@
package relayconvert
import (
"context"
"fmt"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
sharedgemini "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/gemini"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/relaykit/types"
)
func ClaudeMessagesRequestToOpenAIChat(claudeRequest dto.ClaudeRequest, info convmeta.Meta) (*dto.GeneralOpenAIRequest, error) {
return convertCompatRequest[dto.GeneralOpenAIRequest](context.Background(), info, types.RelayFormatOpenAI, &claudeRequest)
}
func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, textRequest dto.GeneralOpenAIRequest) (*dto.ClaudeRequest, error) {
return convertCompatRequest[dto.ClaudeRequest](c, info, types.RelayFormatClaude, &textRequest)
}
func GeminiGenerateContentRequestToOpenAIChat(geminiRequest *dto.GeminiChatRequest, info convmeta.Meta) (*dto.GeneralOpenAIRequest, error) {
return convertCompatRequest[dto.GeneralOpenAIRequest](context.Background(), info, types.RelayFormatOpenAI, geminiRequest)
}
func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto.GeneralOpenAIRequest, info convmeta.Meta) (*dto.GeminiChatRequest, error) {
return convertCompatRequest[dto.GeminiChatRequest](c, info, types.RelayFormatGemini, &textRequest)
}
func ApplyGeminiThinkingConfigChecked(geminiRequest *dto.GeminiChatRequest, info convmeta.Meta, oaiRequest ...dto.GeneralOpenAIRequest) error {
return reasoning.AsClientError(sharedgemini.ApplyThinkingConfig(geminiRequest, info, oaiRequest...))
}
func ApplyClaudeThinkingModel(claudeRequest *dto.ClaudeRequest, info convmeta.Meta) error {
ctx, collector := convdiag.WithCollector(context.Background())
err := reasoning.AsClientError(sharedclaude.ApplyReasoning(ctx, claudeRequest, info, reasoning.Intent{}, false))
if recorder, ok := info.(interface {
RecordConversionDiagnostics(context.Context, []types.ConversionDiagnostic)
}); ok {
recorder.RecordConversionDiagnostics(ctx, collector.Diagnostics())
}
return err
}
func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*dto.OpenAIResponsesRequest, error) {
return convertCompatRequest[dto.OpenAIResponsesRequest](context.Background(), nil, types.RelayFormatOpenAIResponses, req)
}
func ResponsesRequestToChatCompletionsRequest(req *dto.OpenAIResponsesRequest) (*dto.GeneralOpenAIRequest, error) {
return convertCompatRequest[dto.GeneralOpenAIRequest](context.Background(), nil, types.RelayFormatOpenAI, req)
}
func OpenAIResponsesRequestToClaudeMessages(c context.Context, info convmeta.Meta, req *dto.OpenAIResponsesRequest) (*dto.ClaudeRequest, error) {
return convertCompatRequest[dto.ClaudeRequest](c, info, types.RelayFormatClaude, req)
}
func OpenAIResponsesRequestToGeminiChat(c context.Context, req *dto.OpenAIResponsesRequest, info convmeta.Meta) (*dto.GeminiChatRequest, error) {
return convertCompatRequest[dto.GeminiChatRequest](c, info, types.RelayFormatGemini, req)
}
func convertCompatRequest[T any](c context.Context, info convmeta.Meta, target types.RelayFormat, request any) (*T, error) {
result, err := ConvertRequest(c, info, target, request)
if err != nil {
return nil, err
}
converted, ok := result.Value.(*T)
if !ok {
return nil, fmt.Errorf("expected %s request, got %T", target, result.Value)
}
return converted, nil
}
+543
View File
@@ -0,0 +1,543 @@
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"
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
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/relayconvert/internal/toolconv"
"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
Diagnostics []types.ConversionDiagnostic
}
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 = ConverterOpenAIResponsesToClaudeMessages
)
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"
ConverterOpenAIResponsesToClaudeMessages = "openai_responses_to_claude_messages"
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) {
c, diagnosticCollector := convdiag.WithCollector(c)
current, tools, err := toolconv.ExtractRequest(from, request)
if err != nil {
return nil, err
}
steps := make([]RequestStep, 0, len(specs))
for _, spec := range specs {
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)
}
current, toolDiagnostics, err := toolconv.AttachRequest(target, current, tools, convmeta.OptionsOf(info))
diagnostics := append(diagnosticCollector.Diagnostics(), toolDiagnostics...)
for i := range diagnostics {
if diagnostics[i].From == "" {
diagnostics[i].From = from
}
if diagnostics[i].To == "" {
diagnostics[i].To = target
}
}
if err != nil {
return &RequestResult{
Value: current,
From: from,
To: target,
Quality: quality,
Steps: steps,
Diagnostics: diagnostics,
}, err
}
if info != nil {
for _, step := range steps {
info.AppendRequestConversion(step.To)
}
}
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,
Diagnostics: diagnostics,
}, 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
}
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 convertClaudeRequestToOpenAIResponses(_ 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.ClaudeMessagesRequestToOpenAIResponses(*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,711 @@
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,
},
{
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 TestConvertRequestClaudeToResponsesUsesDirectPath(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: requestConverterClaudeToResponses,
From: types.RelayFormatClaude,
To: types.RelayFormatOpenAIResponses,
},
}, result.Steps)
assert.Equal(t, []types.RelayFormat{types.RelayFormatClaude, types.RelayFormatOpenAIResponses}, info.ConversionChain)
}
func TestConvertRequestClaudeToResponsesPreservesMixedBlockOrder(t *testing.T) {
info := &convmeta.Values{ConversionChain: []types.RelayFormat{types.RelayFormatClaude}}
stream := true
strict := true
maxTokens := uint(4096)
req := &dto.ClaudeRequest{
Model: "gpt-test",
System: []dto.ClaudeMediaMessage{{Type: "text", Text: kitutil.GetPointer("system ")}, {Type: "text", Text: kitutil.GetPointer("rules")}},
MaxTokens: &maxTokens,
Stream: &stream,
Tools: []dto.Tool{{
Name: "lookup",
Description: "Look up a value",
InputSchema: map[string]any{"type": "object", "properties": map[string]any{"q": map[string]any{"type": "string"}}},
Strict: &strict,
}},
ToolChoice: dto.ClaudeToolChoice{Type: "tool", Name: "lookup", DisableParallelToolUse: true},
Messages: []dto.ClaudeMessage{
{Role: "user", Content: []dto.ClaudeMediaMessage{{Type: "text", Text: kitutil.GetPointer("question")}}},
{Role: "assistant", Content: []dto.ClaudeMediaMessage{
{Type: "text", Text: kitutil.GetPointer("before")},
{Type: "tool_use", Id: "call_1", Name: "lookup", Input: map[string]any{"q": "x"}},
{Type: "text", Text: kitutil.GetPointer("after")},
}},
{Role: "user", Content: []dto.ClaudeMediaMessage{
{Type: "tool_result", ToolUseId: "call_1", Content: "result"},
{Type: "text", Text: kitutil.GetPointer("continue")},
}},
},
}
result, err := ConvertRequest(nil, info, types.RelayFormatOpenAIResponses, req)
require.NoError(t, err)
responsesReq := result.Value.(*dto.OpenAIResponsesRequest)
assert.Equal(t, "gpt-test", responsesReq.Model)
assert.Equal(t, maxTokens, *responsesReq.MaxOutputTokens)
assert.True(t, *responsesReq.Stream)
assert.JSONEq(t, `"system rules"`, string(responsesReq.Instructions))
assert.JSONEq(t, `[{"type":"function","name":"lookup","description":"Look up a value","parameters":{"type":"object","properties":{"q":{"type":"string"}}},"strict":true}]`, string(responsesReq.Tools))
assert.JSONEq(t, `{"type":"function","name":"lookup"}`, string(responsesReq.ToolChoice))
assert.JSONEq(t, `false`, string(responsesReq.ParallelToolCalls))
var input []map[string]any
require.NoError(t, kitutil.Unmarshal(responsesReq.Input, &input))
require.Len(t, input, 6)
assert.Equal(t, "user", input[0]["role"])
assert.Equal(t, "question", inputContentText(t, input[0]))
assert.Equal(t, "assistant", input[1]["role"])
assert.Equal(t, "before", inputContentText(t, input[1]))
assert.Equal(t, "function_call", input[2]["type"])
assert.Equal(t, "call_1", input[2]["call_id"])
assert.Equal(t, "lookup", input[2]["name"])
assert.JSONEq(t, `{"q":"x"}`, input[2]["arguments"].(string))
assert.Equal(t, "assistant", input[3]["role"])
assert.Equal(t, "after", inputContentText(t, input[3]))
assert.Equal(t, "function_call_output", input[4]["type"])
assert.Equal(t, "result", input[4]["output"])
assert.Equal(t, "user", input[5]["role"])
assert.Equal(t, "continue", inputContentText(t, input[5]))
}
func TestConvertRequestClaudeToResponsesDropsIncompatibleContextManagement(t *testing.T) {
req := &dto.ClaudeRequest{
Model: "gpt-test",
Messages: []dto.ClaudeMessage{
{Role: "user", Content: "hello"},
},
ContextManagement: mustRawMessage(t, map[string]any{
"edits": []map[string]any{{"type": "clear_tool_uses_20250919"}},
}),
}
result, err := ConvertRequest(nil, nil, types.RelayFormatOpenAIResponses, req)
require.NoError(t, err)
responsesReq, ok := result.Value.(*dto.OpenAIResponsesRequest)
require.True(t, ok)
assert.Empty(t, responsesReq.ContextManagement)
}
func TestConvertRequestClaudeAdaptiveThinkingPreservesEffort(t *testing.T) {
tests := []struct {
name string
outputConfig []byte
wantEffort string
}{
{name: "adaptive default", wantEffort: "high"},
{name: "explicit low", outputConfig: mustRawMessage(t, map[string]any{"effort": "low"}), wantEffort: "low"},
{name: "explicit xhigh", outputConfig: mustRawMessage(t, map[string]any{"effort": "xhigh"}), wantEffort: "xhigh"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
info := &convmeta.Values{
OriginModelName: "gpt-5.6-sol",
ConversionChain: []types.RelayFormat{types.RelayFormatClaude},
}
req := &dto.ClaudeRequest{
Model: "gpt-5.6-sol",
OutputConfig: tt.outputConfig,
Thinking: &dto.Thinking{Type: "adaptive", Display: "summarized"},
Messages: []dto.ClaudeMessage{
{Role: "user", Content: "hello"},
},
}
result, err := ConvertRequest(nil, info, types.RelayFormatOpenAIResponses, req)
require.NoError(t, err)
responsesReq, ok := result.Value.(*dto.OpenAIResponsesRequest)
require.True(t, ok)
require.NotNil(t, responsesReq.Reasoning)
assert.Equal(t, tt.wantEffort, responsesReq.Reasoning.Effort)
assert.Equal(t, "detailed", responsesReq.Reasoning.Summary)
assert.Equal(t, tt.wantEffort, info.GetReasoningEffort())
})
}
}
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 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 TestConvertRequestByIDExecutesDirectClaudeToResponsesConverter(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: requestConverterClaudeToResponses,
From: types.RelayFormatClaude,
To: types.RelayFormatOpenAIResponses,
},
}, result.Steps)
assert.Equal(t, []types.RelayFormat{types.RelayFormatClaude, 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
}
func inputContentText(t *testing.T, item map[string]any) string {
t.Helper()
content, ok := item["content"].([]any)
require.True(t, ok)
require.Len(t, content, 1)
part, ok := content[0].(map[string]any)
require.True(t, ok)
text, ok := part["text"].(string)
require.True(t, ok)
return text
}
+210
View File
@@ -0,0 +1,210 @@
package relayconvert
import (
"fmt"
"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 ClaudeToChatStreamState = claudemessages.ClaudeToChatStreamState
type ChatToResponsesStreamEvent = oaichat.ChatToResponsesStreamEvent
type ChatToResponsesStreamState = oaichat.ChatToResponsesStreamState
type ResponsesToChatStreamState = oairesponses.ResponsesToChatStreamState
type ResponsesBufferedAccumulator = oairesponses.ResponsesBufferedAccumulator
// ClaudeHostedStreamBridge owns Anthropic server-tool input deltas while a
// Claude stream is being converted to the Responses protocol.
type ClaudeHostedStreamBridge struct {
bridge *claudemessages.ClaudeHostedStreamBridge
}
// GeminiHostedStreamBridge accumulates Gemini grounding queries until the
// upstream stream ends, then emits one canonical Responses web-search call.
type GeminiHostedStreamBridge struct {
bridge *geminichat.GeminiHostedStreamBridge
}
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 NewClaudeToChatStreamState() *ClaudeToChatStreamState {
return claudemessages.NewClaudeToChatStreamState()
}
func NewClaudeHostedStreamBridge() *ClaudeHostedStreamBridge {
return &ClaudeHostedStreamBridge{bridge: claudemessages.NewClaudeHostedStreamBridge()}
}
func NewGeminiHostedStreamBridge() *GeminiHostedStreamBridge {
return &GeminiHostedStreamBridge{bridge: geminichat.NewGeminiHostedStreamBridge()}
}
func (b *GeminiHostedStreamBridge) Observe(response *dto.GeminiChatResponse) {
if b == nil || b.bridge == nil {
return
}
b.bridge.Observe(response)
}
func (b *GeminiHostedStreamBridge) Finalize(state *ResponseStreamState) ([]ChatToResponsesStreamEvent, error) {
if b == nil || b.bridge == nil || state == nil {
return nil, nil
}
for _, stepState := range state.stepStates {
if streamState, ok := stepState.(*ChatToResponsesStreamState); ok {
return b.bridge.Finalize(streamState)
}
}
return nil, fmt.Errorf("Gemini hosted stream bridge requires a Chat-to-Responses stream state")
}
func (b *ClaudeHostedStreamBridge) Convert(response *dto.ClaudeResponse, state *ResponseStreamState) ([]ChatToResponsesStreamEvent, bool, error) {
if state == nil {
return nil, false, nil
}
if b == nil || b.bridge == nil {
return nil, false, nil
}
for _, stepState := range state.stepStates {
if streamState, ok := stepState.(*ChatToResponsesStreamState); ok {
return b.bridge.Convert(response, streamState)
}
}
return nil, false, nil
}
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 FinalizeClaudeStreamBillingUsage(claudeInfo *ClaudeResponseInfo) {
claudemessages.FinalizeClaudeStreamBillingUsage(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 NormalizeResponsesUsage(src *dto.Usage) *dto.Usage {
return oairesponses.NormalizeResponsesUsage(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,701 @@
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,
},
{
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 TestConvertResponseDirectAndMultiHopConverters(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: ConverterOpenAIResponsesToClaudeMessages, From: types.RelayFormatOpenAIResponses, 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]any{"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]any{"q": "x"}, geminiValue.Candidates[0].Content.Parts[1].FunctionCall.Arguments)
assert.Equal(t, 11, toGemini.Usage.TotalTokens)
}
func TestConvertResponsePreservesInterleavedResponsesBlocksForClaude(t *testing.T) {
responses := &dto.OpenAIResponsesResponse{
ID: "resp_1",
Model: "gpt-test",
Status: []byte(`"completed"`),
Output: []dto.ResponsesOutput{
{Type: "reasoning", Summary: []dto.ResponsesReasoningSummaryPart{{Type: "summary_text", Text: "**Planning file inspection**"}}},
{Type: "message", Role: "assistant", Content: []dto.ResponsesOutputContent{{Type: "output_text", Text: "I’ll inspect the starter repository."}}},
{Type: "reasoning", Summary: []dto.ResponsesReasoningSummaryPart{{Type: "summary_text", Text: "**Clarifying environment task requirements**"}}},
{Type: "message", Role: "assistant", Content: []dto.ResponsesOutputContent{{Type: "output_text", Text: "What would you like me to build?"}}},
},
}
result, err := ConvertResponse(nil, nil, types.RelayFormatClaude, responses)
require.NoError(t, err)
assert.Equal(t, []ResponseStep{
{Converter: ConverterOpenAIResponsesToClaudeMessages, From: types.RelayFormatOpenAIResponses, To: types.RelayFormatClaude},
}, result.Steps)
claudeResponse := result.Value.(*dto.ClaudeResponse)
require.Len(t, claudeResponse.Content, 4)
assert.Equal(t, []string{"thinking", "text", "thinking", "text"}, []string{
claudeResponse.Content[0].Type,
claudeResponse.Content[1].Type,
claudeResponse.Content[2].Type,
claudeResponse.Content[3].Type,
})
require.NotNil(t, claudeResponse.Content[0].Thinking)
require.NotNil(t, claudeResponse.Content[2].Thinking)
assert.Equal(t, "**Planning file inspection**", *claudeResponse.Content[0].Thinking)
assert.Equal(t, "I’ll inspect the starter repository.", claudeResponse.Content[1].GetText())
assert.Equal(t, "**Clarifying environment task requirements**", *claudeResponse.Content[2].Thinking)
assert.Equal(t, "What would you like me to build?", claudeResponse.Content[3].GetText())
}
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]any{"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]any{"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 TestConvertStreamResponseStatefulDirectResponsesToClaude(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: ConverterOpenAIResponsesToClaudeMessages, From: types.RelayFormatOpenAIResponses, 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,445 @@
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 TestGeminiToOpenAIStatefulStreamTerminal(t *testing.T) {
tests := []struct {
name string
chunk *dto.GeminiChatResponse
wantFinishReason string
wantFinishOnFinalize bool
wantEmptyFinishDelta bool
}{
{
name: "stop",
chunk: terminalTestGeminiChunk("Hello", "STOP", false),
wantFinishReason: types.FinishReasonStop,
wantEmptyFinishDelta: true,
},
{
name: "tool call",
chunk: terminalTestGeminiChunk("", "STOP", true),
wantFinishReason: types.FinishReasonToolCalls,
wantEmptyFinishDelta: true,
},
{
name: "non stop finish reason",
chunk: terminalTestGeminiChunk("partial", "MAX_TOKENS", false),
wantFinishReason: types.FinishReasonLength,
},
{
name: "truncated stream",
chunk: terminalTestGeminiChunk("partial", "", false),
wantFinishReason: types.FinishReasonStop,
wantFinishOnFinalize: true,
wantEmptyFinishDelta: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
info := &convmeta.Values{
ChannelMetaAttached: true,
UpstreamModelName: "upstream-model",
}
state, err := NewResponseStreamState(
types.RelayFormatGemini,
types.RelayFormatOpenAI,
ResponseStreamOptions{
ID: "chatcmpl-fixed",
Created: 1700000000,
},
)
require.NoError(t, err)
results, err := ConvertStreamResponseChunk(nil, info, state, tt.chunk)
require.NoError(t, err)
chunkFinishes := terminalTestFinishedChatChunks(t, results)
if tt.wantFinishOnFinalize {
assert.Empty(t, chunkFinishes)
} else {
require.Len(t, chunkFinishes, 1)
}
finalResults, err := FinalizeStreamResponse(nil, info, state)
require.NoError(t, err)
finalFinishes := terminalTestFinishedChatChunks(t, finalResults)
if tt.wantFinishOnFinalize {
require.Len(t, finalFinishes, 1)
} else {
assert.Empty(t, finalFinishes)
}
finishes := append(chunkFinishes, finalFinishes...)
require.Len(t, finishes, 1)
finish := finishes[0]
require.Len(t, finish.Choices, 1)
require.NotNil(t, finish.Choices[0].FinishReason)
assert.Equal(t, tt.wantFinishReason, *finish.Choices[0].FinishReason)
assert.Equal(t, "chatcmpl-fixed", finish.Id)
assert.Equal(t, int64(1700000000), finish.Created)
assert.Equal(t, "upstream-model", finish.Model)
require.NotNil(t, finish.Usage)
assert.Equal(t, 4, finish.Usage.PromptTokens)
assert.Equal(t, 2, finish.Usage.CompletionTokens)
assert.Equal(t, 6, finish.Usage.TotalTokens)
if tt.wantEmptyFinishDelta {
assert.Nil(t, finish.Choices[0].Delta.Content)
assert.Empty(t, finish.Choices[0].Delta.ToolCalls)
}
repeatedFinal, err := FinalizeStreamResponse(nil, info, state)
require.NoError(t, err)
assert.Empty(t, repeatedFinal)
})
}
}
func TestClaudeTargetStatefulStreamTerminalTail(t *testing.T) {
tests := []struct {
name string
from types.RelayFormat
chunks []any
wantFinalizerTerminals bool
wantStopReason string
}{
{
name: "gemini to claude",
from: types.RelayFormatGemini,
chunks: []any{
terminalTestGeminiChunkWithoutUsage("Hello", ""),
terminalTestGeminiChunk(" world", "STOP", false),
},
wantStopReason: "end_turn",
},
{
name: "gemini tool call with split usage",
from: types.RelayFormatGemini,
chunks: []any{
terminalTestGeminiToolChunkWithoutUsage(),
terminalTestGeminiChunk("", "STOP", false),
},
wantStopReason: "tool_use",
},
{
name: "gemini non stop finish with split usage",
from: types.RelayFormatGemini,
chunks: []any{
terminalTestGeminiChunkWithoutUsage("partial", "MAX_TOKENS"),
terminalTestGeminiUsageOnlyChunk(),
},
wantStopReason: "max_tokens",
},
{
name: "responses to claude",
from: types.RelayFormatOpenAIResponses,
chunks: []any{
&dto.ResponsesStreamResponse{
Type: "response.output_text.delta",
Delta: "Hello",
},
&dto.ResponsesStreamResponse{
Type: "response.output_text.delta",
Delta: " world",
},
&dto.ResponsesStreamResponse{
Type: "response.completed",
Response: &dto.OpenAIResponsesResponse{
ID: "resp-fixed",
Object: "response",
Model: "upstream-model",
Status: []byte(`"completed"`),
Usage: &dto.Usage{
InputTokens: 4,
OutputTokens: 2,
TotalTokens: 6,
},
},
},
},
wantStopReason: "end_turn",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
info := &convmeta.Values{
ChannelMetaAttached: true,
UpstreamModelName: "upstream-model",
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{
LastMessagesType: convmeta.LastMessageTypeNone,
},
}
state, err := NewResponseStreamState(
tt.from,
types.RelayFormatClaude,
ResponseStreamOptions{
ID: "stream-fixed",
Model: "upstream-model",
Created: 1700000000,
},
)
require.NoError(t, err)
var results []ResponseResult
for _, chunk := range tt.chunks {
chunkResults, err := ConvertStreamResponseChunk(nil, info, state, chunk)
require.NoError(t, err)
results = append(results, chunkResults...)
}
finalResults, err := FinalizeStreamResponse(nil, info, state)
require.NoError(t, err)
if tt.wantFinalizerTerminals {
require.Len(t, finalResults, 3)
} else {
assert.Empty(t, finalResults)
}
results = append(results, finalResults...)
terminalTestAssertClaudeTail(t, results, tt.wantStopReason)
assert.True(t, info.ClaudeConvertInfo.Done)
repeatedFinal, err := FinalizeStreamResponse(nil, info, state)
require.NoError(t, err)
assert.Empty(t, repeatedFinal)
})
}
t.Run("preserves preseeded usage", func(t *testing.T) {
info := &convmeta.Values{
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{
LastMessagesType: convmeta.LastMessageTypeNone,
},
}
state, err := NewResponseStreamState(
types.RelayFormatOpenAIResponses,
types.RelayFormatClaude,
ResponseStreamOptions{Model: "upstream-model"},
)
require.NoError(t, err)
chunks := []*dto.ResponsesStreamResponse{{
Type: "response.output_text.delta",
Delta: "Hello",
}}
for _, chunk := range chunks {
_, err := ConvertStreamResponseChunk(nil, info, state, chunk)
require.NoError(t, err)
}
preseeded := &dto.Usage{
PromptTokens: 11,
CompletionTokens: 7,
TotalTokens: 18,
}
info.ClaudeConvertInfo.Usage = preseeded
finalResults, err := FinalizeStreamResponse(nil, info, state)
require.NoError(t, err)
require.Len(t, finalResults, 3)
assert.Same(t, preseeded, info.ClaudeConvertInfo.Usage)
messageDelta, ok := finalResults[1].Value.(*dto.ClaudeResponse)
require.True(t, ok)
assert.Equal(t, "message_delta", messageDelta.Type)
require.NotNil(t, messageDelta.Usage)
assert.Equal(t, 11, messageDelta.Usage.InputTokens)
assert.Equal(t, 7, messageDelta.Usage.OutputTokens)
})
}
func TestConvertStreamResponseKeepsStatelessCompatibility(t *testing.T) {
t.Run("gemini to openai", func(t *testing.T) {
info := &convmeta.Values{
ChannelMetaAttached: true,
UpstreamModelName: "upstream-model",
}
result, err := ConvertStreamResponse(
nil,
info,
types.RelayFormatOpenAI,
terminalTestGeminiChunk("Hello", "STOP", false),
)
require.NoError(t, err)
require.IsType(t, &dto.ChatCompletionsStreamResponse{}, result.Value)
response := result.Value.(*dto.ChatCompletionsStreamResponse)
require.Len(t, response.Choices, 1)
require.NotNil(t, response.Choices[0].Delta.Content)
assert.Equal(t, "Hello", *response.Choices[0].Delta.Content)
assert.Nil(t, response.Choices[0].FinishReason)
assert.Equal(t, "upstream-model", response.Model)
require.NotNil(t, response.Usage)
assert.Equal(t, 4, response.Usage.PromptTokens)
assert.Equal(t, 2, response.Usage.CompletionTokens)
assert.Equal(t, 6, response.Usage.TotalTokens)
})
t.Run("openai to claude", func(t *testing.T) {
info := &convmeta.Values{
SendResponseCount: 1,
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{
LastMessagesType: convmeta.LastMessageTypeNone,
},
}
result, err := ConvertStreamResponse(
nil,
info,
types.RelayFormatClaude,
&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl-fixed",
Object: "chat.completion.chunk",
Created: 1700000000,
Model: "upstream-model",
Choices: []dto.ChatCompletionsStreamResponseChoice{
{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
Role: "assistant",
Content: terminalTestPtr("Hello"),
},
},
},
Usage: &dto.Usage{
PromptTokens: 4,
CompletionTokens: 2,
TotalTokens: 6,
},
},
)
require.NoError(t, err)
require.IsType(t, []*dto.ClaudeResponse{}, result.Value)
responses := result.Value.([]*dto.ClaudeResponse)
require.Len(t, responses, 3)
assert.Equal(t, "message_start", responses[0].Type)
assert.Equal(t, "content_block_start", responses[1].Type)
assert.Equal(t, "content_block_delta", responses[2].Type)
require.NotNil(t, responses[2].Delta)
require.NotNil(t, responses[2].Delta.Text)
assert.Equal(t, "Hello", *responses[2].Delta.Text)
assert.False(t, info.ClaudeConvertInfo.Done)
assert.Equal(t, 6, result.Usage.TotalTokens)
})
}
func terminalTestGeminiChunk(text string, finishReason string, toolCall bool) *dto.GeminiChatResponse {
response := terminalTestGeminiChunkWithoutUsage(text, finishReason)
response.HasUsageMetadata = true
response.UsageMetadata = dto.GeminiUsageMetadata{
PromptTokenCount: 4,
CandidatesTokenCount: 2,
TotalTokenCount: 6,
}
if toolCall {
response.Candidates[0].Content.Parts = []dto.GeminiPart{
{
FunctionCall: &dto.FunctionCall{
FunctionName: "lookup",
Arguments: map[string]any{"q": "x"},
},
},
}
}
return response
}
func terminalTestGeminiChunkWithoutUsage(text string, finishReason string) *dto.GeminiChatResponse {
candidate := dto.GeminiChatCandidate{
Content: dto.GeminiChatContent{
Role: "model",
Parts: []dto.GeminiPart{{Text: text}},
},
}
if finishReason != "" {
candidate.FinishReason = terminalTestPtr(finishReason)
}
return &dto.GeminiChatResponse{
Candidates: []dto.GeminiChatCandidate{candidate},
}
}
func terminalTestGeminiToolChunkWithoutUsage() *dto.GeminiChatResponse {
return &dto.GeminiChatResponse{
Candidates: []dto.GeminiChatCandidate{
{
Content: dto.GeminiChatContent{
Role: "model",
Parts: []dto.GeminiPart{
{
FunctionCall: &dto.FunctionCall{
FunctionName: "lookup",
Arguments: map[string]any{"q": "x"},
},
},
},
},
},
},
}
}
func terminalTestGeminiUsageOnlyChunk() *dto.GeminiChatResponse {
return &dto.GeminiChatResponse{
HasUsageMetadata: true,
UsageMetadata: dto.GeminiUsageMetadata{
PromptTokenCount: 4,
CandidatesTokenCount: 2,
TotalTokenCount: 6,
},
}
}
func terminalTestFinishedChatChunks(t *testing.T, results []ResponseResult) []*dto.ChatCompletionsStreamResponse {
t.Helper()
finished := make([]*dto.ChatCompletionsStreamResponse, 0, 1)
for _, result := range results {
response, ok := result.Value.(*dto.ChatCompletionsStreamResponse)
require.True(t, ok, "unexpected stream result type %T", result.Value)
if response.IsFinished() {
finished = append(finished, response)
}
}
return finished
}
func terminalTestAssertClaudeTail(t *testing.T, results []ResponseResult, wantStopReason string) {
t.Helper()
responses := make([]*dto.ClaudeResponse, 0, len(results))
eventCounts := make(map[string]int)
for _, result := range results {
response, ok := result.Value.(*dto.ClaudeResponse)
require.True(t, ok, "unexpected stream result type %T", result.Value)
responses = append(responses, response)
eventCounts[response.Type]++
}
require.GreaterOrEqual(t, len(responses), 4)
assert.Equal(t, "message_start", responses[0].Type)
tail := responses[len(responses)-3:]
assert.Equal(t, "content_block_stop", tail[0].Type)
require.NotNil(t, tail[0].Index)
assert.Equal(t, 0, *tail[0].Index)
assert.Equal(t, "message_delta", tail[1].Type)
require.NotNil(t, tail[1].Delta)
require.NotNil(t, tail[1].Delta.StopReason)
assert.Equal(t, wantStopReason, *tail[1].Delta.StopReason)
require.NotNil(t, tail[1].Usage)
assert.Equal(t, 4, tail[1].Usage.InputTokens)
assert.Equal(t, 2, tail[1].Usage.OutputTokens)
assert.Equal(t, "message_stop", tail[2].Type)
assert.Equal(t, 1, eventCounts["content_block_stop"])
assert.Equal(t, 1, eventCounts["message_delta"])
assert.Equal(t, 1, eventCounts["message_stop"])
}
func terminalTestPtr[T any](value T) *T {
return &value
}
@@ -0,0 +1,70 @@
{
"model": "gpt-test",
"system": [
{
"type": "text",
"text": "You are a helpful assistant."
}
],
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": "What is in this image?"
},
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": "aGVsbG8="
}
}
]
},
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "call_abc",
"name": "get_weather",
"input": {
"city": "Paris"
}
}
]
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"content": "15 degrees",
"tool_use_id": "call_abc"
}
]
}
],
"max_tokens": 1024,
"stream": true,
"tools": [
{
"name": "get_weather",
"description": "Get weather by city",
"input_schema": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
}
}
]
}
@@ -0,0 +1,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,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,64 @@
{
"id": "msg_fixed",
"model": "claude-test",
"object": "chat.completion",
"created": 0,
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "The answer is 42.",
"tool_calls": [
{
"id": "toolu_abc",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\":\"Paris\"}"
}
}
]
},
"finish_reason": "tool_calls"
}
],
"usage": {
"prompt_tokens": 15,
"completion_tokens": 5,
"total_tokens": 20,
"usage_semantic": "openai",
"usage_source": "anthropic",
"billing_usage": {
"source": "claude_messages",
"semantic": "anthropic",
"claude_usage": {
"input_tokens": 10,
"cache_creation_input_tokens": 2,
"cache_read_input_tokens": 3,
"output_tokens": 5,
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0
}
},
"prompt_tokens_details": {
"cached_tokens": 3,
"cached_creation_tokens": 2,
"cache_write_tokens": 2,
"text_tokens": 0,
"audio_tokens": 0,
"image_tokens": 0
},
"completion_tokens_details": {
"text_tokens": 0,
"audio_tokens": 0,
"image_tokens": 0,
"reasoning_tokens": 0
},
"input_tokens": 15,
"output_tokens": 0,
"input_tokens_details": null,
"claude_cache_creation_5_m_tokens": 2,
"claude_cache_creation_1_h_tokens": 0
}
}
@@ -0,0 +1,92 @@
{
"id": "msg_fixed",
"object": "response",
"created_at": 0,
"status": "completed",
"instructions": null,
"max_output_tokens": 0,
"model": "claude-test",
"output": [
{
"type": "message",
"id": "msg_fixed_msg_0",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "The answer is 42.",
"annotations": []
}
],
"quality": "",
"size": ""
},
{
"type": "function_call",
"id": "toolu_abc",
"status": "completed",
"role": "",
"content": null,
"quality": "",
"size": "",
"call_id": "toolu_abc",
"name": "get_weather",
"arguments": "{\"city\":\"Paris\"}"
}
],
"parallel_tool_calls": false,
"previous_response_id": null,
"reasoning": null,
"store": false,
"temperature": 0,
"tool_choice": null,
"tools": null,
"top_p": 0,
"truncation": null,
"usage": {
"prompt_tokens": 15,
"completion_tokens": 5,
"total_tokens": 20,
"usage_semantic": "openai",
"usage_source": "anthropic",
"billing_usage": {
"source": "claude_messages",
"semantic": "anthropic",
"claude_usage": {
"input_tokens": 10,
"cache_creation_input_tokens": 2,
"cache_read_input_tokens": 3,
"output_tokens": 5,
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0
}
},
"prompt_tokens_details": {
"cached_tokens": 0,
"text_tokens": 0,
"audio_tokens": 0,
"image_tokens": 0
},
"completion_tokens_details": {
"text_tokens": 0,
"audio_tokens": 0,
"image_tokens": 0,
"reasoning_tokens": 0
},
"input_tokens": 15,
"output_tokens": 5,
"input_tokens_details": {
"cached_tokens": 3,
"cached_creation_tokens": 2,
"cache_write_tokens": 2,
"text_tokens": 0,
"audio_tokens": 0,
"image_tokens": 0
},
"claude_cache_creation_5_m_tokens": 2,
"claude_cache_creation_1_h_tokens": 0
},
"user": null,
"metadata": null
}
@@ -0,0 +1,44 @@
{
"id": "chatcmpl-<uuid>",
"type": "message",
"role": "assistant",
"content": [
{
"type": "text",
"text": "The answer is 42."
},
{
"type": "tool_use",
"id": "call_<uuid>",
"name": "get_weather",
"input": {
"city": "Paris"
}
}
],
"stop_reason": "tool_use",
"model": "upstream-model",
"usage": {
"input_tokens": 10,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"output_tokens": 7,
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0,
"billing_usage": {
"source": "gemini_chat",
"semantic": "gemini",
"gemini_usage_metadata": {
"promptTokenCount": 10,
"toolUsePromptTokenCount": 0,
"candidatesTokenCount": 5,
"totalTokenCount": 15,
"thoughtsTokenCount": 2,
"cachedContentTokenCount": 0,
"promptTokensDetails": [],
"toolUsePromptTokensDetails": [],
"candidatesTokensDetails": []
}
}
}
}
@@ -0,0 +1,63 @@
{
"id": "chatcmpl-<uuid>",
"model": "upstream-model",
"object": "chat.completion",
"created": 0,
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "The answer is 42.",
"tool_calls": [
{
"id": "call_<uuid>",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\":\"Paris\"}"
}
}
]
},
"finish_reason": "tool_calls"
}
],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 7,
"total_tokens": 15,
"billing_usage": {
"source": "gemini_chat",
"semantic": "gemini",
"gemini_usage_metadata": {
"promptTokenCount": 10,
"toolUsePromptTokenCount": 0,
"candidatesTokenCount": 5,
"totalTokenCount": 15,
"thoughtsTokenCount": 2,
"cachedContentTokenCount": 0,
"promptTokensDetails": [],
"toolUsePromptTokensDetails": [],
"candidatesTokensDetails": []
}
},
"prompt_tokens_details": {
"cached_tokens": 0,
"text_tokens": 10,
"audio_tokens": 0,
"image_tokens": 0
},
"completion_tokens_details": {
"text_tokens": 0,
"audio_tokens": 0,
"image_tokens": 0,
"reasoning_tokens": 2
},
"input_tokens": 0,
"output_tokens": 0,
"input_tokens_details": null,
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0
}
}
@@ -0,0 +1,91 @@
{
"id": "chatcmpl-<uuid>",
"object": "response",
"created_at": 0,
"status": "completed",
"instructions": null,
"max_output_tokens": 0,
"model": "upstream-model",
"output": [
{
"type": "message",
"id": "chatcmpl-<uuid>_msg_0",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "The answer is 42.",
"annotations": []
}
],
"quality": "",
"size": ""
},
{
"type": "function_call",
"id": "call_<uuid>",
"status": "completed",
"role": "",
"content": null,
"quality": "",
"size": "",
"call_id": "call_<uuid>",
"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": 10,
"completion_tokens": 7,
"total_tokens": 15,
"billing_usage": {
"source": "gemini_chat",
"semantic": "gemini",
"gemini_usage_metadata": {
"promptTokenCount": 10,
"toolUsePromptTokenCount": 0,
"candidatesTokenCount": 5,
"totalTokenCount": 15,
"thoughtsTokenCount": 2,
"cachedContentTokenCount": 0,
"promptTokensDetails": [],
"toolUsePromptTokensDetails": [],
"candidatesTokensDetails": []
}
},
"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": 2
},
"input_tokens": 10,
"output_tokens": 7,
"input_tokens_details": {
"cached_tokens": 0,
"text_tokens": 10,
"audio_tokens": 0,
"image_tokens": 0
},
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0
},
"user": null,
"metadata": null
}
@@ -0,0 +1,59 @@
{
"id": "resp_fixed",
"type": "message",
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "Deep thought."
},
{
"type": "text",
"text": "The answer is 42."
},
{
"type": "tool_use",
"id": "call_abc",
"name": "get_weather",
"input": {
"city": "Paris"
}
}
],
"stop_reason": "tool_use",
"model": "gpt-test",
"usage": {
"input_tokens": 10,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"output_tokens": 5,
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0,
"billing_usage": {
"source": "oai_responses",
"semantic": "openai",
"openai_usage": {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 15,
"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": 10,
"output_tokens": 5,
"input_tokens_details": null,
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0
}
}
}
}
@@ -0,0 +1,75 @@
{
"id": "resp_fixed",
"model": "gpt-test",
"object": "chat.completion",
"created": 0,
"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,
"billing_usage": {
"source": "oai_responses",
"semantic": "openai",
"openai_usage": {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 15,
"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": 10,
"output_tokens": 5,
"input_tokens_details": null,
"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": 10,
"output_tokens": 5,
"input_tokens_details": null,
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0
}
}
@@ -0,0 +1,117 @@
{
"id": "chatcmpl-fixed",
"object": "response",
"created_at": 0,
"status": "completed",
"instructions": null,
"max_output_tokens": 0,
"model": "gpt-test",
"output": [
{
"type": "reasoning",
"id": "chatcmpl-fixed_reasoning_0",
"status": "completed",
"role": "",
"content": null,
"summary": [
{
"type": "summary_text",
"text": "Deep thought."
}
],
"quality": "",
"size": ""
},
{
"type": "message",
"id": "chatcmpl-fixed_msg_0",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "The answer is 42.",
"annotations": []
}
],
"quality": "",
"size": ""
},
{
"type": "function_call",
"id": "call_abc",
"status": "completed",
"role": "",
"content": null,
"quality": "",
"size": "",
"call_id": "call_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": 10,
"completion_tokens": 5,
"total_tokens": 15,
"billing_usage": {
"source": "oai_chat",
"semantic": "openai",
"openai_usage": {
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15,
"prompt_tokens_details": {
"cached_tokens": 3,
"text_tokens": 0,
"audio_tokens": 0,
"image_tokens": 0
},
"completion_tokens_details": {
"text_tokens": 0,
"audio_tokens": 0,
"image_tokens": 0,
"reasoning_tokens": 2
},
"input_tokens": 0,
"output_tokens": 0,
"input_tokens_details": null,
"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": 2
},
"input_tokens": 10,
"output_tokens": 5,
"input_tokens_details": {
"cached_tokens": 3,
"text_tokens": 0,
"audio_tokens": 0,
"image_tokens": 0
},
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0
},
"user": null,
"metadata": null
}
@@ -0,0 +1,68 @@
{
"events": [
{
"candidates": [
{
"content": {
"role": "model",
"parts": [
{
"text": "Hello world"
}
]
},
"finishReason": null,
"index": 0,
"safetyRatings": []
}
],
"usageMetadata": {
"promptTokenCount": 0,
"toolUsePromptTokenCount": 0,
"candidatesTokenCount": 0,
"totalTokenCount": 0,
"thoughtsTokenCount": 0,
"cachedContentTokenCount": 0,
"promptTokensDetails": null,
"toolUsePromptTokensDetails": null,
"candidatesTokensDetails": null
}
},
{
"candidates": [
{
"content": {
"role": "model",
"parts": []
},
"finishReason": "STOP",
"index": 0,
"safetyRatings": []
}
],
"usageMetadata": {
"promptTokenCount": 0,
"toolUsePromptTokenCount": 0,
"candidatesTokenCount": 2,
"totalTokenCount": 2,
"thoughtsTokenCount": 0,
"cachedContentTokenCount": 0,
"promptTokensDetails": null,
"toolUsePromptTokensDetails": null,
"candidatesTokensDetails": null,
"billing_usage": {
"source": "claude_messages",
"semantic": "anthropic",
"claude_usage": {
"input_tokens": 0,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"output_tokens": 2,
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0
}
}
}
}
]
}
@@ -0,0 +1,181 @@
{
"events": [
{
"id": "msg_fixed",
"object": "chat.completion.chunk",
"created": 0,
"model": "claude-test",
"system_fingerprint": null,
"choices": [
{
"delta": {
"content": "",
"role": "assistant"
},
"logprobs": null,
"finish_reason": null,
"index": 0
}
],
"usage": {
"prompt_tokens": 4,
"completion_tokens": 0,
"total_tokens": 4,
"usage_semantic": "openai",
"usage_source": "anthropic",
"billing_usage": {
"source": "claude_messages",
"semantic": "anthropic",
"claude_usage": {
"input_tokens": 4,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"output_tokens": 0,
"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": 4,
"output_tokens": 0,
"input_tokens_details": null,
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0
}
},
{
"id": "",
"object": "chat.completion.chunk",
"created": 0,
"model": "",
"system_fingerprint": null,
"choices": [
{
"delta": {
"content": ""
},
"logprobs": null,
"finish_reason": null,
"index": 0
}
],
"usage": null
},
{
"id": "",
"object": "chat.completion.chunk",
"created": 0,
"model": "",
"system_fingerprint": null,
"choices": [
{
"delta": {
"content": "Hello world"
},
"logprobs": null,
"finish_reason": null,
"index": 0
}
],
"usage": null
},
{
"id": "",
"object": "chat.completion.chunk",
"created": 0,
"model": "",
"system_fingerprint": null,
"choices": [
{
"delta": {},
"logprobs": null,
"finish_reason": "stop",
"index": 0
}
],
"usage": {
"prompt_tokens": 0,
"completion_tokens": 2,
"total_tokens": 2,
"usage_semantic": "openai",
"usage_source": "anthropic",
"billing_usage": {
"source": "claude_messages",
"semantic": "anthropic",
"claude_usage": {
"input_tokens": 0,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"output_tokens": 2,
"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": 0,
"output_tokens": 0,
"input_tokens_details": null,
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0
}
}
],
"usage": {
"prompt_tokens": 0,
"completion_tokens": 2,
"total_tokens": 2,
"usage_semantic": "openai",
"usage_source": "anthropic",
"billing_usage": {
"source": "claude_messages",
"semantic": "anthropic",
"claude_usage": {
"input_tokens": 0,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"output_tokens": 2,
"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": 0,
"output_tokens": 0,
"input_tokens_details": null,
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0
}
}
@@ -0,0 +1,204 @@
{
"events": [
{
"Type": "response.created",
"Payload": {
"type": "response.created",
"response": {
"id": "stream_fixed",
"object": "response",
"created_at": 0,
"status": "in_progress",
"instructions": null,
"max_output_tokens": 0,
"model": "stream-model",
"output": [],
"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": null,
"user": null,
"metadata": null
}
}
},
{
"Type": "response.output_item.added",
"Payload": {
"type": "response.output_item.added",
"item": {
"type": "message",
"id": "stream_fixed_msg_0",
"status": "in_progress",
"role": "assistant",
"content": [],
"quality": "",
"size": ""
},
"output_index": 0
}
},
{
"Type": "response.output_text.delta",
"Payload": {
"type": "response.output_text.delta",
"delta": "Hello world",
"output_index": 0,
"content_index": 0,
"item_id": "stream_fixed_msg_0"
}
},
{
"Type": "response.output_text.done",
"Payload": {
"type": "response.output_text.done",
"output_index": 0,
"content_index": 0,
"item_id": "stream_fixed_msg_0"
}
},
{
"Type": "response.output_item.done",
"Payload": {
"type": "response.output_item.done",
"item": {
"type": "message",
"id": "stream_fixed_msg_0",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "Hello world",
"annotations": []
}
],
"quality": "",
"size": ""
},
"output_index": 0
}
},
{
"Type": "response.completed",
"Payload": {
"type": "response.completed",
"response": {
"id": "stream_fixed",
"object": "response",
"created_at": 0,
"status": "completed",
"instructions": null,
"max_output_tokens": 0,
"model": "stream-model",
"output": [
{
"type": "message",
"id": "stream_fixed_msg_0",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "Hello world",
"annotations": []
}
],
"quality": "",
"size": ""
}
],
"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": 0,
"completion_tokens": 2,
"total_tokens": 2,
"usage_semantic": "openai",
"usage_source": "anthropic",
"billing_usage": {
"source": "claude_messages",
"semantic": "anthropic",
"claude_usage": {
"input_tokens": 0,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"output_tokens": 2,
"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": 0,
"output_tokens": 2,
"input_tokens_details": null,
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0
},
"user": null,
"metadata": null
}
}
}
],
"usage": {
"prompt_tokens": 0,
"completion_tokens": 2,
"total_tokens": 2,
"usage_semantic": "openai",
"usage_source": "anthropic",
"billing_usage": {
"source": "claude_messages",
"semantic": "anthropic",
"claude_usage": {
"input_tokens": 0,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"output_tokens": 2,
"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": 0,
"output_tokens": 2,
"input_tokens_details": null,
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0
}
}
@@ -0,0 +1,82 @@
{
"events": [
{
"type": "message_start",
"message": {
"type": "message",
"model": "upstream-model",
"usage": {
"input_tokens": 0,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"output_tokens": 0,
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0
},
"role": "assistant",
"id": "stream_fixed",
"content": []
}
},
{
"type": "content_block_start",
"index": 0,
"content_block": {
"type": "text",
"text": ""
}
},
{
"type": "content_block_delta",
"index": 0,
"delta": {
"type": "text_delta",
"text": "Hello"
}
},
{
"type": "content_block_delta",
"index": 0,
"delta": {
"type": "text_delta",
"text": " world"
}
},
{
"type": "content_block_stop",
"index": 0
},
{
"type": "message_delta",
"usage": {
"input_tokens": 4,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"output_tokens": 2,
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0,
"billing_usage": {
"source": "gemini_chat",
"semantic": "gemini",
"gemini_usage_metadata": {
"promptTokenCount": 4,
"toolUsePromptTokenCount": 0,
"candidatesTokenCount": 2,
"totalTokenCount": 6,
"thoughtsTokenCount": 0,
"cachedContentTokenCount": 0,
"promptTokensDetails": [],
"toolUsePromptTokensDetails": [],
"candidatesTokensDetails": []
}
}
},
"delta": {
"stop_reason": "end_turn"
}
},
{
"type": "message_stop"
}
]
}
@@ -0,0 +1,186 @@
{
"events": [
{
"id": "stream_fixed",
"object": "chat.completion.chunk",
"created": 0,
"model": "upstream-model",
"system_fingerprint": null,
"choices": [
{
"delta": {
"content": "Hello"
},
"logprobs": null,
"finish_reason": null,
"index": 0
}
],
"usage": {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_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": 0,
"output_tokens": 0,
"input_tokens_details": null,
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0
}
},
{
"id": "stream_fixed",
"object": "chat.completion.chunk",
"created": 0,
"model": "upstream-model",
"system_fingerprint": null,
"choices": [
{
"delta": {
"content": " world"
},
"logprobs": null,
"finish_reason": null,
"index": 0
}
],
"usage": {
"prompt_tokens": 4,
"completion_tokens": 2,
"total_tokens": 6,
"billing_usage": {
"source": "gemini_chat",
"semantic": "gemini",
"gemini_usage_metadata": {
"promptTokenCount": 4,
"toolUsePromptTokenCount": 0,
"candidatesTokenCount": 2,
"totalTokenCount": 6,
"thoughtsTokenCount": 0,
"cachedContentTokenCount": 0,
"promptTokensDetails": [],
"toolUsePromptTokensDetails": [],
"candidatesTokensDetails": []
}
},
"prompt_tokens_details": {
"cached_tokens": 0,
"text_tokens": 4,
"audio_tokens": 0,
"image_tokens": 0
},
"completion_tokens_details": {
"text_tokens": 0,
"audio_tokens": 0,
"image_tokens": 0,
"reasoning_tokens": 0
},
"input_tokens": 0,
"output_tokens": 0,
"input_tokens_details": null,
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0
}
},
{
"id": "stream_fixed",
"object": "chat.completion.chunk",
"created": 0,
"model": "upstream-model",
"system_fingerprint": null,
"choices": [
{
"delta": {},
"logprobs": null,
"finish_reason": "stop",
"index": 0
}
],
"usage": {
"prompt_tokens": 4,
"completion_tokens": 2,
"total_tokens": 6,
"billing_usage": {
"source": "gemini_chat",
"semantic": "gemini",
"gemini_usage_metadata": {
"promptTokenCount": 4,
"toolUsePromptTokenCount": 0,
"candidatesTokenCount": 2,
"totalTokenCount": 6,
"thoughtsTokenCount": 0,
"cachedContentTokenCount": 0,
"promptTokensDetails": [],
"toolUsePromptTokensDetails": [],
"candidatesTokensDetails": []
}
},
"prompt_tokens_details": {
"cached_tokens": 0,
"text_tokens": 4,
"audio_tokens": 0,
"image_tokens": 0
},
"completion_tokens_details": {
"text_tokens": 0,
"audio_tokens": 0,
"image_tokens": 0,
"reasoning_tokens": 0
},
"input_tokens": 0,
"output_tokens": 0,
"input_tokens_details": null,
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0
}
}
],
"usage": {
"prompt_tokens": 4,
"completion_tokens": 2,
"total_tokens": 6,
"billing_usage": {
"source": "gemini_chat",
"semantic": "gemini",
"gemini_usage_metadata": {
"promptTokenCount": 4,
"toolUsePromptTokenCount": 0,
"candidatesTokenCount": 2,
"totalTokenCount": 6,
"thoughtsTokenCount": 0,
"cachedContentTokenCount": 0,
"promptTokensDetails": [],
"toolUsePromptTokensDetails": [],
"candidatesTokensDetails": []
}
},
"prompt_tokens_details": {
"cached_tokens": 0,
"text_tokens": 4,
"audio_tokens": 0,
"image_tokens": 0
},
"completion_tokens_details": {
"text_tokens": 0,
"audio_tokens": 0,
"image_tokens": 0,
"reasoning_tokens": 0
},
"input_tokens": 0,
"output_tokens": 0,
"input_tokens_details": null,
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0
}
}
@@ -0,0 +1,226 @@
{
"events": [
{
"Type": "response.created",
"Payload": {
"type": "response.created",
"response": {
"id": "stream_fixed",
"object": "response",
"created_at": 0,
"status": "in_progress",
"instructions": null,
"max_output_tokens": 0,
"model": "stream-model",
"output": [],
"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": null,
"user": null,
"metadata": null
}
}
},
{
"Type": "response.output_item.added",
"Payload": {
"type": "response.output_item.added",
"item": {
"type": "message",
"id": "stream_fixed_msg_0",
"status": "in_progress",
"role": "assistant",
"content": [],
"quality": "",
"size": ""
},
"output_index": 0
}
},
{
"Type": "response.output_text.delta",
"Payload": {
"type": "response.output_text.delta",
"delta": "Hello",
"output_index": 0,
"content_index": 0,
"item_id": "stream_fixed_msg_0"
}
},
{
"Type": "response.output_text.delta",
"Payload": {
"type": "response.output_text.delta",
"delta": " world",
"output_index": 0,
"content_index": 0,
"item_id": "stream_fixed_msg_0"
}
},
{
"Type": "response.output_text.done",
"Payload": {
"type": "response.output_text.done",
"output_index": 0,
"content_index": 0,
"item_id": "stream_fixed_msg_0"
}
},
{
"Type": "response.output_item.done",
"Payload": {
"type": "response.output_item.done",
"item": {
"type": "message",
"id": "stream_fixed_msg_0",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "Hello world",
"annotations": []
}
],
"quality": "",
"size": ""
},
"output_index": 0
}
},
{
"Type": "response.completed",
"Payload": {
"type": "response.completed",
"response": {
"id": "stream_fixed",
"object": "response",
"created_at": 0,
"status": "completed",
"instructions": null,
"max_output_tokens": 0,
"model": "stream-model",
"output": [
{
"type": "message",
"id": "stream_fixed_msg_0",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "Hello world",
"annotations": []
}
],
"quality": "",
"size": ""
}
],
"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": 4,
"completion_tokens": 2,
"total_tokens": 6,
"billing_usage": {
"source": "gemini_chat",
"semantic": "gemini",
"gemini_usage_metadata": {
"promptTokenCount": 4,
"toolUsePromptTokenCount": 0,
"candidatesTokenCount": 2,
"totalTokenCount": 6,
"thoughtsTokenCount": 0,
"cachedContentTokenCount": 0,
"promptTokensDetails": [],
"toolUsePromptTokensDetails": [],
"candidatesTokensDetails": []
}
},
"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": 4,
"output_tokens": 2,
"input_tokens_details": {
"cached_tokens": 0,
"text_tokens": 4,
"audio_tokens": 0,
"image_tokens": 0
},
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0
},
"user": null,
"metadata": null
}
}
}
],
"usage": {
"prompt_tokens": 4,
"completion_tokens": 2,
"total_tokens": 6,
"billing_usage": {
"source": "gemini_chat",
"semantic": "gemini",
"gemini_usage_metadata": {
"promptTokenCount": 4,
"toolUsePromptTokenCount": 0,
"candidatesTokenCount": 2,
"totalTokenCount": 6,
"thoughtsTokenCount": 0,
"cachedContentTokenCount": 0,
"promptTokensDetails": [],
"toolUsePromptTokensDetails": [],
"candidatesTokensDetails": []
}
},
"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": 4,
"output_tokens": 2,
"input_tokens_details": {
"cached_tokens": 0,
"text_tokens": 4,
"audio_tokens": 0,
"image_tokens": 0
},
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0
}
}
@@ -0,0 +1,141 @@
{
"events": [
{
"type": "message_start",
"message": {
"type": "message",
"model": "stream-model",
"usage": {
"input_tokens": 0,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"output_tokens": 0,
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0
},
"role": "assistant",
"id": "stream_fixed",
"content": []
}
},
{
"type": "content_block_start",
"index": 0,
"content_block": {
"type": "text",
"text": ""
}
},
{
"type": "content_block_delta",
"index": 0,
"delta": {
"type": "text_delta",
"text": "Hello"
}
},
{
"type": "content_block_delta",
"index": 0,
"delta": {
"type": "text_delta",
"text": " world"
}
},
{
"type": "content_block_stop",
"index": 0
},
{
"type": "message_delta",
"usage": {
"input_tokens": 4,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"output_tokens": 2,
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0,
"billing_usage": {
"source": "oai_responses",
"semantic": "openai",
"openai_usage": {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 6,
"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": 4,
"output_tokens": 2,
"input_tokens_details": null,
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0
}
}
},
"delta": {
"stop_reason": "end_turn"
}
},
{
"type": "message_stop"
}
],
"usage": {
"prompt_tokens": 4,
"completion_tokens": 2,
"total_tokens": 6,
"billing_usage": {
"source": "oai_responses",
"semantic": "openai",
"openai_usage": {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 6,
"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": 4,
"output_tokens": 2,
"input_tokens_details": null,
"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": 4,
"output_tokens": 2,
"input_tokens_details": null,
"claude_cache_creation_5_m_tokens": 0,
"claude_cache_creation_1_h_tokens": 0
}
}
Loaded 100 of 2381 files, more files were not shown because too many files have changed in this diff. Show more