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
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:
commit
bb7968b612
2381 files changed
+551494
No files matched your search
@@ -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},
|
||||
}}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package convmeta
|
||||
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
)
|
||||
|
||||
// GuessRelayFormatFromRequest infers the relay format from a request DTO's
|
||||
// concrete type. Moved from relay/common (which keeps a delegating alias).
|
||||
func GuessRelayFormatFromRequest(req any) (types.RelayFormat, bool) {
|
||||
switch req.(type) {
|
||||
case *dto.GeneralOpenAIRequest, dto.GeneralOpenAIRequest:
|
||||
return types.RelayFormatOpenAI, true
|
||||
case *dto.OpenAIResponsesRequest, dto.OpenAIResponsesRequest:
|
||||
return types.RelayFormatOpenAIResponses, true
|
||||
case *dto.ClaudeRequest, dto.ClaudeRequest:
|
||||
return types.RelayFormatClaude, true
|
||||
case *dto.GeminiChatRequest, dto.GeminiChatRequest:
|
||||
return types.RelayFormatGemini, true
|
||||
case *dto.EmbeddingRequest, dto.EmbeddingRequest:
|
||||
return types.RelayFormatEmbedding, true
|
||||
case *dto.RerankRequest, dto.RerankRequest:
|
||||
return types.RelayFormatRerank, true
|
||||
case *dto.ImageRequest, dto.ImageRequest:
|
||||
return types.RelayFormatOpenAIImage, true
|
||||
case *dto.AudioRequest, dto.AudioRequest:
|
||||
return types.RelayFormatOpenAIAudio, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("
|
||||
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("
|
||||
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, ¶llelToolCalls); err != nil {
|
||||
return nil
|
||||
}
|
||||
return ¶llelToolCalls
|
||||
}
|
||||
|
||||
func ParallelToolCalls(raw []byte) *bool {
|
||||
return responsesParallelToolCalls(raw)
|
||||
}
|
||||
|
||||
func ContentPartToFileSource(part map[string]any) types.FileSource {
|
||||
partType := strings.TrimSpace(kitutil.Interface2String(part["type"]))
|
||||
var data string
|
||||
var mimeType string
|
||||
|
||||
switch partType {
|
||||
case "input_image":
|
||||
data, mimeType = responsesPartDataAndMime(part, "image_url", "url")
|
||||
case "input_file":
|
||||
data, mimeType = responsesPartDataAndMime(part, "file", "file_data", "file_url", "url")
|
||||
case "input_audio":
|
||||
data, mimeType = responsesPartDataAndMime(part, "input_audio", "data", "url")
|
||||
if mimeType == "" {
|
||||
if payload, ok := part["input_audio"].(map[string]any); ok {
|
||||
if format := strings.TrimSpace(kitutil.Interface2String(payload["format"])); format != "" {
|
||||
mimeType = "audio/" + format
|
||||
}
|
||||
}
|
||||
}
|
||||
case "input_video":
|
||||
data, mimeType = responsesPartDataAndMime(part, "video_url", "url")
|
||||
}
|
||||
if data == "" {
|
||||
return nil
|
||||
}
|
||||
return types.NewFileSourceFromData(data, mimeType)
|
||||
}
|
||||
|
||||
func responsesPartDataAndMime(part map[string]any, keys ...string) (string, string) {
|
||||
mimeType := strings.TrimSpace(kitutil.Interface2String(part["mime_type"]))
|
||||
for _, key := range keys {
|
||||
value, ok := part[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if typed != "" {
|
||||
return typed, mimeType
|
||||
}
|
||||
case map[string]any:
|
||||
if mimeType == "" {
|
||||
mimeType = strings.TrimSpace(kitutil.Interface2String(typed["mime_type"]))
|
||||
}
|
||||
for _, nestedKey := range []string{"url", "file_data", "file_url", "data"} {
|
||||
if data := strings.TrimSpace(kitutil.Interface2String(typed[nestedKey])); data != "" {
|
||||
return data, mimeType
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", mimeType
|
||||
}
|
||||
@@ -0,0 +1,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, ¶llelToolCalls); err == nil {
|
||||
out.ParallelTooCalls = ¶llelToolCalls
|
||||
}
|
||||
}
|
||||
if len(req.PromptCacheKey) > 0 && kitutil.GetJsonType(req.PromptCacheKey) == "string" {
|
||||
var promptCacheKey string
|
||||
if err := kitutil.Unmarshal(req.PromptCacheKey, &promptCacheKey); err == nil {
|
||||
out.PromptCacheKey = promptCacheKey
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validateResponsesRequestChatUnsupportedFields(req *dto.OpenAIResponsesRequest) error {
|
||||
unsupported := make([]string, 0, 4)
|
||||
if rawJSONPresent(req.Conversation) {
|
||||
unsupported = append(unsupported, "conversation")
|
||||
}
|
||||
if strings.TrimSpace(req.PreviousResponseID) != "" {
|
||||
unsupported = append(unsupported, "previous_response_id")
|
||||
}
|
||||
if rawJSONPresent(req.Prompt) {
|
||||
unsupported = append(unsupported, "prompt")
|
||||
}
|
||||
if rawJSONPresent(req.ContextManagement) {
|
||||
unsupported = append(unsupported, "context_management")
|
||||
}
|
||||
if len(unsupported) > 0 {
|
||||
return fmt.Errorf("responses to chat conversion does not support stateful fields: %s", strings.Join(unsupported, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateRequestChatUnsupportedFields(req *dto.OpenAIResponsesRequest) error {
|
||||
return validateResponsesRequestChatUnsupportedFields(req)
|
||||
}
|
||||
|
||||
func responsesRequestMessagesToChat(req *dto.OpenAIResponsesRequest) ([]dto.Message, error) {
|
||||
messages := make([]dto.Message, 0)
|
||||
if rawJSONPresent(req.Instructions) {
|
||||
instructions, err := responsesJSONString(req.Instructions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid instructions: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(instructions) != "" {
|
||||
messages = append(messages, dto.Message{Role: "system", Content: instructions})
|
||||
}
|
||||
}
|
||||
|
||||
if !rawJSONPresent(req.Input) {
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
switch kitutil.GetJsonType(req.Input) {
|
||||
case "string":
|
||||
input, err := responsesJSONString(req.Input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid input string: %w", err)
|
||||
}
|
||||
messages = append(messages, dto.Message{Role: "user", Content: input})
|
||||
return messages, nil
|
||||
case "array":
|
||||
var items []map[string]any
|
||||
if err := kitutil.Unmarshal(req.Input, &items); err != nil {
|
||||
return nil, fmt.Errorf("invalid input array: %w", err)
|
||||
}
|
||||
for _, item := range items {
|
||||
nextMessages, err := responsesInputItemToChatMessages(item, messages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
messages = nextMessages
|
||||
}
|
||||
return messages, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported responses input type %q", kitutil.GetJsonType(req.Input))
|
||||
}
|
||||
}
|
||||
|
||||
func responsesInputItemToChatMessages(item map[string]any, messages []dto.Message) ([]dto.Message, error) {
|
||||
itemType := strings.TrimSpace(kitutil.Interface2String(item["type"]))
|
||||
switch itemType {
|
||||
case responsesInputTypeFunctionCall:
|
||||
toolCall, err := responsesFunctionCallItemToChatToolCall(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return appendToolCallToLastAssistant(messages, toolCall), nil
|
||||
case responsesInputTypeCustomToolCall:
|
||||
toolCall, err := responsesCustomToolCallItemToChatToolCall(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return appendToolCallToLastAssistant(messages, toolCall), nil
|
||||
case responsesInputTypeFunctionCallOutput:
|
||||
callID := strings.TrimSpace(kitutil.Interface2String(item["call_id"]))
|
||||
content := responseToolOutputToChatContent(item["output"])
|
||||
return append(messages, dto.Message{Role: "tool", ToolCallId: callID, Content: content}), nil
|
||||
}
|
||||
|
||||
role := strings.TrimSpace(kitutil.Interface2String(item["role"]))
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
content, err := responsesInputContentToChatContent(item["content"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(messages, dto.Message{Role: role, Content: content}), nil
|
||||
}
|
||||
|
||||
func responsesInputContentToChatContent(content any) (any, error) {
|
||||
if content == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
switch value := content.(type) {
|
||||
case string:
|
||||
return value, nil
|
||||
case []any:
|
||||
return responsesContentPartsToChatContent(value)
|
||||
case []map[string]any:
|
||||
parts := make([]any, 0, len(value))
|
||||
for _, part := range value {
|
||||
parts = append(parts, part)
|
||||
}
|
||||
return responsesContentPartsToChatContent(parts)
|
||||
default:
|
||||
return content, nil
|
||||
}
|
||||
}
|
||||
|
||||
func responsesContentPartsToChatContent(parts []any) (any, error) {
|
||||
chatParts := make([]any, 0, len(parts))
|
||||
var textOnly strings.Builder
|
||||
onlyText := true
|
||||
|
||||
for _, rawPart := range parts {
|
||||
part, ok := rawPart.(map[string]any)
|
||||
if !ok {
|
||||
onlyText = false
|
||||
chatParts = append(chatParts, rawPart)
|
||||
continue
|
||||
}
|
||||
|
||||
partType := strings.TrimSpace(kitutil.Interface2String(part["type"]))
|
||||
switch partType {
|
||||
case "input_text", "output_text", "text":
|
||||
text := kitutil.Interface2String(part["text"])
|
||||
textOnly.WriteString(text)
|
||||
chatParts = append(chatParts, map[string]any{
|
||||
"type": dto.ContentTypeText,
|
||||
"text": text,
|
||||
})
|
||||
case "input_image":
|
||||
onlyText = false
|
||||
chatParts = append(chatParts, map[string]any{
|
||||
"type": dto.ContentTypeImageURL,
|
||||
"image_url": responsesImagePartToChatImageURL(part),
|
||||
})
|
||||
case "input_file":
|
||||
onlyText = false
|
||||
chatParts = append(chatParts, map[string]any{
|
||||
"type": dto.ContentTypeFile,
|
||||
"file": responsesFilePartToChatFile(part),
|
||||
})
|
||||
case "input_audio":
|
||||
onlyText = false
|
||||
chatParts = append(chatParts, map[string]any{
|
||||
"type": dto.ContentTypeInputAudio,
|
||||
"input_audio": responsesPartPayload(part, "input_audio"),
|
||||
})
|
||||
case "input_video":
|
||||
onlyText = false
|
||||
chatParts = append(chatParts, map[string]any{
|
||||
"type": dto.ContentTypeVideoUrl,
|
||||
"video_url": responsesVideoPartToChatVideoURL(part),
|
||||
})
|
||||
default:
|
||||
onlyText = false
|
||||
chatParts = append(chatParts, part)
|
||||
}
|
||||
}
|
||||
|
||||
if onlyText {
|
||||
return textOnly.String(), nil
|
||||
}
|
||||
return chatParts, nil
|
||||
}
|
||||
|
||||
func responsesFunctionCallItemToChatToolCall(item map[string]any) (dto.ToolCallRequest, error) {
|
||||
name := strings.TrimSpace(kitutil.Interface2String(item["name"]))
|
||||
if name == "" {
|
||||
return dto.ToolCallRequest{}, errors.New("function_call item is missing name")
|
||||
}
|
||||
return dto.ToolCallRequest{
|
||||
ID: responsesCallID(item),
|
||||
Type: "function",
|
||||
Function: dto.FunctionRequest{
|
||||
Name: name,
|
||||
Arguments: responsesArgumentsString(item["arguments"]),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func responsesCustomToolCallItemToChatToolCall(item map[string]any) (dto.ToolCallRequest, error) {
|
||||
raw, err := kitutil.Marshal(item)
|
||||
if err != nil {
|
||||
return dto.ToolCallRequest{}, err
|
||||
}
|
||||
return dto.ToolCallRequest{
|
||||
ID: responsesCallID(item),
|
||||
Type: dto.CustomType,
|
||||
Custom: raw,
|
||||
Function: dto.FunctionRequest{
|
||||
Name: strings.TrimSpace(kitutil.Interface2String(item["name"])),
|
||||
Arguments: responsesArgumentsString(item["input"]),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func appendToolCallToLastAssistant(messages []dto.Message, toolCall dto.ToolCallRequest) []dto.Message {
|
||||
if len(messages) == 0 || messages[len(messages)-1].Role != "assistant" {
|
||||
messages = append(messages, dto.Message{Role: "assistant"})
|
||||
}
|
||||
|
||||
idx := len(messages) - 1
|
||||
toolCalls := messages[idx].ParseToolCalls()
|
||||
toolCalls = append(toolCalls, toolCall)
|
||||
toolCallsRaw, _ := kitutil.Marshal(toolCalls)
|
||||
messages[idx].ToolCalls = toolCallsRaw
|
||||
return messages
|
||||
}
|
||||
|
||||
func responsesRequestToolsToChat(raw json.RawMessage) ([]dto.ToolCallRequest, error) {
|
||||
if !rawJSONPresent(raw) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var tools []map[string]any
|
||||
if err := kitutil.Unmarshal(raw, &tools); err != nil {
|
||||
return nil, fmt.Errorf("invalid tools: %w", err)
|
||||
}
|
||||
|
||||
out := make([]dto.ToolCallRequest, 0, len(tools))
|
||||
for _, tool := range tools {
|
||||
toolType := strings.TrimSpace(kitutil.Interface2String(tool["type"]))
|
||||
if toolType == "function" {
|
||||
out = append(out, dto.ToolCallRequest{
|
||||
Type: "function",
|
||||
Function: dto.FunctionRequest{
|
||||
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
|
||||
Description: kitutil.Interface2String(tool["description"]),
|
||||
Parameters: tool["parameters"],
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
rawTool, err := kitutil.Marshal(tool)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, dto.ToolCallRequest{
|
||||
Type: toolType,
|
||||
Custom: rawTool,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func responsesRequestToolChoiceToChat(raw json.RawMessage) (any, error) {
|
||||
if !rawJSONPresent(raw) {
|
||||
return nil, nil
|
||||
}
|
||||
if kitutil.GetJsonType(raw) == "string" {
|
||||
var choice string
|
||||
if err := kitutil.Unmarshal(raw, &choice); err != nil {
|
||||
return nil, fmt.Errorf("invalid tool_choice: %w", err)
|
||||
}
|
||||
return choice, nil
|
||||
}
|
||||
|
||||
var choice map[string]any
|
||||
if err := kitutil.Unmarshal(raw, &choice); err != nil {
|
||||
return nil, fmt.Errorf("invalid tool_choice: %w", err)
|
||||
}
|
||||
if kitutil.Interface2String(choice["type"]) == "function" {
|
||||
name := strings.TrimSpace(kitutil.Interface2String(choice["name"]))
|
||||
if name != "" {
|
||||
return map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": name,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return choice, nil
|
||||
}
|
||||
|
||||
func RequestToolChoiceToChat(raw json.RawMessage) (any, error) {
|
||||
return responsesRequestToolChoiceToChat(raw)
|
||||
}
|
||||
|
||||
func responsesRequestTextToChatResponseFormat(raw json.RawMessage) (*dto.ResponseFormat, error) {
|
||||
if !rawJSONPresent(raw) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var textConfig map[string]any
|
||||
if err := kitutil.Unmarshal(raw, &textConfig); err != nil {
|
||||
return nil, fmt.Errorf("invalid text config: %w", err)
|
||||
}
|
||||
format, ok := textConfig["format"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
formatType := strings.TrimSpace(kitutil.Interface2String(format["type"]))
|
||||
if formatType == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
out := &dto.ResponseFormat{Type: formatType}
|
||||
if formatType == "json_schema" {
|
||||
schemaRaw, err := kitutil.Marshal(format)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.JsonSchema = schemaRaw
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func RequestTextToChatResponseFormat(raw json.RawMessage) (*dto.ResponseFormat, error) {
|
||||
return responsesRequestTextToChatResponseFormat(raw)
|
||||
}
|
||||
|
||||
func responsesImagePartToChatImageURL(part map[string]any) any {
|
||||
if imageURL, ok := part["image_url"]; ok {
|
||||
return imageURL
|
||||
}
|
||||
imageURL := map[string]any{}
|
||||
for _, key := range []string{"url", "file_id", "detail"} {
|
||||
if value, ok := part[key]; ok {
|
||||
imageURL[key] = value
|
||||
}
|
||||
}
|
||||
if len(imageURL) == 0 {
|
||||
return part
|
||||
}
|
||||
return imageURL
|
||||
}
|
||||
|
||||
func responsesFilePartToChatFile(part map[string]any) any {
|
||||
if file, ok := part["file"]; ok {
|
||||
return file
|
||||
}
|
||||
file := map[string]any{}
|
||||
for _, key := range []string{"file_id", "file_data", "filename", "file_url"} {
|
||||
if value, ok := part[key]; ok {
|
||||
file[key] = value
|
||||
}
|
||||
}
|
||||
if len(file) == 0 {
|
||||
return part
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
func responsesVideoPartToChatVideoURL(part map[string]any) any {
|
||||
if videoURL, ok := part["video_url"]; ok {
|
||||
if videoURLMap, ok := videoURL.(map[string]any); ok {
|
||||
if url := kitutil.Interface2String(videoURLMap["url"]); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
return videoURL
|
||||
}
|
||||
if url := kitutil.Interface2String(part["url"]); url != "" {
|
||||
return url
|
||||
}
|
||||
return responsesPartPayload(part, "video_url")
|
||||
}
|
||||
|
||||
func responsesPartPayload(part map[string]any, key string) any {
|
||||
if value, ok := part[key]; ok {
|
||||
return value
|
||||
}
|
||||
payload := make(map[string]any, len(part))
|
||||
for k, value := range part {
|
||||
if k == "type" {
|
||||
continue
|
||||
}
|
||||
payload[k] = value
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func responsesCallID(item map[string]any) string {
|
||||
callID := strings.TrimSpace(kitutil.Interface2String(item["call_id"]))
|
||||
if callID != "" {
|
||||
return callID
|
||||
}
|
||||
return strings.TrimSpace(kitutil.Interface2String(item["id"]))
|
||||
}
|
||||
|
||||
func CallID(item map[string]any) string {
|
||||
return responsesCallID(item)
|
||||
}
|
||||
|
||||
func responsesArgumentsString(value any) string {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return v
|
||||
default:
|
||||
raw, err := kitutil.Marshal(v)
|
||||
if err != nil {
|
||||
return kitutil.Interface2String(v)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
}
|
||||
|
||||
func responseToolOutputToChatContent(value any) any {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return v
|
||||
default:
|
||||
raw, err := kitutil.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
}
|
||||
|
||||
func 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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package kitutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Kit packages log rare data-shape anomalies through these hooks. The host
|
||||
// redirects them into its logging system at startup; standalone relaykit users
|
||||
// get stderr defaults.
|
||||
|
||||
type LogFunc func(message string)
|
||||
|
||||
var (
|
||||
logInfo atomic.Pointer[LogFunc]
|
||||
logError atomic.Pointer[LogFunc]
|
||||
logSystemError atomic.Pointer[LogFunc]
|
||||
)
|
||||
|
||||
func SetLogging(info LogFunc, errorFn LogFunc) {
|
||||
if info != nil {
|
||||
logInfo.Store(&info)
|
||||
}
|
||||
if errorFn != nil {
|
||||
logError.Store(&errorFn)
|
||||
}
|
||||
}
|
||||
|
||||
// SetSystemErrorLogging configures the hook for internal converter failures.
|
||||
func SetSystemErrorLogging(errorFn LogFunc) {
|
||||
if errorFn != nil {
|
||||
logSystemError.Store(&errorFn)
|
||||
}
|
||||
}
|
||||
|
||||
func LogInfo(message string) {
|
||||
if fn := logInfo.Load(); fn != nil {
|
||||
(*fn)(message)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[relaykit] %s\n", message)
|
||||
}
|
||||
|
||||
func LogError(message string) {
|
||||
if fn := logError.Load(); fn != nil {
|
||||
(*fn)(message)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[relaykit] ERROR %s\n", message)
|
||||
}
|
||||
|
||||
// LogSystemError reports an internal converter failure through its dedicated
|
||||
// hook, keeping it distinct from malformed request-data diagnostics.
|
||||
func LogSystemError(message string) {
|
||||
if fn := logSystemError.Load(); fn != nil {
|
||||
(*fn)(message)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[relaykit] SYSTEM ERROR %s\n", message)
|
||||
}
|
||||
|
||||
// Debug reports whether verbose kit diagnostics are enabled. The host sets
|
||||
// this once at startup (new-api mirrors common.DebugEnabled into it).
|
||||
var Debug atomic.Bool
|
||||
@@ -0,0 +1,31 @@
|
||||
package kitutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestErrorHooksRemainDistinct(t *testing.T) {
|
||||
previousError := logError.Load()
|
||||
previousSystemError := logSystemError.Load()
|
||||
t.Cleanup(func() {
|
||||
logError.Store(previousError)
|
||||
logSystemError.Store(previousSystemError)
|
||||
})
|
||||
|
||||
var ordinaryMessages []string
|
||||
var systemMessages []string
|
||||
SetLogging(nil, func(message string) {
|
||||
ordinaryMessages = append(ordinaryMessages, message)
|
||||
})
|
||||
SetSystemErrorLogging(func(message string) {
|
||||
systemMessages = append(systemMessages, message)
|
||||
})
|
||||
|
||||
LogError("invalid dto")
|
||||
LogSystemError("converter failure")
|
||||
|
||||
assert.Equal(t, []string{"invalid dto"}, ordinaryMessages)
|
||||
assert.Equal(t, []string{"converter failure"}, systemMessages)
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package relayconvert
|
||||
|
||||
import relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
|
||||
|
||||
type MediaResolver = relaymedia.MediaResolver
|
||||
|
||||
func SetMediaResolver(resolver MediaResolver) {
|
||||
relaymedia.SetMediaResolver(resolver)
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"model": "gpt-test",
|
||||
"system": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "You are a helpful assistant."
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "What is in this image?"
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": "aGVsbG8="
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "call_abc",
|
||||
"name": "get_weather",
|
||||
"input": {
|
||||
"city": "Paris"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "15 degrees",
|
||||
"tool_use_id": "call_abc"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"max_tokens": 1024,
|
||||
"stream": true,
|
||||
"tools": [
|
||||
{
|
||||
"name": "get_weather",
|
||||
"description": "Get weather by city",
|
||||
"input_schema": {
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"city"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"model": "gpt-test",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You are a helpful assistant."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"text": "What is in this image?",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"image_url": "https://example.com/cat.png",
|
||||
"type": "image_url"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_abc",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": "{\"city\":\"Paris\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "15 degrees",
|
||||
"tool_call_id": "call_abc"
|
||||
}
|
||||
],
|
||||
"stream": true,
|
||||
"max_completion_tokens": 1024,
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"description": "Get weather by city",
|
||||
"name": "get_weather",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"city"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"model": "gpt-test",
|
||||
"system": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "You are a helpful assistant."
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "What is in this image?"
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": "aGVsbG8="
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "..."
|
||||
},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "call_abc",
|
||||
"name": "get_weather",
|
||||
"input": {
|
||||
"city": "Paris"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "15 degrees",
|
||||
"tool_use_id": "call_abc"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Summarize."
|
||||
}
|
||||
],
|
||||
"max_tokens": 1024,
|
||||
"stream": true,
|
||||
"tools": [
|
||||
{
|
||||
"name": "get_weather",
|
||||
"description": "Get weather by city",
|
||||
"input_schema": {
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"city"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tool_choice": {
|
||||
"type": "auto"
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"model": "gpt-test",
|
||||
"input": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"text": "What is in this image?",
|
||||
"type": "input_text"
|
||||
},
|
||||
{
|
||||
"image_url": "https://example.com/cat.png",
|
||||
"type": "input_image"
|
||||
}
|
||||
],
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"content": "",
|
||||
"role": "assistant"
|
||||
},
|
||||
{
|
||||
"arguments": "{\"city\":\"Paris\"}",
|
||||
"call_id": "call_abc",
|
||||
"name": "get_weather",
|
||||
"type": "function_call"
|
||||
},
|
||||
{
|
||||
"call_id": "call_abc",
|
||||
"output": "15 degrees",
|
||||
"type": "function_call_output"
|
||||
},
|
||||
{
|
||||
"content": "Summarize.",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"instructions": "You are a helpful assistant.",
|
||||
"max_output_tokens": 1024,
|
||||
"stream": true,
|
||||
"tool_choice": "auto",
|
||||
"tools": [
|
||||
{
|
||||
"description": "Get weather by city",
|
||||
"name": "get_weather",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"city"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"id": "msg_fixed",
|
||||
"model": "claude-test",
|
||||
"object": "chat.completion",
|
||||
"created": 0,
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "The answer is 42.",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "toolu_abc",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": "{\"city\":\"Paris\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"finish_reason": "tool_calls"
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 15,
|
||||
"completion_tokens": 5,
|
||||
"total_tokens": 20,
|
||||
"usage_semantic": "openai",
|
||||
"usage_source": "anthropic",
|
||||
"billing_usage": {
|
||||
"source": "claude_messages",
|
||||
"semantic": "anthropic",
|
||||
"claude_usage": {
|
||||
"input_tokens": 10,
|
||||
"cache_creation_input_tokens": 2,
|
||||
"cache_read_input_tokens": 3,
|
||||
"output_tokens": 5,
|
||||
"claude_cache_creation_5_m_tokens": 0,
|
||||
"claude_cache_creation_1_h_tokens": 0
|
||||
}
|
||||
},
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 3,
|
||||
"cached_creation_tokens": 2,
|
||||
"cache_write_tokens": 2,
|
||||
"text_tokens": 0,
|
||||
"audio_tokens": 0,
|
||||
"image_tokens": 0
|
||||
},
|
||||
"completion_tokens_details": {
|
||||
"text_tokens": 0,
|
||||
"audio_tokens": 0,
|
||||
"image_tokens": 0,
|
||||
"reasoning_tokens": 0
|
||||
},
|
||||
"input_tokens": 15,
|
||||
"output_tokens": 0,
|
||||
"input_tokens_details": null,
|
||||
"claude_cache_creation_5_m_tokens": 2,
|
||||
"claude_cache_creation_1_h_tokens": 0
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"id": "msg_fixed",
|
||||
"object": "response",
|
||||
"created_at": 0,
|
||||
"status": "completed",
|
||||
"instructions": null,
|
||||
"max_output_tokens": 0,
|
||||
"model": "claude-test",
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_fixed_msg_0",
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "The answer is 42.",
|
||||
"annotations": []
|
||||
}
|
||||
],
|
||||
"quality": "",
|
||||
"size": ""
|
||||
},
|
||||
{
|
||||
"type": "function_call",
|
||||
"id": "toolu_abc",
|
||||
"status": "completed",
|
||||
"role": "",
|
||||
"content": null,
|
||||
"quality": "",
|
||||
"size": "",
|
||||
"call_id": "toolu_abc",
|
||||
"name": "get_weather",
|
||||
"arguments": "{\"city\":\"Paris\"}"
|
||||
}
|
||||
],
|
||||
"parallel_tool_calls": false,
|
||||
"previous_response_id": null,
|
||||
"reasoning": null,
|
||||
"store": false,
|
||||
"temperature": 0,
|
||||
"tool_choice": null,
|
||||
"tools": null,
|
||||
"top_p": 0,
|
||||
"truncation": null,
|
||||
"usage": {
|
||||
"prompt_tokens": 15,
|
||||
"completion_tokens": 5,
|
||||
"total_tokens": 20,
|
||||
"usage_semantic": "openai",
|
||||
"usage_source": "anthropic",
|
||||
"billing_usage": {
|
||||
"source": "claude_messages",
|
||||
"semantic": "anthropic",
|
||||
"claude_usage": {
|
||||
"input_tokens": 10,
|
||||
"cache_creation_input_tokens": 2,
|
||||
"cache_read_input_tokens": 3,
|
||||
"output_tokens": 5,
|
||||
"claude_cache_creation_5_m_tokens": 0,
|
||||
"claude_cache_creation_1_h_tokens": 0
|
||||
}
|
||||
},
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 0,
|
||||
"text_tokens": 0,
|
||||
"audio_tokens": 0,
|
||||
"image_tokens": 0
|
||||
},
|
||||
"completion_tokens_details": {
|
||||
"text_tokens": 0,
|
||||
"audio_tokens": 0,
|
||||
"image_tokens": 0,
|
||||
"reasoning_tokens": 0
|
||||
},
|
||||
"input_tokens": 15,
|
||||
"output_tokens": 5,
|
||||
"input_tokens_details": {
|
||||
"cached_tokens": 3,
|
||||
"cached_creation_tokens": 2,
|
||||
"cache_write_tokens": 2,
|
||||
"text_tokens": 0,
|
||||
"audio_tokens": 0,
|
||||
"image_tokens": 0
|
||||
},
|
||||
"claude_cache_creation_5_m_tokens": 2,
|
||||
"claude_cache_creation_1_h_tokens": 0
|
||||
},
|
||||
"user": null,
|
||||
"metadata": null
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+91
@@ -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
|
||||
}
|
||||
+59
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -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
|
||||
}
|
||||
}
|
||||
+117
@@ -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
|
||||
}
|
||||
}
|
||||
+204
@@ -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
|
||||
}
|
||||
}
|
||||
+226
@@ -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
|
||||
}
|
||||
}
|
||||
+141
@@ -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
Reference in new issue
Block a user