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

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

No files matched your search

+253
View File
@@ -0,0 +1,253 @@
# RelayKit
RelayKit 是从 [new-api](https://github.com/QuantumNous/new-api) 中拆分出的独立 Go 模块,提供常用大模型文本协议的 DTO、请求转换、响应转换和流式事件转换。
它只负责协议层的数据建模与语义转换,不包含 HTTP 服务、上游请求发送、渠道调度、鉴权、计费或数据库逻辑。因此可以脱离 new-api 主模块,嵌入其他 Go 网关或代理服务。
## 能力
- 在 OpenAI Chat Completions、OpenAI Responses、Anthropic Messages 和 Gemini `generateContent` 之间转换
- 同时支持请求、非流式响应和增量流式响应
- 自动根据 DTO 类型识别源协议,并选择内置的直接或多跳转换路径
- 返回转换器 ID、质量等级、实际转换步骤和统一 usage,方便审计与调试
- 支持常用的文本、多模态内容、工具调用、推理内容和 usage 映射
- 作为独立 Go module 构建,不依赖 new-api 主模块、Gin、数据库或全局设置
## 支持矩阵
以下四种文本协议支持任意两种格式之间的转换:
| 源格式 \ 目标格式 | OpenAI Chat | OpenAI Responses | Claude Messages | Gemini |
|---|---:|---:|---:|---:|
| OpenAI Chat | — | Good | Fair | Fair |
| OpenAI Responses | Good | — | Fair | Fair |
| Claude Messages | Fair | Fair | — | Discouraged |
| Gemini | Fair | Fair | Discouraged | — |
质量等级表示协议之间的语义匹配程度:
- `Good`:两种协议的核心结构较接近
- `Fair`:主要能力可转换,但部分协议特性可能需要适配或无法完整保留
- `Discouraged`:目前需要经过中间协议转换,语义损失风险更高
请求、非流式响应和流式响应均覆盖上述矩阵。实际采用的路径可从转换结果的 `Steps` 和 `Quality` 字段中读取。
## 安装
RelayKit 要求 Go 1.25.1 或更高版本。
```bash
go get github.com/QuantumNous/new-api/relaykit@latest
```
主要包:
| 包 | 用途 |
|---|---|
| `relaykit/dto` | 各协议的请求、响应、流式事件和 usage DTO |
| `relaykit/types` | 协议格式、错误、文件来源及共享类型 |
| `relaykit/relayconvert` | 请求、响应和流式转换入口 |
| `relaykit/relayconvert/convmeta` | 与宿主实现解耦的转换上下文和选项 |
| `relaykit/reasonmap` | 不同协议之间的结束原因映射 |
## 快速开始
下面将 OpenAI Chat Completions 请求转换为 Claude Messages 请求:
```go
package main
import (
"context"
"fmt"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/QuantumNous/new-api/relaykit/types"
)
func main() {
maxTokens := uint(1024)
request := &dto.GeneralOpenAIRequest{
Model: "claude-sonnet-4-5",
Messages: []dto.Message{
{Role: "user", Content: "Hello!"},
},
MaxTokens: &maxTokens,
}
meta := &convmeta.Values{
OriginModelName: "client-model",
UpstreamModelName: request.Model,
ChannelMetaAttached: true,
}
result, err := relayconvert.ConvertRequest(
context.Background(),
meta,
types.RelayFormatClaude,
request,
)
if err != nil {
panic(err)
}
claudeRequest, ok := result.Value.(*dto.ClaudeRequest)
if !ok {
panic(fmt.Sprintf("unexpected result type %T", result.Value))
}
fmt.Printf("model=%s messages=%d\n", claudeRequest.Model, len(claudeRequest.Messages))
}
```
`ConvertRequest` 根据请求的具体 DTO 类型推断源格式。传入原始 JSON、`map[string]any` 或不受支持的 DTO 会返回错误。
### 非流式响应
响应转换使用相同的目标格式模型:
```go
result, err := relayconvert.ConvertResponse(
ctx,
meta,
types.RelayFormatOpenAI,
claudeResponse,
)
if err != nil {
return err
}
openAIResponse := result.Value.(*dto.OpenAITextResponse)
usage := result.Usage
```
支持的响应 DTO:
| 格式 | 非流式响应 | 流式事件 |
|---|---|---|
| OpenAI Chat | `dto.OpenAITextResponse` | `dto.ChatCompletionsStreamResponse` |
| OpenAI Responses | `dto.OpenAIResponsesResponse` | `dto.ResponsesStreamResponse` |
| Claude Messages | `dto.ClaudeResponse` | `dto.ClaudeResponse` |
| Gemini | `dto.GeminiChatResponse` | `dto.GeminiChatResponse` |
### 流式响应
流式转换可能需要跨事件保存工具调用、usage 和结束状态。每条上游流应创建独立的 `ResponseStreamState`,并在上游结束后调用 `FinalizeStreamResponse`:
```go
state, err := relayconvert.NewResponseStreamState(
types.RelayFormatOpenAI,
types.RelayFormatOpenAIResponses,
relayconvert.ResponseStreamOptions{
ID: "resp_123",
Model: "gpt-4.1",
IncludeUsage: true,
},
)
if err != nil {
return err
}
for _, chunk := range upstreamChunks {
results, err := relayconvert.ConvertStreamResponseChunk(ctx, meta, state, chunk)
if err != nil {
return err
}
for _, result := range results {
emit(result.Value)
}
}
finalResults, err := relayconvert.FinalizeStreamResponse(ctx, meta, state)
if err != nil {
return err
}
for _, result := range finalResults {
emit(result.Value)
}
usage := state.Usage()
```
RelayKit 不负责 SSE 的读取和写入。宿主需要将每个 SSE 事件解析为对应 DTO,并将转换结果重新编码后发送给下游。不要省略 `FinalizeStreamResponse`,部分转换器会在该阶段补发终止事件或最终 usage。
## 转换上下文
大多数基础转换可以传入 `nil` 作为 `convmeta.Meta`。需要模型映射、推理适配、安全设置或流式状态时,应使用 `convmeta.Values`,或在宿主中实现 `convmeta.Meta`。
常用选项通过 `convmeta.Options` 按请求传入:
```go
meta := &convmeta.Values{
Options: &convmeta.Options{
Claude: convmeta.ClaudeOptions{
DefaultMaxTokens: func(model string) int {
return 4096
},
},
Gemini: convmeta.GeminiOptions{
ThinkingAdapterEnabled: true,
},
},
}
```
需要注意:
- OpenAI Chat 或 OpenAI Responses 转 Claude 时,Claude 请求必须具有 `max_tokens`。源请求未提供时,需要配置 `Claude.DefaultMaxTokens`,否则转换会返回错误。
- RelayKit 不负责选择渠道或映射模型名。调用转换前,应将请求中的 `Model` 设置为目标上游使用的模型名。
- 自定义 `convmeta.Meta` 的指针实现必须保证所有方法对 nil receiver 安全,完整约束见 `convmeta.Meta` 的接口注释。
- 工具损耗策略默认是 `allow`:跨协议转换会成功,损耗以诊断形式返回。`safe` / `strict` 只在请求阶段 opt-in 拒绝;响应和流式转换无论策略如何都不会因损耗失败。
- `ThinkingAdapterEnabled` 只控制是否把已解析的推理意图渲染到 Claude / Gemini 请求上。`-thinking` / `-nothinking` / effort 尾缀等命名约定不再由转换器自动解释。
- 若你的入口仍使用这些模型名后缀,请在调用转换前自行调用 `relayconvert/reasoning` 的 `Parse*` 帮助函数,把结果写成 `dto.ReasoningConversionState`,并通过 `convmeta.Meta.ReasoningState()`(`convmeta.Values.ReasoningConversion`)传入。同时把发给上游的模型名裁成无后缀基础名。
## 多模态内容
某些跨协议的图片转换需要下载 URL 内容或解析 data URL。宿主应在启动时配置媒体解析器:
```go
relayconvert.SetMediaResolver(relayconvert.MediaResolver{
GetBase64Data: getBase64Data,
DecodeBase64FileData: decodeBase64FileData,
})
```
两个回调的签名由 `relayconvert.MediaResolver` 定义。需要媒体解析而未配置对应回调时,转换会明确返回错误;RelayKit 本身不会发起网络请求。
## 转换结果
请求转换返回 `relayconvert.RequestResult`,响应转换返回 `relayconvert.ResponseResult`。除 `Value` 外,建议关注:
- `From` / `To`:源格式和目标格式
- `Converter`:所选转换器 ID
- `Quality`:转换质量等级
- `Steps`:直接转换或多跳转换的实际路径
- `Usage`:响应转换后的统一 token usage
- `Stream`:结果是否来自流式转换
如果需要固定转换路径,可使用 `ConvertRequestVia`;如果需要按转换器 ID 执行,可使用 `ConvertRequestByID`、`ConvertResponseByID` 和 `NewResponseStreamStateByID`。
## 开发
RelayKit 必须始终保持独立可构建。修改模块后,在 `relaykit` 目录运行:
```bash
GOWORK=off go test ./...
GOWORK=off go build ./...
```
转换矩阵由 golden tests 覆盖。确认协议输出变化是预期行为后,可更新快照:
```bash
GOWORK=off go test ./relayconvert -run TestGolden -update
```
## 版本与兼容性
RelayKit 当前使用 `v0.x` 版本。公开 API 和 DTO 仍可能在小版本中调整,升级前请检查发布说明和实际序列化结果。协议之间并非完全同构,建议对业务实际使用的工具调用、多模态、推理和流式场景增加端到端测试。
## 许可证
RelayKit 是 new-api 项目的一部分,遵循项目根目录中的 [GNU Affero General Public License v3.0](../LICENSE)。
+38
View File
@@ -0,0 +1,38 @@
package dto
import (
"encoding/json"
"net/http"
"github.com/QuantumNous/new-api/relaykit/types"
)
// AlphaSearchRequest is the Codex standalone web search request.
// RawBody preserves the original JSON so unknown fields are forwarded intact.
type AlphaSearchRequest struct {
Model string `json:"model"`
Id string `json:"id,omitempty"`
Stream *bool `json:"stream,omitempty"`
RawBody json.RawMessage `json:"-"`
}
func (r *AlphaSearchRequest) GetTokenCountMeta() *types.TokenCountMeta {
combineText := ""
if len(r.RawBody) > 0 {
combineText = string(r.RawBody)
}
return &types.TokenCountMeta{
CombineText: combineText,
TokenType: types.TokenTypeTokenizer,
}
}
func (r *AlphaSearchRequest) IsStream(_ *http.Request) bool {
return false
}
func (r *AlphaSearchRequest) SetModelName(modelName string) {
if modelName != "" {
r.Model = modelName
}
}
+76
View File
@@ -0,0 +1,76 @@
package dto
import (
"encoding/json"
"net/http"
"strings"
"github.com/QuantumNous/new-api/relaykit/types"
)
type AudioRequest struct {
Model string `json:"model"`
Input string `json:"input"`
Voice string `json:"voice"`
Instructions string `json:"instructions,omitempty"`
ResponseFormat string `json:"response_format,omitempty"`
Speed *float64 `json:"speed,omitempty"`
StreamFormat string `json:"stream_format,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
// vllm-omini
TaskType json.RawMessage `json:"task_type,omitempty"`
Language json.RawMessage `json:"language,omitempty"`
RefAudio json.RawMessage `json:"ref_audio,omitempty"`
RefText json.RawMessage `json:"ref_text,omitempty"`
XVectorOnlyMode json.RawMessage `json:"x_vector_only_mode,omitempty"`
MaxNewTokens json.RawMessage `json:"max_new_tokens,omitempty"`
InitialCodecChunkFrames json.RawMessage `json:"initial_codec_chunk_frames,omitempty"`
// TODO:ensure that the logic remains correct after the stream is started.
//Stream json.RawMessage `json:"stream,omitempty"`
}
func (r *AudioRequest) GetTokenCountMeta() *types.TokenCountMeta {
meta := &types.TokenCountMeta{
CombineText: r.Input,
TokenType: types.TokenTypeTextNumber,
}
if strings.Contains(r.Model, "gpt") {
meta.TokenType = types.TokenTypeTokenizer
}
return meta
}
func (r *AudioRequest) IsStream(c *http.Request) bool {
return r.StreamFormat == "sse"
}
func (r *AudioRequest) SetModelName(modelName string) {
if modelName != "" {
r.Model = modelName
}
}
type AudioResponse struct {
Text string `json:"text"`
}
type WhisperVerboseJSONResponse struct {
Task string `json:"task,omitempty"`
Language string `json:"language,omitempty"`
Duration float64 `json:"duration,omitempty"`
Text string `json:"text,omitempty"`
Segments []Segment `json:"segments,omitempty"`
}
type Segment struct {
Id int `json:"id"`
Seek int `json:"seek"`
Start float64 `json:"start"`
End float64 `json:"end"`
Text string `json:"text"`
Tokens []int `json:"tokens"`
Temperature float64 `json:"temperature"`
AvgLogprob float64 `json:"avg_logprob"`
CompressionRatio float64 `json:"compression_ratio"`
NoSpeechProb float64 `json:"no_speech_prob"`
}
+448
View File
@@ -0,0 +1,448 @@
package dto
import "strings"
const (
BillingUsageSourceClaudeMessages = "claude_messages"
BillingUsageSourceGeminiChat = "gemini_chat"
BillingUsageSourceOAIChat = "oai_chat"
BillingUsageSourceOAIResponses = "oai_responses"
BillingUsageSemanticAnthropic = "anthropic"
BillingUsageSemanticGemini = "gemini"
BillingUsageSemanticOpenAI = "openai"
)
type BillingUsage struct {
Source string `json:"source,omitempty"`
Semantic string `json:"semantic,omitempty"`
Estimated bool `json:"estimated,omitempty"`
OpenAIUsage *Usage `json:"openai_usage,omitempty"`
ClaudeUsage *ClaudeUsage `json:"claude_usage,omitempty"`
GeminiUsageMetadata *GeminiUsageMetadata `json:"gemini_usage_metadata,omitempty"`
}
func NewClaudeMessagesBillingUsage(usage *ClaudeUsage) *BillingUsage {
if !HasClaudeUsageTokens(usage) {
return nil
}
return &BillingUsage{
Source: BillingUsageSourceClaudeMessages,
Semantic: BillingUsageSemanticAnthropic,
ClaudeUsage: cloneClaudeUsage(usage),
}
}
// HasClaudeUsageTokens mirrors HasOpenAIUsageTokens/HasGeminiUsageMetadataTokens:
// an all-zero ClaudeUsage must not become a BillingUsage, otherwise it would take
// precedence during settlement and zero out a non-zero top-level usage.
func HasClaudeUsageTokens(usage *ClaudeUsage) bool {
if usage == nil {
return false
}
if usage.InputTokens != 0 ||
usage.OutputTokens != 0 ||
usage.CacheCreationInputTokens != 0 ||
usage.CacheReadInputTokens != 0 ||
usage.ClaudeCacheCreation5mTokens != 0 ||
usage.ClaudeCacheCreation1hTokens != 0 {
return true
}
if usage.CacheCreation != nil &&
(usage.CacheCreation.Ephemeral5mInputTokens != 0 || usage.CacheCreation.Ephemeral1hInputTokens != 0) {
return true
}
return false
}
func NewOpenAIChatBillingUsage(usage *Usage) *BillingUsage {
return newOpenAIBillingUsage(BillingUsageSourceOAIChat, usage)
}
func NewOpenAIResponsesBillingUsage(usage *Usage) *BillingUsage {
return newOpenAIBillingUsage(BillingUsageSourceOAIResponses, usage)
}
func newOpenAIBillingUsage(source string, usage *Usage) *BillingUsage {
if !HasOpenAIUsageTokens(usage) {
return nil
}
return &BillingUsage{
Source: source,
Semantic: BillingUsageSemanticOpenAI,
OpenAIUsage: cloneOpenAIUsage(usage),
}
}
func HasOpenAIUsageTokens(usage *Usage) bool {
if usage == nil {
return false
}
if usage.PromptTokens != 0 ||
usage.CompletionTokens != 0 ||
usage.TotalTokens != 0 ||
usage.InputTokens != 0 ||
usage.OutputTokens != 0 ||
usage.PromptCacheHitTokens != 0 ||
usage.ClaudeCacheCreation5mTokens != 0 ||
usage.ClaudeCacheCreation1hTokens != 0 {
return true
}
if usage.PromptTokensDetails.CachedTokens != 0 ||
usage.PromptTokensDetails.CachedCreationTokens != 0 ||
usage.PromptTokensDetails.CacheWriteTokens != 0 ||
usage.PromptTokensDetails.TextTokens != 0 ||
usage.PromptTokensDetails.ImageTokens != 0 ||
usage.PromptTokensDetails.AudioTokens != 0 {
return true
}
if usage.CompletionTokenDetails.ReasoningTokens != 0 ||
usage.CompletionTokenDetails.TextTokens != 0 ||
usage.CompletionTokenDetails.ImageTokens != 0 ||
usage.CompletionTokenDetails.AudioTokens != 0 {
return true
}
if usage.InputTokensDetails == nil {
return false
}
return usage.InputTokensDetails.CachedTokens != 0 ||
usage.InputTokensDetails.CachedCreationTokens != 0 ||
usage.InputTokensDetails.CacheWriteTokens != 0 ||
usage.InputTokensDetails.TextTokens != 0 ||
usage.InputTokensDetails.ImageTokens != 0 ||
usage.InputTokensDetails.AudioTokens != 0
}
func NewGeminiChatBillingUsage(metadata *GeminiUsageMetadata) *BillingUsage {
return newGeminiChatBillingUsage(metadata, false)
}
func NewEstimatedGeminiChatBillingUsage(usage *Usage) *BillingUsage {
if usage == nil {
return nil
}
reasoningTokens := usage.CompletionTokenDetails.ReasoningTokens
candidateTokens := max(usage.CompletionTokens-reasoningTokens, 0)
totalTokens := usage.TotalTokens
if totalTokens == 0 {
totalTokens = usage.PromptTokens + usage.CompletionTokens
}
metadata := &GeminiUsageMetadata{
PromptTokenCount: usage.PromptTokens,
CandidatesTokenCount: candidateTokens,
TotalTokenCount: totalTokens,
ThoughtsTokenCount: reasoningTokens,
CachedContentTokenCount: usage.PromptTokensDetails.CachedTokens,
}
for _, detail := range []GeminiPromptTokensDetails{
{Modality: "TEXT", TokenCount: usage.PromptTokensDetails.TextTokens},
{Modality: "IMAGE", TokenCount: usage.PromptTokensDetails.ImageTokens},
{Modality: "AUDIO", TokenCount: usage.PromptTokensDetails.AudioTokens},
} {
if detail.TokenCount != 0 {
metadata.PromptTokensDetails = append(metadata.PromptTokensDetails, detail)
}
}
for _, detail := range []GeminiPromptTokensDetails{
{Modality: "TEXT", TokenCount: usage.CompletionTokenDetails.TextTokens},
{Modality: "IMAGE", TokenCount: usage.CompletionTokenDetails.ImageTokens},
{Modality: "AUDIO", TokenCount: usage.CompletionTokenDetails.AudioTokens},
} {
if detail.TokenCount != 0 {
metadata.CandidatesTokensDetails = append(metadata.CandidatesTokensDetails, detail)
}
}
return newGeminiChatBillingUsage(metadata, true)
}
// CloneBillingUsageWithEstimatedCompletion preserves the original upstream
// billing dialect and fills a missing completion count without rebuilding the
// payload from a converted, potentially lossy Usage value.
func CloneBillingUsageWithEstimatedCompletion(usage *BillingUsage, completionTokens int) *BillingUsage {
clone := CloneBillingUsage(usage)
if clone == nil || completionTokens <= 0 {
return clone
}
updated := false
switch {
case clone.OpenAIUsage != nil:
openAIUsage := clone.OpenAIUsage
if openAIUsage.CompletionTokens == 0 && openAIUsage.OutputTokens == 0 {
openAIUsage.CompletionTokens = completionTokens
openAIUsage.OutputTokens = completionTokens
inputTokens := openAIUsage.PromptTokens
if inputTokens == 0 {
inputTokens = openAIUsage.InputTokens
}
if totalTokens := inputTokens + completionTokens; openAIUsage.TotalTokens < totalTokens {
openAIUsage.TotalTokens = totalTokens
}
updated = true
}
case clone.ClaudeUsage != nil:
if clone.ClaudeUsage.OutputTokens == 0 {
clone.ClaudeUsage.OutputTokens = completionTokens
updated = true
}
case clone.GeminiUsageMetadata != nil:
metadata := clone.GeminiUsageMetadata
if metadata.CandidatesTokenCount == 0 {
candidateTokens := max(completionTokens-metadata.ThoughtsTokenCount, 0)
metadata.CandidatesTokenCount = candidateTokens
totalTokens := metadata.PromptTokenCount + metadata.ToolUsePromptTokenCount + metadata.CandidatesTokenCount + metadata.ThoughtsTokenCount
if metadata.TotalTokenCount < totalTokens {
metadata.TotalTokenCount = totalTokens
}
updated = true
}
}
if updated {
clone.Estimated = true
}
return clone
}
func newGeminiChatBillingUsage(metadata *GeminiUsageMetadata, estimated bool) *BillingUsage {
if !HasGeminiUsageMetadataTokens(metadata) {
return nil
}
usageMetadata := cloneGeminiUsageMetadata(*metadata)
return &BillingUsage{
Source: BillingUsageSourceGeminiChat,
Semantic: BillingUsageSemanticGemini,
Estimated: estimated,
GeminiUsageMetadata: &usageMetadata,
}
}
func CloneBillingUsage(usage *BillingUsage) *BillingUsage {
if usage == nil {
return nil
}
clone := *usage
clone.OpenAIUsage = cloneOpenAIUsage(usage.OpenAIUsage)
clone.ClaudeUsage = cloneClaudeUsage(usage.ClaudeUsage)
if usage.GeminiUsageMetadata != nil {
metadata := cloneGeminiUsageMetadata(*usage.GeminiUsageMetadata)
clone.GeminiUsageMetadata = &metadata
}
return &clone
}
// CanonicalUsage decodes the original provider usage carried across relay
// hops into the shared accounting shape. The BillingUsage snapshot remains the
// source of truth and is cloned onto the returned value for further relays.
func (usage *BillingUsage) CanonicalUsage() (*Usage, bool) {
if usage == nil {
return nil, false
}
source := strings.TrimSpace(usage.Source)
semantic := strings.TrimSpace(usage.Semantic)
// A structurally recognized but all-zero payload must not become the
// settlement source of truth; rejecting it lets settlement fall back to a
// non-zero top-level usage.
if HasOpenAIUsageTokens(usage.OpenAIUsage) &&
(strings.EqualFold(source, BillingUsageSourceOAIChat) ||
strings.EqualFold(source, BillingUsageSourceOAIResponses) ||
strings.EqualFold(semantic, BillingUsageSemanticOpenAI)) {
return usage.canonicalOpenAIUsage(), true
}
if HasClaudeUsageTokens(usage.ClaudeUsage) &&
(strings.EqualFold(source, BillingUsageSourceClaudeMessages) ||
strings.EqualFold(semantic, BillingUsageSemanticAnthropic)) {
return usage.canonicalClaudeUsage(), true
}
if HasGeminiUsageMetadataTokens(usage.GeminiUsageMetadata) &&
(strings.EqualFold(source, BillingUsageSourceGeminiChat) ||
strings.EqualFold(semantic, BillingUsageSemanticGemini)) {
return usage.canonicalGeminiUsage(), true
}
return nil, false
}
func (usage *BillingUsage) canonicalOpenAIUsage() *Usage {
canonical := cloneOpenAIUsage(usage.OpenAIUsage)
if canonical.InputTokensDetails != nil {
// InputTokensDetails fills fields that PromptTokensDetails omitted;
// existing PromptTokensDetails values stay canonical on overlap.
filled := *canonical.InputTokensDetails
mergeInputTokenDetails(&filled, canonical.PromptTokensDetails)
canonical.PromptTokensDetails = filled
}
if canonical.PromptTokensDetails.CachedTokens == 0 && canonical.PromptCacheHitTokens > 0 {
canonical.PromptTokensDetails.CachedTokens = canonical.PromptCacheHitTokens
}
if canonical.PromptTokens == 0 && canonical.InputTokens > 0 {
canonical.PromptTokens = canonical.InputTokens
}
if canonical.CompletionTokens == 0 && canonical.OutputTokens > 0 {
canonical.CompletionTokens = canonical.OutputTokens
}
if canonical.InputTokens == 0 && canonical.PromptTokens > 0 {
canonical.InputTokens = canonical.PromptTokens
}
if canonical.OutputTokens == 0 && canonical.CompletionTokens > 0 {
canonical.OutputTokens = canonical.CompletionTokens
}
if canonical.TotalTokens == 0 {
canonical.TotalTokens = canonical.PromptTokens + canonical.CompletionTokens
}
canonical.UsageSemantic = BillingUsageSemanticOpenAI
canonical.UsageSource = usage.Source
canonical.BillingUsage = CloneBillingUsage(usage)
return canonical
}
func (usage *BillingUsage) canonicalClaudeUsage() *Usage {
claudeUsage := usage.ClaudeUsage
// Flat legacy fields are a fallback only when this snapshot never carried
// a CacheCreation sub-object. Presence (non-nil), not zero vs non-zero,
// is the discriminator — a later sub-object that zeros 1h must win.
var cacheCreation5m, cacheCreation1h int
if claudeUsage.CacheCreation != nil {
cacheCreation5m = claudeUsage.GetCacheCreation5mTokens()
cacheCreation1h = claudeUsage.GetCacheCreation1hTokens()
} else {
cacheCreation5m = claudeUsage.ClaudeCacheCreation5mTokens
cacheCreation1h = claudeUsage.ClaudeCacheCreation1hTokens
}
canonical := &Usage{
PromptTokens: claudeUsage.InputTokens,
CompletionTokens: claudeUsage.OutputTokens,
TotalTokens: claudeUsage.InputTokens + claudeUsage.OutputTokens,
InputTokens: claudeUsage.InputTokens + claudeUsage.CacheReadInputTokens + claudeUsage.CacheCreationInputTokens,
OutputTokens: claudeUsage.OutputTokens,
UsageSemantic: BillingUsageSemanticAnthropic,
UsageSource: BillingUsageSourceClaudeMessages,
BillingUsage: CloneBillingUsage(usage),
ClaudeCacheCreation5mTokens: cacheCreation5m,
ClaudeCacheCreation1hTokens: cacheCreation1h,
}
canonical.PromptTokensDetails.CachedTokens = claudeUsage.CacheReadInputTokens
canonical.PromptTokensDetails.CachedCreationTokens = claudeUsage.CacheCreationInputTokens
return canonical
}
func (usage *BillingUsage) canonicalGeminiUsage() *Usage {
metadata := usage.GeminiUsageMetadata
promptTokens := metadata.PromptTokenCount + metadata.ToolUsePromptTokenCount
canonical := &Usage{
PromptTokens: promptTokens,
CompletionTokens: metadata.CandidatesTokenCount + metadata.ThoughtsTokenCount,
TotalTokens: metadata.TotalTokenCount,
UsageSemantic: BillingUsageSemanticGemini,
UsageSource: BillingUsageSourceGeminiChat,
BillingUsage: CloneBillingUsage(usage),
}
canonical.CompletionTokenDetails.ReasoningTokens = metadata.ThoughtsTokenCount
canonical.PromptTokensDetails.CachedTokens = metadata.CachedContentTokenCount
for _, detail := range metadata.PromptTokensDetails {
addGeminiInputTokenDetail(&canonical.PromptTokensDetails, detail)
}
for _, detail := range metadata.ToolUsePromptTokensDetails {
addGeminiInputTokenDetail(&canonical.PromptTokensDetails, detail)
}
for _, detail := range metadata.CandidatesTokensDetails {
switch normalizeGeminiModality(detail.Modality) {
case "IMAGE":
canonical.CompletionTokenDetails.ImageTokens += detail.TokenCount
case "AUDIO":
canonical.CompletionTokenDetails.AudioTokens += detail.TokenCount
case "TEXT":
canonical.CompletionTokenDetails.TextTokens += detail.TokenCount
}
}
if canonical.TotalTokens == 0 {
canonical.TotalTokens = canonical.PromptTokens + canonical.CompletionTokens
} else if canonical.CompletionTokens <= 0 {
canonical.CompletionTokens = max(canonical.TotalTokens-canonical.PromptTokens, 0)
}
if canonical.PromptTokens > 0 && canonical.PromptTokensDetails.TextTokens == 0 && canonical.PromptTokensDetails.AudioTokens == 0 {
canonical.PromptTokensDetails.TextTokens = canonical.PromptTokens
}
return canonical
}
func addGeminiInputTokenDetail(details *InputTokenDetails, detail GeminiPromptTokensDetails) {
switch normalizeGeminiModality(detail.Modality) {
case "AUDIO":
details.AudioTokens += detail.TokenCount
case "IMAGE":
details.ImageTokens += detail.TokenCount
case "TEXT":
details.TextTokens += detail.TokenCount
}
}
func cloneOpenAIUsage(usage *Usage) *Usage {
if usage == nil {
return nil
}
clone := *usage
clone.BillingUsage = nil
if usage.InputTokensDetails != nil {
inputTokensDetails := *usage.InputTokensDetails
clone.InputTokensDetails = &inputTokensDetails
}
return &clone
}
func cloneClaudeUsage(usage *ClaudeUsage) *ClaudeUsage {
if usage == nil {
return nil
}
clone := *usage
clone.BillingUsage = nil
if usage.CacheCreation != nil {
cacheCreation := *usage.CacheCreation
clone.CacheCreation = &cacheCreation
}
if usage.ServerToolUse != nil {
serverToolUse := *usage.ServerToolUse
clone.ServerToolUse = &serverToolUse
}
return &clone
}
func cloneGeminiUsageMetadata(metadata GeminiUsageMetadata) GeminiUsageMetadata {
metadata.PromptTokensDetails = append([]GeminiPromptTokensDetails{}, metadata.PromptTokensDetails...)
metadata.ToolUsePromptTokensDetails = append([]GeminiPromptTokensDetails{}, metadata.ToolUsePromptTokensDetails...)
metadata.CandidatesTokensDetails = append([]GeminiPromptTokensDetails{}, metadata.CandidatesTokensDetails...)
metadata.BillingUsage = nil
return metadata
}
func HasGeminiUsageMetadataTokens(metadata *GeminiUsageMetadata) bool {
if metadata == nil {
return false
}
if metadata.PromptTokenCount != 0 ||
metadata.ToolUsePromptTokenCount != 0 ||
metadata.CandidatesTokenCount != 0 ||
metadata.TotalTokenCount != 0 ||
metadata.ThoughtsTokenCount != 0 ||
metadata.CachedContentTokenCount != 0 {
return true
}
for _, detail := range metadata.PromptTokensDetails {
if detail.TokenCount != 0 {
return true
}
}
for _, detail := range metadata.ToolUsePromptTokensDetails {
if detail.TokenCount != 0 {
return true
}
}
for _, detail := range metadata.CandidatesTokensDetails {
if detail.TokenCount != 0 {
return true
}
}
return false
}
+121
View File
@@ -0,0 +1,121 @@
package dto
import (
"testing"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewGeminiChatBillingUsageRequiresTokenContent(t *testing.T) {
require.Nil(t, NewGeminiChatBillingUsage(nil))
require.Nil(t, NewGeminiChatBillingUsage(&GeminiUsageMetadata{}))
billingUsage := NewGeminiChatBillingUsage(&GeminiUsageMetadata{PromptTokenCount: 1})
require.NotNil(t, billingUsage)
require.NotNil(t, billingUsage.GeminiUsageMetadata)
assert.Equal(t, BillingUsageSourceGeminiChat, billingUsage.Source)
assert.Equal(t, BillingUsageSemanticGemini, billingUsage.Semantic)
assert.False(t, billingUsage.Estimated)
}
func TestNewClaudeMessagesBillingUsageRequiresTokenContent(t *testing.T) {
require.Nil(t, NewClaudeMessagesBillingUsage(nil))
require.Nil(t, NewClaudeMessagesBillingUsage(&ClaudeUsage{}))
require.Nil(t, NewClaudeMessagesBillingUsage(&ClaudeUsage{CacheCreation: &ClaudeCacheCreationUsage{}}))
billingUsage := NewClaudeMessagesBillingUsage(&ClaudeUsage{InputTokens: 1})
require.NotNil(t, billingUsage)
require.NotNil(t, billingUsage.ClaudeUsage)
assert.Equal(t, BillingUsageSourceClaudeMessages, billingUsage.Source)
assert.Equal(t, BillingUsageSemanticAnthropic, billingUsage.Semantic)
cacheOnly := NewClaudeMessagesBillingUsage(&ClaudeUsage{
CacheCreation: &ClaudeCacheCreationUsage{Ephemeral5mInputTokens: 4},
})
require.NotNil(t, cacheOnly)
}
func TestNewOpenAIChatBillingUsageRequiresTokenContent(t *testing.T) {
require.Nil(t, NewOpenAIChatBillingUsage(nil))
require.Nil(t, NewOpenAIChatBillingUsage(&Usage{}))
billingUsage := NewOpenAIChatBillingUsage(&Usage{PromptTokens: 1})
require.NotNil(t, billingUsage)
require.NotNil(t, billingUsage.OpenAIUsage)
assert.Equal(t, BillingUsageSourceOAIChat, billingUsage.Source)
assert.Equal(t, BillingUsageSemanticOpenAI, billingUsage.Semantic)
assert.Equal(t, 1, billingUsage.OpenAIUsage.PromptTokens)
}
func TestNewEstimatedGeminiChatBillingUsage(t *testing.T) {
billingUsage := NewEstimatedGeminiChatBillingUsage(&Usage{
PromptTokens: 11,
CompletionTokens: 7,
})
require.NotNil(t, billingUsage)
require.NotNil(t, billingUsage.GeminiUsageMetadata)
assert.True(t, billingUsage.Estimated)
assert.Equal(t, 11, billingUsage.GeminiUsageMetadata.PromptTokenCount)
assert.Equal(t, 7, billingUsage.GeminiUsageMetadata.CandidatesTokenCount)
assert.Equal(t, 18, billingUsage.GeminiUsageMetadata.TotalTokenCount)
}
func TestCanonicalGeminiUsageClampsNegativeCompletionFromTotalMinusPrompt(t *testing.T) {
usage, ok := NewGeminiChatBillingUsage(&GeminiUsageMetadata{
PromptTokenCount: 50,
TotalTokenCount: 30,
}).CanonicalUsage()
require.True(t, ok)
assert.Equal(t, 0, usage.CompletionTokens)
}
func TestCanonicalOpenAIUsageMergesInputTokenDetailsFieldwise(t *testing.T) {
usage, ok := NewOpenAIResponsesBillingUsage(&Usage{
PromptTokens: 10,
PromptTokensDetails: InputTokenDetails{
CachedTokens: 8,
TextTokens: 12,
ImageTokens: 4,
AudioTokens: 3,
},
InputTokensDetails: &InputTokenDetails{
CachedTokens: 5,
CachedCreationTokens: 7,
TextTokens: 2,
},
}).CanonicalUsage()
require.True(t, ok)
assert.Equal(t, 8, usage.PromptTokensDetails.CachedTokens)
assert.Equal(t, 12, usage.PromptTokensDetails.TextTokens)
assert.Equal(t, 4, usage.PromptTokensDetails.ImageTokens)
assert.Equal(t, 3, usage.PromptTokensDetails.AudioTokens)
assert.Equal(t, 7, usage.PromptTokensDetails.CachedCreationTokens)
}
func TestBillingUsageJSONUsesProtocolNamedFields(t *testing.T) {
billingUsage := &BillingUsage{
OpenAIUsage: &Usage{PromptTokens: 1, BillingUsage: NewClaudeMessagesBillingUsage(&ClaudeUsage{InputTokens: 9})},
ClaudeUsage: &ClaudeUsage{InputTokens: 2, BillingUsage: NewOpenAIChatBillingUsage(&Usage{PromptTokens: 8})},
GeminiUsageMetadata: &GeminiUsageMetadata{PromptTokenCount: 3, BillingUsage: NewOpenAIChatBillingUsage(&Usage{PromptTokens: 7})},
}
data, err := kitutil.Marshal(billingUsage)
require.NoError(t, err)
assert.Contains(t, string(data), `"openai_usage"`)
assert.Contains(t, string(data), `"claude_usage"`)
assert.Contains(t, string(data), `"gemini_usage_metadata"`)
assert.NotContains(t, string(data), `"usage":`)
assert.NotContains(t, string(data), `"usage_metadata"`)
clone := CloneBillingUsage(billingUsage)
require.NotNil(t, clone.OpenAIUsage)
require.NotNil(t, clone.ClaudeUsage)
require.NotNil(t, clone.GeminiUsageMetadata)
assert.Nil(t, clone.OpenAIUsage.BillingUsage)
assert.Nil(t, clone.ClaudeUsage.BillingUsage)
assert.Nil(t, clone.GeminiUsageMetadata.BillingUsage)
}
+613
View File
@@ -0,0 +1,613 @@
package dto
import (
"fmt"
"net/url"
"regexp"
"strings"
"sync"
"github.com/QuantumNous/new-api/relaykit/types"
)
type ChannelSettings struct {
TaskPluginKey string `json:"task_plugin_key,omitempty"`
ForceFormat bool `json:"force_format,omitempty"`
ThinkingToContent bool `json:"thinking_to_content,omitempty"`
Proxy string `json:"proxy"`
PassThroughBodyEnabled bool `json:"pass_through_body_enabled,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
SystemPromptOverride bool `json:"system_prompt_override,omitempty"`
// HTTPProtocol controls outbound HTTP version negotiation for this channel.
// Accepted values: "", "auto" (default), "http1".
HTTPProtocol string `json:"http_protocol,omitempty"`
// HTTP2ConnectionShards spreads HTTP/2 traffic across N independent transports
// (1-8). Zero/unset means 1. Ignored when HTTPProtocol is "http1".
HTTP2ConnectionShards int `json:"http2_connection_shards,omitempty"`
}
const (
HTTPProtocolAuto = "auto"
HTTPProtocolHTTP1 = "http1"
MaxHTTP2ConnectionShards = 8
)
// ValidateHTTPTransport validates save-time HTTP transport channel settings.
func (s *ChannelSettings) ValidateHTTPTransport() error {
if s == nil {
return nil
}
protocol := strings.ToLower(strings.TrimSpace(s.HTTPProtocol))
switch protocol {
case "", HTTPProtocolAuto, HTTPProtocolHTTP1:
default:
return fmt.Errorf("invalid http_protocol: %s", s.HTTPProtocol)
}
if s.HTTP2ConnectionShards < 0 || s.HTTP2ConnectionShards > MaxHTTP2ConnectionShards {
return fmt.Errorf("invalid http2_connection_shards: %d", s.HTTP2ConnectionShards)
}
if protocol == HTTPProtocolHTTP1 && s.HTTP2ConnectionShards > 1 {
return fmt.Errorf("http2_connection_shards must be 1 when http_protocol is http1")
}
return nil
}
type VertexKeyType string
const (
VertexKeyTypeJSON VertexKeyType = "json"
VertexKeyTypeAPIKey VertexKeyType = "api_key"
)
type AwsKeyType string
const (
AwsKeyTypeAKSK AwsKeyType = "ak_sk" // 默认
AwsKeyTypeApiKey AwsKeyType = "api_key"
)
type ChannelOtherSettings struct {
AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key"
OpenRouterEnterprise *bool `json:"openrouter_enterprise,omitempty"`
ClaudeBetaQuery bool `json:"claude_beta_query,omitempty"` // Claude 渠道是否强制追加 ?beta=true
AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费)
AllowInferenceGeo bool `json:"allow_inference_geo,omitempty"` // 是否允许 inference_geo 透传(仅 Claude,默认过滤以满足数据驻留合规
AllowSpeed bool `json:"allow_speed,omitempty"` // 是否允许 speed 透传(仅 Claude,默认过滤以避免意外切换推理速度模式)
AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私)
DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用)
AllowIncludeObfuscation bool `json:"allow_include_obfuscation,omitempty"` // 是否允许 stream_options.include_obfuscation 透传(默认过滤以避免关闭流混淆保护)
DisableTaskPollingSleep bool `json:"disable_task_polling_sleep,omitempty"` // 是否跳过异步任务轮询间隔
AwsKeyType AwsKeyType `json:"aws_key_type,omitempty"`
UpstreamModelUpdateCheckEnabled bool `json:"upstream_model_update_check_enabled,omitempty"` // 是否检测上游模型更新
UpstreamModelUpdateAutoSyncEnabled bool `json:"upstream_model_update_auto_sync_enabled,omitempty"` // 是否自动同步上游模型更新
UpstreamModelUpdateLastCheckTime int64 `json:"upstream_model_update_last_check_time,omitempty"` // 上次检测时间
UpstreamModelUpdateLastDetectedModels []string `json:"upstream_model_update_last_detected_models,omitempty"` // 上次检测到的可加入模型
UpstreamModelUpdateLastRemovedModels []string `json:"upstream_model_update_last_removed_models,omitempty"` // 上次检测到的可删除模型
UpstreamModelUpdateIgnoredModels []string `json:"upstream_model_update_ignored_models,omitempty"` // 手动忽略的模型
AdvancedCustom *AdvancedCustomConfig `json:"advanced_custom,omitempty"`
// ToolLossPolicy is a channel-level opt-in for request-phase conversion
// rejection. Empty follows the default allow policy. Accepted values:
// "", "allow", "safe", "strict".
ToolLossPolicy string `json:"tool_loss_policy,omitempty"`
}
func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool {
if s == nil || s.OpenRouterEnterprise == nil {
return false
}
return *s.OpenRouterEnterprise
}
// ValidateToolLossPolicy validates the channel-level request-phase tool-loss
// policy. Empty keeps the default allow policy.
func (s *ChannelOtherSettings) ValidateToolLossPolicy() error {
if s == nil {
return nil
}
switch strings.TrimSpace(s.ToolLossPolicy) {
case "", string(types.ConversionLossPolicyAllow), string(types.ConversionLossPolicySafe), string(types.ConversionLossPolicyStrict):
return nil
default:
return fmt.Errorf("invalid tool_loss_policy: %s", s.ToolLossPolicy)
}
}
const (
advancedCustomConverterNone = "none"
advancedCustomConverterClaudeMessagesToOpenAIChat = "anthropic_messages_to_openai_chat_completions"
advancedCustomConverterOpenAIChatToClaudeMessages = "openai_chat_completions_to_anthropic_messages"
advancedCustomConverterOpenAIChatToOpenAIResponses = "openai_chat_completions_to_openai_responses"
advancedCustomConverterOpenAIResponsesToOpenAIChat = "openai_responses_to_openai_chat_completions"
advancedCustomConverterOpenAIResponsesToGemini = "openai_responses_to_gemini_generate_content"
advancedCustomConverterGeminiContentToOpenAIChat = "gemini_generate_content_to_openai_chat_completions"
advancedCustomConverterOpenAIChatToGeminiContent = "openai_chat_completions_to_gemini_generate_content"
)
const (
AdvancedCustomAuthTypeNone = "none"
AdvancedCustomAuthTypeHeader = "header"
AdvancedCustomAuthTypeQuery = "query"
)
type AdvancedCustomConfig struct {
Routes []AdvancedCustomRoute `json:"advanced_routes,omitempty"`
}
type AdvancedCustomRoute struct {
IncomingPath string `json:"incoming_path,omitempty"`
UpstreamPath string `json:"upstream_path,omitempty"`
Converter string `json:"converter,omitempty"`
Models []string `json:"models,omitempty"`
Auth *AdvancedCustomRouteAuth `json:"auth,omitempty"`
}
type AdvancedCustomRouteAuth struct {
Type string `json:"type,omitempty"`
Name string `json:"name,omitempty"`
Value string `json:"value,omitempty"`
}
const (
advancedCustomModelPlaceholder = "{model}"
advancedCustomModelRegexPrefix = "re:"
)
const (
advancedCustomEndpointPathOpenAIChat = "/v1/chat/completions"
advancedCustomEndpointPathOpenAIResponses = "/v1/responses"
advancedCustomEndpointPathOpenAIResponsesCompact = "/v1/responses/compact"
advancedCustomEndpointPathOpenAIAlphaSearch = "/v1/alpha/search"
advancedCustomEndpointPathClaudeMessages = "/v1/messages"
advancedCustomEndpointPathJinaRerank = "/v1/rerank"
advancedCustomEndpointPathImageGeneration = "/v1/images/generations"
advancedCustomEndpointPathEmbeddings = "/v1/embeddings"
)
const (
// AdvancedCustomModelListPath identifies the optional OpenAI Models discovery route.
AdvancedCustomModelListPath = "/v1/models"
// AdvancedCustomBalancePath identifies the optional balance lookup route used by channel management.
AdvancedCustomBalancePath = "/v1/dashboard/billing/credit_grants"
)
// MatchPath returns the first route whose IncomingPath matches requestPath.
// Matching mirrors the relay adaptor: exact match, {model} placeholder, and
// :generateContent <-> :streamGenerateContent equivalence.
func (c *AdvancedCustomConfig) MatchPath(requestPath string) (AdvancedCustomRoute, bool) {
if c == nil {
return AdvancedCustomRoute{}, false
}
for _, route := range c.Routes {
if matchAdvancedCustomIncomingPath(strings.TrimSpace(route.IncomingPath), requestPath) {
return route, true
}
}
return AdvancedCustomRoute{}, false
}
// MatchPathForModel returns the first route whose IncomingPath and Models match.
// An empty Models list is a catch-all fallback for that incoming path.
func (c *AdvancedCustomConfig) MatchPathForModel(requestPath string, model string) (AdvancedCustomRoute, bool) {
if c == nil {
return AdvancedCustomRoute{}, false
}
model = strings.TrimSpace(model)
for _, route := range c.Routes {
if matchAdvancedCustomIncomingPath(strings.TrimSpace(route.IncomingPath), requestPath) &&
matchAdvancedCustomRouteModel(route.Models, model) {
return route, true
}
}
return AdvancedCustomRoute{}, false
}
// ModelListRoute returns the explicitly configured OpenAI Models discovery route.
// Template routes that merely happen to match /v1/models are not discovery routes.
func (c *AdvancedCustomConfig) ModelListRoute() (AdvancedCustomRoute, bool) {
if c == nil {
return AdvancedCustomRoute{}, false
}
for _, route := range c.Routes {
if strings.TrimSpace(route.IncomingPath) == AdvancedCustomModelListPath {
return route, true
}
}
return AdvancedCustomRoute{}, false
}
// BalanceRoute returns the explicitly configured channel-management balance route.
func (c *AdvancedCustomConfig) BalanceRoute() (AdvancedCustomRoute, bool) {
if c == nil {
return AdvancedCustomRoute{}, false
}
for _, route := range c.Routes {
if strings.TrimSpace(route.IncomingPath) == AdvancedCustomBalancePath {
return route, true
}
}
return AdvancedCustomRoute{}, false
}
// SupportsPath reports whether any route matches requestPath.
func (c *AdvancedCustomConfig) SupportsPath(requestPath string) bool {
_, ok := c.MatchPath(requestPath)
return ok
}
// SupportsPathForModel reports whether any route matches requestPath and model.
func (c *AdvancedCustomConfig) SupportsPathForModel(requestPath string, model string) bool {
_, ok := c.MatchPathForModel(requestPath, model)
return ok
}
func (c *AdvancedCustomConfig) SupportedEndpointTypesForModel(model string) []types.EndpointType {
if c == nil {
return nil
}
model = strings.TrimSpace(model)
endpoints := make([]types.EndpointType, 0, len(c.Routes))
seen := make(map[types.EndpointType]struct{}, len(c.Routes))
for _, route := range c.Routes {
if !matchAdvancedCustomRouteModel(route.Models, model) {
continue
}
endpointType, ok := advancedCustomEndpointTypeFromIncomingPath(strings.TrimSpace(route.IncomingPath))
if !ok {
continue
}
if _, exists := seen[endpointType]; exists {
continue
}
seen[endpointType] = struct{}{}
endpoints = append(endpoints, endpointType)
}
return endpoints
}
func advancedCustomEndpointTypeFromIncomingPath(incomingPath string) (types.EndpointType, bool) {
switch incomingPath {
case advancedCustomEndpointPathOpenAIChat:
return types.EndpointTypeOpenAI, true
case advancedCustomEndpointPathOpenAIResponses:
return types.EndpointTypeOpenAIResponse, true
case advancedCustomEndpointPathOpenAIResponsesCompact:
return types.EndpointTypeOpenAIResponseCompact, true
case advancedCustomEndpointPathOpenAIAlphaSearch:
return types.EndpointTypeOpenAIAlphaSearch, true
case advancedCustomEndpointPathClaudeMessages:
return types.EndpointTypeAnthropic, true
case advancedCustomEndpointPathJinaRerank:
return types.EndpointTypeJinaRerank, true
case advancedCustomEndpointPathImageGeneration:
return types.EndpointTypeImageGeneration, true
case advancedCustomEndpointPathEmbeddings:
return types.EndpointTypeEmbeddings, true
default:
if isAdvancedCustomGeminiIncomingPath(incomingPath) {
return types.EndpointTypeGemini, true
}
return "", false
}
}
func isAdvancedCustomGeminiIncomingPath(incomingPath string) bool {
if !strings.HasPrefix(incomingPath, "/v1beta/models/") {
return false
}
return strings.Contains(incomingPath, ":generateContent") || strings.Contains(incomingPath, ":streamGenerateContent")
}
func matchAdvancedCustomRouteModel(models []string, model string) bool {
normalizedModels := normalizeAdvancedCustomRouteModels(models)
if len(normalizedModels) == 0 {
return true
}
for _, allowedModel := range normalizedModels {
if matchAdvancedCustomRouteModelRule(allowedModel, model) {
return true
}
}
return false
}
// advancedCustomModelRegexCache caches compiled route model patterns. Route model
// matching runs on the request hot path (distributor affinity, ability filtering,
// channel cache filtering, adaptor resolve), so patterns must not be recompiled per
// request. Invalid patterns are cached as nil to avoid recompiling them as well.
var advancedCustomModelRegexCache sync.Map // pattern string -> *regexp.Regexp (nil when invalid)
func compileAdvancedCustomModelRegex(pattern string) *regexp.Regexp {
if cached, ok := advancedCustomModelRegexCache.Load(pattern); ok {
re, _ := cached.(*regexp.Regexp)
return re
}
re, err := regexp.Compile(pattern)
if err != nil {
re = nil
}
advancedCustomModelRegexCache.Store(pattern, re)
return re
}
func matchAdvancedCustomRouteModelRule(rule string, model string) bool {
if !strings.HasPrefix(rule, advancedCustomModelRegexPrefix) {
return rule == model
}
pattern := strings.TrimPrefix(rule, advancedCustomModelRegexPrefix)
if pattern == "" {
return false
}
re := compileAdvancedCustomModelRegex(pattern)
return re != nil && re.MatchString(model)
}
func matchAdvancedCustomIncomingPath(configuredPath string, requestPath string) bool {
if matchAdvancedCustomIncomingPathTemplate(configuredPath, requestPath) {
return true
}
if strings.Contains(configuredPath, ":generateContent") {
streamPath := strings.Replace(configuredPath, ":generateContent", ":streamGenerateContent", 1)
return matchAdvancedCustomIncomingPathTemplate(streamPath, requestPath)
}
return false
}
func matchAdvancedCustomIncomingPathTemplate(configuredPath string, requestPath string) bool {
if !strings.Contains(configuredPath, advancedCustomModelPlaceholder) {
return configuredPath == requestPath
}
parts := strings.Split(configuredPath, advancedCustomModelPlaceholder)
if len(parts) != 2 {
return false
}
if !strings.HasPrefix(requestPath, parts[0]) || !strings.HasSuffix(requestPath, parts[1]) {
return false
}
model := strings.TrimSuffix(strings.TrimPrefix(requestPath, parts[0]), parts[1])
return model != "" && !strings.Contains(model, "/")
}
func IsAdvancedCustomConverterAllowed(converter string) bool {
switch converter {
case advancedCustomConverterNone,
advancedCustomConverterClaudeMessagesToOpenAIChat,
advancedCustomConverterOpenAIChatToClaudeMessages,
advancedCustomConverterOpenAIChatToOpenAIResponses,
advancedCustomConverterOpenAIResponsesToOpenAIChat,
advancedCustomConverterOpenAIResponsesToGemini,
advancedCustomConverterGeminiContentToOpenAIChat,
advancedCustomConverterOpenAIChatToGeminiContent:
return true
default:
return false
}
}
func (c *AdvancedCustomConfig) Validate() error {
if c == nil {
return fmt.Errorf("advanced_custom is required")
}
if len(c.Routes) == 0 {
return fmt.Errorf("advanced_custom requires at least one route")
}
paths := make(map[string]*advancedCustomPathModelState, len(c.Routes))
modelListRouteIndex := -1
balanceRouteIndex := -1
for i := range c.Routes {
route := c.Routes[i]
route.IncomingPath = strings.TrimSpace(route.IncomingPath)
upstreamPath := strings.TrimSpace(route.UpstreamPath)
route.Converter = strings.TrimSpace(route.Converter)
if route.Converter == "" {
route.Converter = advancedCustomConverterNone
}
if route.IncomingPath == "" {
return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path is required", i)
}
if !strings.HasPrefix(route.IncomingPath, "/") {
return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path must start with /", i)
}
if strings.Contains(route.IncomingPath, "?") {
return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path must not include query", i)
}
if route.IncomingPath == AdvancedCustomModelListPath || route.IncomingPath == AdvancedCustomBalancePath {
managementRouteName := route.IncomingPath
previousIndex := modelListRouteIndex
if route.IncomingPath == AdvancedCustomBalancePath {
previousIndex = balanceRouteIndex
}
if previousIndex >= 0 {
return fmt.Errorf("advanced_custom.advanced_routes[%d] duplicates the %s route at advanced_routes[%d]", i, managementRouteName, previousIndex)
}
if route.IncomingPath == AdvancedCustomModelListPath {
modelListRouteIndex = i
} else {
balanceRouteIndex = i
}
if len(normalizeAdvancedCustomRouteModels(route.Models)) > 0 {
return fmt.Errorf("advanced_custom.advanced_routes[%d].models must be empty for %s", i, managementRouteName)
}
if route.Converter != advancedCustomConverterNone {
return fmt.Errorf("advanced_custom.advanced_routes[%d].converter must be none for %s", i, managementRouteName)
}
if strings.Contains(upstreamPath, advancedCustomModelPlaceholder) {
return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path must not contain %s for %s", i, advancedCustomModelPlaceholder, managementRouteName)
}
}
if err := validateAdvancedCustomRouteModels(i, route.IncomingPath, route.Models, paths); err != nil {
return err
}
if upstreamPath == "" {
return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path is required", i)
}
if err := validateAdvancedCustomUpstreamTarget(i, upstreamPath); err != nil {
return err
}
if !IsAdvancedCustomConverterAllowed(route.Converter) {
return fmt.Errorf("advanced_custom.advanced_routes[%d].converter is not registered: %s", i, route.Converter)
}
if err := validateAdvancedCustomConverterPath(i, route.IncomingPath, route.Converter); err != nil {
return err
}
if err := validateAdvancedCustomRouteAuth(i, route.Auth); err != nil {
return err
}
}
return nil
}
type advancedCustomPathModelState struct {
catchAllIndex int
modelIndexes map[string]int
}
func validateAdvancedCustomRouteModels(index int, incomingPath string, models []string, paths map[string]*advancedCustomPathModelState) error {
state := paths[incomingPath]
if state == nil {
state = &advancedCustomPathModelState{
catchAllIndex: -1,
modelIndexes: make(map[string]int),
}
paths[incomingPath] = state
}
normalizedModels := normalizeAdvancedCustomRouteModels(models)
if len(normalizedModels) == 0 {
if state.catchAllIndex >= 0 {
return fmt.Errorf("advanced_custom.advanced_routes[%d].models catch-all already exists for incoming_path: %s", index, incomingPath)
}
state.catchAllIndex = index
return nil
}
if state.catchAllIndex >= 0 {
return fmt.Errorf("advanced_custom.advanced_routes[%d].models catch-all route must be last for incoming_path: %s", index, incomingPath)
}
seenInRoute := make(map[string]struct{}, len(normalizedModels))
for _, model := range normalizedModels {
if err := validateAdvancedCustomRouteModelRule(index, incomingPath, model); err != nil {
return err
}
if _, exists := seenInRoute[model]; exists {
return fmt.Errorf("advanced_custom.advanced_routes[%d].models contains duplicate model for incoming_path %s: %s", index, incomingPath, model)
}
seenInRoute[model] = struct{}{}
if existingIndex, exists := state.modelIndexes[model]; exists {
return fmt.Errorf("advanced_custom.advanced_routes[%d].models overlaps with advanced_routes[%d] for incoming_path %s: %s", index, existingIndex, incomingPath, model)
}
state.modelIndexes[model] = index
}
return nil
}
func validateAdvancedCustomRouteModelRule(index int, incomingPath string, model string) error {
if !strings.HasPrefix(model, advancedCustomModelRegexPrefix) {
return nil
}
pattern := strings.TrimPrefix(model, advancedCustomModelRegexPrefix)
if pattern == "" {
return fmt.Errorf("advanced_custom.advanced_routes[%d].models regex is empty for incoming_path %s: %s", index, incomingPath, model)
}
if _, err := regexp.Compile(pattern); err != nil {
return fmt.Errorf("advanced_custom.advanced_routes[%d].models regex is invalid for incoming_path %s: %s", index, incomingPath, model)
}
return nil
}
func normalizeAdvancedCustomRouteModels(models []string) []string {
if len(models) == 0 {
return nil
}
normalized := make([]string, 0, len(models))
for _, model := range models {
model = strings.TrimSpace(model)
if model != "" {
normalized = append(normalized, model)
}
}
return normalized
}
func validateAdvancedCustomUpstreamTarget(index int, upstreamPath string) error {
if strings.HasPrefix(upstreamPath, "/") {
if strings.HasPrefix(upstreamPath, "//") {
return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path must be a full URL or a path starting with /", index)
}
return nil
}
parsedURL, err := url.Parse(upstreamPath)
if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path must be a full URL or a path starting with /", index)
}
if !strings.EqualFold(parsedURL.Scheme, "http") && !strings.EqualFold(parsedURL.Scheme, "https") {
return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path must use http or https", index)
}
return nil
}
func validateAdvancedCustomConverterPath(index int, incomingPath string, converter string) error {
if incomingPath == advancedCustomEndpointPathOpenAIAlphaSearch {
if converter == advancedCustomConverterNone {
return nil
}
return fmt.Errorf("advanced_custom.advanced_routes[%d].converter does not match incoming_path: %s", index, converter)
}
switch converter {
case advancedCustomConverterNone:
return nil
case advancedCustomConverterClaudeMessagesToOpenAIChat:
if incomingPath == "/v1/messages" {
return nil
}
case advancedCustomConverterOpenAIChatToClaudeMessages,
advancedCustomConverterOpenAIChatToOpenAIResponses,
advancedCustomConverterOpenAIChatToGeminiContent:
if incomingPath == "/v1/chat/completions" {
return nil
}
case advancedCustomConverterOpenAIResponsesToOpenAIChat:
if incomingPath == "/v1/responses" {
return nil
}
case advancedCustomConverterOpenAIResponsesToGemini:
if incomingPath == "/v1/responses" {
return nil
}
case advancedCustomConverterGeminiContentToOpenAIChat:
if strings.Contains(incomingPath, ":generateContent") || strings.Contains(incomingPath, ":streamGenerateContent") {
return nil
}
}
return fmt.Errorf("advanced_custom.advanced_routes[%d].converter does not match incoming_path: %s", index, converter)
}
func validateAdvancedCustomRouteAuth(index int, auth *AdvancedCustomRouteAuth) error {
if auth == nil {
return nil
}
authType := strings.TrimSpace(auth.Type)
switch authType {
case AdvancedCustomAuthTypeNone:
return nil
case AdvancedCustomAuthTypeHeader, AdvancedCustomAuthTypeQuery:
if strings.TrimSpace(auth.Name) == "" {
return fmt.Errorf("advanced_custom.advanced_routes[%d].auth.name is required", index)
}
if strings.TrimSpace(auth.Value) == "" {
return fmt.Errorf("advanced_custom.advanced_routes[%d].auth.value is required", index)
}
return nil
default:
return fmt.Errorf("advanced_custom.advanced_routes[%d].auth.type is invalid: %s", index, auth.Type)
}
}
+656
View File
@@ -0,0 +1,656 @@
package dto
import (
"encoding/json"
"regexp"
"testing"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAdvancedCustomValidateResponsesToChatConverterPath(t *testing.T) {
valid := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1/chat/completions",
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
},
},
}
require.NoError(t, valid.Validate())
validGemini := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: advancedCustomConverterOpenAIResponsesToGemini,
},
},
}
require.NoError(t, validGemini.Validate())
tests := []struct {
name string
incomingPath string
}{
{name: "chat completions", incomingPath: "/v1/chat/completions"},
{name: "responses compact", incomingPath: "/v1/responses/compact"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: tt.incomingPath,
UpstreamPath: "/v1/chat/completions",
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
},
},
}
err := config.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "converter does not match incoming_path")
})
}
}
func TestAdvancedCustomValidateModelListRouteConstraints(t *testing.T) {
valid := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: AdvancedCustomModelListPath,
UpstreamPath: "https://upstream.example/custom/models",
Converter: advancedCustomConverterNone,
},
},
}
require.NoError(t, valid.Validate())
tests := []struct {
name string
routes []AdvancedCustomRoute
want string
}{
{
name: "model matching rules",
routes: []AdvancedCustomRoute{
{
IncomingPath: AdvancedCustomModelListPath,
UpstreamPath: "/v1/models",
Models: []string{"gpt-4o"},
},
},
want: "models must be empty",
},
{
name: "converter",
routes: []AdvancedCustomRoute{
{
IncomingPath: AdvancedCustomModelListPath,
UpstreamPath: "/v1/models",
Converter: advancedCustomConverterOpenAIChatToOpenAIResponses,
},
},
want: "converter must be none",
},
{
name: "model placeholder",
routes: []AdvancedCustomRoute{
{
IncomingPath: AdvancedCustomModelListPath,
UpstreamPath: "/v1/models/{model}",
},
},
want: "upstream_path must not contain {model}",
},
{
name: "duplicate routes",
routes: []AdvancedCustomRoute{
{IncomingPath: AdvancedCustomModelListPath, UpstreamPath: "/v1/models"},
{IncomingPath: AdvancedCustomModelListPath, UpstreamPath: "/provider/models"},
},
want: "duplicates the /v1/models route",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := (&AdvancedCustomConfig{Routes: tt.routes}).Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), tt.want)
})
}
}
func TestAdvancedCustomModelListRouteRequiresExactIncomingPath(t *testing.T) {
config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/{model}",
UpstreamPath: "/generic/{model}",
},
{
IncomingPath: AdvancedCustomModelListPath,
UpstreamPath: "/provider/models",
},
},
}
require.NoError(t, config.Validate())
route, ok := config.ModelListRoute()
require.True(t, ok)
assert.Equal(t, "/provider/models", route.UpstreamPath)
}
func TestAdvancedCustomValidateBalanceRouteConstraints(t *testing.T) {
valid := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{{
IncomingPath: AdvancedCustomBalancePath,
UpstreamPath: "/provider/balance",
Converter: advancedCustomConverterNone,
}},
}
require.NoError(t, valid.Validate())
route, ok := valid.BalanceRoute()
require.True(t, ok)
assert.Equal(t, "/provider/balance", route.UpstreamPath)
tests := []struct {
name string
routes []AdvancedCustomRoute
want string
}{
{
name: "model matching rules",
routes: []AdvancedCustomRoute{{
IncomingPath: AdvancedCustomBalancePath,
UpstreamPath: "/provider/balance",
Models: []string{"gpt-4o"},
}},
want: "models must be empty",
},
{
name: "converter",
routes: []AdvancedCustomRoute{{
IncomingPath: AdvancedCustomBalancePath,
UpstreamPath: "/provider/balance",
Converter: advancedCustomConverterOpenAIChatToOpenAIResponses,
}},
want: "converter must be none",
},
{
name: "model placeholder",
routes: []AdvancedCustomRoute{{
IncomingPath: AdvancedCustomBalancePath,
UpstreamPath: "/provider/{model}/balance",
}},
want: "upstream_path must not contain {model}",
},
{
name: "duplicate routes",
routes: []AdvancedCustomRoute{
{IncomingPath: AdvancedCustomBalancePath, UpstreamPath: "/provider/balance"},
{IncomingPath: AdvancedCustomBalancePath, UpstreamPath: "/provider/credits"},
},
want: "duplicates the /v1/dashboard/billing/credit_grants route",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := (&AdvancedCustomConfig{Routes: tt.routes}).Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), tt.want)
})
}
}
func TestAdvancedCustomValidateDuplicateIncomingPathWithDisjointModels(t *testing.T) {
config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1/chat/completions",
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
Models: []string{"gpt-4o"},
},
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: advancedCustomConverterOpenAIResponsesToGemini,
Models: []string{"gemini-2.5-flash"},
},
},
}
require.NoError(t, config.Validate())
}
func TestAdvancedCustomValidateDuplicateIncomingPathRejectsOverlappingModels(t *testing.T) {
config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1/chat/completions",
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
Models: []string{"shared-model"},
},
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: advancedCustomConverterOpenAIResponsesToGemini,
Models: []string{"shared-model"},
},
},
}
err := config.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "models overlaps")
}
func TestAdvancedCustomValidateDuplicateIncomingPathRejectsMultipleCatchAllRoutes(t *testing.T) {
config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1/chat/completions",
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
},
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: advancedCustomConverterOpenAIResponsesToGemini,
},
},
}
err := config.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "catch-all already exists")
}
func TestAdvancedCustomValidateDuplicateIncomingPathRequiresCatchAllLast(t *testing.T) {
config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1/chat/completions",
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
},
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: advancedCustomConverterOpenAIResponsesToGemini,
Models: []string{"gemini-2.5-flash"},
},
},
}
err := config.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "catch-all route must be last")
}
func TestAdvancedCustomMatchPathForModel(t *testing.T) {
config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: advancedCustomConverterOpenAIResponsesToGemini,
Models: []string{"gemini-2.5-flash"},
},
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1/chat/completions",
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
Models: []string{"gpt-4o"},
},
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1/responses",
Converter: advancedCustomConverterNone,
},
},
}
require.NoError(t, config.Validate())
geminiRoute, ok := config.MatchPathForModel("/v1/responses", "gemini-2.5-flash")
require.True(t, ok)
assert.Equal(t, advancedCustomConverterOpenAIResponsesToGemini, geminiRoute.Converter)
chatRoute, ok := config.MatchPathForModel("/v1/responses", "gpt-4o")
require.True(t, ok)
assert.Equal(t, advancedCustomConverterOpenAIResponsesToOpenAIChat, chatRoute.Converter)
fallbackRoute, ok := config.MatchPathForModel("/v1/responses", "unknown-model")
require.True(t, ok)
assert.Equal(t, advancedCustomConverterNone, fallbackRoute.Converter)
}
func TestAdvancedCustomMatchPathForModelRegexRules(t *testing.T) {
config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: advancedCustomConverterOpenAIResponsesToGemini,
Models: []string{"re:^gemini-"},
},
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1/chat/completions",
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
Models: []string{"re:(?i)^OAI-"},
},
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1/responses",
Converter: advancedCustomConverterNone,
},
},
}
require.NoError(t, config.Validate())
geminiRoute, ok := config.MatchPathForModel("/v1/responses", "gemini-2.5-flash")
require.True(t, ok)
assert.Equal(t, advancedCustomConverterOpenAIResponsesToGemini, geminiRoute.Converter)
chatRoute, ok := config.MatchPathForModel("/v1/responses", "oai-test")
require.True(t, ok)
assert.Equal(t, advancedCustomConverterOpenAIResponsesToOpenAIChat, chatRoute.Converter)
fallbackRoute, ok := config.MatchPathForModel("/v1/responses", "gpt-4o")
require.True(t, ok)
assert.Equal(t, advancedCustomConverterNone, fallbackRoute.Converter)
}
func TestAdvancedCustomRouteModelRegexRulesAreCachedCompiled(t *testing.T) {
require.True(t, matchAdvancedCustomRouteModelRule("re:^cache-probe-", "cache-probe-model"))
cached, ok := advancedCustomModelRegexCache.Load("^cache-probe-")
require.True(t, ok)
require.NotNil(t, cached)
_, isRegexp := cached.(*regexp.Regexp)
require.True(t, isRegexp)
// Invalid patterns never match and are cached as nil so they are not recompiled.
require.False(t, matchAdvancedCustomRouteModelRule("re:(", "anything"))
cached, ok = advancedCustomModelRegexCache.Load("(")
require.True(t, ok)
re, _ := cached.(*regexp.Regexp)
require.Nil(t, re)
// Cached entries keep matching correctly on subsequent calls.
require.True(t, matchAdvancedCustomRouteModelRule("re:^cache-probe-", "cache-probe-other"))
require.False(t, matchAdvancedCustomRouteModelRule("re:^cache-probe-", "other-model"))
}
func TestAdvancedCustomMatchPathForModelExactRuleDoesNotMatchPrefix(t *testing.T) {
config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: advancedCustomConverterOpenAIResponsesToGemini,
Models: []string{"gemini"},
},
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1/responses",
Converter: advancedCustomConverterNone,
},
},
}
require.NoError(t, config.Validate())
fallbackRoute, ok := config.MatchPathForModel("/v1/responses", "gemini-2.5-flash")
require.True(t, ok)
assert.Equal(t, advancedCustomConverterNone, fallbackRoute.Converter)
}
func TestAdvancedCustomValidateDuplicateIncomingPathRejectsInvalidRegexModels(t *testing.T) {
tests := []struct {
name string
models []string
want string
}{
{name: "empty regex", models: []string{"re:"}, want: "regex is empty"},
{name: "invalid regex", models: []string{"re:["}, want: "regex is invalid"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: advancedCustomConverterOpenAIResponsesToGemini,
Models: tt.models,
},
},
}
err := config.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), tt.want)
})
}
}
func TestAdvancedCustomValidateDuplicateIncomingPathRejectsDuplicateRegexModels(t *testing.T) {
config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: advancedCustomConverterOpenAIResponsesToGemini,
Models: []string{"re:^gemini-"},
},
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1/chat/completions",
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
Models: []string{"re:^gemini-"},
},
},
}
err := config.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "models overlaps")
}
func TestAdvancedCustomMatchPathForModelUsesFirstMatchingRegexRoute(t *testing.T) {
config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: advancedCustomConverterOpenAIResponsesToGemini,
Models: []string{"re:^gemini-"},
},
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1/chat/completions",
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
Models: []string{"gemini-2.5-flash"},
},
},
}
require.NoError(t, config.Validate())
route, ok := config.MatchPathForModel("/v1/responses", "gemini-2.5-flash")
require.True(t, ok)
assert.Equal(t, advancedCustomConverterOpenAIResponsesToGemini, route.Converter)
}
func TestAdvancedCustomSupportedEndpointTypesForModel(t *testing.T) {
config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Converter: advancedCustomConverterOpenAIResponsesToGemini,
Models: []string{"re:^gemini-"},
},
{
IncomingPath: "/v1beta/models/{model}:generateContent",
UpstreamPath: "/v1beta/models/{model}:generateContent",
Models: []string{"re:^gemini-"},
},
{
IncomingPath: "/v1beta/models/{model}:streamGenerateContent",
UpstreamPath: "/v1beta/models/{model}:streamGenerateContent",
Models: []string{"re:^gemini-"},
},
{
IncomingPath: "/v1/chat/completions",
UpstreamPath: "/v1/chat/completions",
Models: []string{"gpt-4o"},
},
{
IncomingPath: "/v1/messages",
UpstreamPath: "/v1/messages",
},
{
IncomingPath: "/custom/endpoint",
UpstreamPath: "/custom/endpoint",
},
},
}
require.NoError(t, config.Validate())
assert.Equal(t, []types.EndpointType{
types.EndpointTypeOpenAIResponse,
types.EndpointTypeGemini,
types.EndpointTypeAnthropic,
}, config.SupportedEndpointTypesForModel("gemini-2.5-flash"))
assert.Equal(t, []types.EndpointType{
types.EndpointTypeOpenAI,
types.EndpointTypeAnthropic,
}, config.SupportedEndpointTypesForModel("gpt-4o"))
assert.Equal(t, []types.EndpointType{
types.EndpointTypeAnthropic,
}, config.SupportedEndpointTypesForModel("other-model"))
}
func TestAdvancedCustomValidateAlphaSearchConverterPath(t *testing.T) {
valid := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/alpha/search",
UpstreamPath: "/v1/alpha/search",
Converter: advancedCustomConverterNone,
},
},
}
require.NoError(t, valid.Validate())
assert.Equal(t, []types.EndpointType{
types.EndpointTypeOpenAIAlphaSearch,
}, valid.SupportedEndpointTypesForModel("gpt-5.1"))
nonNoneConverters := []string{
advancedCustomConverterClaudeMessagesToOpenAIChat,
advancedCustomConverterOpenAIChatToClaudeMessages,
advancedCustomConverterOpenAIChatToOpenAIResponses,
advancedCustomConverterOpenAIResponsesToOpenAIChat,
advancedCustomConverterOpenAIResponsesToGemini,
advancedCustomConverterGeminiContentToOpenAIChat,
advancedCustomConverterOpenAIChatToGeminiContent,
}
for _, converter := range nonNoneConverters {
t.Run(converter, func(t *testing.T) {
config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/alpha/search",
UpstreamPath: "/v1/alpha/search",
Converter: converter,
},
},
}
err := config.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "converter does not match incoming_path")
})
}
}
func TestChannelSettingsHTTPTransportJSONRoundTrip(t *testing.T) {
legacy := `{"proxy":"http://127.0.0.1:8080","force_format":true}`
var settings ChannelSettings
require.NoError(t, json.Unmarshal([]byte(legacy), &settings))
assert.Equal(t, "http://127.0.0.1:8080", settings.Proxy)
assert.True(t, settings.ForceFormat)
assert.Empty(t, settings.HTTPProtocol)
assert.Zero(t, settings.HTTP2ConnectionShards)
encoded, err := json.Marshal(settings)
require.NoError(t, err)
assert.NotContains(t, string(encoded), "http_protocol")
assert.NotContains(t, string(encoded), "http2_connection_shards")
explicit := ChannelSettings{
Proxy: "socks5://127.0.0.1:1080",
HTTPProtocol: HTTPProtocolHTTP1,
HTTP2ConnectionShards: 1,
}
encoded, err = json.Marshal(explicit)
require.NoError(t, err)
assert.Contains(t, string(encoded), `"http_protocol":"http1"`)
var decoded ChannelSettings
require.NoError(t, json.Unmarshal(encoded, &decoded))
assert.Equal(t, explicit.HTTPProtocol, decoded.HTTPProtocol)
assert.Equal(t, 1, decoded.HTTP2ConnectionShards)
sharded := ChannelSettings{HTTP2ConnectionShards: 4}
encoded, err = json.Marshal(sharded)
require.NoError(t, err)
assert.Contains(t, string(encoded), `"http2_connection_shards":4`)
assert.NotContains(t, string(encoded), "http_protocol")
}
func TestChannelSettingsValidateHTTPTransport(t *testing.T) {
require.NoError(t, (&ChannelSettings{}).ValidateHTTPTransport())
require.NoError(t, (&ChannelSettings{HTTPProtocol: "AUTO"}).ValidateHTTPTransport())
require.NoError(t, (&ChannelSettings{HTTPProtocol: "http1"}).ValidateHTTPTransport())
require.NoError(t, (&ChannelSettings{HTTP2ConnectionShards: 8}).ValidateHTTPTransport())
err := (&ChannelSettings{HTTPProtocol: "http2"}).ValidateHTTPTransport()
require.Error(t, err)
assert.Contains(t, err.Error(), "http_protocol")
err = (&ChannelSettings{HTTP2ConnectionShards: -1}).ValidateHTTPTransport()
require.Error(t, err)
assert.Contains(t, err.Error(), "http2_connection_shards")
err = (&ChannelSettings{HTTP2ConnectionShards: 9}).ValidateHTTPTransport()
require.Error(t, err)
assert.Contains(t, err.Error(), "http2_connection_shards")
err = (&ChannelSettings{HTTPProtocol: "http1", HTTP2ConnectionShards: 2}).ValidateHTTPTransport()
require.Error(t, err)
assert.Contains(t, err.Error(), "http2_connection_shards")
}
func TestChannelOtherSettingsValidateToolLossPolicy(t *testing.T) {
require.NoError(t, (*ChannelOtherSettings)(nil).ValidateToolLossPolicy())
require.NoError(t, (&ChannelOtherSettings{}).ValidateToolLossPolicy())
require.NoError(t, (&ChannelOtherSettings{ToolLossPolicy: "allow"}).ValidateToolLossPolicy())
require.NoError(t, (&ChannelOtherSettings{ToolLossPolicy: "safe"}).ValidateToolLossPolicy())
require.NoError(t, (&ChannelOtherSettings{ToolLossPolicy: "strict"}).ValidateToolLossPolicy())
err := (&ChannelOtherSettings{ToolLossPolicy: "drop"}).ValidateToolLossPolicy()
require.Error(t, err)
assert.Contains(t, err.Error(), "tool_loss_policy")
}
+622
View File
@@ -0,0 +1,622 @@
package dto
import (
"encoding/json"
"fmt"
"net/http"
"strings"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
type ClaudeMetadata struct {
UserId string `json:"user_id"`
}
type ClaudeMediaMessage struct {
Type string `json:"type,omitempty"`
Text *string `json:"text,omitempty"`
Model string `json:"model,omitempty"`
Source *ClaudeMessageSource `json:"source,omitempty"`
Usage *ClaudeUsage `json:"usage,omitempty"`
StopReason *string `json:"stop_reason,omitempty"`
PartialJson *string `json:"partial_json,omitempty"`
Role string `json:"role,omitempty"`
Thinking *string `json:"thinking,omitempty"`
Data string `json:"data,omitempty"`
Signature string `json:"signature,omitempty"`
Delta string `json:"delta,omitempty"`
CacheControl json.RawMessage `json:"cache_control,omitempty"`
// Text blocks and citations_delta events.
Citations json.RawMessage `json:"citations,omitempty"`
Citation json.RawMessage `json:"citation,omitempty"`
// Server-tool and tool-result blocks.
Caller json.RawMessage `json:"caller,omitempty"`
ServerName string `json:"server_name,omitempty"`
IsError *bool `json:"is_error,omitempty"`
// ErrorCode is a relaykit compatibility extension. Claude places provider
// error codes inside nested tool-result error content.
ErrorCode string `json:"error_code,omitempty"`
// Tool-use and tool-result blocks.
Id string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input any `json:"input,omitempty"`
Content any `json:"content,omitempty"`
ToolUseId string `json:"tool_use_id,omitempty"`
}
func (c *ClaudeMediaMessage) SetText(s string) {
c.Text = &s
}
func (c *ClaudeMediaMessage) GetText() string {
if c.Text == nil {
return ""
}
return *c.Text
}
func (c *ClaudeMediaMessage) IsStringContent() bool {
if c.Content == nil {
return false
}
_, ok := c.Content.(string)
if ok {
return true
}
return false
}
func (c *ClaudeMediaMessage) GetStringContent() string {
if c.Content == nil {
return ""
}
switch c.Content.(type) {
case string:
return c.Content.(string)
case []any:
var contentStr strings.Builder
for _, contentItem := range c.Content.([]any) {
contentMap, ok := contentItem.(map[string]any)
if !ok {
continue
}
if contentMap["type"] == ContentTypeText {
if subStr, ok := contentMap["text"].(string); ok {
contentStr.WriteString(subStr)
}
}
}
return contentStr.String()
}
return ""
}
func (c *ClaudeMediaMessage) GetJsonRowString() string {
jsonContent, _ := kitutil.Marshal(c)
return string(jsonContent)
}
func (c *ClaudeMediaMessage) SetContent(content any) {
c.Content = content
}
func (c *ClaudeMediaMessage) ParseMediaContent() []ClaudeMediaMessage {
mediaContent, _ := kitutil.Any2Type[[]ClaudeMediaMessage](c.Content)
return mediaContent
}
func (m *ClaudeMediaMessage) ToFileSource() types.FileSource {
if m.Source == nil {
return nil
}
data := m.Source.Url
if data == "" {
data = kitutil.Interface2String(m.Source.Data)
}
if data == "" {
return nil
}
return types.NewFileSourceFromData(data, m.Source.MediaType)
}
type ClaudeMessageSource struct {
Type string `json:"type"`
MediaType string `json:"media_type,omitempty"`
Data any `json:"data,omitempty"`
Url string `json:"url,omitempty"`
}
type ClaudeMessage struct {
Role string `json:"role"`
Content any `json:"content"`
}
func (c *ClaudeMessage) IsStringContent() bool {
if c.Content == nil {
return false
}
_, ok := c.Content.(string)
return ok
}
func (c *ClaudeMessage) GetStringContent() string {
if c.Content == nil {
return ""
}
switch c.Content.(type) {
case string:
return c.Content.(string)
case []any:
var contentStr strings.Builder
for _, contentItem := range c.Content.([]any) {
contentMap, ok := contentItem.(map[string]any)
if !ok {
continue
}
if contentMap["type"] == ContentTypeText {
if subStr, ok := contentMap["text"].(string); ok {
contentStr.WriteString(subStr)
}
}
}
return contentStr.String()
}
return ""
}
func (c *ClaudeMessage) SetStringContent(content string) {
c.Content = content
}
func (c *ClaudeMessage) SetContent(content any) {
c.Content = content
}
func (c *ClaudeMessage) ParseContent() ([]ClaudeMediaMessage, error) {
return kitutil.Any2Type[[]ClaudeMediaMessage](c.Content)
}
type Tool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema map[string]any `json:"input_schema"`
Strict *bool `json:"strict,omitempty"`
}
type InputSchema struct {
Type string `json:"type"`
Properties any `json:"properties,omitempty"`
Required any `json:"required,omitempty"`
}
type ClaudeWebSearchTool struct {
Type string `json:"type"`
Name string `json:"name"`
MaxUses int `json:"max_uses,omitempty"`
AllowedDomains []string `json:"allowed_domains,omitempty"`
BlockedDomains []string `json:"blocked_domains,omitempty"`
AllowedCallers []string `json:"allowed_callers,omitempty"`
ResponseInclusion string `json:"response_inclusion,omitempty"`
UserLocation *ClaudeWebSearchUserLocation `json:"user_location,omitempty"`
}
type ClaudeWebSearchUserLocation struct {
Type string `json:"type"`
Timezone string `json:"timezone,omitempty"`
Country string `json:"country,omitempty"`
Region string `json:"region,omitempty"`
City string `json:"city,omitempty"`
}
type ClaudeToolChoice struct {
Type string `json:"type"`
Name string `json:"name,omitempty"`
DisableParallelToolUse bool `json:"disable_parallel_tool_use,omitempty"`
}
type ClaudeRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt,omitempty"`
System any `json:"system,omitempty"`
Messages []ClaudeMessage `json:"messages,omitempty"`
CacheControl json.RawMessage `json:"cache_control,omitempty"`
// InferenceGeo controls Claude data residency region.
// This field is filtered by default and can be enabled via channel setting allow_inference_geo.
InferenceGeo string `json:"inference_geo,omitempty"`
MaxTokens *uint `json:"max_tokens,omitempty"`
MaxTokensToSample *uint `json:"max_tokens_to_sample,omitempty"`
StopSequences []string `json:"stop_sequences,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
TopK *int `json:"top_k,omitempty"`
Stream *bool `json:"stream,omitempty"`
Tools any `json:"tools,omitempty"`
ContextManagement json.RawMessage `json:"context_management,omitempty"`
OutputConfig json.RawMessage `json:"output_config,omitempty"`
OutputFormat json.RawMessage `json:"output_format,omitempty"`
Container json.RawMessage `json:"container,omitempty"`
ToolChoice any `json:"tool_choice,omitempty"`
Thinking *Thinking `json:"thinking,omitempty"`
McpServers json.RawMessage `json:"mcp_servers,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
// Speed specifies the Claude inference speed mode.
// This field is filtered by default and can be enabled via channel setting allow_speed.
Speed json.RawMessage `json:"speed,omitempty"`
// ServiceTier specifies upstream service level and may affect billing.
// This field is filtered by default and can be enabled via channel setting allow_service_tier.
ServiceTier string `json:"service_tier,omitempty"`
}
// OutputConfigForEffort just for extract effort
type OutputConfigForEffort struct {
Effort string `json:"effort,omitempty"`
}
func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
maxTokens := 0
if c.MaxTokens != nil {
maxTokens = int(*c.MaxTokens)
}
var tokenCountMeta = types.TokenCountMeta{
TokenType: types.TokenTypeTokenizer,
MaxTokens: maxTokens,
}
var texts = make([]string, 0)
var fileMeta = make([]*types.FileMeta, 0)
// system
if c.System != nil {
if c.IsStringSystem() {
sys := c.GetStringSystem()
if sys != "" {
texts = append(texts, sys)
}
} else {
systemMedia := c.ParseSystem()
for _, media := range systemMedia {
switch media.Type {
case "text":
texts = append(texts, media.GetText())
case "image":
if source := media.ToFileSource(); source != nil {
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage,
Source: source,
})
}
}
}
}
}
// messages
for _, message := range c.Messages {
tokenCountMeta.MessagesCount++
texts = append(texts, message.Role)
if message.IsStringContent() {
content := message.GetStringContent()
if content != "" {
texts = append(texts, content)
}
continue
}
content, _ := message.ParseContent()
for _, media := range content {
switch media.Type {
case "text":
texts = append(texts, media.GetText())
case "image":
if source := media.ToFileSource(); source != nil {
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage,
Source: source,
})
}
case "tool_use":
if media.Name != "" {
texts = append(texts, media.Name)
}
if media.Input != nil {
b, _ := kitutil.Marshal(media.Input)
texts = append(texts, string(b))
}
case "tool_result":
if media.Content != nil {
b, _ := kitutil.Marshal(media.Content)
texts = append(texts, string(b))
}
}
}
}
// tools
if c.Tools != nil {
tools := c.GetTools()
normalTools, webSearchTools := ProcessTools(tools)
if normalTools != nil {
for _, t := range normalTools {
tokenCountMeta.ToolsCount++
if t.Name != "" {
texts = append(texts, t.Name)
}
if t.Description != "" {
texts = append(texts, t.Description)
}
if t.InputSchema != nil {
b, _ := kitutil.Marshal(t.InputSchema)
texts = append(texts, string(b))
}
}
}
if webSearchTools != nil {
for _, t := range webSearchTools {
tokenCountMeta.ToolsCount++
if t.Name != "" {
texts = append(texts, t.Name)
}
if t.UserLocation != nil {
b, _ := kitutil.Marshal(t.UserLocation)
texts = append(texts, string(b))
}
}
}
}
tokenCountMeta.CombineText = strings.Join(texts, "\n")
tokenCountMeta.Files = fileMeta
return &tokenCountMeta
}
func (c *ClaudeRequest) IsStream(ctx *http.Request) bool {
if c.Stream == nil {
return false
}
return *c.Stream
}
func (c *ClaudeRequest) SetModelName(modelName string) {
if modelName != "" {
c.Model = modelName
}
}
func (c *ClaudeRequest) SearchToolNameByToolCallId(toolCallId string) string {
for _, message := range c.Messages {
content, _ := message.ParseContent()
for _, mediaMessage := range content {
if mediaMessage.Id == toolCallId {
return mediaMessage.Name
}
}
}
return ""
}
// AddTool 添加工具到请求中
func (c *ClaudeRequest) AddTool(tool any) {
if c.Tools == nil {
c.Tools = make([]any, 0)
}
switch tools := c.Tools.(type) {
case []any:
c.Tools = append(tools, tool)
default:
// 如果Tools不是[]any类型,重新初始化为[]any
c.Tools = []any{tool}
}
}
// GetTools 获取工具列表
func (c *ClaudeRequest) GetTools() []any {
if c.Tools == nil {
return nil
}
switch tools := c.Tools.(type) {
case []any:
return tools
default:
return nil
}
}
func (c *ClaudeRequest) GetEfforts() string {
var OutputConfig OutputConfigForEffort
if err := kitutil.Unmarshal(c.OutputConfig, &OutputConfig); err == nil {
effort := OutputConfig.Effort
return effort
}
return ""
}
// ProcessTools 处理工具列表,支持类型断言
func ProcessTools(tools []any) ([]*Tool, []*ClaudeWebSearchTool) {
var normalTools []*Tool
var webSearchTools []*ClaudeWebSearchTool
for _, tool := range tools {
switch t := tool.(type) {
case *Tool:
normalTools = append(normalTools, t)
case *ClaudeWebSearchTool:
webSearchTools = append(webSearchTools, t)
case Tool:
normalTools = append(normalTools, &t)
case ClaudeWebSearchTool:
webSearchTools = append(webSearchTools, &t)
default:
// 未知类型,跳过
continue
}
}
return normalTools, webSearchTools
}
type Thinking struct {
Type string `json:"type,omitempty"`
BudgetTokens *int `json:"budget_tokens,omitempty"`
// Display controls whether thinking content is returned in the response.
// Used with adaptive thinking on Claude Opus 4.7+: "summarized" restores
// the visible summary that was default on Opus 4.6; "omitted" (default on
// 4.7) suppresses it. Pass-through field from upstream Anthropic API.
Display string `json:"display,omitempty"`
}
func (c *Thinking) GetBudgetTokens() int {
if c.BudgetTokens == nil {
return 0
}
return *c.BudgetTokens
}
func (c *ClaudeRequest) IsStringSystem() bool {
_, ok := c.System.(string)
return ok
}
func (c *ClaudeRequest) GetStringSystem() string {
if c.IsStringSystem() {
return c.System.(string)
}
return ""
}
func (c *ClaudeRequest) SetStringSystem(system string) {
c.System = system
}
func (c *ClaudeRequest) ParseSystem() []ClaudeMediaMessage {
mediaContent, _ := kitutil.Any2Type[[]ClaudeMediaMessage](c.System)
return mediaContent
}
type ClaudeErrorWithStatusCode struct {
Error types.ClaudeError `json:"error"`
StatusCode int `json:"status_code"`
LocalError bool
}
type ClaudeResponse struct {
Id string `json:"id,omitempty"`
Type string `json:"type"`
Role string `json:"role,omitempty"`
Content []ClaudeMediaMessage `json:"content,omitempty"`
Completion string `json:"completion,omitempty"`
StopReason string `json:"stop_reason,omitempty"`
Model string `json:"model,omitempty"`
Error any `json:"error,omitempty"`
Usage *ClaudeUsage `json:"usage,omitempty"`
Index *int `json:"index,omitempty"`
ContentBlock *ClaudeMediaMessage `json:"content_block,omitempty"`
Delta *ClaudeMediaMessage `json:"delta,omitempty"`
Message *ClaudeMediaMessage `json:"message,omitempty"`
}
// set index
func (c *ClaudeResponse) SetIndex(i int) {
c.Index = &i
}
// get index
func (c *ClaudeResponse) GetIndex() int {
if c.Index == nil {
return 0
}
return *c.Index
}
// GetClaudeError 从动态错误类型中提取ClaudeError结构
func (c *ClaudeResponse) GetClaudeError() *types.ClaudeError {
if c.Error == nil {
return nil
}
switch err := c.Error.(type) {
case types.ClaudeError:
return &err
case *types.ClaudeError:
return err
case map[string]any:
// 处理从JSON解析来的map结构
claudeErr := &types.ClaudeError{}
if errType, ok := err["type"].(string); ok {
claudeErr.Type = errType
}
if errMsg, ok := err["message"].(string); ok {
claudeErr.Message = errMsg
}
return claudeErr
case string:
// 处理简单字符串错误
return &types.ClaudeError{
Type: "upstream_error",
Message: err,
}
default:
// 未知类型,尝试转换为字符串
return &types.ClaudeError{
Type: "unknown_upstream_error",
Message: fmt.Sprintf("unknown_error: %v", err),
}
}
}
type ClaudeUsage struct {
InputTokens int `json:"input_tokens"`
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheCreation *ClaudeCacheCreationUsage `json:"cache_creation,omitempty"`
// claude cache 1h
ClaudeCacheCreation5mTokens int `json:"claude_cache_creation_5_m_tokens"`
ClaudeCacheCreation1hTokens int `json:"claude_cache_creation_1_h_tokens"`
ServerToolUse *ClaudeServerToolUse `json:"server_tool_use,omitempty"`
BillingUsage *BillingUsage `json:"billing_usage,omitempty"`
}
type ClaudeCacheCreationUsage struct {
Ephemeral5mInputTokens int `json:"ephemeral_5m_input_tokens,omitempty"`
Ephemeral1hInputTokens int `json:"ephemeral_1h_input_tokens,omitempty"`
}
func (u *ClaudeUsage) GetCacheCreation5mTokens() int {
if u == nil || u.CacheCreation == nil {
return 0
}
return u.CacheCreation.Ephemeral5mInputTokens
}
func (u *ClaudeUsage) GetCacheCreation1hTokens() int {
if u == nil || u.CacheCreation == nil {
return 0
}
return u.CacheCreation.Ephemeral1hInputTokens
}
func (u *ClaudeUsage) GetCacheCreationTotalTokens() int {
if u == nil {
return 0
}
if u.CacheCreationInputTokens > 0 {
return u.CacheCreationInputTokens
}
return u.GetCacheCreation5mTokens() + u.GetCacheCreation1hTokens()
}
type ClaudeServerToolUse struct {
WebSearchRequests int `json:"web_search_requests,omitempty"`
WebFetchRequests int `json:"web_fetch_requests,omitempty"`
CodeExecutionRequests int `json:"code_execution_requests,omitempty"`
ToolSearchRequests int `json:"tool_search_requests,omitempty"`
}
+87
View File
@@ -0,0 +1,87 @@
package dto
import (
"net/http"
"strings"
"github.com/QuantumNous/new-api/relaykit/types"
)
type EmbeddingOptions struct {
Seed int `json:"seed,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopK int `json:"top_k,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"`
PresencePenalty *float64 `json:"presence_penalty,omitempty"`
NumPredict int `json:"num_predict,omitempty"`
NumCtx int `json:"num_ctx,omitempty"`
}
type EmbeddingRequest struct {
Model string `json:"model"`
Input any `json:"input"`
EncodingFormat string `json:"encoding_format,omitempty"`
Dimensions *int `json:"dimensions,omitempty"`
User string `json:"user,omitempty"`
Seed *float64 `json:"seed,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"`
PresencePenalty *float64 `json:"presence_penalty,omitempty"`
}
func (r *EmbeddingRequest) GetTokenCountMeta() *types.TokenCountMeta {
var texts = make([]string, 0)
inputs := r.ParseInput()
for _, input := range inputs {
texts = append(texts, input)
}
return &types.TokenCountMeta{
CombineText: strings.Join(texts, "\n"),
}
}
func (r *EmbeddingRequest) IsStream(c *http.Request) bool {
return false
}
func (r *EmbeddingRequest) SetModelName(modelName string) {
if modelName != "" {
r.Model = modelName
}
}
func (r *EmbeddingRequest) ParseInput() []string {
if r.Input == nil {
return make([]string, 0)
}
var input []string
switch r.Input.(type) {
case string:
input = []string{r.Input.(string)}
case []any:
input = make([]string, 0, len(r.Input.([]any)))
for _, item := range r.Input.([]any) {
if str, ok := item.(string); ok {
input = append(input, str)
}
}
}
return input
}
type EmbeddingResponseItem struct {
Object string `json:"object"`
Index int `json:"index"`
Embedding []float64 `json:"embedding"`
}
type EmbeddingResponse struct {
Object string `json:"object"`
Data []EmbeddingResponseItem `json:"data"`
Model string `json:"model"`
Usage `json:"usage"`
}
+93
View File
@@ -0,0 +1,93 @@
package dto
import (
"encoding/json"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
//type OpenAIError struct {
// Message string `json:"message"`
// Type string `json:"type"`
// Param string `json:"param"`
// Code any `json:"code"`
//}
type OpenAIErrorWithStatusCode struct {
Error types.OpenAIError `json:"error"`
StatusCode int `json:"status_code"`
LocalError bool
}
type GeneralErrorResponse struct {
Error json.RawMessage `json:"error"`
Message string `json:"message"`
Msg string `json:"msg"`
Err string `json:"err"`
ErrorMsg string `json:"error_msg"`
Metadata json.RawMessage `json:"metadata,omitempty"`
Detail string `json:"detail,omitempty"`
Header struct {
Message string `json:"message"`
} `json:"header"`
Response struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
} `json:"response"`
}
func (e GeneralErrorResponse) TryToOpenAIError() *types.OpenAIError {
var openAIError types.OpenAIError
if len(e.Error) > 0 {
err := kitutil.Unmarshal(e.Error, &openAIError)
if err == nil && openAIError.Message != "" {
return &openAIError
}
}
return nil
}
func (e GeneralErrorResponse) ToMessage() string {
if len(e.Error) > 0 {
switch kitutil.GetJsonType(e.Error) {
case "object":
var openAIError types.OpenAIError
err := kitutil.Unmarshal(e.Error, &openAIError)
if err == nil && openAIError.Message != "" {
return openAIError.Message
}
case "string":
var msg string
err := kitutil.Unmarshal(e.Error, &msg)
if err == nil && msg != "" {
return msg
}
default:
return string(e.Error)
}
}
if e.Message != "" {
return e.Message
}
if e.Msg != "" {
return e.Msg
}
if e.Err != "" {
return e.Err
}
if e.ErrorMsg != "" {
return e.ErrorMsg
}
if e.Detail != "" {
return e.Detail
}
if e.Header.Message != "" {
return e.Header.Message
}
if e.Response.Error.Message != "" {
return e.Response.Error.Message
}
return ""
}
+652
View File
@@ -0,0 +1,652 @@
package dto
import (
"encoding/json"
"net/http"
"strings"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
type GeminiChatRequest struct {
Requests []GeminiChatRequest `json:"requests,omitempty"` // For batch requests
Contents []GeminiChatContent `json:"contents"`
SafetySettings []GeminiChatSafetySettings `json:"safetySettings,omitempty"`
GenerationConfig GeminiChatGenerationConfig `json:"generationConfig"`
Tools json.RawMessage `json:"tools,omitempty"`
ToolConfig *ToolConfig `json:"toolConfig,omitempty"`
SystemInstructions *GeminiChatContent `json:"systemInstruction,omitempty"`
CachedContent string `json:"cachedContent,omitempty"`
}
// UnmarshalJSON allows GeminiChatRequest to accept both snake_case and camelCase fields.
func (r *GeminiChatRequest) UnmarshalJSON(data []byte) error {
type Alias GeminiChatRequest
var aux struct {
Alias
SystemInstructionSnake *GeminiChatContent `json:"system_instruction,omitempty"`
}
if err := kitutil.Unmarshal(data, &aux); err != nil {
return err
}
*r = GeminiChatRequest(aux.Alias)
if aux.SystemInstructionSnake != nil {
r.SystemInstructions = aux.SystemInstructionSnake
}
return nil
}
type ToolConfig struct {
FunctionCallingConfig *FunctionCallingConfig `json:"functionCallingConfig,omitempty"`
RetrievalConfig *RetrievalConfig `json:"retrievalConfig,omitempty"`
IncludeServerSideToolInvocations *bool `json:"includeServerSideToolInvocations,omitempty"`
}
type FunctionCallingConfig struct {
Mode FunctionCallingConfigMode `json:"mode,omitempty"`
AllowedFunctionNames []string `json:"allowedFunctionNames,omitempty"`
StreamFunctionCallArguments *bool `json:"streamFunctionCallArguments,omitempty"`
}
type FunctionCallingConfigMode string
type RetrievalConfig struct {
LatLng *LatLng `json:"latLng,omitempty"`
LanguageCode string `json:"languageCode,omitempty"`
}
type LatLng struct {
Latitude *float64 `json:"latitude,omitempty"`
Longitude *float64 `json:"longitude,omitempty"`
}
func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
var files []*types.FileMeta = make([]*types.FileMeta, 0)
var maxTokens int
if r.GenerationConfig.MaxOutputTokens != nil && *r.GenerationConfig.MaxOutputTokens > 0 {
maxTokens = int(*r.GenerationConfig.MaxOutputTokens)
}
var inputTexts []string
for _, content := range r.Contents {
for _, part := range content.Parts {
if part.Text != "" {
inputTexts = append(inputTexts, part.Text)
}
if source := part.InlineData.ToFileSource(); source != nil {
mimeType := part.InlineData.MimeType
var fileType types.FileType
if strings.HasPrefix(mimeType, "image/") {
fileType = types.FileTypeImage
} else if strings.HasPrefix(mimeType, "audio/") {
fileType = types.FileTypeAudio
} else if strings.HasPrefix(mimeType, "video/") {
fileType = types.FileTypeVideo
} else {
fileType = types.FileTypeFile
}
files = append(files, &types.FileMeta{
FileType: fileType,
Source: source,
})
}
}
}
inputText := strings.Join(inputTexts, "\n")
return &types.TokenCountMeta{
CombineText: inputText,
Files: files,
MaxTokens: maxTokens,
}
}
func (r *GeminiChatRequest) IsStream(c *http.Request) bool {
if c == nil {
return false
}
if c.URL.Query().Get("alt") == "sse" {
return true
}
// Native Gemini API uses URL action to indicate streaming:
// /v1beta/models/{model}:streamGenerateContent
if strings.Contains(c.URL.Path, "streamGenerateContent") {
return true
}
return false
}
func (r *GeminiChatRequest) SetModelName(modelName string) {
// GeminiChatRequest does not have a model field, so this method does nothing.
}
func (r *GeminiChatRequest) GetTools() []GeminiChatTool {
var tools []GeminiChatTool
if strings.HasPrefix(string(r.Tools), "[") {
// is array
if err := kitutil.Unmarshal(r.Tools, &tools); err != nil {
kitutil.LogError("error_unmarshalling_tools: " + err.Error())
return nil
}
} else if strings.HasPrefix(string(r.Tools), "{") {
// is object
singleTool := GeminiChatTool{}
if err := kitutil.Unmarshal(r.Tools, &singleTool); err != nil {
kitutil.LogError("error_unmarshalling_single_tool: " + err.Error())
return nil
}
tools = []GeminiChatTool{singleTool}
}
return tools
}
func (r *GeminiChatRequest) SetTools(tools []GeminiChatTool) {
if len(tools) == 0 {
r.Tools = json.RawMessage("[]")
return
}
// Marshal the tools to JSON
data, err := kitutil.Marshal(tools)
if err != nil {
kitutil.LogError("error_marshalling_tools: " + err.Error())
return
}
r.Tools = data
}
type GeminiThinkingConfig struct {
IncludeThoughts *bool `json:"includeThoughts,omitempty"`
ThinkingBudget *int `json:"thinkingBudget,omitempty"`
// TODO Conflict with thinkingbudget.
ThinkingLevel string `json:"thinkingLevel,omitempty"`
}
// UnmarshalJSON allows GeminiThinkingConfig to accept both snake_case and camelCase fields.
func (c *GeminiThinkingConfig) UnmarshalJSON(data []byte) error {
type Alias GeminiThinkingConfig
var aux struct {
Alias
IncludeThoughtsSnake *bool `json:"include_thoughts,omitempty"`
ThinkingBudgetSnake *int `json:"thinking_budget,omitempty"`
ThinkingLevelSnake string `json:"thinking_level,omitempty"`
}
if err := kitutil.Unmarshal(data, &aux); err != nil {
return err
}
*c = GeminiThinkingConfig(aux.Alias)
if aux.IncludeThoughtsSnake != nil {
c.IncludeThoughts = aux.IncludeThoughtsSnake
}
if aux.ThinkingBudgetSnake != nil {
c.ThinkingBudget = aux.ThinkingBudgetSnake
}
if aux.ThinkingLevelSnake != "" {
c.ThinkingLevel = aux.ThinkingLevelSnake
}
return nil
}
func (c *GeminiThinkingConfig) SetThinkingBudget(budget int) {
c.ThinkingBudget = &budget
}
type GeminiInlineData struct {
MimeType string `json:"mimeType"`
Data string `json:"data"`
}
func (d *GeminiInlineData) ToFileSource() types.FileSource {
if d == nil || d.Data == "" {
return nil
}
return types.NewFileSourceFromData(d.Data, d.MimeType)
}
// UnmarshalJSON custom unmarshaler for GeminiInlineData to support snake_case and camelCase for MimeType
func (g *GeminiInlineData) UnmarshalJSON(data []byte) error {
type Alias GeminiInlineData // Use type alias to avoid recursion
var aux struct {
Alias
MimeTypeSnake string `json:"mime_type"`
}
if err := kitutil.Unmarshal(data, &aux); err != nil {
return err
}
*g = GeminiInlineData(aux.Alias) // Copy other fields if any in future
// Prioritize snake_case if present
if aux.MimeTypeSnake != "" {
g.MimeType = aux.MimeTypeSnake
} else if aux.MimeType != "" { // Fallback to camelCase from Alias
g.MimeType = aux.MimeType
}
// g.Data would be populated by aux.Alias.Data
return nil
}
type FunctionCall struct {
// ID is optional in the Gemini protocol and identifies the matching function response.
ID string `json:"id,omitempty"`
FunctionName string `json:"name"`
Arguments any `json:"args"`
PartialArgs []GeminiPartialArg `json:"partialArgs,omitempty"`
WillContinue *bool `json:"willContinue,omitempty"`
}
type GeminiPartialArg struct {
JSONPath string `json:"jsonPath"`
NumberValue *float64 `json:"numberValue,omitempty"`
StringValue *string `json:"stringValue,omitempty"`
BoolValue *bool `json:"boolValue,omitempty"`
NullValue json.RawMessage `json:"nullValue,omitempty"`
WillContinue *bool `json:"willContinue,omitempty"`
}
type GeminiFunctionResponse struct {
Name string `json:"name"`
Response map[string]any `json:"response"`
WillContinue json.RawMessage `json:"willContinue,omitempty"`
Scheduling json.RawMessage `json:"scheduling,omitempty"`
Parts json.RawMessage `json:"parts,omitempty"`
ID json.RawMessage `json:"id,omitempty"`
}
type GeminiPartExecutableCode struct {
Language string `json:"language,omitempty"`
Code string `json:"code,omitempty"`
}
type GeminiPartCodeExecutionResult struct {
Outcome string `json:"outcome,omitempty"`
Output string `json:"output,omitempty"`
}
type GeminiFileData struct {
MimeType string `json:"mimeType,omitempty"`
FileUri string `json:"fileUri,omitempty"`
}
type GeminiPart struct {
Text string `json:"text,omitempty"`
Thought bool `json:"thought,omitempty"`
InlineData *GeminiInlineData `json:"inlineData,omitempty"`
FunctionCall *FunctionCall `json:"functionCall,omitempty"`
ThoughtSignature json.RawMessage `json:"thoughtSignature,omitempty"`
FunctionResponse *GeminiFunctionResponse `json:"functionResponse,omitempty"`
// Optional. Media resolution for the input media.
MediaResolution json.RawMessage `json:"mediaResolution,omitempty"`
VideoMetadata json.RawMessage `json:"videoMetadata,omitempty"`
FileData *GeminiFileData `json:"fileData,omitempty"`
ExecutableCode *GeminiPartExecutableCode `json:"executableCode,omitempty"`
CodeExecutionResult *GeminiPartCodeExecutionResult `json:"codeExecutionResult,omitempty"`
}
// UnmarshalJSON custom unmarshaler for GeminiPart to support snake_case and camelCase for InlineData
func (p *GeminiPart) UnmarshalJSON(data []byte) error {
// Alias to avoid recursion during unmarshalling
type Alias GeminiPart
var aux struct {
Alias
InlineDataSnake *GeminiInlineData `json:"inline_data,omitempty"` // snake_case variant
}
if err := kitutil.Unmarshal(data, &aux); err != nil {
return err
}
// Assign fields from alias
*p = GeminiPart(aux.Alias)
// Prioritize snake_case for InlineData if present
if aux.InlineDataSnake != nil {
p.InlineData = aux.InlineDataSnake
} else if aux.InlineData != nil { // Fallback to camelCase from Alias
p.InlineData = aux.InlineData
}
// Other fields like Text, FunctionCall etc. are already populated via aux.Alias
return nil
}
type GeminiChatContent struct {
Role string `json:"role,omitempty"`
Parts []GeminiPart `json:"parts"`
}
type GeminiChatSafetySettings struct {
Category string `json:"category"`
Threshold string `json:"threshold"`
}
type GeminiChatTool struct {
GoogleSearch any `json:"googleSearch,omitempty"`
GoogleSearchRetrieval any `json:"googleSearchRetrieval,omitempty"`
GoogleMaps json.RawMessage `json:"googleMaps,omitempty"`
EnterpriseWebSearch json.RawMessage `json:"enterpriseWebSearch,omitempty"`
CodeExecution any `json:"codeExecution,omitempty"`
FunctionDeclarations any `json:"functionDeclarations,omitempty"`
URLContext any `json:"urlContext,omitempty"`
FileSearch json.RawMessage `json:"fileSearch,omitempty"`
ComputerUse json.RawMessage `json:"computerUse,omitempty"`
Retrieval json.RawMessage `json:"retrieval,omitempty"`
}
type GeminiChatGenerationConfig struct {
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"topP,omitempty"`
TopK *float64 `json:"topK,omitempty"`
MaxOutputTokens *uint `json:"maxOutputTokens,omitempty"`
CandidateCount *int `json:"candidateCount,omitempty"`
StopSequences []string `json:"stopSequences,omitempty"`
ResponseMimeType string `json:"responseMimeType,omitempty"`
ResponseSchema any `json:"responseSchema,omitempty"`
ResponseJsonSchema json.RawMessage `json:"responseJsonSchema,omitempty"`
PresencePenalty *float32 `json:"presencePenalty,omitempty"`
FrequencyPenalty *float32 `json:"frequencyPenalty,omitempty"`
ResponseLogprobs *bool `json:"responseLogprobs,omitempty"`
Logprobs *int32 `json:"logprobs,omitempty"`
EnableEnhancedCivicAnswers *bool `json:"enableEnhancedCivicAnswers,omitempty"`
MediaResolution MediaResolution `json:"mediaResolution,omitempty"`
Seed *int64 `json:"seed,omitempty"`
ResponseModalities []string `json:"responseModalities,omitempty"`
ThinkingConfig *GeminiThinkingConfig `json:"thinkingConfig,omitempty"`
SpeechConfig json.RawMessage `json:"speechConfig,omitempty"` // RawMessage to allow flexible speech config
ImageConfig json.RawMessage `json:"imageConfig,omitempty"` // RawMessage to allow flexible image config
}
// UnmarshalJSON allows GeminiChatGenerationConfig to accept both snake_case and camelCase fields.
func (c *GeminiChatGenerationConfig) UnmarshalJSON(data []byte) error {
type Alias GeminiChatGenerationConfig
var aux struct {
Alias
TopPSnake *float64 `json:"top_p,omitempty"`
TopKSnake *float64 `json:"top_k,omitempty"`
MaxOutputTokensSnake *uint `json:"max_output_tokens,omitempty"`
CandidateCountSnake *int `json:"candidate_count,omitempty"`
StopSequencesSnake []string `json:"stop_sequences,omitempty"`
ResponseMimeTypeSnake string `json:"response_mime_type,omitempty"`
ResponseSchemaSnake any `json:"response_schema,omitempty"`
ResponseJsonSchemaSnake json.RawMessage `json:"response_json_schema,omitempty"`
PresencePenaltySnake *float32 `json:"presence_penalty,omitempty"`
FrequencyPenaltySnake *float32 `json:"frequency_penalty,omitempty"`
ResponseLogprobsSnake *bool `json:"response_logprobs,omitempty"`
EnableEnhancedCivicAnswersSnake *bool `json:"enable_enhanced_civic_answers,omitempty"`
MediaResolutionSnake MediaResolution `json:"media_resolution,omitempty"`
ResponseModalitiesSnake []string `json:"response_modalities,omitempty"`
ThinkingConfigSnake *GeminiThinkingConfig `json:"thinking_config,omitempty"`
SpeechConfigSnake json.RawMessage `json:"speech_config,omitempty"`
ImageConfigSnake json.RawMessage `json:"image_config,omitempty"`
}
if err := kitutil.Unmarshal(data, &aux); err != nil {
return err
}
*c = GeminiChatGenerationConfig(aux.Alias)
// Prioritize snake_case if present
if aux.TopPSnake != nil {
c.TopP = aux.TopPSnake
}
if aux.TopKSnake != nil {
c.TopK = aux.TopKSnake
}
if aux.MaxOutputTokensSnake != nil {
c.MaxOutputTokens = aux.MaxOutputTokensSnake
}
if aux.CandidateCountSnake != nil {
c.CandidateCount = aux.CandidateCountSnake
}
if len(aux.StopSequencesSnake) > 0 {
c.StopSequences = aux.StopSequencesSnake
}
if aux.ResponseMimeTypeSnake != "" {
c.ResponseMimeType = aux.ResponseMimeTypeSnake
}
if aux.ResponseSchemaSnake != nil {
c.ResponseSchema = aux.ResponseSchemaSnake
}
if len(aux.ResponseJsonSchemaSnake) > 0 {
c.ResponseJsonSchema = aux.ResponseJsonSchemaSnake
}
if aux.PresencePenaltySnake != nil {
c.PresencePenalty = aux.PresencePenaltySnake
}
if aux.FrequencyPenaltySnake != nil {
c.FrequencyPenalty = aux.FrequencyPenaltySnake
}
if aux.ResponseLogprobsSnake != nil {
c.ResponseLogprobs = aux.ResponseLogprobsSnake
}
if aux.EnableEnhancedCivicAnswersSnake != nil {
c.EnableEnhancedCivicAnswers = aux.EnableEnhancedCivicAnswersSnake
}
if aux.MediaResolutionSnake != "" {
c.MediaResolution = aux.MediaResolutionSnake
}
if len(aux.ResponseModalitiesSnake) > 0 {
c.ResponseModalities = aux.ResponseModalitiesSnake
}
if aux.ThinkingConfigSnake != nil {
c.ThinkingConfig = aux.ThinkingConfigSnake
}
if len(aux.SpeechConfigSnake) > 0 {
c.SpeechConfig = aux.SpeechConfigSnake
}
if len(aux.ImageConfigSnake) > 0 {
c.ImageConfig = aux.ImageConfigSnake
}
return nil
}
type MediaResolution string
type GeminiChatCandidate struct {
Content GeminiChatContent `json:"content"`
FinishReason *string `json:"finishReason"`
Index int64 `json:"index"`
SafetyRatings []GeminiChatSafetyRating `json:"safetyRatings"`
GroundingMetadata *GeminiGroundingMetadata `json:"groundingMetadata,omitempty"`
}
type GeminiGroundingMetadata struct {
WebSearchQueries []string `json:"webSearchQueries,omitempty"`
RetrievalQueries []string `json:"retrievalQueries,omitempty"`
GroundingChunks json.RawMessage `json:"groundingChunks,omitempty"`
GroundingSupports json.RawMessage `json:"groundingSupports,omitempty"`
SearchEntryPoint json.RawMessage `json:"searchEntryPoint,omitempty"`
RetrievalMetadata json.RawMessage `json:"retrievalMetadata,omitempty"`
SourceFlaggingUris json.RawMessage `json:"sourceFlaggingUris,omitempty"`
GoogleMapsWidgetContextToken string `json:"googleMapsWidgetContextToken,omitempty"`
}
type GeminiChatSafetyRating struct {
Category string `json:"category"`
Probability string `json:"probability"`
}
type GeminiChatPromptFeedback struct {
SafetyRatings []GeminiChatSafetyRating `json:"safetyRatings"`
BlockReason *string `json:"blockReason,omitempty"`
}
type GeminiChatResponse struct {
Candidates []GeminiChatCandidate `json:"candidates"`
PromptFeedback *GeminiChatPromptFeedback `json:"promptFeedback,omitempty"`
UsageMetadata GeminiUsageMetadata `json:"usageMetadata"`
HasUsageMetadata bool `json:"-"`
}
// UnmarshalJSON records whether Gemini returned usageMetadata while preserving
// the historical wire shape that always marshals the usageMetadata field.
//
// IMPORTANT: aux shadows GeminiChatResponse. Any field added to
// GeminiChatResponse must also be added to aux (and copied below), otherwise it
// is silently dropped during unmarshal.
func (r *GeminiChatResponse) UnmarshalJSON(data []byte) error {
var aux struct {
Candidates []GeminiChatCandidate `json:"candidates"`
PromptFeedback *GeminiChatPromptFeedback `json:"promptFeedback,omitempty"`
UsageMetadata *GeminiUsageMetadata `json:"usageMetadata"`
}
if err := kitutil.Unmarshal(data, &aux); err != nil {
return err
}
r.Candidates = aux.Candidates
r.PromptFeedback = aux.PromptFeedback
r.HasUsageMetadata = aux.UsageMetadata != nil
if aux.UsageMetadata != nil {
r.UsageMetadata = *aux.UsageMetadata
} else {
r.UsageMetadata = GeminiUsageMetadata{}
}
return nil
}
func (r *GeminiChatResponse) GetUsageMetadata() *GeminiUsageMetadata {
if r == nil {
return nil
}
if r.HasUsageMetadata || HasGeminiUsageMetadataTokens(&r.UsageMetadata) {
return &r.UsageMetadata
}
return nil
}
type GeminiUsageMetadata struct {
PromptTokenCount int `json:"promptTokenCount"`
ToolUsePromptTokenCount int `json:"toolUsePromptTokenCount"`
CandidatesTokenCount int `json:"candidatesTokenCount"`
TotalTokenCount int `json:"totalTokenCount"`
ThoughtsTokenCount int `json:"thoughtsTokenCount"`
CachedContentTokenCount int `json:"cachedContentTokenCount"`
PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"`
ToolUsePromptTokensDetails []GeminiPromptTokensDetails `json:"toolUsePromptTokensDetails"`
CandidatesTokensDetails []GeminiPromptTokensDetails `json:"candidatesTokensDetails"`
BillingUsage *BillingUsage `json:"billing_usage,omitempty"`
}
type GeminiPromptTokensDetails struct {
Modality string `json:"modality"`
TokenCount int `json:"tokenCount"`
}
// Imagen related structs
type GeminiImageRequest struct {
Instances []GeminiImageInstance `json:"instances"`
Parameters GeminiImageParameters `json:"parameters"`
}
type GeminiImageInstance struct {
Prompt string `json:"prompt"`
}
type GeminiImageParameters struct {
SampleCount int `json:"sampleCount,omitempty"`
AspectRatio string `json:"aspectRatio,omitempty"`
PersonGeneration string `json:"personGeneration,omitempty"`
ImageSize string `json:"imageSize,omitempty"`
}
type GeminiImageResponse struct {
Predictions []GeminiImagePrediction `json:"predictions"`
}
type GeminiImagePrediction struct {
MimeType string `json:"mimeType"`
BytesBase64Encoded string `json:"bytesBase64Encoded"`
RaiFilteredReason string `json:"raiFilteredReason,omitempty"`
SafetyAttributes any `json:"safetyAttributes,omitempty"`
}
// Embedding related structs
type GeminiEmbeddingRequest struct {
Model string `json:"model,omitempty"`
Content GeminiChatContent `json:"content"`
TaskType string `json:"taskType,omitempty"`
Title string `json:"title,omitempty"`
OutputDimensionality int `json:"outputDimensionality,omitempty"`
}
func (r *GeminiEmbeddingRequest) IsStream(c *http.Request) bool {
// Gemini embedding requests are not streamed
return false
}
func (r *GeminiEmbeddingRequest) GetTokenCountMeta() *types.TokenCountMeta {
var inputTexts []string
for _, part := range r.Content.Parts {
if part.Text != "" {
inputTexts = append(inputTexts, part.Text)
}
}
inputText := strings.Join(inputTexts, "\n")
return &types.TokenCountMeta{
CombineText: inputText,
}
}
func (r *GeminiEmbeddingRequest) SetModelName(modelName string) {
if modelName != "" {
r.Model = modelName
}
}
type GeminiBatchEmbeddingRequest struct {
Requests []*GeminiEmbeddingRequest `json:"requests"`
}
func (r *GeminiBatchEmbeddingRequest) IsStream(c *http.Request) bool {
// Gemini batch embedding requests are not streamed
return false
}
func (r *GeminiBatchEmbeddingRequest) GetTokenCountMeta() *types.TokenCountMeta {
var inputTexts []string
for _, request := range r.Requests {
meta := request.GetTokenCountMeta()
if meta != nil && meta.CombineText != "" {
inputTexts = append(inputTexts, meta.CombineText)
}
}
inputText := strings.Join(inputTexts, "\n")
return &types.TokenCountMeta{
CombineText: inputText,
}
}
func (r *GeminiBatchEmbeddingRequest) SetModelName(modelName string) {
if modelName != "" {
for _, req := range r.Requests {
req.SetModelName(modelName)
}
}
}
type GeminiEmbeddingResponse struct {
Embedding ContentEmbedding `json:"embedding"`
}
type GeminiBatchEmbeddingResponse struct {
Embeddings []*ContentEmbedding `json:"embeddings"`
}
type ContentEmbedding struct {
Values []float64 `json:"values"`
}
@@ -0,0 +1,89 @@
package dto
import (
"testing"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGeminiChatGenerationConfigPreservesExplicitZeroValuesCamelCase(t *testing.T) {
raw := []byte(`{
"contents":[{"role":"user","parts":[{"text":"hello"}]}],
"generationConfig":{
"topP":0,
"topK":0,
"maxOutputTokens":0,
"candidateCount":0,
"seed":0,
"responseLogprobs":false
}
}`)
var req GeminiChatRequest
require.NoError(t, kitutil.Unmarshal(raw, &req))
encoded, err := kitutil.Marshal(req)
require.NoError(t, err)
var out map[string]any
require.NoError(t, kitutil.Unmarshal(encoded, &out))
generationConfig, ok := out["generationConfig"].(map[string]any)
require.True(t, ok)
assert.Contains(t, generationConfig, "topP")
assert.Contains(t, generationConfig, "topK")
assert.Contains(t, generationConfig, "maxOutputTokens")
assert.Contains(t, generationConfig, "candidateCount")
assert.Contains(t, generationConfig, "seed")
assert.Contains(t, generationConfig, "responseLogprobs")
assert.Equal(t, float64(0), generationConfig["topP"])
assert.Equal(t, float64(0), generationConfig["topK"])
assert.Equal(t, float64(0), generationConfig["maxOutputTokens"])
assert.Equal(t, float64(0), generationConfig["candidateCount"])
assert.Equal(t, float64(0), generationConfig["seed"])
assert.Equal(t, false, generationConfig["responseLogprobs"])
}
func TestGeminiChatGenerationConfigPreservesExplicitZeroValuesSnakeCase(t *testing.T) {
raw := []byte(`{
"contents":[{"role":"user","parts":[{"text":"hello"}]}],
"generationConfig":{
"top_p":0,
"top_k":0,
"max_output_tokens":0,
"candidate_count":0,
"seed":0,
"response_logprobs":false
}
}`)
var req GeminiChatRequest
require.NoError(t, kitutil.Unmarshal(raw, &req))
encoded, err := kitutil.Marshal(req)
require.NoError(t, err)
var out map[string]any
require.NoError(t, kitutil.Unmarshal(encoded, &out))
generationConfig, ok := out["generationConfig"].(map[string]any)
require.True(t, ok)
assert.Contains(t, generationConfig, "topP")
assert.Contains(t, generationConfig, "topK")
assert.Contains(t, generationConfig, "maxOutputTokens")
assert.Contains(t, generationConfig, "candidateCount")
assert.Contains(t, generationConfig, "seed")
assert.Contains(t, generationConfig, "responseLogprobs")
assert.Equal(t, float64(0), generationConfig["topP"])
assert.Equal(t, float64(0), generationConfig["topK"])
assert.Equal(t, float64(0), generationConfig["maxOutputTokens"])
assert.Equal(t, float64(0), generationConfig["candidateCount"])
assert.Equal(t, float64(0), generationConfig["seed"])
assert.Equal(t, false, generationConfig["responseLogprobs"])
}
+68
View File
@@ -0,0 +1,68 @@
package dto
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
)
func TestGeminiChatRequest_IsStream(t *testing.T) {
tests := []struct {
name string
path string
query string
expected bool
}{
{
name: "streamGenerateContent without alt=sse",
path: "/v1beta/models/gemini-2.0-flash:streamGenerateContent",
query: "key=sk-xxx",
expected: true,
},
{
name: "streamGenerateContent with alt=sse",
path: "/v1beta/models/gemini-2.0-flash:streamGenerateContent",
query: "alt=sse&key=sk-xxx",
expected: true,
},
{
name: "generateContent without alt=sse",
path: "/v1beta/models/gemini-2.0-flash:generateContent",
query: "key=sk-xxx",
expected: false,
},
{
name: "generateContent with alt=sse",
path: "/v1beta/models/gemini-2.0-flash:generateContent",
query: "alt=sse",
expected: true,
},
{
name: "GenerateContent capitalized",
path: "/v1beta/models/gemini-2.0-flash:GenerateContent",
query: "key=sk-xxx",
expected: false,
},
{
name: "embedding path",
path: "/v1beta/models/gemini-2.0-flash:embedContent",
query: "",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
url := tt.path
if tt.query != "" {
url += "?" + tt.query
}
httpReq, err := http.NewRequest("POST", url, nil)
assert.NoError(t, err)
req := &GeminiChatRequest{}
assert.Equal(t, tt.expected, req.IsStream(httpReq))
})
}
}
+34
View File
@@ -0,0 +1,34 @@
package dto
import (
"testing"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGeminiChatResponseUsageMetadataPresence(t *testing.T) {
var missing GeminiChatResponse
require.NoError(t, kitutil.Unmarshal([]byte(`{"candidates":[]}`), &missing))
assert.False(t, missing.HasUsageMetadata)
assert.Nil(t, missing.GetUsageMetadata())
var empty GeminiChatResponse
require.NoError(t, kitutil.Unmarshal([]byte(`{"candidates":[],"usageMetadata":{}}`), &empty))
assert.True(t, empty.HasUsageMetadata)
require.NotNil(t, empty.GetUsageMetadata())
assert.False(t, HasGeminiUsageMetadataTokens(empty.GetUsageMetadata()))
var populated GeminiChatResponse
require.NoError(t, kitutil.Unmarshal([]byte(`{"candidates":[],"usageMetadata":{"promptTokenCount":3}}`), &populated))
assert.True(t, populated.HasUsageMetadata)
require.NotNil(t, populated.GetUsageMetadata())
assert.True(t, HasGeminiUsageMetadataTokens(populated.GetUsageMetadata()))
}
func TestGeminiChatResponseMarshalKeepsUsageMetadataField(t *testing.T) {
data, err := kitutil.Marshal(GeminiChatResponse{})
require.NoError(t, err)
assert.Contains(t, string(data), `"usageMetadata"`)
}
+25
View File
@@ -0,0 +1,25 @@
package dto
type Notify struct {
Type string `json:"type"`
Title string `json:"title"`
Content string `json:"content"`
Values []any `json:"values"`
}
const ContentValueParam = "{{value}}"
const (
NotifyTypeQuotaExceed = "quota_exceed"
NotifyTypeChannelUpdate = "channel_update"
NotifyTypeChannelTest = "channel_test"
)
func NewNotify(t string, title string, content string, values []any) Notify {
return Notify{
Type: t,
Title: title,
Content: content,
Values: values,
}
}
+20
View File
@@ -0,0 +1,20 @@
package dto
import (
"encoding/json"
"github.com/QuantumNous/new-api/relaykit/types"
)
type OpenAIResponsesCompactionResponse struct {
ID string `json:"id"`
Object string `json:"object"`
CreatedAt int `json:"created_at"`
Output json.RawMessage `json:"output"`
Usage *Usage `json:"usage"`
Error any `json:"error,omitempty"`
}
func (o *OpenAIResponsesCompactionResponse) GetOpenAIError() *types.OpenAIError {
return GetOpenAIError(o.Error)
}
+192
View File
@@ -0,0 +1,192 @@
package dto
import (
"encoding/json"
"net/http"
"reflect"
"strings"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
// MaxImageN caps the image generation count. Without this bound a huge or
// wrapped-negative n overflows quota calculation into a negative charge.
const MaxImageN = 128
type ImageRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt" binding:"required"`
N *uint `json:"n,omitempty"`
Size string `json:"size,omitempty"`
Quality string `json:"quality,omitempty"`
ResponseFormat string `json:"response_format,omitempty"`
Style json.RawMessage `json:"style,omitempty"`
User json.RawMessage `json:"user,omitempty"`
ExtraFields json.RawMessage `json:"extra_fields,omitempty"`
Background json.RawMessage `json:"background,omitempty"`
Moderation json.RawMessage `json:"moderation,omitempty"`
OutputFormat json.RawMessage `json:"output_format,omitempty"`
OutputCompression json.RawMessage `json:"output_compression,omitempty"`
PartialImages json.RawMessage `json:"partial_images,omitempty"`
Stream *bool `json:"stream,omitempty"`
Images json.RawMessage `json:"images,omitempty"`
Mask json.RawMessage `json:"mask,omitempty"`
InputFidelity json.RawMessage `json:"input_fidelity,omitempty"`
Watermark *bool `json:"watermark,omitempty"`
// zhipu 4v
WatermarkEnabled json.RawMessage `json:"watermark_enabled,omitempty"`
UserId json.RawMessage `json:"user_id,omitempty"`
Image json.RawMessage `json:"image,omitempty"`
// 用匿名参数接收额外参数
Extra map[string]json.RawMessage `json:"-"`
}
func (i *ImageRequest) UnmarshalJSON(data []byte) error {
// 先解析成 map[string]interface{}
var rawMap map[string]json.RawMessage
if err := kitutil.Unmarshal(data, &rawMap); err != nil {
return err
}
// 用 struct tag 获取所有已定义字段名
knownFields := GetJSONFieldNames(reflect.TypeFor[ImageRequest]())
// 再正常解析已定义字段
type Alias ImageRequest
var known Alias
if err := kitutil.Unmarshal(data, &known); err != nil {
return err
}
*i = ImageRequest(known)
// 提取多余字段
i.Extra = make(map[string]json.RawMessage)
for k, v := range rawMap {
if _, ok := knownFields[k]; !ok {
i.Extra[k] = v
}
}
return nil
}
// 序列化时需要重新把字段平铺
func (r ImageRequest) MarshalJSON() ([]byte, error) {
// 将已定义字段转为 map
type Alias ImageRequest
alias := Alias(r)
base, err := kitutil.Marshal(alias)
if err != nil {
return nil, err
}
var baseMap map[string]json.RawMessage
if err := kitutil.Unmarshal(base, &baseMap); err != nil {
return nil, err
}
// 不能合并ExtraFields!!!!!!!!
// 合并 ExtraFields
//for k, v := range r.Extra {
// if _, exists := baseMap[k]; !exists {
// baseMap[k] = v
// }
//}
return kitutil.Marshal(baseMap)
}
func GetJSONFieldNames(t reflect.Type) map[string]struct{} {
fields := make(map[string]struct{})
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
// 跳过匿名字段(例如 ExtraFields)
if field.Anonymous {
continue
}
tag := field.Tag.Get("json")
if tag == "-" || tag == "" {
continue
}
// 取逗号前字段名(排除 omitempty 等)
name := tag
if commaIdx := indexComma(tag); commaIdx != -1 {
name = tag[:commaIdx]
}
fields[name] = struct{}{}
}
return fields
}
func indexComma(s string) int {
for i := 0; i < len(s); i++ {
if s[i] == ',' {
return i
}
}
return -1
}
func (i *ImageRequest) GetTokenCountMeta() *types.TokenCountMeta {
var sizeRatio = 1.0
var qualityRatio = 1.0
if strings.HasPrefix(i.Model, "dall-e") {
// Size
if i.Size == "256x256" {
sizeRatio = 0.4
} else if i.Size == "512x512" {
sizeRatio = 0.45
} else if i.Size == "1024x1024" {
sizeRatio = 1
} else if i.Size == "1024x1792" || i.Size == "1792x1024" {
sizeRatio = 2
}
if i.Model == "dall-e-3" && i.Quality == "hd" {
qualityRatio = 2.0
if i.Size == "1024x1792" || i.Size == "1792x1024" {
qualityRatio = 1.5
}
}
}
imageN := uint(1)
if i.N != nil && *i.N > 0 {
imageN = *i.N
}
// Keep n separate from ImagePriceRatio so size/quality and count remain
// independent billing dimensions. Fixed-price pre-consume stores this on
// PriceData, and image settlement reuses or replaces the same "n" ratio.
return &types.TokenCountMeta{
CombineText: i.Prompt,
MaxTokens: 1584,
ImagePriceRatio: sizeRatio * qualityRatio,
BillingRatios: map[string]float64{"n": float64(imageN)},
}
}
func (i *ImageRequest) IsStream(c *http.Request) bool {
return i.Stream != nil && *i.Stream
}
func (i *ImageRequest) SetModelName(modelName string) {
if modelName != "" {
i.Model = modelName
}
}
type ImageResponse struct {
Data []ImageData `json:"data"`
Created int64 `json:"created"`
Metadata json.RawMessage `json:"metadata,omitempty"`
}
type ImageData struct {
Url string `json:"url"`
B64Json string `json:"b64_json"`
RevisedPrompt string `json:"revised_prompt"`
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,309 @@
package dto
import (
"encoding/json"
"testing"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestGeneralOpenAIRequestPreserveExplicitZeroValues(t *testing.T) {
raw := []byte(`{
"model":"gpt-4.1",
"stream":false,
"max_tokens":0,
"max_completion_tokens":0,
"top_p":0,
"top_k":0,
"n":0,
"frequency_penalty":0,
"presence_penalty":0,
"seed":0,
"logprobs":false,
"top_logprobs":0,
"dimensions":0,
"return_images":false,
"return_related_questions":false
}`)
var req GeneralOpenAIRequest
err := kitutil.Unmarshal(raw, &req)
require.NoError(t, err)
encoded, err := kitutil.Marshal(req)
require.NoError(t, err)
require.True(t, gjson.GetBytes(encoded, "stream").Exists())
require.True(t, gjson.GetBytes(encoded, "max_tokens").Exists())
require.True(t, gjson.GetBytes(encoded, "max_completion_tokens").Exists())
require.True(t, gjson.GetBytes(encoded, "top_p").Exists())
require.True(t, gjson.GetBytes(encoded, "top_k").Exists())
require.True(t, gjson.GetBytes(encoded, "n").Exists())
require.True(t, gjson.GetBytes(encoded, "frequency_penalty").Exists())
require.True(t, gjson.GetBytes(encoded, "presence_penalty").Exists())
require.True(t, gjson.GetBytes(encoded, "seed").Exists())
require.True(t, gjson.GetBytes(encoded, "logprobs").Exists())
require.True(t, gjson.GetBytes(encoded, "top_logprobs").Exists())
require.True(t, gjson.GetBytes(encoded, "dimensions").Exists())
require.True(t, gjson.GetBytes(encoded, "return_images").Exists())
require.True(t, gjson.GetBytes(encoded, "return_related_questions").Exists())
}
func TestGeneralOpenAIRequestPreserveQwenThinkingBudget(t *testing.T) {
raw := []byte(`{
"model":"qwen-plus",
"thinking_budget":0
}`)
var req GeneralOpenAIRequest
err := kitutil.Unmarshal(raw, &req)
require.NoError(t, err)
encoded, err := kitutil.Marshal(req)
require.NoError(t, err)
value := gjson.GetBytes(encoded, "thinking_budget")
assert.True(t, value.Exists())
assert.Equal(t, int64(0), value.Int())
}
func TestGeneralOpenAIRequestPreserveQwQThinkingBudget(t *testing.T) {
req := GeneralOpenAIRequest{
Model: "QwQ-32B",
ThinkingBudget: json.RawMessage(`128`),
}
encoded, err := kitutil.Marshal(req)
require.NoError(t, err)
value := gjson.GetBytes(encoded, "thinking_budget")
assert.True(t, value.Exists())
assert.Equal(t, int64(128), value.Int())
}
func TestGeneralOpenAIRequestDropsThinkingBudgetForNonQwenModel(t *testing.T) {
req := GeneralOpenAIRequest{
Model: "gpt-4.1",
ThinkingBudget: json.RawMessage(`128`),
}
encoded, err := kitutil.Marshal(req)
require.NoError(t, err)
assert.False(t, gjson.GetBytes(encoded, "thinking_budget").Exists())
}
func TestIsQwenThinkingBudgetModel(t *testing.T) {
tests := []struct {
model string
want bool
}{
{model: "qwen-plus", want: true},
{model: "Qwen/Qwen3-235B-A22B-Thinking-2507", want: true},
{model: "qwq-32b", want: true},
{model: "provider/qwen-plus", want: true},
{model: "provider/qwq-32b", want: true},
{model: "gpt-4.1", want: false},
{model: "deepseek-r1", want: false},
}
for _, tt := range tests {
t.Run(tt.model, func(t *testing.T) {
assert.Equal(t, tt.want, IsQwenThinkingBudgetModel(tt.model))
})
}
}
func TestOpenAIResponsesRequestPreserveExplicitZeroValues(t *testing.T) {
raw := []byte(`{
"model":"gpt-4.1",
"max_output_tokens":0,
"max_tool_calls":0,
"stream":false,
"top_p":0,
"frequency_penalty":0,
"presence_penalty":0
}`)
var req OpenAIResponsesRequest
err := kitutil.Unmarshal(raw, &req)
require.NoError(t, err)
encoded, err := kitutil.Marshal(req)
require.NoError(t, err)
require.True(t, gjson.GetBytes(encoded, "max_output_tokens").Exists())
require.True(t, gjson.GetBytes(encoded, "max_tool_calls").Exists())
require.True(t, gjson.GetBytes(encoded, "stream").Exists())
require.True(t, gjson.GetBytes(encoded, "top_p").Exists())
require.True(t, gjson.GetBytes(encoded, "frequency_penalty").Exists())
require.True(t, gjson.GetBytes(encoded, "presence_penalty").Exists())
}
func TestOpenAIResponsesRequestPreserveQwenThinkingBudget(t *testing.T) {
req := OpenAIResponsesRequest{
Model: "qwen-plus",
ThinkingBudget: json.RawMessage(`0`),
}
encoded, err := kitutil.Marshal(req)
require.NoError(t, err)
value := gjson.GetBytes(encoded, "thinking_budget")
assert.True(t, value.Exists())
assert.Equal(t, int64(0), value.Int())
}
func TestOpenAIResponsesRequestPreserveQwQThinkingBudget(t *testing.T) {
req := OpenAIResponsesRequest{
Model: "provider/QwQ-32B",
ThinkingBudget: json.RawMessage(`128`),
}
encoded, err := kitutil.Marshal(req)
require.NoError(t, err)
value := gjson.GetBytes(encoded, "thinking_budget")
assert.True(t, value.Exists())
assert.Equal(t, int64(128), value.Int())
}
func TestOpenAIResponsesRequestDropsThinkingBudgetForNonQwenModel(t *testing.T) {
req := OpenAIResponsesRequest{
Model: "gpt-4.1",
ThinkingBudget: json.RawMessage(`128`),
}
encoded, err := kitutil.Marshal(req)
require.NoError(t, err)
assert.False(t, gjson.GetBytes(encoded, "thinking_budget").Exists())
}
func TestGeneralOpenAIRequestGetSystemRoleName(t *testing.T) {
tests := []struct {
name string
model string
want string
}{
{name: "o1 uses developer", model: "o1", want: "developer"},
{name: "o3 family uses developer", model: "o3-mini-high", want: "developer"},
{name: "o4 family uses developer", model: "o4-mini", want: "developer"},
{name: "o1 mini stays system", model: "o1-mini", want: "system"},
{name: "o1 preview stays system", model: "o1-preview", want: "system"},
{name: "gpt 5 uses developer", model: "gpt-5", want: "developer"},
{name: "gpt 5.6 uses developer", model: "gpt-5.6-luna", want: "developer"},
{name: "gpt 6 uses developer", model: "gpt-6-astra", want: "developer"},
{name: "gpt 6 snapshot uses developer", model: "gpt-6-astra-2026-09-03", want: "developer"},
{name: "unknown gpt 6 variant stays system", model: "gpt-6-astra-pro", want: "system"},
{name: "invalid gpt 6 snapshot stays system", model: "gpt-6-astra-2026-99-03", want: "system"},
{name: "unknown generation stays system", model: "gpt-7", want: "system"},
{name: "gpt 4.1 stays system", model: "gpt-4.1-nano", want: "system"},
{name: "omni is not o series", model: "omni-moderation-latest", want: "system"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := GeneralOpenAIRequest{Model: tt.model}
assert.Equal(t, tt.want, req.GetSystemRoleName())
})
}
}
func TestIsOpenAIGPT5Model(t *testing.T) {
tests := []struct {
model string
want bool
}{
{model: "gpt-5", want: true},
{model: "gpt-5-mini", want: true},
{model: "gpt-5-chat-latest", want: true},
{model: "gpt-5.6-luna", want: true},
{model: "gpt-5.4-nano", want: true},
{model: "gpt-5.2-2025-12-11", want: true},
{model: "gpt-6-astra", want: false},
{model: "gpt-50", want: false},
{model: "gpt-5custom", want: false},
{model: " GPT-5 ", want: false},
{model: "gpt-4.1", want: false},
{model: "gpt-4.1-nano", want: false},
{model: "gpt-4o", want: false},
{model: "gpt-4.5-preview", want: false},
{model: "gpt-oss-120b", want: false},
{model: "gpt-image-2", want: false},
{model: "gpt-realtime-2.1", want: false},
{model: "chatgpt-4o-latest", want: false},
{model: "o3-mini", want: false},
{model: "gpt-", want: false},
{model: "", want: false},
}
for _, tt := range tests {
t.Run(tt.model, func(t *testing.T) {
assert.Equal(t, tt.want, IsOpenAIGPT5Model(tt.model))
})
}
}
func TestGeneralOpenAIRequestPreserveMessageLevelTools(t *testing.T) {
raw := []byte(`{
"model":"kimi-k3",
"tool_choice":"required",
"tools":[{"type":"function","function":{"name":"get_weather","description":"Get the weather","parameters":{"type":"object","properties":{"city":{"type":"string"}}}}}],
"messages":[
{"role":"system","content":"You are Kimi."},
{"role":"user","content":"What time is it in Beijing?"},
{"role":"system","tools":[{"type":"function","function":{"name":"get_current_time","description":"Get the current time of a city","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}]},
{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_current_time","arguments":"{\"city\":\"Beijing\"}"}}]},
{"role":"system","content":"","tools":[{"type":"function","function":{"name":"lookup_order","parameters":{"type":"object"}}}]}
]
}`)
var req GeneralOpenAIRequest
require.NoError(t, kitutil.Unmarshal(raw, &req))
require.Len(t, req.Messages, 5)
encoded, err := kitutil.Marshal(req)
require.NoError(t, err)
messages := gjson.GetBytes(encoded, "messages").Array()
require.Len(t, messages, 5)
assert.Equal(t, "required", gjson.GetBytes(encoded, "tool_choice").String())
assert.JSONEq(t, gjson.GetBytes(raw, "tools").Raw, gjson.GetBytes(encoded, "tools").Raw)
// Regular messages keep their content untouched.
assert.Equal(t, "You are Kimi.", messages[0].Get("content").String())
assert.False(t, messages[0].Get("tools").Exists())
assert.Equal(t, "What time is it in Beijing?", messages[1].Get("content").String())
// Kimi K3 dynamic tool loading message: tools preserved byte-for-byte, no content key at all.
assert.JSONEq(t, gjson.GetBytes(raw, "messages.2.tools").Raw, messages[2].Get("tools").Raw)
assert.False(t, messages[2].Get("content").Exists())
assert.Equal(t, "system", messages[2].Get("role").String())
// Assistant tool call replay still emits an explicit "content": null.
assistantContent := messages[3].Get("content")
assert.True(t, assistantContent.Exists())
assert.Equal(t, gjson.Null, assistantContent.Type)
assert.JSONEq(t, gjson.GetBytes(raw, "messages.3.tool_calls").Raw, messages[3].Get("tool_calls").Raw)
// Explicit empty content next to tools is forwarded as-is for the upstream to judge.
emptyContent := messages[4].Get("content")
assert.True(t, emptyContent.Exists())
assert.Equal(t, gjson.String, emptyContent.Type)
assert.Equal(t, "", emptyContent.String())
assert.JSONEq(t, gjson.GetBytes(raw, "messages.4.tools").Raw, messages[4].Get("tools").Raw)
// Token estimation sees message-level tools alongside the top-level ones.
meta := req.GetTokenCountMeta()
assert.Equal(t, 3, meta.ToolsCount)
assert.Equal(t, 5, meta.MessagesCount)
assert.Contains(t, meta.CombineText, "get_weather")
assert.Contains(t, meta.CombineText, "get_current_time")
assert.Contains(t, meta.CombineText, "Get the current time of a city")
assert.Contains(t, meta.CombineText, "lookup_order")
}
+578
View File
@@ -0,0 +1,578 @@
package dto
import (
"encoding/json"
"fmt"
"strings"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
const (
ResponsesOutputTypeImageGenerationCall = "image_generation_call"
)
type SimpleResponse struct {
Usage `json:"usage"`
Error any `json:"error"`
}
// GetOpenAIError 从动态错误类型中提取OpenAIError结构
func (s *SimpleResponse) GetOpenAIError() *types.OpenAIError {
return GetOpenAIError(s.Error)
}
type TextResponse struct {
Id string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []OpenAITextResponseChoice `json:"choices"`
Usage `json:"usage"`
}
type OpenAITextResponseChoice struct {
Index int `json:"index"`
Message `json:"message"`
FinishReason string `json:"finish_reason"`
}
type OpenAITextResponse struct {
Id string `json:"id"`
Model string `json:"model"`
Object string `json:"object"`
Created any `json:"created"`
Choices []OpenAITextResponseChoice `json:"choices"`
Error any `json:"error,omitempty"`
Usage `json:"usage"`
}
// GetOpenAIError 从动态错误类型中提取OpenAIError结构
func (o *OpenAITextResponse) GetOpenAIError() *types.OpenAIError {
return GetOpenAIError(o.Error)
}
type OpenAIEmbeddingResponseItem struct {
Object string `json:"object"`
Index int `json:"index"`
Embedding []float64 `json:"embedding"`
}
type OpenAIEmbeddingResponse struct {
Object string `json:"object"`
Data []OpenAIEmbeddingResponseItem `json:"data"`
Model string `json:"model"`
Usage `json:"usage"`
}
type FlexibleEmbeddingResponseItem struct {
Object string `json:"object"`
Index int `json:"index"`
Embedding any `json:"embedding"`
}
type FlexibleEmbeddingResponse struct {
Object string `json:"object"`
Data []FlexibleEmbeddingResponseItem `json:"data"`
Model string `json:"model"`
Usage `json:"usage"`
}
type ChatCompletionsStreamResponseChoice struct {
Delta ChatCompletionsStreamResponseChoiceDelta `json:"delta"`
Logprobs *any `json:"logprobs"`
FinishReason *string `json:"finish_reason"`
Index int `json:"index"`
}
type ChatCompletionsStreamResponseChoiceDelta struct {
Content *string `json:"content,omitempty"`
ReasoningContent *string `json:"reasoning_content,omitempty"`
Reasoning *string `json:"reasoning,omitempty"`
Role string `json:"role,omitempty"`
ToolCalls []ToolCallResponse `json:"tool_calls,omitempty"`
// Annotations is an OpenAI-compatible streaming extension supported by
// providers such as OpenRouter. Relaykit uses it to preserve streaming URL
// citations, including Claude round-trip metadata.
Annotations json.RawMessage `json:"annotations,omitempty"`
}
func (c *ChatCompletionsStreamResponseChoiceDelta) SetContentString(s string) {
c.Content = &s
}
func (c *ChatCompletionsStreamResponseChoiceDelta) GetContentString() string {
if c.Content == nil {
return ""
}
return *c.Content
}
func (c *ChatCompletionsStreamResponseChoiceDelta) GetReasoningContent() string {
if c.ReasoningContent == nil && c.Reasoning == nil {
return ""
}
if c.ReasoningContent != nil {
return *c.ReasoningContent
}
return *c.Reasoning
}
func (c *ChatCompletionsStreamResponseChoiceDelta) SetReasoningContent(s string) {
c.ReasoningContent = &s
//c.Reasoning = &s
}
type ToolCallResponse struct {
// Index is not nil only in chat completion chunk object
Index *int `json:"index,omitempty"`
ID string `json:"id,omitempty"`
Type any `json:"type"`
Function FunctionResponse `json:"function"`
}
func (c *ToolCallResponse) SetIndex(i int) {
c.Index = &i
}
type FunctionResponse struct {
Description string `json:"description,omitempty"`
Name string `json:"name,omitempty"`
// call function with arguments in JSON format
Parameters any `json:"parameters,omitempty"` // request
Arguments string `json:"arguments"` // response
}
type ChatCompletionsStreamResponse struct {
Id string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
SystemFingerprint *string `json:"system_fingerprint"`
Choices []ChatCompletionsStreamResponseChoice `json:"choices"`
Usage *Usage `json:"usage"`
}
func (c *ChatCompletionsStreamResponse) IsFinished() bool {
if len(c.Choices) == 0 {
return false
}
return c.Choices[0].FinishReason != nil && *c.Choices[0].FinishReason != ""
}
func (c *ChatCompletionsStreamResponse) IsToolCall() bool {
if len(c.Choices) == 0 {
return false
}
return len(c.Choices[0].Delta.ToolCalls) > 0
}
func (c *ChatCompletionsStreamResponse) GetFirstToolCall() *ToolCallResponse {
if c.IsToolCall() {
return &c.Choices[0].Delta.ToolCalls[0]
}
return nil
}
func (c *ChatCompletionsStreamResponse) ClearToolCalls() {
if !c.IsToolCall() {
return
}
for choiceIdx := range c.Choices {
for callIdx := range c.Choices[choiceIdx].Delta.ToolCalls {
c.Choices[choiceIdx].Delta.ToolCalls[callIdx].ID = ""
c.Choices[choiceIdx].Delta.ToolCalls[callIdx].Type = nil
c.Choices[choiceIdx].Delta.ToolCalls[callIdx].Function.Name = ""
}
}
}
func (c *ChatCompletionsStreamResponse) Copy() *ChatCompletionsStreamResponse {
choices := make([]ChatCompletionsStreamResponseChoice, len(c.Choices))
copy(choices, c.Choices)
return &ChatCompletionsStreamResponse{
Id: c.Id,
Object: c.Object,
Created: c.Created,
Model: c.Model,
SystemFingerprint: c.SystemFingerprint,
Choices: choices,
Usage: c.Usage,
}
}
func (c *ChatCompletionsStreamResponse) GetSystemFingerprint() string {
if c.SystemFingerprint == nil {
return ""
}
return *c.SystemFingerprint
}
func (c *ChatCompletionsStreamResponse) SetSystemFingerprint(s string) {
c.SystemFingerprint = &s
}
type ChatCompletionsStreamResponseSimple struct {
Choices []ChatCompletionsStreamResponseChoice `json:"choices"`
Usage *Usage `json:"usage"`
}
type CompletionsStreamResponse struct {
Choices []struct {
Text string `json:"text"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
}
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
PromptCacheHitTokens int `json:"prompt_cache_hit_tokens,omitempty"`
UsageSemantic string `json:"usage_semantic,omitempty"`
UsageSource string `json:"usage_source,omitempty"`
BillingUsage *BillingUsage `json:"billing_usage,omitempty"`
PromptTokensDetails InputTokenDetails `json:"prompt_tokens_details"`
CompletionTokenDetails OutputTokenDetails `json:"completion_tokens_details"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
InputTokensDetails *InputTokenDetails `json:"input_tokens_details"`
// claude cache 1h
ClaudeCacheCreation5mTokens int `json:"claude_cache_creation_5_m_tokens"`
ClaudeCacheCreation1hTokens int `json:"claude_cache_creation_1_h_tokens"`
// OpenRouter Params
Cost any `json:"cost,omitempty"`
}
type OpenAIVideoResponse struct {
Id string `json:"id" example:"file-abc123"`
Object string `json:"object" example:"file"`
Bytes int64 `json:"bytes" example:"120000"`
CreatedAt int64 `json:"created_at" example:"1677610602"`
ExpiresAt int64 `json:"expires_at" example:"1677614202"`
Filename string `json:"filename" example:"mydata.jsonl"`
Purpose string `json:"purpose" example:"fine-tune"`
}
type InputTokenDetails struct {
CachedTokens int `json:"cached_tokens"`
CachedCreationTokens int `json:"cached_creation_tokens,omitempty"`
// CacheWriteTokens is OpenAI's native cache-write count, reported as
// prompt_tokens_details.cache_write_tokens (Chat Completions) or
// input_tokens_details.cache_write_tokens (Responses). It is billed at the
// cache-creation price.
CacheWriteTokens int `json:"cache_write_tokens,omitempty"`
TextTokens int `json:"text_tokens"`
AudioTokens int `json:"audio_tokens"`
ImageTokens int `json:"image_tokens"`
}
// CacheCreationTokensTotal returns the cache-write token count regardless of
// which field the upstream reported it in: Claude-derived conversions populate
// CachedCreationTokens while OpenAI reports cache_write_tokens natively. Both
// are billed at the cache-creation price; when both are present the larger
// value wins so the same tokens are never double-counted. Negative upstream
// values are clamped to zero so they can never lower a charge.
func (d InputTokenDetails) CacheCreationTokensTotal() int {
total := max(d.CacheWriteTokens, d.CachedCreationTokens)
if total < 0 {
return 0
}
return total
}
type OutputTokenDetails struct {
TextTokens int `json:"text_tokens"`
AudioTokens int `json:"audio_tokens"`
ImageTokens int `json:"image_tokens"`
ReasoningTokens int `json:"reasoning_tokens"`
}
type OpenAIResponsesResponse struct {
ID string `json:"id"`
Object string `json:"object"`
CreatedAt int `json:"created_at"`
Status json.RawMessage `json:"status"`
Error any `json:"error,omitempty"`
IncompleteDetails *IncompleteDetails `json:"incomplete_details,omitempty"`
Instructions json.RawMessage `json:"instructions"`
MaxOutputTokens int `json:"max_output_tokens"`
Model string `json:"model"`
Output []ResponsesOutput `json:"output"`
ParallelToolCalls bool `json:"parallel_tool_calls"`
PreviousResponseID json.RawMessage `json:"previous_response_id"`
Reasoning *Reasoning `json:"reasoning"`
Store bool `json:"store"`
Temperature float64 `json:"temperature"`
ToolChoice json.RawMessage `json:"tool_choice"`
Tools []map[string]any `json:"tools"`
TopP float64 `json:"top_p"`
Truncation json.RawMessage `json:"truncation"`
Usage *Usage `json:"usage"`
User json.RawMessage `json:"user"`
Metadata json.RawMessage `json:"metadata"`
}
// GetOpenAIError 从动态错误类型中提取OpenAIError结构
func (o *OpenAIResponsesResponse) GetOpenAIError() *types.OpenAIError {
return GetOpenAIError(o.Error)
}
type IncompleteDetails struct {
Reason string `json:"reason"`
}
type ResponsesOutput struct {
Type string `json:"type"`
ID string `json:"id"`
Status string `json:"status"`
Role string `json:"role"`
Content []ResponsesOutputContent `json:"content"`
Summary []ResponsesReasoningSummaryPart `json:"summary,omitempty"`
Quality string `json:"quality"`
Size string `json:"size"`
Result string `json:"result,omitempty"`
CallId string `json:"call_id,omitempty"`
Name string `json:"name,omitempty"`
Arguments json.RawMessage `json:"arguments,omitempty"`
Action json.RawMessage `json:"action,omitempty"`
Queries json.RawMessage `json:"queries,omitempty"`
Results json.RawMessage `json:"results,omitempty"`
Sources json.RawMessage `json:"sources,omitempty"`
Code json.RawMessage `json:"code,omitempty"`
Outputs json.RawMessage `json:"outputs,omitempty"`
ContainerID string `json:"container_id,omitempty"`
PendingSafetyChecks json.RawMessage `json:"pending_safety_checks,omitempty"`
Caller json.RawMessage `json:"caller,omitempty"`
ServerLabel string `json:"server_label,omitempty"`
Output json.RawMessage `json:"output,omitempty"`
ItemError json.RawMessage `json:"error,omitempty"`
ApprovalRequestID string `json:"approval_request_id,omitempty"`
MCPTools json.RawMessage `json:"tools,omitempty"`
}
// MarshalJSON keeps hosted-tool variants within their protocol-specific
// schemas. ResponsesOutput also represents messages, images, and function
// calls, whose fields must not leak into web_search_call or mcp_call items.
func (r ResponsesOutput) MarshalJSON() ([]byte, error) {
switch r.Type {
case "web_search_call":
return kitutil.Marshal(struct {
Type string `json:"type"`
ID string `json:"id"`
Status string `json:"status,omitempty"`
Action json.RawMessage `json:"action,omitempty"`
}{Type: r.Type, ID: r.ID, Status: r.Status, Action: r.Action})
case "mcp_call":
return kitutil.Marshal(struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
ServerLabel string `json:"server_label"`
Arguments json.RawMessage `json:"arguments"`
Status string `json:"status,omitempty"`
Output json.RawMessage `json:"output,omitempty"`
Error json.RawMessage `json:"error,omitempty"`
ApprovalRequestID string `json:"approval_request_id,omitempty"`
}{
Type: r.Type,
ID: r.ID,
Name: r.Name,
ServerLabel: r.ServerLabel,
Arguments: r.Arguments,
Status: r.Status,
Output: r.Output,
Error: r.ItemError,
ApprovalRequestID: r.ApprovalRequestID,
})
default:
type responsesOutputAlias ResponsesOutput
return kitutil.Marshal(responsesOutputAlias(r))
}
}
// NormalizeResponsesWebSearchAction validates and canonicalizes the current
// Responses web_search_call action union. Claude emits {"query": ...}; the
// Responses representation additionally requires a discriminator.
func NormalizeResponsesWebSearchAction(raw json.RawMessage) (json.RawMessage, error) {
var action struct {
Type string `json:"type"`
Query string `json:"query"`
Queries []string `json:"queries"`
Sources json.RawMessage `json:"sources"`
URL string `json:"url"`
Pattern string `json:"pattern"`
}
if err := kitutil.Unmarshal(raw, &action); err != nil {
return nil, fmt.Errorf("decode Responses web-search action: %w", err)
}
action.Type = strings.TrimSpace(action.Type)
action.Query = strings.TrimSpace(action.Query)
action.URL = strings.TrimSpace(action.URL)
action.Pattern = strings.TrimSpace(action.Pattern)
for index := range action.Queries {
action.Queries[index] = strings.TrimSpace(action.Queries[index])
if action.Queries[index] == "" {
return nil, fmt.Errorf("Responses web-search action queries[%d] must not be empty", index)
}
}
if action.Type == "" && (action.Query != "" || len(action.Queries) > 0) {
action.Type = "search"
}
var canonical any
switch action.Type {
case "search":
if action.Query == "" && len(action.Queries) == 0 {
return nil, fmt.Errorf("Responses web-search action %q requires query or queries", action.Type)
}
if len(action.Sources) > 0 && kitutil.GetJsonType(action.Sources) != "array" && kitutil.GetJsonType(action.Sources) != "null" {
return nil, fmt.Errorf("Responses web-search action sources must be an array")
}
canonical = struct {
Type string `json:"type"`
Query string `json:"query,omitempty"`
Queries []string `json:"queries,omitempty"`
Sources json.RawMessage `json:"sources,omitempty"`
}{Type: action.Type, Query: action.Query, Queries: action.Queries, Sources: action.Sources}
case "open_page":
if action.URL == "" {
return nil, fmt.Errorf("Responses web-search action %q requires url", action.Type)
}
canonical = struct {
Type string `json:"type"`
URL string `json:"url"`
}{Type: action.Type, URL: action.URL}
case "find", "find_in_page":
if action.URL == "" || action.Pattern == "" {
return nil, fmt.Errorf("Responses web-search action %q requires url and pattern", action.Type)
}
canonical = struct {
Type string `json:"type"`
URL string `json:"url"`
Pattern string `json:"pattern"`
}{Type: "find_in_page", URL: action.URL, Pattern: action.Pattern}
default:
return nil, fmt.Errorf("unsupported Responses web-search action type %q", action.Type)
}
encoded, err := kitutil.Marshal(canonical)
if err != nil {
return nil, fmt.Errorf("encode Responses web-search action: %w", err)
}
return encoded, nil
}
// ArgumentsString returns function call arguments in the string form expected by Chat Completions.
func (r *ResponsesOutput) ArgumentsString() string {
if r == nil {
return ""
}
return ResponsesArgumentsString(r.Arguments)
}
// ResponsesArgumentsString returns function call arguments in the string form expected by Chat Completions.
func ResponsesArgumentsString(arguments json.RawMessage) string {
return kitutil.JsonRawMessageToString(arguments)
}
type ResponsesOutputContent struct {
Type string `json:"type"`
Text string `json:"text"`
Annotations []any `json:"annotations"`
}
type ResponsesReasoningSummaryPart struct {
Type string `json:"type"`
Text string `json:"text"`
}
const (
BuildInToolWebSearchPreview = "web_search_preview"
BuildInToolWebSearch = "web_search"
BuildInToolFileSearch = "file_search"
BuildInToolGoogleSearch = "google_search"
BuildInToolImageGeneration = "image_generation"
)
const (
BuildInCallWebSearchCall = "web_search_call"
BuildInCallFileSearchCall = "file_search_call"
BuildInCallFunctionCall = "function_call"
BuildInCallToolUse = "tool_use"
)
const (
ResponsesOutputTypeItemAdded = "response.output_item.added"
ResponsesOutputTypeItemDone = "response.output_item.done"
)
// ResponsesStreamResponse 用于处理 /v1/responses 流式响应
type ResponsesStreamResponse struct {
Type string `json:"type"`
Response *OpenAIResponsesResponse `json:"response,omitempty"`
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Param string `json:"param,omitempty"`
Delta string `json:"delta,omitempty"`
Arguments *string `json:"arguments,omitempty"`
Name string `json:"name,omitempty"`
Text *string `json:"text,omitempty"`
Item *ResponsesOutput `json:"item,omitempty"`
SequenceNumber *int `json:"sequence_number,omitempty"`
Annotation json.RawMessage `json:"annotation,omitempty"`
AnnotationIndex *int `json:"annotation_index,omitempty"`
Obfuscation string `json:"obfuscation,omitempty"`
// - response.function_call_arguments.delta
// - response.function_call_arguments.done
OutputIndex *int `json:"output_index,omitempty"`
ContentIndex *int `json:"content_index,omitempty"`
SummaryIndex *int `json:"summary_index,omitempty"`
ItemID string `json:"item_id,omitempty"`
Part *ResponsesReasoningSummaryPart `json:"part,omitempty"`
}
// GetOpenAIError 从动态错误类型中提取OpenAIError结构
func GetOpenAIError(errorField any) *types.OpenAIError {
if errorField == nil {
return nil
}
switch err := errorField.(type) {
case types.OpenAIError:
return &err
case *types.OpenAIError:
return err
case map[string]any:
// 处理从JSON解析来的map结构
openaiErr := &types.OpenAIError{}
if errType, ok := err["type"].(string); ok {
openaiErr.Type = errType
}
if errMsg, ok := err["message"].(string); ok {
openaiErr.Message = errMsg
}
if errParam, ok := err["param"].(string); ok {
openaiErr.Param = errParam
}
if errCode, ok := err["code"]; ok {
openaiErr.Code = errCode
}
return openaiErr
case string:
// 处理简单字符串错误
return &types.OpenAIError{
Type: "error",
Message: err,
}
default:
// 未知类型,尝试转换为字符串
return &types.OpenAIError{
Type: "unknown_error",
Message: fmt.Sprintf("%v", err),
}
}
}
@@ -0,0 +1,50 @@
package dto
import (
"encoding/json"
"net/http"
"strings"
"github.com/QuantumNous/new-api/relaykit/types"
)
type OpenAIResponsesCompactionRequest struct {
Model string `json:"model"`
Input json.RawMessage `json:"input,omitempty"`
Instructions json.RawMessage `json:"instructions,omitempty"`
PreviousResponseID string `json:"previous_response_id,omitempty"`
// Codex compact request parity:
// https://github.com/openai/codex/commit/53d59722268dde82fb93c1f37964ce196c2a86d7
// https://github.com/openai/codex/commit/5d6f23a27bf9c90709af527a7108c1c2eadf5123
Tools json.RawMessage `json:"tools,omitempty"`
ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"`
Reasoning *Reasoning `json:"reasoning,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"`
PromptCacheOptions json.RawMessage `json:"prompt_cache_options,omitempty"`
PromptCacheRetention json.RawMessage `json:"prompt_cache_retention,omitempty"`
Text json.RawMessage `json:"text,omitempty"`
}
func (r *OpenAIResponsesCompactionRequest) GetTokenCountMeta() *types.TokenCountMeta {
var parts []string
if len(r.Instructions) > 0 {
parts = append(parts, string(r.Instructions))
}
if len(r.Input) > 0 {
parts = append(parts, string(r.Input))
}
return &types.TokenCountMeta{
CombineText: strings.Join(parts, "\n"),
}
}
func (r *OpenAIResponsesCompactionRequest) IsStream(c *http.Request) bool {
return false
}
func (r *OpenAIResponsesCompactionRequest) SetModelName(modelName string) {
if modelName != "" {
r.Model = modelName
}
}
+53
View File
@@ -0,0 +1,53 @@
package dto
import (
"strconv"
"strings"
)
const (
VideoStatusUnknown = "unknown"
VideoStatusQueued = "queued"
VideoStatusInProgress = "in_progress"
VideoStatusCompleted = "completed"
VideoStatusFailed = "failed"
)
type OpenAIVideo struct {
ID string `json:"id"`
TaskID string `json:"task_id,omitempty"` //兼容旧接口 待废弃
Object string `json:"object"`
Model string `json:"model"`
Status string `json:"status"` // Should use VideoStatus constants: VideoStatusQueued, VideoStatusInProgress, VideoStatusCompleted, VideoStatusFailed
Progress int `json:"progress"`
CreatedAt int64 `json:"created_at"`
CompletedAt int64 `json:"completed_at,omitempty"`
ExpiresAt int64 `json:"expires_at,omitempty"`
Seconds string `json:"seconds,omitempty"`
Size string `json:"size,omitempty"`
RemixedFromVideoID string `json:"remixed_from_video_id,omitempty"`
Error *OpenAIVideoError `json:"error,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
func (m *OpenAIVideo) SetProgressStr(progress string) {
progress = strings.TrimSuffix(progress, "%")
m.Progress, _ = strconv.Atoi(progress)
}
func (m *OpenAIVideo) SetMetadata(k string, v any) {
if m.Metadata == nil {
m.Metadata = make(map[string]any)
}
m.Metadata[k] = v
}
func NewOpenAIVideo() *OpenAIVideo {
return &OpenAIVideo{
Object: "video",
Status: VideoStatusQueued,
}
}
type OpenAIVideoError struct {
Message string `json:"message"`
Code string `json:"code"`
}
+6
View File
@@ -0,0 +1,6 @@
package dto
type PlayGroundRequest struct {
Model string `json:"model,omitempty"`
Group string `json:"group,omitempty"`
}
+35
View File
@@ -0,0 +1,35 @@
package dto
import "github.com/QuantumNous/new-api/relaykit/types"
// 这里不好动就不动了,本来想独立出来的(
type OpenAIModels struct {
Id string `json:"id"`
Object string `json:"object"`
Created int `json:"created"`
OwnedBy string `json:"owned_by"`
SupportedEndpointTypes []types.EndpointType `json:"supported_endpoint_types"`
}
type AnthropicModel struct {
ID string `json:"id"`
CreatedAt string `json:"created_at"`
DisplayName string `json:"display_name"`
Type string `json:"type"`
}
type GeminiModel struct {
Name any `json:"name"`
BaseModelId any `json:"baseModelId"`
Version any `json:"version"`
DisplayName any `json:"displayName"`
Description any `json:"description"`
InputTokenLimit any `json:"inputTokenLimit"`
OutputTokenLimit any `json:"outputTokenLimit"`
SupportedGenerationMethods []any `json:"supportedGenerationMethods"`
Thinking any `json:"thinking"`
Temperature any `json:"temperature"`
MaxTemperature any `json:"maxTemperature"`
TopP any `json:"topP"`
TopK any `json:"topK"`
}
+39
View File
@@ -0,0 +1,39 @@
package dto
type UpstreamDTO struct {
ID int `json:"id,omitempty"`
Name string `json:"name" binding:"required"`
BaseURL string `json:"base_url" binding:"required"`
Endpoint string `json:"endpoint"`
}
type UpstreamRequest struct {
ChannelIDs []int64 `json:"channel_ids"`
Upstreams []UpstreamDTO `json:"upstreams"`
Timeout int `json:"timeout"`
}
// TestResult 上游测试连通性结果
type TestResult struct {
Name string `json:"name"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
// DifferenceItem 差异项
// Current 为本地值,可能为 nil
// Upstreams 为各渠道的上游值,具体数值 / "same" / nil
type DifferenceItem struct {
Current any `json:"current"`
Upstreams map[string]any `json:"upstreams"`
Confidence map[string]bool `json:"confidence"`
}
type SyncableChannel struct {
ID int `json:"id"`
Name string `json:"name"`
BaseURL string `json:"base_url"`
Status int `json:"status"`
Type int `json:"type"`
}
+88
View File
@@ -0,0 +1,88 @@
package dto
import "github.com/QuantumNous/new-api/relaykit/types"
const (
RealtimeEventTypeError = "error"
RealtimeEventTypeSessionUpdate = "session.update"
RealtimeEventTypeConversationCreate = "conversation.item.create"
RealtimeEventTypeResponseCreate = "response.create"
RealtimeEventInputAudioBufferAppend = "input_audio_buffer.append"
)
const (
RealtimeEventTypeResponseDone = "response.done"
RealtimeEventTypeSessionUpdated = "session.updated"
RealtimeEventTypeSessionCreated = "session.created"
RealtimeEventResponseAudioDelta = "response.audio.delta"
RealtimeEventResponseAudioTranscriptionDelta = "response.audio_transcript.delta"
RealtimeEventResponseFunctionCallArgumentsDelta = "response.function_call_arguments.delta"
RealtimeEventResponseFunctionCallArgumentsDone = "response.function_call_arguments.done"
RealtimeEventConversationItemCreated = "conversation.item.created"
)
type RealtimeEvent struct {
EventId string `json:"event_id"`
Type string `json:"type"`
//PreviousItemId string `json:"previous_item_id"`
Session *RealtimeSession `json:"session,omitempty"`
Item *RealtimeItem `json:"item,omitempty"`
Error *types.OpenAIError `json:"error,omitempty"`
Response *RealtimeResponse `json:"response,omitempty"`
Delta string `json:"delta,omitempty"`
Audio string `json:"audio,omitempty"`
}
type RealtimeResponse struct {
Usage *RealtimeUsage `json:"usage"`
}
type RealtimeUsage struct {
TotalTokens int `json:"total_tokens"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
InputTokenDetails InputTokenDetails `json:"input_token_details"`
OutputTokenDetails OutputTokenDetails `json:"output_token_details"`
}
type RealtimeSession struct {
Modalities []string `json:"modalities"`
Instructions string `json:"instructions"`
Voice string `json:"voice"`
InputAudioFormat string `json:"input_audio_format"`
OutputAudioFormat string `json:"output_audio_format"`
InputAudioTranscription InputAudioTranscription `json:"input_audio_transcription"`
TurnDetection any `json:"turn_detection"`
Tools []RealTimeTool `json:"tools"`
ToolChoice string `json:"tool_choice"`
Temperature float64 `json:"temperature"`
//MaxResponseOutputTokens int `json:"max_response_output_tokens"`
}
type InputAudioTranscription struct {
Model string `json:"model"`
}
type RealTimeTool struct {
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description"`
Parameters any `json:"parameters"`
}
type RealtimeItem struct {
Id string `json:"id"`
Type string `json:"type"`
Status string `json:"status"`
Role string `json:"role"`
Content []RealtimeContent `json:"content"`
Name *string `json:"name,omitempty"`
ToolCalls any `json:"tool_calls,omitempty"`
CallId string `json:"call_id,omitempty"`
}
type RealtimeContent struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
Audio string `json:"audio,omitempty"` // Base64-encoded audio bytes.
Transcript string `json:"transcript,omitempty"`
}
+14
View File
@@ -0,0 +1,14 @@
package dto
// ReasoningConversionState carries provider-native reasoning controls between
// in-process conversion steps. It is not part of any provider wire protocol;
// request fields that reference it must use json:"-".
//
// Converters that rebuild an OpenAI request must copy this state so exact
// budgets and explicit include-thoughts choices survive multi-step routes.
type ReasoningConversionState struct {
Mode string
Effort string
BudgetTokens *int
IncludeThoughts *bool
}
+25
View File
@@ -0,0 +1,25 @@
package dto
import (
"github.com/QuantumNous/new-api/relaykit/types"
"net/http"
)
type Request interface {
GetTokenCountMeta() *types.TokenCountMeta
IsStream(c *http.Request) bool
SetModelName(modelName string)
}
type BaseRequest struct {
}
func (b *BaseRequest) GetTokenCountMeta() *types.TokenCountMeta {
return &types.TokenCountMeta{
TokenType: types.TokenTypeTokenizer,
}
}
func (b *BaseRequest) IsStream(c *http.Request) bool {
return false
}
func (b *BaseRequest) SetModelName(modelName string) {}
+67
View File
@@ -0,0 +1,67 @@
package dto
import (
"fmt"
"net/http"
"strings"
"github.com/QuantumNous/new-api/relaykit/types"
)
type RerankRequest struct {
Documents []any `json:"documents"`
Query string `json:"query"`
Model string `json:"model"`
TopN *int `json:"top_n,omitempty"`
ReturnDocuments *bool `json:"return_documents,omitempty"`
MaxChunkPerDoc *int `json:"max_chunk_per_doc,omitempty"`
OverLapTokens *int `json:"overlap_tokens,omitempty"`
}
func (r *RerankRequest) IsStream(c *http.Request) bool {
return false
}
func (r *RerankRequest) GetTokenCountMeta() *types.TokenCountMeta {
var texts = make([]string, 0)
for _, document := range r.Documents {
texts = append(texts, fmt.Sprintf("%v", document))
}
if r.Query != "" {
texts = append(texts, r.Query)
}
return &types.TokenCountMeta{
CombineText: strings.Join(texts, "\n"),
}
}
func (r *RerankRequest) SetModelName(modelName string) {
if modelName != "" {
r.Model = modelName
}
}
func (r *RerankRequest) GetReturnDocuments() bool {
if r.ReturnDocuments == nil {
return false
}
return *r.ReturnDocuments
}
type RerankResponseResult struct {
Document any `json:"document,omitempty"`
Index int `json:"index"`
RelevanceScore float64 `json:"relevance_score"`
}
type RerankDocument struct {
Text any `json:"text"`
}
type RerankResponse struct {
Results []RerankResponseResult `json:"results"`
Usage Usage `json:"usage"`
}
+6
View File
@@ -0,0 +1,6 @@
package dto
type SensitiveResponse struct {
SensitiveWords []string `json:"sensitive_words"`
Content string `json:"content"`
}
+307
View File
@@ -0,0 +1,307 @@
package dto
import (
"reflect"
"strings"
)
// MergeUsageNonZero overlays usage snapshots: a later non-zero field
// overwrites the current value, while a later zero value never erases an
// earlier positive count. Compatible BillingUsage snapshots follow the same
// rule within their provider-native payload.
func MergeUsageNonZero(current *Usage, incoming *Usage) *Usage {
if current == nil {
current = &Usage{}
}
if incoming == nil {
return current
}
if incoming.PromptTokens > 0 {
current.PromptTokens = incoming.PromptTokens
}
if incoming.CompletionTokens > 0 {
current.CompletionTokens = incoming.CompletionTokens
}
if incoming.TotalTokens > 0 {
current.TotalTokens = incoming.TotalTokens
}
if incoming.PromptCacheHitTokens > 0 {
current.PromptCacheHitTokens = incoming.PromptCacheHitTokens
}
if incoming.InputTokens > 0 {
current.InputTokens = incoming.InputTokens
}
if incoming.OutputTokens > 0 {
current.OutputTokens = incoming.OutputTokens
}
if incoming.ClaudeCacheCreation5mTokens > 0 {
current.ClaudeCacheCreation5mTokens = incoming.ClaudeCacheCreation5mTokens
}
if incoming.ClaudeCacheCreation1hTokens > 0 {
current.ClaudeCacheCreation1hTokens = incoming.ClaudeCacheCreation1hTokens
}
mergeInputTokenDetails(&current.PromptTokensDetails, incoming.PromptTokensDetails)
if incoming.InputTokensDetails != nil {
details := *incoming.InputTokensDetails
if details.CachedTokens > 0 ||
details.CachedCreationTokens > 0 ||
details.CacheWriteTokens > 0 ||
details.TextTokens > 0 ||
details.AudioTokens > 0 ||
details.ImageTokens > 0 {
if current.InputTokensDetails == nil {
current.InputTokensDetails = &InputTokenDetails{}
}
mergeInputTokenDetails(current.InputTokensDetails, details)
}
}
if incoming.CompletionTokenDetails.TextTokens > 0 {
current.CompletionTokenDetails.TextTokens = incoming.CompletionTokenDetails.TextTokens
}
if incoming.CompletionTokenDetails.AudioTokens > 0 {
current.CompletionTokenDetails.AudioTokens = incoming.CompletionTokenDetails.AudioTokens
}
if incoming.CompletionTokenDetails.ImageTokens > 0 {
current.CompletionTokenDetails.ImageTokens = incoming.CompletionTokenDetails.ImageTokens
}
if incoming.CompletionTokenDetails.ReasoningTokens > 0 {
current.CompletionTokenDetails.ReasoningTokens = incoming.CompletionTokenDetails.ReasoningTokens
}
if incoming.UsageSemantic != "" {
current.UsageSemantic = incoming.UsageSemantic
}
if incoming.UsageSource != "" {
current.UsageSource = incoming.UsageSource
}
if incoming.BillingUsage != nil {
current.BillingUsage = MergeBillingUsageNonZero(current.BillingUsage, incoming.BillingUsage)
}
if incoming.Cost != nil && !reflect.ValueOf(incoming.Cost).IsZero() {
current.Cost = incoming.Cost
}
if total := current.PromptTokens + current.CompletionTokens; total > current.TotalTokens {
current.TotalTokens = total
}
if total := current.InputTokens + current.OutputTokens; total > current.TotalTokens {
current.TotalTokens = total
}
return current
}
// MergeBillingUsageNonZero preserves non-zero provider-native fields across
// partial stream snapshots. A snapshot from a different billing dialect
// remains authoritative and replaces the previous payload.
func MergeBillingUsageNonZero(current *BillingUsage, incoming *BillingUsage) *BillingUsage {
if incoming == nil {
return CloneBillingUsage(current)
}
if current == nil || !sameBillingUsageDialect(current, incoming) {
replaced := CloneBillingUsage(incoming)
if current != nil && replaced != nil {
// Replacement carries the incoming payload; Estimated carries the
// history of any local synthesis on either side.
replaced.Estimated = current.Estimated || incoming.Estimated
}
return replaced
}
merged := CloneBillingUsage(current)
if incoming.Source != "" {
merged.Source = incoming.Source
}
if incoming.Semantic != "" {
merged.Semantic = incoming.Semantic
}
merged.Estimated = current.Estimated || incoming.Estimated
switch {
case current.OpenAIUsage != nil && incoming.OpenAIUsage != nil:
merged.OpenAIUsage = MergeUsageNonZero(
cloneOpenAIUsage(current.OpenAIUsage),
cloneOpenAIUsage(incoming.OpenAIUsage),
)
case current.ClaudeUsage != nil && incoming.ClaudeUsage != nil:
merged.ClaudeUsage = MergeClaudeUsageNonZero(current.ClaudeUsage, incoming.ClaudeUsage)
case current.GeminiUsageMetadata != nil && incoming.GeminiUsageMetadata != nil:
merged.GeminiUsageMetadata = MergeGeminiUsageMetadataNonZero(current.GeminiUsageMetadata, incoming.GeminiUsageMetadata)
}
return merged
}
func sameBillingUsageDialect(current *BillingUsage, incoming *BillingUsage) bool {
if current.Source != "" && incoming.Source != "" && !strings.EqualFold(current.Source, incoming.Source) {
return false
}
if current.Semantic != "" && incoming.Semantic != "" && !strings.EqualFold(current.Semantic, incoming.Semantic) {
return false
}
return current.OpenAIUsage != nil && incoming.OpenAIUsage != nil ||
current.ClaudeUsage != nil && incoming.ClaudeUsage != nil ||
current.GeminiUsageMetadata != nil && incoming.GeminiUsageMetadata != nil
}
func MergeClaudeUsageNonZero(current *ClaudeUsage, incoming *ClaudeUsage) *ClaudeUsage {
merged := cloneClaudeUsage(current)
if merged == nil {
merged = &ClaudeUsage{}
}
if incoming == nil {
if current != nil {
merged.BillingUsage = CloneBillingUsage(current.BillingUsage)
}
return merged
}
if incoming.InputTokens > 0 {
merged.InputTokens = incoming.InputTokens
}
if incoming.CacheCreationInputTokens > 0 {
merged.CacheCreationInputTokens = incoming.CacheCreationInputTokens
}
if incoming.CacheReadInputTokens > 0 {
merged.CacheReadInputTokens = incoming.CacheReadInputTokens
}
if incoming.OutputTokens > 0 {
merged.OutputTokens = incoming.OutputTokens
}
if incoming.ClaudeCacheCreation5mTokens > 0 {
merged.ClaudeCacheCreation5mTokens = incoming.ClaudeCacheCreation5mTokens
}
if incoming.ClaudeCacheCreation1hTokens > 0 {
merged.ClaudeCacheCreation1hTokens = incoming.ClaudeCacheCreation1hTokens
}
if incoming.CacheCreation != nil {
cacheCreation := *incoming.CacheCreation
merged.CacheCreation = &cacheCreation
// Flat legacy fields are the same information as the sub-object.
// Sync them as a whole overwrite, including explicit zeros, so a
// later correction cannot leave a stale high-watermark behind.
merged.ClaudeCacheCreation5mTokens = cacheCreation.Ephemeral5mInputTokens
merged.ClaudeCacheCreation1hTokens = cacheCreation.Ephemeral1hInputTokens
}
if incoming.ServerToolUse != nil {
if merged.ServerToolUse == nil {
merged.ServerToolUse = &ClaudeServerToolUse{}
}
if incoming.ServerToolUse.WebSearchRequests > 0 {
merged.ServerToolUse.WebSearchRequests = incoming.ServerToolUse.WebSearchRequests
}
if incoming.ServerToolUse.WebFetchRequests > 0 {
merged.ServerToolUse.WebFetchRequests = incoming.ServerToolUse.WebFetchRequests
}
if incoming.ServerToolUse.CodeExecutionRequests > 0 {
merged.ServerToolUse.CodeExecutionRequests = incoming.ServerToolUse.CodeExecutionRequests
}
if incoming.ServerToolUse.ToolSearchRequests > 0 {
merged.ServerToolUse.ToolSearchRequests = incoming.ServerToolUse.ToolSearchRequests
}
}
// cloneClaudeUsage strips BillingUsage so a nested Claude snapshot cannot
// recurse. Restore the client-visible sidecar here: incoming wins when
// present (authoritative/upstream), otherwise keep current's.
if incoming.BillingUsage != nil {
merged.BillingUsage = CloneBillingUsage(incoming.BillingUsage)
} else if current != nil {
merged.BillingUsage = CloneBillingUsage(current.BillingUsage)
}
return merged
}
// MergeGeminiUsageMetadataNonZero overlays Gemini's cumulative usage
// snapshots: a later non-zero field overwrites the current value without
// dropping fields omitted by a later chunk.
func MergeGeminiUsageMetadataNonZero(current *GeminiUsageMetadata, incoming *GeminiUsageMetadata) *GeminiUsageMetadata {
if current == nil && incoming == nil {
return nil
}
if current == nil {
metadata := cloneGeminiUsageMetadata(*incoming)
metadata.BillingUsage = CloneBillingUsage(incoming.BillingUsage)
return &metadata
}
merged := cloneGeminiUsageMetadata(*current)
merged.BillingUsage = CloneBillingUsage(current.BillingUsage)
if incoming == nil {
return &merged
}
if incoming.PromptTokenCount > 0 {
merged.PromptTokenCount = incoming.PromptTokenCount
}
if incoming.ToolUsePromptTokenCount > 0 {
merged.ToolUsePromptTokenCount = incoming.ToolUsePromptTokenCount
}
if incoming.CandidatesTokenCount > 0 {
merged.CandidatesTokenCount = incoming.CandidatesTokenCount
merged.ThoughtsTokenCount = incoming.ThoughtsTokenCount
} else if incoming.ThoughtsTokenCount > 0 {
merged.ThoughtsTokenCount = incoming.ThoughtsTokenCount
}
if incoming.TotalTokenCount > 0 {
merged.TotalTokenCount = incoming.TotalTokenCount
}
if incoming.CachedContentTokenCount > 0 {
merged.CachedContentTokenCount = incoming.CachedContentTokenCount
}
merged.PromptTokensDetails = mergeGeminiTokenDetails(merged.PromptTokensDetails, incoming.PromptTokensDetails)
merged.ToolUsePromptTokensDetails = mergeGeminiTokenDetails(merged.ToolUsePromptTokensDetails, incoming.ToolUsePromptTokensDetails)
merged.CandidatesTokensDetails = mergeGeminiTokenDetails(merged.CandidatesTokensDetails, incoming.CandidatesTokensDetails)
if incoming.BillingUsage != nil {
merged.BillingUsage = MergeBillingUsageNonZero(merged.BillingUsage, incoming.BillingUsage)
}
if total := merged.PromptTokenCount + merged.ToolUsePromptTokenCount + merged.CandidatesTokenCount + merged.ThoughtsTokenCount; total > merged.TotalTokenCount {
merged.TotalTokenCount = total
}
return &merged
}
func normalizeGeminiModality(modality string) string {
return strings.ToUpper(strings.TrimSpace(modality))
}
func mergeGeminiTokenDetails(current []GeminiPromptTokensDetails, incoming []GeminiPromptTokensDetails) []GeminiPromptTokensDetails {
merged := append([]GeminiPromptTokensDetails{}, current...)
indexes := make(map[string]int, len(merged))
for index, detail := range merged {
indexes[normalizeGeminiModality(detail.Modality)] = index
}
for _, detail := range incoming {
if detail.TokenCount <= 0 {
continue
}
key := normalizeGeminiModality(detail.Modality)
if index, ok := indexes[key]; ok {
merged[index].TokenCount += detail.TokenCount
continue
}
indexes[key] = len(merged)
merged = append(merged, detail)
}
return merged
}
func mergeInputTokenDetails(current *InputTokenDetails, incoming InputTokenDetails) {
if incoming.CachedTokens > 0 {
current.CachedTokens = incoming.CachedTokens
}
if incoming.CachedCreationTokens > 0 {
current.CachedCreationTokens = incoming.CachedCreationTokens
}
if incoming.CacheWriteTokens > 0 {
current.CacheWriteTokens = incoming.CacheWriteTokens
}
if incoming.TextTokens > 0 {
current.TextTokens = incoming.TextTokens
}
if incoming.AudioTokens > 0 {
current.AudioTokens = incoming.AudioTokens
}
if incoming.ImageTokens > 0 {
current.ImageTokens = incoming.ImageTokens
}
}
+234
View File
@@ -0,0 +1,234 @@
package dto
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMergeClaudeUsageCacheCreationReplacesWholeObject(t *testing.T) {
t.Parallel()
merged := MergeClaudeUsageNonZero(
&ClaudeUsage{
CacheCreation: &ClaudeCacheCreationUsage{Ephemeral1hInputTokens: 1000},
},
&ClaudeUsage{
CacheCreation: &ClaudeCacheCreationUsage{
Ephemeral5mInputTokens: 1000,
Ephemeral1hInputTokens: 0,
},
},
)
require.NotNil(t, merged.CacheCreation)
assert.Equal(t, 1000, merged.CacheCreation.Ephemeral5mInputTokens)
assert.Equal(t, 0, merged.CacheCreation.Ephemeral1hInputTokens)
}
func TestMergeGeminiUsageMetadataCandidatesAndThoughtsReplacedAsPair(t *testing.T) {
t.Parallel()
merged := MergeGeminiUsageMetadataNonZero(
&GeminiUsageMetadata{
PromptTokenCount: 10,
ThoughtsTokenCount: 100,
},
&GeminiUsageMetadata{
PromptTokenCount: 10,
CandidatesTokenCount: 150,
ThoughtsTokenCount: 0,
TotalTokenCount: 160,
},
)
require.NotNil(t, merged)
assert.Equal(t, 150, merged.CandidatesTokenCount)
assert.Equal(t, 0, merged.ThoughtsTokenCount)
billing := NewGeminiChatBillingUsage(merged)
usage, ok := billing.CanonicalUsage()
require.True(t, ok)
assert.Equal(t, 150, usage.CompletionTokens)
}
func TestGeminiModalityKeysSettleConsistentlyAndDuplicateEntriesSum(t *testing.T) {
t.Parallel()
for _, modality := range []string{"audio", " AUDIO ", "AUDIO"} {
t.Run("settle_"+modality, func(t *testing.T) {
t.Parallel()
billing := NewGeminiChatBillingUsage(&GeminiUsageMetadata{
PromptTokenCount: 100,
PromptTokensDetails: []GeminiPromptTokensDetails{
{Modality: modality, TokenCount: 40},
{Modality: "TEXT", TokenCount: 60},
},
})
usage, ok := billing.CanonicalUsage()
require.True(t, ok)
assert.Equal(t, 40, usage.PromptTokensDetails.AudioTokens)
assert.Equal(t, 60, usage.PromptTokensDetails.TextTokens)
})
}
mergedDetails := mergeGeminiTokenDetails(
[]GeminiPromptTokensDetails{{Modality: "AUDIO", TokenCount: 10}},
[]GeminiPromptTokensDetails{{Modality: "audio", TokenCount: 15}},
)
require.Len(t, mergedDetails, 1)
assert.Equal(t, 25, mergedDetails[0].TokenCount)
streamMerged := MergeGeminiUsageMetadataNonZero(
&GeminiUsageMetadata{
PromptTokenCount: 10,
PromptTokensDetails: []GeminiPromptTokensDetails{{Modality: "AUDIO", TokenCount: 10}},
},
&GeminiUsageMetadata{
PromptTokenCount: 25,
PromptTokensDetails: []GeminiPromptTokensDetails{{Modality: "audio", TokenCount: 15}},
},
)
require.NotNil(t, streamMerged)
streamUsage, ok := NewGeminiChatBillingUsage(streamMerged).CanonicalUsage()
require.True(t, ok)
decodedUsage, ok := NewGeminiChatBillingUsage(&GeminiUsageMetadata{
PromptTokenCount: 25,
PromptTokensDetails: []GeminiPromptTokensDetails{
{Modality: "AUDIO", TokenCount: 10},
{Modality: "audio", TokenCount: 15},
},
}).CanonicalUsage()
require.True(t, ok)
assert.Equal(t, decodedUsage.PromptTokensDetails.AudioTokens, streamUsage.PromptTokensDetails.AudioTokens)
assert.Equal(t, 25, decodedUsage.PromptTokensDetails.AudioTokens)
}
func TestClaudeCacheCreationSubObjectZeroDoesNotReviveFlatLegacyFields(t *testing.T) {
t.Parallel()
merged := MergeClaudeUsageNonZero(
&ClaudeUsage{ClaudeCacheCreation1hTokens: 1000},
&ClaudeUsage{
InputTokens: 10,
CacheCreation: &ClaudeCacheCreationUsage{
Ephemeral5mInputTokens: 1000,
Ephemeral1hInputTokens: 0,
},
},
)
require.NotNil(t, merged.CacheCreation)
assert.Equal(t, 1000, merged.CacheCreation.Ephemeral5mInputTokens)
assert.Equal(t, 0, merged.CacheCreation.Ephemeral1hInputTokens)
assert.Equal(t, 1000, merged.ClaudeCacheCreation5mTokens)
assert.Equal(t, 0, merged.ClaudeCacheCreation1hTokens)
usage, ok := NewClaudeMessagesBillingUsage(merged).CanonicalUsage()
require.True(t, ok)
assert.Equal(t, 1000, usage.ClaudeCacheCreation5mTokens)
assert.Equal(t, 0, usage.ClaudeCacheCreation1hTokens)
}
func TestClaudeCacheCreationFlatFieldsStillSettleWhenSnapshotNeverHadSubObject(t *testing.T) {
t.Parallel()
usage, ok := NewClaudeMessagesBillingUsage(&ClaudeUsage{
InputTokens: 10,
ClaudeCacheCreation1hTokens: 1000,
}).CanonicalUsage()
require.True(t, ok)
assert.Equal(t, 0, usage.ClaudeCacheCreation5mTokens)
assert.Equal(t, 1000, usage.ClaudeCacheCreation1hTokens)
}
func TestMergeBillingUsageORsEstimatedOnSameAndCrossDialect(t *testing.T) {
t.Parallel()
estimated := NewEstimatedGeminiChatBillingUsage(&Usage{PromptTokens: 10, CompletionTokens: 2})
require.NotNil(t, estimated)
require.True(t, estimated.Estimated)
sameDialect := MergeBillingUsageNonZero(estimated, NewGeminiChatBillingUsage(&GeminiUsageMetadata{
PromptTokenCount: 11,
CandidatesTokenCount: 3,
TotalTokenCount: 14,
}))
require.NotNil(t, sameDialect)
assert.True(t, sameDialect.Estimated)
crossDialect := MergeBillingUsageNonZero(estimated, NewOpenAIChatBillingUsage(&Usage{
PromptTokens: 12,
CompletionTokens: 4,
TotalTokens: 16,
}))
require.NotNil(t, crossDialect)
assert.True(t, crossDialect.Estimated)
require.NotNil(t, crossDialect.OpenAIUsage)
assert.Equal(t, 12, crossDialect.OpenAIUsage.PromptTokens)
}
func TestMergeClaudeUsageNonZeroPreservesBillingUsage(t *testing.T) {
t.Parallel()
currentSidecar := NewGeminiChatBillingUsage(&GeminiUsageMetadata{
PromptTokenCount: 3868,
TotalTokenCount: 3868,
CachedContentTokenCount: 20,
})
incomingSidecar := NewGeminiChatBillingUsage(&GeminiUsageMetadata{
PromptTokenCount: 3868,
CandidatesTokenCount: 12,
TotalTokenCount: 3880,
})
require.NotNil(t, currentSidecar)
require.NotNil(t, incomingSidecar)
withIncoming := MergeClaudeUsageNonZero(
&ClaudeUsage{
InputTokens: 3868,
CacheReadInputTokens: 20,
BillingUsage: currentSidecar,
},
&ClaudeUsage{
InputTokens: 3868,
OutputTokens: 12,
BillingUsage: incomingSidecar,
},
)
require.NotNil(t, withIncoming.BillingUsage)
assert.Equal(t, BillingUsageSourceGeminiChat, withIncoming.BillingUsage.Source)
assert.Equal(t, BillingUsageSemanticGemini, withIncoming.BillingUsage.Semantic)
require.NotNil(t, withIncoming.BillingUsage.GeminiUsageMetadata)
assert.Equal(t, 12, withIncoming.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
assert.Equal(t, 20, withIncoming.CacheReadInputTokens)
assert.NotSame(t, incomingSidecar, withIncoming.BillingUsage)
keepCurrent := MergeClaudeUsageNonZero(
&ClaudeUsage{
InputTokens: 3868,
CacheReadInputTokens: 20,
BillingUsage: currentSidecar,
},
&ClaudeUsage{InputTokens: 3868, OutputTokens: 12},
)
require.NotNil(t, keepCurrent.BillingUsage)
require.NotNil(t, keepCurrent.BillingUsage.GeminiUsageMetadata)
assert.Equal(t, 20, keepCurrent.BillingUsage.GeminiUsageMetadata.CachedContentTokenCount)
assert.Equal(t, 0, keepCurrent.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
assert.Equal(t, 20, keepCurrent.CacheReadInputTokens)
}
func TestMergeUsageNonZeroKeepsPositiveValuesAndTakesMaxTotal(t *testing.T) {
t.Parallel()
merged := MergeUsageNonZero(
&Usage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15},
&Usage{PromptTokens: 0, CompletionTokens: 0, TotalTokens: 20},
)
require.NotNil(t, merged)
assert.Equal(t, 10, merged.PromptTokens)
assert.Equal(t, 5, merged.CompletionTokens)
assert.Equal(t, 20, merged.TotalTokens)
}
+26
View File
@@ -0,0 +1,26 @@
package dto
type UserSetting struct {
NotifyType string `json:"notify_type,omitempty"` // QuotaWarningType 额度预警类型
QuotaWarningThreshold float64 `json:"quota_warning_threshold,omitempty"` // QuotaWarningThreshold 额度预警阈值
WebhookUrl string `json:"webhook_url,omitempty"` // WebhookUrl webhook地址
WebhookSecret string `json:"webhook_secret,omitempty"` // WebhookSecret webhook密钥
NotificationEmail string `json:"notification_email,omitempty"` // NotificationEmail 通知邮箱地址
BarkUrl string `json:"bark_url,omitempty"` // BarkUrl Bark推送URL
GotifyUrl string `json:"gotify_url,omitempty"` // GotifyUrl Gotify服务器地址
GotifyToken string `json:"gotify_token,omitempty"` // GotifyToken Gotify应用令牌
GotifyPriority int `json:"gotify_priority"` // GotifyPriority Gotify消息优先级
UpstreamModelUpdateNotifyEnabled bool `json:"upstream_model_update_notify_enabled,omitempty"` // 是否接收上游模型更新定时检测通知(仅管理员)
AcceptUnsetRatioModel bool `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型
RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP
SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置
BillingPreference string `json:"billing_preference,omitempty"` // BillingPreference 扣费策略(订阅/钱包)
Language string `json:"language,omitempty"` // Language 用户语言偏好 (zh, en)
}
var (
NotifyTypeEmail = "email" // Email 邮件
NotifyTypeWebhook = "webhook" // Webhook
NotifyTypeBark = "bark" // Bark 推送
NotifyTypeGotify = "gotify" // Gotify 推送
)
+79
View File
@@ -0,0 +1,79 @@
package dto
import (
"encoding/json"
"strconv"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
type StringValue string
func (s *StringValue) UnmarshalJSON(data []byte) error {
var str string
if err := kitutil.Unmarshal(data, &str); err == nil {
*s = StringValue(str)
return nil
}
var raw json.Number
if err := kitutil.Unmarshal(data, &raw); err == nil {
*s = StringValue(raw.String())
return nil
}
return kitutil.Unmarshal(data, &str)
}
func (s StringValue) MarshalJSON() ([]byte, error) {
return kitutil.Marshal(string(s))
}
type IntValue int
func (i *IntValue) UnmarshalJSON(b []byte) error {
var n int
if err := kitutil.Unmarshal(b, &n); err == nil {
*i = IntValue(n)
return nil
}
var s string
if err := kitutil.Unmarshal(b, &s); err != nil {
return err
}
v, err := strconv.Atoi(s)
if err != nil {
return err
}
*i = IntValue(v)
return nil
}
func (i IntValue) MarshalJSON() ([]byte, error) {
return kitutil.Marshal(int(i))
}
type BoolValue bool
func (b *BoolValue) UnmarshalJSON(data []byte) error {
var boolean bool
if err := kitutil.Unmarshal(data, &boolean); err == nil {
*b = BoolValue(boolean)
return nil
}
var str string
if err := kitutil.Unmarshal(data, &str); err != nil {
return err
}
if str == "true" {
*b = BoolValue(true)
} else if str == "false" {
*b = BoolValue(false)
} else {
return kitutil.Unmarshal(data, &boolean)
}
return nil
}
func (b BoolValue) MarshalJSON() ([]byte, error) {
return kitutil.Marshal(bool(b))
}
+23
View File
@@ -0,0 +1,23 @@
module github.com/QuantumNous/new-api/relaykit
go 1.25.1
require (
github.com/google/uuid v1.6.0
github.com/samber/lo v1.53.0
github.com/stretchr/testify v1.11.1
github.com/tidwall/gjson v1.19.0
github.com/tidwall/sjson v1.2.5
)
require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
golang.org/x/text v0.35.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+38
View File
@@ -0,0 +1,38 @@
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+46
View File
@@ -0,0 +1,46 @@
package reasonmap
import (
"strings"
"github.com/QuantumNous/new-api/relaykit/types"
)
func ClaudeStopReasonToOpenAIFinishReason(stopReason string) string {
switch strings.ToLower(stopReason) {
case "stop_sequence":
return "stop"
case "end_turn":
return "stop"
case "max_tokens":
return "length"
case "tool_use":
return "tool_calls"
case "pause_turn":
// Responses has no pause_turn finish reason. Treat the provider's
// resumable server-side loop as an incomplete response instead of a
// successful stop; the hosted output items preserve continuation state.
return "length"
case "refusal":
return types.FinishReasonContentFilter
default:
return stopReason
}
}
func OpenAIFinishReasonToClaudeStopReason(finishReason string) string {
switch strings.ToLower(finishReason) {
case "stop":
return "end_turn"
case "stop_sequence":
return "stop_sequence"
case "length", "max_tokens":
return "max_tokens"
case types.FinishReasonContentFilter:
return "refusal"
case "tool_calls":
return "tool_use"
default:
return finishReason
}
}
+100
View File
@@ -0,0 +1,100 @@
package relayconvert
// boundary_test.go enforces the relaykit extraction dependency boundary
// (plans/relaykit-extraction-plan.md): packages that will move into the
// relaykit module must not grow imports of host-only packages. Entries in
// allowedViolations are the known couplings scheduled for removal in
// Phase 1/2 — shrink this list, never grow it.
import (
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
"testing"
)
const modulePrefix = "github.com/QuantumNous/new-api/"
// Packages (relative to the relaykit module root) covered by the boundary.
var kitDirs = []string{
"relayconvert",
"dto",
"types",
"reasonmap",
}
// Import prefixes forbidden inside the kit module: the entire host module
// (everything outside relaykit/) and gin.
var forbiddenPrefixes = []string{
modulePrefix,
"github.com/gin-gonic/gin",
}
// hostModuleExceptions are host-prefix imports that are actually the kit's
// own packages (the kit module path nests under the host path).
const kitModulePrefix = modulePrefix + "relaykit/"
// Known pre-existing couplings, removed phase by phase. Key: "dir|import".
// All initial violations have been cleared; keep the map so future
// exemptions (if ever needed) are explicit and reviewed.
var allowedViolations = map[string]bool{}
func TestRelaykitBoundary(t *testing.T) {
root := repoRoot(t)
fset := token.NewFileSet()
for _, dir := range kitDirs {
err := filepath.WalkDir(filepath.Join(root, dir), func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
if err != nil {
return err
}
for _, imp := range file.Imports {
importPath := strings.Trim(imp.Path.Value, `"`)
for _, prefix := range forbiddenPrefixes {
if importPath != prefix && !strings.HasPrefix(importPath, prefix) {
continue
}
if strings.HasPrefix(importPath, kitModulePrefix) {
continue
}
if allowedViolations[dir+"|"+importPath] {
continue
}
rel, _ := filepath.Rel(root, path)
t.Errorf("%s imports %q — forbidden inside future relaykit package %s (see plans/relaykit-extraction-plan.md)", rel, importPath, dir)
}
}
return nil
})
if err != nil {
t.Fatalf("walking %s: %v", dir, err)
}
}
}
func repoRoot(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("go.mod not found above test directory")
}
dir = parent
}
}
@@ -0,0 +1,210 @@
package relayconvert
import (
"context"
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestClaudeDefaultMaxTokensPresence(t *testing.T) {
converters := []struct {
name string
convert func(t *testing.T, meta convmeta.Meta, clientMaxTokens *uint) (*dto.ClaudeRequest, error)
}{
{
name: "chat completions",
convert: func(t *testing.T, meta convmeta.Meta, clientMaxTokens *uint) (*dto.ClaudeRequest, error) {
t.Helper()
return OpenAIChatRequestToClaudeMessages(context.Background(), meta, dto.GeneralOpenAIRequest{
Model: "claude-test",
MaxTokens: clientMaxTokens,
Messages: []dto.Message{
{Role: "user", Content: "hello"},
},
})
},
},
{
name: "responses",
convert: func(t *testing.T, meta convmeta.Meta, clientMaxTokens *uint) (*dto.ClaudeRequest, error) {
t.Helper()
return OpenAIResponsesRequestToClaudeMessages(context.Background(), meta, &dto.OpenAIResponsesRequest{
Model: "claude-test",
Input: []byte(`"hello"`),
MaxOutputTokens: clientMaxTokens,
})
},
},
}
for _, converter := range converters {
t.Run(converter.name, func(t *testing.T) {
t.Run("callback absent fails conversion", func(t *testing.T) {
got, err := converter.convert(t, &convmeta.Values{}, nil)
require.ErrorIs(t, err, sharedclaude.ErrMissingMaxTokens)
assert.Nil(t, got)
})
t.Run("callback absent, client value wins", func(t *testing.T) {
clientMaxTokens := uint(99)
got, err := converter.convert(t, &convmeta.Values{}, &clientMaxTokens)
require.NoError(t, err)
require.NotNil(t, got.MaxTokens)
assert.Equal(t, clientMaxTokens, *got.MaxTokens)
})
t.Run("configured zero", func(t *testing.T) {
got, err := converter.convert(t, claudeDefaultsMeta(func(string) int { return 0 }), nil)
require.NoError(t, err)
require.NotNil(t, got.MaxTokens)
assert.Zero(t, *got.MaxTokens)
})
t.Run("configured positive", func(t *testing.T) {
got, err := converter.convert(t, claudeDefaultsMeta(func(string) int { return 512 }), nil)
require.NoError(t, err)
require.NotNil(t, got.MaxTokens)
assert.Equal(t, uint(512), *got.MaxTokens)
})
t.Run("client nonzero wins", func(t *testing.T) {
clientMaxTokens := uint(99)
got, err := converter.convert(t, claudeDefaultsMeta(func(string) int { return 512 }), &clientMaxTokens)
require.NoError(t, err)
require.NotNil(t, got.MaxTokens)
assert.Equal(t, clientMaxTokens, *got.MaxTokens)
})
t.Run("client zero same as absent, hook fills", func(t *testing.T) {
clientMaxTokens := uint(0)
got, err := converter.convert(t, claudeDefaultsMeta(func(string) int { return 512 }), &clientMaxTokens)
require.NoError(t, err)
require.NotNil(t, got.MaxTokens)
assert.Equal(t, uint(512), *got.MaxTokens)
})
t.Run("client zero same as absent, no hook fails", func(t *testing.T) {
clientMaxTokens := uint(0)
got, err := converter.convert(t, &convmeta.Values{}, &clientMaxTokens)
require.ErrorIs(t, err, sharedclaude.ErrMissingMaxTokens)
assert.Nil(t, got)
})
})
}
}
// The thinking adapter's max_tokens floor is an injection path of its own: a
// "-thinking" request without max_tokens must keep converting even when no
// DefaultMaxTokens hook is configured.
func TestClaudeThinkingAdapterSatisfiesMaxTokensWithoutCallback(t *testing.T) {
_, intent, found, err := reasoning.ParseClaudeModelSuffix("claude-test-thinking", true)
require.NoError(t, err)
require.True(t, found)
meta := &convmeta.Values{
ReasoningConversion: reasoning.StateFromIntent(intent),
Options: &convmeta.Options{
Claude: convmeta.ClaudeOptions{
ThinkingAdapterEnabled: true,
ThinkingAdapterBudgetTokensPercentage: 0.8,
},
},
}
got, err := OpenAIChatRequestToClaudeMessages(context.Background(), meta, dto.GeneralOpenAIRequest{
Model: "claude-test-thinking",
Messages: []dto.Message{
{Role: "user", Content: "hello"},
},
})
require.NoError(t, err)
require.NotNil(t, got.MaxTokens)
assert.Equal(t, uint(1280), *got.MaxTokens)
}
func TestOpenAIChatRequestToClaudeMessagesOmitsEmptyTools(t *testing.T) {
maxTokens := uint(16)
tests := []struct {
name string
request dto.GeneralOpenAIRequest
wantTools bool
}{
{
name: "omitted tools",
request: dto.GeneralOpenAIRequest{
Model: "claude-test",
MaxTokens: &maxTokens,
Messages: []dto.Message{{Role: "user", Content: "hi"}},
},
},
{
name: "explicit empty tools",
request: dto.GeneralOpenAIRequest{
Model: "claude-test",
MaxTokens: &maxTokens,
Messages: []dto.Message{{Role: "user", Content: "hi"}},
Tools: []dto.ToolCallRequest{},
},
},
{
name: "function tool",
request: dto.GeneralOpenAIRequest{
Model: "claude-test",
MaxTokens: &maxTokens,
Messages: []dto.Message{{Role: "user", Content: "hi"}},
Tools: []dto.ToolCallRequest{{
Type: "function",
Function: dto.FunctionRequest{
Name: "get_weather",
Description: "Get weather by city",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{"city": map[string]any{"type": "string"}},
"required": []any{"city"},
},
},
}},
},
wantTools: true,
},
{
name: "web search only",
request: dto.GeneralOpenAIRequest{
Model: "claude-test",
MaxTokens: &maxTokens,
Messages: []dto.Message{{Role: "user", Content: "hi"}},
WebSearchOptions: &dto.WebSearchOptions{SearchContextSize: "low"},
},
wantTools: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := OpenAIChatRequestToClaudeMessages(context.Background(), &convmeta.Values{}, test.request)
require.NoError(t, err)
body, err := kitutil.Marshal(got)
require.NoError(t, err)
if test.wantTools {
assert.NotNil(t, got.Tools)
assert.Contains(t, string(body), `"tools":`)
return
}
assert.Nil(t, got.Tools)
assert.NotContains(t, string(body), `"tools":`)
})
}
}
func claudeDefaultsMeta(defaultMaxTokens func(string) int) convmeta.Meta {
return &convmeta.Values{Options: &convmeta.Options{
Claude: convmeta.ClaudeOptions{DefaultMaxTokens: defaultMaxTokens},
}}
}
+31
View File
@@ -0,0 +1,31 @@
package convmeta
import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
)
// GuessRelayFormatFromRequest infers the relay format from a request DTO's
// concrete type. Moved from relay/common (which keeps a delegating alias).
func GuessRelayFormatFromRequest(req any) (types.RelayFormat, bool) {
switch req.(type) {
case *dto.GeneralOpenAIRequest, dto.GeneralOpenAIRequest:
return types.RelayFormatOpenAI, true
case *dto.OpenAIResponsesRequest, dto.OpenAIResponsesRequest:
return types.RelayFormatOpenAIResponses, true
case *dto.ClaudeRequest, dto.ClaudeRequest:
return types.RelayFormatClaude, true
case *dto.GeminiChatRequest, dto.GeminiChatRequest:
return types.RelayFormatGemini, true
case *dto.EmbeddingRequest, dto.EmbeddingRequest:
return types.RelayFormatEmbedding, true
case *dto.RerankRequest, dto.RerankRequest:
return types.RelayFormatRerank, true
case *dto.ImageRequest, dto.ImageRequest:
return types.RelayFormatOpenAIImage, true
case *dto.AudioRequest, dto.AudioRequest:
return types.RelayFormatOpenAIAudio, true
default:
return "", false
}
}
+249
View File
@@ -0,0 +1,249 @@
// Package convmeta defines the conversion-context contract between format
// converters (future relaykit) and the hosting application. Converters read
// protocol state and per-request options exclusively through the Meta
// interface; the host's RelayInfo implements it.
package convmeta
import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
)
// Meta is the only view of the relay session that format converters may use.
// It is satisfied by *relaycommon.RelayInfo on the host side; other embedders
// (tests, external relaykit users) can use *Values.
// Implementations backed by pointer types must make every method safe on a nil
// receiver: a typed-nil pointer stored in Meta is still a non-nil interface,
// and relaykit deliberately does not use reflection to detect that case.
type Meta interface {
GetOriginModelName() string
GetUpstreamModelName() string
// HasChannelMeta reports whether upstream channel information is attached;
// converters use it to decide if GetUpstreamModelName is meaningful.
HasChannelMeta() bool
GetChannelID() int
GetChannelType() int
GetIsStream() bool
GetReasoningEffort() string
// SetReasoningEffort records the effort level a converter derived from a
// model-name suffix so downstream billing/logging can see it.
SetReasoningEffort(effort string)
// ReasoningState returns the suffix-derived reasoning intent attached at
// the host entry layer. Standalone callers that do not set it receive nil;
// converters then use only explicit request fields.
ReasoningState() *dto.ReasoningConversionState
GetEstimatePromptTokens() int
// EnsureClaudeConvertInfo lazily creates and returns the mutable
// OpenAI→Claude stream conversion state. For non-nil receivers, the same
// instance must be returned for the lifetime of one streaming session; a
// nil receiver may return a temporary initialized state.
EnsureClaudeConvertInfo() *ClaudeConvertInfo
// GetSendResponseCount / IncrSendResponseCount expose the shared
// downstream-chunk counter (the host may also increment it).
GetSendResponseCount() int
IncrSendResponseCount()
// AppendRequestConversion records a hop in the request format chain.
AppendRequestConversion(format types.RelayFormat)
// ConvOptions returns the request-scoped conversion options snapshot.
// Must never return nil.
ConvOptions() *Options
}
// ClaudeConvertInfo carries mutable state for OpenAI chat → Claude Messages
// stream conversion. Moved here from relay/common (which keeps an alias).
type ClaudeConvertInfo struct {
LastMessagesType string
Index int
Usage *dto.Usage
FinishReason string
Done bool
ToolCallBaseIndex int
ToolCallMaxIndexOffset int
ToolCalls []*ClaudeStreamToolCall
ToolCallByIndex map[int]*ClaudeStreamToolCall
ToolCallByID map[string]*ClaudeStreamToolCall
}
// ClaudeStreamToolCall tracks one OpenAI tool_calls entry while it is encoded
// as a Claude tool_use content block. Chat tool indexes and Claude content
// block indexes are separate domains, so the mapping must remain explicit.
type ClaudeStreamToolCall struct {
BlockIndex int
ID string
Name string
PendingArguments string
Started bool
}
const (
LastMessageTypeNone = "none"
LastMessageTypeText = "text"
LastMessageTypeTools = "tools"
LastMessageTypeThinking = "thinking"
)
// Values is a plain-struct Meta implementation for tests and non-RelayInfo
// hosts (the relaykit-native entry point).
type Values struct {
OriginModelName string
UpstreamModelName string
ChannelMetaAttached bool
ChannelID int
ChannelType int
IsStream bool
ReasoningEffort string
ReasoningConversion *dto.ReasoningConversionState
EstimatePromptTokens int
ClaudeConvertInfo *ClaudeConvertInfo
SendResponseCount int
ConversionChain []types.RelayFormat
Options *Options
}
var _ Meta = (*Values)(nil)
func (v *Values) GetOriginModelName() string {
if v == nil {
return ""
}
return v.OriginModelName
}
func (v *Values) GetUpstreamModelName() string {
if v == nil {
return ""
}
return v.UpstreamModelName
}
func (v *Values) HasChannelMeta() bool {
return v != nil && v.ChannelMetaAttached
}
func (v *Values) GetChannelID() int {
if v == nil {
return 0
}
return v.ChannelID
}
func (v *Values) GetChannelType() int {
if v == nil {
return 0
}
return v.ChannelType
}
func (v *Values) GetIsStream() bool {
return v != nil && v.IsStream
}
func (v *Values) GetReasoningEffort() string {
if v == nil {
return ""
}
return v.ReasoningEffort
}
func (v *Values) SetReasoningEffort(effort string) {
if v != nil {
v.ReasoningEffort = effort
}
}
func (v *Values) ReasoningState() *dto.ReasoningConversionState {
if v == nil {
return nil
}
return v.ReasoningConversion
}
func (v *Values) GetEstimatePromptTokens() int {
if v == nil {
return 0
}
return v.EstimatePromptTokens
}
func (v *Values) EnsureClaudeConvertInfo() *ClaudeConvertInfo {
if v == nil {
return &ClaudeConvertInfo{LastMessagesType: LastMessageTypeNone}
}
if v.ClaudeConvertInfo == nil {
v.ClaudeConvertInfo = &ClaudeConvertInfo{LastMessagesType: LastMessageTypeNone}
}
return v.ClaudeConvertInfo
}
func (v *Values) GetSendResponseCount() int {
if v == nil {
return 0
}
return v.SendResponseCount
}
func (v *Values) IncrSendResponseCount() {
if v != nil {
v.SendResponseCount++
}
}
func (v *Values) AppendRequestConversion(format types.RelayFormat) {
if v == nil || format == "" {
return
}
if n := len(v.ConversionChain); n > 0 && v.ConversionChain[n-1] == format {
return
}
v.ConversionChain = append(v.ConversionChain, format)
}
func (v *Values) ConvOptions() *Options {
if v == nil {
return &Options{}
}
if v.Options == nil {
v.Options = &Options{}
}
return v.Options
}
// UpstreamModelName / ChannelTypeOf are nil-safe accessors for optional Meta
// values (converters are often called with a nil Meta in tests and compat
// shims).
func UpstreamModelName(m Meta) string {
if m == nil || !m.HasChannelMeta() {
return ""
}
return m.GetUpstreamModelName()
}
func ChannelTypeOf(m Meta) int {
if m == nil || !m.HasChannelMeta() {
return 0
}
return m.GetChannelType()
}
// OptionsOf returns m's conversion options, or empty defaults when m is nil.
func OptionsOf(m Meta) *Options {
if m == nil {
return &Options{}
}
return m.ConvOptions()
}
// ReasoningStateOf is a nil-safe reader for Meta.ReasoningState.
func ReasoningStateOf(m Meta) *dto.ReasoningConversionState {
if m == nil {
return nil
}
return m.ReasoningState()
}
@@ -0,0 +1,39 @@
package convmeta
import (
"testing"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValuesTypedNilMetaIsSafe(t *testing.T) {
var values *Values
var meta Meta = values
assert.Empty(t, meta.GetOriginModelName())
assert.Empty(t, meta.GetUpstreamModelName())
assert.False(t, meta.HasChannelMeta())
assert.Zero(t, meta.GetChannelID())
assert.Zero(t, meta.GetChannelType())
assert.False(t, meta.GetIsStream())
assert.Empty(t, meta.GetReasoningEffort())
assert.Nil(t, meta.ReasoningState())
assert.Zero(t, meta.GetEstimatePromptTokens())
assert.Zero(t, meta.GetSendResponseCount())
require.NotPanics(t, func() {
meta.SetReasoningEffort("high")
meta.IncrSendResponseCount()
meta.AppendRequestConversion(types.RelayFormatClaude)
})
convertInfo := meta.EnsureClaudeConvertInfo()
require.NotNil(t, convertInfo)
assert.Equal(t, LastMessageTypeNone, convertInfo.LastMessagesType)
require.NotNil(t, meta.ConvOptions())
require.NotNil(t, OptionsOf(meta))
assert.Empty(t, UpstreamModelName(meta))
assert.Zero(t, ChannelTypeOf(meta))
}
+109
View File
@@ -0,0 +1,109 @@
package convmeta
import "github.com/QuantumNous/new-api/relaykit/types"
// Options is the per-request snapshot of host configuration that converters
// consult. The host fills it from its settings system when constructing the
// Meta (see relaycommon.RelayInfo.ConvOptions); relaykit users fill it
// directly. Zero value = every adaptation disabled, no defaults applied.
type Options struct {
Claude ClaudeOptions
Gemini GeminiOptions
// ToolLossPolicy controls whether a cross-protocol conversion may omit or
// approximate built-in-tool semantics. The zero value uses the allow
// policy: conversion succeeds and every loss is returned as a diagnostic.
// safe/strict rejection is request-phase opt-in only; response and stream
// conversion never reject regardless of this field.
ToolLossPolicy types.ConversionLossPolicy
// OpenRouterDialect marks the upstream as OpenRouter's OpenAI-compatible
// surface, which accepts extra fields (reasoning config, cache_control on
// system parts) that converters emit only for that dialect. The host sets
// it from the channel type.
OpenRouterDialect bool
// PreserveThinkingSuffix reports models whose -thinking/-nothinking/effort
// suffix must be kept on the outgoing model name (host blacklist lookup).
// Nil means "never preserve".
PreserveThinkingSuffix func(modelName string) bool
// PreserveEffortTail reports real model IDs whose names already end in an
// effort-like token (for example qwen-max). Nil means "never preserve".
PreserveEffortTail func(modelName string) bool
}
type ClaudeOptions struct {
// ThinkingAdapterEnabled controls whether suffix-derived reasoning intent
// is rendered onto Claude thinking / output_config. Suffix parsing itself
// is the host entry layer's job (standalone users call Parse* themselves).
ThinkingAdapterEnabled bool
// ThinkingAdapterBudgetTokensPercentage sizes thinking budget_tokens as a
// fraction of max_tokens when the adapter fires.
ThinkingAdapterBudgetTokensPercentage float64
// DefaultMaxTokens returns the max_tokens to inject when the source
// request carries none. The Claude Messages API requires max_tokens
// (omitting it is a 400), so when this hook is nil and no other path
// supplies a value, OpenAI→Claude request conversion fails with an
// explicit error instead of emitting a request the upstream is
// guaranteed to reject. The new-api host always provides this hook;
// standalone relaykit users must supply one or guarantee max_tokens on
// every request.
DefaultMaxTokens func(modelName string) int
// WebSearchToolVersion selects the Claude hosted web-search tool version
// emitted by cross-protocol conversion. Empty keeps the compatibility
// baseline web_search_20250305.
WebSearchToolVersion string
}
type GeminiOptions struct {
// ThinkingAdapterEnabled controls whether suffix-derived reasoning intent
// is rendered onto Gemini thinkingConfig. Suffix parsing itself is the
// host entry layer's job (standalone users call Parse* themselves).
ThinkingAdapterEnabled bool
// ThinkingAdapterBudgetTokensPercentage sizes thinkingBudget as a fraction
// of maxOutputTokens when the adapter fires.
ThinkingAdapterBudgetTokensPercentage float64
// FunctionCallThoughtSignatureEnabled attaches thoughtSignature bypass
// values to function-call parts.
FunctionCallThoughtSignatureEnabled bool
// SupportsImagine reports whether the model supports image generation
// (switches response modalities). Nil means "never".
SupportsImagine func(modelName string) bool
// SafetySetting returns the harm threshold for a category. Nil or empty
// return means no safetySettings are attached.
SafetySetting func(category string) string
}
func (o *ClaudeOptions) DefaultMaxTokensFor(modelName string) (int, bool) {
if o == nil || o.DefaultMaxTokens == nil {
return 0, false
}
return o.DefaultMaxTokens(modelName), true
}
func (o *GeminiOptions) SupportsImagineModel(modelName string) bool {
return o != nil && o.SupportsImagine != nil && o.SupportsImagine(modelName)
}
func (o *GeminiOptions) SafetySettingFor(category string) string {
if o == nil || o.SafetySetting == nil {
return ""
}
return o.SafetySetting(category)
}
func (o *Options) ShouldPreserveThinkingSuffix(modelName string) bool {
return o != nil && o.PreserveThinkingSuffix != nil && o.PreserveThinkingSuffix(modelName)
}
func (o *Options) ShouldPreserveEffortTail(modelName string) bool {
return o != nil && o.PreserveEffortTail != nil && o.PreserveEffortTail(modelName)
}
func (o *Options) EffectiveToolLossPolicy() types.ConversionLossPolicy {
if o == nil || o.ToolLossPolicy == "" {
return types.ConversionLossPolicyAllow
}
return o.ToolLossPolicy
}
+464
View File
@@ -0,0 +1,464 @@
package relayconvert
// golden_test.go pins the byte-level output of selected public conversion
// routes. Run with -update to regenerate testdata/golden.
//
// Volatile values (generated UUID-based ids, unix timestamps) are normalized
// before comparison so the snapshots are deterministic.
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"regexp"
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/stretchr/testify/require"
)
var updateGolden = flag.Bool("update", false, "update golden files")
// TestMain installs a deterministic media resolver so image-bearing fixtures
// convert without network access.
func TestMain(m *testing.M) {
flag.Parse()
SetMediaResolver(MediaResolver{
GetBase64Data: func(c context.Context, source types.FileSource, reason ...string) (string, string, error) {
return "aGVsbG8=", "image/png", nil
},
DecodeBase64FileData: func(base64String string) (string, string, error) {
return "aGVsbG8=", "image/png", nil
},
})
os.Exit(m.Run())
}
const goldenDir = "testdata/golden"
var (
hex32Re = regexp.MustCompile(`[0-9a-f]{32}`)
timestampRe = regexp.MustCompile(`("created(_at)?"\s*:\s*)\d{9,}`)
)
func normalizeVolatile(data []byte) []byte {
data = hex32Re.ReplaceAll(data, []byte("<uuid>"))
data = timestampRe.ReplaceAll(data, []byte(`${1}0`))
return data
}
func marshalGolden(t *testing.T, value any) []byte {
t.Helper()
data, err := json.MarshalIndent(value, "", " ")
require.NoError(t, err)
return append(normalizeVolatile(data), '\n')
}
func checkGolden(t *testing.T, name string, got []byte) {
t.Helper()
path := filepath.Join(goldenDir, name+".golden.json")
if *updateGolden {
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, got, 0o644))
return
}
want, err := os.ReadFile(path)
require.NoError(t, err, "golden file missing, run: cd relaykit && GOWORK=off go test ./relayconvert -run TestGolden -update")
require.Equal(t, string(want), string(got), "conversion output drifted from golden snapshot %s", path)
}
func checkStreamEventsGolden(t *testing.T, name string, events []any) {
t.Helper()
got := marshalGolden(t, map[string]any{"events": events})
path := filepath.Join(goldenDir, name+".golden.json")
if *updateGolden {
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, got, 0o644))
return
}
wantData, err := os.ReadFile(path)
require.NoError(t, err, "golden file missing, run: cd relaykit && GOWORK=off go test ./relayconvert -run TestGolden -update")
var wantSnapshot map[string]json.RawMessage
require.NoError(t, json.Unmarshal(wantData, &wantSnapshot))
wantEvents, ok := wantSnapshot["events"]
require.True(t, ok, "stream golden snapshot %s has no events", path)
want := marshalGolden(t, map[string]json.RawMessage{"events": wantEvents})
require.Equal(t, string(want), string(got), "conversion events drifted from golden snapshot %s", path)
}
// goldenInfo mirrors the host's default converter options (new-api's
// model_setting defaults at the time the snapshots were recorded) so the
// golden files stay comparable across the extraction.
func goldenInfo() convmeta.Meta {
return &convmeta.Values{
ChannelMetaAttached: true,
UpstreamModelName: "upstream-model",
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{
LastMessagesType: convmeta.LastMessageTypeNone,
},
Options: &convmeta.Options{
Gemini: convmeta.GeminiOptions{
ThinkingAdapterBudgetTokensPercentage: 0.6,
FunctionCallThoughtSignatureEnabled: true,
SafetySetting: func(string) string { return "OFF" },
},
},
}
}
// ---------------------------------------------------------------------------
// Fixtures: one representative rich request per source format
// ---------------------------------------------------------------------------
// Fixtures are built by unmarshalling wire-format JSON into the dto types —
// the same path production requests take — so they stay valid as struct
// internals evolve.
func fixtureRequests() map[types.RelayFormat]any {
openai := &dto.GeneralOpenAIRequest{}
mustUnmarshalFixture(`{
"model": "gpt-test",
"max_tokens": 1024,
"stream": true,
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": [
{"type": "text", "text": "What is in this image?"},
{"type": "image_url", "image_url": {"url": "https://example.com/cat.png", "detail": "high"}}
]},
{"role": "assistant", "tool_calls": [{"id": "call_abc", "type": "function", "function": {"name": "get_weather", "arguments": "{\"city\":\"Paris\"}"}}]},
{"role": "tool", "tool_call_id": "call_abc", "content": "15 degrees"},
{"role": "user", "content": "Summarize."}
],
"tools": [{"type": "function", "function": {"name": "get_weather", "description": "Get weather by city", "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}}],
"tool_choice": "auto"
}`, openai)
responses := &dto.OpenAIResponsesRequest{}
mustUnmarshalFixture(`{
"model": "gpt-test",
"stream": true,
"max_output_tokens": 1024,
"instructions": "You are a helpful assistant.",
"input": [
{"type": "message", "role": "user", "content": [
{"type": "input_text", "text": "What is in this image?"},
{"type": "input_image", "image_url": "https://example.com/cat.png"}
]},
{"type": "function_call", "call_id": "call_abc", "name": "get_weather", "arguments": "{\"city\":\"Paris\"}"},
{"type": "function_call_output", "call_id": "call_abc", "output": "15 degrees"}
],
"tools": [{"type": "function", "name": "get_weather", "description": "Get weather by city", "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}]
}`, responses)
return map[types.RelayFormat]any{
types.RelayFormatOpenAI: openai,
types.RelayFormatOpenAIResponses: responses,
}
}
// ---------------------------------------------------------------------------
// Fixtures: one representative non-stream response per source format
// ---------------------------------------------------------------------------
func fixtureResponses() map[types.RelayFormat]any {
openai := &dto.OpenAITextResponse{}
mustUnmarshalFixture(`{
"id": "chatcmpl-fixed",
"object": "chat.completion",
"created": 1700000000,
"model": "gpt-test",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "The answer is 42.",
"reasoning_content": "Deep thought.",
"tool_calls": [{"id": "call_abc", "type": "function", "function": {"name": "get_weather", "arguments": "{\"city\":\"Paris\"}"}}]
},
"finish_reason": "tool_calls"
}],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15,
"prompt_tokens_details": {"cached_tokens": 3},
"completion_tokens_details": {"reasoning_tokens": 2}
}
}`, openai)
claude := &dto.ClaudeResponse{}
mustUnmarshalFixture(`{
"id": "msg_fixed",
"type": "message",
"role": "assistant",
"model": "claude-test",
"content": [
{"type": "text", "text": "The answer is 42."},
{"type": "tool_use", "id": "toolu_abc", "name": "get_weather", "input": {"city": "Paris"}}
],
"stop_reason": "tool_use",
"usage": {"input_tokens": 10, "output_tokens": 5, "cache_read_input_tokens": 3, "cache_creation_input_tokens": 2}
}`, claude)
gemini := &dto.GeminiChatResponse{}
mustUnmarshalFixture(`{
"candidates": [{
"finishReason": "STOP",
"content": {
"role": "model",
"parts": [
{"text": "The answer is 42."},
{"functionCall": {"name": "get_weather", "args": {"city": "Paris"}}}
]
}
}],
"usageMetadata": {"promptTokenCount": 10, "candidatesTokenCount": 5, "thoughtsTokenCount": 2, "totalTokenCount": 15}
}`, gemini)
responses := &dto.OpenAIResponsesResponse{}
mustUnmarshalFixture(`{
"id": "resp_fixed",
"object": "response",
"model": "gpt-test",
"status": "completed",
"output": [
{"type": "reasoning", "summary": [{"type": "summary_text", "text": "Deep thought."}]},
{"type": "message", "role": "assistant", "status": "completed", "content": [{"type": "output_text", "text": "The answer is 42."}]},
{"type": "function_call", "call_id": "call_abc", "name": "get_weather", "arguments": "{\"city\":\"Paris\"}", "status": "completed"}
],
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}
}`, responses)
return map[types.RelayFormat]any{
types.RelayFormatOpenAI: openai,
types.RelayFormatClaude: claude,
types.RelayFormatGemini: gemini,
types.RelayFormatOpenAIResponses: responses,
}
}
// ---------------------------------------------------------------------------
// Fixtures: stream chunk sequences per source format
// ---------------------------------------------------------------------------
func fixtureStreamChunks() map[types.RelayFormat][]any {
return map[types.RelayFormat][]any{
types.RelayFormatOpenAI: {
chatStreamChunk(`{"id":"chatcmpl-fixed","object":"chat.completion.chunk","created":1700000000,"model":"gpt-test","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"}}]}`),
chatStreamChunk(`{"id":"chatcmpl-fixed","object":"chat.completion.chunk","created":1700000000,"model":"gpt-test","choices":[{"index":0,"delta":{"content":" world"}}]}`),
chatStreamChunk(`{"id":"chatcmpl-fixed","object":"chat.completion.chunk","created":1700000000,"model":"gpt-test","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":4,"completion_tokens":2,"total_tokens":6}}`),
},
types.RelayFormatClaude: {
claudeStreamChunk(`{"type":"message_start","message":{"id":"msg_fixed","type":"message","role":"assistant","model":"claude-test","content":[],"usage":{"input_tokens":4,"output_tokens":0}}}`),
claudeStreamChunk(`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`),
claudeStreamChunk(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello world"}}`),
claudeStreamChunk(`{"type":"content_block_stop","index":0}`),
claudeStreamChunk(`{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":2}}`),
claudeStreamChunk(`{"type":"message_stop"}`),
},
types.RelayFormatGemini: {
geminiStreamChunk(`{"candidates":[{"content":{"role":"model","parts":[{"text":"Hello"}]}}]}`),
geminiStreamChunk(`{"candidates":[{"finishReason":"STOP","content":{"role":"model","parts":[{"text":" world"}]}}],"usageMetadata":{"promptTokenCount":4,"candidatesTokenCount":2,"totalTokenCount":6}}`),
},
types.RelayFormatOpenAIResponses: {
responsesStreamChunk(`{"type":"response.output_text.delta","delta":"Hello"}`),
responsesStreamChunk(`{"type":"response.output_text.delta","delta":" world"}`),
responsesStreamChunk(`{"type":"response.completed","response":{"id":"resp_fixed","object":"response","status":"completed","model":"gpt-test","usage":{"input_tokens":4,"output_tokens":2,"total_tokens":6}}}`),
},
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
func allFormats() []types.RelayFormat {
return []types.RelayFormat{
types.RelayFormatOpenAI,
types.RelayFormatClaude,
types.RelayFormatGemini,
types.RelayFormatOpenAIResponses,
}
}
func TestGoldenRequestConversionMatrix(t *testing.T) {
requests := fixtureRequests()
fromFormats := []types.RelayFormat{
types.RelayFormatOpenAI,
types.RelayFormatOpenAIResponses,
}
toFormats := []types.RelayFormat{
types.RelayFormatOpenAI,
types.RelayFormatClaude,
types.RelayFormatOpenAIResponses,
}
for _, from := range fromFormats {
for _, to := range toFormats {
if from == to {
continue
}
if _, ok := lookupRequestRoute(from, to); !ok {
t.Fatalf("request route %s -> %s is no longer registered", from, to)
}
name := fmt.Sprintf("request/%s_to_%s", from, to)
t.Run(name, func(t *testing.T) {
result, err := ConvertRequest(nil, goldenInfo(), to, deepCopyFixture(t, requests[from]))
require.NoError(t, err)
checkGolden(t, name, marshalGolden(t, result.Value))
})
}
}
}
func TestGoldenResponseConversionMatrix(t *testing.T) {
responses := fixtureResponses()
for _, from := range allFormats() {
for _, to := range allFormats() {
if from == to || to == types.RelayFormatGemini ||
(from == types.RelayFormatOpenAI && to == types.RelayFormatClaude) {
continue
}
name := fmt.Sprintf("response/%s_to_%s", from, to)
t.Run(name, func(t *testing.T) {
result, err := ConvertResponse(nil, goldenInfo(), to, deepCopyFixture(t, responses[from]))
require.NoError(t, err)
checkGolden(t, name, marshalGolden(t, result.Value))
})
}
}
}
func TestGoldenStreamConversionMatrix(t *testing.T) {
chunkSets := fixtureStreamChunks()
for _, from := range allFormats() {
for _, to := range allFormats() {
if from == to {
continue
}
name := fmt.Sprintf("stream/%s_to_%s", from, to)
t.Run(name, func(t *testing.T) {
info := goldenInfo()
state, err := NewResponseStreamState(from, to, ResponseStreamOptions{
ID: "stream_fixed",
Model: "stream-model",
})
require.NoError(t, err)
var outputs []any
for _, chunk := range chunkSets[from] {
results, err := ConvertStreamResponseChunk(nil, info, state, deepCopyFixture(t, chunk))
require.NoError(t, err)
for _, r := range results {
outputs = append(outputs, r.Value)
}
}
finals, err := FinalizeStreamResponse(nil, info, state)
require.NoError(t, err)
for _, r := range finals {
outputs = append(outputs, r.Value)
}
// Billing usage has private, cross-module acceptance coverage. Keep
// the public golden focused on client-visible stream events.
checkStreamEventsGolden(t, name, outputs)
})
}
}
}
// ---------------------------------------------------------------------------
// Fixture helpers
// ---------------------------------------------------------------------------
func rawJSON(s string) json.RawMessage {
return json.RawMessage(s)
}
// deepCopyFixture guards against converters mutating shared fixture state
// between subtests (JSON round-trip through the concrete type).
func deepCopyFixture(t *testing.T, v any) any {
t.Helper()
data, err := json.Marshal(v)
require.NoError(t, err)
switch v.(type) {
case *dto.GeneralOpenAIRequest:
out := &dto.GeneralOpenAIRequest{}
require.NoError(t, json.Unmarshal(data, out))
return out
case *dto.ClaudeRequest:
out := &dto.ClaudeRequest{}
require.NoError(t, json.Unmarshal(data, out))
return out
case *dto.GeminiChatRequest:
out := &dto.GeminiChatRequest{}
require.NoError(t, json.Unmarshal(data, out))
return out
case *dto.OpenAIResponsesRequest:
out := &dto.OpenAIResponsesRequest{}
require.NoError(t, json.Unmarshal(data, out))
return out
case *dto.OpenAITextResponse:
out := &dto.OpenAITextResponse{}
require.NoError(t, json.Unmarshal(data, out))
return out
case *dto.ClaudeResponse:
out := &dto.ClaudeResponse{}
require.NoError(t, json.Unmarshal(data, out))
return out
case *dto.GeminiChatResponse:
out := &dto.GeminiChatResponse{}
require.NoError(t, json.Unmarshal(data, out))
return out
case *dto.OpenAIResponsesResponse:
out := &dto.OpenAIResponsesResponse{}
require.NoError(t, json.Unmarshal(data, out))
return out
case *dto.ChatCompletionsStreamResponse:
out := &dto.ChatCompletionsStreamResponse{}
require.NoError(t, json.Unmarshal(data, out))
return out
case *dto.ResponsesStreamResponse:
out := &dto.ResponsesStreamResponse{}
require.NoError(t, json.Unmarshal(data, out))
return out
default:
t.Fatalf("deepCopyFixture: unsupported fixture type %T", v)
return nil
}
}
func chatStreamChunk(raw string) *dto.ChatCompletionsStreamResponse {
var r dto.ChatCompletionsStreamResponse
mustUnmarshalFixture(raw, &r)
return &r
}
func claudeStreamChunk(raw string) *dto.ClaudeResponse {
var r dto.ClaudeResponse
mustUnmarshalFixture(raw, &r)
return &r
}
func geminiStreamChunk(raw string) *dto.GeminiChatResponse {
var r dto.GeminiChatResponse
mustUnmarshalFixture(raw, &r)
return &r
}
func responsesStreamChunk(raw string) *dto.ResponsesStreamResponse {
var r dto.ResponsesStreamResponse
mustUnmarshalFixture(raw, &r)
return &r
}
func mustUnmarshalFixture(raw string, out any) {
if err := json.Unmarshal([]byte(raw), out); err != nil {
panic(fmt.Sprintf("bad fixture JSON: %v", err))
}
}
@@ -0,0 +1,58 @@
package claudemessages
import (
"encoding/json"
"fmt"
"strings"
"unicode/utf8"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
func claudeCitationsToChat(raw json.RawMessage, text string, textOffset int) ([]any, error) {
if len(raw) == 0 {
return nil, nil
}
var citations []map[string]any
if err := kitutil.Unmarshal(raw, &citations); err != nil {
return nil, fmt.Errorf("invalid Claude citations: %w", err)
}
annotations := make([]any, 0, len(citations))
for _, citation := range citations {
url := strings.TrimSpace(kitutil.Interface2String(citation["url"]))
if url == "" {
continue
}
converted := map[string]any{
"url": url,
"title": strings.TrimSpace(kitutil.Interface2String(citation["title"])),
}
citedText := kitutil.Interface2String(citation["cited_text"])
if citedText != "" {
converted["cited_text"] = citedText
if before, _, ok := strings.Cut(text, citedText); ok {
startIndex := textOffset + utf8.RuneCountInString(before)
converted["start_index"] = startIndex
converted["end_index"] = startIndex + utf8.RuneCountInString(citedText)
}
}
if encryptedIndex := kitutil.Interface2String(citation["encrypted_index"]); encryptedIndex != "" {
converted["encrypted_index"] = encryptedIndex
}
if converted["title"] == "" {
delete(converted, "title")
}
annotations = append(annotations, map[string]any{
"type": "url_citation",
"url_citation": converted,
})
}
return annotations, nil
}
func marshalChatAnnotations(annotations []any) (json.RawMessage, error) {
if len(annotations) == 0 {
return nil, nil
}
return kitutil.Marshal(annotations)
}
@@ -0,0 +1,66 @@
package claudemessages
import (
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMessageStartZeroOutputSidecarRemainsRefreshable(t *testing.T) {
t.Parallel()
info := &ClaudeResponseInfo{Usage: &dto.Usage{}}
ok := FormatClaudeResponseInfo(&dto.ClaudeResponse{
Type: "message_start",
Message: &dto.ClaudeMediaMessage{
Id: "msg_1",
Model: "claude-test",
Usage: &dto.ClaudeUsage{
InputTokens: 10,
OutputTokens: 0,
BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{
InputTokens: 10,
OutputTokens: 0,
}),
},
},
}, nil, info)
require.True(t, ok)
ok = FormatClaudeResponseInfo(&dto.ClaudeResponse{
Type: "message_delta",
Usage: &dto.ClaudeUsage{
OutputTokens: 42,
},
}, nil, info)
require.True(t, ok)
require.NotNil(t, info.Usage.BillingUsage)
require.NotNil(t, info.Usage.BillingUsage.ClaudeUsage)
assert.Equal(t, 42, info.Usage.BillingUsage.ClaudeUsage.OutputTokens)
}
func TestTerminalSidecarRemainsAuthoritativeAgainstFinalize(t *testing.T) {
t.Parallel()
info := &ClaudeResponseInfo{Usage: &dto.Usage{}}
ok := FormatClaudeResponseInfo(&dto.ClaudeResponse{
Type: "message_delta",
Usage: &dto.ClaudeUsage{
InputTokens: 10,
OutputTokens: 7,
BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{
InputTokens: 10,
OutputTokens: 7,
}),
},
}, nil, info)
require.True(t, ok)
require.NotNil(t, info.Usage.BillingUsage)
require.NotNil(t, info.Usage.BillingUsage.ClaudeUsage)
info.Usage.CompletionTokens = 99
FinalizeClaudeStreamBillingUsage(info)
assert.Equal(t, 7, info.Usage.BillingUsage.ClaudeUsage.OutputTokens)
}
@@ -0,0 +1,239 @@
package claudemessages
import (
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
const (
webSearchMaxUsesLow = 1
webSearchMaxUsesMedium = 5
webSearchMaxUsesHigh = 10
)
type openRouterRequestReasoning struct {
Enabled *bool `json:"enabled,omitempty"`
Effort string `json:"effort,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Exclude bool `json:"exclude,omitempty"`
}
func ClaudeMessagesRequestToOpenAIChat(claudeRequest dto.ClaudeRequest, info convmeta.Meta) (*dto.GeneralOpenAIRequest, error) {
openAIRequest := dto.GeneralOpenAIRequest{
Model: claudeRequest.Model,
Temperature: claudeRequest.Temperature,
}
if claudeRequest.MaxTokens != nil {
openAIRequest.MaxTokens = kitutil.GetPointer(*claudeRequest.MaxTokens)
}
if claudeRequest.TopP != nil {
openAIRequest.TopP = kitutil.GetPointer(*claudeRequest.TopP)
}
if claudeRequest.TopK != nil {
openAIRequest.TopK = kitutil.GetPointer(*claudeRequest.TopK)
}
if claudeRequest.Stream != nil {
openAIRequest.Stream = kitutil.GetPointer(*claudeRequest.Stream)
}
reasoningIntent, effectiveEffort, err := claudeRequestReasoningIntent(&claudeRequest, info)
if err != nil {
return nil, reasoning.AsClientError(err)
}
isOpenRouter := convmeta.OptionsOf(info).OpenRouterDialect
if isOpenRouter {
if effort := claudeRequest.GetEfforts(); effort != "" {
effortBytes, _ := kitutil.Marshal(effort)
openAIRequest.Verbosity = effortBytes
}
if !reasoningIntent.IsEmpty() {
var reasoningConfig openRouterRequestReasoning
disabled := reasoningIntent.Mode == reasoning.ModeDisabled || reasoningIntent.Effort == reasoning.EffortNone
enabled := !disabled
reasoningConfig.Enabled = &enabled
if enabled && reasoningIntent.BudgetTokens != nil && reasoningIntent.Mode != reasoning.ModeAdaptive {
reasoningConfig = openRouterRequestReasoning{
Enabled: &enabled,
MaxTokens: *reasoningIntent.BudgetTokens,
}
} else if enabled {
reasoningConfig.Effort = string(reasoning.EffectiveEffort(reasoningIntent))
}
if reasoningIntent.IncludeThoughts != nil {
reasoningConfig.Exclude = !*reasoningIntent.IncludeThoughts
}
reasoningJSON, err := kitutil.Marshal(reasoningConfig)
if err != nil {
return nil, fmt.Errorf("failed to marshal reasoning: %w", err)
}
openAIRequest.Reasoning = reasoningJSON
}
} else {
if err := reasoning.ApplyToOpenAIChat(&openAIRequest, reasoningIntent); err != nil {
return nil, reasoning.AsClientError(err)
}
if info != nil {
// Keep the outgoing -thinking suffix so a cascaded downstream
// new-api can recover reasoning intent from the model name. This
// is an emission-side policy, not converter-side suffix parsing.
thinkingSuffix := "-thinking"
if strings.HasSuffix(info.GetOriginModelName(), thinkingSuffix) &&
!strings.HasSuffix(openAIRequest.Model, thinkingSuffix) {
openAIRequest.Model = openAIRequest.Model + thinkingSuffix
}
}
}
if info != nil && effectiveEffort != "" {
info.SetReasoningEffort(string(effectiveEffort))
}
if len(claudeRequest.StopSequences) == 1 {
openAIRequest.Stop = claudeRequest.StopSequences[0]
} else if len(claudeRequest.StopSequences) > 1 {
openAIRequest.Stop = claudeRequest.StopSequences
}
tools, _ := kitutil.Any2Type[[]dto.Tool](claudeRequest.Tools)
openAITools := make([]dto.ToolCallRequest, 0)
for _, claudeTool := range tools {
openAITool := dto.ToolCallRequest{
Type: "function",
Function: dto.FunctionRequest{
Name: claudeTool.Name,
Description: claudeTool.Description,
Parameters: claudeTool.InputSchema,
},
}
openAITools = append(openAITools, openAITool)
}
openAIRequest.Tools = openAITools
openAIMessages := make([]dto.Message, 0)
if claudeRequest.System != nil {
if claudeRequest.IsStringSystem() && claudeRequest.GetStringSystem() != "" {
openAIMessage := dto.Message{
Role: "system",
}
openAIMessage.SetStringContent(claudeRequest.GetStringSystem())
openAIMessages = append(openAIMessages, openAIMessage)
} else {
systems := claudeRequest.ParseSystem()
if len(systems) > 0 {
openAIMessage := dto.Message{
Role: "system",
}
isOpenRouterClaude := isOpenRouter && strings.HasPrefix(convmeta.UpstreamModelName(info), "anthropic/claude")
if isOpenRouterClaude {
systemMediaMessages := make([]dto.MediaContent, 0, len(systems))
for _, system := range systems {
message := dto.MediaContent{
Type: "text",
Text: system.GetText(),
CacheControl: system.CacheControl,
}
systemMediaMessages = append(systemMediaMessages, message)
}
openAIMessage.SetMediaContent(systemMediaMessages)
} else {
var systemStr strings.Builder
for _, system := range systems {
if system.Text != nil {
systemStr.WriteString(*system.Text)
}
}
openAIMessage.SetStringContent(systemStr.String())
}
openAIMessages = append(openAIMessages, openAIMessage)
}
}
}
for _, claudeMessage := range claudeRequest.Messages {
openAIMessage := dto.Message{
Role: claudeMessage.Role,
}
if claudeMessage.IsStringContent() {
openAIMessage.SetStringContent(claudeMessage.GetStringContent())
} else {
content, err := claudeMessage.ParseContent()
if err != nil {
return nil, err
}
var toolCalls []dto.ToolCallRequest
mediaMessages := make([]dto.MediaContent, 0, len(content))
for _, mediaMsg := range content {
switch mediaMsg.Type {
case "text", "input_text":
message := dto.MediaContent{
Type: "text",
Text: mediaMsg.GetText(),
CacheControl: mediaMsg.CacheControl,
}
mediaMessages = append(mediaMessages, message)
case "image":
imageData := fmt.Sprintf("data:%s;base64,%s", mediaMsg.Source.MediaType, mediaMsg.Source.Data)
mediaMessage := dto.MediaContent{
Type: "image_url",
ImageUrl: &dto.MessageImageUrl{Url: imageData},
}
mediaMessages = append(mediaMessages, mediaMessage)
case "tool_use":
toolCall := dto.ToolCallRequest{
ID: mediaMsg.Id,
Type: "function",
Function: dto.FunctionRequest{
Name: mediaMsg.Name,
Arguments: requestToJSONString(mediaMsg.Input),
},
}
toolCalls = append(toolCalls, toolCall)
case "tool_result":
toolName := mediaMsg.Name
if toolName == "" {
toolName = claudeRequest.SearchToolNameByToolCallId(mediaMsg.ToolUseId)
}
oaiToolMessage := dto.Message{
Role: "tool",
Name: &toolName,
ToolCallId: mediaMsg.ToolUseId,
}
if mediaMsg.IsStringContent() {
oaiToolMessage.SetStringContent(mediaMsg.GetStringContent())
} else {
mediaContents := mediaMsg.ParseMediaContent()
encodedJSON, _ := kitutil.Marshal(mediaContents)
oaiToolMessage.SetStringContent(string(encodedJSON))
}
openAIMessages = append(openAIMessages, oaiToolMessage)
}
}
if len(toolCalls) > 0 {
openAIMessage.SetToolCalls(toolCalls)
}
if len(mediaMessages) > 0 && len(toolCalls) == 0 {
openAIMessage.SetMediaContent(mediaMessages)
}
}
if len(openAIMessage.ParseContent()) > 0 || len(openAIMessage.ToolCalls) > 0 {
openAIMessages = append(openAIMessages, openAIMessage)
}
}
openAIRequest.Messages = openAIMessages
return &openAIRequest, nil
}
func requestToJSONString(v any) string {
b, err := kitutil.Marshal(v)
if err != nil {
return "{}"
}
return string(b)
}
@@ -0,0 +1,564 @@
package claudemessages
import (
"encoding/json"
"fmt"
"strings"
"unicode/utf8"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/reasonmap"
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
type ClaudeResponseInfo struct {
ResponseId string
Created int64
Model string
ResponseText strings.Builder
Usage *dto.Usage
Done bool
// Only snapshots synthesized from partial display usage may be refreshed by
// later display deltas. Serialized BillingUsage always remains authoritative.
billingUsageSynthesized bool
}
func StopReasonClaudeToOpenAI(reason string) string {
return reasonmap.ClaudeStopReasonToOpenAIFinishReason(reason)
}
func StreamResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.ChatCompletionsStreamResponse {
var response dto.ChatCompletionsStreamResponse
response.Object = "chat.completion.chunk"
response.Model = claudeResponse.Model
response.Choices = make([]dto.ChatCompletionsStreamResponseChoice, 0)
tools := make([]dto.ToolCallResponse, 0)
fcIdx := 0
if claudeResponse.Index != nil {
fcIdx = *claudeResponse.Index
}
var choice dto.ChatCompletionsStreamResponseChoice
if claudeResponse.Type == "message_start" {
if claudeResponse.Message != nil {
response.Id = claudeResponse.Message.Id
response.Model = claudeResponse.Message.Model
}
choice.Delta.SetContentString("")
choice.Delta.Role = "assistant"
} else if claudeResponse.Type == "content_block_start" {
if claudeResponse.ContentBlock != nil {
if claudeResponse.ContentBlock.Type == "text" && claudeResponse.ContentBlock.Text != nil {
choice.Delta.SetContentString(*claudeResponse.ContentBlock.Text)
annotations, err := claudeCitationsToChat(claudeResponse.ContentBlock.Citations, *claudeResponse.ContentBlock.Text, 0)
if err == nil {
choice.Delta.Annotations, _ = marshalChatAnnotations(annotations)
}
}
if claudeResponse.ContentBlock.Type == "tool_use" {
tools = append(tools, dto.ToolCallResponse{
Index: kitutil.GetPointer(fcIdx),
ID: claudeResponse.ContentBlock.Id,
Type: "function",
Function: dto.FunctionResponse{
Name: claudeResponse.ContentBlock.Name,
Arguments: "",
},
})
}
} else {
return nil
}
} else if claudeResponse.Type == "content_block_delta" {
if claudeResponse.Delta != nil {
choice.Delta.Content = claudeResponse.Delta.Text
switch claudeResponse.Delta.Type {
case "input_json_delta":
tools = append(tools, dto.ToolCallResponse{
Type: "function",
Index: kitutil.GetPointer(fcIdx),
Function: dto.FunctionResponse{
Arguments: *claudeResponse.Delta.PartialJson,
},
})
case "signature_delta":
signatureContent := "\n"
choice.Delta.ReasoningContent = &signatureContent
case "thinking_delta":
choice.Delta.ReasoningContent = claudeResponse.Delta.Thinking
case "citations_delta":
if len(claudeResponse.Delta.Citation) > 0 {
raw, _ := kitutil.Marshal([]json.RawMessage{claudeResponse.Delta.Citation})
annotations, err := claudeCitationsToChat(raw, "", 0)
if err == nil {
choice.Delta.Annotations, _ = marshalChatAnnotations(annotations)
}
}
}
}
} else if claudeResponse.Type == "message_delta" {
if claudeResponse.Delta != nil && claudeResponse.Delta.StopReason != nil {
finishReason := StopReasonClaudeToOpenAI(*claudeResponse.Delta.StopReason)
if finishReason != "null" {
choice.FinishReason = &finishReason
}
}
} else if claudeResponse.Type == "message_stop" {
return nil
} else {
return nil
}
if len(tools) > 0 {
choice.Delta.Content = nil
choice.Delta.ToolCalls = tools
}
response.Choices = append(response.Choices, choice)
return &response
}
// ClaudeToChatStreamState translates Anthropic content block indexes into the
// independent, dense index space used by Chat Completions tool_calls. Text and
// thinking blocks therefore do not create holes in the downstream tool array.
type ClaudeToChatStreamState struct {
toolIndexByContentBlock map[int]int
blockTypeByContentBlock map[int]string
nextToolIndex int
}
func NewClaudeToChatStreamState() *ClaudeToChatStreamState {
return &ClaudeToChatStreamState{
toolIndexByContentBlock: make(map[int]int),
blockTypeByContentBlock: make(map[int]string),
}
}
func (s *ClaudeToChatStreamState) ConvertChunk(claudeResponse *dto.ClaudeResponse) (*dto.ChatCompletionsStreamResponse, error) {
if s == nil {
return nil, fmt.Errorf("Claude-to-Chat stream state is required")
}
if claudeResponse == nil {
return nil, nil
}
if s.toolIndexByContentBlock == nil {
s.toolIndexByContentBlock = make(map[int]int)
}
if s.blockTypeByContentBlock == nil {
s.blockTypeByContentBlock = make(map[int]string)
}
converted := *claudeResponse
switch claudeResponse.Type {
case "content_block_start":
if claudeResponse.ContentBlock == nil {
break
}
blockType := strings.TrimSpace(claudeResponse.ContentBlock.Type)
if blockType == "" {
break
}
if claudeResponse.Index == nil {
return nil, fmt.Errorf("Claude content block stream start is missing index")
}
contentBlockIndex := *claudeResponse.Index
s.blockTypeByContentBlock[contentBlockIndex] = blockType
if blockType != "tool_use" {
if isClaudeHostedToolStreamBlock(blockType) {
return nil, nil
}
break
}
toolIndex, exists := s.toolIndexByContentBlock[contentBlockIndex]
if !exists {
toolIndex = s.nextToolIndex
s.nextToolIndex++
s.toolIndexByContentBlock[contentBlockIndex] = toolIndex
}
converted.Index = kitutil.GetPointer(toolIndex)
case "content_block_delta":
if claudeResponse.Delta == nil || claudeResponse.Delta.Type != "input_json_delta" {
break
}
if claudeResponse.Index == nil {
return nil, fmt.Errorf("Claude tool-use stream delta is missing content block index")
}
if claudeResponse.Delta.PartialJson == nil {
return nil, fmt.Errorf("Claude tool-use stream delta is missing partial JSON")
}
contentBlockIndex := *claudeResponse.Index
toolIndex, exists := s.toolIndexByContentBlock[contentBlockIndex]
if !exists {
if isClaudeHostedToolStreamBlock(s.blockTypeByContentBlock[contentBlockIndex]) {
return nil, nil
}
return nil, fmt.Errorf("Claude tool-use stream delta references unknown content block index %d", contentBlockIndex)
}
converted.Index = kitutil.GetPointer(toolIndex)
case "content_block_stop":
if claudeResponse.Index != nil {
delete(s.blockTypeByContentBlock, *claudeResponse.Index)
}
}
return StreamResponseClaude2OpenAI(&converted), nil
}
func isClaudeHostedToolStreamBlock(blockType string) bool {
switch blockType {
case "server_tool_use", "mcp_tool_use", "web_search_tool_result", "mcp_tool_result", "code_execution_tool_result", "web_fetch_tool_result":
return true
default:
return false
}
}
func ResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.OpenAITextResponse {
choices := make([]dto.OpenAITextResponseChoice, 0)
fullTextResponse := dto.OpenAITextResponse{
Id: fmt.Sprintf("chatcmpl-%s", kitutil.GetUUID()),
Object: "chat.completion",
Created: kitutil.GetTimestamp(),
}
var responseText strings.Builder
responseTextOffset := 0
var responseThinking string
if len(claudeResponse.Content) > 0 {
if claudeResponse.Content[0].Thinking != nil {
responseThinking = *claudeResponse.Content[0].Thinking
}
}
tools := make([]dto.ToolCallResponse, 0)
thinkingContent := ""
annotations := make([]any, 0)
fullTextResponse.Id = claudeResponse.Id
for _, message := range claudeResponse.Content {
switch message.Type {
case "tool_use":
args, _ := kitutil.Marshal(message.Input)
tools = append(tools, dto.ToolCallResponse{
ID: message.Id,
Type: "function",
Function: dto.FunctionResponse{
Name: message.Name,
Arguments: string(args),
},
})
case "thinking":
if message.Thinking != nil {
thinkingContent = *message.Thinking
}
case "text":
text := message.GetText()
offset := responseTextOffset
responseText.WriteString(text)
responseTextOffset += utf8.RuneCountInString(text)
converted, err := claudeCitationsToChat(message.Citations, text, offset)
if err == nil {
annotations = append(annotations, converted...)
}
}
}
choice := dto.OpenAITextResponseChoice{
Index: 0,
Message: dto.Message{
Role: "assistant",
},
FinishReason: StopReasonClaudeToOpenAI(claudeResponse.StopReason),
}
choice.SetStringContent(responseText.String())
if encodedAnnotations, err := marshalChatAnnotations(annotations); err == nil && len(encodedAnnotations) > 0 {
choice.Message.Annotations = encodedAnnotations
}
if len(responseThinking) > 0 {
choice.ReasoningContent = &responseThinking
}
if len(tools) > 0 {
choice.Message.SetToolCalls(tools)
}
if thinkingContent != "" {
choice.Message.ReasoningContent = &thinkingContent
}
fullTextResponse.Model = claudeResponse.Model
choices = append(choices, choice)
fullTextResponse.Choices = choices
return &fullTextResponse
}
func UsageFromClaudeAPIUsage(usage *dto.ClaudeUsage) *dto.Usage {
if usage == nil {
return &dto.Usage{}
}
semanticUsage := &dto.Usage{
PromptTokens: usage.InputTokens,
CompletionTokens: usage.OutputTokens,
UsageSemantic: "anthropic",
UsageSource: "anthropic",
BillingUsage: dto.CloneBillingUsage(usage.BillingUsage),
}
if semanticUsage.BillingUsage == nil {
semanticUsage.BillingUsage = dto.NewClaudeMessagesBillingUsage(usage)
}
semanticUsage.PromptTokensDetails.CachedTokens = usage.CacheReadInputTokens
semanticUsage.PromptTokensDetails.CachedCreationTokens = usage.CacheCreationInputTokens
semanticUsage.ClaudeCacheCreation5mTokens = usage.GetCacheCreation5mTokens()
semanticUsage.ClaudeCacheCreation1hTokens = usage.GetCacheCreation1hTokens()
return UsageFromClaudeUsage(semanticUsage)
}
func UsageFromClaudeUsage(usage *dto.Usage) *dto.Usage {
mapped := buildOpenAIStyleUsageFromClaudeUsage(usage)
return &mapped
}
func cacheCreationTokensForOpenAIUsage(usage *dto.Usage) int {
if usage == nil {
return 0
}
splitCacheCreationTokens := usage.ClaudeCacheCreation5mTokens + usage.ClaudeCacheCreation1hTokens
if splitCacheCreationTokens == 0 {
return usage.PromptTokensDetails.CachedCreationTokens
}
if usage.PromptTokensDetails.CachedCreationTokens > splitCacheCreationTokens {
return usage.PromptTokensDetails.CachedCreationTokens
}
return splitCacheCreationTokens
}
func buildOpenAIStyleUsageFromClaudeUsage(usage *dto.Usage) dto.Usage {
if usage == nil {
return dto.Usage{}
}
clone := *usage
clone.BillingUsage = dto.CloneBillingUsage(usage.BillingUsage)
clone.ClaudeCacheCreation5mTokens, clone.ClaudeCacheCreation1hTokens = sharedclaude.NormalizeCacheCreationSplit(
usage.PromptTokensDetails.CachedCreationTokens,
usage.ClaudeCacheCreation5mTokens,
usage.ClaudeCacheCreation1hTokens,
)
cacheCreationTokens := cacheCreationTokensForOpenAIUsage(usage)
// Expose the standard OpenAI cache-write field alongside the legacy
// cached_creation_tokens so OpenAI-format clients can bill cache writes.
clone.PromptTokensDetails.CacheWriteTokens = cacheCreationTokens
totalInputTokens := usage.PromptTokens + usage.PromptTokensDetails.CachedTokens + cacheCreationTokens
clone.PromptTokens = totalInputTokens
clone.InputTokens = totalInputTokens
clone.TotalTokens = totalInputTokens + usage.CompletionTokens
clone.UsageSemantic = "openai"
clone.UsageSource = "anthropic"
return clone
}
func BuildMessageDeltaPatchUsage(claudeResponse *dto.ClaudeResponse, claudeInfo *ClaudeResponseInfo) *dto.ClaudeUsage {
usage := &dto.ClaudeUsage{}
if claudeResponse != nil && claudeResponse.Usage != nil {
*usage = *claudeResponse.Usage
}
if claudeInfo == nil || claudeInfo.Usage == nil {
return usage
}
if usage.InputTokens == 0 && claudeInfo.Usage.PromptTokens > 0 {
usage.InputTokens = claudeInfo.Usage.PromptTokens
}
if usage.CacheReadInputTokens == 0 && claudeInfo.Usage.PromptTokensDetails.CachedTokens > 0 {
usage.CacheReadInputTokens = claudeInfo.Usage.PromptTokensDetails.CachedTokens
}
if usage.CacheCreationInputTokens == 0 && claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens > 0 {
usage.CacheCreationInputTokens = claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens
}
cacheCreation5m := 0
cacheCreation1h := 0
if usage.CacheCreation != nil {
cacheCreation5m = usage.CacheCreation.Ephemeral5mInputTokens
cacheCreation1h = usage.CacheCreation.Ephemeral1hInputTokens
} else {
cacheCreation5m = claudeInfo.Usage.ClaudeCacheCreation5mTokens
cacheCreation1h = claudeInfo.Usage.ClaudeCacheCreation1hTokens
}
cacheCreation5m, cacheCreation1h = sharedclaude.NormalizeCacheCreationSplit(
usage.CacheCreationInputTokens,
cacheCreation5m,
cacheCreation1h,
)
if usage.CacheCreation == nil && (cacheCreation5m > 0 || cacheCreation1h > 0) {
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{}
}
if usage.CacheCreation != nil {
usage.CacheCreation.Ephemeral5mInputTokens = cacheCreation5m
usage.CacheCreation.Ephemeral1hInputTokens = cacheCreation1h
}
return usage
}
func claudeBillingUsageFromSemanticUsage(usage *dto.Usage) *dto.BillingUsage {
if usage == nil {
return nil
}
cacheCreation5m, cacheCreation1h := sharedclaude.NormalizeCacheCreationSplit(
usage.PromptTokensDetails.CachedCreationTokens,
usage.ClaudeCacheCreation5mTokens,
usage.ClaudeCacheCreation1hTokens,
)
claudeUsage := &dto.ClaudeUsage{
InputTokens: usage.PromptTokens,
CacheCreationInputTokens: usage.PromptTokensDetails.CachedCreationTokens,
CacheReadInputTokens: usage.PromptTokensDetails.CachedTokens,
OutputTokens: usage.CompletionTokens,
}
if cacheCreation5m > 0 || cacheCreation1h > 0 {
claudeUsage.CacheCreation = &dto.ClaudeCacheCreationUsage{
Ephemeral5mInputTokens: cacheCreation5m,
Ephemeral1hInputTokens: cacheCreation1h,
}
}
return dto.NewClaudeMessagesBillingUsage(claudeUsage)
}
func updateClaudeStreamBillingUsage(claudeUsage *dto.ClaudeUsage, claudeInfo *ClaudeResponseInfo, terminal bool) {
if claudeUsage == nil || claudeInfo == nil || claudeInfo.Usage == nil {
return
}
if billingUsage := dto.CloneBillingUsage(claudeUsage.BillingUsage); billingUsage != nil {
claudeInfo.Usage.BillingUsage = billingUsage
if terminal || claudeUsage.OutputTokens > 0 {
claudeInfo.billingUsageSynthesized = false
return
}
claudeInfo.billingUsageSynthesized = true
return
}
if claudeInfo.Usage.BillingUsage != nil && !claudeInfo.billingUsageSynthesized {
return
}
claudeInfo.Usage.BillingUsage = claudeBillingUsageFromSemanticUsage(claudeInfo.Usage)
claudeInfo.billingUsageSynthesized = claudeInfo.Usage.BillingUsage != nil
}
// FinalizeClaudeStreamBillingUsage refreshes only a locally synthesized
// snapshot after the host has applied its missing-usage fallback. A snapshot
// received on the wire remains authoritative and is never rewritten.
func FinalizeClaudeStreamBillingUsage(claudeInfo *ClaudeResponseInfo) {
if claudeInfo == nil || claudeInfo.Usage == nil {
return
}
if claudeInfo.Usage.BillingUsage != nil && !claudeInfo.billingUsageSynthesized {
return
}
billingUsage := claudeBillingUsageFromSemanticUsage(claudeInfo.Usage)
if billingUsage != nil && !claudeInfo.Done {
billingUsage.Estimated = true
}
claudeInfo.Usage.BillingUsage = billingUsage
claudeInfo.billingUsageSynthesized = billingUsage != nil
}
func PatchClaudeMessageDeltaUsageData(data string, usage *dto.ClaudeUsage) string {
if data == "" || usage == nil {
return data
}
data = setMessageDeltaUsageInt(data, "usage.input_tokens", usage.InputTokens)
data = setMessageDeltaUsageInt(data, "usage.cache_read_input_tokens", usage.CacheReadInputTokens)
data = setMessageDeltaUsageInt(data, "usage.cache_creation_input_tokens", usage.CacheCreationInputTokens)
if usage.CacheCreation != nil {
data = setMessageDeltaUsageInt(data, "usage.cache_creation.ephemeral_5m_input_tokens", usage.CacheCreation.Ephemeral5mInputTokens)
data = setMessageDeltaUsageInt(data, "usage.cache_creation.ephemeral_1h_input_tokens", usage.CacheCreation.Ephemeral1hInputTokens)
}
return data
}
func setMessageDeltaUsageInt(data string, path string, localValue int) string {
if localValue <= 0 {
return data
}
upstreamValue := gjson.Get(data, path)
if upstreamValue.Exists() && upstreamValue.Int() > 0 {
return data
}
patchedData, err := sjson.Set(data, path, localValue)
if err != nil {
return data
}
return patchedData
}
func FormatClaudeResponseInfo(claudeResponse *dto.ClaudeResponse, oaiResponse *dto.ChatCompletionsStreamResponse, claudeInfo *ClaudeResponseInfo) bool {
if claudeInfo == nil {
return false
}
if claudeInfo.Usage == nil {
claudeInfo.Usage = &dto.Usage{}
}
if claudeResponse.Type == "message_start" {
if claudeResponse.Message != nil {
claudeInfo.ResponseId = claudeResponse.Message.Id
claudeInfo.Model = claudeResponse.Message.Model
}
if claudeResponse.Message != nil && claudeResponse.Message.Usage != nil {
messageUsage := claudeResponse.Message.Usage
claudeInfo.Usage.PromptTokens = messageUsage.InputTokens
claudeInfo.Usage.UsageSemantic = "anthropic"
claudeInfo.Usage.PromptTokensDetails.CachedTokens = messageUsage.CacheReadInputTokens
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = messageUsage.CacheCreationInputTokens
claudeInfo.Usage.ClaudeCacheCreation5mTokens = messageUsage.GetCacheCreation5mTokens()
claudeInfo.Usage.ClaudeCacheCreation1hTokens = messageUsage.GetCacheCreation1hTokens()
claudeInfo.Usage.CompletionTokens = messageUsage.OutputTokens
updateClaudeStreamBillingUsage(messageUsage, claudeInfo, false)
}
} else if claudeResponse.Type == "content_block_delta" {
if claudeResponse.Delta != nil {
if claudeResponse.Delta.Text != nil {
claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Text)
}
if claudeResponse.Delta.Thinking != nil {
claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Thinking)
}
}
} else if claudeResponse.Type == "message_delta" {
if claudeResponse.Usage != nil {
claudeInfo.Usage.UsageSemantic = "anthropic"
if claudeResponse.Usage.InputTokens > 0 {
claudeInfo.Usage.PromptTokens = claudeResponse.Usage.InputTokens
}
if claudeResponse.Usage.CacheReadInputTokens > 0 {
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Usage.CacheReadInputTokens
}
if claudeResponse.Usage.CacheCreationInputTokens > 0 {
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Usage.CacheCreationInputTokens
}
if cacheCreation5m := claudeResponse.Usage.GetCacheCreation5mTokens(); cacheCreation5m > 0 {
claudeInfo.Usage.ClaudeCacheCreation5mTokens = cacheCreation5m
}
if cacheCreation1h := claudeResponse.Usage.GetCacheCreation1hTokens(); cacheCreation1h > 0 {
claudeInfo.Usage.ClaudeCacheCreation1hTokens = cacheCreation1h
}
if claudeResponse.Usage.OutputTokens > 0 {
claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens
}
claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens
updateClaudeStreamBillingUsage(claudeResponse.Usage, claudeInfo, true)
}
claudeInfo.Done = true
} else if claudeResponse.Type == "content_block_start" {
} else {
return false
}
if oaiResponse != nil {
oaiResponse.Id = claudeInfo.ResponseId
oaiResponse.Created = claudeInfo.Created
oaiResponse.Model = claudeInfo.Model
}
return true
}
@@ -0,0 +1,194 @@
package claudemessages
import (
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
oaichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/oai_chat"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
// ClaudeHostedStreamBridge keeps Anthropic server-executed tool blocks out of
// the Chat Completions pivot. Anthropic streams a hosted call's input after the
// content_block_start event, so the bridge owns those input_json_delta events
// until content_block_stop and only then starts the Responses output item.
type ClaudeHostedStreamBridge struct {
pending map[int]*claudeHostedStreamCall
}
type claudeHostedStreamCall struct {
blockType string
id string
name string
serverName string
caller []byte
startInput []byte
input strings.Builder
}
func NewClaudeHostedStreamBridge() *ClaudeHostedStreamBridge {
return &ClaudeHostedStreamBridge{pending: make(map[int]*claudeHostedStreamCall)}
}
// Convert consumes provider-hosted stream frames and reports whether the frame
// must be skipped by the ordinary Claude-to-Chat converter.
func (b *ClaudeHostedStreamBridge) Convert(response *dto.ClaudeResponse, state *oaichat.ChatToResponsesStreamState) ([]oaichat.ChatToResponsesStreamEvent, bool, error) {
if response == nil || state == nil {
return nil, false, nil
}
if b == nil {
return nil, false, fmt.Errorf("Claude hosted stream bridge is required")
}
if b.pending == nil {
b.pending = make(map[int]*claudeHostedStreamCall)
}
index := response.GetIndex()
switch response.Type {
case "content_block_start":
if response.ContentBlock == nil {
return nil, false, nil
}
block := response.ContentBlock
blockType := strings.TrimSpace(block.Type)
switch blockType {
case "server_tool_use", "mcp_tool_use":
if _, exists := b.pending[index]; exists {
return nil, true, fmt.Errorf("duplicate Claude hosted-tool content block index %d", index)
}
if blockType == "mcp_tool_use" && (strings.TrimSpace(block.Name) == "" || strings.TrimSpace(block.ServerName) == "") {
return nil, true, fmt.Errorf("Claude MCP tool use must include name and server_name")
}
if _, err := claudeHostedCallOutputType(blockType, block.Name); err != nil {
return nil, true, err
}
pending := &claudeHostedStreamCall{
blockType: blockType,
id: block.Id,
name: block.Name,
serverName: block.ServerName,
caller: append([]byte(nil), block.Caller...),
}
// Non-stream-shaped gateways occasionally include the complete input
// on the start frame. Preserve it as a fallback, while streamed deltas
// replace the placeholder at block completion.
if block.Input != nil {
input, err := kitutil.Marshal(block.Input)
if err != nil {
return nil, true, fmt.Errorf("marshal Claude hosted-tool input: %w", err)
}
if string(input) != "{}" && string(input) != "null" {
pending.startInput = input
}
}
b.pending[index] = pending
return nil, true, nil
case "web_search_tool_result", "mcp_tool_result":
outputType, err := claudeHostedResultOutputType(blockType)
if err != nil {
return nil, true, err
}
var result []byte
if outputType != "web_search_call" {
result, err = kitutil.Marshal(block.Content)
if err != nil {
return nil, true, fmt.Errorf("marshal Claude hosted-tool result: %w", err)
}
}
events, err := state.CompleteHostedTool(oaichat.HostedToolStreamResult{
Type: outputType,
ID: block.ToolUseId,
Result: result,
ErrorCode: claudeHostedResultErrorCode(block.Content, block.ErrorCode),
IsError: block.IsError != nil && *block.IsError,
})
return events, true, err
default:
return nil, false, nil
}
case "content_block_delta":
pending := b.pending[index]
if pending == nil {
return nil, false, nil
}
if response.Delta != nil && response.Delta.Type == "input_json_delta" && response.Delta.PartialJson != nil {
pending.input.WriteString(*response.Delta.PartialJson)
}
return nil, true, nil
case "content_block_stop":
pending := b.pending[index]
if pending == nil {
return nil, false, nil
}
delete(b.pending, index)
action := []byte(pending.input.String())
if len(action) == 0 {
action = pending.startInput
}
if len(action) == 0 {
action = []byte("{}")
}
outputType, err := claudeHostedCallOutputType(pending.blockType, pending.name)
if err != nil {
return nil, true, err
}
events, err := state.StartHostedTool(oaichat.HostedToolStreamStart{
Type: outputType,
ID: pending.id,
Name: pending.name,
Action: action,
Caller: pending.caller,
ServerLabel: pending.serverName,
})
return events, true, err
default:
return nil, false, nil
}
}
func claudeHostedCallOutputType(blockType string, name string) (string, error) {
if blockType == "mcp_tool_use" {
return "mcp_call", nil
}
switch strings.TrimSpace(name) {
case "web_search":
return "web_search_call", nil
case "code_execution":
return "", fmt.Errorf("Claude code_execution has no valid OpenAI Responses mapping without a container_id")
case "web_fetch":
return "", fmt.Errorf("Claude web_fetch has no valid OpenAI Responses hosted-tool mapping")
default:
return "", fmt.Errorf("unknown Claude server tool %q cannot be represented as an OpenAI Responses hosted tool", name)
}
}
func claudeHostedResultOutputType(blockType string) (string, error) {
switch blockType {
case "web_search_tool_result":
return "web_search_call", nil
case "mcp_tool_result":
return "mcp_call", nil
case "code_execution_tool_result":
return "", fmt.Errorf("Claude code_execution result has no valid OpenAI Responses mapping without a container_id")
case "web_fetch_tool_result":
return "", fmt.Errorf("Claude web_fetch result has no valid OpenAI Responses hosted-tool mapping")
default:
return "", fmt.Errorf("unknown Claude hosted-tool result %q", blockType)
}
}
func claudeHostedResultErrorCode(content any, fallback string) string {
if strings.TrimSpace(fallback) != "" {
return strings.TrimSpace(fallback)
}
value, ok := content.(map[string]any)
if !ok {
return ""
}
contentType := strings.TrimSpace(kitutil.Interface2String(value["type"]))
if !strings.HasSuffix(contentType, "_error") {
return ""
}
return strings.TrimSpace(kitutil.Interface2String(value["error_code"]))
}
@@ -0,0 +1,326 @@
package claudemessages
import (
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
func ClaudeMessagesRequestToOpenAIResponses(claudeRequest dto.ClaudeRequest, info convmeta.Meta) (*dto.OpenAIResponsesRequest, error) {
if strings.TrimSpace(claudeRequest.Model) == "" {
return nil, errors.New("model is required")
}
input, err := claudeMessagesToResponsesInput(claudeRequest.Messages)
if err != nil {
return nil, err
}
instructions, err := claudeSystemToResponsesInstructions(&claudeRequest)
if err != nil {
return nil, err
}
tools, err := claudeToolsToResponsesTools(claudeRequest.Tools)
if err != nil {
return nil, err
}
toolChoice, parallelToolCalls, err := claudeToolChoiceToResponses(claudeRequest.ToolChoice)
if err != nil {
return nil, err
}
// Claude context_management is an object containing protocol-specific edit
// strategies. Responses expects an array of compaction entries, so copying
// the raw Claude value would produce an invalid upstream request.
responsesRequest := &dto.OpenAIResponsesRequest{
Model: claudeRequest.Model,
Input: input,
Instructions: instructions,
Metadata: append(json.RawMessage(nil), claudeRequest.Metadata...),
ServiceTier: claudeRequest.ServiceTier,
Stream: claudeRequest.Stream,
Temperature: claudeRequest.Temperature,
Tools: tools,
ToolChoice: toolChoice,
ParallelToolCalls: parallelToolCalls,
TopP: claudeRequest.TopP,
}
if info != nil && !convmeta.OptionsOf(info).OpenRouterDialect {
// Keep the outgoing -thinking suffix so a cascaded downstream new-api
// can recover reasoning intent from the model name. This is an
// emission-side policy, not converter-side suffix parsing.
thinkingSuffix := "-thinking"
if strings.HasSuffix(info.GetOriginModelName(), thinkingSuffix) && !strings.HasSuffix(responsesRequest.Model, thinkingSuffix) {
responsesRequest.Model += thinkingSuffix
}
}
if claudeRequest.MaxTokens != nil {
maxOutputTokens := *claudeRequest.MaxTokens
responsesRequest.MaxOutputTokens = &maxOutputTokens
} else if claudeRequest.MaxTokensToSample != nil {
maxOutputTokens := *claudeRequest.MaxTokensToSample
responsesRequest.MaxOutputTokens = &maxOutputTokens
}
reasoningIntent, effectiveEffort, err := claudeRequestReasoningIntent(&claudeRequest, info)
if err != nil {
return nil, reasoning.AsClientError(err)
}
if err := reasoning.ApplyToOpenAIResponses(responsesRequest, reasoningIntent); err != nil {
return nil, reasoning.AsClientError(err)
}
if info != nil && effectiveEffort != "" {
info.SetReasoningEffort(string(effectiveEffort))
}
return responsesRequest, nil
}
func claudeRequestReasoningIntent(claudeRequest *dto.ClaudeRequest, info convmeta.Meta) (reasoning.Intent, reasoning.Effort, error) {
reasoningIntent, err := reasoning.FromClaude(claudeRequest)
if err != nil {
return reasoning.Intent{}, "", err
}
sourceModel := claudeRequest.Model
if info != nil && info.GetOriginModelName() != "" {
sourceModel = info.GetOriginModelName()
}
if suffix := reasoning.IntentFromState(convmeta.ReasoningStateOf(info)); !suffix.IsEmpty() {
reasoningIntent, err = reasoning.MergeExplicitAndSuffix(reasoningIntent, suffix, sourceModel)
if err != nil {
return reasoning.Intent{}, "", err
}
}
reasoningIntent = reasoning.ResolveClaudeDefault(sourceModel, reasoningIntent)
return reasoningIntent, reasoning.EffectiveEffort(reasoningIntent), nil
}
func claudeSystemToResponsesInstructions(request *dto.ClaudeRequest) (json.RawMessage, error) {
if request == nil || request.System == nil {
return nil, nil
}
if request.IsStringSystem() {
return kitutil.Marshal(request.GetStringSystem())
}
var instructions strings.Builder
systemBlocks, err := kitutil.Any2Type[[]dto.ClaudeMediaMessage](request.System)
if err != nil {
return nil, fmt.Errorf("invalid Claude system content: %w", err)
}
for _, block := range systemBlocks {
if block.Type == "text" || block.Type == "input_text" || block.Type == "" {
instructions.WriteString(block.GetText())
}
}
if instructions.Len() == 0 {
return nil, nil
}
return kitutil.Marshal(instructions.String())
}
func claudeMessagesToResponsesInput(messages []dto.ClaudeMessage) (json.RawMessage, error) {
input := make([]map[string]any, 0, len(messages))
for messageIndex := range messages {
message := messages[messageIndex]
role := strings.TrimSpace(message.Role)
if role == "" {
continue
}
if message.IsStringContent() {
input = append(input, map[string]any{
"role": role,
"content": message.GetStringContent(),
})
continue
}
blocks, err := message.ParseContent()
if err != nil {
return nil, fmt.Errorf("messages[%d].content: %w", messageIndex, err)
}
contentParts := make([]map[string]any, 0, len(blocks))
flushContent := func() {
if len(contentParts) == 0 {
return
}
input = append(input, map[string]any{
"role": role,
"content": contentParts,
})
contentParts = nil
}
for blockIndex := range blocks {
block := blocks[blockIndex]
switch block.Type {
case "text", "input_text":
partType := "input_text"
if role == "assistant" {
partType = "output_text"
}
contentParts = append(contentParts, map[string]any{
"type": partType,
"text": block.GetText(),
})
case "image":
if source := claudeSourceURL(block.Source); source != "" {
contentParts = append(contentParts, map[string]any{
"type": "input_image",
"image_url": source,
})
}
case "document":
if source := claudeSourceURL(block.Source); source != "" {
contentParts = append(contentParts, map[string]any{
"type": "input_file",
"file_data": source,
})
}
case "tool_use":
flushContent()
arguments, err := kitutil.Marshal(block.Input)
if err != nil {
return nil, fmt.Errorf("messages[%d].content[%d].input: %w", messageIndex, blockIndex, err)
}
if block.Input == nil {
arguments = []byte("{}")
}
input = append(input, map[string]any{
"type": "function_call",
"call_id": block.Id,
"name": block.Name,
"arguments": string(arguments),
})
case "tool_result":
flushContent()
output, err := claudeToolResultToResponsesOutput(block.Content)
if err != nil {
return nil, fmt.Errorf("messages[%d].content[%d].content: %w", messageIndex, blockIndex, err)
}
input = append(input, map[string]any{
"type": "function_call_output",
"call_id": block.ToolUseId,
"output": output,
})
}
}
flushContent()
}
return kitutil.Marshal(input)
}
func claudeToolsToResponsesTools(value any) (json.RawMessage, error) {
if value == nil {
return nil, nil
}
tools, err := kitutil.Any2Type[[]dto.Tool](value)
if err != nil {
return nil, fmt.Errorf("invalid Claude tools: %w", err)
}
converted := make([]map[string]any, 0, len(tools))
for _, tool := range tools {
function := map[string]any{
"type": "function",
"name": tool.Name,
"description": tool.Description,
"parameters": tool.InputSchema,
}
if tool.Strict != nil {
function["strict"] = *tool.Strict
}
converted = append(converted, function)
}
return kitutil.Marshal(converted)
}
func claudeToolChoiceToResponses(value any) (json.RawMessage, json.RawMessage, error) {
if value == nil {
return nil, nil, nil
}
choice, err := kitutil.Any2Type[dto.ClaudeToolChoice](value)
if err != nil {
return nil, nil, fmt.Errorf("invalid Claude tool_choice: %w", err)
}
var converted any
switch choice.Type {
case "", "auto":
converted = "auto"
case "any":
converted = "required"
case "none":
converted = "none"
case "tool":
converted = map[string]any{"type": "function", "name": choice.Name}
default:
return nil, nil, fmt.Errorf("unsupported Claude tool_choice type %q", choice.Type)
}
toolChoice, err := kitutil.Marshal(converted)
if err != nil {
return nil, nil, err
}
var parallelToolCalls json.RawMessage
if choice.DisableParallelToolUse && choice.Type != "none" {
parallelToolCalls, err = kitutil.Marshal(false)
if err != nil {
return nil, nil, err
}
}
return toolChoice, parallelToolCalls, nil
}
func claudeToolResultToResponsesOutput(content any) (any, error) {
if content == nil {
return "", nil
}
if text, ok := content.(string); ok {
return text, nil
}
blocks, err := kitutil.Any2Type[[]dto.ClaudeMediaMessage](content)
if err != nil {
return content, nil
}
parts := make([]map[string]any, 0, len(blocks))
for _, block := range blocks {
switch block.Type {
case "text", "input_text":
parts = append(parts, map[string]any{"type": "input_text", "text": block.GetText()})
case "image":
if source := claudeSourceURL(block.Source); source != "" {
parts = append(parts, map[string]any{"type": "input_image", "image_url": source})
}
case "document":
if source := claudeSourceURL(block.Source); source != "" {
parts = append(parts, map[string]any{"type": "input_file", "file_data": source})
}
}
}
if len(parts) == 0 {
return content, nil
}
return parts, nil
}
func claudeSourceURL(source *dto.ClaudeMessageSource) string {
if source == nil {
return ""
}
if strings.TrimSpace(source.Url) != "" {
return source.Url
}
data := kitutil.Interface2String(source.Data)
if data == "" {
return ""
}
if strings.HasPrefix(data, "data:") {
return data
}
return fmt.Sprintf("data:%s;base64,%s", source.MediaType, data)
}
@@ -0,0 +1,56 @@
package convdiag
import (
"context"
"reflect"
"github.com/QuantumNous/new-api/relaykit/types"
)
type collectorKey struct{}
type Collector struct {
diagnostics []types.ConversionDiagnostic
}
func WithCollector(ctx context.Context) (context.Context, *Collector) {
if isNilContext(ctx) {
ctx = context.Background()
}
if collector, _ := ctx.Value(collectorKey{}).(*Collector); collector != nil {
return ctx, collector
}
collector := &Collector{}
return context.WithValue(ctx, collectorKey{}, collector), collector
}
func Add(ctx context.Context, diagnostics ...types.ConversionDiagnostic) {
if isNilContext(ctx) || len(diagnostics) == 0 {
return
}
collector, _ := ctx.Value(collectorKey{}).(*Collector)
if collector == nil {
return
}
collector.diagnostics = append(collector.diagnostics, diagnostics...)
}
func isNilContext(ctx context.Context) bool {
if ctx == nil {
return true
}
value := reflect.ValueOf(ctx)
switch value.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice:
return value.IsNil()
default:
return false
}
}
func (c *Collector) Diagnostics() []types.ConversionDiagnostic {
if c == nil || len(c.diagnostics) == 0 {
return nil
}
return append([]types.ConversionDiagnostic(nil), c.diagnostics...)
}
@@ -0,0 +1,367 @@
package geminichat
import (
"fmt"
"strings"
"unicode/utf8"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
type geminiGroundingChunk struct {
Web *geminiGroundingSource `json:"web,omitempty"`
RetrievedContext *geminiGroundingSource `json:"retrievedContext,omitempty"`
}
type geminiGroundingSource struct {
URI string `json:"uri,omitempty"`
Title string `json:"title,omitempty"`
}
type geminiGroundingSupport struct {
Segment struct {
PartIndex *int `json:"partIndex,omitempty"`
StartIndex int `json:"startIndex,omitempty"`
EndIndex int `json:"endIndex,omitempty"`
Text string `json:"text,omitempty"`
} `json:"segment"`
GroundingChunkIndices []int `json:"groundingChunkIndices"`
}
type renderedGeminiPart struct {
text string
startByte int
}
type streamedGeminiPartSpan struct {
partStartByte int
partEndByte int
renderedStartByte int
}
type streamedGeminiPart struct {
text strings.Builder
spans []streamedGeminiPartSpan
}
// geminiGroundingStreamCandidate retains the protocol state needed to resolve
// grounding metadata emitted after the text it describes. Gemini's streaming
// contract makes grounding chunk indexes cumulative across response chunks and
// keeps segment offsets relative to the accumulated candidate part.
type geminiGroundingStreamCandidate struct {
rendered strings.Builder
parts map[int]*streamedGeminiPart
chunks []geminiGroundingChunk
}
// GroundingWebSearchQueries returns the distinct hosted-search queries that
// Gemini reports for a response. The provider may repeat metadata across
// candidates or stream chunks, so callers can safely accumulate this result
// without manufacturing duplicate Responses tool calls.
func GroundingWebSearchQueries(response *dto.GeminiChatResponse) []string {
if response == nil {
return nil
}
queries := make([]string, 0)
seen := make(map[string]struct{})
for candidateIndex := range response.Candidates {
metadata := response.Candidates[candidateIndex].GroundingMetadata
if metadata == nil {
continue
}
for _, query := range metadata.WebSearchQueries {
query = strings.TrimSpace(query)
if query == "" {
continue
}
if _, exists := seen[query]; exists {
continue
}
seen[query] = struct{}{}
queries = append(queries, query)
}
}
return queries
}
func groundingAnnotationsToChat(metadata *dto.GeminiGroundingMetadata, content dto.GeminiChatContent, rendered string) []byte {
if metadata == nil || len(metadata.GroundingChunks) == 0 || len(metadata.GroundingSupports) == 0 {
return nil
}
var chunks []geminiGroundingChunk
if err := kitutil.Unmarshal(metadata.GroundingChunks, &chunks); err != nil {
return nil
}
var supports []geminiGroundingSupport
if err := kitutil.Unmarshal(metadata.GroundingSupports, &supports); err != nil {
return nil
}
parts := locateRenderedGeminiParts(content, rendered)
textPartCount := 0
soleTextPart := -1
for index := range parts {
if parts[index].startByte < 0 {
continue
}
textPartCount++
soleTextPart = index
}
annotations := make([]any, 0)
seen := make(map[string]struct{})
for _, support := range supports {
partIndex := soleTextPart
if support.Segment.PartIndex != nil {
partIndex = *support.Segment.PartIndex
} else if textPartCount != 1 {
continue
}
if partIndex < 0 || partIndex >= len(parts) || parts[partIndex].startByte < 0 {
continue
}
part := parts[partIndex]
start, end, ok := groundingRuneRange(rendered, part, support.Segment.StartIndex, support.Segment.EndIndex)
if !ok {
continue
}
if support.Segment.Text != "" && part.text[support.Segment.StartIndex:support.Segment.EndIndex] != support.Segment.Text {
continue
}
annotations = appendGroundingAnnotations(annotations, chunks, support, start, end, "", seen)
}
return marshalGroundingAnnotations(annotations)
}
func newGeminiGroundingStreamCandidate() *geminiGroundingStreamCandidate {
return &geminiGroundingStreamCandidate{parts: make(map[int]*streamedGeminiPart)}
}
func (s *geminiGroundingStreamCandidate) appendContent(content dto.GeminiChatContent, rendered string) {
if s == nil {
return
}
if s.parts == nil {
s.parts = make(map[int]*streamedGeminiPart)
}
renderedParts := locateRenderedGeminiParts(content, rendered)
renderedBase := s.rendered.Len()
for index := range content.Parts {
partContent := content.Parts[index]
text := partContent.Text
if text == "" || partContent.Thought {
continue
}
part := s.parts[index]
if part == nil {
part = &streamedGeminiPart{}
s.parts[index] = part
}
partStart := part.text.Len()
part.text.WriteString(text)
// A standalone newline is intentionally omitted by the existing Gemini
// renderer. Keep it in the source part so later byte offsets stay correct,
// but do not claim that it has a corresponding rendered span.
if text == "\n" || index >= len(renderedParts) || renderedParts[index].startByte < 0 {
continue
}
renderedStart := renderedBase + renderedParts[index].startByte
part.spans = append(part.spans, streamedGeminiPartSpan{
partStartByte: partStart,
partEndByte: partStart + len(text),
renderedStartByte: renderedStart,
})
}
s.rendered.WriteString(rendered)
}
func (s *geminiGroundingStreamCandidate) appendGroundingChunks(metadata *dto.GeminiGroundingMetadata) {
if s == nil || metadata == nil || len(metadata.GroundingChunks) == 0 {
return
}
var chunks []geminiGroundingChunk
if err := kitutil.Unmarshal(metadata.GroundingChunks, &chunks); err != nil {
return
}
s.chunks = append(s.chunks, chunks...)
}
func (s *geminiGroundingStreamCandidate) groundingAnnotations(
metadata *dto.GeminiGroundingMetadata,
candidateIndex int64,
seen map[string]struct{},
) []byte {
if s == nil || metadata == nil {
return nil
}
s.appendGroundingChunks(metadata)
if len(s.chunks) == 0 || len(metadata.GroundingSupports) == 0 {
return nil
}
var supports []geminiGroundingSupport
if err := kitutil.Unmarshal(metadata.GroundingSupports, &supports); err != nil {
return nil
}
annotations := make([]any, 0)
keyPrefix := fmt.Sprintf("%d:", candidateIndex)
for _, support := range supports {
partIndex, ok := s.groundingPartIndex(support)
if !ok {
continue
}
start, end, ok := s.groundingRuneRange(partIndex, support.Segment.StartIndex, support.Segment.EndIndex)
if !ok {
continue
}
part := s.parts[partIndex]
if support.Segment.Text != "" && part.text.String()[support.Segment.StartIndex:support.Segment.EndIndex] != support.Segment.Text {
continue
}
annotations = appendGroundingAnnotations(annotations, s.chunks, support, start, end, keyPrefix, seen)
}
return marshalGroundingAnnotations(annotations)
}
func (s *geminiGroundingStreamCandidate) groundingPartIndex(support geminiGroundingSupport) (int, bool) {
if support.Segment.PartIndex != nil {
partIndex := *support.Segment.PartIndex
part := s.parts[partIndex]
return partIndex, part != nil && len(part.spans) > 0
}
solePartIndex := -1
for partIndex, part := range s.parts {
if part == nil || len(part.spans) == 0 {
continue
}
if solePartIndex >= 0 {
return 0, false
}
solePartIndex = partIndex
}
return solePartIndex, solePartIndex >= 0
}
func (s *geminiGroundingStreamCandidate) groundingRuneRange(partIndex int, startByte int, endByte int) (int, int, bool) {
if s == nil {
return 0, 0, false
}
part := s.parts[partIndex]
if part == nil {
return 0, 0, false
}
partText := part.text.String()
if startByte < 0 || endByte <= startByte || endByte > len(partText) {
return 0, 0, false
}
if !utf8.ValidString(partText[:startByte]) || !utf8.ValidString(partText[:endByte]) {
return 0, 0, false
}
renderedStart, renderedEnd := -1, -1
for _, span := range part.spans {
if renderedStart < 0 && startByte >= span.partStartByte && startByte < span.partEndByte {
renderedStart = span.renderedStartByte + startByte - span.partStartByte
}
if endByte > span.partStartByte && endByte <= span.partEndByte {
renderedEnd = span.renderedStartByte + endByte - span.partStartByte
}
}
if renderedStart < 0 || renderedEnd <= renderedStart {
return 0, 0, false
}
rendered := s.rendered.String()
if renderedEnd > len(rendered) || rendered[renderedStart:renderedEnd] != partText[startByte:endByte] {
return 0, 0, false
}
if !utf8.ValidString(rendered[:renderedStart]) || !utf8.ValidString(rendered[:renderedEnd]) {
return 0, 0, false
}
return utf8.RuneCountInString(rendered[:renderedStart]), utf8.RuneCountInString(rendered[:renderedEnd]), true
}
func appendGroundingAnnotations(
annotations []any,
chunks []geminiGroundingChunk,
support geminiGroundingSupport,
start int,
end int,
keyPrefix string,
seen map[string]struct{},
) []any {
for _, chunkIndex := range support.GroundingChunkIndices {
if chunkIndex < 0 || chunkIndex >= len(chunks) {
continue
}
source := chunks[chunkIndex].Web
if source == nil {
source = chunks[chunkIndex].RetrievedContext
}
if source == nil || source.URI == "" {
continue
}
key := fmt.Sprintf("%s%d:%d:%s", keyPrefix, start, end, source.URI)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
annotations = append(annotations, map[string]any{
"type": "url_citation",
"url_citation": map[string]any{
"start_index": start,
"end_index": end,
"url": source.URI,
"title": source.Title,
},
})
}
return annotations
}
func marshalGroundingAnnotations(annotations []any) []byte {
if len(annotations) == 0 {
return nil
}
encoded, err := kitutil.Marshal(annotations)
if err != nil {
return nil
}
return encoded
}
func locateRenderedGeminiParts(content dto.GeminiChatContent, rendered string) []renderedGeminiPart {
parts := make([]renderedGeminiPart, len(content.Parts))
cursor := 0
for index := range content.Parts {
part := content.Parts[index]
text := part.Text
parts[index] = renderedGeminiPart{text: text, startByte: -1}
if text == "" || part.Thought || cursor > len(rendered) {
continue
}
relative := strings.Index(rendered[cursor:], text)
if relative < 0 {
continue
}
start := cursor + relative
parts[index].startByte = start
cursor = start + len(text)
}
return parts
}
func groundingRuneRange(rendered string, part renderedGeminiPart, startByte int, endByte int) (int, int, bool) {
if startByte < 0 || endByte <= startByte || endByte > len(part.text) {
return 0, 0, false
}
if !utf8.ValidString(part.text[:startByte]) || !utf8.ValidString(part.text[:endByte]) {
return 0, 0, false
}
partStartRunes := utf8.RuneCountInString(rendered[:part.startByte])
start := partStartRunes + utf8.RuneCountInString(part.text[:startByte])
end := partStartRunes + utf8.RuneCountInString(part.text[:endByte])
return start, end, true
}
@@ -0,0 +1,300 @@
package geminichat
import (
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/jsonutil"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
func GeminiGenerateContentRequestToOpenAIChat(geminiRequest *dto.GeminiChatRequest, info convmeta.Meta) (*dto.GeneralOpenAIRequest, error) {
modelName := ""
isStream := false
if info != nil {
isStream = info.GetIsStream()
}
modelName = convmeta.UpstreamModelName(info)
openaiRequest := &dto.GeneralOpenAIRequest{
Model: modelName,
Stream: kitutil.GetPointer(isStream),
}
reasoningIntent, err := reasoning.FromGemini(geminiRequest)
if err != nil {
return nil, reasoning.AsClientError(err)
}
sourceModelName := modelName
if info != nil && info.GetOriginModelName() != "" {
sourceModelName = info.GetOriginModelName()
}
baseSourceModel := sourceModelName
opts := convmeta.OptionsOf(info)
preserveSuffix := opts.ShouldPreserveThinkingSuffix(sourceModelName)
if !preserveSuffix {
if suffix := reasoning.IntentFromState(convmeta.ReasoningStateOf(info)); !suffix.IsEmpty() {
reasoningIntent, err = reasoning.MergeExplicitAndSuffix(reasoningIntent, suffix, sourceModelName)
if err != nil {
return nil, reasoning.AsClientError(err)
}
}
}
if baseSourceModel != "" && geminiRequest.GenerationConfig.ThinkingConfig != nil {
_, err = reasoning.ValidateGeminiThinkingConfig(baseSourceModel, geminiRequest.GenerationConfig.ThinkingConfig)
if err != nil {
return nil, reasoning.AsClientError(err)
}
}
reasoningIntent = reasoning.ResolveGeminiDefault(baseSourceModel, reasoningIntent)
effectiveEffort := reasoning.EffectiveEffort(reasoningIntent)
if err := reasoning.ApplyToOpenAIChat(openaiRequest, reasoningIntent); err != nil {
return nil, reasoning.AsClientError(err)
}
if effectiveEffort != "" && info != nil {
info.SetReasoningEffort(string(effectiveEffort))
}
callHistory := newGeminiFunctionCallHistory(geminiRequest.Contents)
var messages []dto.Message
for _, content := range geminiRequest.Contents {
message := dto.Message{
Role: convertGeminiRoleToOpenAI(content.Role),
}
var mediaContents []dto.MediaContent
var toolCalls []dto.ToolCallRequest
var reasoningTexts []string
for _, part := range content.Parts {
if part.Text != "" {
if part.Thought {
reasoningTexts = append(reasoningTexts, part.Text)
continue
}
mediaContent := dto.MediaContent{
Type: "text",
Text: part.Text,
}
mediaContents = append(mediaContents, mediaContent)
} else if part.InlineData != nil {
mediaContent := dto.MediaContent{
Type: "image_url",
ImageUrl: &dto.MessageImageUrl{
Url: fmt.Sprintf("data:%s;base64,%s", part.InlineData.MimeType, part.InlineData.Data),
Detail: "auto",
MimeType: part.InlineData.MimeType,
},
}
mediaContents = append(mediaContents, mediaContent)
} else if part.FileData != nil {
mediaContent := dto.MediaContent{
Type: "image_url",
ImageUrl: &dto.MessageImageUrl{
Url: part.FileData.FileUri,
Detail: "auto",
MimeType: part.FileData.MimeType,
},
}
mediaContents = append(mediaContents, mediaContent)
} else if part.FunctionCall != nil {
toolCall := dto.ToolCallRequest{
ID: callHistory.add(part.FunctionCall),
Type: "function",
Function: dto.FunctionRequest{
Name: part.FunctionCall.FunctionName,
Arguments: jsonutil.ToJSONString(part.FunctionCall.Arguments),
},
}
toolCalls = append(toolCalls, toolCall)
} else if part.FunctionResponse != nil {
toolMessage := dto.Message{
Role: "tool",
ToolCallId: callHistory.match(part.FunctionResponse),
}
toolMessage.SetStringContent(jsonutil.ToJSONString(part.FunctionResponse.Response))
messages = append(messages, toolMessage)
}
}
if len(toolCalls) > 0 {
message.SetToolCalls(toolCalls)
} else if len(mediaContents) == 1 && mediaContents[0].Type == "text" {
message.Content = mediaContents[0].Text
} else if len(mediaContents) > 0 {
message.SetMediaContent(mediaContents)
}
if len(reasoningTexts) > 0 {
reasoningContent := strings.Join(reasoningTexts, "\n")
message.ReasoningContent = &reasoningContent
}
if len(message.ParseContent()) > 0 || len(message.ToolCalls) > 0 || message.ReasoningContent != nil {
messages = append(messages, message)
}
}
openaiRequest.Messages = messages
if geminiRequest.GenerationConfig.Temperature != nil {
openaiRequest.Temperature = geminiRequest.GenerationConfig.Temperature
}
if geminiRequest.GenerationConfig.TopP != nil {
openaiRequest.TopP = kitutil.GetPointer(*geminiRequest.GenerationConfig.TopP)
}
if geminiRequest.GenerationConfig.TopK != nil {
openaiRequest.TopK = kitutil.GetPointer(int(*geminiRequest.GenerationConfig.TopK))
}
if geminiRequest.GenerationConfig.MaxOutputTokens != nil {
openaiRequest.MaxTokens = kitutil.GetPointer(*geminiRequest.GenerationConfig.MaxOutputTokens)
}
if len(geminiRequest.GenerationConfig.StopSequences) > 0 {
openaiRequest.Stop = geminiRequest.GenerationConfig.StopSequences[:min(len(geminiRequest.GenerationConfig.StopSequences), 4)]
}
if geminiRequest.GenerationConfig.CandidateCount != nil {
openaiRequest.N = kitutil.GetPointer(*geminiRequest.GenerationConfig.CandidateCount)
}
if len(geminiRequest.GetTools()) > 0 {
var tools []dto.ToolCallRequest
for _, tool := range geminiRequest.GetTools() {
if tool.FunctionDeclarations == nil {
continue
}
functionDeclarations, err := kitutil.Any2Type[[]dto.FunctionRequest](tool.FunctionDeclarations)
if err != nil {
kitutil.LogSystemError(fmt.Sprintf("failed to parse gemini function declarations: %v (type=%T)", err, tool.FunctionDeclarations))
continue
}
for _, function := range functionDeclarations {
openAITool := dto.ToolCallRequest{
Type: "function",
Function: dto.FunctionRequest{
Name: function.Name,
Description: function.Description,
Parameters: function.Parameters,
},
}
tools = append(tools, openAITool)
}
}
if len(tools) > 0 {
openaiRequest.Tools = tools
}
}
if geminiRequest.SystemInstructions != nil {
systemMessage := dto.Message{
Role: "system",
Content: extractTextFromGeminiParts(geminiRequest.SystemInstructions.Parts),
}
openaiRequest.Messages = append([]dto.Message{systemMessage}, openaiRequest.Messages...)
}
return openaiRequest, nil
}
type geminiPendingFunctionCall struct {
id string
name string
}
// geminiFunctionCallHistory keeps legacy Gemini histories without call IDs
// correlated across content boundaries. Named matching permits results for
// different parallel functions to arrive out of order; same-name calls use
// their original call order because old payloads contain no stronger identity.
type geminiFunctionCallHistory struct {
reservedIDs map[string]struct{}
pending []geminiPendingFunctionCall
nextID int
}
func newGeminiFunctionCallHistory(contents []dto.GeminiChatContent) *geminiFunctionCallHistory {
history := &geminiFunctionCallHistory{
reservedIDs: make(map[string]struct{}),
nextID: 1,
}
for _, content := range contents {
for _, part := range content.Parts {
if part.FunctionCall != nil && part.FunctionCall.ID != "" {
history.reservedIDs[part.FunctionCall.ID] = struct{}{}
}
if part.FunctionResponse != nil {
if id := kitutil.JsonRawMessageToString(part.FunctionResponse.ID); id != "" {
history.reservedIDs[id] = struct{}{}
}
}
}
}
return history
}
func (h *geminiFunctionCallHistory) add(call *dto.FunctionCall) string {
id := call.ID
if id == "" {
id = h.newFallbackID()
}
h.pending = append(h.pending, geminiPendingFunctionCall{id: id, name: call.FunctionName})
return id
}
func (h *geminiFunctionCallHistory) match(response *dto.GeminiFunctionResponse) string {
if id := kitutil.JsonRawMessageToString(response.ID); id != "" {
h.removePendingByID(id)
return id
}
for i, call := range h.pending {
if response.Name != "" && call.name != response.Name {
continue
}
h.pending = append(h.pending[:i], h.pending[i+1:]...)
return call.id
}
return h.newFallbackID()
}
func (h *geminiFunctionCallHistory) removePendingByID(id string) {
for i, call := range h.pending {
if call.id != id {
continue
}
h.pending = append(h.pending[:i], h.pending[i+1:]...)
return
}
}
func (h *geminiFunctionCallHistory) newFallbackID() string {
for {
id := fmt.Sprintf("call_%d", h.nextID)
h.nextID++
if _, exists := h.reservedIDs[id]; exists {
continue
}
h.reservedIDs[id] = struct{}{}
return id
}
}
func convertGeminiRoleToOpenAI(geminiRole string) string {
switch geminiRole {
case "user":
return "user"
case "model":
return "assistant"
case "function":
return "function"
default:
return "user"
}
}
func extractTextFromGeminiParts(parts []dto.GeminiPart) string {
texts := make([]string, 0)
for _, part := range parts {
if part.Text != "" {
texts = append(texts, part.Text)
}
}
return strings.Join(texts, "\n")
}
@@ -0,0 +1,739 @@
package geminichat
import (
"fmt"
"sort"
"strconv"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
func UsageFromGeminiMetadata(metadata *dto.GeminiUsageMetadata, fallbackPromptTokens int) *dto.Usage {
if metadata == nil {
if fallbackPromptTokens <= 0 {
return nil
}
usage := &dto.Usage{PromptTokens: fallbackPromptTokens}
usage.PromptTokensDetails.TextTokens = fallbackPromptTokens
return usage
}
promptTokens := metadata.PromptTokenCount + metadata.ToolUsePromptTokenCount
if promptTokens <= 0 && fallbackPromptTokens > 0 {
promptTokens = fallbackPromptTokens
}
usage := &dto.Usage{
PromptTokens: promptTokens,
CompletionTokens: metadata.CandidatesTokenCount + metadata.ThoughtsTokenCount,
TotalTokens: metadata.TotalTokenCount,
BillingUsage: dto.CloneBillingUsage(metadata.BillingUsage),
}
if usage.BillingUsage == nil {
usage.BillingUsage = dto.NewGeminiChatBillingUsage(metadata)
}
usage.CompletionTokenDetails.ReasoningTokens = metadata.ThoughtsTokenCount
usage.PromptTokensDetails.CachedTokens = metadata.CachedContentTokenCount
for _, detail := range metadata.PromptTokensDetails {
if detail.Modality == "AUDIO" {
usage.PromptTokensDetails.AudioTokens += detail.TokenCount
} else if detail.Modality == "IMAGE" {
usage.PromptTokensDetails.ImageTokens += detail.TokenCount
} else if detail.Modality == "TEXT" {
usage.PromptTokensDetails.TextTokens += detail.TokenCount
}
}
for _, detail := range metadata.ToolUsePromptTokensDetails {
if detail.Modality == "AUDIO" {
usage.PromptTokensDetails.AudioTokens += detail.TokenCount
} else if detail.Modality == "IMAGE" {
usage.PromptTokensDetails.ImageTokens += detail.TokenCount
} else if detail.Modality == "TEXT" {
usage.PromptTokensDetails.TextTokens += detail.TokenCount
}
}
for _, detail := range metadata.CandidatesTokensDetails {
switch detail.Modality {
case "IMAGE":
usage.CompletionTokenDetails.ImageTokens += detail.TokenCount
case "AUDIO":
usage.CompletionTokenDetails.AudioTokens += detail.TokenCount
case "TEXT":
usage.CompletionTokenDetails.TextTokens += detail.TokenCount
}
}
if usage.TotalTokens > 0 && usage.CompletionTokens <= 0 {
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
}
if usage.PromptTokens > 0 && usage.PromptTokensDetails.TextTokens == 0 && usage.PromptTokensDetails.AudioTokens == 0 {
usage.PromptTokensDetails.TextTokens = usage.PromptTokens
}
return usage
}
func ResponseGeminiChat2OpenAI(id string, created int64, response *dto.GeminiChatResponse) *dto.OpenAITextResponse {
fullTextResponse := dto.OpenAITextResponse{
Id: id,
Object: "chat.completion",
Created: created,
Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)),
}
isToolCall := false
for _, candidate := range response.Candidates {
choice := dto.OpenAITextResponseChoice{
Index: int(candidate.Index),
Message: dto.Message{
Role: "assistant",
Content: "",
},
FinishReason: types.FinishReasonStop,
}
if len(candidate.Content.Parts) > 0 {
var content strings.Builder
var inlineGrow int
for _, part := range candidate.Content.Parts {
if part.InlineData != nil {
inlineGrow += len(part.InlineData.MimeType) + len(part.InlineData.Data) + 32
}
}
if inlineGrow > 0 {
content.Grow(inlineGrow)
}
appended := 0
writeSep := func() {
if appended > 0 {
content.WriteByte('\n')
}
appended++
}
var toolCalls []dto.ToolCallResponse
for _, part := range candidate.Content.Parts {
if part.InlineData != nil {
if strings.HasPrefix(part.InlineData.MimeType, "image") {
writeSep()
content.WriteString("![image](data:")
content.WriteString(part.InlineData.MimeType)
content.WriteString(";base64,")
content.WriteString(part.InlineData.Data)
content.WriteByte(')')
} else {
writeSep()
content.WriteString("[media](data:")
content.WriteString(part.InlineData.MimeType)
content.WriteString(";base64,")
content.WriteString(part.InlineData.Data)
content.WriteByte(')')
}
} else if part.FunctionCall != nil {
choice.FinishReason = types.FinishReasonToolCalls
if call := geminiResponseToolCall(&part); call != nil {
toolCalls = append(toolCalls, *call)
}
} else if part.Thought {
choice.Message.ReasoningContent = &part.Text
} else {
if part.ExecutableCode != nil {
writeSep()
content.WriteString("```")
content.WriteString(part.ExecutableCode.Language)
content.WriteByte('\n')
content.WriteString(part.ExecutableCode.Code)
content.WriteString("\n```")
} else if part.CodeExecutionResult != nil {
writeSep()
content.WriteString("```output\n")
content.WriteString(part.CodeExecutionResult.Output)
content.WriteString("\n```")
} else if part.Text != "\n" {
writeSep()
content.WriteString(part.Text)
}
}
}
if len(toolCalls) > 0 {
choice.Message.SetToolCalls(toolCalls)
isToolCall = true
}
choice.Message.SetStringContent(content.String())
}
if candidate.FinishReason != nil {
switch *candidate.FinishReason {
case "STOP":
choice.FinishReason = types.FinishReasonStop
case "MAX_TOKENS":
choice.FinishReason = types.FinishReasonLength
case "SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII", "OTHER":
choice.FinishReason = types.FinishReasonContentFilter
default:
choice.FinishReason = types.FinishReasonContentFilter
}
}
if isToolCall {
choice.FinishReason = types.FinishReasonToolCalls
}
choice.Message.Annotations = groundingAnnotationsToChat(candidate.GroundingMetadata, candidate.Content, choice.Message.StringContent())
fullTextResponse.Choices = append(fullTextResponse.Choices, choice)
}
return &fullTextResponse
}
func StreamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*dto.ChatCompletionsStreamResponse, bool) {
choices := make([]dto.ChatCompletionsStreamResponseChoice, 0, len(geminiResponse.Candidates))
isStop := false
for _, candidate := range geminiResponse.Candidates {
if candidate.FinishReason != nil && *candidate.FinishReason == "STOP" {
isStop = true
candidate.FinishReason = nil
}
choice := dto.ChatCompletionsStreamResponseChoice{
Index: int(candidate.Index),
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{},
}
var content strings.Builder
var inlineGrow int
for _, part := range candidate.Content.Parts {
if part.InlineData != nil {
inlineGrow += len(part.InlineData.MimeType) + len(part.InlineData.Data) + 32
}
}
if inlineGrow > 0 {
content.Grow(inlineGrow)
}
appended := 0
writeSep := func() {
if appended > 0 {
content.WriteByte('\n')
}
appended++
}
isTools := false
isThought := false
if candidate.FinishReason != nil {
switch *candidate.FinishReason {
case "STOP":
choice.FinishReason = &types.FinishReasonStop
case "MAX_TOKENS":
choice.FinishReason = &types.FinishReasonLength
case "SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII", "OTHER":
choice.FinishReason = &types.FinishReasonContentFilter
default:
choice.FinishReason = &types.FinishReasonContentFilter
}
}
for _, part := range candidate.Content.Parts {
if part.InlineData != nil {
if strings.HasPrefix(part.InlineData.MimeType, "image") {
writeSep()
content.WriteString("![image](data:")
content.WriteString(part.InlineData.MimeType)
content.WriteString(";base64,")
content.WriteString(part.InlineData.Data)
content.WriteByte(')')
}
} else if part.FunctionCall != nil {
isTools = true
if call := geminiResponseToolCall(&part); call != nil {
call.SetIndex(len(choice.Delta.ToolCalls))
choice.Delta.ToolCalls = append(choice.Delta.ToolCalls, *call)
}
} else if part.Thought {
isThought = true
writeSep()
content.WriteString(part.Text)
} else {
if part.ExecutableCode != nil {
writeSep()
content.WriteString("```")
content.WriteString(part.ExecutableCode.Language)
content.WriteByte('\n')
content.WriteString(part.ExecutableCode.Code)
content.WriteString("\n```\n")
} else if part.CodeExecutionResult != nil {
writeSep()
content.WriteString("```output\n")
content.WriteString(part.CodeExecutionResult.Output)
content.WriteString("\n```\n")
} else if part.Text != "\n" {
writeSep()
content.WriteString(part.Text)
}
}
}
if isThought {
choice.Delta.SetReasoningContent(content.String())
} else {
choice.Delta.SetContentString(content.String())
}
if isTools {
choice.FinishReason = &types.FinishReasonToolCalls
}
choice.Delta.Annotations = groundingAnnotationsToChat(candidate.GroundingMetadata, candidate.Content, content.String())
choices = append(choices, choice)
}
response := dto.ChatCompletionsStreamResponse{
Object: "chat.completion.chunk",
Choices: choices,
}
// Only attach usage the chunk actually reported. Do not fall back to a
// local prompt estimate — converters treat this as first-frame truth.
if metadata := geminiResponse.GetUsageMetadata(); dto.HasGeminiUsageMetadataTokens(metadata) {
response.Usage = UsageFromGeminiMetadata(metadata, 0)
}
return &response, isStop
}
type GeminiToChatStreamState struct {
id string
created int64
sawToolCall bool
finishEmitted bool
latestUsage *dto.Usage
// Gemini generateContent streams complete function calls. Keep their
// occurrence indexes monotonic because chunk-local indexes restart at zero.
nextToolIndexByCandidate map[int64]int
toolIndexByCandidateID map[int64]map[string]int
partialToolByCandidate map[int64]*geminiPartialToolCall
groundingByCandidate map[int64]*geminiGroundingStreamCandidate
sentGroundingAnnotations map[string]struct{}
}
type geminiPartialToolCall struct {
id string
name string
arguments map[string]interface{}
}
type geminiPartialArgPathSegment struct {
member string
index int
isIndex bool
}
const maxGeminiPartialArgArrayIndex = 4095
func NewGeminiToChatStreamState(id string, created int64) *GeminiToChatStreamState {
id = strings.TrimSpace(id)
if id == "" {
id = fmt.Sprintf("chatcmpl-%s", kitutil.GetUUID())
}
if created == 0 {
created = kitutil.GetTimestamp()
}
return &GeminiToChatStreamState{
id: id,
created: created,
nextToolIndexByCandidate: make(map[int64]int),
toolIndexByCandidateID: make(map[int64]map[string]int),
partialToolByCandidate: make(map[int64]*geminiPartialToolCall),
groundingByCandidate: make(map[int64]*geminiGroundingStreamCandidate),
sentGroundingAnnotations: make(map[string]struct{}),
}
}
func (s *GeminiToChatStreamState) ConvertChunk(geminiResponse *dto.GeminiChatResponse, model string, usage *dto.Usage) ([]*dto.ChatCompletionsStreamResponse, error) {
if s == nil || geminiResponse == nil {
return nil, nil
}
if s.groundingByCandidate == nil {
s.groundingByCandidate = make(map[int64]*geminiGroundingStreamCandidate)
}
if s.sentGroundingAnnotations == nil {
s.sentGroundingAnnotations = make(map[string]struct{})
}
if s.nextToolIndexByCandidate == nil {
s.nextToolIndexByCandidate = make(map[int64]int)
}
if s.toolIndexByCandidateID == nil {
s.toolIndexByCandidateID = make(map[int64]map[string]int)
}
if s.partialToolByCandidate == nil {
s.partialToolByCandidate = make(map[int64]*geminiPartialToolCall)
}
var err error
geminiResponse, err = s.preparePartialFunctionCalls(geminiResponse)
if err != nil {
return nil, err
}
hasNonStopFinish := false
for _, candidate := range geminiResponse.Candidates {
if candidate.FinishReason != nil && *candidate.FinishReason != "" && *candidate.FinishReason != "STOP" {
hasNonStopFinish = true
break
}
}
response, isStop := StreamResponseGeminiChat2OpenAI(geminiResponse)
if response == nil {
return nil, nil
}
response.Id = s.id
response.Created = s.created
response.Model = model
response.Usage = usage
for index := range geminiResponse.Candidates {
if index >= len(response.Choices) {
break
}
candidate := &geminiResponse.Candidates[index]
choice := &response.Choices[index]
for toolIndex := range choice.Delta.ToolCalls {
callID := strings.TrimSpace(choice.Delta.ToolCalls[toolIndex].ID)
indexesByID := s.toolIndexByCandidateID[candidate.Index]
if indexesByID == nil {
indexesByID = make(map[string]int)
s.toolIndexByCandidateID[candidate.Index] = indexesByID
}
stableIndex, exists := indexesByID[callID]
if callID == "" || !exists {
stableIndex = s.nextToolIndexByCandidate[candidate.Index]
s.nextToolIndexByCandidate[candidate.Index] = stableIndex + 1
if callID != "" {
indexesByID[callID] = stableIndex
}
}
choice.Delta.ToolCalls[toolIndex].SetIndex(stableIndex)
}
grounding := s.groundingByCandidate[candidate.Index]
if grounding == nil {
grounding = newGeminiGroundingStreamCandidate()
s.groundingByCandidate[candidate.Index] = grounding
}
grounding.appendContent(candidate.Content, response.Choices[index].Delta.GetContentString())
response.Choices[index].Delta.Annotations = grounding.groundingAnnotations(
candidate.GroundingMetadata,
candidate.Index,
s.sentGroundingAnnotations,
)
}
if response.IsToolCall() {
s.sawToolCall = true
if !hasNonStopFinish {
for i := range response.Choices {
if response.Choices[i].FinishReason != nil && *response.Choices[i].FinishReason == types.FinishReasonToolCalls {
response.Choices[i].FinishReason = nil
}
}
}
}
if usage != nil {
s.latestUsage = usage
}
for _, choice := range response.Choices {
if choice.FinishReason != nil && *choice.FinishReason != "" {
s.finishEmitted = true
break
}
}
responses := []*dto.ChatCompletionsStreamResponse{response}
if isStop && !s.finishEmitted {
responses = append(responses, s.terminalChunk(model))
}
return responses, nil
}
func (s *GeminiToChatStreamState) Finalize(model string) ([]*dto.ChatCompletionsStreamResponse, error) {
if s == nil {
return nil, nil
}
if len(s.partialToolByCandidate) > 0 {
candidateIndexes := make([]int64, 0, len(s.partialToolByCandidate))
for candidateIndex := range s.partialToolByCandidate {
candidateIndexes = append(candidateIndexes, candidateIndex)
}
sort.Slice(candidateIndexes, func(i, j int) bool {
return candidateIndexes[i] < candidateIndexes[j]
})
candidateIndex := candidateIndexes[0]
partial := s.partialToolByCandidate[candidateIndex]
return nil, fmt.Errorf("Gemini stream ended with an incomplete function call for candidate %d (id %q, name %q)", candidateIndex, partial.id, partial.name)
}
if s.finishEmitted {
return nil, nil
}
return []*dto.ChatCompletionsStreamResponse{s.terminalChunk(model)}, nil
}
func (s *GeminiToChatStreamState) Usage() *dto.Usage {
if s == nil {
return nil
}
return s.latestUsage
}
func (s *GeminiToChatStreamState) preparePartialFunctionCalls(response *dto.GeminiChatResponse) (*dto.GeminiChatResponse, error) {
prepared := *response
prepared.Candidates = append([]dto.GeminiChatCandidate(nil), response.Candidates...)
for candidateIndex := range prepared.Candidates {
candidate := &prepared.Candidates[candidateIndex]
parts := make([]dto.GeminiPart, 0, len(candidate.Content.Parts))
for _, part := range candidate.Content.Parts {
call := part.FunctionCall
if call == nil || (s.partialToolByCandidate[candidate.Index] == nil && call.WillContinue == nil && len(call.PartialArgs) == 0) {
parts = append(parts, part)
continue
}
completed, ready, err := s.appendPartialFunctionCall(candidate.Index, call)
if err != nil {
return nil, fmt.Errorf("reconstruct Gemini streamed function arguments: %w", err)
}
if ready {
part.FunctionCall = completed
parts = append(parts, part)
}
}
candidate.Content.Parts = parts
}
return &prepared, nil
}
func (s *GeminiToChatStreamState) appendPartialFunctionCall(candidateIndex int64, call *dto.FunctionCall) (*dto.FunctionCall, bool, error) {
current := s.partialToolByCandidate[candidateIndex]
if current == nil {
current = &geminiPartialToolCall{arguments: make(map[string]interface{})}
s.partialToolByCandidate[candidateIndex] = current
}
if id := strings.TrimSpace(call.ID); id != "" {
if current.id != "" && current.id != id {
return nil, false, fmt.Errorf("candidate %d function call changed id from %q to %q", candidateIndex, current.id, id)
}
current.id = id
}
if name := strings.TrimSpace(call.FunctionName); name != "" {
if current.name != "" && current.name != name {
return nil, false, fmt.Errorf("candidate %d function call changed name from %q to %q", candidateIndex, current.name, name)
}
current.name = name
}
for _, partial := range call.PartialArgs {
path, err := parseGeminiPartialArgPath(partial.JSONPath)
if err != nil {
return nil, false, err
}
value, present := geminiPartialArgValue(partial)
if !present {
continue
}
updated, err := setGeminiPartialArgValue(current.arguments, path, value, partial.StringValue != nil)
if err != nil {
return nil, false, fmt.Errorf("set partial argument %q: %w", partial.JSONPath, err)
}
arguments, ok := updated.(map[string]interface{})
if !ok {
return nil, false, fmt.Errorf("partial argument path %q replaced the arguments object", partial.JSONPath)
}
current.arguments = arguments
}
if call.WillContinue != nil && *call.WillContinue {
return nil, false, nil
}
if current.name == "" {
return nil, false, fmt.Errorf("candidate %d completed a partial function call without a name", candidateIndex)
}
completed := &dto.FunctionCall{ID: current.id, FunctionName: current.name, Arguments: current.arguments}
delete(s.partialToolByCandidate, candidateIndex)
return completed, true, nil
}
func parseGeminiPartialArgPath(jsonPath string) ([]geminiPartialArgPathSegment, error) {
path := strings.TrimSpace(jsonPath)
if path == "" || path[0] != '$' {
return nil, fmt.Errorf("unsupported Gemini partial argument path %q", jsonPath)
}
segments := make([]geminiPartialArgPathSegment, 0)
for offset := 1; offset < len(path); {
switch path[offset] {
case '.':
offset++
start := offset
for offset < len(path) && path[offset] != '.' && path[offset] != '[' {
offset++
}
if start == offset {
return nil, fmt.Errorf("empty member in Gemini partial argument path %q", jsonPath)
}
member := path[start:offset]
if strings.ContainsAny(member, "]*?") {
return nil, fmt.Errorf("unsupported member %q in Gemini partial argument path", member)
}
segments = append(segments, geminiPartialArgPathSegment{member: member})
case '[':
offset++
if offset >= len(path) {
return nil, fmt.Errorf("unterminated selector in Gemini partial argument path %q", jsonPath)
}
if path[offset] == '\'' || path[offset] == '"' {
member, next, err := parseGeminiPartialArgMember(path, offset)
if err != nil {
return nil, fmt.Errorf("invalid Gemini partial argument path %q: %w", jsonPath, err)
}
offset = next
if offset >= len(path) || path[offset] != ']' {
return nil, fmt.Errorf("unterminated member selector in Gemini partial argument path %q", jsonPath)
}
offset++
segments = append(segments, geminiPartialArgPathSegment{member: member})
continue
}
start := offset
for offset < len(path) && path[offset] >= '0' && path[offset] <= '9' {
offset++
}
if start == offset || offset >= len(path) || path[offset] != ']' {
return nil, fmt.Errorf("unsupported selector in Gemini partial argument path %q", jsonPath)
}
index, err := strconv.Atoi(path[start:offset])
if err != nil {
return nil, fmt.Errorf("invalid array index in Gemini partial argument path %q: %w", jsonPath, err)
}
if index > maxGeminiPartialArgArrayIndex {
return nil, fmt.Errorf("array index %d exceeds Gemini partial argument materialization limit %d", index, maxGeminiPartialArgArrayIndex)
}
offset++
segments = append(segments, geminiPartialArgPathSegment{index: index, isIndex: true})
default:
return nil, fmt.Errorf("unsupported selector at offset %d in Gemini partial argument path %q", offset, jsonPath)
}
}
if len(segments) == 0 {
return nil, fmt.Errorf("Gemini partial argument path %q targets the arguments root", jsonPath)
}
return segments, nil
}
func geminiPartialArgValue(partial dto.GeminiPartialArg) (any, bool) {
switch {
case partial.StringValue != nil:
return *partial.StringValue, true
case partial.NumberValue != nil:
return *partial.NumberValue, true
case partial.BoolValue != nil:
return *partial.BoolValue, true
case partial.NullValue != nil:
return nil, true
default:
return nil, false
}
}
func parseGeminiPartialArgMember(path string, offset int) (string, int, error) {
quote := path[offset]
start := offset
offset++
for offset < len(path) {
if path[offset] == '\\' {
offset += 2
continue
}
if path[offset] == quote {
raw := path[start : offset+1]
if quote == '\'' {
raw = `"` + strings.ReplaceAll(strings.ReplaceAll(raw[1:len(raw)-1], `"`, `\"`), `\'`, `'`) + `"`
}
var member string
if err := kitutil.Unmarshal([]byte(raw), &member); err != nil {
return "", 0, err
}
return member, offset + 1, nil
}
offset++
}
return "", 0, fmt.Errorf("unterminated quoted member")
}
func setGeminiPartialArgValue(current any, path []geminiPartialArgPathSegment, value any, appendString bool) (any, error) {
if len(path) == 0 {
if appendString {
if existing, ok := current.(string); ok {
return existing + value.(string), nil
}
}
return value, nil
}
segment := path[0]
if segment.isIndex {
var array []interface{}
switch typed := current.(type) {
case nil:
array = make([]interface{}, segment.index+1)
case []interface{}:
array = typed
if len(array) <= segment.index {
array = append(array, make([]interface{}, segment.index-len(array)+1)...)
}
default:
return nil, fmt.Errorf("array index %d traverses %T", segment.index, current)
}
updated, err := setGeminiPartialArgValue(array[segment.index], path[1:], value, appendString)
if err != nil {
return nil, err
}
array[segment.index] = updated
return array, nil
}
var object map[string]interface{}
switch typed := current.(type) {
case nil:
object = make(map[string]interface{})
case map[string]interface{}:
object = typed
default:
return nil, fmt.Errorf("member %q traverses %T", segment.member, current)
}
updated, err := setGeminiPartialArgValue(object[segment.member], path[1:], value, appendString)
if err != nil {
return nil, err
}
object[segment.member] = updated
return object, nil
}
func (s *GeminiToChatStreamState) terminalChunk(model string) *dto.ChatCompletionsStreamResponse {
finishReason := types.FinishReasonStop
if s.sawToolCall {
finishReason = types.FinishReasonToolCalls
}
s.finishEmitted = true
return &dto.ChatCompletionsStreamResponse{
Id: s.id,
Object: "chat.completion.chunk",
Created: s.created,
Model: model,
Choices: []dto.ChatCompletionsStreamResponseChoice{
{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{},
FinishReason: &finishReason,
},
},
Usage: s.latestUsage,
}
}
func geminiResponseToolCall(item *dto.GeminiPart) *dto.ToolCallResponse {
argsBytes, err := kitutil.Marshal(item.FunctionCall.Arguments)
if err != nil {
return nil
}
callID := strings.TrimSpace(item.FunctionCall.ID)
if callID == "" {
callID = fmt.Sprintf("call_%s", kitutil.GetUUID())
}
return &dto.ToolCallResponse{
ID: callID,
Type: "function",
Function: dto.FunctionResponse{
Arguments: string(argsBytes),
Name: item.FunctionCall.FunctionName,
},
}
}
@@ -0,0 +1,71 @@
package geminichat
import (
"fmt"
"github.com/QuantumNous/new-api/relaykit/dto"
oaichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/oai_chat"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
// GeminiHostedStreamBridge accumulates grounding metadata until the provider
// stream ends. Gemini commonly reports the search queries after emitting the
// answer text; delaying the synthetic Responses item keeps one complete,
// canonical action instead of emitting partial or duplicate tool calls.
type GeminiHostedStreamBridge struct {
queries []string
seen map[string]struct{}
}
func NewGeminiHostedStreamBridge() *GeminiHostedStreamBridge {
return &GeminiHostedStreamBridge{seen: make(map[string]struct{})}
}
func (b *GeminiHostedStreamBridge) Observe(response *dto.GeminiChatResponse) {
if b == nil {
return
}
if b.seen == nil {
b.seen = make(map[string]struct{})
}
for _, query := range GroundingWebSearchQueries(response) {
if _, exists := b.seen[query]; exists {
continue
}
b.seen[query] = struct{}{}
b.queries = append(b.queries, query)
}
}
func (b *GeminiHostedStreamBridge) Finalize(state *oaichat.ChatToResponsesStreamState) ([]oaichat.ChatToResponsesStreamEvent, error) {
if b == nil || len(b.queries) == 0 {
return nil, nil
}
if state == nil {
return nil, fmt.Errorf("Chat-to-Responses stream state is required")
}
action, err := kitutil.Marshal(map[string]any{
"type": "search",
"queries": append([]string(nil), b.queries...),
})
if err != nil {
return nil, fmt.Errorf("marshal Gemini web-search action: %w", err)
}
callID := fmt.Sprintf("ws_%s", kitutil.GetUUID())
events, err := state.StartHostedTool(oaichat.HostedToolStreamStart{
Type: "web_search_call",
ID: callID,
Action: action,
})
if err != nil {
return nil, err
}
completed, err := state.CompleteHostedTool(oaichat.HostedToolStreamResult{
Type: "web_search_call",
ID: callID,
})
if err != nil {
return nil, err
}
return append(events, completed...), nil
}
@@ -0,0 +1,15 @@
package jsonutil
import (
"fmt"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
func ToJSONString(v interface{}) string {
bytes, err := kitutil.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(bytes)
}
@@ -0,0 +1,46 @@
package media
import (
"errors"
"sync"
"context"
"github.com/QuantumNous/new-api/relaykit/types"
)
type MediaResolver struct {
GetBase64Data func(c context.Context, source types.FileSource, reason ...string) (string, string, error)
DecodeBase64FileData func(base64String string) (string, string, error)
}
var (
mediaResolverMu sync.RWMutex
mediaResolver MediaResolver
)
func SetMediaResolver(resolver MediaResolver) {
mediaResolverMu.Lock()
defer mediaResolverMu.Unlock()
mediaResolver = resolver
}
func ResolveBase64Data(c context.Context, source types.FileSource, reason ...string) (string, string, error) {
mediaResolverMu.RLock()
resolver := mediaResolver.GetBase64Data
mediaResolverMu.RUnlock()
if resolver == nil {
return "", "", errors.New("relayconvert media resolver is not configured")
}
return resolver(c, source, reason...)
}
func DecodeBase64FileData(base64String string) (string, string, error) {
mediaResolverMu.RLock()
resolver := mediaResolver.DecodeBase64FileData
mediaResolverMu.RUnlock()
if resolver == nil {
return "", "", errors.New("relayconvert media resolver is not configured")
}
return resolver(base64String)
}
@@ -0,0 +1,81 @@
package oaichat
import (
"encoding/json"
"strings"
"unicode/utf8"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
func chatAnnotationsToClaude(raw json.RawMessage, text string) []json.RawMessage {
if len(raw) == 0 {
return nil
}
var annotations []map[string]any
if err := kitutil.Unmarshal(raw, &annotations); err != nil {
return nil
}
citations := make([]json.RawMessage, 0, len(annotations))
for _, annotation := range annotations {
if strings.TrimSpace(kitutil.Interface2String(annotation["type"])) != "url_citation" {
continue
}
citation, ok := annotation["url_citation"].(map[string]any)
if !ok {
citation = annotation
}
url := strings.TrimSpace(kitutil.Interface2String(citation["url"]))
if url == "" {
continue
}
converted := map[string]any{
"type": "web_search_result_location",
"url": url,
"title": strings.TrimSpace(kitutil.Interface2String(citation["title"])),
}
if citedText := kitutil.Interface2String(citation["cited_text"]); citedText != "" {
converted["cited_text"] = citedText
} else if citedText := citedTextFromAnnotation(text, citation); citedText != "" {
converted["cited_text"] = citedText
}
if encryptedIndex := kitutil.Interface2String(citation["encrypted_index"]); encryptedIndex != "" {
converted["encrypted_index"] = encryptedIndex
}
if converted["title"] == "" {
delete(converted, "title")
}
encoded, err := kitutil.Marshal(converted)
if err == nil {
citations = append(citations, encoded)
}
}
return citations
}
func citedTextFromAnnotation(text string, citation map[string]any) string {
start, startOK := annotationIndex(citation["start_index"])
end, endOK := annotationIndex(citation["end_index"])
if !startOK || !endOK || start < 0 || end <= start {
return ""
}
if end > utf8.RuneCountInString(text) {
return ""
}
runes := []rune(text)
return string(runes[start:end])
}
func annotationIndex(value any) (int, bool) {
switch number := value.(type) {
case float64:
return int(number), number >= 0 && number == float64(int(number))
case int:
return number, number >= 0
case json.Number:
parsed, err := number.Int64()
return int(parsed), err == nil && parsed >= 0
default:
return 0, false
}
}
@@ -0,0 +1,324 @@
package oaichat
import (
"fmt"
"strings"
"context"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, textRequest dto.GeneralOpenAIRequest) (*dto.ClaudeRequest, error) {
opts := convmeta.OptionsOf(info)
claudeTools := make([]any, 0, len(textRequest.Tools))
for _, tool := range textRequest.Tools {
if _, ok := tool.Function.Parameters.(map[string]any); !ok && tool.Type != "function" {
continue
}
claudeTools = append(claudeTools, &dto.Tool{
Name: tool.Function.Name,
Description: tool.Function.Description,
InputSchema: sharedclaude.FunctionParametersToInputSchema(tool.Function.Parameters),
})
}
if textRequest.WebSearchOptions != nil {
webSearchTool := dto.ClaudeWebSearchTool{
Type: "web_search_20250305",
Name: "web_search",
}
if textRequest.WebSearchOptions.UserLocation != nil {
anthropicUserLocation := &dto.ClaudeWebSearchUserLocation{
Type: "approximate",
}
var userLocationMap map[string]any
if err := kitutil.Unmarshal(textRequest.WebSearchOptions.UserLocation, &userLocationMap); err == nil {
if approximateData, ok := userLocationMap["approximate"].(map[string]any); ok {
if timezone, ok := approximateData["timezone"].(string); ok && timezone != "" {
anthropicUserLocation.Timezone = timezone
}
if country, ok := approximateData["country"].(string); ok && country != "" {
anthropicUserLocation.Country = country
}
if region, ok := approximateData["region"].(string); ok && region != "" {
anthropicUserLocation.Region = region
}
if city, ok := approximateData["city"].(string); ok && city != "" {
anthropicUserLocation.City = city
}
}
}
webSearchTool.UserLocation = anthropicUserLocation
}
claudeTools = append(claudeTools, &webSearchTool)
}
claudeRequest := dto.ClaudeRequest{
Model: textRequest.Model,
StopSequences: nil,
Temperature: textRequest.Temperature,
}
if len(claudeTools) > 0 {
claudeRequest.Tools = claudeTools
}
if textRequest.MaxCompletionTokens != nil && *textRequest.MaxCompletionTokens > 0 {
claudeRequest.MaxTokens = kitutil.GetPointer(*textRequest.MaxCompletionTokens)
} else if textRequest.MaxTokens != nil && *textRequest.MaxTokens > 0 {
claudeRequest.MaxTokens = kitutil.GetPointer(*textRequest.MaxTokens)
}
if textRequest.TopP != nil {
claudeRequest.TopP = kitutil.GetPointer(*textRequest.TopP)
}
if textRequest.TopK != nil {
claudeRequest.TopK = kitutil.GetPointer(*textRequest.TopK)
}
if textRequest.IsStream(nil) {
claudeRequest.Stream = kitutil.GetPointer(true)
}
if textRequest.ToolChoice != nil || textRequest.ParallelTooCalls != nil {
claudeToolChoice := sharedclaude.MapOpenAIToolChoice(textRequest.ToolChoice, textRequest.ParallelTooCalls)
if claudeToolChoice != nil {
claudeRequest.ToolChoice = claudeToolChoice
}
}
sourceReasoning, err := reasoning.FromOpenAIChat(&textRequest)
if err != nil {
return nil, reasoning.AsClientError(err)
}
if err := sharedclaude.ApplyReasoning(c, &claudeRequest, info, sourceReasoning, true); err != nil {
return nil, reasoning.AsClientError(err)
}
if claudeRequest.MaxTokens == nil {
if defaultMaxTokens, configured := opts.Claude.DefaultMaxTokensFor(claudeRequest.Model); configured {
value := uint(defaultMaxTokens)
claudeRequest.MaxTokens = &value
}
}
if textRequest.Stop != nil {
switch stop := textRequest.Stop.(type) {
case string:
claudeRequest.StopSequences = []string{stop}
case []any:
stopSequences := make([]string, 0)
for _, item := range stop {
stopSequences = append(stopSequences, item.(string))
}
claudeRequest.StopSequences = stopSequences
}
}
formatMessages := make([]dto.Message, 0)
lastMessage := dto.Message{
Role: "tool",
}
for _, message := range textRequest.Messages {
switch message.Role {
case "":
message.Role = "user"
case "developer":
message.Role = "system"
case "function":
if message.ToolCallId != "" {
message.Role = "tool"
} else {
message.Role = "user"
}
case "tool":
if message.ToolCallId == "" {
message.Role = "user"
}
case "system", "user", "assistant":
default:
message.Role = "user"
}
fmtMessage := dto.Message{
Role: message.Role,
Content: message.Content,
}
if message.Role == "tool" {
fmtMessage.ToolCallId = message.ToolCallId
}
if message.Role == "assistant" && message.ToolCalls != nil {
fmtMessage.ToolCalls = message.ToolCalls
}
if lastMessage.Role == message.Role && lastMessage.Role != "tool" {
if lastMessage.IsStringContent() && message.IsStringContent() {
fmtMessage.SetStringContent(fmt.Sprintf("%s %s", lastMessage.StringContent(), message.StringContent()))
formatMessages = formatMessages[:len(formatMessages)-1]
}
}
if fmtMessage.Content == nil || (fmtMessage.IsStringContent() && fmtMessage.StringContent() == "") {
fmtMessage.SetStringContent("...")
}
formatMessages = append(formatMessages, fmtMessage)
lastMessage = fmtMessage
}
claudeMessages := make([]dto.ClaudeMessage, 0)
isFirstMessage := true
var systemMessages []dto.ClaudeMediaMessage
placeholderUserMessage := dto.ClaudeMessage{
Role: "user",
Content: []dto.ClaudeMediaMessage{
{
Type: "text",
Text: kitutil.GetPointer[string]("..."),
},
},
}
for _, message := range formatMessages {
if message.Role == "system" {
if message.IsStringContent() {
if text := message.StringContent(); text != "" {
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
Type: "text",
Text: kitutil.GetPointer[string](text),
})
}
} else {
for _, ctx := range message.ParseContent() {
if ctx.Type == "text" && ctx.Text != "" {
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
Type: "text",
Text: kitutil.GetPointer[string](ctx.Text),
})
}
}
}
continue
}
if isFirstMessage {
isFirstMessage = false
if message.Role != "user" {
claudeMessages = append(claudeMessages, placeholderUserMessage)
}
}
claudeMessage := dto.ClaudeMessage{
Role: message.Role,
}
if message.Role == "tool" {
if len(claudeMessages) > 0 && claudeMessages[len(claudeMessages)-1].Role == "user" {
lastClaudeMessage := claudeMessages[len(claudeMessages)-1]
if content, ok := lastClaudeMessage.Content.(string); ok {
lastClaudeMessage.Content = []dto.ClaudeMediaMessage{
{
Type: "text",
Text: kitutil.GetPointer[string](content),
},
}
}
lastClaudeMessage.Content = append(lastClaudeMessage.Content.([]dto.ClaudeMediaMessage), dto.ClaudeMediaMessage{
Type: "tool_result",
ToolUseId: message.ToolCallId,
Content: message.Content,
})
claudeMessages[len(claudeMessages)-1] = lastClaudeMessage
continue
}
claudeMessage.Role = "user"
claudeMessage.Content = []dto.ClaudeMediaMessage{
{
Type: "tool_result",
ToolUseId: message.ToolCallId,
Content: message.Content,
},
}
} else if message.IsStringContent() && message.ToolCalls == nil {
text := message.StringContent()
if text == "" {
text = "..."
}
claudeMessage.Content = text
} else {
claudeMediaMessages := make([]dto.ClaudeMediaMessage, 0)
for _, mediaMessage := range message.ParseContent() {
switch mediaMessage.Type {
case "text":
if mediaMessage.Text != "" {
claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{
Type: "text",
Text: kitutil.GetPointer[string](mediaMessage.Text),
})
}
default:
source := mediaMessage.ToFileSource()
if source == nil {
continue
}
base64Data, mimeType, err := relaymedia.ResolveBase64Data(c, source, "formatting image for Claude")
if err != nil {
return nil, fmt.Errorf("get file data failed: %s", err.Error())
}
claudeMediaMessage := dto.ClaudeMediaMessage{
Source: &dto.ClaudeMessageSource{
Type: "base64",
},
}
if strings.HasPrefix(mimeType, "application/pdf") {
claudeMediaMessage.Type = "document"
} else {
claudeMediaMessage.Type = "image"
}
claudeMediaMessage.Source.MediaType = mimeType
claudeMediaMessage.Source.Data = base64Data
claudeMediaMessages = append(claudeMediaMessages, claudeMediaMessage)
continue
}
}
if message.ToolCalls != nil {
for _, toolCall := range message.ParseToolCalls() {
inputObj := make(map[string]any)
if args := toolCall.Function.Arguments; args != "" {
if err := kitutil.Unmarshal([]byte(args), &inputObj); err != nil {
kitutil.LogInfo("tool call function arguments is not a map[string]any: " + fmt.Sprintf("%v", toolCall.Function.Arguments))
}
}
claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{
Type: "tool_use",
Id: toolCall.ID,
Name: toolCall.Function.Name,
Input: inputObj,
})
}
}
claudeMessage.Content = claudeMediaMessages
}
claudeMessages = append(claudeMessages, claudeMessage)
}
if len(claudeMessages) == 0 && len(systemMessages) > 0 {
claudeMessages = append(claudeMessages, placeholderUserMessage)
}
if len(systemMessages) > 0 {
claudeRequest.System = systemMessages
}
claudeRequest.Prompt = ""
claudeRequest.Messages = claudeMessages
// Checked last so every injection path (default hook, thinking adapter
// floor) has had its chance to satisfy the required field.
if claudeRequest.MaxTokens == nil {
return nil, sharedclaude.ErrMissingMaxTokens
}
return &claudeRequest, nil
}
@@ -0,0 +1,81 @@
package oaichat
import (
"context"
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOpenAIChatRequestToClaudeMessagesNormalizesToolInputSchema(t *testing.T) {
tests := []struct {
name string
parameters any
wantSchema map[string]any
}{
{
name: "omitted parameters",
parameters: nil,
wantSchema: map[string]any{
"type": "object",
"properties": map[string]any{},
},
},
{
name: "missing type and properties",
parameters: map[string]any{
"additionalProperties": false,
},
wantSchema: map[string]any{
"type": "object",
"properties": map[string]any{},
"additionalProperties": false,
},
},
{
name: "non-string type",
parameters: map[string]any{
"type": 123,
"properties": map[string]any{},
},
wantSchema: map[string]any{
"type": 123,
"properties": map[string]any{},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
maxTokens := uint(1024)
got, err := OpenAIChatRequestToClaudeMessages(context.Background(), nil, dto.GeneralOpenAIRequest{
Model: "claude-test",
MaxTokens: &maxTokens,
Messages: []dto.Message{
{Role: "user", Content: "Call the tool."},
},
Tools: []dto.ToolCallRequest{
{
Type: "function",
Function: dto.FunctionRequest{
Name: "get_current_time",
Description: "Get the current time",
Parameters: tt.parameters,
},
},
},
})
require.NoError(t, err)
tools, ok := got.Tools.([]any)
require.True(t, ok)
require.Len(t, tools, 1)
tool, ok := tools[0].(*dto.Tool)
require.True(t, ok)
assert.Equal(t, "get_current_time", tool.Name)
assert.Equal(t, tt.wantSchema, tool.InputSchema)
})
}
}
@@ -0,0 +1,503 @@
package oaichat
import (
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/reasonmap"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
func generateStopBlock(index int) *dto.ClaudeResponse {
return &dto.ClaudeResponse{
Type: "content_block_stop",
Index: kitutil.GetPointer[int](index),
}
}
func stopOpenBlocks(state *convmeta.ClaudeConvertInfo) []*dto.ClaudeResponse {
if state == nil {
return nil
}
switch state.LastMessagesType {
case convmeta.LastMessageTypeText, convmeta.LastMessageTypeThinking:
return []*dto.ClaudeResponse{generateStopBlock(state.Index)}
case convmeta.LastMessageTypeTools:
if len(state.ToolCalls) == 0 {
responses := make([]*dto.ClaudeResponse, 0, state.ToolCallMaxIndexOffset+1)
for offset := 0; offset <= state.ToolCallMaxIndexOffset; offset++ {
responses = append(responses, generateStopBlock(state.ToolCallBaseIndex+offset))
}
return responses
}
responses := make([]*dto.ClaudeResponse, 0, len(state.ToolCalls))
for _, tool := range state.ToolCalls {
if tool != nil && tool.Started {
responses = append(responses, generateStopBlock(tool.BlockIndex))
}
}
return responses
default:
return nil
}
}
func startPendingToolBlocks(state *convmeta.ClaudeConvertInfo) []*dto.ClaudeResponse {
if state == nil || state.LastMessagesType != convmeta.LastMessageTypeTools {
return nil
}
responses := make([]*dto.ClaudeResponse, 0)
for _, tool := range state.ToolCalls {
if tool == nil || tool.Started || tool.Name == "" {
continue
}
if tool.ID == "" {
tool.ID = fmt.Sprintf("toolu_%s", kitutil.GetUUID())
}
idx := tool.BlockIndex
responses = append(responses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Id: tool.ID,
Type: "tool_use",
Name: tool.Name,
Input: map[string]any{},
},
})
tool.Started = true
if tool.PendingArguments != "" {
arguments := tool.PendingArguments
responses = append(responses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "input_json_delta",
PartialJson: &arguments,
},
})
tool.PendingArguments = ""
}
}
return responses
}
func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
return sharedclaude.UsageFromOpenAI(oaiUsage)
}
func clientVisibleClaudeStreamUsage(state *convmeta.ClaudeConvertInfo, incoming *dto.Usage) *dto.ClaudeUsage {
prior := buildClaudeUsageFromOpenAIUsage(state.Usage)
converted := buildClaudeUsageFromOpenAIUsage(incoming)
if incoming != nil {
state.Usage = dto.MergeUsageNonZero(state.Usage, incoming)
}
if prior == nil {
return converted
}
if converted == nil {
return prior
}
return dto.MergeClaudeUsageNonZero(prior, converted)
}
func NormalizeCacheCreationSplit(totalTokens int, tokens5m int, tokens1h int) (int, int) {
return sharedclaude.NormalizeCacheCreationSplit(totalTokens, tokens5m, tokens1h)
}
func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamResponse, info convmeta.Meta) []*dto.ClaudeResponse {
if info == nil {
info = &convmeta.Values{}
}
state := info.EnsureClaudeConvertInfo()
if state.Done {
return nil
}
var claudeResponses []*dto.ClaudeResponse
// stopOpenBlocks emits the required content_block_stop event(s) for the currently open block(s)
// according to Anthropic's SSE streaming state machine:
// content_block_start -> content_block_delta* -> content_block_stop (per index).
//
// For text/thinking, there is at most one open block at state.Index.
// For tools, OpenAI tool_calls can stream multiple parallel tool_use blocks (indexed from 0),
// so we may have multiple open blocks and must stop each one explicitly.
appendStopOpenBlocks := func() {
claudeResponses = append(claudeResponses, startPendingToolBlocks(state)...)
claudeResponses = append(claudeResponses, stopOpenBlocks(state)...)
}
// stopOpenBlocksAndAdvance closes the currently open block(s) and advances the content block index
// to the next available slot for subsequent content_block_start events.
//
// This prevents invalid streams where a content_block_delta (e.g. thinking_delta) is emitted for an
// index whose active content_block type is different (the typical cause of "Mismatched content block type").
stopOpenBlocksAndAdvance := func() {
if state.LastMessagesType == convmeta.LastMessageTypeNone {
return
}
appendStopOpenBlocks()
switch state.LastMessagesType {
case convmeta.LastMessageTypeTools:
state.Index = state.ToolCallBaseIndex + len(state.ToolCalls)
state.ToolCallBaseIndex = 0
state.ToolCallMaxIndexOffset = 0
state.ToolCalls = nil
state.ToolCallByIndex = nil
state.ToolCallByID = nil
default:
state.Index++
}
state.LastMessagesType = convmeta.LastMessageTypeNone
}
appendCitationDeltas := func(raw []byte) {
citations := chatAnnotationsToClaude(raw, "")
if len(citations) == 0 {
return
}
if state.LastMessagesType != convmeta.LastMessageTypeText {
stopOpenBlocksAndAdvance()
idx := state.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "text",
Text: kitutil.GetPointer[string](""),
},
})
state.LastMessagesType = convmeta.LastMessageTypeText
}
for _, citation := range citations {
idx := state.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "citations_delta",
Citation: citation,
},
})
}
}
if info.GetSendResponseCount() == 1 {
// Client-visible Claude stream usage matches billing merge: first
// frame records first, a later non-zero field overrides, and a later
// zero/missing field never erases a first-frame positive. Anthropic
// clients treat message_delta as authoritative for the fields it
// carries, including a corrected input_tokens.
startUsage := &dto.ClaudeUsage{
InputTokens: info.GetEstimatePromptTokens(),
OutputTokens: 0,
}
if openAIResponse.Usage != nil && dto.HasOpenAIUsageTokens(openAIResponse.Usage) {
if real := buildClaudeUsageFromOpenAIUsage(openAIResponse.Usage); real != nil {
startUsage = real
}
state.Usage = dto.MergeUsageNonZero(state.Usage, openAIResponse.Usage)
}
msg := &dto.ClaudeMediaMessage{
Id: openAIResponse.Id,
Model: openAIResponse.Model,
Type: "message",
Role: "assistant",
Usage: startUsage,
}
msg.SetContent(make([]any, 0))
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_start",
Message: msg,
})
}
if len(openAIResponse.Choices) == 0 {
// Some OpenAI-compatible upstreams end with a usage-only SSE chunk.
oaiUsage := clientVisibleClaudeStreamUsage(state, openAIResponse.Usage)
if oaiUsage != nil {
appendStopOpenBlocks()
stopReason := stopReasonOpenAI2Claude(state.FinishReason)
if stopReason == "" {
stopReason = "end_turn"
}
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: oaiUsage,
Delta: &dto.ClaudeMediaMessage{
StopReason: kitutil.GetPointer[string](stopReason),
},
})
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_stop",
})
state.Done = true
}
return claudeResponses
} else {
chosenChoice := openAIResponse.Choices[0]
doneChunk := chosenChoice.FinishReason != nil && *chosenChoice.FinishReason != ""
if doneChunk {
state.FinishReason = *chosenChoice.FinishReason
}
var claudeResponse dto.ClaudeResponse
var isEmpty bool
claudeResponse.Type = "content_block_delta"
if len(chosenChoice.Delta.ToolCalls) > 0 {
toolCalls := chosenChoice.Delta.ToolCalls
if state.LastMessagesType != convmeta.LastMessageTypeTools {
stopOpenBlocksAndAdvance()
state.ToolCallBaseIndex = state.Index
state.ToolCallMaxIndexOffset = 0
state.ToolCalls = nil
state.ToolCallByIndex = make(map[int]*convmeta.ClaudeStreamToolCall)
state.ToolCallByID = make(map[string]*convmeta.ClaudeStreamToolCall)
}
state.LastMessagesType = convmeta.LastMessageTypeTools
if state.ToolCallByIndex == nil {
state.ToolCallByIndex = make(map[int]*convmeta.ClaudeStreamToolCall)
}
if state.ToolCallByID == nil {
state.ToolCallByID = make(map[string]*convmeta.ClaudeStreamToolCall)
}
for i, toolCall := range toolCalls {
toolIndex := i
if toolCall.Index != nil {
toolIndex = *toolCall.Index
}
incomingID := strings.TrimSpace(toolCall.ID)
var tool *convmeta.ClaudeStreamToolCall
if incomingID != "" {
tool = state.ToolCallByID[incomingID]
}
if tool == nil {
tool = state.ToolCallByIndex[toolIndex]
}
if tool != nil && incomingID != "" && tool.ID != "" && tool.ID != incomingID {
tool = nil
}
if tool == nil {
tool = &convmeta.ClaudeStreamToolCall{
BlockIndex: state.ToolCallBaseIndex + len(state.ToolCalls),
}
state.ToolCalls = append(state.ToolCalls, tool)
}
state.ToolCallByIndex[toolIndex] = tool
if tool.ID == "" && incomingID != "" {
tool.ID = incomingID
state.ToolCallByID[incomingID] = tool
}
if tool.Name == "" && strings.TrimSpace(toolCall.Function.Name) != "" {
tool.Name = strings.TrimSpace(toolCall.Function.Name)
}
if !tool.Started {
tool.PendingArguments += toolCall.Function.Arguments
}
idx := tool.BlockIndex
if !tool.Started && tool.ID != "" && tool.Name != "" {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Id: tool.ID,
Type: "tool_use",
Name: tool.Name,
Input: map[string]any{},
},
})
tool.Started = true
if tool.PendingArguments != "" {
arguments := tool.PendingArguments
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "input_json_delta",
PartialJson: &arguments,
},
})
tool.PendingArguments = ""
}
continue
}
if tool.Started && toolCall.Function.Arguments != "" {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "input_json_delta",
PartialJson: &toolCall.Function.Arguments,
},
})
}
}
state.ToolCallMaxIndexOffset = len(state.ToolCalls) - 1
if len(state.ToolCalls) > 0 {
state.Index = state.ToolCallBaseIndex + len(state.ToolCalls) - 1
}
} else {
reasoning := chosenChoice.Delta.GetReasoningContent()
textContent := chosenChoice.Delta.GetContentString()
if reasoning != "" || textContent != "" {
if reasoning != "" {
if state.LastMessagesType != convmeta.LastMessageTypeThinking {
stopOpenBlocksAndAdvance()
idx := state.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "thinking",
Thinking: kitutil.GetPointer[string](""),
},
})
}
state.LastMessagesType = convmeta.LastMessageTypeThinking
claudeResponse.Delta = &dto.ClaudeMediaMessage{
Type: "thinking_delta",
Thinking: &reasoning,
}
} else {
if state.LastMessagesType != convmeta.LastMessageTypeText {
stopOpenBlocksAndAdvance()
idx := state.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "text",
Text: kitutil.GetPointer[string](""),
},
})
}
state.LastMessagesType = convmeta.LastMessageTypeText
claudeResponse.Delta = &dto.ClaudeMediaMessage{
Type: "text_delta",
Text: kitutil.GetPointer[string](textContent),
}
}
} else {
isEmpty = true
}
}
claudeResponse.Index = kitutil.GetPointer[int](state.Index)
if !isEmpty && claudeResponse.Delta != nil {
claudeResponses = append(claudeResponses, &claudeResponse)
}
appendCitationDeltas(chosenChoice.Delta.Annotations)
if doneChunk || state.Done {
oaiUsage := clientVisibleClaudeStreamUsage(state, openAIResponse.Usage)
if oaiUsage == nil {
// Some upstreams emit finish_reason first, then send a final usage-only chunk.
// Keep content blocks open until usage is available so the terminal message_delta
// can carry both usage and the final stop reason.
return claudeResponses
}
appendStopOpenBlocks()
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: oaiUsage,
Delta: &dto.ClaudeMediaMessage{
StopReason: kitutil.GetPointer[string](stopReasonOpenAI2Claude(state.FinishReason)),
},
})
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_stop",
})
state.Done = true
return claudeResponses
}
}
return claudeResponses
}
func FinalizeStreamResponseOpenAI2Claude(info convmeta.Meta) []*dto.ClaudeResponse {
if info == nil {
info = &convmeta.Values{}
}
state := info.EnsureClaudeConvertInfo()
if state.Done {
return nil
}
stopReason := stopReasonOpenAI2Claude(state.FinishReason)
if stopReason == "" {
stopReason = "end_turn"
}
responses := startPendingToolBlocks(state)
responses = append(responses, stopOpenBlocks(state)...)
responses = append(responses,
&dto.ClaudeResponse{
Type: "message_delta",
Usage: clientVisibleClaudeStreamUsage(state, nil),
Delta: &dto.ClaudeMediaMessage{
StopReason: kitutil.GetPointer[string](stopReason),
},
},
&dto.ClaudeResponse{Type: "message_stop"},
)
state.Done = true
return responses
}
func ResponseOpenAI2Claude(openAIResponse *dto.OpenAITextResponse, info convmeta.Meta) *dto.ClaudeResponse {
var stopReason string
contents := make([]dto.ClaudeMediaMessage, 0)
claudeResponse := &dto.ClaudeResponse{
Id: openAIResponse.Id,
Type: "message",
Role: "assistant",
Model: openAIResponse.Model,
}
for _, choice := range openAIResponse.Choices {
stopReason = stopReasonOpenAI2Claude(choice.FinishReason)
reasoningContent := choice.Message.GetReasoningContent()
textContent := choice.Message.StringContent()
toolCalls := choice.Message.ParseToolCalls()
if reasoningContent != "" {
claudeContent := dto.ClaudeMediaMessage{Type: "thinking"}
claudeContent.Thinking = kitutil.GetPointer(reasoningContent)
contents = append(contents, claudeContent)
}
if textContent != "" || (reasoningContent == "" && len(toolCalls) == 0) {
claudeContent := dto.ClaudeMediaMessage{}
claudeContent.Type = "text"
claudeContent.SetText(textContent)
citations := chatAnnotationsToClaude(choice.Message.Annotations, textContent)
if len(citations) > 0 {
claudeContent.Citations, _ = kitutil.Marshal(citations)
}
contents = append(contents, claudeContent)
}
for _, toolUse := range toolCalls {
claudeContent := dto.ClaudeMediaMessage{}
claudeContent.Type = "tool_use"
claudeContent.Id = toolUse.ID
claudeContent.Name = toolUse.Function.Name
mapParams := map[string]any{}
if strings.TrimSpace(toolUse.Function.Arguments) != "" {
var parsed map[string]any
if err := kitutil.Unmarshal([]byte(toolUse.Function.Arguments), &parsed); err == nil && parsed != nil {
mapParams = parsed
}
}
claudeContent.Input = mapParams
contents = append(contents, claudeContent)
}
}
claudeResponse.Content = contents
claudeResponse.StopReason = stopReason
claudeResponse.Usage = buildClaudeUsageFromOpenAIUsage(&openAIResponse.Usage)
return claudeResponse
}
func stopReasonOpenAI2Claude(reason string) string {
return reasonmap.OpenAIFinishReasonToClaudeStopReason(reason)
}
@@ -0,0 +1,424 @@
package oaichat
import (
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResponseOpenAI2ClaudeToolUseInputIsObject(t *testing.T) {
tests := []struct {
name string
args string
want map[string]any
}{
{name: "object", args: `{"q":"x"}`, want: map[string]any{"q": "x"}},
{name: "empty", args: "", want: map[string]any{}},
{name: "invalid", args: "{", want: map[string]any{}},
{name: "null", args: "null", want: map[string]any{}},
{name: "array", args: `["x"]`, want: map[string]any{}},
{name: "string", args: `"x"`, want: map[string]any{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msg := dto.Message{Role: "assistant"}
msg.SetToolCalls([]dto.ToolCallRequest{
{
ID: "call_1",
Type: "function",
Function: dto.FunctionRequest{
Name: "lookup",
Arguments: tt.args,
},
},
})
resp := ResponseOpenAI2Claude(&dto.OpenAITextResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.OpenAITextResponseChoice{
{Message: msg, FinishReason: "tool_calls"},
},
}, nil)
require.Len(t, resp.Content, 1)
assert.Equal(t, "tool_use", resp.Content[0].Type)
assert.Equal(t, tt.want, resp.Content[0].Input)
})
}
}
func TestResponseOpenAI2ClaudeUsageCarriesOpenAIBillingUsage(t *testing.T) {
resp := ResponseOpenAI2Claude(&dto.OpenAITextResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.OpenAITextResponseChoice{
{Message: dto.Message{Role: "assistant", Content: "hello"}, FinishReason: "stop"},
},
Usage: dto.Usage{
PromptTokens: 11,
CompletionTokens: 5,
TotalTokens: 16,
},
}, nil)
require.NotNil(t, resp.Usage)
assert.Equal(t, 11, resp.Usage.InputTokens)
assert.Equal(t, 5, resp.Usage.OutputTokens)
require.NotNil(t, resp.Usage.BillingUsage)
require.NotNil(t, resp.Usage.BillingUsage.OpenAIUsage)
assert.Equal(t, dto.BillingUsageSourceOAIChat, resp.Usage.BillingUsage.Source)
assert.Equal(t, dto.BillingUsageSemanticOpenAI, resp.Usage.BillingUsage.Semantic)
assert.Equal(t, 11, resp.Usage.BillingUsage.OpenAIUsage.PromptTokens)
assert.Equal(t, 5, resp.Usage.BillingUsage.OpenAIUsage.CompletionTokens)
assert.Equal(t, 16, resp.Usage.BillingUsage.OpenAIUsage.TotalTokens)
assert.Nil(t, resp.Usage.BillingUsage.OpenAIUsage.BillingUsage)
}
func TestResponseOpenAI2ClaudePreservesReasoningBeforeText(t *testing.T) {
message := dto.Message{Role: "assistant", Content: "final answer"}
message.ReasoningContent = ptr("considering the request")
resp := ResponseOpenAI2Claude(&dto.OpenAITextResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.OpenAITextResponseChoice{
{Message: message, FinishReason: "stop"},
},
}, nil)
require.Len(t, resp.Content, 2)
assert.Equal(t, "thinking", resp.Content[0].Type)
require.NotNil(t, resp.Content[0].Thinking)
assert.Equal(t, "considering the request", *resp.Content[0].Thinking)
assert.Equal(t, "text", resp.Content[1].Type)
assert.Equal(t, "final answer", resp.Content[1].GetText())
}
func TestBuildClaudeUsageFromOpenAICacheWriteUsage(t *testing.T) {
usage := buildClaudeUsageFromOpenAIUsage(&dto.Usage{
PromptTokens: 3619,
CompletionTokens: 36,
TotalTokens: 3655,
PromptTokensDetails: dto.InputTokenDetails{
CachedTokens: 2921,
CacheWriteTokens: 3616,
},
})
require.NotNil(t, usage)
// Claude semantics reports input_tokens excluding cache read/write; the
// overlapping unadjusted prefixes drive the remainder negative, clamp to 0.
assert.Equal(t, 0, usage.InputTokens)
assert.Equal(t, 2921, usage.CacheReadInputTokens)
assert.Equal(t, 3616, usage.CacheCreationInputTokens)
assert.Equal(t, 36, usage.OutputTokens)
require.NotNil(t, usage.BillingUsage)
require.NotNil(t, usage.BillingUsage.OpenAIUsage)
assert.Equal(t, dto.BillingUsageSemanticOpenAI, usage.BillingUsage.Semantic)
assert.Equal(t, 3616, usage.BillingUsage.OpenAIUsage.PromptTokensDetails.CacheWriteTokens)
}
func TestStreamResponseOpenAI2ClaudeClosesTextThinkingAndToolBlocks(t *testing.T) {
info := &convmeta.Values{
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{
LastMessagesType: convmeta.LastMessageTypeNone,
},
}
info.SendResponseCount = 1
textResponses := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{
{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
Content: ptr("hello"),
},
},
},
}, info)
require.Len(t, textResponses, 3)
assert.Equal(t, "message_start", textResponses[0].Type)
assert.Equal(t, "content_block_start", textResponses[1].Type)
assert.Equal(t, 0, textResponses[1].GetIndex())
assert.Equal(t, "content_block_delta", textResponses[2].Type)
info.SendResponseCount = 2
thinkingResponses := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{
{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
ReasoningContent: ptr("thinking"),
},
},
},
}, info)
require.Len(t, thinkingResponses, 3)
assert.Equal(t, "content_block_stop", thinkingResponses[0].Type)
assert.Equal(t, 0, thinkingResponses[0].GetIndex())
assert.Equal(t, "content_block_start", thinkingResponses[1].Type)
assert.Equal(t, 1, thinkingResponses[1].GetIndex())
assert.Equal(t, "thinking", thinkingResponses[1].ContentBlock.Type)
assert.Equal(t, "content_block_delta", thinkingResponses[2].Type)
info.SendResponseCount = 3
toolResponses := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{
{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
ToolCalls: []dto.ToolCallResponse{
{
Index: ptr(0),
ID: "call_1",
Type: "function",
Function: dto.FunctionResponse{
Name: "lookup",
Arguments: `{"q":"x"}`,
},
},
},
},
},
},
}, info)
require.Len(t, toolResponses, 3)
assert.Equal(t, "content_block_stop", toolResponses[0].Type)
assert.Equal(t, 1, toolResponses[0].GetIndex())
assert.Equal(t, "content_block_start", toolResponses[1].Type)
assert.Equal(t, 2, toolResponses[1].GetIndex())
assert.Equal(t, "tool_use", toolResponses[1].ContentBlock.Type)
assert.Equal(t, "content_block_delta", toolResponses[2].Type)
info.SendResponseCount = 4
finishResponses := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{
{FinishReason: ptr("tool_calls")},
},
Usage: &dto.Usage{
PromptTokens: 7,
CompletionTokens: 3,
TotalTokens: 10,
},
}, info)
require.Len(t, finishResponses, 3)
assert.Equal(t, "content_block_stop", finishResponses[0].Type)
assert.Equal(t, 2, finishResponses[0].GetIndex())
assert.Equal(t, "message_delta", finishResponses[1].Type)
assert.Equal(t, "tool_use", *finishResponses[1].Delta.StopReason)
require.NotNil(t, finishResponses[1].Usage)
require.NotNil(t, finishResponses[1].Usage.BillingUsage)
require.NotNil(t, finishResponses[1].Usage.BillingUsage.OpenAIUsage)
assert.Equal(t, 7, finishResponses[1].Usage.BillingUsage.OpenAIUsage.PromptTokens)
assert.Equal(t, 3, finishResponses[1].Usage.BillingUsage.OpenAIUsage.CompletionTokens)
assert.Equal(t, "message_stop", finishResponses[2].Type)
}
func TestStreamResponseOpenAI2ClaudeFirstFrameUsesUpstreamUsageWhenPresent(t *testing.T) {
info := &convmeta.Values{
EstimatePromptTokens: 32,
SendResponseCount: 1,
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{LastMessagesType: convmeta.LastMessageTypeNone},
}
responses := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("hello")},
}},
Usage: &dto.Usage{PromptTokens: 29, CompletionTokens: 0, TotalTokens: 29},
}, info)
require.NotEmpty(t, responses)
require.Equal(t, "message_start", responses[0].Type)
require.NotNil(t, responses[0].Message)
require.NotNil(t, responses[0].Message.Usage)
assert.Equal(t, 29, responses[0].Message.Usage.InputTokens)
}
func TestStreamResponseOpenAI2ClaudeMessageDeltaCorrectsEstimatedFirstFrame(t *testing.T) {
info := &convmeta.Values{
EstimatePromptTokens: 32,
SendResponseCount: 1,
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{LastMessagesType: convmeta.LastMessageTypeNone},
}
first := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("hello")},
}},
}, info)
require.NotEmpty(t, first)
require.Equal(t, "message_start", first[0].Type)
require.NotNil(t, first[0].Message.Usage)
assert.Equal(t, 32, first[0].Message.Usage.InputTokens)
info.SendResponseCount = 2
finish := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
FinishReason: ptr("stop"),
}},
Usage: &dto.Usage{PromptTokens: 29, CompletionTokens: 4, TotalTokens: 33},
}, info)
var delta *dto.ClaudeResponse
for _, resp := range finish {
if resp.Type == "message_delta" {
delta = resp
break
}
}
require.NotNil(t, delta)
require.NotNil(t, delta.Usage)
assert.Equal(t, 29, delta.Usage.InputTokens)
assert.Equal(t, 4, delta.Usage.OutputTokens)
}
func TestStreamResponseOpenAI2ClaudeMessageDeltaDoesNotZeroFirstFrameCache(t *testing.T) {
info := &convmeta.Values{
EstimatePromptTokens: 8,
SendResponseCount: 1,
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{LastMessagesType: convmeta.LastMessageTypeNone},
}
first := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("hello")},
}},
Usage: &dto.Usage{
PromptTokens: 40,
CompletionTokens: 0,
TotalTokens: 40,
PromptTokensDetails: dto.InputTokenDetails{
CachedTokens: 20,
CachedCreationTokens: 10,
},
},
}, info)
require.Equal(t, "message_start", first[0].Type)
require.NotNil(t, first[0].Message.Usage)
assert.Equal(t, 20, first[0].Message.Usage.CacheReadInputTokens)
assert.Equal(t, 10, first[0].Message.Usage.CacheCreationInputTokens)
info.SendResponseCount = 2
finish := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
FinishReason: ptr("stop"),
}},
Usage: &dto.Usage{PromptTokens: 29, CompletionTokens: 4, TotalTokens: 33},
}, info)
var delta *dto.ClaudeResponse
for _, resp := range finish {
if resp.Type == "message_delta" {
delta = resp
break
}
}
require.NotNil(t, delta)
require.NotNil(t, delta.Usage)
assert.Equal(t, 29, delta.Usage.InputTokens)
assert.Equal(t, 20, delta.Usage.CacheReadInputTokens)
assert.Equal(t, 10, delta.Usage.CacheCreationInputTokens)
}
func TestStreamResponseOpenAI2ClaudeGeminiBillingUsageOnStartAndDelta(t *testing.T) {
info := &convmeta.Values{
EstimatePromptTokens: 4994,
SendResponseCount: 1,
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{LastMessagesType: convmeta.LastMessageTypeNone},
}
firstUsage := &dto.Usage{
PromptTokens: 3868,
CompletionTokens: 0,
TotalTokens: 3868,
BillingUsage: dto.NewGeminiChatBillingUsage(&dto.GeminiUsageMetadata{
PromptTokenCount: 3868,
TotalTokenCount: 3868,
}),
}
first := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("hello")},
}},
Usage: firstUsage,
}, info)
require.NotEmpty(t, first)
require.Equal(t, "message_start", first[0].Type)
require.NotNil(t, first[0].Message)
require.NotNil(t, first[0].Message.Usage)
assert.Equal(t, 3868, first[0].Message.Usage.InputTokens)
require.NotNil(t, first[0].Message.Usage.BillingUsage)
assert.Equal(t, dto.BillingUsageSourceGeminiChat, first[0].Message.Usage.BillingUsage.Source)
assert.Equal(t, dto.BillingUsageSemanticGemini, first[0].Message.Usage.BillingUsage.Semantic)
require.NotNil(t, first[0].Message.Usage.BillingUsage.GeminiUsageMetadata)
assert.Equal(t, 3868, first[0].Message.Usage.BillingUsage.GeminiUsageMetadata.PromptTokenCount)
info.SendResponseCount = 2
finish := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
FinishReason: ptr("stop"),
}},
Usage: &dto.Usage{
PromptTokens: 3868,
CompletionTokens: 12,
TotalTokens: 3880,
BillingUsage: dto.NewGeminiChatBillingUsage(&dto.GeminiUsageMetadata{
PromptTokenCount: 3868,
CandidatesTokenCount: 12,
TotalTokenCount: 3880,
}),
},
}, info)
var delta *dto.ClaudeResponse
for _, resp := range finish {
if resp.Type == "message_delta" {
delta = resp
break
}
}
require.NotNil(t, delta)
require.NotNil(t, delta.Usage)
assert.Equal(t, 3868, delta.Usage.InputTokens)
assert.Equal(t, 12, delta.Usage.OutputTokens)
require.NotNil(t, delta.Usage.BillingUsage)
assert.Equal(t, dto.BillingUsageSourceGeminiChat, delta.Usage.BillingUsage.Source)
assert.Equal(t, dto.BillingUsageSemanticGemini, delta.Usage.BillingUsage.Semantic)
require.NotNil(t, delta.Usage.BillingUsage.GeminiUsageMetadata)
assert.Equal(t, 3868, delta.Usage.BillingUsage.GeminiUsageMetadata.PromptTokenCount)
assert.Equal(t, 12, delta.Usage.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
}
func TestNormalizeCacheCreationSplit(t *testing.T) {
cache5m, cache1h := NormalizeCacheCreationSplit(10, 3, 2)
assert.Equal(t, 8, cache5m)
assert.Equal(t, 2, cache1h)
cache5m, cache1h = NormalizeCacheCreationSplit(3, 5, 1)
assert.Equal(t, 5, cache5m)
assert.Equal(t, 1, cache1h)
}
func ptr[T any](value T) *T {
return &value
}
@@ -0,0 +1,379 @@
package oaichat
import (
"errors"
"fmt"
"math"
"strings"
"context"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
sharedgemini "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/gemini"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto.GeneralOpenAIRequest, info convmeta.Meta) (*dto.GeminiChatRequest, error) {
opts := convmeta.OptionsOf(info)
geminiRequest := dto.GeminiChatRequest{
Contents: make([]dto.GeminiChatContent, 0, len(textRequest.Messages)),
GenerationConfig: dto.GeminiChatGenerationConfig{
Temperature: textRequest.Temperature,
},
}
if textRequest.TopP != nil {
geminiRequest.GenerationConfig.TopP = kitutil.GetPointer(*textRequest.TopP)
}
if textRequest.MaxCompletionTokens != nil {
geminiRequest.GenerationConfig.MaxOutputTokens = kitutil.GetPointer(*textRequest.MaxCompletionTokens)
} else if textRequest.MaxTokens != nil {
geminiRequest.GenerationConfig.MaxOutputTokens = kitutil.GetPointer(*textRequest.MaxTokens)
}
if textRequest.Seed != nil {
geminiRequest.GenerationConfig.Seed = kitutil.GetPointer(int64(*textRequest.Seed))
}
upstreamModelName := textRequest.Model
if modelName := convmeta.UpstreamModelName(info); modelName != "" {
upstreamModelName = modelName
}
if opts.Gemini.SupportsImagineModel(upstreamModelName) {
geminiRequest.GenerationConfig.ResponseModalities = []string{
"TEXT",
"IMAGE",
}
}
if stopSequences := sharedgemini.ParseStopSequences(textRequest.Stop); len(stopSequences) > 0 {
if len(stopSequences) > 5 {
stopSequences = stopSequences[:5]
}
geminiRequest.GenerationConfig.StopSequences = stopSequences
}
if len(textRequest.ExtraBody) > 0 {
var extraBody map[string]any
if err := kitutil.Unmarshal(textRequest.ExtraBody, &extraBody); err != nil {
return nil, fmt.Errorf("invalid extra body: %w", err)
}
if googleBody, ok := extraBody["google"].(map[string]any); ok {
if _, hasErrorParam := googleBody["thinkingConfig"]; hasErrorParam {
return nil, errors.New("extra_body.google.thinkingConfig is not supported, use extra_body.google.thinking_config instead")
}
if thinkingConfig, ok := googleBody["thinking_config"].(map[string]any); ok {
if _, hasErrorParam := thinkingConfig["thinkingBudget"]; hasErrorParam {
return nil, errors.New("extra_body.google.thinking_config.thinkingBudget is not supported, use extra_body.google.thinking_config.thinking_budget instead")
}
var hasThinkingConfig bool
var tempThinkingConfig dto.GeminiThinkingConfig
if thinkingBudget, exists := thinkingConfig["thinking_budget"]; exists {
v, ok := thinkingBudget.(float64)
maxInt := int(^uint(0) >> 1)
if !ok || math.IsNaN(v) || math.IsInf(v, 0) || math.Trunc(v) != v || v > float64(maxInt) || v < float64(-maxInt-1) {
return nil, errors.New("extra_body.google.thinking_config.thinking_budget must be an integer")
}
budgetInt := int(v)
tempThinkingConfig.ThinkingBudget = kitutil.GetPointer(budgetInt)
hasThinkingConfig = true
}
if includeThoughts, exists := thinkingConfig["include_thoughts"]; exists {
if v, ok := includeThoughts.(bool); ok {
tempThinkingConfig.IncludeThoughts = kitutil.GetPointer(v)
hasThinkingConfig = true
} else {
return nil, errors.New("extra_body.google.thinking_config.include_thoughts must be a boolean")
}
}
if thinkingLevel, exists := thinkingConfig["thinking_level"]; exists {
if v, ok := thinkingLevel.(string); ok {
tempThinkingConfig.ThinkingLevel = v
hasThinkingConfig = true
} else {
return nil, errors.New("extra_body.google.thinking_config.thinking_level must be a string")
}
}
if hasThinkingConfig {
geminiRequest.GenerationConfig.ThinkingConfig = &tempThinkingConfig
}
}
if _, hasErrorParam := googleBody["imageConfig"]; hasErrorParam {
return nil, errors.New("extra_body.google.imageConfig is not supported, use extra_body.google.image_config instead")
}
if imageConfig, ok := googleBody["image_config"].(map[string]any); ok {
if _, hasErrorParam := imageConfig["aspectRatio"]; hasErrorParam {
return nil, errors.New("extra_body.google.image_config.aspectRatio is not supported, use extra_body.google.image_config.aspect_ratio instead")
}
if _, hasErrorParam := imageConfig["imageSize"]; hasErrorParam {
return nil, errors.New("extra_body.google.image_config.imageSize is not supported, use extra_body.google.image_config.image_size instead")
}
geminiImageConfig := make(map[string]any)
if aspectRatio, ok := imageConfig["aspect_ratio"]; ok {
geminiImageConfig["aspectRatio"] = aspectRatio
}
if imageSize, ok := imageConfig["image_size"]; ok {
geminiImageConfig["imageSize"] = imageSize
}
if len(geminiImageConfig) > 0 {
imageConfigBytes, err := kitutil.Marshal(geminiImageConfig)
if err != nil {
return nil, fmt.Errorf("failed to marshal image_config: %w", err)
}
geminiRequest.GenerationConfig.ImageConfig = imageConfigBytes
}
}
}
}
if err := sharedgemini.ApplyThinkingConfig(&geminiRequest, info, textRequest); err != nil {
return nil, reasoning.AsClientError(err)
}
var safetySettings []dto.GeminiChatSafetySettings
for _, category := range sharedgemini.SafetySettingCategories {
threshold := opts.Gemini.SafetySettingFor(category)
if threshold == "" {
continue
}
safetySettings = append(safetySettings, dto.GeminiChatSafetySettings{
Category: category,
Threshold: threshold,
})
}
if len(safetySettings) > 0 {
geminiRequest.SafetySettings = safetySettings
}
if textRequest.Tools != nil {
functions := make([]dto.FunctionRequest, 0, len(textRequest.Tools))
for _, tool := range textRequest.Tools {
if tool.Function.Parameters != nil {
if params, ok := tool.Function.Parameters.(map[string]any); ok {
if props, hasProps := params["properties"].(map[string]any); hasProps && len(props) == 0 {
tool.Function.Parameters = nil
}
}
}
tool.Function.Parameters = sharedgemini.CleanFunctionParameters(tool.Function.Parameters)
functions = append(functions, tool.Function)
}
geminiTools := geminiRequest.GetTools()
if len(functions) > 0 {
geminiTools = append(geminiTools, dto.GeminiChatTool{
FunctionDeclarations: functions,
})
}
geminiRequest.SetTools(geminiTools)
if textRequest.ToolChoice != nil {
geminiRequest.ToolConfig = sharedgemini.OpenAIToolChoiceToConfig(textRequest.ToolChoice)
}
}
if textRequest.ResponseFormat != nil && (textRequest.ResponseFormat.Type == "json_schema" || textRequest.ResponseFormat.Type == "json_object") {
geminiRequest.GenerationConfig.ResponseMimeType = "application/json"
if len(textRequest.ResponseFormat.JsonSchema) > 0 {
var jsonSchema dto.FormatJsonSchema
if err := kitutil.Unmarshal(textRequest.ResponseFormat.JsonSchema, &jsonSchema); err == nil {
cleanedSchema := sharedgemini.RemoveAdditionalProperties(jsonSchema.Schema, 0)
geminiRequest.GenerationConfig.ResponseSchema = cleanedSchema
}
}
}
toolCallIDs := make(map[string]string)
var systemContent []string
for _, message := range textRequest.Messages {
if message.Role == "system" || message.Role == "developer" {
systemContent = append(systemContent, message.StringContent())
continue
}
if message.Role == "tool" || message.Role == "function" {
if len(geminiRequest.Contents) == 0 || geminiRequest.Contents[len(geminiRequest.Contents)-1].Role == "model" {
geminiRequest.Contents = append(geminiRequest.Contents, dto.GeminiChatContent{
Role: "user",
})
}
parts := &geminiRequest.Contents[len(geminiRequest.Contents)-1].Parts
name := ""
if message.Name != nil {
name = *message.Name
} else if val, exists := toolCallIDs[message.ToolCallId]; exists {
name = val
}
var contentMap map[string]any
contentStr := message.StringContent()
if err := kitutil.Unmarshal([]byte(contentStr), &contentMap); err != nil {
var contentSlice []any
if err := kitutil.Unmarshal([]byte(contentStr), &contentSlice); err == nil {
contentMap = map[string]any{"result": contentSlice}
} else {
contentMap = map[string]any{"content": contentStr}
}
}
functionResp := &dto.GeminiFunctionResponse{
Name: name,
Response: contentMap,
}
if message.ToolCallId != "" {
id, err := kitutil.Marshal(message.ToolCallId)
if err != nil {
return nil, fmt.Errorf("failed to marshal function response ID: %w", err)
}
functionResp.ID = id
}
*parts = append(*parts, dto.GeminiPart{
FunctionResponse: functionResp,
})
continue
}
var parts []dto.GeminiPart
content := dto.GeminiChatContent{
Role: message.Role,
}
shouldAttachThoughtSignature := (message.Role == "assistant" || message.Role == "model") && sharedgemini.ShouldAttachThoughtSignature(opts)
signatureAttached := false
if message.ToolCalls != nil {
for _, call := range message.ParseToolCalls() {
args := map[string]any{}
if call.Function.Arguments != "" {
if kitutil.Unmarshal([]byte(call.Function.Arguments), &args) != nil {
return nil, fmt.Errorf("invalid arguments for function %s, args: %s", call.Function.Name, call.Function.Arguments)
}
}
toolCall := dto.GeminiPart{
FunctionCall: &dto.FunctionCall{
ID: call.ID,
FunctionName: call.Function.Name,
Arguments: args,
},
}
if shouldAttachThoughtSignature && !signatureAttached && sharedgemini.AttachFunctionCallThoughtSignature(opts, &toolCall) {
signatureAttached = true
}
parts = append(parts, toolCall)
toolCallIDs[call.ID] = call.Function.Name
}
}
openaiContent := message.ParseContent()
for _, part := range openaiContent {
if part.Type == dto.ContentTypeText {
if part.Text == "" {
continue
}
text := part.Text
hasMarkdownImage := false
for {
startIdx := strings.Index(text, "![")
if startIdx == -1 {
break
}
bracketIdx := strings.Index(text[startIdx:], "](data:")
if bracketIdx == -1 {
break
}
bracketIdx += startIdx
closeIdx := strings.Index(text[bracketIdx+2:], ")")
if closeIdx == -1 {
break
}
closeIdx += bracketIdx + 2
hasMarkdownImage = true
if startIdx > 0 {
textBefore := text[:startIdx]
if textBefore != "" {
parts = append(parts, dto.GeminiPart{
Text: textBefore,
})
}
}
dataURL := text[bracketIdx+2 : closeIdx]
format, base64String, err := relaymedia.DecodeBase64FileData(dataURL)
if err != nil {
return nil, fmt.Errorf("decode markdown base64 image data failed: %s", err.Error())
}
imgPart := dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: format,
Data: base64String,
},
}
if shouldAttachThoughtSignature {
sharedgemini.AttachThoughtSignatureBypass(opts, &imgPart)
}
parts = append(parts, imgPart)
text = text[closeIdx+1:]
}
if !hasMarkdownImage {
parts = append(parts, dto.GeminiPart{
Text: part.Text,
})
}
} else {
source := part.ToFileSource()
if source == nil {
continue
}
base64Data, mimeType, err := relaymedia.ResolveBase64Data(c, source, "formatting image for Gemini")
if err != nil {
return nil, fmt.Errorf("get file data from '%s' failed: %w", source.GetIdentifier(), err)
}
if _, ok := sharedgemini.SupportedMimeTypes[strings.ToLower(mimeType)]; !ok {
return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", mimeType, source.GetIdentifier(), sharedgemini.SupportedMimeTypesList())
}
parts = append(parts, dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: mimeType,
Data: base64Data,
},
})
}
}
if shouldAttachThoughtSignature && !signatureAttached && len(parts) > 0 {
sharedgemini.AttachFirstTextThoughtSignature(opts, parts)
}
content.Parts = parts
if content.Role == "assistant" {
content.Role = "model"
}
if len(content.Parts) > 0 {
geminiRequest.Contents = append(geminiRequest.Contents, content)
}
}
if len(systemContent) > 0 {
geminiRequest.SystemInstructions = &dto.GeminiChatContent{
Parts: []dto.GeminiPart{
{
Text: strings.Join(systemContent, "\n"),
},
},
}
}
return &geminiRequest, nil
}
@@ -0,0 +1,558 @@
package oaichat
import (
"fmt"
"sort"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
type ChatToGeminiStreamState struct {
toolsByChoice map[int][]*chatToGeminiStreamTool
toolByIndex map[chatToGeminiStreamToolKey]*chatToGeminiStreamTool
toolByID map[chatToGeminiStreamToolIDKey]*chatToGeminiStreamTool
finishedChoices map[int]bool
seenChoices map[int]bool
usage *dto.Usage
usageEmitted bool
finalized bool
}
type chatToGeminiStreamToolKey struct {
ChoiceIndex int
ToolIndex int
}
type chatToGeminiStreamToolIDKey struct {
ChoiceIndex int
ID string
}
type chatToGeminiStreamTool struct {
ID string
Name string
Arguments strings.Builder
Emitted bool
}
func NewChatToGeminiStreamState() *ChatToGeminiStreamState {
return &ChatToGeminiStreamState{
toolsByChoice: make(map[int][]*chatToGeminiStreamTool),
toolByIndex: make(map[chatToGeminiStreamToolKey]*chatToGeminiStreamTool),
toolByID: make(map[chatToGeminiStreamToolIDKey]*chatToGeminiStreamTool),
finishedChoices: make(map[int]bool),
seenChoices: make(map[int]bool),
}
}
// ResponseOpenAI2Gemini 将 OpenAI 响应转换为 Gemini 格式
func ResponseOpenAI2Gemini(openAIResponse *dto.OpenAITextResponse, info convmeta.Meta) *dto.GeminiChatResponse {
totalTokens := openAIResponse.TotalTokens
if totalTokens == 0 {
totalTokens = openAIResponse.PromptTokens + openAIResponse.CompletionTokens
}
geminiResponse := &dto.GeminiChatResponse{
Candidates: make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices)),
HasUsageMetadata: true,
UsageMetadata: dto.GeminiUsageMetadata{
PromptTokenCount: openAIResponse.PromptTokens,
CandidatesTokenCount: openAIResponse.CompletionTokens,
TotalTokenCount: totalTokens,
BillingUsage: openAIBillingUsageFromUsage(&openAIResponse.Usage),
},
}
if metadata, ok := geminiBillingMetadataFromOpenAIUsage(&openAIResponse.Usage); ok {
geminiResponse.UsageMetadata = metadata
}
for _, choice := range openAIResponse.Choices {
candidate := dto.GeminiChatCandidate{
Index: int64(choice.Index),
SafetyRatings: []dto.GeminiChatSafetyRating{},
}
// 设置结束原因
var finishReason string
switch choice.FinishReason {
case "stop":
finishReason = "STOP"
case "length":
finishReason = "MAX_TOKENS"
case "content_filter":
finishReason = "SAFETY"
case "tool_calls":
finishReason = "STOP"
default:
finishReason = "STOP"
}
candidate.FinishReason = &finishReason
// 转换消息内容
content := dto.GeminiChatContent{
Role: "model",
Parts: make([]dto.GeminiPart, 0),
}
textContent := choice.Message.StringContent()
if textContent != "" {
part := dto.GeminiPart{
Text: textContent,
}
content.Parts = append(content.Parts, part)
}
toolCalls := choice.Message.ParseToolCalls()
for _, toolCall := range toolCalls {
part := dto.GeminiPart{
FunctionCall: &dto.FunctionCall{
ID: toolCall.ID,
FunctionName: toolCall.Function.Name,
Arguments: geminiFunctionArguments(toolCall.Function.Arguments),
},
}
content.Parts = append(content.Parts, part)
}
candidate.Content = content
geminiResponse.Candidates = append(geminiResponse.Candidates, candidate)
}
return geminiResponse
}
// StreamResponseOpenAI2Gemini 将 OpenAI 流式响应转换为 Gemini 格式
func StreamResponseOpenAI2Gemini(openAIResponse *dto.ChatCompletionsStreamResponse, info convmeta.Meta) *dto.GeminiChatResponse {
// 检查是否有实际内容或结束标志
hasContent := false
hasFinishReason := false
for _, choice := range openAIResponse.Choices {
if len(choice.Delta.GetContentString()) > 0 || (choice.Delta.ToolCalls != nil && len(choice.Delta.ToolCalls) > 0) {
hasContent = true
}
if choice.FinishReason != nil {
hasFinishReason = true
}
}
// 如果没有实际内容且没有结束标志,跳过。主要针对 openai 流响应开头的空数据
if !hasContent && !hasFinishReason {
return nil
}
estimatePromptTokens := 0
if info != nil {
estimatePromptTokens = info.GetEstimatePromptTokens()
}
geminiResponse := &dto.GeminiChatResponse{
Candidates: make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices)),
HasUsageMetadata: true,
UsageMetadata: dto.GeminiUsageMetadata{
PromptTokenCount: estimatePromptTokens,
CandidatesTokenCount: 0, // 流式响应中可能没有完整的 usage 信息
TotalTokenCount: estimatePromptTokens,
},
}
if openAIResponse.Usage != nil {
geminiResponse.UsageMetadata.PromptTokenCount = openAIResponse.Usage.PromptTokens
geminiResponse.UsageMetadata.CandidatesTokenCount = openAIResponse.Usage.CompletionTokens
geminiResponse.UsageMetadata.TotalTokenCount = openAIResponse.Usage.TotalTokens
geminiResponse.UsageMetadata.BillingUsage = openAIBillingUsageFromUsage(openAIResponse.Usage)
if metadata, ok := geminiBillingMetadataFromOpenAIUsage(openAIResponse.Usage); ok {
geminiResponse.UsageMetadata = metadata
}
}
for _, choice := range openAIResponse.Choices {
candidate := dto.GeminiChatCandidate{
Index: int64(choice.Index),
SafetyRatings: []dto.GeminiChatSafetyRating{},
}
// 设置结束原因
if choice.FinishReason != nil {
var finishReason string
switch *choice.FinishReason {
case "stop":
finishReason = "STOP"
case "length":
finishReason = "MAX_TOKENS"
case "content_filter":
finishReason = "SAFETY"
case "tool_calls":
finishReason = "STOP"
default:
finishReason = "STOP"
}
candidate.FinishReason = &finishReason
}
// 转换消息内容
content := dto.GeminiChatContent{
Role: "model",
Parts: make([]dto.GeminiPart, 0),
}
// 处理工具调用
if choice.Delta.ToolCalls != nil {
for _, toolCall := range choice.Delta.ToolCalls {
part := dto.GeminiPart{
FunctionCall: &dto.FunctionCall{
ID: toolCall.ID,
FunctionName: toolCall.Function.Name,
Arguments: geminiFunctionArguments(toolCall.Function.Arguments),
},
}
content.Parts = append(content.Parts, part)
}
} else {
// 处理文本内容
textContent := choice.Delta.GetContentString()
if textContent != "" {
part := dto.GeminiPart{
Text: textContent,
}
content.Parts = append(content.Parts, part)
}
}
candidate.Content = content
geminiResponse.Candidates = append(geminiResponse.Candidates, candidate)
}
return geminiResponse
}
// ConvertChunk accumulates OpenAI tool-call deltas until their choice ends.
// Gemini functionCall parts are atomic, so emitting each OpenAI arguments
// fragment as a separate part would create duplicate calls with invalid input.
func (s *ChatToGeminiStreamState) ConvertChunk(openAIResponse *dto.ChatCompletionsStreamResponse, info convmeta.Meta) ([]*dto.GeminiChatResponse, error) {
if openAIResponse == nil {
return nil, nil
}
if s == nil {
return nil, fmt.Errorf("OpenAI chat to Gemini stream state is required")
}
if s.finalized {
return nil, fmt.Errorf("OpenAI chat to Gemini stream received data after finalization")
}
if s.toolsByChoice == nil {
s.toolsByChoice = make(map[int][]*chatToGeminiStreamTool)
}
if s.toolByIndex == nil {
s.toolByIndex = make(map[chatToGeminiStreamToolKey]*chatToGeminiStreamTool)
}
if s.toolByID == nil {
s.toolByID = make(map[chatToGeminiStreamToolIDKey]*chatToGeminiStreamTool)
}
if s.finishedChoices == nil {
s.finishedChoices = make(map[int]bool)
}
if s.seenChoices == nil {
s.seenChoices = make(map[int]bool)
}
if openAIResponse.Usage != nil {
s.usage = UsageFromChatUsage(openAIResponse.Usage)
}
candidates := make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices))
for _, choice := range openAIResponse.Choices {
s.seenChoices[choice.Index] = true
hasText := choice.Delta.GetContentString() != ""
hasToolDelta := len(choice.Delta.ToolCalls) > 0
hasFinish := choice.FinishReason != nil && strings.TrimSpace(*choice.FinishReason) != ""
if s.finishedChoices[choice.Index] {
if hasText || hasToolDelta {
return nil, fmt.Errorf("OpenAI chat choice %d received data after completion", choice.Index)
}
continue
}
for position, toolCall := range choice.Delta.ToolCalls {
if toolCall.Index == nil {
toolCall.SetIndex(position)
}
if err := s.appendToolCallDelta(choice.Index, toolCall); err != nil {
return nil, err
}
}
candidate := dto.GeminiChatCandidate{
Index: int64(choice.Index),
SafetyRatings: []dto.GeminiChatSafetyRating{},
Content: dto.GeminiChatContent{
Role: "model",
Parts: make([]dto.GeminiPart, 0),
},
}
if hasText {
candidate.Content.Parts = append(candidate.Content.Parts, dto.GeminiPart{Text: choice.Delta.GetContentString()})
}
if hasFinish {
parts, err := s.finishChoice(choice.Index)
if err != nil {
return nil, err
}
candidate.Content.Parts = append(candidate.Content.Parts, parts...)
finishReason := geminiFinishReason(*choice.FinishReason)
candidate.FinishReason = &finishReason
s.finishedChoices[choice.Index] = true
}
if len(candidate.Content.Parts) > 0 || candidate.FinishReason != nil {
candidates = append(candidates, candidate)
}
}
if len(candidates) == 0 {
if openAIResponse.Usage != nil && len(s.finishedChoices) > 0 {
s.usageEmitted = true
return []*dto.GeminiChatResponse{newGeminiStreamResponse(nil, s.usage, info)}, nil
}
return nil, nil
}
if openAIResponse.Usage != nil {
s.usageEmitted = true
}
return []*dto.GeminiChatResponse{newGeminiStreamResponse(candidates, openAIResponse.Usage, info)}, nil
}
// Finalize emits any calls left pending when an upstream closes without a
// finish-reason chunk. Calling Finalize more than once is safe.
func (s *ChatToGeminiStreamState) Finalize(info convmeta.Meta) ([]*dto.GeminiChatResponse, error) {
if s == nil || s.finalized {
return nil, nil
}
choiceIndexes := make(map[int]struct{})
for choiceIndex, tools := range s.toolsByChoice {
for _, tool := range tools {
if !tool.Emitted {
choiceIndexes[choiceIndex] = struct{}{}
break
}
}
}
for choiceIndex := range s.seenChoices {
if !s.finishedChoices[choiceIndex] {
choiceIndexes[choiceIndex] = struct{}{}
}
}
orderedChoices := make([]int, 0, len(choiceIndexes))
for choiceIndex := range choiceIndexes {
orderedChoices = append(orderedChoices, choiceIndex)
}
sort.Ints(orderedChoices)
candidates := make([]dto.GeminiChatCandidate, 0, len(orderedChoices))
for _, choiceIndex := range orderedChoices {
parts, err := s.finishChoice(choiceIndex)
if err != nil {
return nil, err
}
finishReason := "STOP"
candidates = append(candidates, dto.GeminiChatCandidate{
Index: int64(choiceIndex),
FinishReason: &finishReason,
SafetyRatings: []dto.GeminiChatSafetyRating{},
Content: dto.GeminiChatContent{
Role: "model",
Parts: parts,
},
})
}
if len(candidates) == 0 {
s.finalized = true
if s.usage == nil || s.usageEmitted {
return nil, nil
}
s.usageEmitted = true
return []*dto.GeminiChatResponse{newGeminiStreamResponse(nil, s.usage, info)}, nil
}
s.finalized = true
s.usageEmitted = s.usage != nil
return []*dto.GeminiChatResponse{newGeminiStreamResponse(candidates, s.usage, info)}, nil
}
func (s *ChatToGeminiStreamState) Usage() *dto.Usage {
if s == nil || s.usage == nil {
return nil
}
return UsageFromChatUsage(s.usage)
}
func (s *ChatToGeminiStreamState) SetUsage(usage *dto.Usage) {
if s == nil || usage == nil {
return
}
s.usage = UsageFromChatUsage(usage)
}
func (s *ChatToGeminiStreamState) StreamUsage() *dto.Usage {
return s.Usage()
}
func (s *ChatToGeminiStreamState) SetStreamUsage(usage *dto.Usage) {
s.SetUsage(usage)
}
func (s *ChatToGeminiStreamState) appendToolCallDelta(choiceIndex int, toolCall dto.ToolCallResponse) error {
toolIndex := 0
if toolCall.Index != nil {
toolIndex = *toolCall.Index
}
if toolIndex < 0 {
return fmt.Errorf("OpenAI chat choice %d has negative tool-call index %d", choiceIndex, toolIndex)
}
key := chatToGeminiStreamToolKey{ChoiceIndex: choiceIndex, ToolIndex: toolIndex}
incomingID := strings.TrimSpace(toolCall.ID)
var tool *chatToGeminiStreamTool
if incomingID != "" {
tool = s.toolByID[chatToGeminiStreamToolIDKey{ChoiceIndex: choiceIndex, ID: incomingID}]
}
if tool == nil {
tool = s.toolByIndex[key]
}
if tool != nil && incomingID != "" && tool.ID != "" && tool.ID != incomingID {
tool = nil
}
if tool == nil {
tool = &chatToGeminiStreamTool{}
s.toolsByChoice[choiceIndex] = append(s.toolsByChoice[choiceIndex], tool)
}
s.toolByIndex[key] = tool
// Compatibility gateways may reset a source index for the next occurrence.
// Once identity changes, keep the new occurrence active for later metadata-free deltas.
if tool.Emitted {
return fmt.Errorf("OpenAI chat choice %d tool-call index %d received data after completion", choiceIndex, toolIndex)
}
if incomingID != "" {
if tool.ID != "" && tool.ID != incomingID {
return fmt.Errorf("OpenAI chat choice %d tool-call index %d changed id from %q to %q", choiceIndex, toolIndex, tool.ID, incomingID)
}
tool.ID = incomingID
s.toolByID[chatToGeminiStreamToolIDKey{ChoiceIndex: choiceIndex, ID: incomingID}] = tool
}
incomingName := strings.TrimSpace(toolCall.Function.Name)
if incomingName != "" {
if tool.Name != "" && tool.Name != incomingName {
return fmt.Errorf("OpenAI chat choice %d tool-call index %d changed name from %q to %q", choiceIndex, toolIndex, tool.Name, incomingName)
}
tool.Name = incomingName
}
tool.Arguments.WriteString(toolCall.Function.Arguments)
return nil
}
func (s *ChatToGeminiStreamState) finishChoice(choiceIndex int) ([]dto.GeminiPart, error) {
tools := s.toolsByChoice[choiceIndex]
pending := make([]*chatToGeminiStreamTool, 0, len(tools))
for _, tool := range tools {
if !tool.Emitted {
pending = append(pending, tool)
}
}
parts := make([]dto.GeminiPart, 0, len(pending))
for _, tool := range pending {
if tool.Name == "" {
return nil, fmt.Errorf("OpenAI chat choice %d has a tool call without a function name", choiceIndex)
}
parts = append(parts, dto.GeminiPart{FunctionCall: &dto.FunctionCall{
ID: tool.ID,
FunctionName: tool.Name,
Arguments: geminiFunctionArguments(tool.Arguments.String()),
}})
}
for _, tool := range pending {
tool.Emitted = true
}
return parts, nil
}
func newGeminiStreamResponse(candidates []dto.GeminiChatCandidate, usage *dto.Usage, info convmeta.Meta) *dto.GeminiChatResponse {
if candidates == nil {
candidates = make([]dto.GeminiChatCandidate, 0)
}
estimatePromptTokens := 0
if info != nil {
estimatePromptTokens = info.GetEstimatePromptTokens()
}
response := &dto.GeminiChatResponse{
Candidates: candidates,
HasUsageMetadata: true,
UsageMetadata: dto.GeminiUsageMetadata{
PromptTokenCount: estimatePromptTokens,
TotalTokenCount: estimatePromptTokens,
},
}
if usage == nil {
return response
}
response.UsageMetadata.PromptTokenCount = usage.PromptTokens
response.UsageMetadata.CandidatesTokenCount = usage.CompletionTokens
response.UsageMetadata.TotalTokenCount = usage.TotalTokens
response.UsageMetadata.BillingUsage = openAIBillingUsageFromUsage(usage)
if metadata, ok := geminiBillingMetadataFromOpenAIUsage(usage); ok {
response.UsageMetadata = metadata
}
return response
}
func geminiFunctionArguments(raw string) map[string]any {
if strings.TrimSpace(raw) == "" || strings.TrimSpace(raw) == "null" {
return map[string]any{}
}
var args map[string]any
if err := kitutil.Unmarshal([]byte(raw), &args); err == nil && args != nil {
return args
}
// Preserve historically accepted malformed/non-object input without
// emitting a non-object Gemini args value.
return map[string]any{"arguments": raw}
}
func geminiFinishReason(finishReason string) string {
switch strings.TrimSpace(finishReason) {
case "length":
return "MAX_TOKENS"
case "content_filter":
return "SAFETY"
default:
return "STOP"
}
}
func geminiBillingMetadataFromOpenAIUsage(usage *dto.Usage) (dto.GeminiUsageMetadata, bool) {
if usage == nil || usage.BillingUsage == nil || usage.BillingUsage.GeminiUsageMetadata == nil {
return dto.GeminiUsageMetadata{}, false
}
if usage.BillingUsage.Source != dto.BillingUsageSourceGeminiChat && usage.BillingUsage.Semantic != dto.BillingUsageSemanticGemini {
return dto.GeminiUsageMetadata{}, false
}
billingUsage := dto.CloneBillingUsage(usage.BillingUsage)
if billingUsage == nil || billingUsage.GeminiUsageMetadata == nil {
return dto.GeminiUsageMetadata{}, false
}
metadata := *billingUsage.GeminiUsageMetadata
// Restore the sidecar marker on the restored native payload so the next
// hop keeps settling on the original dialect (including Estimated).
metadata.BillingUsage = dto.CloneBillingUsage(usage.BillingUsage)
return metadata, true
}
func openAIBillingUsageFromUsage(usage *dto.Usage) *dto.BillingUsage {
if usage == nil {
return nil
}
// An existing sidecar snapshots the original provider usage; carry it
// across this bridge unchanged regardless of its dialect. Only synthesize
// an OpenAI snapshot when no sidecar exists yet.
if existingBillingUsage := dto.CloneBillingUsage(usage.BillingUsage); existingBillingUsage != nil {
return existingBillingUsage
}
return dto.NewOpenAIChatBillingUsage(usage)
}
@@ -0,0 +1,112 @@
package oaichat
import (
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResponseOpenAI2GeminiMapsTextToolFinishReasonAndUsage(t *testing.T) {
msg := dto.Message{
Role: "assistant",
Content: "hello",
}
msg.SetToolCalls([]dto.ToolCallRequest{
{
ID: "call_1",
Type: "function",
Function: dto.FunctionRequest{
Name: "lookup",
Arguments: `{"q":"x"}`,
},
},
})
resp := ResponseOpenAI2Gemini(&dto.OpenAITextResponse{
Model: "gpt-test",
Choices: []dto.OpenAITextResponseChoice{
{
Index: 2,
Message: msg,
FinishReason: "length",
},
},
Usage: dto.Usage{
PromptTokens: 11,
CompletionTokens: 5,
TotalTokens: 16,
},
}, nil)
assert.Equal(t, 11, resp.UsageMetadata.PromptTokenCount)
assert.Equal(t, 5, resp.UsageMetadata.CandidatesTokenCount)
assert.Equal(t, 16, resp.UsageMetadata.TotalTokenCount)
require.NotNil(t, resp.UsageMetadata.BillingUsage)
require.NotNil(t, resp.UsageMetadata.BillingUsage.OpenAIUsage)
assert.Equal(t, dto.BillingUsageSourceOAIChat, resp.UsageMetadata.BillingUsage.Source)
assert.Equal(t, dto.BillingUsageSemanticOpenAI, resp.UsageMetadata.BillingUsage.Semantic)
assert.Equal(t, 11, resp.UsageMetadata.BillingUsage.OpenAIUsage.PromptTokens)
assert.Equal(t, 5, resp.UsageMetadata.BillingUsage.OpenAIUsage.CompletionTokens)
assert.Equal(t, 16, resp.UsageMetadata.BillingUsage.OpenAIUsage.TotalTokens)
assert.Nil(t, resp.UsageMetadata.BillingUsage.OpenAIUsage.BillingUsage)
require.Len(t, resp.Candidates, 1)
assert.Equal(t, int64(2), resp.Candidates[0].Index)
require.NotNil(t, resp.Candidates[0].FinishReason)
assert.Equal(t, "MAX_TOKENS", *resp.Candidates[0].FinishReason)
require.Len(t, resp.Candidates[0].Content.Parts, 2)
assert.Equal(t, "hello", resp.Candidates[0].Content.Parts[0].Text)
require.NotNil(t, resp.Candidates[0].Content.Parts[1].FunctionCall)
assert.Equal(t, "lookup", resp.Candidates[0].Content.Parts[1].FunctionCall.FunctionName)
assert.Equal(t, map[string]any{"q": "x"}, resp.Candidates[0].Content.Parts[1].FunctionCall.Arguments)
}
func TestStreamResponseOpenAI2GeminiMapsToolCallFinishReasonAndUsage(t *testing.T) {
resp := StreamResponseOpenAI2Gemini(&dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{
{
Index: 1,
FinishReason: geminiRespPtr("tool_calls"),
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
ToolCalls: []dto.ToolCallResponse{
{
Type: "function",
Function: dto.FunctionResponse{
Name: "lookup",
Arguments: `{"q":"x"}`,
},
},
},
},
},
},
Usage: &dto.Usage{
PromptTokens: 13,
CompletionTokens: 8,
TotalTokens: 21,
},
}, &convmeta.Values{})
require.NotNil(t, resp)
assert.Equal(t, 13, resp.UsageMetadata.PromptTokenCount)
assert.Equal(t, 8, resp.UsageMetadata.CandidatesTokenCount)
assert.Equal(t, 21, resp.UsageMetadata.TotalTokenCount)
require.NotNil(t, resp.UsageMetadata.BillingUsage)
require.NotNil(t, resp.UsageMetadata.BillingUsage.OpenAIUsage)
assert.Equal(t, 13, resp.UsageMetadata.BillingUsage.OpenAIUsage.PromptTokens)
assert.Equal(t, 8, resp.UsageMetadata.BillingUsage.OpenAIUsage.CompletionTokens)
require.Len(t, resp.Candidates, 1)
assert.Equal(t, int64(1), resp.Candidates[0].Index)
require.NotNil(t, resp.Candidates[0].FinishReason)
assert.Equal(t, "STOP", *resp.Candidates[0].FinishReason)
require.Len(t, resp.Candidates[0].Content.Parts, 1)
require.NotNil(t, resp.Candidates[0].Content.Parts[0].FunctionCall)
assert.Equal(t, "lookup", resp.Candidates[0].Content.Parts[0].FunctionCall.FunctionName)
assert.Equal(t, map[string]any{"q": "x"}, resp.Candidates[0].Content.Parts[0].FunctionCall.Arguments)
}
func geminiRespPtr[T any](value T) *T {
return &value
}
@@ -0,0 +1,424 @@
package oaichat
import (
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/samber/lo"
)
func normalizeChatImageURLToString(v any) any {
switch vv := v.(type) {
case string:
return vv
case map[string]any:
if url := kitutil.Interface2String(vv["url"]); url != "" {
return url
}
return v
case dto.MessageImageUrl:
if vv.Url != "" {
return vv.Url
}
return v
case *dto.MessageImageUrl:
if vv != nil && vv.Url != "" {
return vv.Url
}
return v
default:
return v
}
}
func convertChatResponseFormatToResponsesText(reqFormat *dto.ResponseFormat) json.RawMessage {
if reqFormat == nil || strings.TrimSpace(reqFormat.Type) == "" {
return nil
}
format := map[string]any{
"type": reqFormat.Type,
}
if reqFormat.Type == "json_schema" && len(reqFormat.JsonSchema) > 0 {
var chatSchema map[string]any
if err := kitutil.Unmarshal(reqFormat.JsonSchema, &chatSchema); err == nil {
for key, value := range chatSchema {
if key == "type" {
continue
}
format[key] = value
}
if nested, ok := format["json_schema"].(map[string]any); ok {
for key, value := range nested {
if _, exists := format[key]; !exists {
format[key] = value
}
}
delete(format, "json_schema")
}
} else {
format["json_schema"] = reqFormat.JsonSchema
}
}
textRaw, _ := kitutil.Marshal(map[string]any{
"format": format,
})
return textRaw
}
func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*dto.OpenAIResponsesRequest, error) {
if req == nil {
return nil, errors.New("request is nil")
}
if req.Model == "" {
return nil, errors.New("model is required")
}
if lo.FromPtrOr(req.N, 1) > 1 {
return nil, fmt.Errorf("n>1 is not supported in responses compatibility mode")
}
var instructionsParts []string
inputItems := make([]map[string]any, 0, len(req.Messages))
for _, msg := range req.Messages {
role := strings.TrimSpace(msg.Role)
if role == "" {
continue
}
if role == "tool" || role == "function" {
callID := strings.TrimSpace(msg.ToolCallId)
var output any
if msg.Content == nil {
output = ""
} else if msg.IsStringContent() {
output = msg.StringContent()
} else {
if b, err := kitutil.Marshal(msg.Content); err == nil {
output = string(b)
} else {
output = fmt.Sprintf("%v", msg.Content)
}
}
if callID == "" {
inputItems = append(inputItems, map[string]any{
"role": "user",
"content": fmt.Sprintf("[tool_output_missing_call_id] %v", output),
})
continue
}
inputItems = append(inputItems, map[string]any{
"type": "function_call_output",
"call_id": callID,
"output": output,
})
continue
}
// Prefer mapping system/developer messages into `instructions`.
if role == "system" || role == "developer" {
if msg.Content == nil {
continue
}
if msg.IsStringContent() {
if s := strings.TrimSpace(msg.StringContent()); s != "" {
instructionsParts = append(instructionsParts, s)
}
continue
}
parts := msg.ParseContent()
var sb strings.Builder
for _, part := range parts {
if part.Type == dto.ContentTypeText && strings.TrimSpace(part.Text) != "" {
if sb.Len() > 0 {
sb.WriteString("\n")
}
sb.WriteString(part.Text)
}
}
if s := strings.TrimSpace(sb.String()); s != "" {
instructionsParts = append(instructionsParts, s)
}
continue
}
item := map[string]any{
"role": role,
}
if msg.Content == nil {
item["content"] = ""
inputItems = append(inputItems, item)
if role == "assistant" {
for _, tc := range msg.ParseToolCalls() {
if strings.TrimSpace(tc.ID) == "" {
continue
}
if tc.Type != "" && tc.Type != "function" {
continue
}
name := strings.TrimSpace(tc.Function.Name)
if name == "" {
continue
}
inputItems = append(inputItems, map[string]any{
"type": "function_call",
"call_id": tc.ID,
"name": name,
"arguments": tc.Function.Arguments,
})
}
}
continue
}
if msg.IsStringContent() {
item["content"] = msg.StringContent()
inputItems = append(inputItems, item)
if role == "assistant" {
for _, tc := range msg.ParseToolCalls() {
if strings.TrimSpace(tc.ID) == "" {
continue
}
if tc.Type != "" && tc.Type != "function" {
continue
}
name := strings.TrimSpace(tc.Function.Name)
if name == "" {
continue
}
inputItems = append(inputItems, map[string]any{
"type": "function_call",
"call_id": tc.ID,
"name": name,
"arguments": tc.Function.Arguments,
})
}
}
continue
}
parts := msg.ParseContent()
contentParts := make([]map[string]any, 0, len(parts))
for _, part := range parts {
switch part.Type {
case dto.ContentTypeText:
textType := "input_text"
if role == "assistant" {
textType = "output_text"
}
contentParts = append(contentParts, map[string]any{
"type": textType,
"text": part.Text,
})
case dto.ContentTypeImageURL:
contentParts = append(contentParts, map[string]any{
"type": "input_image",
"image_url": normalizeChatImageURLToString(part.ImageUrl),
})
case dto.ContentTypeInputAudio:
contentParts = append(contentParts, map[string]any{
"type": "input_audio",
"input_audio": part.InputAudio,
})
case dto.ContentTypeFile:
contentParts = append(contentParts, map[string]any{
"type": "input_file",
"file": part.File,
})
case dto.ContentTypeVideoUrl:
contentParts = append(contentParts, map[string]any{
"type": "input_video",
"video_url": part.VideoUrl,
})
default:
contentParts = append(contentParts, map[string]any{
"type": part.Type,
})
}
}
item["content"] = contentParts
inputItems = append(inputItems, item)
if role == "assistant" {
for _, tc := range msg.ParseToolCalls() {
if strings.TrimSpace(tc.ID) == "" {
continue
}
if tc.Type != "" && tc.Type != "function" {
continue
}
name := strings.TrimSpace(tc.Function.Name)
if name == "" {
continue
}
inputItems = append(inputItems, map[string]any{
"type": "function_call",
"call_id": tc.ID,
"name": name,
"arguments": tc.Function.Arguments,
})
}
}
}
inputRaw, err := kitutil.Marshal(inputItems)
if err != nil {
return nil, err
}
var instructionsRaw json.RawMessage
if len(instructionsParts) > 0 {
instructions := strings.Join(instructionsParts, "\n\n")
instructionsRaw, _ = kitutil.Marshal(instructions)
}
var toolsRaw json.RawMessage
if req.Tools != nil {
tools := make([]map[string]any, 0, len(req.Tools))
for _, tool := range req.Tools {
switch tool.Type {
case "function":
tools = append(tools, map[string]any{
"type": "function",
"name": tool.Function.Name,
"description": tool.Function.Description,
"parameters": tool.Function.Parameters,
})
default:
// Best-effort: keep original tool shape for unknown types.
var m map[string]any
if b, err := kitutil.Marshal(tool); err == nil {
_ = kitutil.Unmarshal(b, &m)
}
if len(m) == 0 {
m = map[string]any{"type": tool.Type}
}
tools = append(tools, m)
}
}
toolsRaw, _ = kitutil.Marshal(tools)
}
var toolChoiceRaw json.RawMessage
if req.ToolChoice != nil {
switch v := req.ToolChoice.(type) {
case string:
toolChoiceRaw, _ = kitutil.Marshal(v)
default:
var m map[string]any
if b, err := kitutil.Marshal(v); err == nil {
_ = kitutil.Unmarshal(b, &m)
}
if m == nil {
toolChoiceRaw, _ = kitutil.Marshal(v)
} else if t, _ := m["type"].(string); t == "function" {
// Chat: {"type":"function","function":{"name":"..."}}
// Responses: {"type":"function","name":"..."}
if name, ok := m["name"].(string); ok && name != "" {
toolChoiceRaw, _ = kitutil.Marshal(map[string]any{
"type": "function",
"name": name,
})
} else if fn, ok := m["function"].(map[string]any); ok {
if name, ok := fn["name"].(string); ok && name != "" {
toolChoiceRaw, _ = kitutil.Marshal(map[string]any{
"type": "function",
"name": name,
})
} else {
toolChoiceRaw, _ = kitutil.Marshal(v)
}
} else {
toolChoiceRaw, _ = kitutil.Marshal(v)
}
} else {
toolChoiceRaw, _ = kitutil.Marshal(v)
}
}
}
var parallelToolCallsRaw json.RawMessage
if req.ParallelTooCalls != nil {
parallelToolCallsRaw, _ = kitutil.Marshal(*req.ParallelTooCalls)
}
textRaw := convertChatResponseFormatToResponsesText(req.ResponseFormat)
maxOutputTokens := lo.FromPtrOr(req.MaxTokens, uint(0))
if req.MaxCompletionTokens != nil {
maxOutputTokens = *req.MaxCompletionTokens
}
// OpenAI Responses API rejects max_output_tokens < 16 when explicitly provided.
//if maxOutputTokens > 0 && maxOutputTokens < 16 {
// maxOutputTokens = 16
//}
var topP *float64
if req.TopP != nil {
topP = kitutil.GetPointer(lo.FromPtr(req.TopP))
}
var frequencyPenaltyRaw, presencePenaltyRaw json.RawMessage
if req.FrequencyPenalty != nil {
frequencyPenaltyRaw, _ = kitutil.Marshal(req.FrequencyPenalty)
}
if req.PresencePenalty != nil {
presencePenaltyRaw, _ = kitutil.Marshal(req.PresencePenalty)
}
var promptCacheKeyRaw json.RawMessage
if req.PromptCacheKey != "" {
promptCacheKeyRaw, err = kitutil.Marshal(req.PromptCacheKey)
if err != nil {
return nil, fmt.Errorf("marshal prompt_cache_key: %w", err)
}
}
out := &dto.OpenAIResponsesRequest{
Model: req.Model,
Input: inputRaw,
Instructions: instructionsRaw,
Stream: req.Stream,
Temperature: req.Temperature,
Text: textRaw,
ToolChoice: toolChoiceRaw,
Tools: toolsRaw,
TopP: topP,
FrequencyPenalty: frequencyPenaltyRaw,
PresencePenalty: presencePenaltyRaw,
User: req.User,
ParallelToolCalls: parallelToolCallsRaw,
Store: req.Store,
Metadata: req.Metadata,
PromptCacheKey: promptCacheKeyRaw,
EnableThinking: req.EnableThinking,
ThinkingBudget: req.ThinkingBudget,
}
if req.MaxTokens != nil || req.MaxCompletionTokens != nil {
out.MaxOutputTokens = lo.ToPtr(maxOutputTokens)
}
reasoningIntent, err := reasoning.FromOpenAIChat(req)
if err != nil {
return nil, reasoning.AsClientError(err)
}
if err := reasoning.ApplyToOpenAIResponses(out, reasoningIntent); err != nil {
return nil, reasoning.AsClientError(err)
}
return out, nil
}
@@ -0,0 +1,177 @@
package oaichat
import (
"encoding/json"
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestChatCompletionsRequestToResponsesRequestInstructionsAndTools(t *testing.T) {
req := &dto.GeneralOpenAIRequest{
Model: "gpt-test",
N: lo.ToPtr(1),
Messages: []dto.Message{
{Role: "system", Content: "system rules"},
{Role: "developer", Content: "developer rules"},
{Role: "user", Content: []any{
map[string]any{"type": "text", "text": "look"},
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://example.test/a.png"}},
}},
assistantMessageWithTool("partial text", "call_1", "lookup", `{"q":"x"}`),
{Role: "tool", ToolCallId: "call_1", Content: "tool result"},
},
}
got, err := ChatCompletionsRequestToResponsesRequest(req)
require.NoError(t, err)
assert.Equal(t, "gpt-test", got.Model)
assert.Equal(t, `"system rules\n\ndeveloper rules"`, string(got.Instructions))
assert.Equal(t, "input_image", gjson.GetBytes(got.Input, "0.content.1.type").String())
assert.Equal(t, "function_call", gjson.GetBytes(got.Input, "2.type").String())
assert.Equal(t, "call_1", gjson.GetBytes(got.Input, "2.call_id").String())
assert.Equal(t, "function_call_output", gjson.GetBytes(got.Input, "3.type").String())
}
func TestChatCompletionsRequestToResponsesRequestPreservesPromptCacheKey(t *testing.T) {
t.Run("present", func(t *testing.T) {
key := "session-\"quoted\"\\path\n世界"
got, err := ChatCompletionsRequestToResponsesRequest(&dto.GeneralOpenAIRequest{
Model: "gpt-test",
Messages: []dto.Message{{Role: "user", Content: "hello"}},
PromptCacheKey: key,
})
require.NoError(t, err)
keyRaw, err := kitutil.Marshal(key)
require.NoError(t, err)
assert.Equal(t, keyRaw, []byte(got.PromptCacheKey))
encoded, err := kitutil.Marshal(got)
require.NoError(t, err)
assert.Equal(t, key, gjson.GetBytes(encoded, "prompt_cache_key").String())
})
t.Run("absent", func(t *testing.T) {
got, err := ChatCompletionsRequestToResponsesRequest(&dto.GeneralOpenAIRequest{
Model: "gpt-test",
Messages: []dto.Message{{Role: "user", Content: "hello"}},
})
require.NoError(t, err)
encoded, err := kitutil.Marshal(got)
require.NoError(t, err)
assert.False(t, gjson.GetBytes(encoded, "prompt_cache_key").Exists())
})
}
func TestChatCompletionsRequestToResponsesRequestPreservesQwenThinkingBudget(t *testing.T) {
tests := []struct {
name string
budget json.RawMessage
want int64
}{
{name: "positive budget", budget: json.RawMessage(`128`), want: 128},
{name: "zero budget", budget: json.RawMessage(`0`), want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := &dto.GeneralOpenAIRequest{
Model: "qwen-plus",
EnableThinking: json.RawMessage(`true`),
ThinkingBudget: tt.budget,
Messages: []dto.Message{
{Role: "user", Content: "hello"},
},
}
got, err := ChatCompletionsRequestToResponsesRequest(req)
require.NoError(t, err)
assert.Equal(t, tt.budget, got.ThinkingBudget)
encoded, err := kitutil.Marshal(got)
require.NoError(t, err)
assert.True(t, gjson.GetBytes(encoded, "enable_thinking").Bool())
value := gjson.GetBytes(encoded, "thinking_budget")
assert.True(t, value.Exists())
assert.Equal(t, tt.want, value.Int())
})
}
}
func TestChatCompletionsRequestToResponsesRequestRejectsMultipleChoices(t *testing.T) {
_, err := ChatCompletionsRequestToResponsesRequest(&dto.GeneralOpenAIRequest{
Model: "gpt-test",
N: lo.ToPtr(2),
})
require.Error(t, err)
assert.Contains(t, err.Error(), "n>1")
}
func TestChatCompletionsRequestToResponsesRequestPreservesPenalties(t *testing.T) {
tests := []struct {
name string
frequency *float64
frequencyWant json.RawMessage
presence *float64
presenceWant json.RawMessage
}{
{
name: "positive values",
frequency: lo.ToPtr(0.5),
frequencyWant: json.RawMessage(`0.5`),
presence: lo.ToPtr(1.5),
presenceWant: json.RawMessage(`1.5`),
},
{
name: "explicit zero values",
frequency: lo.ToPtr(0.0),
frequencyWant: json.RawMessage(`0`),
presence: lo.ToPtr(0.0),
presenceWant: json.RawMessage(`0`),
},
{
name: "unset stays nil",
frequency: nil,
presence: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ChatCompletionsRequestToResponsesRequest(&dto.GeneralOpenAIRequest{
Model: "gpt-test",
Messages: []dto.Message{{Role: "user", Content: "hello"}},
FrequencyPenalty: tt.frequency,
PresencePenalty: tt.presence,
})
require.NoError(t, err)
assert.Equal(t, tt.frequencyWant, got.FrequencyPenalty)
assert.Equal(t, tt.presenceWant, got.PresencePenalty)
})
}
}
func assistantMessageWithTool(content string, id string, name string, args string) dto.Message {
msg := dto.Message{Role: "assistant", Content: content}
msg.SetToolCalls([]dto.ToolCallRequest{
{
ID: id,
Type: "function",
Function: dto.FunctionRequest{
Name: name,
Arguments: args,
},
},
})
return msg
}
@@ -0,0 +1,269 @@
package oaichat
import (
"errors"
"fmt"
"maps"
"strings"
"time"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
const (
chatFinishReasonLength = "length"
chatFinishReasonContentFilter = "content_filter"
responsesEventCreated = "response.created"
responsesEventCompleted = "response.completed"
responsesEventIncomplete = "response.incomplete"
responsesEventOutputTextDelta = "response.output_text.delta"
responsesEventOutputTextAnnotationAdded = "response.output_text.annotation.added"
responsesEventOutputItemAdded = "response.output_item.added"
responsesEventOutputItemDone = "response.output_item.done"
responsesEventFunctionArgsDelta = "response.function_call_arguments.delta"
responsesEventFunctionArgsDone = "response.function_call_arguments.done"
responsesEventReasoningSummaryDelta = "response.reasoning_summary_text.delta"
responsesEventReasoningSummaryDone = "response.reasoning_summary_text.done"
responsesOutputTypeFunctionCall = "function_call"
responsesOutputTypeMessage = "message"
responsesOutputTypeReasoning = "reasoning"
responsesIncompleteReasonContentFilter = "content_filter"
responsesIncompleteReasonMaxTokens = "max_output_tokens"
)
func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id string) (*dto.OpenAIResponsesResponse, *dto.Usage, error) {
if resp == nil {
return nil, nil, errors.New("response is nil")
}
usage := UsageFromChatUsage(&resp.Usage)
out := &dto.OpenAIResponsesResponse{
ID: id,
Object: "response",
CreatedAt: chatCreatedAt(resp.Created),
Status: []byte(`"completed"`),
Model: resp.Model,
Output: make([]dto.ResponsesOutput, 0),
Usage: usage,
}
if len(resp.Choices) == 0 {
return out, usage, nil
}
choice := resp.Choices[0]
if status, details := ResponsesStatusFromChatFinishReason(choice.FinishReason); status != "" {
out.Status = []byte(fmt.Sprintf("%q", status))
out.IncompleteDetails = details
}
if reasoning := choice.Message.GetReasoningContent(); reasoning != "" {
out.Output = append(out.Output, dto.ResponsesOutput{
Type: responsesOutputTypeReasoning,
ID: fmt.Sprintf("%s_reasoning_0", id),
Status: responseOutputStatus(out),
Summary: []dto.ResponsesReasoningSummaryPart{
{
Type: "summary_text",
Text: reasoning,
},
},
})
}
if text := choice.Message.StringContent(); text != "" {
annotations, err := chatAnnotationsToResponses(choice.Message.Annotations)
if err != nil {
return nil, nil, err
}
out.Output = append(out.Output, dto.ResponsesOutput{
Type: responsesOutputTypeMessage,
ID: fmt.Sprintf("%s_msg_0", id),
Status: responseOutputStatus(out),
Role: "assistant",
Content: []dto.ResponsesOutputContent{
{
Type: "output_text",
Text: text,
Annotations: annotations,
},
},
})
}
for i, toolCall := range choice.Message.ParseToolCalls() {
toolOutput, err := chatToolCallToResponsesOutput(toolCall, id, i, responseOutputStatus(out))
if err != nil {
return nil, nil, err
}
out.Output = append(out.Output, toolOutput)
}
return out, usage, nil
}
func chatAnnotationsToResponses(raw []byte) ([]any, error) {
if len(raw) == 0 {
return []any{}, nil
}
var annotations []map[string]any
if err := kitutil.Unmarshal(raw, &annotations); err != nil {
return nil, fmt.Errorf("invalid Chat annotations: %w", err)
}
converted := make([]any, 0, len(annotations))
for _, annotation := range annotations {
if strings.TrimSpace(kitutil.Interface2String(annotation["type"])) != "url_citation" {
converted = append(converted, annotation)
continue
}
citation, ok := annotation["url_citation"].(map[string]any)
if !ok {
converted = append(converted, annotation)
continue
}
flattened := make(map[string]any, len(citation)+1)
flattened["type"] = "url_citation"
maps.Copy(flattened, citation)
converted = append(converted, flattened)
}
return converted, nil
}
func ResponsesStatusFromChatFinishReason(finishReason string) (string, *dto.IncompleteDetails) {
switch strings.TrimSpace(finishReason) {
case chatFinishReasonLength:
return "incomplete", &dto.IncompleteDetails{Reason: responsesIncompleteReasonMaxTokens}
case chatFinishReasonContentFilter:
return "incomplete", &dto.IncompleteDetails{Reason: responsesIncompleteReasonContentFilter}
default:
return "completed", nil
}
}
func UsageFromChatUsage(src *dto.Usage) *dto.Usage {
usage := &dto.Usage{}
if src == nil {
return usage
}
usage.UsageSemantic = src.UsageSemantic
usage.UsageSource = src.UsageSource
usage.BillingUsage = dto.CloneBillingUsage(src.BillingUsage)
if usage.BillingUsage == nil {
usage.BillingUsage = dto.NewOpenAIChatBillingUsage(src)
}
usage.Cost = src.Cost
if src.PromptTokens != 0 {
usage.PromptTokens = src.PromptTokens
usage.InputTokens = src.PromptTokens
}
if src.CompletionTokens != 0 {
usage.CompletionTokens = src.CompletionTokens
usage.OutputTokens = src.CompletionTokens
}
if src.TotalTokens != 0 {
usage.TotalTokens = src.TotalTokens
} else {
usage.TotalTokens = usage.InputTokens + usage.OutputTokens
}
if src.PromptTokensDetails.CachedTokens != 0 ||
src.PromptTokensDetails.ImageTokens != 0 ||
src.PromptTokensDetails.AudioTokens != 0 ||
src.PromptTokensDetails.CachedCreationTokens != 0 ||
src.PromptTokensDetails.CacheWriteTokens != 0 ||
src.PromptTokensDetails.TextTokens != 0 {
details := src.PromptTokensDetails
usage.InputTokensDetails = &details
}
if src.CompletionTokenDetails.ReasoningTokens != 0 ||
src.CompletionTokenDetails.TextTokens != 0 ||
src.CompletionTokenDetails.AudioTokens != 0 ||
src.CompletionTokenDetails.ImageTokens != 0 {
usage.CompletionTokenDetails = src.CompletionTokenDetails
}
usage.ClaudeCacheCreation5mTokens = src.ClaudeCacheCreation5mTokens
usage.ClaudeCacheCreation1hTokens = src.ClaudeCacheCreation1hTokens
return usage
}
func responseOutputStatus(resp *dto.OpenAIResponsesResponse) string {
if resp == nil || responseStatusString(resp) != "incomplete" {
return "completed"
}
return "incomplete"
}
func responseStatusString(resp *dto.OpenAIResponsesResponse) string {
if resp == nil || len(resp.Status) == 0 {
return ""
}
var status string
_ = kitutil.Unmarshal(resp.Status, &status)
return strings.TrimSpace(status)
}
func chatToolCallToResponsesOutput(toolCall dto.ToolCallRequest, responseID string, index int, status string) (dto.ResponsesOutput, error) {
callID := strings.TrimSpace(toolCall.ID)
if callID == "" {
callID = fmt.Sprintf("%s_call_%d", responseID, index)
}
if toolCall.Type == "" || toolCall.Type == "function" {
return dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: callID,
Status: status,
CallId: callID,
Name: toolCall.Function.Name,
Arguments: chatArgumentsRawMessage(toolCall.Function.Arguments),
}, nil
}
return dto.ResponsesOutput{
Type: toolCall.Type,
ID: callID,
Status: status,
CallId: callID,
Arguments: toolCall.Custom,
}, nil
}
func chatArgumentsRawMessage(arguments string) []byte {
raw, err := kitutil.Marshal(arguments)
if err != nil {
return []byte(`""`)
}
return raw
}
func chatCreatedAt(created any) int {
switch v := created.(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
case float32:
return int(v)
case string:
if parsed := kitutil.String2Int(v); parsed != 0 {
return parsed
}
}
return int(time.Now().Unix())
}
func responsesStreamEvent(eventType string, payload dto.ResponsesStreamResponse) ChatToResponsesStreamEvent {
payload.Type = eventType
return ChatToResponsesStreamEvent{
Type: eventType,
Payload: payload,
}
}
func intPtr(v int) *int {
return &v
}
func stringPtr(v string) *string {
return &v
}
@@ -0,0 +1,161 @@
package oaichat
import (
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestChatCompletionsResponseToResponsesPreservesTextToolCallsAndUsage(t *testing.T) {
chat := &dto.OpenAITextResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Created: 456,
Choices: []dto.OpenAITextResponseChoice{
{
Message: assistantMessageWithTool("I will call.", "call_1", "lookup", `{"q":"x"}`),
FinishReason: "tool_calls",
},
},
Usage: dto.Usage{PromptTokens: 3, CompletionTokens: 5, TotalTokens: 8},
}
resp, usage, err := ChatCompletionsResponseToResponsesResponse(chat, "resp_1")
require.NoError(t, err)
require.NotNil(t, usage)
assert.Equal(t, "resp_1", resp.ID)
assert.Equal(t, "response", resp.Object)
assert.Equal(t, `"completed"`, string(resp.Status))
assert.Equal(t, 3, resp.Usage.InputTokens)
assert.Equal(t, 5, resp.Usage.OutputTokens)
require.Len(t, resp.Output, 2)
assert.Equal(t, responsesOutputTypeMessage, resp.Output[0].Type)
assert.Equal(t, "I will call.", resp.Output[0].Content[0].Text)
assert.Equal(t, responsesOutputTypeFunctionCall, resp.Output[1].Type)
assert.Equal(t, "call_1", resp.Output[1].CallId)
assert.Equal(t, "lookup", resp.Output[1].Name)
assert.Equal(t, `"{\"q\":\"x\"}"`, string(resp.Output[1].Arguments))
}
func TestChatCompletionsResponseToResponsesEmitsReasoningSummaryBeforeText(t *testing.T) {
message := dto.Message{Role: "assistant", Content: "final answer"}
message.ReasoningContent = lo.ToPtr("thinking summary")
resp, _, err := ChatCompletionsResponseToResponsesResponse(&dto.OpenAITextResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.OpenAITextResponseChoice{
{Message: message, FinishReason: "stop"},
},
}, "resp_1")
require.NoError(t, err)
require.Len(t, resp.Output, 2)
assert.Equal(t, responsesOutputTypeReasoning, resp.Output[0].Type)
require.Len(t, resp.Output[0].Summary, 1)
assert.Equal(t, "thinking summary", resp.Output[0].Summary[0].Text)
assert.Empty(t, resp.Output[0].Content)
assert.Equal(t, responsesOutputTypeMessage, resp.Output[1].Type)
assert.Equal(t, "final answer", resp.Output[1].Content[0].Text)
}
func TestChatCompletionsResponseToResponsesMapsIncompleteFinishReasons(t *testing.T) {
tests := []struct {
name string
finishReason string
wantReason string
}{
{name: "length", finishReason: "length", wantReason: responsesIncompleteReasonMaxTokens},
{name: "content filter", finishReason: "content_filter", wantReason: responsesIncompleteReasonContentFilter},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, _, err := ChatCompletionsResponseToResponsesResponse(&dto.OpenAITextResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.OpenAITextResponseChoice{
{
Message: dto.Message{Role: "assistant", Content: "partial"},
FinishReason: tt.finishReason,
},
},
}, "resp_1")
require.NoError(t, err)
assert.Equal(t, `"incomplete"`, string(resp.Status))
require.NotNil(t, resp.IncompleteDetails)
assert.Equal(t, tt.wantReason, resp.IncompleteDetails.Reason)
require.Len(t, resp.Output, 1)
assert.Equal(t, "incomplete", resp.Output[0].Status)
})
}
}
func TestChatCompletionsStreamToResponsesEventsAggregatesUsageAndToolArgs(t *testing.T) {
state := NewChatToResponsesStreamState("resp_1", "gpt-test")
state.Created = 123
toolIndex := 0
var events []ChatToResponsesStreamEvent
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Created: 123,
Choices: []dto.ChatCompletionsStreamResponseChoice{
{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Role: "assistant"}},
},
})...)
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{
{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: lo.ToPtr("hello")}},
},
})...)
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{
{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{
{Index: &toolIndex, ID: "call_1", Type: "function", Function: dto.FunctionResponse{Name: "lookup"}},
}}},
},
})...)
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{
{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{
{Index: &toolIndex, Function: dto.FunctionResponse{Arguments: `{"q":"x"}`}},
}}},
},
})...)
finishReason := "tool_calls"
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{
{Index: 0, FinishReason: &finishReason},
},
})...)
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
Usage: &dto.Usage{PromptTokens: 2, CompletionTokens: 4, TotalTokens: 6},
})...)
events = append(events, FinalizeChatCompletionsStreamToResponses(state)...)
require.Len(t, events, 10)
assert.Equal(t, responsesEventCreated, events[0].Type)
assert.Equal(t, responsesEventOutputTextDelta, events[2].Type)
assert.Equal(t, "hello", events[2].Payload.Delta)
assert.Equal(t, responsesEventFunctionArgsDelta, events[4].Type)
assert.Equal(t, `{"q":"x"}`, events[4].Payload.Delta)
assert.Equal(t, responsesEventCompleted, events[9].Type)
require.NotNil(t, events[9].Payload.Response)
assert.Equal(t, 6, events[9].Payload.Response.Usage.TotalTokens)
require.Len(t, events[9].Payload.Response.Output, 2)
assert.Equal(t, "hello", events[9].Payload.Response.Output[0].Content[0].Text)
assert.Equal(t, `"{\"q\":\"x\"}"`, string(events[9].Payload.Response.Output[1].Arguments))
}
func mustResponsesEventsFromChatChunk(t *testing.T, state *ChatToResponsesStreamState, chunk *dto.ChatCompletionsStreamResponse) []ChatToResponsesStreamEvent {
t.Helper()
events, err := ChatCompletionsStreamChunkToResponsesEvents(chunk, state)
require.NoError(t, err)
return events
}
@@ -0,0 +1,862 @@
package oaichat
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
type ChatToResponsesStreamEvent struct {
Type string
Payload dto.ResponsesStreamResponse
}
type ChatToResponsesStreamState struct {
ID string
Model string
Created int64
Usage *dto.Usage
// EmitSequenceNumber enables the required sequence_number field for current
// Responses API SSE consumers while preserving the legacy relaykit default.
EmitSequenceNumber bool
status string
incompleteDetails *dto.IncompleteDetails
sentCreated bool
textOutputIndex int
textStarted bool
textDone bool
reasoningIndex int
reasoningStarted bool
reasoningDone bool
finalized bool
nextSequenceNumber int
nextOutputIndex int
toolsByIndex map[int]*chatToResponsesStreamTool
hostedByID map[string]*chatToResponsesHostedTool
outputOrder []chatToResponsesOutputRef
text strings.Builder
annotations []any
reasoning strings.Builder
}
type chatToResponsesStreamTool struct {
ChatIndex int
OutputIndex int
ItemID string
CallID string
Name string
Arguments strings.Builder
Done bool
}
type chatToResponsesOutputRef struct {
Kind string
ToolIndex int
HostedID string
}
// HostedToolStreamStart describes a provider-hosted tool call that is already
// being executed upstream. It is intentionally separate from function calls:
// hosted calls have their own Responses lifecycle and result fields.
type HostedToolStreamStart struct {
Type string
ID string
Name string
Action []byte
Caller []byte
ServerLabel string
}
// HostedToolStreamResult completes a previously started hosted tool call.
type HostedToolStreamResult struct {
Type string
ID string
Result []byte
ErrorCode string
IsError bool
}
type chatToResponsesHostedTool struct {
OutputIndex int
Output dto.ResponsesOutput
Done bool
}
func NewChatToResponsesStreamState(id string, model string) *ChatToResponsesStreamState {
return &ChatToResponsesStreamState{
ID: id,
Model: model,
Created: time.Now().Unix(),
Usage: &dto.Usage{},
status: "completed",
textOutputIndex: -1,
reasoningIndex: -1,
toolsByIndex: make(map[int]*chatToResponsesStreamTool),
hostedByID: make(map[string]*chatToResponsesHostedTool),
}
}
func (s *ChatToResponsesStreamState) StreamUsage() *dto.Usage {
if s == nil {
return nil
}
return s.Usage
}
func (s *ChatToResponsesStreamState) SetStreamUsage(usage *dto.Usage) {
if s != nil && usage != nil {
s.Usage = UsageFromChatUsage(usage)
}
}
func (s *ChatToResponsesStreamState) StartHostedTool(start HostedToolStreamStart) ([]ChatToResponsesStreamEvent, error) {
if s == nil {
return nil, fmt.Errorf("Chat-to-Responses stream state is required")
}
start.ID = strings.TrimSpace(start.ID)
if start.ID == "" {
return nil, fmt.Errorf("hosted-tool stream call is missing an id")
}
if _, exists := s.hostedByID[start.ID]; exists {
return nil, fmt.Errorf("duplicate hosted-tool stream call id %q", start.ID)
}
if hostedEventPrefix(start.Type) == "" {
return nil, fmt.Errorf("unsupported Responses hosted-tool output type %q", start.Type)
}
caller := strings.TrimSpace(string(start.Caller))
if caller != "" && caller != "null" {
return nil, fmt.Errorf("Responses %s cannot preserve Claude hosted-tool caller provenance", start.Type)
}
tool := &chatToResponsesHostedTool{
Output: dto.ResponsesOutput{
Type: start.Type,
ID: start.ID,
Status: "in_progress",
},
}
switch start.Type {
case "web_search_call":
action, err := dto.NormalizeResponsesWebSearchAction(start.Action)
if err != nil {
return nil, err
}
tool.Output.Action = action
case "code_interpreter_call":
return nil, fmt.Errorf("cannot map provider code execution to Responses code_interpreter_call without a container_id")
case "mcp_call":
if strings.TrimSpace(start.Name) == "" || strings.TrimSpace(start.ServerLabel) == "" {
return nil, fmt.Errorf("Responses MCP call requires name and server_label")
}
arguments, err := hostedJSONString(start.Action)
if err != nil {
return nil, fmt.Errorf("encode Responses MCP arguments: %w", err)
}
tool.Output.Name = start.Name
tool.Output.ServerLabel = start.ServerLabel
tool.Output.Arguments = arguments
}
outputIndex := s.nextHostedIndex(start.ID)
tool.OutputIndex = outputIndex
s.hostedByID[start.ID] = tool
events := s.ensureCreated()
addedItem := cloneHostedOutput(&tool.Output)
if start.Type == "mcp_call" {
addedItem.Arguments = json.RawMessage(`""`)
}
events = append(events,
s.event(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
OutputIndex: intPtr(outputIndex),
ItemID: start.ID,
Item: addedItem,
}),
s.event(hostedEventPrefix(start.Type)+".in_progress", dto.ResponsesStreamResponse{
OutputIndex: intPtr(outputIndex),
ItemID: start.ID,
}),
)
if start.Type == "web_search_call" {
events = append(events, s.event(hostedEventPrefix(start.Type)+".searching", dto.ResponsesStreamResponse{
OutputIndex: intPtr(outputIndex),
ItemID: start.ID,
}))
}
if start.Type == "mcp_call" {
arguments := dto.ResponsesArgumentsString(tool.Output.Arguments)
events = append(events,
s.event("response.mcp_call_arguments.delta", dto.ResponsesStreamResponse{
OutputIndex: intPtr(outputIndex),
ItemID: start.ID,
Delta: arguments,
}),
s.event("response.mcp_call_arguments.done", dto.ResponsesStreamResponse{
OutputIndex: intPtr(outputIndex),
ItemID: start.ID,
Arguments: kitutil.GetPointer(arguments),
}),
)
}
return events, nil
}
func (s *ChatToResponsesStreamState) CompleteHostedTool(result HostedToolStreamResult) ([]ChatToResponsesStreamEvent, error) {
if s == nil {
return nil, fmt.Errorf("Chat-to-Responses stream state is required")
}
result.ID = strings.TrimSpace(result.ID)
tool := s.hostedByID[result.ID]
if tool == nil {
return nil, fmt.Errorf("hosted-tool result references unknown call %q", result.ID)
}
if tool.Done {
return nil, fmt.Errorf("duplicate hosted-tool result for call %q", result.ID)
}
if result.Type != "" && result.Type != tool.Output.Type {
return nil, fmt.Errorf("hosted-tool result type %q does not match call type %q", result.Type, tool.Output.Type)
}
failed := result.IsError || strings.TrimSpace(result.ErrorCode) != ""
tool.Output.Status = "completed"
switch tool.Output.Type {
case "web_search_call":
// Responses exposes only the action and lifecycle status on a
// web_search_call. Claude's opaque result payload cannot be emitted
// as a top-level `results` field.
case "code_interpreter_call":
return nil, fmt.Errorf("Responses code_interpreter_call is not supported without a container_id")
case "mcp_call":
output, err := hostedResultString(result.Result)
if err != nil {
return nil, fmt.Errorf("encode Responses MCP output: %w", err)
}
tool.Output.Output = output
}
if failed {
tool.Output.Status = "failed"
errorValue := result.ErrorCode
if errorValue == "" {
errorValue = "hosted tool execution failed"
}
if tool.Output.Type == "mcp_call" {
encoded, err := kitutil.Marshal(errorValue)
if err != nil {
return nil, fmt.Errorf("marshal hosted-tool error: %w", err)
}
tool.Output.ItemError = encoded
tool.Output.Output = nil
}
}
tool.Done = true
events := make([]ChatToResponsesStreamEvent, 0, 2)
if eventType := hostedTerminalEvent(tool.Output.Type, failed); eventType != "" {
events = append(events, s.event(eventType, dto.ResponsesStreamResponse{
OutputIndex: intPtr(tool.OutputIndex),
ItemID: result.ID,
}))
}
events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
OutputIndex: intPtr(tool.OutputIndex),
ItemID: result.ID,
Item: cloneHostedOutput(&tool.Output),
}))
return events, nil
}
// Fail emits a terminal Responses error using the same event allocator as the
// rest of the stream, so callers never have to append a JSON HTTP error to an
// already-started SSE response.
func (s *ChatToResponsesStreamState) Fail(code string, message string, param string) []ChatToResponsesStreamEvent {
if s == nil || s.finalized {
return nil
}
code = strings.TrimSpace(code)
if code == "" {
code = "server_error"
}
message = strings.TrimSpace(message)
if message == "" {
message = "upstream response stream failed"
}
s.status = "failed"
events := s.ensureCreated()
events = append(events, s.doneDeltaEvents()...)
s.finalized = true
events = append(events, s.event("error", dto.ResponsesStreamResponse{
Code: code,
Message: message,
Param: param,
}))
response := s.finalResponse()
response.Error = map[string]any{
"code": code,
"message": message,
}
events = append(events, s.event("response.failed", dto.ResponsesStreamResponse{
Response: response,
}))
return events
}
func ChatCompletionsStreamChunkToResponsesEvents(chunk *dto.ChatCompletionsStreamResponse, state *ChatToResponsesStreamState) ([]ChatToResponsesStreamEvent, error) {
if chunk == nil || state == nil {
return nil, nil
}
if state.ID == "" {
state.ID = chunk.Id
}
if state.Model == "" {
state.Model = chunk.Model
}
if state.Created == 0 {
state.Created = chunk.Created
}
if chunk.Usage != nil {
state.Usage = UsageFromChatUsage(chunk.Usage)
}
events := state.ensureCreated()
for _, choice := range chunk.Choices {
if choice.Delta.GetReasoningContent() != "" {
events = append(events, state.appendReasoningDelta(choice.Delta.GetReasoningContent())...)
}
if choice.Delta.GetContentString() != "" {
events = append(events, state.appendTextDelta(choice.Delta.GetContentString())...)
}
if len(choice.Delta.Annotations) > 0 {
annotationEvents, err := state.appendAnnotationDelta(choice.Delta.Annotations)
if err != nil {
return nil, err
}
events = append(events, annotationEvents...)
}
for _, toolCall := range choice.Delta.ToolCalls {
toolEvents, err := state.appendToolCallDelta(toolCall)
if err != nil {
return nil, err
}
events = append(events, toolEvents...)
}
if choice.FinishReason != nil && strings.TrimSpace(*choice.FinishReason) != "" {
state.applyFinishReason(*choice.FinishReason)
events = append(events, state.doneDeltaEvents()...)
}
}
return events, nil
}
func (s *ChatToResponsesStreamState) ensureCreated() []ChatToResponsesStreamEvent {
if s.sentCreated {
return nil
}
s.sentCreated = true
return []ChatToResponsesStreamEvent{s.event(responsesEventCreated, dto.ResponsesStreamResponse{
Type: responsesEventCreated,
Response: s.createdResponse(),
})}
}
func FinalizeChatCompletionsStreamToResponses(state *ChatToResponsesStreamState) []ChatToResponsesStreamEvent {
if state == nil || state.finalized {
return nil
}
events := state.doneDeltaEvents()
state.finalized = true
resp := state.finalResponse()
eventType := responsesEventCompleted
if state.status == "incomplete" {
eventType = responsesEventIncomplete
}
events = append(events, state.event(eventType, dto.ResponsesStreamResponse{
Type: eventType,
Response: resp,
}))
return events
}
func (s *ChatToResponsesStreamState) UsageText() string {
if s == nil {
return ""
}
return s.text.String()
}
func (s *ChatToResponsesStreamState) appendTextDelta(delta string) []ChatToResponsesStreamEvent {
events := s.startText()
s.text.WriteString(delta)
events = append(events, s.event(responsesEventOutputTextDelta, dto.ResponsesStreamResponse{
Type: responsesEventOutputTextDelta,
OutputIndex: intPtr(s.textOutputIndex),
ContentIndex: intPtr(0),
Delta: delta,
ItemID: s.messageID(),
}))
return events
}
func (s *ChatToResponsesStreamState) startText() []ChatToResponsesStreamEvent {
if !s.textStarted {
s.textStarted = true
s.textOutputIndex = s.nextIndex("message", -1)
return []ChatToResponsesStreamEvent{s.event(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: intPtr(s.textOutputIndex),
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeMessage,
ID: s.messageID(),
Status: "in_progress",
Role: "assistant",
Content: []dto.ResponsesOutputContent{},
},
})}
}
return nil
}
func (s *ChatToResponsesStreamState) appendAnnotationDelta(raw []byte) ([]ChatToResponsesStreamEvent, error) {
annotations, err := chatAnnotationsToResponses(raw)
if err != nil {
return nil, err
}
events := s.startText()
for _, annotation := range annotations {
annotationJSON, err := kitutil.Marshal(annotation)
if err != nil {
return nil, fmt.Errorf("marshal Responses annotation: %w", err)
}
annotationIndex := len(s.annotations)
s.annotations = append(s.annotations, annotation)
events = append(events, s.event(responsesEventOutputTextAnnotationAdded, dto.ResponsesStreamResponse{
Type: responsesEventOutputTextAnnotationAdded,
OutputIndex: intPtr(s.textOutputIndex),
ContentIndex: intPtr(0),
AnnotationIndex: intPtr(annotationIndex),
Annotation: annotationJSON,
ItemID: s.messageID(),
}))
}
return events, nil
}
func (s *ChatToResponsesStreamState) appendReasoningDelta(delta string) []ChatToResponsesStreamEvent {
events := make([]ChatToResponsesStreamEvent, 0, 2)
if !s.reasoningStarted {
s.reasoningStarted = true
s.reasoningIndex = s.nextIndex("reasoning", -1)
events = append(events, s.event(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: intPtr(s.reasoningIndex),
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeReasoning,
ID: s.reasoningID(),
Status: "in_progress",
Summary: []dto.ResponsesReasoningSummaryPart{},
},
}))
}
s.reasoning.WriteString(delta)
events = append(events, s.event(responsesEventReasoningSummaryDelta, dto.ResponsesStreamResponse{
Type: responsesEventReasoningSummaryDelta,
OutputIndex: intPtr(s.reasoningIndex),
SummaryIndex: intPtr(0),
Delta: delta,
ItemID: s.reasoningID(),
}))
return events
}
func (s *ChatToResponsesStreamState) appendToolCallDelta(toolCall dto.ToolCallResponse) ([]ChatToResponsesStreamEvent, error) {
chatIndex := 0
if toolCall.Index != nil {
chatIndex = *toolCall.Index
}
incomingID := strings.TrimSpace(toolCall.ID)
tool := s.toolsByIndex[chatIndex]
events := make([]ChatToResponsesStreamEvent, 0, 2)
if tool == nil {
tool = &chatToResponsesStreamTool{
ChatIndex: chatIndex,
OutputIndex: s.nextIndex("tool", chatIndex),
CallID: incomingID,
Name: strings.TrimSpace(toolCall.Function.Name),
}
tool.ItemID = incomingID
if tool.ItemID == "" {
tool.ItemID = fmt.Sprintf("%s_call_%d", s.ID, chatIndex)
}
s.toolsByIndex[chatIndex] = tool
events = append(events, s.event(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.ItemID,
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: tool.ItemID,
Status: "in_progress",
CallId: tool.callID(),
Name: tool.Name,
Arguments: []byte(`""`),
},
}))
}
if tool.Done {
return nil, fmt.Errorf("tool-call stream index %d received data after completion", chatIndex)
}
if incomingID != "" {
if tool.CallID != "" && tool.CallID != incomingID {
return nil, fmt.Errorf("tool-call stream index %d changed id from %q to %q", chatIndex, tool.CallID, incomingID)
}
tool.CallID = incomingID
}
incomingName := strings.TrimSpace(toolCall.Function.Name)
if incomingName != "" {
if tool.Name != "" && tool.Name != incomingName {
return nil, fmt.Errorf("tool-call stream index %d changed name from %q to %q", chatIndex, tool.Name, incomingName)
}
tool.Name = incomingName
}
if toolCall.Function.Arguments != "" {
tool.Arguments.WriteString(toolCall.Function.Arguments)
events = append(events, s.event(responsesEventFunctionArgsDelta, dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDelta,
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.ItemID,
Delta: toolCall.Function.Arguments,
}))
}
return events, nil
}
func (s *ChatToResponsesStreamState) doneDeltaEvents() []ChatToResponsesStreamEvent {
events := make([]ChatToResponsesStreamEvent, 0)
status := s.outputStatus()
if s.textStarted && !s.textDone {
s.textDone = true
textDone := dto.ResponsesStreamResponse{
Type: "response.output_text.done",
OutputIndex: intPtr(s.textOutputIndex),
ContentIndex: intPtr(0),
ItemID: s.messageID(),
}
if s.EmitSequenceNumber {
textDone.Text = kitutil.GetPointer(s.text.String())
}
events = append(events, s.event("response.output_text.done", textDone))
events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemDone,
OutputIndex: intPtr(s.textOutputIndex),
Item: s.messageOutput(status),
}))
}
if s.reasoningStarted && !s.reasoningDone {
s.reasoningDone = true
reasoningDone := dto.ResponsesStreamResponse{
Type: responsesEventReasoningSummaryDone,
OutputIndex: intPtr(s.reasoningIndex),
SummaryIndex: intPtr(0),
ItemID: s.reasoningID(),
Part: &dto.ResponsesReasoningSummaryPart{
Type: "summary_text",
Text: s.reasoning.String(),
},
}
if s.EmitSequenceNumber {
reasoningDone.Text = kitutil.GetPointer(s.reasoning.String())
reasoningDone.Part = nil
}
events = append(events, s.event(responsesEventReasoningSummaryDone, reasoningDone))
events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemDone,
OutputIndex: intPtr(s.reasoningIndex),
Item: s.reasoningOutput(status),
}))
}
for _, tool := range s.sortedTools() {
if tool.Done {
continue
}
tool.Done = true
argumentsDone := dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDone,
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.ItemID,
}
if s.EmitSequenceNumber {
argumentsDone.Arguments = kitutil.GetPointer(tool.Arguments.String())
argumentsDone.Name = tool.Name
}
events = append(events, s.event(responsesEventFunctionArgsDone, argumentsDone))
events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemDone,
OutputIndex: intPtr(tool.OutputIndex),
Item: s.toolOutput(tool, status),
}))
}
for _, ref := range s.outputOrder {
if ref.Kind != "hosted" {
continue
}
tool := s.hostedByID[ref.HostedID]
if tool == nil || tool.Done {
continue
}
if s.status != "failed" {
s.status = "incomplete"
}
tool.Done = true
tool.Output.Status = "incomplete"
if s.status == "failed" {
tool.Output.Status = "failed"
errorValue, err := kitutil.Marshal("provider stream failed before hosted-tool result")
if err == nil && tool.Output.Type == "mcp_call" {
tool.Output.ItemError = errorValue
tool.Output.Output = nil
}
if eventType := hostedTerminalEvent(tool.Output.Type, true); eventType != "" {
events = append(events, s.event(eventType, dto.ResponsesStreamResponse{
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.Output.ID,
}))
}
}
events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.Output.ID,
Item: cloneHostedOutput(&tool.Output),
}))
}
return events
}
func (s *ChatToResponsesStreamState) applyFinishReason(finishReason string) {
if status, details := ResponsesStatusFromChatFinishReason(finishReason); status != "" {
s.status = status
s.incompleteDetails = details
}
}
func (s *ChatToResponsesStreamState) finalResponse() *dto.OpenAIResponsesResponse {
output := make([]dto.ResponsesOutput, 0, len(s.outputOrder))
status := s.outputStatus()
for _, ref := range s.outputOrder {
switch ref.Kind {
case "message":
output = append(output, *s.messageOutput(status))
case "reasoning":
output = append(output, *s.reasoningOutput(status))
case "tool":
if tool := s.toolsByIndex[ref.ToolIndex]; tool != nil {
output = append(output, *s.toolOutput(tool, status))
}
case "hosted":
if tool := s.hostedByID[ref.HostedID]; tool != nil {
output = append(output, *cloneHostedOutput(&tool.Output))
}
}
}
return &dto.OpenAIResponsesResponse{
ID: s.ID,
Object: "response",
CreatedAt: int(s.Created),
Status: []byte(fmt.Sprintf("%q", s.status)),
IncompleteDetails: s.incompleteDetails,
Model: s.Model,
Output: output,
Usage: s.Usage,
}
}
func (s *ChatToResponsesStreamState) createdResponse() *dto.OpenAIResponsesResponse {
return &dto.OpenAIResponsesResponse{
ID: s.ID,
Object: "response",
CreatedAt: int(s.Created),
Status: []byte(`"in_progress"`),
Model: s.Model,
Output: []dto.ResponsesOutput{},
}
}
func (s *ChatToResponsesStreamState) nextIndex(kind string, toolIndex int) int {
index := s.nextOutputIndex
s.nextOutputIndex++
s.outputOrder = append(s.outputOrder, chatToResponsesOutputRef{Kind: kind, ToolIndex: toolIndex})
return index
}
func (s *ChatToResponsesStreamState) nextHostedIndex(id string) int {
index := s.nextOutputIndex
s.nextOutputIndex++
s.outputOrder = append(s.outputOrder, chatToResponsesOutputRef{Kind: "hosted", HostedID: id})
return index
}
func (s *ChatToResponsesStreamState) sortedTools() []*chatToResponsesStreamTool {
indexes := make([]int, 0, len(s.toolsByIndex))
for index := range s.toolsByIndex {
indexes = append(indexes, index)
}
sort.Ints(indexes)
tools := make([]*chatToResponsesStreamTool, 0, len(indexes))
for _, index := range indexes {
tools = append(tools, s.toolsByIndex[index])
}
return tools
}
func (s *ChatToResponsesStreamState) outputStatus() string {
if s.status == "incomplete" || s.status == "failed" {
return "incomplete"
}
return "completed"
}
func (s *ChatToResponsesStreamState) messageID() string {
return fmt.Sprintf("%s_msg_0", s.ID)
}
func (s *ChatToResponsesStreamState) reasoningID() string {
return fmt.Sprintf("%s_reasoning_0", s.ID)
}
func (s *ChatToResponsesStreamState) messageOutput(status string) *dto.ResponsesOutput {
annotations := s.annotations
if annotations == nil {
annotations = []any{}
}
return &dto.ResponsesOutput{
Type: responsesOutputTypeMessage,
ID: s.messageID(),
Status: status,
Role: "assistant",
Content: []dto.ResponsesOutputContent{
{
Type: "output_text",
Text: s.text.String(),
Annotations: annotations,
},
},
}
}
func (s *ChatToResponsesStreamState) reasoningOutput(status string) *dto.ResponsesOutput {
return &dto.ResponsesOutput{
Type: responsesOutputTypeReasoning,
ID: s.reasoningID(),
Status: status,
Summary: []dto.ResponsesReasoningSummaryPart{
{
Type: "summary_text",
Text: s.reasoning.String(),
},
},
}
}
func (s *ChatToResponsesStreamState) toolOutput(tool *chatToResponsesStreamTool, status string) *dto.ResponsesOutput {
return &dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: tool.ItemID,
Status: status,
CallId: tool.callID(),
Name: tool.Name,
Arguments: chatArgumentsRawMessage(tool.Arguments.String()),
}
}
func (t *chatToResponsesStreamTool) callID() string {
if t == nil {
return ""
}
if t.CallID == "" {
return t.ItemID
}
return t.CallID
}
func hostedEventPrefix(outputType string) string {
switch outputType {
case "web_search_call":
return "response.web_search_call"
case "mcp_call":
return "response.mcp_call"
default:
return ""
}
}
func hostedTerminalEvent(outputType string, failed bool) string {
prefix := hostedEventPrefix(outputType)
if prefix == "" {
return ""
}
if !failed {
return prefix + ".completed"
}
// OpenAI currently defines a dedicated failed lifecycle event for MCP.
// Web search and code interpreter surface failure on output_item.done.
if outputType == "mcp_call" {
return prefix + ".failed"
}
return ""
}
func hostedJSONString(value []byte) (json.RawMessage, error) {
if len(value) == 0 {
return json.RawMessage(`""`), nil
}
if !kitutil.Valid(value) {
return nil, fmt.Errorf("invalid JSON payload")
}
encoded, err := kitutil.Marshal(string(value))
if err != nil {
return nil, err
}
return encoded, nil
}
func hostedResultString(value []byte) (json.RawMessage, error) {
if len(value) == 0 {
return json.RawMessage(`""`), nil
}
if !kitutil.Valid(value) {
return nil, fmt.Errorf("invalid JSON payload")
}
if kitutil.GetJsonType(value) == "string" {
return append(json.RawMessage(nil), value...), nil
}
return hostedJSONString(value)
}
func cloneHostedOutput(output *dto.ResponsesOutput) *dto.ResponsesOutput {
if output == nil {
return nil
}
clone := *output
clone.Action = append([]byte(nil), output.Action...)
clone.Arguments = append([]byte(nil), output.Arguments...)
clone.Code = append([]byte(nil), output.Code...)
clone.Results = append([]byte(nil), output.Results...)
clone.Outputs = append([]byte(nil), output.Outputs...)
clone.Output = append([]byte(nil), output.Output...)
clone.ItemError = append([]byte(nil), output.ItemError...)
clone.Caller = append([]byte(nil), output.Caller...)
return &clone
}
func (s *ChatToResponsesStreamState) event(eventType string, payload dto.ResponsesStreamResponse) ChatToResponsesStreamEvent {
if s.EmitSequenceNumber {
sequenceNumber := s.nextSequenceNumber
s.nextSequenceNumber++
payload.SequenceNumber = &sequenceNumber
}
return responsesStreamEvent(eventType, payload)
}
@@ -0,0 +1,263 @@
package oairesponses
import (
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
func openAIResponsesRequestFromAny(request any) (*dto.OpenAIResponsesRequest, error) {
responsesRequest, ok := request.(*dto.OpenAIResponsesRequest)
if !ok {
if value, ok := request.(dto.OpenAIResponsesRequest); ok {
responsesRequest = &value
}
}
if responsesRequest == nil {
return nil, fmt.Errorf("expected OpenAI responses request, got %T", request)
}
return responsesRequest, nil
}
func OpenAIResponsesRequestFromAny(request any) (*dto.OpenAIResponsesRequest, error) {
return openAIResponsesRequestFromAny(request)
}
func responsesInputItems(raw []byte) ([]map[string]any, error) {
if !rawJSONPresent(raw) {
return nil, nil
}
switch kitutil.GetJsonType(raw) {
case "string":
input, err := responsesJSONString(raw)
if err != nil {
return nil, fmt.Errorf("invalid input string: %w", err)
}
return []map[string]any{
{
"role": "user",
"content": input,
},
}, nil
case "array":
var items []map[string]any
if err := kitutil.Unmarshal(raw, &items); err != nil {
return nil, fmt.Errorf("invalid input array: %w", err)
}
return items, nil
default:
return nil, fmt.Errorf("unsupported responses input type %q", kitutil.GetJsonType(raw))
}
}
func InputItems(raw []byte) ([]map[string]any, error) {
return responsesInputItems(raw)
}
func responsesContentParts(content any) ([]map[string]any, error) {
switch typed := content.(type) {
case nil:
return nil, nil
case string:
return []map[string]any{{"type": "input_text", "text": typed}}, nil
case []map[string]any:
return typed, nil
case []any:
parts := make([]map[string]any, 0, len(typed))
for _, item := range typed {
switch part := item.(type) {
case string:
parts = append(parts, map[string]any{"type": "input_text", "text": part})
case map[string]any:
parts = append(parts, part)
default:
raw, err := kitutil.Marshal(part)
if err != nil {
return nil, err
}
parts = append(parts, map[string]any{"type": "input_text", "text": string(raw)})
}
}
return parts, nil
default:
raw, err := kitutil.Marshal(typed)
if err != nil {
return nil, err
}
return []map[string]any{{"type": "input_text", "text": string(raw)}}, nil
}
}
func ContentParts(content any) ([]map[string]any, error) {
return responsesContentParts(content)
}
func responsesRequestFunctionDeclarations(raw []byte) ([]dto.FunctionRequest, error) {
if !rawJSONPresent(raw) {
return nil, nil
}
var tools []map[string]any
if err := kitutil.Unmarshal(raw, &tools); err != nil {
return nil, fmt.Errorf("invalid tools: %w", err)
}
functions := make([]dto.FunctionRequest, 0, len(tools))
for _, tool := range tools {
if strings.TrimSpace(kitutil.Interface2String(tool["type"])) != "function" {
continue
}
name := strings.TrimSpace(kitutil.Interface2String(tool["name"]))
if name == "" {
continue
}
functions = append(functions, dto.FunctionRequest{
Name: name,
Description: kitutil.Interface2String(tool["description"]),
Parameters: tool["parameters"],
})
}
return functions, nil
}
func RequestFunctionDeclarations(raw []byte) ([]dto.FunctionRequest, error) {
return responsesRequestFunctionDeclarations(raw)
}
func responsesReasoningEffort(req *dto.OpenAIResponsesRequest) string {
if req == nil || req.Reasoning == nil {
return ""
}
return req.Reasoning.Effort
}
func ReasoningEffort(req *dto.OpenAIResponsesRequest) string {
return responsesReasoningEffort(req)
}
func responsesObjectValue(value any, fallbackKey string) map[string]any {
switch typed := value.(type) {
case nil:
return map[string]any{}
case map[string]any:
return typed
case string:
var object map[string]any
if err := kitutil.Unmarshal([]byte(typed), &object); err == nil {
return object
}
var array []any
if err := kitutil.Unmarshal([]byte(typed), &array); err == nil {
return map[string]any{fallbackKey: array}
}
return map[string]any{fallbackKey: typed}
case []any:
return map[string]any{fallbackKey: typed}
default:
return map[string]any{fallbackKey: typed}
}
}
func ObjectValue(value any, fallbackKey string) map[string]any {
return responsesObjectValue(value, fallbackKey)
}
func responsesGeminiResponseMap(value any) map[string]any {
switch typed := value.(type) {
case nil:
return map[string]any{}
case map[string]any:
return typed
case string:
var object map[string]any
if err := kitutil.Unmarshal([]byte(typed), &object); err == nil {
return object
}
var array []any
if err := kitutil.Unmarshal([]byte(typed), &array); err == nil {
return map[string]any{"result": array}
}
return map[string]any{"content": typed}
case []any:
return map[string]any{"result": typed}
default:
return map[string]any{"content": typed}
}
}
func GeminiResponseMap(value any) map[string]any {
return responsesGeminiResponseMap(value)
}
func responsesParallelToolCalls(raw []byte) *bool {
if !rawJSONPresent(raw) || kitutil.GetJsonType(raw) != "boolean" {
return nil
}
var parallelToolCalls bool
if err := kitutil.Unmarshal(raw, &parallelToolCalls); err != nil {
return nil
}
return &parallelToolCalls
}
func ParallelToolCalls(raw []byte) *bool {
return responsesParallelToolCalls(raw)
}
func ContentPartToFileSource(part map[string]any) types.FileSource {
partType := strings.TrimSpace(kitutil.Interface2String(part["type"]))
var data string
var mimeType string
switch partType {
case "input_image":
data, mimeType = responsesPartDataAndMime(part, "image_url", "url")
case "input_file":
data, mimeType = responsesPartDataAndMime(part, "file", "file_data", "file_url", "url")
case "input_audio":
data, mimeType = responsesPartDataAndMime(part, "input_audio", "data", "url")
if mimeType == "" {
if payload, ok := part["input_audio"].(map[string]any); ok {
if format := strings.TrimSpace(kitutil.Interface2String(payload["format"])); format != "" {
mimeType = "audio/" + format
}
}
}
case "input_video":
data, mimeType = responsesPartDataAndMime(part, "video_url", "url")
}
if data == "" {
return nil
}
return types.NewFileSourceFromData(data, mimeType)
}
func responsesPartDataAndMime(part map[string]any, keys ...string) (string, string) {
mimeType := strings.TrimSpace(kitutil.Interface2String(part["mime_type"]))
for _, key := range keys {
value, ok := part[key]
if !ok {
continue
}
switch typed := value.(type) {
case string:
if typed != "" {
return typed, mimeType
}
case map[string]any:
if mimeType == "" {
mimeType = strings.TrimSpace(kitutil.Interface2String(typed["mime_type"]))
}
for _, nestedKey := range []string{"url", "file_data", "file_url", "data"} {
if data := strings.TrimSpace(kitutil.Interface2String(typed[nestedKey])); data != "" {
return data, mimeType
}
}
}
}
return "", mimeType
}
@@ -0,0 +1,304 @@
package oairesponses
import (
"context"
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
func convertOpenAIResponsesRequestToClaudeMessages(c context.Context, info convmeta.Meta, request any) (any, error) {
responsesRequest, err := OpenAIResponsesRequestFromAny(request)
if err != nil {
return nil, err
}
return OpenAIResponsesRequestToClaudeMessages(c, info, responsesRequest)
}
func OpenAIResponsesRequestToClaudeMessages(c context.Context, info convmeta.Meta, req *dto.OpenAIResponsesRequest) (*dto.ClaudeRequest, error) {
if req == nil {
return nil, fmt.Errorf("request is nil")
}
if req.Model == "" {
return nil, fmt.Errorf("model is required")
}
if err := ValidateRequestChatUnsupportedFields(req); err != nil {
return nil, err
}
claudeRequest := &dto.ClaudeRequest{
Model: req.Model,
Temperature: req.Temperature,
TopP: req.TopP,
Stream: req.Stream,
}
if req.MaxOutputTokens != nil && *req.MaxOutputTokens > 0 {
claudeRequest.MaxTokens = kitutil.GetPointer(*req.MaxOutputTokens)
}
functions, err := RequestFunctionDeclarations(req.Tools)
if err != nil {
return nil, err
}
if len(functions) > 0 {
claudeRequest.Tools = responsesFunctionDeclarationsToClaudeTools(functions)
}
toolChoice, err := RequestToolChoiceToChat(req.ToolChoice)
if err != nil {
return nil, err
}
if toolChoice != nil || RawJSONPresent(req.ParallelToolCalls) {
claudeRequest.ToolChoice = sharedclaude.MapOpenAIToolChoice(toolChoice, ParallelToolCalls(req.ParallelToolCalls))
}
sourceReasoning, err := reasoning.FromOpenAIResponses(req)
if err != nil {
return nil, reasoning.AsClientError(err)
}
if err := sharedclaude.ApplyReasoning(c, claudeRequest, info, sourceReasoning, true); err != nil {
return nil, reasoning.AsClientError(err)
}
if claudeRequest.MaxTokens == nil {
if defaultMaxTokens, configured := convmeta.OptionsOf(info).Claude.DefaultMaxTokensFor(claudeRequest.Model); configured {
value := uint(defaultMaxTokens)
claudeRequest.MaxTokens = &value
}
}
systemMessages := make([]dto.ClaudeMediaMessage, 0)
if RawJSONPresent(req.Instructions) {
instructions, err := JSONString(req.Instructions)
if err != nil {
return nil, fmt.Errorf("invalid instructions: %w", err)
}
if strings.TrimSpace(instructions) != "" {
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
Type: "text",
Text: kitutil.GetPointer(instructions),
})
}
}
inputItems, err := InputItems(req.Input)
if err != nil {
return nil, err
}
for _, item := range inputItems {
itemType := strings.TrimSpace(kitutil.Interface2String(item["type"]))
switch itemType {
case ResponsesInputTypeFunctionCall:
claudeRequest.Messages = appendClaudeToolUse(claudeRequest.Messages, responsesFunctionCallItemToClaudeToolUse(item, "arguments"))
case ResponsesInputTypeCustomToolCall:
claudeRequest.Messages = appendClaudeToolUse(claudeRequest.Messages, responsesFunctionCallItemToClaudeToolUse(item, "input"))
case ResponsesInputTypeFunctionCallOutput, ResponsesInputTypeCustomToolOutput:
claudeRequest.Messages = appendClaudeToolResult(claudeRequest.Messages, responsesFunctionOutputItemToClaudeToolResult(item))
default:
sourceRole := strings.TrimSpace(kitutil.Interface2String(item["role"]))
role := responsesClaudeRole(sourceRole)
parts, err := responsesInputContentToClaudeMediaMessages(c, item["content"])
if err != nil {
return nil, err
}
if sourceRole == "" && len(parts) == 0 {
continue
}
if role == "system" {
for _, part := range parts {
if part.Type == "text" {
systemMessages = append(systemMessages, part)
}
}
continue
}
if len(parts) == 0 {
parts = []dto.ClaudeMediaMessage{
{
Type: "text",
Text: kitutil.GetPointer("..."),
},
}
}
claudeRequest.Messages = append(claudeRequest.Messages, dto.ClaudeMessage{
Role: role,
Content: parts,
})
}
}
if len(systemMessages) > 0 {
claudeRequest.System = systemMessages
}
if len(claudeRequest.Messages) > 0 || len(systemMessages) > 0 {
claudeRequest.Messages = ensureClaudeMessagesStartWithUser(claudeRequest.Messages)
}
// Checked last so every injection path has had its chance to satisfy the
// required field.
if claudeRequest.MaxTokens == nil {
return nil, sharedclaude.ErrMissingMaxTokens
}
return claudeRequest, nil
}
func responsesFunctionDeclarationsToClaudeTools(functions []dto.FunctionRequest) []any {
tools := make([]any, 0, len(functions))
for _, function := range functions {
tools = append(tools, &dto.Tool{
Name: function.Name,
Description: function.Description,
InputSchema: sharedclaude.FunctionParametersToInputSchema(function.Parameters),
})
}
return tools
}
func responsesInputContentToClaudeMediaMessages(c context.Context, content any) ([]dto.ClaudeMediaMessage, error) {
contentParts, err := ContentParts(content)
if err != nil {
return nil, err
}
parts := make([]dto.ClaudeMediaMessage, 0, len(contentParts))
for _, contentPart := range contentParts {
partType := strings.TrimSpace(kitutil.Interface2String(contentPart["type"]))
switch partType {
case "input_text", "output_text", "text":
text := kitutil.Interface2String(contentPart["text"])
if text != "" {
parts = append(parts, dto.ClaudeMediaMessage{
Type: "text",
Text: kitutil.GetPointer(text),
})
}
case "input_image", "input_file", "input_audio", "input_video":
source := ContentPartToFileSource(contentPart)
if source == nil {
continue
}
base64Data, mimeType, err := relaymedia.ResolveBase64Data(c, source, "formatting Responses input for Claude")
if err != nil {
return nil, fmt.Errorf("get file data failed: %s", err.Error())
}
claudePart := dto.ClaudeMediaMessage{
Source: &dto.ClaudeMessageSource{
Type: "base64",
MediaType: mimeType,
Data: base64Data,
},
}
if strings.HasPrefix(mimeType, "application/pdf") {
claudePart.Type = "document"
} else {
claudePart.Type = "image"
}
parts = append(parts, claudePart)
}
}
return parts, nil
}
func responsesFunctionCallItemToClaudeToolUse(item map[string]any, inputKey string) dto.ClaudeMediaMessage {
return dto.ClaudeMediaMessage{
Type: "tool_use",
Id: CallID(item),
Name: strings.TrimSpace(kitutil.Interface2String(item["name"])),
Input: ObjectValue(item[inputKey], inputKey),
}
}
func responsesFunctionOutputItemToClaudeToolResult(item map[string]any) dto.ClaudeMediaMessage {
return dto.ClaudeMediaMessage{
Type: "tool_result",
ToolUseId: CallID(item),
Content: responsesToolOutputValue(item["output"]),
}
}
func responsesToolOutputValue(value any) any {
if value == nil {
return ""
}
return value
}
func appendClaudeToolUse(messages []dto.ClaudeMessage, toolUse dto.ClaudeMediaMessage) []dto.ClaudeMessage {
if len(messages) > 0 && messages[len(messages)-1].Role == "assistant" {
last := messages[len(messages)-1]
parts := claudeMessageContentParts(last.Content)
parts = append(parts, toolUse)
last.Content = parts
messages[len(messages)-1] = last
return messages
}
return append(messages, dto.ClaudeMessage{
Role: "assistant",
Content: []dto.ClaudeMediaMessage{toolUse},
})
}
func appendClaudeToolResult(messages []dto.ClaudeMessage, toolResult dto.ClaudeMediaMessage) []dto.ClaudeMessage {
if len(messages) > 0 && messages[len(messages)-1].Role == "user" {
last := messages[len(messages)-1]
parts := claudeMessageContentParts(last.Content)
parts = append(parts, toolResult)
last.Content = parts
messages[len(messages)-1] = last
return messages
}
return append(messages, dto.ClaudeMessage{
Role: "user",
Content: []dto.ClaudeMediaMessage{toolResult},
})
}
func claudeMessageContentParts(content any) []dto.ClaudeMediaMessage {
switch typed := content.(type) {
case []dto.ClaudeMediaMessage:
return typed
case string:
if typed == "" {
return nil
}
return []dto.ClaudeMediaMessage{
{
Type: "text",
Text: kitutil.GetPointer(typed),
},
}
default:
parts, _ := kitutil.Any2Type[[]dto.ClaudeMediaMessage](content)
return parts
}
}
func responsesClaudeRole(role string) string {
switch role {
case "assistant":
return "assistant"
case "system", "developer":
return "system"
default:
return "user"
}
}
func ensureClaudeMessagesStartWithUser(messages []dto.ClaudeMessage) []dto.ClaudeMessage {
if len(messages) > 0 && messages[0].Role == "user" {
return messages
}
return append([]dto.ClaudeMessage{
{
Role: "user",
Content: []dto.ClaudeMediaMessage{
{
Type: "text",
Text: kitutil.GetPointer("..."),
},
},
},
}, messages...)
}
@@ -0,0 +1,156 @@
package oairesponses
import (
"encoding/json"
"errors"
"strings"
"unicode/utf8"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/reasonmap"
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
func ResponsesResponseToClaudeMessagesResponse(resp *dto.OpenAIResponsesResponse) (*dto.ClaudeResponse, *dto.Usage, error) {
if resp == nil {
return nil, nil, errors.New("response is nil")
}
usage := UsageFromResponsesUsage(resp.Usage)
claudeResponse := &dto.ClaudeResponse{
Id: resp.ID,
Type: "message",
Role: "assistant",
Model: resp.Model,
Usage: sharedclaude.UsageFromOpenAI(usage),
}
sawToolCall := false
for index := range resp.Output {
output := resp.Output[index]
if output.Type == responsesOutputTypeMessage && output.Role != "" && output.Role != "assistant" {
continue
}
switch output.Type {
case responsesOutputTypeReasoning:
if thinking := reasoningOutputText(&output); thinking != "" {
claudeResponse.Content = append(claudeResponse.Content, dto.ClaudeMediaMessage{
Type: "thinking",
Thinking: kitutil.GetPointer(thinking),
})
}
case responsesOutputTypeMessage:
for _, content := range output.Content {
if content.Type != "output_text" {
continue
}
block := dto.ClaudeMediaMessage{Type: "text", Text: kitutil.GetPointer(content.Text)}
if citations := responsesAnnotationsToClaude(content.Annotations, content.Text); len(citations) > 0 {
block.Citations, _ = kitutil.Marshal(citations)
}
claudeResponse.Content = append(claudeResponse.Content, block)
}
case responsesOutputTypeFunctionCall, responsesOutputTypeCustomToolCall:
sawToolCall = true
callID := strings.TrimSpace(output.CallId)
if callID == "" {
callID = strings.TrimSpace(output.ID)
}
claudeResponse.Content = append(claudeResponse.Content, dto.ClaudeMediaMessage{
Type: "tool_use",
Id: callID,
Name: output.Name,
Input: responsesArgumentsToClaudeInput(output.ArgumentsString()),
})
}
}
if len(claudeResponse.Content) == 0 {
claudeResponse.Content = []dto.ClaudeMediaMessage{{Type: "text", Text: kitutil.GetPointer("")}}
}
claudeResponse.StopReason = responsesClaudeStopReason(resp, sawToolCall)
return claudeResponse, usage, nil
}
func responsesArgumentsToClaudeInput(arguments string) map[string]any {
input := make(map[string]any)
if strings.TrimSpace(arguments) == "" {
return input
}
if err := kitutil.Unmarshal([]byte(arguments), &input); err == nil && input != nil {
return input
}
return map[string]any{"input": arguments}
}
func responsesClaudeStopReason(resp *dto.OpenAIResponsesResponse, sawToolCall bool) string {
if finishReason, ok := ResponsesFinishReasonFromStatus(resp); ok {
return reasonmap.OpenAIFinishReasonToClaudeStopReason(finishReason)
}
if sawToolCall {
return "tool_use"
}
return "end_turn"
}
func responsesAnnotationsToClaude(annotations []any, text string) []json.RawMessage {
citations := make([]json.RawMessage, 0, len(annotations))
for _, rawAnnotation := range annotations {
annotation, err := kitutil.Any2Type[map[string]any](rawAnnotation)
if err != nil || strings.TrimSpace(kitutil.Interface2String(annotation["type"])) != "url_citation" {
continue
}
citation := annotation
if nested, ok := annotation["url_citation"].(map[string]any); ok {
citation = nested
}
url := strings.TrimSpace(kitutil.Interface2String(citation["url"]))
if url == "" {
continue
}
converted := map[string]any{
"type": "web_search_result_location",
"url": url,
"title": strings.TrimSpace(kitutil.Interface2String(citation["title"])),
}
if citedText := kitutil.Interface2String(citation["cited_text"]); citedText != "" {
converted["cited_text"] = citedText
} else if citedText := responsesCitedText(text, citation); citedText != "" {
converted["cited_text"] = citedText
}
if encryptedIndex := kitutil.Interface2String(citation["encrypted_index"]); encryptedIndex != "" {
converted["encrypted_index"] = encryptedIndex
}
if converted["title"] == "" {
delete(converted, "title")
}
encoded, err := kitutil.Marshal(converted)
if err == nil {
citations = append(citations, encoded)
}
}
return citations
}
func responsesCitedText(text string, citation map[string]any) string {
start, startOK := responsesAnnotationIndex(citation["start_index"])
end, endOK := responsesAnnotationIndex(citation["end_index"])
if !startOK || !endOK || start < 0 || end <= start || end > utf8.RuneCountInString(text) {
return ""
}
runes := []rune(text)
return string(runes[start:end])
}
func responsesAnnotationIndex(value any) (int, bool) {
switch number := value.(type) {
case float64:
return int(number), number >= 0 && number == float64(int(number))
case int:
return number, number >= 0
case json.Number:
parsed, err := number.Int64()
return int(parsed), err == nil && parsed >= 0
default:
return 0, false
}
}
@@ -0,0 +1,500 @@
package oairesponses
import (
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
const responsesEventOutputTextDone = "response.output_text.done"
type ResponsesToClaudeStreamState struct {
ID string
Model string
Usage *dto.Usage
sentMessageStart bool
done bool
sawToolCall bool
nextBlockIndex int
blocks []*responsesClaudeStreamBlock
byOutputIndex map[int]*responsesClaudeStreamBlock
byItemID map[string]*responsesClaudeStreamBlock
lastByKind map[string]*responsesClaudeStreamBlock
usageText strings.Builder
}
type responsesClaudeStreamBlock struct {
Index int
Kind string
ItemID string
CallID string
Name string
Started bool
Stopped bool
Value strings.Builder
SentBytes int
AnnotationCount int
NeedsReasoningBreak bool
}
func NewResponsesToClaudeStreamState(id string, model string) *ResponsesToClaudeStreamState {
return &ResponsesToClaudeStreamState{
ID: strings.TrimSpace(id),
Model: strings.TrimSpace(model),
byOutputIndex: make(map[int]*responsesClaudeStreamBlock),
byItemID: make(map[string]*responsesClaudeStreamBlock),
lastByKind: make(map[string]*responsesClaudeStreamBlock),
}
}
func (s *ResponsesToClaudeStreamState) UsageText() string {
if s == nil {
return ""
}
return s.usageText.String()
}
func (s *ResponsesToClaudeStreamState) Done() bool {
return s != nil && s.done
}
func (s *ResponsesToClaudeStreamState) SetUsage(usage *dto.Usage) {
if s != nil && usage != nil {
s.Usage = usage
}
}
func (s *ResponsesToClaudeStreamState) StreamUsage() *dto.Usage {
if s == nil {
return nil
}
return s.Usage
}
func (s *ResponsesToClaudeStreamState) SetStreamUsage(usage *dto.Usage) {
s.SetUsage(usage)
}
func (s *ResponsesToClaudeStreamState) ConvertChunk(event *dto.ResponsesStreamResponse, estimatedInputTokens int) ([]*dto.ClaudeResponse, *dto.Usage, error) {
if s == nil {
return nil, nil, nil
}
if event == nil || s.done {
return nil, s.Usage, nil
}
s.applyResponseMetadata(event.Response)
switch event.Type {
case responsesEventCreated:
return s.ensureMessageStart(estimatedInputTokens), s.Usage, nil
case responsesEventReasoningSummaryDelta, responsesEventReasoningTextDelta:
block, err := s.ensureBlock(event, "thinking")
if err != nil {
return nil, s.Usage, err
}
delta := event.Delta
if block.NeedsReasoningBreak && delta != "" {
delta = separatedResponsesDelta(delta)
block.NeedsReasoningBreak = false
}
return s.appendDelta(block, delta, estimatedInputTokens), s.Usage, nil
case responsesEventReasoningSummaryDone, responsesEventReasoningTextDone:
block, err := s.ensureBlock(event, "thinking")
if err != nil {
return nil, s.Usage, err
}
var responses []*dto.ClaudeResponse
if event.Text != nil {
responses = append(responses, s.mergeFinalValue(block, *event.Text, estimatedInputTokens)...)
}
if block.Value.Len() > 0 {
block.NeedsReasoningBreak = true
}
return responses, s.Usage, nil
case responsesEventOutputTextDelta:
block, err := s.ensureBlock(event, "text")
if err != nil {
return nil, s.Usage, err
}
return s.appendDelta(block, event.Delta, estimatedInputTokens), s.Usage, nil
case responsesEventOutputTextDone:
block, err := s.ensureBlock(event, "text")
if err != nil {
return nil, s.Usage, err
}
if event.Text == nil {
return nil, s.Usage, nil
}
return s.mergeFinalValue(block, *event.Text, estimatedInputTokens), s.Usage, nil
case responsesEventOutputTextAnnotationAdded:
block, err := s.ensureBlock(event, "text")
if err != nil {
return nil, s.Usage, err
}
var annotation any
if err := kitutil.Unmarshal(event.Annotation, &annotation); err != nil {
return nil, s.Usage, fmt.Errorf("invalid Responses stream annotation: %w", err)
}
return s.appendAnnotations(block, []any{annotation}, estimatedInputTokens, false), s.Usage, nil
case responsesEventOutputItemAdded, responsesEventOutputItemDone:
responses, err := s.applyOutputItem(event, estimatedInputTokens, event.Type == responsesEventOutputItemDone)
return responses, s.Usage, err
case responsesEventFunctionArgsDelta, responsesEventCustomToolInputDelta:
block, err := s.ensureBlock(event, "tool_use")
if err != nil {
return nil, s.Usage, err
}
return s.appendDelta(block, event.Delta, estimatedInputTokens), s.Usage, nil
case responsesEventFunctionArgsDone, responsesEventCustomToolInputDone:
block, err := s.ensureBlock(event, "tool_use")
if err != nil {
return nil, s.Usage, err
}
if event.Arguments == nil {
return nil, s.Usage, nil
}
return s.mergeFinalValue(block, *event.Arguments, estimatedInputTokens), s.Usage, nil
case responsesEventCompleted, responsesEventDone, responsesEventIncomplete:
responses, err := s.finish(event.Response, estimatedInputTokens)
return responses, s.Usage, err
case responsesEventFailed, responsesEventError:
message := strings.TrimSpace(event.Message)
if message == "" {
message = event.Type
}
return nil, s.Usage, fmt.Errorf("responses stream error: %s", message)
default:
return nil, s.Usage, nil
}
}
func (s *ResponsesToClaudeStreamState) Finalize(estimatedInputTokens int) ([]*dto.ClaudeResponse, error) {
if s == nil || s.done {
return nil, nil
}
return s.finish(nil, estimatedInputTokens)
}
func (s *ResponsesToClaudeStreamState) applyResponseMetadata(response *dto.OpenAIResponsesResponse) {
if s == nil || response == nil {
return
}
if response.ID != "" {
s.ID = response.ID
}
if response.Model != "" {
s.Model = response.Model
}
if response.Usage != nil {
s.Usage = dto.MergeUsageNonZero(s.Usage, UsageFromResponsesUsage(response.Usage))
}
}
func (s *ResponsesToClaudeStreamState) ensureMessageStart(estimatedInputTokens int) []*dto.ClaudeResponse {
if s.sentMessageStart {
return nil
}
s.sentMessageStart = true
inputTokens := estimatedInputTokens
if s.Usage != nil {
if usage := sharedclaude.UsageFromOpenAI(s.Usage); usage != nil {
inputTokens = usage.InputTokens
}
}
message := &dto.ClaudeMediaMessage{
Id: s.ID,
Type: "message",
Role: "assistant",
Model: s.Model,
Usage: &dto.ClaudeUsage{InputTokens: inputTokens},
}
message.SetContent(make([]any, 0))
return []*dto.ClaudeResponse{{Type: "message_start", Message: message}}
}
func (s *ResponsesToClaudeStreamState) ensureBlock(event *dto.ResponsesStreamResponse, kind string) (*responsesClaudeStreamBlock, error) {
block := s.findBlock(event)
if block == nil {
if last := s.lastByKind[kind]; last != nil && !last.Stopped && event.OutputIndex == nil && responseStreamEventItemID(event) == "" {
block = last
}
}
if block == nil {
block = &responsesClaudeStreamBlock{Index: s.nextBlockIndex, Kind: kind}
s.nextBlockIndex++
s.blocks = append(s.blocks, block)
}
if block.Kind == "" {
block.Kind = kind
}
if block.Kind != kind {
return nil, fmt.Errorf("Responses output item changed from %s to %s", block.Kind, kind)
}
s.applyBlockMetadata(block, event)
s.lastByKind[kind] = block
return block, nil
}
func (s *ResponsesToClaudeStreamState) findBlock(event *dto.ResponsesStreamResponse) *responsesClaudeStreamBlock {
if event == nil {
return nil
}
if event.OutputIndex != nil {
if block := s.byOutputIndex[*event.OutputIndex]; block != nil {
return block
}
}
if itemID := responseStreamEventItemID(event); itemID != "" {
return s.byItemID[itemID]
}
return nil
}
func (s *ResponsesToClaudeStreamState) applyBlockMetadata(block *responsesClaudeStreamBlock, event *dto.ResponsesStreamResponse) {
if block == nil || event == nil {
return
}
if event.OutputIndex != nil {
s.byOutputIndex[*event.OutputIndex] = block
}
if itemID := responseStreamEventItemID(event); itemID != "" {
block.ItemID = itemID
s.byItemID[itemID] = block
}
if event.Item == nil {
return
}
if callID := strings.TrimSpace(event.Item.CallId); callID != "" {
block.CallID = callID
} else if block.CallID == "" {
block.CallID = strings.TrimSpace(event.Item.ID)
}
if name := strings.TrimSpace(event.Item.Name); name != "" {
block.Name = name
}
}
func (s *ResponsesToClaudeStreamState) startBlock(block *responsesClaudeStreamBlock, estimatedInputTokens int) []*dto.ClaudeResponse {
if block == nil || block.Started || block.Stopped {
return nil
}
var content dto.ClaudeMediaMessage
switch block.Kind {
case "text":
content = dto.ClaudeMediaMessage{Type: "text", Text: kitutil.GetPointer("")}
case "thinking":
content = dto.ClaudeMediaMessage{Type: "thinking", Thinking: kitutil.GetPointer("")}
case "tool_use":
if block.Name == "" {
return nil
}
callID := block.CallID
if callID == "" {
callID = block.ItemID
}
content = dto.ClaudeMediaMessage{Type: "tool_use", Id: callID, Name: block.Name, Input: map[string]any{}}
s.sawToolCall = true
default:
return nil
}
block.Started = true
responses := s.ensureMessageStart(estimatedInputTokens)
index := block.Index
responses = append(responses, &dto.ClaudeResponse{Type: "content_block_start", Index: &index, ContentBlock: &content})
return responses
}
func (s *ResponsesToClaudeStreamState) appendDelta(block *responsesClaudeStreamBlock, delta string, estimatedInputTokens int) []*dto.ClaudeResponse {
if block == nil || block.Stopped || delta == "" {
return nil
}
block.Value.WriteString(delta)
return s.flushBlock(block, estimatedInputTokens)
}
func (s *ResponsesToClaudeStreamState) mergeFinalValue(block *responsesClaudeStreamBlock, finalValue string, estimatedInputTokens int) []*dto.ClaudeResponse {
if block == nil || block.Stopped {
return nil
}
current := block.Value.String()
if current == "" {
block.Value.WriteString(finalValue)
} else if strings.HasPrefix(finalValue, current) {
block.Value.WriteString(finalValue[len(current):])
}
return s.flushBlock(block, estimatedInputTokens)
}
func (s *ResponsesToClaudeStreamState) flushBlock(block *responsesClaudeStreamBlock, estimatedInputTokens int) []*dto.ClaudeResponse {
if block == nil || block.Stopped {
return nil
}
responses := s.startBlock(block, estimatedInputTokens)
if !block.Started {
return responses
}
value := block.Value.String()
if block.SentBytes >= len(value) {
return responses
}
delta := value[block.SentBytes:]
block.SentBytes = len(value)
s.usageText.WriteString(delta)
index := block.Index
media := &dto.ClaudeMediaMessage{}
switch block.Kind {
case "text":
media.Type = "text_delta"
media.Text = &delta
case "thinking":
media.Type = "thinking_delta"
media.Thinking = &delta
case "tool_use":
media.Type = "input_json_delta"
media.PartialJson = &delta
}
responses = append(responses, &dto.ClaudeResponse{Type: "content_block_delta", Index: &index, Delta: media})
return responses
}
func (s *ResponsesToClaudeStreamState) stopBlock(block *responsesClaudeStreamBlock, estimatedInputTokens int) []*dto.ClaudeResponse {
if block == nil || block.Stopped {
return nil
}
responses := s.flushBlock(block, estimatedInputTokens)
responses = append(responses, s.startBlock(block, estimatedInputTokens)...)
if !block.Started {
return responses
}
block.Stopped = true
index := block.Index
return append(responses, &dto.ClaudeResponse{Type: "content_block_stop", Index: &index})
}
func (s *ResponsesToClaudeStreamState) applyOutputItem(event *dto.ResponsesStreamResponse, estimatedInputTokens int, stop bool) ([]*dto.ClaudeResponse, error) {
if event == nil || event.Item == nil {
return nil, nil
}
item := event.Item
var kind string
switch item.Type {
case responsesOutputTypeReasoning:
kind = "thinking"
case responsesOutputTypeMessage:
if item.Role != "" && item.Role != "assistant" {
return nil, nil
}
kind = "text"
case responsesOutputTypeFunctionCall, responsesOutputTypeCustomToolCall:
kind = "tool_use"
default:
return nil, nil
}
block, err := s.ensureBlock(event, kind)
if err != nil {
return nil, err
}
var responses []*dto.ClaudeResponse
switch kind {
case "thinking":
responses = append(responses, s.mergeFinalValue(block, reasoningOutputText(item), estimatedInputTokens)...)
case "text":
var text strings.Builder
var annotations []any
for _, content := range item.Content {
if content.Type != "output_text" {
continue
}
text.WriteString(content.Text)
annotations = append(annotations, content.Annotations...)
}
responses = append(responses, s.mergeFinalValue(block, text.String(), estimatedInputTokens)...)
responses = append(responses, s.appendAnnotations(block, annotations, estimatedInputTokens, true)...)
case "tool_use":
responses = append(responses, s.mergeFinalValue(block, item.ArgumentsString(), estimatedInputTokens)...)
}
if stop {
responses = append(responses, s.stopBlock(block, estimatedInputTokens)...)
}
return responses, nil
}
func (s *ResponsesToClaudeStreamState) appendAnnotations(block *responsesClaudeStreamBlock, annotations []any, estimatedInputTokens int, snapshot bool) []*dto.ClaudeResponse {
if block == nil || block.Kind != "text" || block.Stopped || len(annotations) == 0 {
return nil
}
remaining := annotations
if snapshot {
if len(annotations) <= block.AnnotationCount {
return nil
}
remaining = annotations[block.AnnotationCount:]
block.AnnotationCount = len(annotations)
} else {
block.AnnotationCount += len(annotations)
}
citations := responsesAnnotationsToClaude(remaining, block.Value.String())
if len(citations) == 0 {
return nil
}
responses := s.startBlock(block, estimatedInputTokens)
index := block.Index
for _, citation := range citations {
responses = append(responses, &dto.ClaudeResponse{
Type: "content_block_delta",
Index: &index,
Delta: &dto.ClaudeMediaMessage{Type: "citations_delta", Citation: citation},
})
}
return responses
}
func (s *ResponsesToClaudeStreamState) finish(response *dto.OpenAIResponsesResponse, estimatedInputTokens int) ([]*dto.ClaudeResponse, error) {
if s.done {
return nil, nil
}
s.applyResponseMetadata(response)
responses := make([]*dto.ClaudeResponse, 0)
if response != nil {
for outputIndex := range response.Output {
index := outputIndex
item := response.Output[outputIndex]
event := &dto.ResponsesStreamResponse{OutputIndex: &index, ItemID: item.ID, Item: &item}
itemResponses, err := s.applyOutputItem(event, estimatedInputTokens, true)
if err != nil {
return nil, err
}
responses = append(responses, itemResponses...)
}
}
for _, block := range s.blocks {
responses = append(responses, s.stopBlock(block, estimatedInputTokens)...)
}
responses = append(responses, s.ensureMessageStart(estimatedInputTokens)...)
stopReason := responsesClaudeStopReason(response, s.sawToolCall)
usage := sharedclaude.UsageFromOpenAI(s.Usage)
responses = append(responses,
&dto.ClaudeResponse{
Type: "message_delta",
Usage: usage,
Delta: &dto.ClaudeMediaMessage{StopReason: &stopReason},
},
&dto.ClaudeResponse{Type: "message_stop"},
)
s.done = true
return responses, nil
}
func separatedResponsesDelta(delta string) string {
if strings.HasPrefix(delta, "\n\n") {
return delta
}
if strings.HasPrefix(delta, "\n") {
return "\n" + delta
}
return "\n\n" + delta
}
@@ -0,0 +1,130 @@
package oairesponses
import (
"strings"
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResponsesToClaudeStreamDoesNotRepeatBlocksFromDoneAndCompletedEvents(t *testing.T) {
state := NewResponsesToClaudeStreamState("", "")
arguments := `{"q":"x"}`
argumentRaw, err := kitutil.Marshal(arguments)
require.NoError(t, err)
statusRaw, err := kitutil.Marshal("completed")
require.NoError(t, err)
reasoningItem := dto.ResponsesOutput{
Type: responsesOutputTypeReasoning,
ID: "rs_1",
Summary: []dto.ResponsesReasoningSummaryPart{{Type: "summary_text", Text: "plan"}},
}
messageItem := dto.ResponsesOutput{
Type: responsesOutputTypeMessage,
ID: "msg_1",
Role: "assistant",
Content: []dto.ResponsesOutputContent{{Type: "output_text", Text: "hello"}},
}
toolItem := dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: "fc_1",
CallId: "call_1",
Name: "lookup",
Arguments: argumentRaw,
}
events := []*dto.ResponsesStreamResponse{
{Type: responsesEventCreated, Response: &dto.OpenAIResponsesResponse{ID: "resp_1", Model: "gpt-test"}},
{Type: responsesEventOutputItemAdded, OutputIndex: kitutil.GetPointer(0), ItemID: reasoningItem.ID, Item: &dto.ResponsesOutput{Type: reasoningItem.Type, ID: reasoningItem.ID}},
{Type: responsesEventReasoningSummaryDelta, OutputIndex: kitutil.GetPointer(0), ItemID: reasoningItem.ID, Delta: "plan"},
{Type: responsesEventReasoningSummaryDone, OutputIndex: kitutil.GetPointer(0), ItemID: reasoningItem.ID, Text: kitutil.GetPointer("plan")},
{Type: responsesEventOutputItemDone, OutputIndex: kitutil.GetPointer(0), ItemID: reasoningItem.ID, Item: &reasoningItem},
{Type: responsesEventOutputItemAdded, OutputIndex: kitutil.GetPointer(1), ItemID: messageItem.ID, Item: &dto.ResponsesOutput{Type: messageItem.Type, ID: messageItem.ID, Role: "assistant"}},
{Type: responsesEventOutputTextDelta, OutputIndex: kitutil.GetPointer(1), ItemID: messageItem.ID, Delta: "hello"},
{Type: responsesEventOutputTextDone, OutputIndex: kitutil.GetPointer(1), ItemID: messageItem.ID, Text: kitutil.GetPointer("hello")},
{Type: responsesEventOutputItemDone, OutputIndex: kitutil.GetPointer(1), ItemID: messageItem.ID, Item: &messageItem},
{Type: responsesEventOutputItemAdded, OutputIndex: kitutil.GetPointer(2), ItemID: toolItem.ID, Item: &dto.ResponsesOutput{Type: toolItem.Type, ID: toolItem.ID, CallId: toolItem.CallId, Name: toolItem.Name}},
{Type: responsesEventFunctionArgsDelta, OutputIndex: kitutil.GetPointer(2), ItemID: toolItem.ID, Delta: `{"q":`},
{Type: responsesEventFunctionArgsDelta, OutputIndex: kitutil.GetPointer(2), ItemID: toolItem.ID, Delta: `"x"}`},
{Type: responsesEventFunctionArgsDone, OutputIndex: kitutil.GetPointer(2), ItemID: toolItem.ID, Arguments: &arguments},
{Type: responsesEventOutputItemDone, OutputIndex: kitutil.GetPointer(2), ItemID: toolItem.ID, Item: &toolItem},
{
Type: responsesEventCompleted,
Response: &dto.OpenAIResponsesResponse{
ID: "resp_1",
Model: "gpt-test",
Status: statusRaw,
Output: []dto.ResponsesOutput{reasoningItem, messageItem, toolItem},
Usage: &dto.Usage{InputTokens: 11, OutputTokens: 7, TotalTokens: 18},
},
},
}
var output []*dto.ClaudeResponse
for _, event := range events {
converted, _, err := state.ConvertChunk(event, 9)
require.NoError(t, err)
output = append(output, converted...)
}
starts := responsesOfType(output, "content_block_start")
stops := responsesOfType(output, "content_block_stop")
require.Len(t, responsesOfType(output, "message_start"), 1)
require.Len(t, starts, 3)
require.Len(t, stops, 3)
require.Len(t, responsesOfType(output, "message_delta"), 1)
require.Len(t, responsesOfType(output, "message_stop"), 1)
assert.Equal(t, []int{0, 1, 2}, []int{starts[0].GetIndex(), starts[1].GetIndex(), starts[2].GetIndex()})
assert.Equal(t, []string{"thinking", "text", "tool_use"}, []string{starts[0].ContentBlock.Type, starts[1].ContentBlock.Type, starts[2].ContentBlock.Type})
assert.Equal(t, "plan", joinedClaudeDeltas(output, "thinking_delta"))
assert.Equal(t, "hello", joinedClaudeDeltas(output, "text_delta"))
assert.Equal(t, arguments, joinedClaudeDeltas(output, "input_json_delta"))
messageDelta := responsesOfType(output, "message_delta")[0]
require.NotNil(t, messageDelta.Delta.StopReason)
assert.Equal(t, "tool_use", *messageDelta.Delta.StopReason)
finalized, err := state.Finalize(9)
require.NoError(t, err)
assert.Empty(t, finalized)
repeated, _, err := state.ConvertChunk(events[len(events)-1], 9)
require.NoError(t, err)
assert.Empty(t, repeated)
}
func responsesOfType(responses []*dto.ClaudeResponse, responseType string) []*dto.ClaudeResponse {
filtered := make([]*dto.ClaudeResponse, 0)
for _, response := range responses {
if response != nil && response.Type == responseType {
filtered = append(filtered, response)
}
}
return filtered
}
func joinedClaudeDeltas(responses []*dto.ClaudeResponse, deltaType string) string {
var result strings.Builder
for _, response := range responses {
if response == nil || response.Type != "content_block_delta" || response.Delta == nil || response.Delta.Type != deltaType {
continue
}
switch deltaType {
case "thinking_delta":
if response.Delta.Thinking != nil {
result.WriteString(*response.Delta.Thinking)
}
case "text_delta":
if response.Delta.Text != nil {
result.WriteString(*response.Delta.Text)
}
case "input_json_delta":
if response.Delta.PartialJson != nil {
result.WriteString(*response.Delta.PartialJson)
}
}
}
return result.String()
}
@@ -0,0 +1,331 @@
package oairesponses
import (
"fmt"
"strings"
"context"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
sharedgemini "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/gemini"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
func convertOpenAIResponsesRequestToGeminiChat(c context.Context, info convmeta.Meta, request any) (any, error) {
responsesRequest, err := OpenAIResponsesRequestFromAny(request)
if err != nil {
return nil, err
}
prepared, err := PrepareOpenAIResponsesRequest(*responsesRequest)
if err != nil {
return nil, err
}
return OpenAIResponsesRequestToGeminiChat(c, &prepared, info)
}
func OpenAIResponsesRequestToGeminiChat(c context.Context, req *dto.OpenAIResponsesRequest, info convmeta.Meta) (*dto.GeminiChatRequest, error) {
opts := convmeta.OptionsOf(info)
if req == nil {
return nil, fmt.Errorf("request is nil")
}
if req.Model == "" {
return nil, fmt.Errorf("model is required")
}
if err := ValidateRequestChatUnsupportedFields(req); err != nil {
return nil, err
}
geminiRequest := &dto.GeminiChatRequest{
GenerationConfig: dto.GeminiChatGenerationConfig{
Temperature: req.Temperature,
},
}
if req.TopP != nil {
geminiRequest.GenerationConfig.TopP = kitutil.GetPointer(*req.TopP)
}
if req.MaxOutputTokens != nil {
geminiRequest.GenerationConfig.MaxOutputTokens = kitutil.GetPointer(*req.MaxOutputTokens)
}
upstreamModelName := req.Model
if modelName := convmeta.UpstreamModelName(info); modelName != "" {
upstreamModelName = modelName
}
if opts.Gemini.SupportsImagineModel(upstreamModelName) {
geminiRequest.GenerationConfig.ResponseModalities = []string{"TEXT", "IMAGE"}
}
if err := applyResponsesTextToGemini(req.Text, geminiRequest); err != nil {
return nil, err
}
reasoningIntent, err := reasoning.FromOpenAIResponses(req)
if err != nil {
return nil, reasoning.AsClientError(err)
}
var reasoningPivot dto.GeneralOpenAIRequest
if err := reasoning.ApplyToOpenAIChat(&reasoningPivot, reasoningIntent); err != nil {
return nil, reasoning.AsClientError(err)
}
reasoningPivot.Model = req.Model
reasoningPivot.MaxCompletionTokens = req.MaxOutputTokens
if err := sharedgemini.ApplyThinkingConfig(geminiRequest, info, reasoningPivot); err != nil {
return nil, reasoning.AsClientError(err)
}
var safetySettings []dto.GeminiChatSafetySettings
for _, category := range sharedgemini.SafetySettingCategories {
threshold := opts.Gemini.SafetySettingFor(category)
if threshold == "" {
continue
}
safetySettings = append(safetySettings, dto.GeminiChatSafetySettings{
Category: category,
Threshold: threshold,
})
}
if len(safetySettings) > 0 {
geminiRequest.SafetySettings = safetySettings
}
functions, err := RequestFunctionDeclarations(req.Tools)
if err != nil {
return nil, err
}
for i := range functions {
if params, ok := functions[i].Parameters.(map[string]any); ok {
if props, hasProps := params["properties"].(map[string]any); hasProps && len(props) == 0 {
functions[i].Parameters = nil
continue
}
}
functions[i].Parameters = sharedgemini.CleanFunctionParameters(functions[i].Parameters)
}
if len(functions) > 0 {
geminiRequest.SetTools([]dto.GeminiChatTool{
{FunctionDeclarations: functions},
})
}
toolChoice, err := RequestToolChoiceToChat(req.ToolChoice)
if err != nil {
return nil, err
}
if toolChoice != nil {
geminiRequest.ToolConfig = sharedgemini.OpenAIToolChoiceToConfig(toolChoice)
}
systemTexts := make([]string, 0)
if RawJSONPresent(req.Instructions) {
instructions, err := JSONString(req.Instructions)
if err != nil {
return nil, fmt.Errorf("invalid instructions: %w", err)
}
if strings.TrimSpace(instructions) != "" {
systemTexts = append(systemTexts, instructions)
}
}
inputItems, err := InputItems(req.Input)
if err != nil {
return nil, err
}
callNames := make(map[string]string)
for _, item := range inputItems {
itemType := strings.TrimSpace(kitutil.Interface2String(item["type"]))
switch itemType {
case ResponsesInputTypeFunctionCall:
part, callID, err := responsesFunctionCallItemToGeminiPart(item)
if err != nil {
return nil, err
}
sharedgemini.AttachFunctionCallThoughtSignature(opts, &part)
if callID != "" {
callNames[callID] = part.FunctionCall.FunctionName
}
appendGeminiContentPart(geminiRequest, "model", part)
case ResponsesInputTypeFunctionCallOutput:
part, err := responsesFunctionOutputItemToGeminiPart(item, callNames)
if err != nil {
return nil, err
}
appendGeminiContentPart(geminiRequest, "user", part)
default:
role := responsesGeminiRole(item)
parts, err := responsesInputContentToGeminiParts(c, item["content"])
if err != nil {
return nil, err
}
if role == "system" {
for _, part := range parts {
if part.Text != "" {
systemTexts = append(systemTexts, part.Text)
}
}
continue
}
if len(parts) > 0 {
geminiRequest.Contents = append(geminiRequest.Contents, dto.GeminiChatContent{
Role: role,
Parts: parts,
})
}
}
}
if len(systemTexts) > 0 {
geminiRequest.SystemInstructions = &dto.GeminiChatContent{
Parts: []dto.GeminiPart{{Text: strings.Join(systemTexts, "\n")}},
}
}
return geminiRequest, nil
}
func applyResponsesTextToGemini(raw []byte, geminiRequest *dto.GeminiChatRequest) error {
responseFormat, err := RequestTextToChatResponseFormat(raw)
if err != nil {
return err
}
if responseFormat == nil || (responseFormat.Type != "json_schema" && responseFormat.Type != "json_object") {
return nil
}
geminiRequest.GenerationConfig.ResponseMimeType = "application/json"
if len(responseFormat.JsonSchema) == 0 {
return nil
}
var jsonSchema dto.FormatJsonSchema
if err := kitutil.Unmarshal(responseFormat.JsonSchema, &jsonSchema); err != nil {
return nil
}
geminiRequest.GenerationConfig.ResponseSchema = sharedgemini.RemoveAdditionalProperties(jsonSchema.Schema, 0)
return nil
}
func responsesInputContentToGeminiParts(c context.Context, content any) ([]dto.GeminiPart, error) {
contentParts, err := ContentParts(content)
if err != nil {
return nil, err
}
parts := make([]dto.GeminiPart, 0, len(contentParts))
for _, contentPart := range contentParts {
nextParts, err := responsesContentPartToGeminiParts(c, contentPart)
if err != nil {
return nil, err
}
parts = append(parts, nextParts...)
}
return parts, nil
}
func responsesContentPartToGeminiParts(c context.Context, part map[string]any) ([]dto.GeminiPart, error) {
partType := strings.TrimSpace(kitutil.Interface2String(part["type"]))
switch partType {
case "input_text", "output_text", "text":
text := kitutil.Interface2String(part["text"])
if text == "" {
return nil, nil
}
return []dto.GeminiPart{{Text: text}}, nil
case "input_image", "input_file", "input_audio", "input_video":
source := ContentPartToFileSource(part)
if source == nil {
return nil, nil
}
base64Data, mimeType, err := relaymedia.ResolveBase64Data(c, source, "formatting Responses input for Gemini")
if err != nil {
return nil, fmt.Errorf("get file data from '%s' failed: %w", source.GetIdentifier(), err)
}
if _, ok := sharedgemini.SupportedMimeTypes[strings.ToLower(mimeType)]; !ok {
return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", mimeType, source.GetIdentifier(), sharedgemini.SupportedMimeTypesList())
}
return []dto.GeminiPart{
{
InlineData: &dto.GeminiInlineData{
MimeType: mimeType,
Data: base64Data,
},
},
}, nil
default:
return nil, nil
}
}
func responsesFunctionCallItemToGeminiPart(item map[string]any) (dto.GeminiPart, string, error) {
name := strings.TrimSpace(kitutil.Interface2String(item["name"]))
if name == "" {
return dto.GeminiPart{}, "", fmt.Errorf("function_call item is missing name")
}
callID := CallID(item)
return dto.GeminiPart{
FunctionCall: &dto.FunctionCall{
ID: callID,
FunctionName: name,
Arguments: ObjectValue(item["arguments"], "arguments"),
},
}, callID, nil
}
func responsesFunctionOutputItemToGeminiPart(item map[string]any, callNames map[string]string) (dto.GeminiPart, error) {
callID := CallID(item)
name := strings.TrimSpace(kitutil.Interface2String(item["name"]))
if name == "" {
name = callNames[callID]
}
response := &dto.GeminiFunctionResponse{
Name: name,
Response: GeminiResponseMap(item["output"]),
}
if callID != "" {
id, err := kitutil.Marshal(callID)
if err != nil {
return dto.GeminiPart{}, fmt.Errorf("failed to marshal function response ID: %w", err)
}
response.ID = id
}
return dto.GeminiPart{
FunctionResponse: response,
}, nil
}
func appendGeminiContentPart(req *dto.GeminiChatRequest, role string, part dto.GeminiPart) {
if len(req.Contents) > 0 && req.Contents[len(req.Contents)-1].Role == role {
if role == "model" && part.FunctionCall != nil {
parts := req.Contents[len(req.Contents)-1].Parts
insertAt := 0
for insertAt < len(parts) && parts[insertAt].FunctionCall != nil {
insertAt++
}
parts = append(parts, dto.GeminiPart{})
copy(parts[insertAt+1:], parts[insertAt:])
parts[insertAt] = part
req.Contents[len(req.Contents)-1].Parts = parts
return
}
req.Contents[len(req.Contents)-1].Parts = append(req.Contents[len(req.Contents)-1].Parts, part)
return
}
req.Contents = append(req.Contents, dto.GeminiChatContent{
Role: role,
Parts: []dto.GeminiPart{part},
})
}
func responsesGeminiRole(item map[string]any) string {
switch strings.TrimSpace(kitutil.Interface2String(item["role"])) {
case "assistant":
return "model"
case "system", "developer":
return "system"
case "model":
return "model"
default:
return "user"
}
}
@@ -0,0 +1,101 @@
package oairesponses
import (
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
const (
geminiResponsesInputTypeCustomToolCall = "custom_tool_call"
geminiResponsesInputTypeCustomToolCallOutput = "custom_tool_call_output"
geminiResponsesInputTypeFunctionCallOutput = "function_call_output"
)
const (
ResponsesInputTypeCustomToolCallOutput = geminiResponsesInputTypeCustomToolCallOutput
)
func PrepareOpenAIResponsesRequest(request dto.OpenAIResponsesRequest) (dto.OpenAIResponsesRequest, error) {
tools, err := filterGeminiResponsesTools(request.Tools)
if err != nil {
return request, err
}
request.Tools = tools
input, err := filterGeminiResponsesInput(request.Input)
if err != nil {
return request, err
}
request.Input = input
return request, nil
}
func filterGeminiResponsesTools(raw []byte) ([]byte, error) {
if !geminiRawJSONPresent(raw) || kitutil.GetJsonType(raw) != "array" {
return raw, nil
}
var tools []map[string]any
if err := kitutil.Unmarshal(raw, &tools); err != nil {
return nil, err
}
filtered := make([]map[string]any, 0, len(tools))
for _, tool := range tools {
if strings.TrimSpace(kitutil.Interface2String(tool["type"])) != "function" {
continue
}
filtered = append(filtered, tool)
}
if len(filtered) == 0 {
return nil, nil
}
return kitutil.Marshal(filtered)
}
func filterGeminiResponsesInput(raw []byte) ([]byte, error) {
if !geminiRawJSONPresent(raw) || kitutil.GetJsonType(raw) != "array" {
return raw, nil
}
var items []map[string]any
if err := kitutil.Unmarshal(raw, &items); err != nil {
return nil, err
}
skippedCustomCallIDs := make(map[string]struct{})
for _, item := range items {
if strings.TrimSpace(kitutil.Interface2String(item["type"])) != geminiResponsesInputTypeCustomToolCall {
continue
}
if callID := strings.TrimSpace(kitutil.Interface2String(item["call_id"])); callID != "" {
skippedCustomCallIDs[callID] = struct{}{}
}
}
filtered := make([]map[string]any, 0, len(items))
for _, item := range items {
itemType := strings.TrimSpace(kitutil.Interface2String(item["type"]))
switch itemType {
case geminiResponsesInputTypeCustomToolCall, geminiResponsesInputTypeCustomToolCallOutput:
continue
case geminiResponsesInputTypeFunctionCallOutput:
if _, ok := skippedCustomCallIDs[strings.TrimSpace(kitutil.Interface2String(item["call_id"]))]; ok {
continue
}
}
filtered = append(filtered, item)
}
return kitutil.Marshal(filtered)
}
func geminiRawJSONPresent(raw []byte) bool {
if len(raw) == 0 {
return false
}
return kitutil.GetJsonType(raw) != "null"
}
@@ -0,0 +1,577 @@
package oairesponses
import (
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
const (
responsesInputTypeFunctionCall = "function_call"
responsesInputTypeFunctionCallOutput = "function_call_output"
responsesInputTypeCustomToolCall = "custom_tool_call"
responsesInputTypeCustomToolOutput = "custom_tool_call_output"
)
const (
ResponsesInputTypeFunctionCall = responsesInputTypeFunctionCall
ResponsesInputTypeFunctionCallOutput = responsesInputTypeFunctionCallOutput
ResponsesInputTypeCustomToolCall = responsesInputTypeCustomToolCall
ResponsesInputTypeCustomToolOutput = responsesInputTypeCustomToolOutput
)
func ResponsesRequestToChatCompletionsRequest(req *dto.OpenAIResponsesRequest) (*dto.GeneralOpenAIRequest, error) {
if req == nil {
return nil, errors.New("request is nil")
}
if req.Model == "" {
return nil, errors.New("model is required")
}
if err := validateResponsesRequestChatUnsupportedFields(req); err != nil {
return nil, err
}
messages, err := responsesRequestMessagesToChat(req)
if err != nil {
return nil, err
}
tools, err := responsesRequestToolsToChat(req.Tools)
if err != nil {
return nil, err
}
toolChoice, err := responsesRequestToolChoiceToChat(req.ToolChoice)
if err != nil {
return nil, err
}
responseFormat, err := responsesRequestTextToChatResponseFormat(req.Text)
if err != nil {
return nil, err
}
out := &dto.GeneralOpenAIRequest{
Model: req.Model,
Messages: messages,
Stream: req.Stream,
StreamOptions: req.StreamOptions,
MaxCompletionTokens: req.MaxOutputTokens,
Temperature: req.Temperature,
TopP: req.TopP,
TopLogProbs: req.TopLogProbs,
ResponseFormat: responseFormat,
Tools: tools,
ToolChoice: toolChoice,
User: req.User,
Store: req.Store,
Metadata: req.Metadata,
SafetyIdentifier: req.SafetyIdentifier,
PromptCacheRetention: req.PromptCacheRetention,
EnableThinking: req.EnableThinking,
ThinkingBudget: req.ThinkingBudget,
}
out.FrequencyPenalty, err = responsesRawFloat(req.FrequencyPenalty)
if err != nil {
return nil, fmt.Errorf("invalid frequency_penalty: %w", err)
}
out.PresencePenalty, err = responsesRawFloat(req.PresencePenalty)
if err != nil {
return nil, fmt.Errorf("invalid presence_penalty: %w", err)
}
if reasoningIntent, err := reasoning.FromOpenAIResponses(req); err != nil {
return nil, reasoning.AsClientError(err)
} else if err := reasoning.ApplyToOpenAIChat(out, reasoningIntent); err != nil {
return nil, reasoning.AsClientError(err)
}
if req.ServiceTier != "" {
out.ServiceTier, _ = kitutil.Marshal(req.ServiceTier)
}
if len(req.ParallelToolCalls) > 0 && kitutil.GetJsonType(req.ParallelToolCalls) == "boolean" {
var parallelToolCalls bool
if err := kitutil.Unmarshal(req.ParallelToolCalls, &parallelToolCalls); err == nil {
out.ParallelTooCalls = &parallelToolCalls
}
}
if len(req.PromptCacheKey) > 0 && kitutil.GetJsonType(req.PromptCacheKey) == "string" {
var promptCacheKey string
if err := kitutil.Unmarshal(req.PromptCacheKey, &promptCacheKey); err == nil {
out.PromptCacheKey = promptCacheKey
}
}
return out, nil
}
func validateResponsesRequestChatUnsupportedFields(req *dto.OpenAIResponsesRequest) error {
unsupported := make([]string, 0, 4)
if rawJSONPresent(req.Conversation) {
unsupported = append(unsupported, "conversation")
}
if strings.TrimSpace(req.PreviousResponseID) != "" {
unsupported = append(unsupported, "previous_response_id")
}
if rawJSONPresent(req.Prompt) {
unsupported = append(unsupported, "prompt")
}
if rawJSONPresent(req.ContextManagement) {
unsupported = append(unsupported, "context_management")
}
if len(unsupported) > 0 {
return fmt.Errorf("responses to chat conversion does not support stateful fields: %s", strings.Join(unsupported, ", "))
}
return nil
}
func ValidateRequestChatUnsupportedFields(req *dto.OpenAIResponsesRequest) error {
return validateResponsesRequestChatUnsupportedFields(req)
}
func responsesRequestMessagesToChat(req *dto.OpenAIResponsesRequest) ([]dto.Message, error) {
messages := make([]dto.Message, 0)
if rawJSONPresent(req.Instructions) {
instructions, err := responsesJSONString(req.Instructions)
if err != nil {
return nil, fmt.Errorf("invalid instructions: %w", err)
}
if strings.TrimSpace(instructions) != "" {
messages = append(messages, dto.Message{Role: "system", Content: instructions})
}
}
if !rawJSONPresent(req.Input) {
return messages, nil
}
switch kitutil.GetJsonType(req.Input) {
case "string":
input, err := responsesJSONString(req.Input)
if err != nil {
return nil, fmt.Errorf("invalid input string: %w", err)
}
messages = append(messages, dto.Message{Role: "user", Content: input})
return messages, nil
case "array":
var items []map[string]any
if err := kitutil.Unmarshal(req.Input, &items); err != nil {
return nil, fmt.Errorf("invalid input array: %w", err)
}
for _, item := range items {
nextMessages, err := responsesInputItemToChatMessages(item, messages)
if err != nil {
return nil, err
}
messages = nextMessages
}
return messages, nil
default:
return nil, fmt.Errorf("unsupported responses input type %q", kitutil.GetJsonType(req.Input))
}
}
func responsesInputItemToChatMessages(item map[string]any, messages []dto.Message) ([]dto.Message, error) {
itemType := strings.TrimSpace(kitutil.Interface2String(item["type"]))
switch itemType {
case responsesInputTypeFunctionCall:
toolCall, err := responsesFunctionCallItemToChatToolCall(item)
if err != nil {
return nil, err
}
return appendToolCallToLastAssistant(messages, toolCall), nil
case responsesInputTypeCustomToolCall:
toolCall, err := responsesCustomToolCallItemToChatToolCall(item)
if err != nil {
return nil, err
}
return appendToolCallToLastAssistant(messages, toolCall), nil
case responsesInputTypeFunctionCallOutput:
callID := strings.TrimSpace(kitutil.Interface2String(item["call_id"]))
content := responseToolOutputToChatContent(item["output"])
return append(messages, dto.Message{Role: "tool", ToolCallId: callID, Content: content}), nil
}
role := strings.TrimSpace(kitutil.Interface2String(item["role"]))
if role == "" {
role = "user"
}
content, err := responsesInputContentToChatContent(item["content"])
if err != nil {
return nil, err
}
return append(messages, dto.Message{Role: role, Content: content}), nil
}
func responsesInputContentToChatContent(content any) (any, error) {
if content == nil {
return "", nil
}
switch value := content.(type) {
case string:
return value, nil
case []any:
return responsesContentPartsToChatContent(value)
case []map[string]any:
parts := make([]any, 0, len(value))
for _, part := range value {
parts = append(parts, part)
}
return responsesContentPartsToChatContent(parts)
default:
return content, nil
}
}
func responsesContentPartsToChatContent(parts []any) (any, error) {
chatParts := make([]any, 0, len(parts))
var textOnly strings.Builder
onlyText := true
for _, rawPart := range parts {
part, ok := rawPart.(map[string]any)
if !ok {
onlyText = false
chatParts = append(chatParts, rawPart)
continue
}
partType := strings.TrimSpace(kitutil.Interface2String(part["type"]))
switch partType {
case "input_text", "output_text", "text":
text := kitutil.Interface2String(part["text"])
textOnly.WriteString(text)
chatParts = append(chatParts, map[string]any{
"type": dto.ContentTypeText,
"text": text,
})
case "input_image":
onlyText = false
chatParts = append(chatParts, map[string]any{
"type": dto.ContentTypeImageURL,
"image_url": responsesImagePartToChatImageURL(part),
})
case "input_file":
onlyText = false
chatParts = append(chatParts, map[string]any{
"type": dto.ContentTypeFile,
"file": responsesFilePartToChatFile(part),
})
case "input_audio":
onlyText = false
chatParts = append(chatParts, map[string]any{
"type": dto.ContentTypeInputAudio,
"input_audio": responsesPartPayload(part, "input_audio"),
})
case "input_video":
onlyText = false
chatParts = append(chatParts, map[string]any{
"type": dto.ContentTypeVideoUrl,
"video_url": responsesVideoPartToChatVideoURL(part),
})
default:
onlyText = false
chatParts = append(chatParts, part)
}
}
if onlyText {
return textOnly.String(), nil
}
return chatParts, nil
}
func responsesFunctionCallItemToChatToolCall(item map[string]any) (dto.ToolCallRequest, error) {
name := strings.TrimSpace(kitutil.Interface2String(item["name"]))
if name == "" {
return dto.ToolCallRequest{}, errors.New("function_call item is missing name")
}
return dto.ToolCallRequest{
ID: responsesCallID(item),
Type: "function",
Function: dto.FunctionRequest{
Name: name,
Arguments: responsesArgumentsString(item["arguments"]),
},
}, nil
}
func responsesCustomToolCallItemToChatToolCall(item map[string]any) (dto.ToolCallRequest, error) {
raw, err := kitutil.Marshal(item)
if err != nil {
return dto.ToolCallRequest{}, err
}
return dto.ToolCallRequest{
ID: responsesCallID(item),
Type: dto.CustomType,
Custom: raw,
Function: dto.FunctionRequest{
Name: strings.TrimSpace(kitutil.Interface2String(item["name"])),
Arguments: responsesArgumentsString(item["input"]),
},
}, nil
}
func appendToolCallToLastAssistant(messages []dto.Message, toolCall dto.ToolCallRequest) []dto.Message {
if len(messages) == 0 || messages[len(messages)-1].Role != "assistant" {
messages = append(messages, dto.Message{Role: "assistant"})
}
idx := len(messages) - 1
toolCalls := messages[idx].ParseToolCalls()
toolCalls = append(toolCalls, toolCall)
toolCallsRaw, _ := kitutil.Marshal(toolCalls)
messages[idx].ToolCalls = toolCallsRaw
return messages
}
func responsesRequestToolsToChat(raw json.RawMessage) ([]dto.ToolCallRequest, error) {
if !rawJSONPresent(raw) {
return nil, nil
}
var tools []map[string]any
if err := kitutil.Unmarshal(raw, &tools); err != nil {
return nil, fmt.Errorf("invalid tools: %w", err)
}
out := make([]dto.ToolCallRequest, 0, len(tools))
for _, tool := range tools {
toolType := strings.TrimSpace(kitutil.Interface2String(tool["type"]))
if toolType == "function" {
out = append(out, dto.ToolCallRequest{
Type: "function",
Function: dto.FunctionRequest{
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Description: kitutil.Interface2String(tool["description"]),
Parameters: tool["parameters"],
},
})
continue
}
rawTool, err := kitutil.Marshal(tool)
if err != nil {
return nil, err
}
out = append(out, dto.ToolCallRequest{
Type: toolType,
Custom: rawTool,
})
}
return out, nil
}
func responsesRequestToolChoiceToChat(raw json.RawMessage) (any, error) {
if !rawJSONPresent(raw) {
return nil, nil
}
if kitutil.GetJsonType(raw) == "string" {
var choice string
if err := kitutil.Unmarshal(raw, &choice); err != nil {
return nil, fmt.Errorf("invalid tool_choice: %w", err)
}
return choice, nil
}
var choice map[string]any
if err := kitutil.Unmarshal(raw, &choice); err != nil {
return nil, fmt.Errorf("invalid tool_choice: %w", err)
}
if kitutil.Interface2String(choice["type"]) == "function" {
name := strings.TrimSpace(kitutil.Interface2String(choice["name"]))
if name != "" {
return map[string]any{
"type": "function",
"function": map[string]any{
"name": name,
},
}, nil
}
}
return choice, nil
}
func RequestToolChoiceToChat(raw json.RawMessage) (any, error) {
return responsesRequestToolChoiceToChat(raw)
}
func responsesRequestTextToChatResponseFormat(raw json.RawMessage) (*dto.ResponseFormat, error) {
if !rawJSONPresent(raw) {
return nil, nil
}
var textConfig map[string]any
if err := kitutil.Unmarshal(raw, &textConfig); err != nil {
return nil, fmt.Errorf("invalid text config: %w", err)
}
format, ok := textConfig["format"].(map[string]any)
if !ok {
return nil, nil
}
formatType := strings.TrimSpace(kitutil.Interface2String(format["type"]))
if formatType == "" {
return nil, nil
}
out := &dto.ResponseFormat{Type: formatType}
if formatType == "json_schema" {
schemaRaw, err := kitutil.Marshal(format)
if err != nil {
return nil, err
}
out.JsonSchema = schemaRaw
}
return out, nil
}
func RequestTextToChatResponseFormat(raw json.RawMessage) (*dto.ResponseFormat, error) {
return responsesRequestTextToChatResponseFormat(raw)
}
func responsesImagePartToChatImageURL(part map[string]any) any {
if imageURL, ok := part["image_url"]; ok {
return imageURL
}
imageURL := map[string]any{}
for _, key := range []string{"url", "file_id", "detail"} {
if value, ok := part[key]; ok {
imageURL[key] = value
}
}
if len(imageURL) == 0 {
return part
}
return imageURL
}
func responsesFilePartToChatFile(part map[string]any) any {
if file, ok := part["file"]; ok {
return file
}
file := map[string]any{}
for _, key := range []string{"file_id", "file_data", "filename", "file_url"} {
if value, ok := part[key]; ok {
file[key] = value
}
}
if len(file) == 0 {
return part
}
return file
}
func responsesVideoPartToChatVideoURL(part map[string]any) any {
if videoURL, ok := part["video_url"]; ok {
if videoURLMap, ok := videoURL.(map[string]any); ok {
if url := kitutil.Interface2String(videoURLMap["url"]); url != "" {
return url
}
}
return videoURL
}
if url := kitutil.Interface2String(part["url"]); url != "" {
return url
}
return responsesPartPayload(part, "video_url")
}
func responsesPartPayload(part map[string]any, key string) any {
if value, ok := part[key]; ok {
return value
}
payload := make(map[string]any, len(part))
for k, value := range part {
if k == "type" {
continue
}
payload[k] = value
}
return payload
}
func responsesCallID(item map[string]any) string {
callID := strings.TrimSpace(kitutil.Interface2String(item["call_id"]))
if callID != "" {
return callID
}
return strings.TrimSpace(kitutil.Interface2String(item["id"]))
}
func CallID(item map[string]any) string {
return responsesCallID(item)
}
func responsesArgumentsString(value any) string {
switch v := value.(type) {
case nil:
return ""
case string:
return v
default:
raw, err := kitutil.Marshal(v)
if err != nil {
return kitutil.Interface2String(v)
}
return string(raw)
}
}
func responseToolOutputToChatContent(value any) any {
switch v := value.(type) {
case nil:
return ""
case string:
return v
default:
raw, err := kitutil.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(raw)
}
}
func responsesRawFloat(raw json.RawMessage) (*float64, error) {
if !rawJSONPresent(raw) {
return nil, nil
}
var value float64
if err := kitutil.Unmarshal(raw, &value); err != nil {
return nil, err
}
return &value, nil
}
func responsesJSONString(raw json.RawMessage) (string, error) {
if kitutil.GetJsonType(raw) != "string" {
return string(raw), nil
}
var value string
if err := kitutil.Unmarshal(raw, &value); err != nil {
return "", err
}
return value, nil
}
func rawJSONPresent(raw json.RawMessage) bool {
if len(raw) == 0 {
return false
}
return kitutil.GetJsonType(raw) != "null"
}
func JSONString(raw json.RawMessage) (string, error) {
return responsesJSONString(raw)
}
func RawJSONPresent(raw json.RawMessage) bool {
return rawJSONPresent(raw)
}
@@ -0,0 +1,358 @@
package oairesponses
import (
"encoding/json"
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestResponsesRequestToChatCompletionsRequestInstructionsAndScalarInput(t *testing.T) {
stream := true
temperature := 0.0
topP := 0.9
maxOutputTokens := uint(128)
parallelToolCalls := true
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
Model: "gpt-test",
Instructions: mustRawMessage(t, "system rules"),
Input: mustRawMessage(t, "hello"),
Stream: &stream,
StreamOptions: &dto.StreamOptions{IncludeUsage: true},
MaxOutputTokens: &maxOutputTokens,
Temperature: &temperature,
TopP: &topP,
User: mustRawMessage(t, "user-1"),
Store: mustRawMessage(t, false),
Metadata: mustRawMessage(t, map[string]any{"trace": "abc"}),
ParallelToolCalls: mustRawMessage(t, parallelToolCalls),
PromptCacheKey: mustRawMessage(t, "cache-key"),
PromptCacheRetention: mustRawMessage(t, "24h"),
Reasoning: &dto.Reasoning{Effort: "medium"},
})
require.NoError(t, err)
assert.Equal(t, "gpt-test", got.Model)
require.Len(t, got.Messages, 2)
assert.Equal(t, dto.Message{Role: "system", Content: "system rules"}, got.Messages[0])
assert.Equal(t, dto.Message{Role: "user", Content: "hello"}, got.Messages[1])
assert.Same(t, &stream, got.Stream)
require.NotNil(t, got.StreamOptions)
assert.True(t, got.StreamOptions.IncludeUsage)
assert.Equal(t, maxOutputTokens, lo.FromPtr(got.MaxCompletionTokens))
assert.Equal(t, 0.0, lo.FromPtr(got.Temperature))
assert.Equal(t, 0.9, lo.FromPtr(got.TopP))
assert.True(t, lo.FromPtr(got.ParallelTooCalls))
assert.Equal(t, "cache-key", got.PromptCacheKey)
assert.Equal(t, "medium", got.ReasoningEffort)
assert.Equal(t, `"user-1"`, string(got.User))
assert.Equal(t, `false`, string(got.Store))
assert.Equal(t, "abc", gjson.GetBytes(got.Metadata, "trace").String())
}
func TestResponsesRequestToChatCompletionsRequestPreservesQwenThinkingBudget(t *testing.T) {
tests := []struct {
name string
budget json.RawMessage
want int64
}{
{name: "positive budget", budget: json.RawMessage(`128`), want: 128},
{name: "zero budget", budget: json.RawMessage(`0`), want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
Model: "qwen-plus",
Input: mustRawMessage(t, "hello"),
EnableThinking: json.RawMessage(`true`),
ThinkingBudget: tt.budget,
})
require.NoError(t, err)
assert.Equal(t, tt.budget, got.ThinkingBudget)
encoded, err := kitutil.Marshal(got)
require.NoError(t, err)
assert.True(t, gjson.GetBytes(encoded, "enable_thinking").Bool())
value := gjson.GetBytes(encoded, "thinking_budget")
assert.True(t, value.Exists())
assert.Equal(t, tt.want, value.Int())
})
}
}
func TestResponsesRequestToChatCompletionsRequestMultimodalInput(t *testing.T) {
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
Model: "gpt-test",
Input: mustRawMessage(t, []map[string]any{
{
"role": "user",
"content": []map[string]any{
{"type": "input_text", "text": "look"},
{"type": "input_image", "image_url": "https://example.test/a.png", "detail": "low"},
{"type": "input_file", "file_id": "file_1", "filename": "a.txt"},
{"type": "input_audio", "input_audio": map[string]any{"data": "abc", "format": "wav"}},
{"type": "input_video", "video_url": map[string]any{"url": "https://example.test/v.mp4"}},
},
},
}),
})
require.NoError(t, err)
require.Len(t, got.Messages, 1)
assert.Equal(t, "user", got.Messages[0].Role)
parts := got.Messages[0].ParseContent()
require.Len(t, parts, 5)
assert.Equal(t, dto.ContentTypeText, parts[0].Type)
assert.Equal(t, "look", parts[0].Text)
assert.Equal(t, dto.ContentTypeImageURL, parts[1].Type)
assert.Equal(t, "https://example.test/a.png", parts[1].GetImageMedia().Url)
assert.Equal(t, dto.ContentTypeFile, parts[2].Type)
assert.Equal(t, "file_1", parts[2].GetFile().FileId)
assert.Equal(t, dto.ContentTypeInputAudio, parts[3].Type)
assert.Equal(t, "wav", parts[3].GetInputAudio().Format)
assert.Equal(t, dto.ContentTypeVideoUrl, parts[4].Type)
assert.Equal(t, "https://example.test/v.mp4", parts[4].GetVideoUrl().Url)
}
func TestResponsesRequestToChatCompletionsRequestAssistantTextAndFunctionCallCoexist(t *testing.T) {
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
Model: "gpt-test",
Input: mustRawMessage(t, []map[string]any{
{
"role": "assistant",
"content": []map[string]any{
{"type": "output_text", "text": "I will call."},
},
},
{
"type": "function_call",
"call_id": "call_1",
"name": "lookup",
"arguments": map[string]any{"q": "x"},
},
{
"type": "function_call_output",
"call_id": "call_1",
"output": map[string]any{"ok": true},
},
}),
})
require.NoError(t, err)
require.Len(t, got.Messages, 2)
assert.Equal(t, "assistant", got.Messages[0].Role)
assert.Equal(t, "I will call.", got.Messages[0].StringContent())
toolCalls := got.Messages[0].ParseToolCalls()
require.Len(t, toolCalls, 1)
assert.Equal(t, "call_1", toolCalls[0].ID)
assert.Equal(t, "function", toolCalls[0].Type)
assert.Equal(t, "lookup", toolCalls[0].Function.Name)
assert.JSONEq(t, `{"q":"x"}`, toolCalls[0].Function.Arguments)
assert.Equal(t, "tool", got.Messages[1].Role)
assert.Equal(t, "call_1", got.Messages[1].ToolCallId)
assert.JSONEq(t, `{"ok":true}`, got.Messages[1].StringContent())
}
func TestResponsesRequestToChatCompletionsRequestOnlyFunctionCallCreatesAssistant(t *testing.T) {
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
Model: "gpt-test",
Input: mustRawMessage(t, []map[string]any{
{
"type": "function_call",
"call_id": "call_1",
"name": "lookup",
"arguments": `{"q":"x"}`,
},
}),
})
require.NoError(t, err)
require.Len(t, got.Messages, 1)
assert.Equal(t, "assistant", got.Messages[0].Role)
assert.Nil(t, got.Messages[0].Content)
toolCalls := got.Messages[0].ParseToolCalls()
require.Len(t, toolCalls, 1)
assert.Equal(t, `{"q":"x"}`, toolCalls[0].Function.Arguments)
}
func TestResponsesRequestToChatCompletionsRequestToolsToolChoiceAndTextFormat(t *testing.T) {
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
Model: "gpt-test",
Input: mustRawMessage(t, "hello"),
Tools: mustRawMessage(t, []map[string]any{
{
"type": "function",
"name": "lookup",
"description": "Lookup data",
"parameters": map[string]any{
"type": "object",
"properties": map[string]any{
"q": map[string]any{"type": "string"},
},
},
},
}),
ToolChoice: mustRawMessage(t, map[string]any{
"type": "function",
"name": "lookup",
}),
Text: mustRawMessage(t, map[string]any{
"format": map[string]any{
"type": "json_schema",
"name": "answer",
"schema": map[string]any{"type": "object"},
"strict": true,
},
}),
})
require.NoError(t, err)
require.Len(t, got.Tools, 1)
assert.Equal(t, "function", got.Tools[0].Type)
assert.Equal(t, "lookup", got.Tools[0].Function.Name)
assert.Equal(t, "Lookup data", got.Tools[0].Function.Description)
assert.Equal(t, "object", got.Tools[0].Function.Parameters.(map[string]any)["type"])
assert.Equal(t, map[string]any{
"type": "function",
"function": map[string]any{
"name": "lookup",
},
}, got.ToolChoice)
require.NotNil(t, got.ResponseFormat)
assert.Equal(t, "json_schema", got.ResponseFormat.Type)
assert.Equal(t, "answer", gjson.GetBytes(got.ResponseFormat.JsonSchema, "name").String())
assert.True(t, gjson.GetBytes(got.ResponseFormat.JsonSchema, "strict").Bool())
}
func TestResponsesRequestToChatCompletionsRequestCustomToolCallPreservesRawShape(t *testing.T) {
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
Model: "gpt-test",
Input: mustRawMessage(t, []map[string]any{
{
"type": "custom_tool_call",
"call_id": "call_custom",
"name": "apply_patch",
"input": "patch body",
},
}),
})
require.NoError(t, err)
require.Len(t, got.Messages, 1)
toolCalls := got.Messages[0].ParseToolCalls()
require.Len(t, toolCalls, 1)
assert.Equal(t, dto.CustomType, toolCalls[0].Type)
assert.Equal(t, "call_custom", toolCalls[0].ID)
assert.Equal(t, "apply_patch", toolCalls[0].Function.Name)
assert.Equal(t, "patch body", toolCalls[0].Function.Arguments)
assert.Equal(t, "custom_tool_call", gjson.GetBytes(toolCalls[0].Custom, "type").String())
assert.Equal(t, "patch body", gjson.GetBytes(toolCalls[0].Custom, "input").String())
}
func TestResponsesRequestToChatCompletionsRequestRejectsStatefulFields(t *testing.T) {
tests := []struct {
name string
req *dto.OpenAIResponsesRequest
want string
}{
{
name: "conversation",
req: &dto.OpenAIResponsesRequest{Model: "gpt-test", Conversation: mustRawMessage(t, "conv_1")},
want: "conversation",
},
{
name: "previous response",
req: &dto.OpenAIResponsesRequest{Model: "gpt-test", PreviousResponseID: "resp_1"},
want: "previous_response_id",
},
{
name: "prompt",
req: &dto.OpenAIResponsesRequest{Model: "gpt-test", Prompt: mustRawMessage(t, map[string]any{"id": "pmpt_1"})},
want: "prompt",
},
{
name: "context management",
req: &dto.OpenAIResponsesRequest{Model: "gpt-test", ContextManagement: mustRawMessage(t, map[string]any{"type": "auto"})},
want: "context_management",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ResponsesRequestToChatCompletionsRequest(tt.req)
require.Error(t, err)
assert.Contains(t, err.Error(), tt.want)
assert.Contains(t, err.Error(), "stateful fields")
})
}
}
func TestResponsesRequestToChatCompletionsRequestPreservesPenalties(t *testing.T) {
tests := []struct {
name string
frequencyRaw json.RawMessage
frequencyWant *float64
presenceRaw json.RawMessage
presenceWant *float64
}{
{
name: "positive values",
frequencyRaw: json.RawMessage(`0.5`),
frequencyWant: lo.ToPtr(0.5),
presenceRaw: json.RawMessage(`1.5`),
presenceWant: lo.ToPtr(1.5),
},
{
name: "explicit zero values",
frequencyRaw: json.RawMessage(`0.0`),
frequencyWant: lo.ToPtr(0.0),
presenceRaw: json.RawMessage(`0.0`),
presenceWant: lo.ToPtr(0.0),
},
{
name: "unset stays nil",
frequencyRaw: nil,
presenceRaw: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
Model: "gpt-test",
Input: mustRawMessage(t, "hello"),
FrequencyPenalty: tt.frequencyRaw,
PresencePenalty: tt.presenceRaw,
})
require.NoError(t, err)
assert.Equal(t, tt.frequencyWant, got.FrequencyPenalty)
assert.Equal(t, tt.presenceWant, got.PresencePenalty)
})
}
}
func TestResponsesRequestToChatCompletionsRequestRejectsMalformedPenalty(t *testing.T) {
_, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
Model: "gpt-test",
Input: mustRawMessage(t, "hello"),
FrequencyPenalty: json.RawMessage(`"not-a-number"`),
})
require.Error(t, err)
assert.Contains(t, err.Error(), "frequency_penalty")
}
func mustRawMessage(t *testing.T, value any) []byte {
t.Helper()
raw, err := kitutil.Marshal(value)
require.NoError(t, err)
return raw
}
@@ -0,0 +1,399 @@
package oairesponses
import (
"errors"
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
const (
responsesEventCreated = "response.created"
responsesEventCompleted = "response.completed"
responsesEventDone = "response.done"
responsesEventIncomplete = "response.incomplete"
responsesEventFailed = "response.failed"
responsesEventError = "response.error"
responsesEventOutputTextDelta = "response.output_text.delta"
responsesEventOutputTextAnnotationAdded = "response.output_text.annotation.added"
responsesEventOutputItemAdded = "response.output_item.added"
responsesEventOutputItemDone = "response.output_item.done"
responsesEventFunctionArgsDelta = "response.function_call_arguments.delta"
responsesEventFunctionArgsDone = "response.function_call_arguments.done"
responsesEventCustomToolInputDelta = "response.custom_tool_call_input.delta"
responsesEventCustomToolInputDone = "response.custom_tool_call_input.done"
responsesEventReasoningSummaryDelta = "response.reasoning_summary_text.delta"
responsesEventReasoningSummaryDone = "response.reasoning_summary_text.done"
responsesEventReasoningTextDelta = "response.reasoning_text.delta"
responsesEventReasoningTextDone = "response.reasoning_text.done"
responsesOutputTypeFunctionCall = "function_call"
responsesOutputTypeCustomToolCall = "custom_tool_call"
responsesOutputTypeMessage = "message"
responsesOutputTypeReasoning = "reasoning"
responsesIncompleteReasonContentFilter = "content_filter"
responsesIncompleteReasonMaxTokens = "max_output_tokens"
)
func ResponsesFinishReasonFromStatus(resp *dto.OpenAIResponsesResponse) (string, bool) {
if resp == nil {
return "", false
}
status := responseStatusString(resp)
if status != "incomplete" {
return "", false
}
reason := ""
if resp.IncompleteDetails != nil {
reason = strings.TrimSpace(resp.IncompleteDetails.Reason)
}
if reason == responsesIncompleteReasonContentFilter {
return "content_filter", true
}
return "length", true
}
func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesResponse, id string) (*dto.OpenAITextResponse, *dto.Usage, error) {
if resp == nil {
return nil, nil, errors.New("response is nil")
}
text := ExtractOutputTextFromResponses(resp)
reasoning := ExtractReasoningTextFromResponses(resp)
usage := UsageFromResponsesUsage(resp.Usage)
created := resp.CreatedAt
var toolCalls []dto.ToolCallResponse
if len(resp.Output) > 0 {
for _, out := range resp.Output {
if !isResponsesToolOutputType(out.Type) {
continue
}
name := strings.TrimSpace(out.Name)
if name == "" {
continue
}
callId := strings.TrimSpace(out.CallId)
if callId == "" {
callId = strings.TrimSpace(out.ID)
}
toolCalls = append(toolCalls, dto.ToolCallResponse{
ID: callId,
Type: "function",
Function: dto.FunctionResponse{
Name: name,
Arguments: out.ArgumentsString(),
},
})
}
}
finishReason := "stop"
if mappedReason, ok := ResponsesFinishReasonFromStatus(resp); ok {
finishReason = mappedReason
} else if len(toolCalls) > 0 {
finishReason = "tool_calls"
}
msg := dto.Message{
Role: "assistant",
Content: text,
}
if annotations, err := responsesAnnotationsToChat(resp); err != nil {
return nil, nil, err
} else if len(annotations) > 0 {
msg.Annotations = annotations
}
if reasoning != "" {
msg.ReasoningContent = &reasoning
}
if len(toolCalls) > 0 {
msg.SetToolCalls(toolCalls)
}
out := &dto.OpenAITextResponse{
Id: id,
Object: "chat.completion",
Created: created,
Model: resp.Model,
Choices: []dto.OpenAITextResponseChoice{
{
Index: 0,
Message: msg,
FinishReason: finishReason,
},
},
Usage: *usage,
}
return out, usage, nil
}
func responsesAnnotationsToChat(resp *dto.OpenAIResponsesResponse) ([]byte, error) {
annotations := make([]any, 0)
for _, output := range resp.Output {
if output.Type != responsesOutputTypeMessage {
continue
}
for _, content := range output.Content {
for _, annotation := range content.Annotations {
converted, err := responseAnnotationToChat(annotation)
if err != nil {
return nil, err
}
annotations = append(annotations, converted)
}
}
}
if len(annotations) == 0 {
return nil, nil
}
return kitutil.Marshal(annotations)
}
func responseAnnotationToChat(annotation any) (map[string]any, error) {
value, ok := annotation.(map[string]any)
if !ok {
converted, err := kitutil.Any2Type[map[string]any](annotation)
if err != nil {
return nil, fmt.Errorf("invalid Responses annotation: %w", err)
}
value = converted
}
if strings.TrimSpace(kitutil.Interface2String(value["type"])) != "url_citation" {
return value, nil
}
citation := make(map[string]any, len(value)-1)
for key, item := range value {
if key != "type" {
citation[key] = item
}
}
return map[string]any{
"type": "url_citation",
"url_citation": citation,
}, nil
}
func UsageFromResponsesUsage(src *dto.Usage) *dto.Usage {
return usageFromResponsesUsage(src, true)
}
// NormalizeResponsesUsage maps Responses usage into the shared accounting
// shape without creating a BillingUsage snapshot. Native Responses handlers
// use it so passthrough traffic preserves an existing snapshot but does not
// introduce a conversion sidecar solely for local settlement.
func NormalizeResponsesUsage(src *dto.Usage) *dto.Usage {
return usageFromResponsesUsage(src, false)
}
func usageFromResponsesUsage(src *dto.Usage, createBillingSnapshot bool) *dto.Usage {
usage := &dto.Usage{}
if src == nil {
return usage
}
usage.UsageSemantic = src.UsageSemantic
usage.UsageSource = src.UsageSource
usage.BillingUsage = dto.CloneBillingUsage(src.BillingUsage)
if usage.BillingUsage == nil && createBillingSnapshot {
usage.BillingUsage = dto.NewOpenAIResponsesBillingUsage(src)
}
usage.Cost = src.Cost
if src.InputTokens != 0 {
usage.PromptTokens = src.InputTokens
usage.InputTokens = src.InputTokens
}
if src.OutputTokens != 0 {
usage.CompletionTokens = src.OutputTokens
usage.OutputTokens = src.OutputTokens
}
if src.TotalTokens != 0 {
usage.TotalTokens = src.TotalTokens
} else {
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
}
if src.InputTokensDetails != nil {
usage.PromptTokensDetails.CachedTokens = src.InputTokensDetails.CachedTokens
usage.PromptTokensDetails.CachedCreationTokens = src.InputTokensDetails.CachedCreationTokens
usage.PromptTokensDetails.CacheWriteTokens = src.InputTokensDetails.CacheWriteTokens
usage.PromptTokensDetails.TextTokens = src.InputTokensDetails.TextTokens
usage.PromptTokensDetails.ImageTokens = src.InputTokensDetails.ImageTokens
usage.PromptTokensDetails.AudioTokens = src.InputTokensDetails.AudioTokens
}
if src.CompletionTokenDetails.ReasoningTokens != 0 ||
src.CompletionTokenDetails.TextTokens != 0 ||
src.CompletionTokenDetails.AudioTokens != 0 ||
src.CompletionTokenDetails.ImageTokens != 0 {
usage.CompletionTokenDetails.ReasoningTokens = src.CompletionTokenDetails.ReasoningTokens
usage.CompletionTokenDetails.TextTokens = src.CompletionTokenDetails.TextTokens
usage.CompletionTokenDetails.AudioTokens = src.CompletionTokenDetails.AudioTokens
usage.CompletionTokenDetails.ImageTokens = src.CompletionTokenDetails.ImageTokens
}
usage.ClaudeCacheCreation5mTokens = src.ClaudeCacheCreation5mTokens
usage.ClaudeCacheCreation1hTokens = src.ClaudeCacheCreation1hTokens
return usage
}
func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
if resp == nil || len(resp.Output) == 0 {
return ""
}
var sb strings.Builder
// Prefer assistant message outputs.
for _, out := range resp.Output {
if out.Type != "message" {
continue
}
if out.Role != "" && out.Role != "assistant" {
continue
}
var outputText strings.Builder
for _, c := range out.Content {
if c.Type == "output_text" && c.Text != "" {
outputText.WriteString(c.Text)
}
}
appendSeparatedText(&sb, outputText.String())
}
if sb.Len() > 0 {
return sb.String()
}
for _, out := range resp.Output {
var outputText strings.Builder
for _, c := range out.Content {
if c.Text != "" {
outputText.WriteString(c.Text)
}
}
appendSeparatedText(&sb, outputText.String())
}
return sb.String()
}
func ExtractReasoningTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
if resp == nil || len(resp.Output) == 0 {
return ""
}
var sb strings.Builder
for _, out := range resp.Output {
if out.Type != responsesOutputTypeReasoning {
continue
}
appendSeparatedText(&sb, reasoningOutputText(&out))
}
return sb.String()
}
func reasoningOutputText(output *dto.ResponsesOutput) string {
if output == nil {
return ""
}
var text strings.Builder
hasContentText := false
for _, part := range output.Content {
if part.Text != "" {
hasContentText = true
break
}
}
if hasContentText {
for _, part := range output.Content {
appendSeparatedText(&text, part.Text)
}
return text.String()
}
for _, part := range output.Summary {
appendSeparatedText(&text, part.Text)
}
return text.String()
}
func appendSeparatedText(builder *strings.Builder, text string) {
if builder == nil || text == "" {
return
}
if builder.Len() > 0 {
current := builder.String()
trailingNewlines := 0
for index := len(current) - 1; index >= 0 && trailingNewlines < 2 && current[index] == '\n'; index-- {
trailingNewlines++
}
leadingNewlines := 0
for leadingNewlines < len(text) && leadingNewlines < 2 && text[leadingNewlines] == '\n' {
leadingNewlines++
}
for missing := 2 - trailingNewlines - leadingNewlines; missing > 0; missing-- {
builder.WriteByte('\n')
}
}
builder.WriteString(text)
}
func responseStatusString(resp *dto.OpenAIResponsesResponse) string {
if resp == nil || len(resp.Status) == 0 {
return ""
}
var status string
_ = kitutil.Unmarshal(resp.Status, &status)
return strings.TrimSpace(status)
}
func ensureIncompleteResponse(resp *dto.OpenAIResponsesResponse) *dto.OpenAIResponsesResponse {
if resp == nil {
resp = &dto.OpenAIResponsesResponse{}
}
if len(resp.Status) == 0 {
resp.Status = []byte(`"incomplete"`)
}
return resp
}
func isResponsesToolOutputType(outputType string) bool {
return outputType == responsesOutputTypeFunctionCall || outputType == responsesOutputTypeCustomToolCall
}
func responseStreamEventItemID(event *dto.ResponsesStreamResponse) string {
if event == nil {
return ""
}
if event.Item != nil {
if itemID := strings.TrimSpace(event.Item.ID); itemID != "" {
return itemID
}
}
return strings.TrimSpace(event.ItemID)
}
func fallbackToolKey(itemID string, callID string, outputIndex *int) string {
if outputIndex != nil {
return fmt.Sprintf("output:%d", *outputIndex)
}
if strings.TrimSpace(itemID) != "" {
return "item:" + strings.TrimSpace(itemID)
}
if strings.TrimSpace(callID) != "" {
return "call:" + strings.TrimSpace(callID)
}
return ""
}
func fallbackCallID(event *dto.ResponsesStreamResponse) string {
if event == nil {
return ""
}
if strings.TrimSpace(event.ItemID) != "" {
return strings.TrimSpace(event.ItemID)
}
if event.OutputIndex != nil {
return fmt.Sprintf("call_output_%d", *event.OutputIndex)
}
return ""
}
@@ -0,0 +1,547 @@
package oairesponses
import (
"strings"
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResponsesResponseToChatCompletionsPreservesTextAndToolCalls(t *testing.T) {
resp := &dto.OpenAIResponsesResponse{
ID: "resp_1",
CreatedAt: 123,
Model: "gpt-test",
Status: []byte(`"completed"`),
Output: []dto.ResponsesOutput{
{
Type: responsesOutputTypeMessage,
Role: "assistant",
Content: []dto.ResponsesOutputContent{
{Type: "output_text", Text: "I will call a tool."},
},
},
{
Type: responsesOutputTypeFunctionCall,
ID: "fc_1",
CallId: "call_1",
Name: "lookup",
Arguments: []byte(`{"q":"x"}`),
},
},
Usage: &dto.Usage{InputTokens: 3, OutputTokens: 4, TotalTokens: 7},
}
chat, usage, err := ResponsesResponseToChatCompletionsResponse(resp, "chatcmpl_1")
require.NoError(t, err)
require.NotNil(t, usage)
require.Len(t, chat.Choices, 1)
assert.Equal(t, "tool_calls", chat.Choices[0].FinishReason)
assert.Equal(t, "I will call a tool.", chat.Choices[0].Message.StringContent())
toolCalls := chat.Choices[0].Message.ParseToolCalls()
require.Len(t, toolCalls, 1)
assert.Equal(t, "call_1", toolCalls[0].ID)
assert.Equal(t, "lookup", toolCalls[0].Function.Name)
assert.Equal(t, `{"q":"x"}`, toolCalls[0].Function.Arguments)
assert.Equal(t, 7, usage.TotalTokens)
}
func TestResponsesResponseToChatCompletionsPreservesReasoningSummary(t *testing.T) {
resp := &dto.OpenAIResponsesResponse{
ID: "resp_1",
Model: "gpt-test",
Status: []byte(`"completed"`),
Output: []dto.ResponsesOutput{
{
Type: responsesOutputTypeReasoning,
Summary: []dto.ResponsesReasoningSummaryPart{
{Type: "summary_text", Text: "first summary"},
{Type: "summary_text", Text: "second summary"},
},
},
{
Type: responsesOutputTypeMessage,
Role: "assistant",
Content: []dto.ResponsesOutputContent{
{Type: "output_text", Text: "final"},
},
},
},
}
chat, _, err := ResponsesResponseToChatCompletionsResponse(resp, "chatcmpl_1")
require.NoError(t, err)
assert.Equal(t, "first summary\n\nsecond summary", chat.Choices[0].Message.GetReasoningContent())
assert.Equal(t, "final", chat.Choices[0].Message.StringContent())
}
func TestResponsesResponseToChatCompletionsSeparatesInterleavedOutputItems(t *testing.T) {
resp := &dto.OpenAIResponsesResponse{
ID: "resp_1",
Model: "gpt-test",
Status: []byte(`"completed"`),
Output: interleavedReasoningAndTextOutput(),
}
chat, _, err := ResponsesResponseToChatCompletionsResponse(resp, "chatcmpl_1")
require.NoError(t, err)
assert.Equal(t, "**Planning file inspection**\n\n**Clarifying environment task requirements**", chat.Choices[0].Message.GetReasoningContent())
assert.Equal(t, "I’ll inspect the starter repository.\n\nWhat would you like me to build?", chat.Choices[0].Message.StringContent())
}
func TestResponsesFinishReasonFromIncompleteStatus(t *testing.T) {
tests := []struct {
name string
reason string
want string
}{
{name: "max output", reason: responsesIncompleteReasonMaxTokens, want: "length"},
{name: "content filter", reason: responsesIncompleteReasonContentFilter, want: "content_filter"},
{name: "unknown", reason: "other", want: "length"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := ResponsesFinishReasonFromStatus(&dto.OpenAIResponsesResponse{
Status: []byte(`"incomplete"`),
IncompleteDetails: &dto.IncompleteDetails{Reason: tt.reason},
})
require.True(t, ok)
assert.Equal(t, tt.want, got)
})
}
}
func TestResponsesStreamEventToChatChunksUsesOutputIndexForToolArguments(t *testing.T) {
state := newTestResponsesStreamState()
outputIndex := 1
var chunks []dto.ChatCompletionsStreamResponse
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{Type: responsesEventCreated})...)
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{Type: responsesEventOutputTextDelta, Delta: "text before tool"})...)
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDelta,
OutputIndex: &outputIndex,
Delta: `{"cmd":"ls"}`,
})...)
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: &outputIndex,
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: "fc_1",
CallId: "call_1",
Name: "exec",
},
})...)
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventCompleted,
Response: &dto.OpenAIResponsesResponse{
Status: []byte(`"completed"`),
Usage: &dto.Usage{InputTokens: 1, OutputTokens: 2, TotalTokens: 3},
},
})...)
require.Len(t, chunks, 4)
assert.Equal(t, "assistant", chunks[0].Choices[0].Delta.Role)
assert.Equal(t, "text before tool", chunks[1].Choices[0].Delta.GetContentString())
tool := chunks[2].Choices[0].Delta.ToolCalls[0]
require.NotNil(t, tool.Index)
assert.Equal(t, 0, *tool.Index)
assert.Equal(t, "call_1", tool.ID)
assert.Equal(t, "exec", tool.Function.Name)
assert.Equal(t, `{"cmd":"ls"}`, tool.Function.Arguments)
require.NotNil(t, chunks[3].Choices[0].FinishReason)
assert.Equal(t, "tool_calls", *chunks[3].Choices[0].FinishReason)
assert.Equal(t, 3, state.Usage.TotalTokens)
}
func TestResponsesStreamEventToChatChunksDoesNotDuplicatePendingArgsWithOutputIndexAndItemID(t *testing.T) {
state := newTestResponsesStreamState()
outputIndex := 1
var chunks []dto.ChatCompletionsStreamResponse
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{Type: responsesEventCreated})...)
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDelta,
OutputIndex: &outputIndex,
ItemID: "fc_1",
Delta: `{"q":"x"}`,
})...)
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: &outputIndex,
ItemID: "fc_1",
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: "fc_1",
CallId: "call_1",
Name: "lookup",
},
})...)
require.Len(t, chunks, 2)
tool := chunks[1].Choices[0].Delta.ToolCalls[0]
assert.Equal(t, "call_1", tool.ID)
assert.Equal(t, "lookup", tool.Function.Name)
assert.Equal(t, `{"q":"x"}`, tool.Function.Arguments)
assert.Empty(t, state.pendingArgsByOutputIndex)
assert.Empty(t, state.pendingArgsByItemID)
}
func TestResponsesStreamEventToChatChunksDrainsItemOnlyPendingArgsWhenOutputIndexArrives(t *testing.T) {
state := newTestResponsesStreamState()
outputIndex := 1
var chunks []dto.ChatCompletionsStreamResponse
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{Type: responsesEventCreated})...)
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDelta,
ItemID: "fc_1",
Delta: `{"q":"x"}`,
})...)
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: &outputIndex,
ItemID: "fc_1",
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
CallId: "call_1",
Name: "lookup",
},
})...)
require.Len(t, chunks, 2)
tool := chunks[1].Choices[0].Delta.ToolCalls[0]
assert.Equal(t, "call_1", tool.ID)
assert.Equal(t, "lookup", tool.Function.Name)
assert.Equal(t, `{"q":"x"}`, tool.Function.Arguments)
assert.Empty(t, state.pendingArgsByOutputIndex)
assert.Empty(t, state.pendingArgsByItemID)
}
func TestResponsesStreamEventToChatChunksCustomToolAndReasoning(t *testing.T) {
state := newTestResponsesStreamState()
outputIndex := 0
chunks := mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventReasoningTextDelta,
Delta: "thinking",
})
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: &outputIndex,
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeCustomToolCall,
ID: "ct_1",
CallId: "call_custom",
Name: "apply_patch",
},
})...)
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventCustomToolInputDelta,
OutputIndex: &outputIndex,
Delta: "patch body",
})...)
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventIncomplete,
Response: &dto.OpenAIResponsesResponse{
IncompleteDetails: &dto.IncompleteDetails{Reason: responsesIncompleteReasonContentFilter},
},
})...)
require.Len(t, chunks, 5)
assert.Equal(t, "thinking", chunks[1].Choices[0].Delta.GetReasoningContent())
assert.Equal(t, "apply_patch", chunks[2].Choices[0].Delta.ToolCalls[0].Function.Name)
assert.Equal(t, "patch body", chunks[3].Choices[0].Delta.ToolCalls[0].Function.Arguments)
require.NotNil(t, chunks[4].Choices[0].FinishReason)
assert.Equal(t, "content_filter", *chunks[4].Choices[0].FinishReason)
}
func TestResponsesStreamEventToChatChunksUsesTerminalDoneOutput(t *testing.T) {
state := newTestResponsesStreamState()
chunks := mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventDone,
Response: &dto.OpenAIResponsesResponse{
Status: []byte(`"completed"`),
Output: []dto.ResponsesOutput{
{
Type: responsesOutputTypeMessage,
Role: "assistant",
Content: []dto.ResponsesOutputContent{
{Type: "output_text", Text: "terminal text"},
},
},
{
Type: responsesOutputTypeFunctionCall,
ID: "fc_1",
CallId: "call_1",
Name: "lookup",
Arguments: []byte(`{"q":"x"}`),
},
},
},
})
require.Len(t, chunks, 4)
assert.Equal(t, "assistant", chunks[0].Choices[0].Delta.Role)
assert.Equal(t, "terminal text", chunks[1].Choices[0].Delta.GetContentString())
tool := chunks[2].Choices[0].Delta.ToolCalls[0]
assert.Equal(t, "lookup", tool.Function.Name)
assert.Equal(t, `{"q":"x"}`, tool.Function.Arguments)
require.NotNil(t, chunks[3].Choices[0].FinishReason)
assert.Equal(t, "tool_calls", *chunks[3].Choices[0].FinishReason)
}
func TestResponsesStreamEventToChatChunksDoesNotResendToolOnTerminalOutput(t *testing.T) {
state := newTestResponsesStreamState()
outputIndex := 0
var chunks []dto.ChatCompletionsStreamResponse
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{Type: responsesEventCreated})...)
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: &outputIndex,
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: "fc_1",
CallId: "call_1",
Name: "lookup",
},
})...)
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDelta,
OutputIndex: &outputIndex,
Delta: `{"q":"x"}`,
})...)
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
Type: responsesEventCompleted,
Response: &dto.OpenAIResponsesResponse{
Status: []byte(`"completed"`),
Output: []dto.ResponsesOutput{
{
Type: responsesOutputTypeFunctionCall,
ID: "fc_1",
CallId: "call_1",
Name: "lookup",
Arguments: []byte(`{"q":"x"}`),
},
},
},
})...)
var totalArgs strings.Builder
toolIndexes := map[int]bool{}
var finishReason string
for _, chunk := range chunks {
for _, choice := range chunk.Choices {
for _, tc := range choice.Delta.ToolCalls {
require.NotNil(t, tc.Index)
toolIndexes[*tc.Index] = true
totalArgs.WriteString(tc.Function.Arguments)
}
if choice.FinishReason != nil {
finishReason = *choice.FinishReason
}
}
}
assert.Equal(t, map[int]bool{0: true}, toolIndexes)
assert.Equal(t, `{"q":"x"}`, totalArgs.String())
assert.Equal(t, "tool_calls", finishReason)
}
func TestFinalizeResponsesToChatStreamFlushesPendingDeltaOnlyArguments(t *testing.T) {
state := newTestResponsesStreamState()
outputIndex := 2
_, err := ResponsesStreamEventToChatChunks(&dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDelta,
OutputIndex: &outputIndex,
Delta: `{"pending":true}`,
}, state)
require.NoError(t, err)
chunks := FinalizeResponsesToChatStream(state)
require.Len(t, chunks, 3)
tool := chunks[1].Choices[0].Delta.ToolCalls[0]
assert.Equal(t, "call_output_2", tool.ID)
assert.Equal(t, `{"pending":true}`, tool.Function.Arguments)
require.NotNil(t, chunks[2].Choices[0].FinishReason)
assert.Equal(t, "tool_calls", *chunks[2].Choices[0].FinishReason)
}
func TestResponsesStreamEventToChatChunksFailedEventReturnsError(t *testing.T) {
_, err := ResponsesStreamEventToChatChunks(&dto.ResponsesStreamResponse{Type: responsesEventFailed}, newTestResponsesStreamState())
require.Error(t, err)
}
func TestResponsesBufferedAccumulatorSupplementsEmptyTerminalOutput(t *testing.T) {
acc := NewResponsesBufferedAccumulator()
outputIndex := 1
acc.ProcessEvent(&dto.ResponsesStreamResponse{Type: responsesEventOutputTextDelta, Delta: "buffered text"})
acc.ProcessEvent(&dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: &outputIndex,
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: "fc_1",
CallId: "call_1",
Name: "lookup",
},
})
acc.ProcessEvent(&dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDelta,
OutputIndex: &outputIndex,
Delta: `{"q":"x"}`,
})
resp := &dto.OpenAIResponsesResponse{
Status: []byte(`"completed"`),
Model: "gpt-test",
}
acc.SupplementResponseOutput(resp)
chat, _, err := ResponsesResponseToChatCompletionsResponse(resp, "chatcmpl_1")
require.NoError(t, err)
assert.Equal(t, "buffered text", chat.Choices[0].Message.StringContent())
toolCalls := chat.Choices[0].Message.ParseToolCalls()
require.Len(t, toolCalls, 1)
assert.Equal(t, `{"q":"x"}`, toolCalls[0].Function.Arguments)
}
func TestResponsesBufferedAccumulatorDoesNotDuplicatePendingArgsWithOutputIndexAndItemID(t *testing.T) {
acc := NewResponsesBufferedAccumulator()
outputIndex := 1
acc.ProcessEvent(&dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDelta,
OutputIndex: &outputIndex,
ItemID: "fc_1",
Delta: `{"q":"x"}`,
})
acc.ProcessEvent(&dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: &outputIndex,
ItemID: "fc_1",
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: "fc_1",
CallId: "call_1",
Name: "lookup",
},
})
resp := &dto.OpenAIResponsesResponse{
Status: []byte(`"completed"`),
Model: "gpt-test",
}
acc.SupplementResponseOutput(resp)
chat, _, err := ResponsesResponseToChatCompletionsResponse(resp, "chatcmpl_1")
require.NoError(t, err)
toolCalls := chat.Choices[0].Message.ParseToolCalls()
require.Len(t, toolCalls, 1)
assert.Equal(t, `{"q":"x"}`, toolCalls[0].Function.Arguments)
assert.Empty(t, acc.pendingByOutputIndex)
assert.Empty(t, acc.pendingByItemID)
}
func TestResponsesBufferedAccumulatorPreservesInterleavedReasoningAndTextItems(t *testing.T) {
acc := NewResponsesBufferedAccumulator()
events := []dto.ResponsesStreamResponse{
bufferedOutputItemAdded(0, "rs_1", responsesOutputTypeReasoning),
{Type: responsesEventReasoningSummaryDelta, OutputIndex: intPointer(0), ItemID: "rs_1", Delta: "**Planning file inspection**"},
bufferedOutputItemAdded(1, "msg_1", responsesOutputTypeMessage),
{Type: responsesEventOutputTextDelta, OutputIndex: intPointer(1), ItemID: "msg_1", Delta: "I’ll inspect the starter repository."},
bufferedOutputItemAdded(2, "rs_2", responsesOutputTypeReasoning),
{Type: responsesEventReasoningSummaryDelta, OutputIndex: intPointer(2), ItemID: "rs_2", Delta: "**Clarifying environment task requirements**"},
bufferedOutputItemAdded(3, "msg_2", responsesOutputTypeMessage),
{Type: responsesEventOutputTextDelta, OutputIndex: intPointer(3), ItemID: "msg_2", Delta: "What would you like me to build?"},
}
for index := range events {
acc.ProcessEvent(&events[index])
}
output := acc.BuildOutput()
require.Len(t, output, 4)
assert.Equal(t, []string{
responsesOutputTypeReasoning,
responsesOutputTypeMessage,
responsesOutputTypeReasoning,
responsesOutputTypeMessage,
}, []string{output[0].Type, output[1].Type, output[2].Type, output[3].Type})
assert.Equal(t, "**Planning file inspection**", output[0].Summary[0].Text)
assert.Equal(t, "I’ll inspect the starter repository.", output[1].Content[0].Text)
assert.Equal(t, "**Clarifying environment task requirements**", output[2].Summary[0].Text)
assert.Equal(t, "What would you like me to build?", output[3].Content[0].Text)
}
func TestResponsesStreamTerminalOutputPreservesInterleavedReasoningAndTextItems(t *testing.T) {
state := newTestResponsesStreamState()
chunks, err := ResponsesStreamEventToChatChunks(&dto.ResponsesStreamResponse{
Type: responsesEventCompleted,
Response: &dto.OpenAIResponsesResponse{
Status: []byte(`"completed"`),
Output: interleavedReasoningAndTextOutput(),
},
}, state)
require.NoError(t, err)
var deltas []string
for _, chunk := range chunks {
if len(chunk.Choices) == 0 {
continue
}
delta := chunk.Choices[0].Delta
if delta.ReasoningContent != nil {
deltas = append(deltas, "thinking:"+*delta.ReasoningContent)
}
if delta.Content != nil && *delta.Content != "" {
deltas = append(deltas, "text:"+*delta.Content)
}
}
assert.Equal(t, []string{
"thinking:**Planning file inspection**",
"text:I’ll inspect the starter repository.",
"thinking:**Clarifying environment task requirements**",
"text:What would you like me to build?",
}, deltas)
}
func bufferedOutputItemAdded(outputIndex int, itemID string, itemType string) dto.ResponsesStreamResponse {
return dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: &outputIndex,
ItemID: itemID,
Item: &dto.ResponsesOutput{ID: itemID, Type: itemType},
}
}
func interleavedReasoningAndTextOutput() []dto.ResponsesOutput {
return []dto.ResponsesOutput{
{Type: responsesOutputTypeReasoning, Summary: []dto.ResponsesReasoningSummaryPart{{Type: "summary_text", Text: "**Planning file inspection**"}}},
{Type: responsesOutputTypeMessage, Role: "assistant", Content: []dto.ResponsesOutputContent{{Type: "output_text", Text: "I’ll inspect the starter repository."}}},
{Type: responsesOutputTypeReasoning, Summary: []dto.ResponsesReasoningSummaryPart{{Type: "summary_text", Text: "**Clarifying environment task requirements**"}}},
{Type: responsesOutputTypeMessage, Role: "assistant", Content: []dto.ResponsesOutputContent{{Type: "output_text", Text: "What would you like me to build?"}}},
}
}
func intPointer(value int) *int {
return &value
}
func newTestResponsesStreamState() *ResponsesToChatStreamState {
state := NewResponsesToChatStreamState("gpt-test", false)
state.ID = "chatcmpl_test"
state.Created = 123
return state
}
func mustStreamChunks(t *testing.T, state *ResponsesToChatStreamState, event *dto.ResponsesStreamResponse) []dto.ChatCompletionsStreamResponse {
t.Helper()
chunks, err := ResponsesStreamEventToChatChunks(event, state)
require.NoError(t, err)
return chunks
}
@@ -0,0 +1,934 @@
package oairesponses
import (
"fmt"
"sort"
"strings"
"time"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
type ResponsesToChatStreamState struct {
ID string
Model string
Created int64
IncludeUsage bool
Usage *dto.Usage
sentStart bool
finalized bool
hasSentText bool
sentAnnotationCount int
sawToolCall bool
hasSentReasoning bool
needsReasoningSummaryBreak bool
nextToolIndex int
toolByKey map[string]*responsesStreamTool
outputIndexToKey map[int]string
itemIDToKey map[string]string
callIDToKey map[string]string
pendingArgsByOutputIndex map[int]string
pendingArgsByItemID map[string]string
usageText strings.Builder
}
type responsesStreamTool struct {
Key string
CallID string
ItemID string
Name string
Arguments string
Index int
Sent bool
NameSent bool
ArgsSentAt int
}
func NewResponsesToChatStreamState(model string, includeUsage bool) *ResponsesToChatStreamState {
return &ResponsesToChatStreamState{
Model: model,
Created: time.Now().Unix(),
IncludeUsage: includeUsage,
Usage: &dto.Usage{},
toolByKey: make(map[string]*responsesStreamTool),
outputIndexToKey: make(map[int]string),
itemIDToKey: make(map[string]string),
callIDToKey: make(map[string]string),
pendingArgsByOutputIndex: make(map[int]string),
pendingArgsByItemID: make(map[string]string),
}
}
func (s *ResponsesToChatStreamState) StreamUsage() *dto.Usage {
if s == nil {
return nil
}
return s.Usage
}
func (s *ResponsesToChatStreamState) SetStreamUsage(usage *dto.Usage) {
if s != nil && usage != nil {
s.Usage = usage
}
}
func (s *ResponsesToChatStreamState) UsageText() string {
if s == nil {
return ""
}
return s.usageText.String()
}
func ResponsesStreamEventToChatChunks(event *dto.ResponsesStreamResponse, state *ResponsesToChatStreamState) ([]dto.ChatCompletionsStreamResponse, error) {
if event == nil || state == nil {
return nil, nil
}
switch event.Type {
case responsesEventCreated:
state.applyResponseMetadata(event.Response)
return state.ensureStart(), nil
case responsesEventReasoningSummaryDelta, responsesEventReasoningTextDelta:
return state.reasoningDelta(event.Delta), nil
case responsesEventReasoningSummaryDone, responsesEventReasoningTextDone:
if state.hasSentReasoning {
state.needsReasoningSummaryBreak = true
}
return nil, nil
case responsesEventOutputTextDelta:
return state.textDelta(event.Delta), nil
case responsesEventOutputTextAnnotationAdded:
return state.annotationRawDelta(event.Annotation)
case responsesEventOutputItemAdded, responsesEventOutputItemDone:
if event.Item == nil || !isResponsesToolOutputType(event.Item.Type) {
return nil, nil
}
return state.toolItem(event), nil
case responsesEventFunctionArgsDelta, responsesEventCustomToolInputDelta:
return state.toolArgumentsDelta(event), nil
case responsesEventFunctionArgsDone, responsesEventCustomToolInputDone:
return state.flushPendingTool(event), nil
case responsesEventCompleted, responsesEventDone, responsesEventIncomplete:
response := event.Response
if event.Type == responsesEventIncomplete {
response = ensureIncompleteResponse(response)
}
state.applyResponseMetadata(response)
chunks, err := state.terminalOutputChunks(response)
if err != nil {
return nil, err
}
chunks = append(chunks, state.finalize(response)...)
return chunks, nil
case responsesEventFailed, responsesEventError:
return nil, fmt.Errorf("responses stream error: %s", event.Type)
default:
return nil, nil
}
}
func FinalizeResponsesToChatStream(state *ResponsesToChatStreamState) []dto.ChatCompletionsStreamResponse {
if state == nil {
return nil
}
return state.finalize(nil)
}
func (s *ResponsesToChatStreamState) applyResponseMetadata(response *dto.OpenAIResponsesResponse) {
if response == nil {
return
}
if response.ID != "" && s.ID == "" {
s.ID = response.ID
}
if response.Model != "" {
s.Model = response.Model
}
if response.CreatedAt != 0 {
s.Created = int64(response.CreatedAt)
}
if response.Usage != nil {
s.Usage = dto.MergeUsageNonZero(s.Usage, UsageFromResponsesUsage(response.Usage))
}
}
func (s *ResponsesToChatStreamState) ensureStart() []dto.ChatCompletionsStreamResponse {
if s.sentStart {
return nil
}
s.sentStart = true
return []dto.ChatCompletionsStreamResponse{s.makeChunk(dto.ChatCompletionsStreamResponseChoiceDelta{
Role: "assistant",
Content: kitutil.GetPointer(""),
}, nil)}
}
func (s *ResponsesToChatStreamState) textDelta(delta string) []dto.ChatCompletionsStreamResponse {
if delta == "" {
return nil
}
s.usageText.WriteString(delta)
s.hasSentText = true
chunks := s.ensureStart()
chunks = append(chunks, s.makeChunk(dto.ChatCompletionsStreamResponseChoiceDelta{
Content: &delta,
}, nil))
return chunks
}
func (s *ResponsesToChatStreamState) terminalOutputChunks(response *dto.OpenAIResponsesResponse) ([]dto.ChatCompletionsStreamResponse, error) {
if s == nil || response == nil || len(response.Output) == 0 {
return nil, nil
}
var chunks []dto.ChatCompletionsStreamResponse
hadSentText := s.hasSentText
hadSentReasoning := s.hasSentReasoning
annotationOffset := 0
for i := range response.Output {
out := &response.Output[i]
switch {
case out.Type == responsesOutputTypeMessage && !hadSentText:
var text strings.Builder
for _, c := range out.Content {
if c.Type == "output_text" && c.Text != "" {
text.WriteString(c.Text)
}
}
chunks = append(chunks, s.textDelta(text.String())...)
annotationChunks, err := s.remainingAnnotationChunks(out, annotationOffset)
if err != nil {
return nil, err
}
chunks = append(chunks, annotationChunks...)
annotationOffset += responsesOutputAnnotationCount(out)
case out.Type == responsesOutputTypeMessage:
annotationChunks, err := s.remainingAnnotationChunks(out, annotationOffset)
if err != nil {
return nil, err
}
chunks = append(chunks, annotationChunks...)
annotationOffset += responsesOutputAnnotationCount(out)
case out.Type == responsesOutputTypeReasoning && !hadSentReasoning:
chunks = append(chunks, s.reasoningDelta(reasoningOutputText(out))...)
case isResponsesToolOutputType(out.Type):
chunks = append(chunks, s.toolItem(&dto.ResponsesStreamResponse{Item: out})...)
}
}
return chunks, nil
}
func (s *ResponsesToChatStreamState) annotationRawDelta(raw []byte) ([]dto.ChatCompletionsStreamResponse, error) {
if len(raw) == 0 {
return nil, nil
}
var annotation map[string]any
if err := kitutil.Unmarshal(raw, &annotation); err != nil {
return nil, fmt.Errorf("invalid Responses stream annotation: %w", err)
}
return s.annotationDelta(annotation)
}
func (s *ResponsesToChatStreamState) annotationDelta(annotation any) ([]dto.ChatCompletionsStreamResponse, error) {
converted, err := responseAnnotationToChat(annotation)
if err != nil {
return nil, err
}
raw, err := kitutil.Marshal([]any{converted})
if err != nil {
return nil, fmt.Errorf("marshal Chat annotation: %w", err)
}
s.sentAnnotationCount++
chunks := s.ensureStart()
chunks = append(chunks, s.makeChunk(dto.ChatCompletionsStreamResponseChoiceDelta{
Annotations: raw,
}, nil))
return chunks, nil
}
func (s *ResponsesToChatStreamState) remainingAnnotationChunks(output *dto.ResponsesOutput, offset int) ([]dto.ChatCompletionsStreamResponse, error) {
if output == nil {
return nil, nil
}
annotations := make([]any, 0)
for _, content := range output.Content {
annotations = append(annotations, content.Annotations...)
}
start := max(s.sentAnnotationCount-offset, 0)
if start >= len(annotations) {
return nil, nil
}
var chunks []dto.ChatCompletionsStreamResponse
for _, annotation := range annotations[start:] {
converted, err := s.annotationDelta(annotation)
if err != nil {
return nil, err
}
chunks = append(chunks, converted...)
}
return chunks, nil
}
func responsesOutputAnnotationCount(output *dto.ResponsesOutput) int {
count := 0
for _, content := range output.Content {
count += len(content.Annotations)
}
return count
}
func (s *ResponsesToChatStreamState) reasoningDelta(delta string) []dto.ChatCompletionsStreamResponse {
if delta == "" {
return nil
}
if s.needsReasoningSummaryBreak {
if strings.HasPrefix(delta, "\n\n") {
s.needsReasoningSummaryBreak = false
} else if strings.HasPrefix(delta, "\n") {
delta = "\n" + delta
s.needsReasoningSummaryBreak = false
} else {
delta = "\n\n" + delta
s.needsReasoningSummaryBreak = false
}
}
s.usageText.WriteString(delta)
chunks := s.ensureStart()
chunks = append(chunks, s.makeChunk(dto.ChatCompletionsStreamResponseChoiceDelta{
ReasoningContent: &delta,
}, nil))
s.hasSentReasoning = true
return chunks
}
func (s *ResponsesToChatStreamState) toolItem(event *dto.ResponsesStreamResponse) []dto.ChatCompletionsStreamResponse {
tool := s.ensureToolForEvent(event)
if tool == nil {
return nil
}
args := event.Item.ArgumentsString()
if args != "" {
tool.Arguments = args
}
return s.toolDelta(tool, "")
}
func (s *ResponsesToChatStreamState) toolArgumentsDelta(event *dto.ResponsesStreamResponse) []dto.ChatCompletionsStreamResponse {
if event.Delta == "" {
return nil
}
tool := s.findToolForEvent(event)
if tool == nil {
if event.OutputIndex != nil {
s.pendingArgsByOutputIndex[*event.OutputIndex] += event.Delta
} else if itemID := strings.TrimSpace(event.ItemID); itemID != "" {
s.pendingArgsByItemID[itemID] += event.Delta
}
return nil
}
tool.Arguments += event.Delta
return s.toolDelta(tool, event.Delta)
}
func (s *ResponsesToChatStreamState) flushPendingTool(event *dto.ResponsesStreamResponse) []dto.ChatCompletionsStreamResponse {
tool := s.findToolForEvent(event)
if tool == nil {
tool = s.ensureFallbackToolForEvent(event)
}
if tool == nil {
return nil
}
return s.toolDelta(tool, "")
}
func (s *ResponsesToChatStreamState) ensureToolForEvent(event *dto.ResponsesStreamResponse) *responsesStreamTool {
if event == nil || event.Item == nil {
return nil
}
key := s.keyForEvent(event)
if key == "" {
key = fallbackToolKey(event.Item.ID, event.Item.CallId, event.OutputIndex)
}
if key == "" {
return nil
}
tool := s.toolByKey[key]
if tool == nil {
if itemID := responseStreamEventItemID(event); itemID != "" {
if existingKey := s.itemIDToKey[itemID]; existingKey != "" {
tool = s.toolByKey[existingKey]
}
}
if tool == nil {
if callID := strings.TrimSpace(event.Item.CallId); callID != "" {
if existingKey := s.callIDToKey[callID]; existingKey != "" {
tool = s.toolByKey[existingKey]
}
}
}
if tool != nil {
s.toolByKey[key] = tool
}
}
if tool == nil {
tool = &responsesStreamTool{Key: key, Index: s.nextToolIndex}
s.nextToolIndex++
s.toolByKey[key] = tool
}
if event.OutputIndex != nil {
s.outputIndexToKey[*event.OutputIndex] = key
if pending := s.pendingArgsByOutputIndex[*event.OutputIndex]; pending != "" {
tool.Arguments += pending
delete(s.pendingArgsByOutputIndex, *event.OutputIndex)
}
}
if itemID := responseStreamEventItemID(event); itemID != "" {
tool.ItemID = itemID
s.itemIDToKey[itemID] = key
if pending := s.pendingArgsByItemID[itemID]; pending != "" {
tool.Arguments += pending
delete(s.pendingArgsByItemID, itemID)
}
}
if callID := strings.TrimSpace(event.Item.CallId); callID != "" {
tool.CallID = callID
s.callIDToKey[callID] = key
} else if tool.CallID == "" {
tool.CallID = strings.TrimSpace(event.Item.ID)
}
if name := strings.TrimSpace(event.Item.Name); name != "" {
tool.Name = name
}
return tool
}
func (s *ResponsesToChatStreamState) findToolForEvent(event *dto.ResponsesStreamResponse) *responsesStreamTool {
if event == nil {
return nil
}
if event.OutputIndex != nil {
if key := s.outputIndexToKey[*event.OutputIndex]; key != "" {
return s.toolByKey[key]
}
}
if itemID := strings.TrimSpace(event.ItemID); itemID != "" {
if key := s.itemIDToKey[itemID]; key != "" {
return s.toolByKey[key]
}
}
if event.Item != nil {
if key := s.keyForEvent(event); key != "" {
return s.toolByKey[key]
}
}
return nil
}
func (s *ResponsesToChatStreamState) ensureFallbackToolForEvent(event *dto.ResponsesStreamResponse) *responsesStreamTool {
if event == nil {
return nil
}
key := ""
if event.OutputIndex != nil {
key = fmt.Sprintf("output:%d", *event.OutputIndex)
}
if key == "" && strings.TrimSpace(event.ItemID) != "" {
key = "item:" + strings.TrimSpace(event.ItemID)
}
if key == "" {
return nil
}
tool := s.toolByKey[key]
if tool == nil {
tool = &responsesStreamTool{
Key: key,
Index: s.nextToolIndex,
CallID: fallbackCallID(event),
}
s.nextToolIndex++
s.toolByKey[key] = tool
}
if event.OutputIndex != nil {
s.outputIndexToKey[*event.OutputIndex] = key
if pending := s.pendingArgsByOutputIndex[*event.OutputIndex]; pending != "" {
tool.Arguments += pending
delete(s.pendingArgsByOutputIndex, *event.OutputIndex)
}
}
if itemID := responseStreamEventItemID(event); itemID != "" {
tool.ItemID = itemID
s.itemIDToKey[itemID] = key
if pending := s.pendingArgsByItemID[itemID]; pending != "" {
tool.Arguments += pending
delete(s.pendingArgsByItemID, itemID)
}
}
return tool
}
func (s *ResponsesToChatStreamState) toolDelta(tool *responsesStreamTool, explicitDelta string) []dto.ChatCompletionsStreamResponse {
if tool == nil {
return nil
}
argsDelta := explicitDelta
if argsDelta == "" && len(tool.Arguments) > tool.ArgsSentAt {
argsDelta = tool.Arguments[tool.ArgsSentAt:]
}
if tool.Sent && argsDelta == "" && (tool.Name == "" || tool.NameSent) {
return nil
}
chunks := s.ensureStart()
callID := strings.TrimSpace(tool.CallID)
if callID == "" {
callID = tool.Key
}
responseTool := dto.ToolCallResponse{
ID: callID,
Type: "function",
Function: dto.FunctionResponse{
Arguments: argsDelta,
},
}
responseTool.SetIndex(tool.Index)
if !tool.NameSent && tool.Name != "" {
responseTool.Function.Name = tool.Name
tool.NameSent = true
}
if !tool.Sent {
tool.Sent = true
}
if argsDelta != "" {
tool.ArgsSentAt += len(argsDelta)
s.usageText.WriteString(argsDelta)
}
if responseTool.Function.Name != "" {
s.usageText.WriteString(responseTool.Function.Name)
}
chunks = append(chunks, s.makeChunk(dto.ChatCompletionsStreamResponseChoiceDelta{
ToolCalls: []dto.ToolCallResponse{responseTool},
}, nil))
s.sawToolCall = true
return chunks
}
func (s *ResponsesToChatStreamState) finalize(response *dto.OpenAIResponsesResponse) []dto.ChatCompletionsStreamResponse {
if s.finalized {
return nil
}
s.finalized = true
chunks := s.flushAllPendingTools()
chunks = append(chunks, s.ensureStart()...)
finishReason := "stop"
if mappedReason, ok := ResponsesFinishReasonFromStatus(response); ok {
finishReason = mappedReason
} else if s.sawToolCall {
finishReason = "tool_calls"
}
chunks = append(chunks, s.makeChunk(dto.ChatCompletionsStreamResponseChoiceDelta{}, &finishReason))
if s.IncludeUsage && s.Usage != nil {
chunks = append(chunks, dto.ChatCompletionsStreamResponse{
Id: s.ID,
Object: "chat.completion.chunk",
Created: s.Created,
Model: s.Model,
Choices: make([]dto.ChatCompletionsStreamResponseChoice, 0),
Usage: s.Usage,
})
}
return chunks
}
func (s *ResponsesToChatStreamState) flushAllPendingTools() []dto.ChatCompletionsStreamResponse {
keys := make([]string, 0, len(s.toolByKey)+len(s.pendingArgsByOutputIndex)+len(s.pendingArgsByItemID))
seen := make(map[string]bool)
for key := range s.toolByKey {
keys = append(keys, key)
seen[key] = true
}
for outputIndex := range s.pendingArgsByOutputIndex {
key := fmt.Sprintf("output:%d", outputIndex)
if !seen[key] {
keys = append(keys, key)
seen[key] = true
}
}
for itemID := range s.pendingArgsByItemID {
key := "item:" + itemID
if !seen[key] {
keys = append(keys, key)
seen[key] = true
}
}
sort.Strings(keys)
var chunks []dto.ChatCompletionsStreamResponse
for _, key := range keys {
tool := s.toolByKey[key]
if tool == nil {
callID := strings.TrimPrefix(key, "item:")
if after, ok := strings.CutPrefix(key, "output:"); ok {
callID = "call_output_" + after
}
tool = &responsesStreamTool{
Key: key,
Index: s.nextToolIndex,
CallID: callID,
}
s.nextToolIndex++
s.toolByKey[key] = tool
}
if strings.HasPrefix(key, "output:") {
var outputIndex int
if _, err := fmt.Sscanf(key, "output:%d", &outputIndex); err == nil {
tool.Arguments += s.pendingArgsByOutputIndex[outputIndex]
delete(s.pendingArgsByOutputIndex, outputIndex)
}
}
if after, ok := strings.CutPrefix(key, "item:"); ok {
itemID := after
tool.Arguments += s.pendingArgsByItemID[itemID]
delete(s.pendingArgsByItemID, itemID)
}
chunks = append(chunks, s.toolDelta(tool, "")...)
}
return chunks
}
func (s *ResponsesToChatStreamState) makeChunk(delta dto.ChatCompletionsStreamResponseChoiceDelta, finishReason *string) dto.ChatCompletionsStreamResponse {
return dto.ChatCompletionsStreamResponse{
Id: s.ID,
Object: "chat.completion.chunk",
Created: s.Created,
Model: s.Model,
Choices: []dto.ChatCompletionsStreamResponseChoice{
{
Index: 0,
Delta: delta,
FinishReason: finishReason,
},
},
}
}
func (s *ResponsesToChatStreamState) keyForEvent(event *dto.ResponsesStreamResponse) string {
if event == nil {
return ""
}
if event.OutputIndex != nil {
return fmt.Sprintf("output:%d", *event.OutputIndex)
}
if event.Item != nil {
if itemID := strings.TrimSpace(event.Item.ID); itemID != "" {
return "item:" + itemID
}
if callID := strings.TrimSpace(event.Item.CallId); callID != "" {
return "call:" + callID
}
}
if itemID := strings.TrimSpace(event.ItemID); itemID != "" {
return "item:" + itemID
}
return ""
}
type ResponsesBufferedAccumulator struct {
items []*responsesBufferedItem
outputIndexToItemIdx map[int]int
itemIDToItemIdx map[string]int
lastUnindexedItemIdx int
tools []*responsesBufferedTool
outputIndexToToolIdx map[int]int
itemIDToToolIdx map[string]int
pendingByOutputIndex map[int]string
pendingByItemID map[string]string
}
type responsesBufferedItem struct {
Type string
ID string
Text strings.Builder
Annotations []any
ToolIndex int
NeedsReasoningBreak bool
}
type responsesBufferedTool struct {
CallID string
ItemID string
Name string
Arguments strings.Builder
}
func NewResponsesBufferedAccumulator() *ResponsesBufferedAccumulator {
return &ResponsesBufferedAccumulator{
outputIndexToItemIdx: make(map[int]int),
itemIDToItemIdx: make(map[string]int),
lastUnindexedItemIdx: -1,
outputIndexToToolIdx: make(map[int]int),
itemIDToToolIdx: make(map[string]int),
pendingByOutputIndex: make(map[int]string),
pendingByItemID: make(map[string]string),
}
}
func (a *ResponsesBufferedAccumulator) ProcessEvent(event *dto.ResponsesStreamResponse) {
if a == nil || event == nil {
return
}
switch event.Type {
case responsesEventOutputTextDelta:
item := a.ensureItem(event, responsesOutputTypeMessage)
item.Text.WriteString(event.Delta)
case responsesEventOutputTextAnnotationAdded:
item := a.ensureItem(event, responsesOutputTypeMessage)
var annotation any
if err := kitutil.Unmarshal(event.Annotation, &annotation); err == nil && annotation != nil {
item.Annotations = append(item.Annotations, annotation)
}
case responsesEventReasoningSummaryDelta, responsesEventReasoningTextDelta:
item := a.ensureItem(event, responsesOutputTypeReasoning)
if item.NeedsReasoningBreak {
appendSeparatedText(&item.Text, event.Delta)
item.NeedsReasoningBreak = false
} else {
item.Text.WriteString(event.Delta)
}
case responsesEventReasoningSummaryDone, responsesEventReasoningTextDone:
item := a.ensureItem(event, responsesOutputTypeReasoning)
if item.Text.Len() == 0 && event.Text != nil {
item.Text.WriteString(*event.Text)
}
if item.Text.Len() > 0 {
item.NeedsReasoningBreak = true
}
case responsesEventOutputItemAdded, responsesEventOutputItemDone:
if event.Item == nil {
return
}
switch {
case event.Item.Type == responsesOutputTypeReasoning:
item := a.ensureItem(event, event.Item.Type)
if item.Text.Len() == 0 {
item.Text.WriteString(reasoningOutputText(event.Item))
}
case event.Item.Type == responsesOutputTypeMessage:
item := a.ensureItem(event, event.Item.Type)
seedText := item.Text.Len() == 0
seedAnnotations := len(item.Annotations) == 0
for _, content := range event.Item.Content {
if content.Type == "output_text" {
if seedText {
item.Text.WriteString(content.Text)
}
if seedAnnotations {
item.Annotations = append(item.Annotations, content.Annotations...)
}
}
}
case isResponsesToolOutputType(event.Item.Type):
tool := a.ensureTool(event)
if args := event.Item.ArgumentsString(); args != "" {
tool.Arguments.Reset()
tool.Arguments.WriteString(args)
}
}
case responsesEventFunctionArgsDelta, responsesEventCustomToolInputDelta:
if idx, ok := a.findToolIndex(event); ok {
a.tools[idx].Arguments.WriteString(event.Delta)
return
}
if event.OutputIndex != nil {
a.pendingByOutputIndex[*event.OutputIndex] += event.Delta
} else if itemID := strings.TrimSpace(event.ItemID); itemID != "" {
a.pendingByItemID[itemID] += event.Delta
}
}
}
func (a *ResponsesBufferedAccumulator) SupplementResponseOutput(resp *dto.OpenAIResponsesResponse) {
if a == nil || resp == nil || len(resp.Output) > 0 {
return
}
resp.Output = a.BuildOutput()
}
func (a *ResponsesBufferedAccumulator) BuildOutput() []dto.ResponsesOutput {
if a == nil {
return nil
}
out := make([]dto.ResponsesOutput, 0, len(a.items))
for _, item := range a.items {
if item == nil {
continue
}
switch item.Type {
case responsesOutputTypeReasoning:
if item.Text.Len() == 0 {
continue
}
out = append(out, dto.ResponsesOutput{
Type: item.Type,
ID: item.ID,
Summary: []dto.ResponsesReasoningSummaryPart{
{Type: "summary_text", Text: item.Text.String()},
},
})
case responsesOutputTypeMessage:
if item.Text.Len() == 0 {
continue
}
out = append(out, dto.ResponsesOutput{
Type: item.Type,
ID: item.ID,
Role: "assistant",
Content: []dto.ResponsesOutputContent{
{Type: "output_text", Text: item.Text.String(), Annotations: item.Annotations},
},
})
case responsesOutputTypeFunctionCall, responsesOutputTypeCustomToolCall:
if item.ToolIndex < 0 || item.ToolIndex >= len(a.tools) || a.tools[item.ToolIndex] == nil {
continue
}
tool := a.tools[item.ToolIndex]
argsRaw, _ := kitutil.Marshal(tool.Arguments.String())
out = append(out, dto.ResponsesOutput{
Type: item.Type,
ID: tool.ItemID,
CallId: tool.CallID,
Name: tool.Name,
Arguments: argsRaw,
})
}
}
return out
}
func (a *ResponsesBufferedAccumulator) ensureItem(event *dto.ResponsesStreamResponse, itemType string) *responsesBufferedItem {
if idx, ok := a.findItemIndex(event); ok {
item := a.items[idx]
if item.Type == "" {
item.Type = itemType
}
a.applyItemMetadata(idx, item, event)
return item
}
if event != nil && event.OutputIndex == nil && responseStreamEventItemID(event) == "" && a.lastUnindexedItemIdx >= 0 {
item := a.items[a.lastUnindexedItemIdx]
if item != nil && item.Type == itemType {
return item
}
}
item := &responsesBufferedItem{Type: itemType, ToolIndex: -1}
idx := len(a.items)
a.items = append(a.items, item)
a.lastUnindexedItemIdx = idx
a.applyItemMetadata(idx, item, event)
return item
}
func (a *ResponsesBufferedAccumulator) applyItemMetadata(idx int, item *responsesBufferedItem, event *dto.ResponsesStreamResponse) {
if item == nil || event == nil {
return
}
if event.OutputIndex != nil {
a.outputIndexToItemIdx[*event.OutputIndex] = idx
}
if itemID := responseStreamEventItemID(event); itemID != "" {
item.ID = itemID
a.itemIDToItemIdx[itemID] = idx
}
}
func (a *ResponsesBufferedAccumulator) findItemIndex(event *dto.ResponsesStreamResponse) (int, bool) {
if event == nil {
return 0, false
}
if event.OutputIndex != nil {
if idx, ok := a.outputIndexToItemIdx[*event.OutputIndex]; ok {
return idx, true
}
}
if itemID := responseStreamEventItemID(event); itemID != "" {
idx, ok := a.itemIDToItemIdx[itemID]
return idx, ok
}
return 0, false
}
func (a *ResponsesBufferedAccumulator) ensureTool(event *dto.ResponsesStreamResponse) *responsesBufferedTool {
if idx, ok := a.findToolIndex(event); ok {
tool := a.tools[idx]
a.applyToolMetadata(tool, event)
item := a.ensureItem(event, event.Item.Type)
item.ToolIndex = idx
return tool
}
tool := &responsesBufferedTool{}
a.applyToolMetadata(tool, event)
idx := len(a.tools)
a.tools = append(a.tools, tool)
item := a.ensureItem(event, event.Item.Type)
item.ToolIndex = idx
if event.OutputIndex != nil {
a.outputIndexToToolIdx[*event.OutputIndex] = idx
if pending := a.pendingByOutputIndex[*event.OutputIndex]; pending != "" {
tool.Arguments.WriteString(pending)
delete(a.pendingByOutputIndex, *event.OutputIndex)
}
}
if tool.ItemID != "" {
a.itemIDToToolIdx[tool.ItemID] = idx
if pending := a.pendingByItemID[tool.ItemID]; pending != "" {
tool.Arguments.WriteString(pending)
delete(a.pendingByItemID, tool.ItemID)
}
}
return tool
}
func (a *ResponsesBufferedAccumulator) applyToolMetadata(tool *responsesBufferedTool, event *dto.ResponsesStreamResponse) {
if tool == nil || event == nil || event.Item == nil {
return
}
if itemID := strings.TrimSpace(event.Item.ID); itemID != "" {
tool.ItemID = itemID
}
if callID := strings.TrimSpace(event.Item.CallId); callID != "" {
tool.CallID = callID
} else if tool.CallID == "" {
tool.CallID = strings.TrimSpace(event.Item.ID)
}
if name := strings.TrimSpace(event.Item.Name); name != "" {
tool.Name = name
}
}
func (a *ResponsesBufferedAccumulator) findToolIndex(event *dto.ResponsesStreamResponse) (int, bool) {
if event == nil {
return 0, false
}
if event.OutputIndex != nil {
if idx, ok := a.outputIndexToToolIdx[*event.OutputIndex]; ok {
return idx, true
}
}
itemID := strings.TrimSpace(event.ItemID)
if itemID == "" && event.Item != nil {
itemID = strings.TrimSpace(event.Item.ID)
}
if itemID != "" {
idx, ok := a.itemIDToToolIdx[itemID]
return idx, ok
}
return 0, false
}
@@ -0,0 +1,9 @@
package claude
func NormalizeCacheCreationSplit(totalTokens int, tokens5m int, tokens1h int) (int, int) {
remainder := totalTokens - tokens5m - tokens1h
if remainder < 0 {
remainder = 0
}
return tokens5m + remainder, tokens1h
}
@@ -0,0 +1,10 @@
package claude
import "errors"
// ErrMissingMaxTokens is returned when an OpenAI-format request carries no
// usable max_tokens and no Options.Claude.DefaultMaxTokens hook is
// configured. The Claude Messages API rejects requests without max_tokens
// (400 "max_tokens: Field required"), so conversion fails loudly instead of
// emitting a request the upstream is guaranteed to refuse.
var ErrMissingMaxTokens = errors.New("claude messages request requires max_tokens: set max_tokens on the request or configure Options.Claude.DefaultMaxTokens")
@@ -0,0 +1,168 @@
package claude
import (
"context"
"fmt"
"math"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/relaykit/types"
)
func ApplyReasoning(ctx context.Context, req *dto.ClaudeRequest, info convmeta.Meta, source reasoning.Intent, crossProtocol bool) error {
if req == nil {
return nil
}
opts := convmeta.OptionsOf(info)
baseModel := req.Model
capabilityModel := baseModel
suffix := reasoning.IntentFromState(convmeta.ReasoningStateOf(info))
preserveSuffix := opts.ShouldPreserveThinkingSuffix(req.Model)
if info != nil && opts.ShouldPreserveThinkingSuffix(info.GetOriginModelName()) {
preserveSuffix = true
}
if preserveSuffix {
suffix = reasoning.Intent{}
}
// A native Claude request without a host modifier is already in the target
// protocol, including Claude-compatible proxies that keep native controls
// instead of applying Anthropic model rules. Read portable effort for
// accounting metadata, but do not run the capability renderer or rewrite
// provider-native controls.
if !crossProtocol && source.IsEmpty() && suffix.IsEmpty() {
if info != nil {
effort := req.GetEfforts()
if effort == "" && req.Thinking != nil {
switch {
case req.Thinking.Type == "disabled":
effort = string(reasoning.EffortNone)
case req.Thinking.BudgetTokens != nil:
effort = string(reasoning.EffortFromBudget(*req.Thinking.BudgetTokens))
case req.Thinking.Type == "enabled" || req.Thinking.Type == "adaptive":
effort = string(reasoning.EffortHigh)
}
}
info.SetReasoningEffort(effort)
}
return nil
}
native, err := reasoning.FromClaude(req)
if err != nil {
return err
}
explicit, err := reasoning.MergeExplicit(native, source, req.Model)
if err != nil {
return err
}
if info != nil && !reasoning.IsKnownClaudeModel(capabilityModel) && reasoning.IsKnownClaudeModel(info.GetOriginModelName()) {
capabilityModel = info.GetOriginModelName()
}
intent, err := reasoning.MergeExplicitAndSuffix(explicit, suffix, req.Model)
if err != nil {
return err
}
knownClaudeModel := reasoning.IsKnownClaudeModel(capabilityModel)
if !knownClaudeModel && intent.Mode == reasoning.ModeAdaptive {
// Cross-protocol pivots cannot safely assume that an unknown
// Claude-compatible model implements Anthropic's adaptive mode. Render
// the broadly supported manual form while retaining the requested
// strength. Native Claude requests took the passthrough path above.
intent.Mode = reasoning.ModeEnabled
if intent.Effort == "" {
intent.Effort = reasoning.EffortHigh
}
}
if req.MaxTokens == nil && intent.HasStrength() {
// Adapter-provided defaults may be raised to accommodate an exact
// cross-protocol budget. Explicit client max_tokens values are never
// expanded and remain subject to the renderer's strict validation.
minimum := uint(1280)
if configuredDefault, configured := opts.Claude.DefaultMaxTokensFor(capabilityModel); configured && configuredDefault > 0 {
minimum = uint(configuredDefault)
}
if reasoning.ClaudeUsesManualThinking(capabilityModel, intent) && *intent.BudgetTokens >= 0 {
if *intent.BudgetTokens == math.MaxInt {
return fmt.Errorf("thinking budget is too large to derive max_tokens")
}
required := uint(*intent.BudgetTokens) + 1
const maxDerivedTokens = uint(math.MaxInt32 / 2)
if required > maxDerivedTokens {
return fmt.Errorf("thinking budget %d exceeds the supported conversion limit", *intent.BudgetTokens)
}
if minimum < required {
minimum = required
}
}
req.MaxTokens = &minimum
}
rendered, err := reasoning.RenderClaude(capabilityModel, intent, req.MaxTokens, opts.Claude.ThinkingAdapterBudgetTokensPercentage)
if err != nil {
return err
}
convdiag.Add(ctx, rendered.Diagnostics...)
req.Model = baseModel
if rendered.Thinking != nil {
req.Thinking = rendered.Thinking
}
if rendered.OutputEffort != "" {
outputConfig := make(map[string]any)
if len(req.OutputConfig) > 0 {
if kitutil.GetJsonType(req.OutputConfig) != "object" {
return fmt.Errorf("Claude output_config must be a JSON object")
}
if err := kitutil.Unmarshal(req.OutputConfig, &outputConfig); err != nil {
return fmt.Errorf("invalid Claude output_config: %w", err)
}
if outputConfig == nil {
outputConfig = make(map[string]any)
}
}
outputConfig["effort"] = string(rendered.OutputEffort)
encoded, err := kitutil.Marshal(outputConfig)
if err != nil {
return fmt.Errorf("failed to marshal Claude output_config: %w", err)
}
req.OutputConfig = encoded
}
if rendered.ClearSampling {
if req.Temperature != nil || req.TopP != nil || req.TopK != nil {
convdiag.Add(ctx, types.ConversionDiagnostic{
Code: "claude_sampling_removed",
Path: "temperature/top_p/top_k",
Message: fmt.Sprintf("model %q does not accept sampling controls with the selected thinking mode", capabilityModel),
Severity: types.ConversionDiagnosticWarning,
To: types.RelayFormatClaude,
})
}
req.Temperature = nil
req.TopP = nil
req.TopK = nil
} else if rendered.ConstrainThinkingSampling {
removedSampling := req.Temperature != nil || req.TopK != nil || req.TopP != nil && (*req.TopP < 0.95 || *req.TopP > 1)
req.Temperature = nil
req.TopK = nil
if req.TopP != nil && (*req.TopP < 0.95 || *req.TopP > 1) {
req.TopP = nil
}
if removedSampling {
convdiag.Add(ctx, types.ConversionDiagnostic{
Code: "claude_sampling_constrained",
Path: "temperature/top_p/top_k",
Message: fmt.Sprintf("model %q accepts only top_p between 0.95 and 1 with manual thinking", capabilityModel),
Severity: types.ConversionDiagnosticWarning,
To: types.RelayFormatClaude,
})
}
}
if info != nil && rendered.EffectiveEffort != "" {
info.SetReasoningEffort(string(rendered.EffectiveEffort))
}
return nil
}
@@ -0,0 +1,16 @@
package claude
func FunctionParametersToInputSchema(parameters any) map[string]any {
params, _ := parameters.(map[string]any)
schema := make(map[string]any, len(params)+2)
for key, value := range params {
schema[key] = value
}
if schema["type"] == nil {
schema["type"] = "object"
}
if schema["properties"] == nil {
schema["properties"] = map[string]any{}
}
return schema
}
@@ -0,0 +1,46 @@
package claude
import "github.com/QuantumNous/new-api/relaykit/dto"
func MapOpenAIToolChoice(toolChoice any, parallelToolCalls *bool) *dto.ClaudeToolChoice {
var claudeToolChoice *dto.ClaudeToolChoice
if toolChoiceStr, ok := toolChoice.(string); ok {
switch toolChoiceStr {
case "auto":
claudeToolChoice = &dto.ClaudeToolChoice{
Type: "auto",
}
case "required":
claudeToolChoice = &dto.ClaudeToolChoice{
Type: "any",
}
case "none":
claudeToolChoice = &dto.ClaudeToolChoice{
Type: "none",
}
}
} else if toolChoiceMap, ok := toolChoice.(map[string]interface{}); ok {
if function, ok := toolChoiceMap["function"].(map[string]interface{}); ok {
if toolName, ok := function["name"].(string); ok {
claudeToolChoice = &dto.ClaudeToolChoice{
Type: "tool",
Name: toolName,
}
}
}
}
if parallelToolCalls != nil {
if claudeToolChoice == nil {
claudeToolChoice = &dto.ClaudeToolChoice{
Type: "auto",
}
}
if claudeToolChoice.Type != "none" {
claudeToolChoice.DisableParallelToolUse = !*parallelToolCalls
}
}
return claudeToolChoice
}
@@ -0,0 +1,52 @@
package claude
import "github.com/QuantumNous/new-api/relaykit/dto"
func UsageFromOpenAI(usage *dto.Usage) *dto.ClaudeUsage {
if usage == nil {
return nil
}
// An existing sidecar snapshots the original provider usage; carry it
// across this bridge unchanged regardless of its dialect. Only synthesize
// an OpenAI snapshot when no sidecar exists yet.
existingBillingUsage := dto.CloneBillingUsage(usage.BillingUsage)
if existingBillingUsage != nil && existingBillingUsage.ClaudeUsage != nil &&
(existingBillingUsage.Source == dto.BillingUsageSourceClaudeMessages || existingBillingUsage.Semantic == dto.BillingUsageSemanticAnthropic) {
result := existingBillingUsage.ClaudeUsage
result.BillingUsage = dto.CloneBillingUsage(usage.BillingUsage)
return result
}
billingUsage := existingBillingUsage
if billingUsage == nil {
billingUsage = dto.NewOpenAIChatBillingUsage(usage)
}
cacheCreation5m, cacheCreation1h := NormalizeCacheCreationSplit(
usage.PromptTokensDetails.CachedCreationTokens,
usage.ClaudeCacheCreation5mTokens,
usage.ClaudeCacheCreation1hTokens,
)
cacheCreationTokens := usage.PromptTokensDetails.CacheCreationTokensTotal()
inputTokens := usage.PromptTokens
if usage.UsageSemantic != dto.BillingUsageSemanticAnthropic {
// OpenAI-style prompt/input totals include cache reads and writes, while
// Claude reports both separately from input_tokens.
inputTokens = usage.PromptTokens - usage.PromptTokensDetails.CachedTokens - cacheCreationTokens
if inputTokens < 0 {
inputTokens = 0
}
}
result := &dto.ClaudeUsage{
InputTokens: inputTokens,
OutputTokens: usage.CompletionTokens,
CacheCreationInputTokens: cacheCreationTokens,
CacheReadInputTokens: usage.PromptTokensDetails.CachedTokens,
BillingUsage: billingUsage,
}
if cacheCreation5m > 0 || cacheCreation1h > 0 {
result.CacheCreation = &dto.ClaudeCacheCreationUsage{
Ephemeral5mInputTokens: cacheCreation5m,
Ephemeral1hInputTokens: cacheCreation1h,
}
}
return result
}
@@ -0,0 +1,250 @@
package gemini
import (
"fmt"
"strconv"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
var SupportedMimeTypes = map[string]bool{
"application/pdf": true,
"audio/mpeg": true,
"audio/mp3": true,
"audio/wav": true,
"image/png": true,
"image/jpeg": true,
"image/jpg": true,
"image/webp": true,
"image/heic": true,
"image/heif": true,
"text/plain": true,
"video/mov": true,
"video/mpeg": true,
"video/mp4": true,
"video/mpg": true,
"video/avi": true,
"video/wmv": true,
"video/mpegps": true,
"video/flv": true,
}
var SafetySettingCategories = []string{
"HARM_CATEGORY_HARASSMENT",
"HARM_CATEGORY_HATE_SPEECH",
"HARM_CATEGORY_SEXUALLY_EXPLICIT",
"HARM_CATEGORY_DANGEROUS_CONTENT",
}
const ThoughtSignatureBypassValue = "context_engineering_is_the_way_to_go"
func ShouldAttachThoughtSignature(opts *convmeta.Options) bool {
return opts != nil && opts.Gemini.FunctionCallThoughtSignatureEnabled
}
func AttachThoughtSignatureBypass(opts *convmeta.Options, part *dto.GeminiPart) bool {
if part == nil || len(part.ThoughtSignature) > 0 || !ShouldAttachThoughtSignature(opts) {
return false
}
part.ThoughtSignature = []byte(strconv.Quote(ThoughtSignatureBypassValue))
return true
}
func AttachFunctionCallThoughtSignature(opts *convmeta.Options, part *dto.GeminiPart) bool {
if part == nil || !HasFunctionCallContent(part.FunctionCall) {
return false
}
return AttachThoughtSignatureBypass(opts, part)
}
func AttachFirstTextThoughtSignature(opts *convmeta.Options, parts []dto.GeminiPart) bool {
if !ShouldAttachThoughtSignature(opts) {
return false
}
for i := range parts {
if parts[i].Text != "" && len(parts[i].ThoughtSignature) == 0 {
parts[i].ThoughtSignature = []byte(strconv.Quote(ThoughtSignatureBypassValue))
return true
}
}
return false
}
func ApplyThinkingConfig(geminiRequest *dto.GeminiChatRequest, info convmeta.Meta, oaiRequest ...dto.GeneralOpenAIRequest) error {
opts := convmeta.OptionsOf(info)
if geminiRequest == nil {
return nil
}
modelName := convmeta.UpstreamModelName(info)
var source reasoning.Intent
crossProtocol := len(oaiRequest) > 0
if len(oaiRequest) > 0 {
if modelName == "" {
modelName = oaiRequest[0].Model
}
var err error
source, err = reasoning.FromOpenAIChat(&oaiRequest[0])
if err != nil {
return err
}
}
baseModel := modelName
suffix := reasoning.IntentFromState(convmeta.ReasoningStateOf(info))
preserveSuffix := opts.ShouldPreserveThinkingSuffix(modelName)
if info != nil && opts.ShouldPreserveThinkingSuffix(info.GetOriginModelName()) {
preserveSuffix = true
}
if preserveSuffix {
suffix = reasoning.Intent{}
}
// Native Gemini requests already use the target protocol. Without a host
// modifier, read portable effort metadata without running the capability
// renderer or rewriting provider-native controls.
if !crossProtocol && suffix.IsEmpty() {
if info != nil {
effort := ""
if config := geminiRequest.GenerationConfig.ThinkingConfig; config != nil {
effort = config.ThinkingLevel
if effort == "" && config.ThinkingBudget != nil {
effort = string(reasoning.EffortFromBudget(*config.ThinkingBudget))
}
}
info.SetReasoningEffort(effort)
}
return nil
}
native, err := reasoning.FromGemini(geminiRequest)
if err != nil {
return err
}
source = reasoning.ResolveGeminiEnabledDefault(baseModel, source, geminiRequest.GenerationConfig.MaxOutputTokens)
if native.HasStrength() && source.HasStrength() {
equivalent, compareErr := reasoning.EquivalentGeminiStrength(baseModel, native, source)
if compareErr != nil {
return compareErr
}
if !equivalent {
nativeEffort := reasoning.EffectiveEffort(native)
sourceEffort := reasoning.EffectiveEffort(source)
return fmt.Errorf("%w for model %q: Gemini thinking_config effort %q differs from standard effort %q", reasoning.ErrEffortConflict, modelName, nativeEffort, sourceEffort)
}
// Native Gemini configuration is the lossless representation. Once the
// two controls are equivalent, retain only portable visibility metadata
// from the standard representation.
if native.IncludeThoughts == nil {
native.IncludeThoughts = source.IncludeThoughts
}
source = reasoning.Intent{}
}
explicit, err := reasoning.MergeExplicit(native, source, modelName)
if err != nil {
return err
}
if explicit.HasStrength() && suffix.HasStrength() {
equivalent, compareErr := reasoning.EquivalentGeminiStrength(baseModel, explicit, suffix)
if compareErr != nil {
return compareErr
}
if equivalent {
if explicit.IncludeThoughts == nil {
explicit.IncludeThoughts = suffix.IncludeThoughts
}
suffix = reasoning.Intent{}
}
}
requested, err := reasoning.MergeExplicitAndSuffix(explicit, suffix, modelName)
if err != nil {
return err
}
requested = reasoning.ResolveGeminiEnabledDefault(baseModel, requested, geminiRequest.GenerationConfig.MaxOutputTokens)
if native.HasStrength() && !suffix.HasStrength() {
if explicit.IncludeThoughts != nil {
geminiRequest.GenerationConfig.ThinkingConfig.IncludeThoughts = explicit.IncludeThoughts
}
effort, err := reasoning.ValidateGeminiThinkingConfig(baseModel, geminiRequest.GenerationConfig.ThinkingConfig)
if err != nil {
return err
}
if info != nil && effort != "" {
info.SetReasoningEffort(string(effort))
}
return nil
}
if requested.IsEmpty() {
return nil
}
rendered, err := reasoning.RenderGemini(
baseModel,
requested,
geminiRequest.GenerationConfig.MaxOutputTokens,
opts.Gemini.ThinkingAdapterBudgetTokensPercentage,
)
if err != nil {
return err
}
geminiRequest.GenerationConfig.ThinkingConfig = rendered.Config
if info != nil && rendered.EffectiveEffort != "" {
info.SetReasoningEffort(string(rendered.EffectiveEffort))
}
return nil
}
func ParseStopSequences(stop any) []string {
if stop == nil {
return nil
}
switch v := stop.(type) {
case string:
if v != "" {
return []string{v}
}
case []string:
return v
case []interface{}:
sequences := make([]string, 0, len(v))
for _, item := range v {
if str, ok := item.(string); ok && str != "" {
sequences = append(sequences, str)
}
}
return sequences
}
return nil
}
func HasFunctionCallContent(call *dto.FunctionCall) bool {
if call == nil {
return false
}
if strings.TrimSpace(call.FunctionName) != "" {
return true
}
switch v := call.Arguments.(type) {
case nil:
return false
case string:
return strings.TrimSpace(v) != ""
case map[string]interface{}:
return len(v) > 0
case []interface{}:
return len(v) > 0
default:
return true
}
}
func SupportedMimeTypesList() []string {
keys := make([]string, 0, len(SupportedMimeTypes))
for key := range SupportedMimeTypes {
keys = append(keys, key)
}
return keys
}
@@ -0,0 +1,256 @@
package gemini
import (
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
)
var geminiOpenAPISchemaAllowedFields = map[string]struct{}{
"anyOf": {},
"default": {},
"description": {},
"enum": {},
"example": {},
"format": {},
"items": {},
"maxItems": {},
"maxLength": {},
"maxProperties": {},
"maximum": {},
"minItems": {},
"minLength": {},
"minProperties": {},
"minimum": {},
"nullable": {},
"pattern": {},
"properties": {},
"propertyOrdering": {},
"required": {},
"title": {},
"type": {},
}
const geminiFunctionSchemaMaxDepth = 64
func CleanFunctionParameters(params interface{}) interface{} {
return cleanGeminiFunctionParametersWithDepth(params, 0)
}
func cleanGeminiFunctionParametersWithDepth(params interface{}, depth int) interface{} {
if params == nil {
return nil
}
if depth >= geminiFunctionSchemaMaxDepth {
return cleanGeminiFunctionParametersShallow(params)
}
switch v := params.(type) {
case map[string]interface{}:
cleanedMap := make(map[string]interface{}, len(v))
for key, val := range v {
if _, ok := geminiOpenAPISchemaAllowedFields[key]; ok {
cleanedMap[key] = val
}
}
normalizeGeminiSchemaTypeAndNullable(cleanedMap)
if props, ok := cleanedMap["properties"].(map[string]interface{}); ok && props != nil {
cleanedProps := make(map[string]interface{})
for propName, propValue := range props {
cleanedProps[propName] = cleanGeminiFunctionParametersWithDepth(propValue, depth+1)
}
cleanedMap["properties"] = cleanedProps
}
if items, ok := cleanedMap["items"].(map[string]interface{}); ok && items != nil {
cleanedMap["items"] = cleanGeminiFunctionParametersWithDepth(items, depth+1)
}
if itemsArray, ok := cleanedMap["items"].([]interface{}); ok && len(itemsArray) > 0 {
cleanedMap["items"] = cleanGeminiFunctionParametersWithDepth(itemsArray[0], depth+1)
}
if nested, ok := cleanedMap["anyOf"].([]interface{}); ok && nested != nil {
cleanedNested := make([]interface{}, len(nested))
for i, item := range nested {
cleanedNested[i] = cleanGeminiFunctionParametersWithDepth(item, depth+1)
}
cleanedMap["anyOf"] = cleanedNested
}
return cleanedMap
case []interface{}:
cleanedArray := make([]interface{}, len(v))
for i, item := range v {
cleanedArray[i] = cleanGeminiFunctionParametersWithDepth(item, depth+1)
}
return cleanedArray
default:
return params
}
}
func cleanGeminiFunctionParametersShallow(params interface{}) interface{} {
switch v := params.(type) {
case map[string]interface{}:
cleanedMap := make(map[string]interface{}, len(v))
for key, val := range v {
if _, ok := geminiOpenAPISchemaAllowedFields[key]; ok {
cleanedMap[key] = val
}
}
normalizeGeminiSchemaTypeAndNullable(cleanedMap)
delete(cleanedMap, "properties")
delete(cleanedMap, "items")
delete(cleanedMap, "anyOf")
return cleanedMap
case []interface{}:
return []interface{}{}
default:
return params
}
}
func normalizeGeminiSchemaTypeAndNullable(schema map[string]interface{}) {
rawType, ok := schema["type"]
if !ok || rawType == nil {
return
}
normalize := func(t string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(t)) {
case "object":
return "OBJECT", false
case "array":
return "ARRAY", false
case "string":
return "STRING", false
case "integer":
return "INTEGER", false
case "number":
return "NUMBER", false
case "boolean":
return "BOOLEAN", false
case "null":
return "", true
default:
return t, false
}
}
switch typed := rawType.(type) {
case string:
normalized, isNull := normalize(typed)
if isNull {
schema["nullable"] = true
delete(schema, "type")
return
}
schema["type"] = normalized
case []interface{}:
nullable := false
var chosen string
for _, item := range typed {
if value, ok := item.(string); ok {
normalized, isNull := normalize(value)
if isNull {
nullable = true
continue
}
if chosen == "" {
chosen = normalized
}
}
}
if nullable {
schema["nullable"] = true
}
if chosen != "" {
schema["type"] = chosen
} else {
delete(schema, "type")
}
}
}
func RemoveAdditionalProperties(schema interface{}, depth int) interface{} {
if depth >= 5 {
return schema
}
value, ok := schema.(map[string]interface{})
if !ok || len(value) == 0 {
return schema
}
delete(value, "title")
delete(value, "$schema")
if typeVal, exists := value["type"]; !exists || (typeVal != "object" && typeVal != "array") {
return schema
}
switch value["type"] {
case "object":
delete(value, "additionalProperties")
if properties, ok := value["properties"].(map[string]interface{}); ok {
for key, nested := range properties {
properties[key] = RemoveAdditionalProperties(nested, depth+1)
}
}
for _, field := range []string{"allOf", "anyOf", "oneOf"} {
if nested, ok := value[field].([]interface{}); ok {
for i, item := range nested {
nested[i] = RemoveAdditionalProperties(item, depth+1)
}
}
}
case "array":
if items, ok := value["items"].(map[string]interface{}); ok {
value["items"] = RemoveAdditionalProperties(items, depth+1)
}
}
return value
}
func OpenAIToolChoiceToConfig(toolChoice any) *dto.ToolConfig {
if toolChoice == nil {
return nil
}
if toolChoiceStr, ok := toolChoice.(string); ok {
config := &dto.ToolConfig{
FunctionCallingConfig: &dto.FunctionCallingConfig{},
}
switch toolChoiceStr {
case "auto":
config.FunctionCallingConfig.Mode = "AUTO"
case "none":
config.FunctionCallingConfig.Mode = "NONE"
case "required":
config.FunctionCallingConfig.Mode = "ANY"
default:
config.FunctionCallingConfig.Mode = "AUTO"
}
return config
}
if toolChoiceMap, ok := toolChoice.(map[string]interface{}); ok {
if toolChoiceMap["type"] == "function" {
config := &dto.ToolConfig{
FunctionCallingConfig: &dto.FunctionCallingConfig{
Mode: "ANY",
},
}
if function, ok := toolChoiceMap["function"].(map[string]interface{}); ok {
if name, ok := function["name"].(string); ok && name != "" {
config.FunctionCallingConfig.AllowedFunctionNames = []string{name}
}
}
return config
}
return nil
}
return nil
}
@@ -0,0 +1,997 @@
package toolconv
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
const maxClaudeWebSearchUses = 1000
func ExtractRequest(format types.RelayFormat, request any) (any, Set, error) {
switch format {
case types.RelayFormatOpenAI:
return extractOpenAIChatRequest(request)
case types.RelayFormatOpenAIResponses:
return extractOpenAIResponsesRequest(request)
case types.RelayFormatClaude:
return extractClaudeRequest(request)
case types.RelayFormatGemini:
return extractGeminiRequest(request)
default:
return request, Set{Source: format}, nil
}
}
func extractOpenAIChatRequest(request any) (any, Set, error) {
source, ok := request.(*dto.GeneralOpenAIRequest)
if !ok {
value, valueOK := request.(dto.GeneralOpenAIRequest)
if !valueOK {
return nil, Set{}, fmt.Errorf("expected OpenAI chat completions request, got %T", request)
}
source = &value
}
set := Set{Source: types.RelayFormatOpenAI}
set.ParallelAllowed = source.ParallelTooCalls
if len(source.Functions) > 0 {
var functions []dto.FunctionRequest
if err := kitutil.Unmarshal(source.Functions, &functions); err != nil {
return nil, Set{}, fmt.Errorf("invalid legacy functions: %w", err)
}
for _, function := range functions {
set.Definitions = append(set.Definitions, Definition{
Kind: KindFunction,
Execution: ExecutionClient,
Function: &Function{Name: function.Name, Description: function.Description, Parameters: function.Parameters, Strict: function.Strict},
})
}
}
for index, tool := range source.Tools {
if tool.Type == "function" || tool.Type == "" {
if definition, ok := decodeOpenAIChatPseudoHostedTool(tool.Function.Name); ok {
set.Definitions = append(set.Definitions, definition)
continue
}
set.Definitions = append(set.Definitions, Definition{
Kind: KindFunction,
Execution: ExecutionClient,
Function: &Function{
Name: tool.Function.Name,
Description: tool.Function.Description,
Parameters: tool.Function.Parameters,
Strict: tool.Function.Strict,
},
})
continue
}
if len(tool.Custom) == 0 {
return nil, Set{}, fmt.Errorf("tools[%d] has unsupported type %q without a native payload", index, tool.Type)
}
definition, err := decodeOpenAIResponsesDefinition(tool.Custom)
if err != nil {
return nil, Set{}, fmt.Errorf("tools[%d]: %w", index, err)
}
set.Definitions = append(set.Definitions, definition)
}
if source.WebSearchOptions != nil {
webSearch := &WebSearch{
SearchContextSize: source.WebSearchOptions.SearchContextSize,
}
location, err := decodeOpenAIChatLocation(source.WebSearchOptions.UserLocation)
if err != nil {
return nil, Set{}, err
}
webSearch.Location = location
set.Definitions = append(set.Definitions, Definition{
Kind: KindWebSearch,
Execution: ExecutionServer,
NativeType: "web_search_options",
WebSearch: webSearch,
})
}
if choice, err := decodeOpenAIChatChoice(source.ToolChoice); err != nil {
return nil, Set{}, err
} else if choice != nil {
set.Choice = choice
}
if len(source.FunctionCall) > 0 {
legacyChoice, err := decodeLegacyOpenAIFunctionChoice(source.FunctionCall)
if err != nil {
return nil, Set{}, err
}
if set.Choice != nil && legacyChoice != nil {
return nil, Set{}, fmt.Errorf("tool_choice and legacy function_call cannot both be converted")
}
set.Choice = legacyChoice
}
clone := *source
clone.Tools = nil
clone.ToolChoice = nil
clone.WebSearchOptions = nil
clone.Functions = nil
clone.FunctionCall = nil
clone.ParallelTooCalls = nil
return &clone, set, nil
}
func extractOpenAIResponsesRequest(request any) (any, Set, error) {
source, ok := request.(*dto.OpenAIResponsesRequest)
if !ok {
value, valueOK := request.(dto.OpenAIResponsesRequest)
if !valueOK {
return nil, Set{}, fmt.Errorf("expected OpenAI Responses request, got %T", request)
}
source = &value
}
set := Set{Source: types.RelayFormatOpenAIResponses}
set.ParallelAllowed = rawBoolPointer(source.ParallelToolCalls)
if len(source.Tools) > 0 {
var rawTools []json.RawMessage
if err := kitutil.Unmarshal(source.Tools, &rawTools); err != nil {
return nil, Set{}, fmt.Errorf("invalid Responses tools: %w", err)
}
for index, rawTool := range rawTools {
definition, err := decodeOpenAIResponsesDefinition(rawTool)
if err != nil {
return nil, Set{}, fmt.Errorf("tools[%d]: %w", index, err)
}
set.Definitions = append(set.Definitions, definition)
}
}
choice, err := decodeOpenAIResponsesChoice(source.ToolChoice)
if err != nil {
return nil, Set{}, err
}
set.Choice = choice
clone := *source
clone.Tools = nil
clone.ToolChoice = nil
clone.ParallelToolCalls = nil
sanitizedInput, history, err := extractOpenAIResponsesHostedHistory(source.Input)
if err != nil {
return nil, Set{}, err
}
clone.Input = sanitizedInput
set.History = history
return &clone, set, nil
}
func extractClaudeRequest(request any) (any, Set, error) {
source, ok := request.(*dto.ClaudeRequest)
if !ok {
value, valueOK := request.(dto.ClaudeRequest)
if !valueOK {
return nil, Set{}, fmt.Errorf("expected Claude Messages request, got %T", request)
}
source = &value
}
set := Set{Source: types.RelayFormatClaude}
if source.ToolChoice != nil {
rawChoice, rawErr := rawJSON(source.ToolChoice)
if rawErr == nil {
var choiceMap map[string]any
if kitutil.Unmarshal(rawChoice, &choiceMap) == nil {
if disabled, ok := choiceMap["disable_parallel_tool_use"].(bool); ok {
allowed := !disabled
set.ParallelAllowed = &allowed
}
}
}
}
if source.Tools != nil {
rawTools, err := rawJSON(source.Tools)
if err != nil {
return nil, Set{}, fmt.Errorf("invalid Claude tools: %w", err)
}
var tools []json.RawMessage
if err := kitutil.Unmarshal(rawTools, &tools); err != nil {
return nil, Set{}, fmt.Errorf("invalid Claude tools: %w", err)
}
for index, rawTool := range tools {
definition, err := decodeClaudeDefinition(rawTool)
if err != nil {
return nil, Set{}, fmt.Errorf("tools[%d]: %w", index, err)
}
set.Definitions = append(set.Definitions, definition)
}
}
choice, err := decodeClaudeChoice(source.ToolChoice, set.Definitions)
if err != nil {
return nil, Set{}, err
}
set.Choice = choice
clone := *source
clone.Tools = nil
clone.ToolChoice = nil
clone.Messages, set.History, err = extractClaudeHostedHistory(source.Messages)
if err != nil {
return nil, Set{}, err
}
return &clone, set, nil
}
func extractGeminiRequest(request any) (any, Set, error) {
source, ok := request.(*dto.GeminiChatRequest)
if !ok {
value, valueOK := request.(dto.GeminiChatRequest)
if !valueOK {
return nil, Set{}, fmt.Errorf("expected Gemini generateContent request, got %T", request)
}
source = &value
}
set := Set{Source: types.RelayFormatGemini}
if source.ToolConfig != nil {
set.NativeToolConfig, _ = rawJSON(source.ToolConfig)
}
if len(source.Tools) > 0 {
var tools []json.RawMessage
if err := kitutil.Unmarshal(source.Tools, &tools); err != nil {
return nil, Set{}, fmt.Errorf("invalid Gemini tools: %w", err)
}
for index, rawTool := range tools {
definitions, err := decodeGeminiDefinitions(rawTool)
if err != nil {
return nil, Set{}, fmt.Errorf("tools[%d]: %w", index, err)
}
for definitionIndex := range definitions {
definitions[definitionIndex].Group = index
}
set.Definitions = append(set.Definitions, definitions...)
}
}
set.Choice = decodeGeminiChoice(source.ToolConfig)
clone := *source
clone.Tools = nil
clone.ToolConfig = nil
return &clone, set, nil
}
func decodeOpenAIResponsesDefinition(raw json.RawMessage) (Definition, error) {
var tool map[string]any
if err := kitutil.Unmarshal(raw, &tool); err != nil {
return Definition{}, err
}
toolType := strings.TrimSpace(kitutil.Interface2String(tool["type"]))
if toolType == "function" {
return Definition{
Kind: KindFunction,
Execution: ExecutionClient,
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Raw: cloneRaw(raw),
Function: &Function{
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Description: kitutil.Interface2String(tool["description"]),
Parameters: tool["parameters"],
Strict: boolPointer(tool, "strict"),
},
}, nil
}
if isOpenAIResponsesWebSearchType(toolType) {
webSearch := &WebSearch{
SearchContextSize: strings.TrimSpace(kitutil.Interface2String(tool["search_context_size"])),
ExternalWebAccess: boolPointer(tool, "external_web_access"),
}
if value, exists := tool["return_token_budget"]; exists {
encoded, err := rawJSON(value)
if err != nil {
return Definition{}, err
}
webSearch.ReturnTokenBudget = encoded
}
if filters, ok := tool["filters"].(map[string]any); ok {
webSearch.AllowedDomains = stringSlice(filters["allowed_domains"])
}
if location, ok := tool["user_location"].(map[string]any); ok {
webSearch.Location = locationFromMap(location)
}
return Definition{
Kind: KindWebSearch,
Execution: ExecutionServer,
NativeType: toolType,
WebSearch: webSearch,
Raw: cloneRaw(raw),
}, nil
}
return Definition{
Kind: kindFromNativeType(toolType),
Execution: executionFromNativeType(toolType),
NativeType: toolType,
Raw: cloneRaw(raw),
}, nil
}
func decodeClaudeDefinition(raw json.RawMessage) (Definition, error) {
var tool map[string]any
if err := kitutil.Unmarshal(raw, &tool); err != nil {
return Definition{}, err
}
toolType := strings.TrimSpace(kitutil.Interface2String(tool["type"]))
if strings.HasPrefix(toolType, "web_search") {
if !isVersionedClaudeWebSearchType(toolType) {
return Definition{}, fmt.Errorf("invalid Claude web-search tool version %q", toolType)
}
if !isKnownClaudeWebSearchType(toolType) {
return Definition{
Kind: KindNative,
Execution: ExecutionServer,
NativeType: toolType,
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Raw: cloneRaw(raw),
}, nil
}
toolName := strings.TrimSpace(kitutil.Interface2String(tool["name"]))
if toolName != "web_search" {
return Definition{}, fmt.Errorf("Claude web-search tool name must be %q", "web_search")
}
webSearch := &WebSearch{
AllowedDomains: stringSlice(tool["allowed_domains"]),
BlockedDomains: stringSlice(tool["blocked_domains"]),
AllowedCallers: stringSlice(tool["allowed_callers"]),
ResponseInclusion: strings.TrimSpace(kitutil.Interface2String(tool["response_inclusion"])),
}
if _, exists := tool["max_uses"]; exists {
var fields struct {
MaxUses *int `json:"max_uses"`
}
if err := kitutil.Unmarshal(raw, &fields); err != nil || fields.MaxUses == nil {
return Definition{}, fmt.Errorf("max_uses must be a JSON integer")
}
if *fields.MaxUses <= 0 || *fields.MaxUses > maxClaudeWebSearchUses {
return Definition{}, fmt.Errorf("max_uses must be between 1 and %d", maxClaudeWebSearchUses)
}
webSearch.MaxUses = fields.MaxUses
}
if len(webSearch.AllowedDomains) > 0 && len(webSearch.BlockedDomains) > 0 {
return Definition{}, fmt.Errorf("allowed_domains and blocked_domains are mutually exclusive")
}
if webSearch.ResponseInclusion != "" && !claudeWebSearchSupportsResponseInclusion(toolType) {
return Definition{}, fmt.Errorf("response_inclusion requires Claude web_search_20260318")
}
if location, ok := tool["user_location"].(map[string]any); ok {
webSearch.Location = locationFromMap(location)
}
return Definition{
Kind: KindWebSearch,
Execution: ExecutionServer,
NativeType: toolType,
Name: toolName,
WebSearch: webSearch,
Raw: cloneRaw(raw),
}, nil
}
if toolType == "" {
return Definition{
Kind: KindFunction,
Execution: ExecutionClient,
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Raw: cloneRaw(raw),
Function: &Function{
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Description: kitutil.Interface2String(tool["description"]),
Parameters: tool["input_schema"],
Strict: boolPointer(tool, "strict"),
},
}, nil
}
return Definition{
Kind: kindFromNativeType(toolType),
Execution: executionFromNativeType(toolType),
NativeType: toolType,
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Raw: cloneRaw(raw),
}, nil
}
func decodeGeminiDefinitions(raw json.RawMessage) ([]Definition, error) {
var tool map[string]any
if err := kitutil.Unmarshal(raw, &tool); err != nil {
return nil, err
}
definitions := make([]Definition, 0)
if functions, ok := tool["functionDeclarations"].([]any); ok {
for _, value := range functions {
function, ok := value.(map[string]any)
if !ok {
continue
}
parameters := function["parameters"]
parametersJSONSchema, hasParametersJSONSchema := function["parametersJsonSchema"]
if parameters != nil && hasParametersJSONSchema && parametersJSONSchema != nil {
return nil, fmt.Errorf("function %q declares both parameters and parametersJsonSchema", strings.TrimSpace(kitutil.Interface2String(function["name"])))
}
if parameters == nil && hasParametersJSONSchema {
parameters = parametersJSONSchema
}
functionRaw, err := rawJSON(map[string]any{"functionDeclarations": []any{value}})
if err != nil {
return nil, err
}
definitions = append(definitions, Definition{
Kind: KindFunction,
Execution: ExecutionClient,
Name: strings.TrimSpace(kitutil.Interface2String(function["name"])),
Raw: functionRaw,
Function: &Function{
Name: strings.TrimSpace(kitutil.Interface2String(function["name"])),
Description: kitutil.Interface2String(function["description"]),
Parameters: parameters,
},
})
}
}
for key := range tool {
var kind Kind
var nativeType string
switch key {
case "functionDeclarations":
continue
case "googleSearch":
kind, nativeType = KindWebSearch, "googleSearch"
case "googleSearchRetrieval":
kind, nativeType = KindWebSearch, "googleSearchRetrieval"
case "enterpriseWebSearch":
kind, nativeType = KindWebSearch, "enterpriseWebSearch"
case "googleMaps":
kind, nativeType = KindNative, "googleMaps"
case "codeExecution":
kind, nativeType = KindCodeExecution, "codeExecution"
case "urlContext":
kind, nativeType = KindURLContext, "urlContext"
case "fileSearch":
kind, nativeType = KindFileSearch, "fileSearch"
case "computerUse":
kind, nativeType = KindComputerUse, "computerUse"
case "retrieval":
kind, nativeType = KindFileSearch, "retrieval"
default:
kind, nativeType = KindNative, key
}
keyRaw, err := rawJSON(map[string]any{key: tool[key]})
if err != nil {
return nil, err
}
definition := Definition{
Kind: kind,
Execution: ExecutionServer,
NativeType: nativeType,
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Raw: keyRaw,
}
if kind == KindWebSearch {
definition.WebSearch = &WebSearch{}
}
definitions = append(definitions, definition)
}
return definitions, nil
}
func decodeLegacyOpenAIFunctionChoice(raw json.RawMessage) (*Choice, error) {
if len(raw) == 0 {
return nil, nil
}
if kitutil.GetJsonType(raw) == "string" {
var value string
if err := kitutil.Unmarshal(raw, &value); err != nil {
return nil, err
}
return choiceFromString(value), nil
}
var value map[string]any
if err := kitutil.Unmarshal(raw, &value); err != nil {
return nil, fmt.Errorf("invalid legacy function_call: %w", err)
}
name := strings.TrimSpace(kitutil.Interface2String(value["name"]))
if name == "" {
return nil, fmt.Errorf("legacy function_call requires name")
}
return &Choice{Mode: ChoiceNamed, Kind: KindFunction, Name: name}, nil
}
func rawBoolPointer(raw json.RawMessage) *bool {
if len(raw) == 0 || kitutil.GetJsonType(raw) != "boolean" {
return nil
}
var value bool
if kitutil.Unmarshal(raw, &value) != nil {
return nil
}
return &value
}
// decodeOpenAIChatPseudoHostedTool recognizes the OpenAI Chat dialect that
// declares Gemini hosted tools as function definitions named googleSearch,
// codeExecution, or urlContext. The names are the historical public contract;
// recognition lives here so every target format goes through the same hosted
// ToolDefinition pipeline.
func decodeOpenAIChatPseudoHostedTool(name string) (Definition, bool) {
switch name {
case "googleSearch":
return Definition{
Kind: KindWebSearch,
Execution: ExecutionServer,
NativeType: "googleSearch",
Name: "googleSearch",
WebSearch: &WebSearch{},
}, true
case "codeExecution":
return Definition{
Kind: KindCodeExecution,
Execution: ExecutionServer,
NativeType: "codeExecution",
Name: "codeExecution",
}, true
case "urlContext":
return Definition{
Kind: KindURLContext,
Execution: ExecutionServer,
NativeType: "urlContext",
Name: "urlContext",
}, true
default:
return Definition{}, false
}
}
func decodeOpenAIChatLocation(raw json.RawMessage) (*ApproximateLocation, error) {
if len(raw) == 0 {
return nil, nil
}
var wrapper map[string]any
if err := kitutil.Unmarshal(raw, &wrapper); err != nil {
return nil, fmt.Errorf("invalid web_search_options.user_location: %w", err)
}
location, ok := wrapper["approximate"].(map[string]any)
if !ok {
return nil, nil
}
return locationFromMap(location), nil
}
func decodeOpenAIChatChoice(value any) (*Choice, error) {
if value == nil {
return nil, nil
}
if text, ok := value.(string); ok {
return choiceFromString(text), nil
}
raw, err := rawJSON(value)
if err != nil {
return nil, fmt.Errorf("invalid Chat tool_choice: %w", err)
}
var choice map[string]any
if err := kitutil.Unmarshal(raw, &choice); err != nil {
return nil, fmt.Errorf("invalid Chat tool_choice: %w", err)
}
if strings.TrimSpace(kitutil.Interface2String(choice["type"])) != "function" {
return &Choice{Mode: ChoiceOpaque, Raw: cloneRaw(raw)}, nil
}
function, _ := choice["function"].(map[string]any)
name := strings.TrimSpace(kitutil.Interface2String(function["name"]))
if name == "" {
return nil, fmt.Errorf("Chat function tool_choice requires function.name")
}
return &Choice{Mode: ChoiceNamed, Kind: KindFunction, Name: name}, nil
}
func decodeOpenAIResponsesChoice(raw json.RawMessage) (*Choice, error) {
if len(raw) == 0 {
return nil, nil
}
if kitutil.GetJsonType(raw) == "string" {
var text string
if err := kitutil.Unmarshal(raw, &text); err != nil {
return nil, err
}
return choiceFromString(text), nil
}
var value map[string]any
if err := kitutil.Unmarshal(raw, &value); err != nil {
return nil, fmt.Errorf("invalid Responses tool_choice: %w", err)
}
toolType := strings.TrimSpace(kitutil.Interface2String(value["type"]))
if toolType == "function" {
name := strings.TrimSpace(kitutil.Interface2String(value["name"]))
if name == "" {
return nil, fmt.Errorf("Responses function tool_choice requires name")
}
return &Choice{Mode: ChoiceNamed, Kind: KindFunction, Name: name}, nil
}
if isOpenAIResponsesWebSearchType(toolType) {
return &Choice{Mode: ChoiceNamed, Kind: KindWebSearch, Name: "web_search", NativeType: toolType, Raw: cloneRaw(raw)}, nil
}
return &Choice{Mode: ChoiceOpaque, Kind: kindFromNativeType(toolType), NativeType: toolType, Raw: cloneRaw(raw)}, nil
}
func decodeClaudeChoice(value any, definitions []Definition) (*Choice, error) {
if value == nil {
return nil, nil
}
raw, err := rawJSON(value)
if err != nil {
return nil, fmt.Errorf("invalid Claude tool_choice: %w", err)
}
var choice map[string]any
if err := kitutil.Unmarshal(raw, &choice); err != nil {
return nil, fmt.Errorf("invalid Claude tool_choice: %w", err)
}
choiceType := strings.TrimSpace(kitutil.Interface2String(choice["type"]))
var decoded *Choice
switch choiceType {
case "auto":
decoded = &Choice{Mode: ChoiceAuto}
case "none":
decoded = &Choice{Mode: ChoiceNone}
case "any":
decoded = &Choice{Mode: ChoiceRequired}
case "tool":
name := strings.TrimSpace(kitutil.Interface2String(choice["name"]))
kind := KindNative
matches := 0
for _, definition := range definitions {
definitionName := definition.Name
if definition.Kind == KindFunction && definition.Function != nil {
definitionName = definition.Function.Name
}
if definitionName != name {
continue
}
matches++
kind = definition.Kind
}
if matches > 1 {
return nil, fmt.Errorf("Claude tool_choice name %q is ambiguous across %d definitions", name, matches)
}
decoded = &Choice{Mode: ChoiceNamed, Kind: kind, Name: name}
default:
decoded = &Choice{Mode: ChoiceOpaque}
}
if disabled, ok := choice["disable_parallel_tool_use"].(bool); ok {
decoded.DisableParallelToolUse = &disabled
}
decoded.Raw = cloneRaw(raw)
return decoded, nil
}
func decodeGeminiChoice(config *dto.ToolConfig) *Choice {
if config == nil || config.FunctionCallingConfig == nil {
return nil
}
functionConfig := config.FunctionCallingConfig
switch strings.ToUpper(strings.TrimSpace(string(functionConfig.Mode))) {
case "NONE":
return &Choice{Mode: ChoiceNone}
case "ANY":
if len(functionConfig.AllowedFunctionNames) == 1 {
return &Choice{Mode: ChoiceNamed, Kind: KindFunction, Name: functionConfig.AllowedFunctionNames[0]}
}
return &Choice{
Mode: ChoiceRequired,
Kind: KindFunction,
AllowedNames: append([]string(nil), functionConfig.AllowedFunctionNames...),
}
case "", "AUTO":
return &Choice{Mode: ChoiceAuto}
default:
raw, _ := rawJSON(functionConfig)
return &Choice{Mode: ChoiceOpaque, Raw: raw}
}
}
func choiceFromString(value string) *Choice {
switch strings.ToLower(strings.TrimSpace(value)) {
case "none":
return &Choice{Mode: ChoiceNone}
case "required", "any":
return &Choice{Mode: ChoiceRequired}
case "auto":
return &Choice{Mode: ChoiceAuto}
default:
raw, _ := rawJSON(value)
return &Choice{Mode: ChoiceOpaque, Raw: raw}
}
}
func isOpenAIResponsesWebSearchType(toolType string) bool {
switch toolType {
case "web_search", "web_search_2025_08_26", "web_search_preview", "web_search_preview_2025_03_11":
return true
default:
return false
}
}
func claudeWebSearchSupportsResponseInclusion(toolType string) bool {
return toolType == "web_search_20260318"
}
func isKnownClaudeWebSearchType(toolType string) bool {
switch toolType {
case "web_search_20250305", "web_search_20260209", "web_search_20260318":
return true
default:
return false
}
}
func isVersionedClaudeWebSearchType(toolType string) bool {
const prefix = "web_search_"
version := strings.TrimPrefix(toolType, prefix)
if !strings.HasPrefix(toolType, prefix) || len(version) != 8 {
return false
}
_, err := strconv.ParseUint(version, 10, 32)
return err == nil
}
func locationFromMap(value map[string]any) *ApproximateLocation {
if len(value) == 0 {
return nil
}
location := &ApproximateLocation{
City: strings.TrimSpace(kitutil.Interface2String(value["city"])),
Region: strings.TrimSpace(kitutil.Interface2String(value["region"])),
Country: strings.TrimSpace(kitutil.Interface2String(value["country"])),
Timezone: strings.TrimSpace(kitutil.Interface2String(value["timezone"])),
}
if location.City == "" && location.Region == "" && location.Country == "" && location.Timezone == "" {
return nil
}
return location
}
func boolPointer(value map[string]any, key string) *bool {
raw, exists := value[key]
if !exists {
return nil
}
parsed, ok := raw.(bool)
if !ok {
return nil
}
return &parsed
}
func stringSlice(value any) []string {
items, ok := value.([]any)
if !ok {
if strings, stringsOK := value.([]string); stringsOK {
return append([]string(nil), strings...)
}
return nil
}
result := make([]string, 0, len(items))
for _, item := range items {
if text, ok := item.(string); ok && strings.TrimSpace(text) != "" {
result = append(result, text)
}
}
return result
}
func rawJSON(value any) (json.RawMessage, error) {
switch raw := value.(type) {
case json.RawMessage:
return cloneRaw(raw), nil
case []byte:
return cloneRaw(raw), nil
default:
encoded, err := kitutil.Marshal(value)
return json.RawMessage(encoded), err
}
}
func cloneRaw(raw []byte) json.RawMessage {
return append(json.RawMessage(nil), raw...)
}
func kindFromNativeType(toolType string) Kind {
switch {
case toolType == "file_search":
return KindFileSearch
case strings.HasPrefix(toolType, "web_fetch"):
return KindWebFetch
case toolType == "code_interpreter", strings.HasPrefix(toolType, "code_execution"):
return KindCodeExecution
case strings.Contains(toolType, "computer"):
return KindComputerUse
case toolType == "url_context":
return KindURLContext
case toolType == "mcp", toolType == "mcp_toolset":
return KindMCP
case toolType == "image_generation":
return KindImage
default:
return KindNative
}
}
func executionFromNativeType(toolType string) Execution {
if strings.HasPrefix(toolType, "computer_") || strings.HasPrefix(toolType, "bash_") || strings.HasPrefix(toolType, "text_editor_") || strings.HasPrefix(toolType, "memory_") {
return ExecutionClient
}
return ExecutionServer
}
func extractOpenAIResponsesHostedHistory(input json.RawMessage) (json.RawMessage, []HostedHistoryItem, error) {
if len(input) == 0 || kitutil.GetJsonType(input) != "array" {
return input, nil, nil
}
var rawItems []json.RawMessage
if err := kitutil.Unmarshal(input, &rawItems); err != nil {
return nil, nil, fmt.Errorf("invalid Responses input: %w", err)
}
filtered := make([]json.RawMessage, 0, len(rawItems))
var history []HostedHistoryItem
for index, rawItem := range rawItems {
var item map[string]any
if err := kitutil.Unmarshal(rawItem, &item); err != nil {
return nil, nil, fmt.Errorf("input[%d]: %w", index, err)
}
itemType := strings.TrimSpace(kitutil.Interface2String(item["type"]))
if !isResponsesHostedHistoryType(itemType) {
filtered = append(filtered, rawItem)
continue
}
status := strings.TrimSpace(kitutil.Interface2String(item["status"]))
action := rawMapValue(item, "action")
results := firstRawMapValue(item, "results", "sources", "output")
if itemType == "mcp_call" {
action = rawMapValue(item, "arguments")
output := rawMapValue(item, "output")
itemError := rawMapValue(item, "error")
results = output
if rawJSONPresent(itemError) {
results = itemError
status = "failed"
}
}
history = append(history, HostedHistoryItem{
Kind: hostedKindFromResponsesType(itemType),
NativeType: itemType,
Role: strings.TrimSpace(kitutil.Interface2String(item["role"])),
MessageIndex: index,
Sequence: index,
ID: strings.TrimSpace(kitutil.Interface2String(item["id"])),
CallID: strings.TrimSpace(kitutil.Interface2String(item["call_id"])),
Name: strings.TrimSpace(kitutil.Interface2String(item["name"])),
ServerName: strings.TrimSpace(kitutil.Interface2String(item["server_label"])),
Status: status,
Action: action,
Results: results,
Caller: rawMapValue(item, "caller"),
Raw: cloneRaw(rawItem),
})
}
if len(history) == 0 {
return input, nil, nil
}
encoded, err := kitutil.Marshal(filtered)
if err != nil {
return nil, nil, err
}
return encoded, history, nil
}
func isResponsesHostedHistoryType(itemType string) bool {
switch strings.TrimSpace(itemType) {
case "web_search_call", "file_search_call", "code_interpreter_call", "computer_call", "computer_call_output", "image_generation_call", "local_shell_call", "local_shell_call_output", "apply_patch_call", "apply_patch_call_output", "mcp_call", "mcp_list_tools", "mcp_approval_request", "mcp_approval_response":
return true
default:
return false
}
}
func extractClaudeHostedHistory(messages []dto.ClaudeMessage) ([]dto.ClaudeMessage, []HostedHistoryItem, error) {
clonedMessages := make([]dto.ClaudeMessage, 0, len(messages))
var history []HostedHistoryItem
for messageIndex := range messages {
message := messages[messageIndex]
if message.IsStringContent() {
clonedMessages = append(clonedMessages, message)
continue
}
rawContent, err := rawJSON(message.Content)
if err != nil {
return nil, nil, fmt.Errorf("messages[%d].content: %w", messageIndex, err)
}
var blocks []json.RawMessage
if err := kitutil.Unmarshal(rawContent, &blocks); err != nil {
return nil, nil, fmt.Errorf("messages[%d].content: %w", messageIndex, err)
}
filtered := make([]any, 0, len(blocks))
historyStart := len(history)
for blockIndex, rawBlock := range blocks {
var block map[string]any
if err := kitutil.Unmarshal(rawBlock, &block); err != nil {
return nil, nil, fmt.Errorf("messages[%d].content[%d]: %w", messageIndex, blockIndex, err)
}
blockType := strings.TrimSpace(kitutil.Interface2String(block["type"]))
if blockType != "server_tool_use" && blockType != "mcp_tool_use" && !isClaudeHostedToolBlock(blockType) {
filtered = append(filtered, block)
continue
}
name := strings.TrimSpace(kitutil.Interface2String(block["name"]))
kind := hostedKindFromClaudeCall(blockType, name)
if strings.HasSuffix(blockType, "_tool_result") {
kind = hostedKindFromClaudeResult(blockType)
}
results := rawMapValue(block, "content")
status := "in_progress"
if strings.HasSuffix(blockType, "_tool_result") {
status = "completed"
isError, _ := block["is_error"].(bool)
failed, _ := claudeHostedResultFailure(
blockType,
results,
&isError,
strings.TrimSpace(kitutil.Interface2String(block["error_code"])),
)
if failed {
status = "failed"
}
}
history = append(history, HostedHistoryItem{
Kind: kind,
NativeType: blockType,
Role: message.Role,
MessageIndex: messageIndex,
BlockIndex: blockIndex,
Sequence: len(history),
ID: strings.TrimSpace(kitutil.Interface2String(block["id"])),
CallID: strings.TrimSpace(kitutil.Interface2String(block["tool_use_id"])),
Name: name,
ServerName: strings.TrimSpace(kitutil.Interface2String(block["server_name"])),
Status: status,
Action: rawMapValue(block, "input"),
Results: results,
Caller: rawMapValue(block, "caller"),
Raw: cloneRaw(rawBlock),
})
}
if len(filtered) > 0 {
for index := historyStart; index < len(history); index++ {
history[index].MessageHasRegular = true
}
message.Content = filtered
clonedMessages = append(clonedMessages, message)
}
}
return clonedMessages, history, nil
}
func rawMapValue(value map[string]any, key string) json.RawMessage {
item, exists := value[key]
if !exists {
return nil
}
encoded, err := kitutil.Marshal(item)
if err != nil {
return nil
}
return encoded
}
func firstRawMapValue(value map[string]any, keys ...string) json.RawMessage {
for _, key := range keys {
if raw := rawMapValue(value, key); len(raw) > 0 {
return raw
}
}
return nil
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,171 @@
package toolconv
import (
"encoding/json"
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
// claudeWebSearchInputFromResponses narrows the richer Responses action union
// to the only operation exposed by Claude's web-search server tool: one query.
func claudeWebSearchInputFromResponses(raw json.RawMessage) (map[string]any, error) {
canonical, err := dto.NormalizeResponsesWebSearchAction(raw)
if err != nil {
return nil, err
}
var action struct {
Type string `json:"type"`
Query string `json:"query"`
Queries []string `json:"queries"`
}
if err := kitutil.Unmarshal(canonical, &action); err != nil {
return nil, fmt.Errorf("decode normalized Responses web-search action: %w", err)
}
if action.Type != "search" {
return nil, fmt.Errorf("Responses web-search action %q has no Claude equivalent", action.Type)
}
queries := make([]string, 0, len(action.Queries)+1)
for _, query := range action.Queries {
query = strings.TrimSpace(query)
if query != "" {
queries = append(queries, query)
}
}
deprecatedQuery := strings.TrimSpace(action.Query)
if len(queries) == 0 && deprecatedQuery != "" {
queries = append(queries, deprecatedQuery)
} else if deprecatedQuery != "" && (len(queries) != 1 || queries[0] != deprecatedQuery) {
return nil, fmt.Errorf("Responses web-search action contains conflicting query and queries fields")
}
if len(queries) != 1 {
return nil, fmt.Errorf("Claude web search requires exactly one query, got %d", len(queries))
}
return map[string]any{"query": queries[0]}, nil
}
// responsesMCPArgumentsFromClaude converts Claude's JSON-object input into the
// JSON string required by a Responses mcp_call.arguments field.
func responsesMCPArgumentsFromClaude(raw json.RawMessage) (json.RawMessage, error) {
trimmed := strings.TrimSpace(string(raw))
if trimmed == "" || kitutil.GetJsonType(raw) != "object" {
return nil, fmt.Errorf("Claude MCP input must be a JSON object")
}
var object map[string]json.RawMessage
if err := kitutil.Unmarshal(raw, &object); err != nil {
return nil, fmt.Errorf("decode Claude MCP input: %w", err)
}
encoded, err := kitutil.Marshal(trimmed)
if err != nil {
return nil, fmt.Errorf("encode Responses MCP arguments: %w", err)
}
return encoded, nil
}
// claudeMCPInputFromResponses decodes the outer Responses JSON string and
// validates that its contents satisfy Claude's JSON-object input contract.
func claudeMCPInputFromResponses(raw json.RawMessage) (any, error) {
var encoded string
if len(raw) == 0 || kitutil.GetJsonType(raw) != "string" {
return nil, fmt.Errorf("Responses MCP arguments must be a JSON string")
}
if err := kitutil.Unmarshal(raw, &encoded); err != nil {
return nil, fmt.Errorf("decode Responses MCP arguments string: %w", err)
}
encoded = strings.TrimSpace(encoded)
if encoded == "" || kitutil.GetJsonType(json.RawMessage(encoded)) != "object" {
return nil, fmt.Errorf("Responses MCP arguments must contain a JSON object")
}
var input map[string]any
if err := kitutil.Unmarshal([]byte(encoded), &input); err != nil {
return nil, fmt.Errorf("decode Responses MCP arguments object: %w", err)
}
return input, nil
}
// responsesMCPStringFromClaudeContent maps the Claude result shapes that can
// be represented without changing their meaning. A single text block is the
// structured form of a plain MCP text result; other block arrays can contain
// media/resources that a Responses string cannot faithfully preserve.
func responsesMCPStringFromClaudeContent(raw json.RawMessage) (json.RawMessage, bool, error) {
switch kitutil.GetJsonType(raw) {
case "string":
var value string
if err := kitutil.Unmarshal(raw, &value); err != nil {
return nil, false, fmt.Errorf("decode Claude MCP result string: %w", err)
}
return append(json.RawMessage(nil), raw...), false, nil
case "array":
var blocks []map[string]json.RawMessage
if err := kitutil.Unmarshal(raw, &blocks); err != nil {
return nil, false, fmt.Errorf("decode Claude MCP result blocks: %w", err)
}
if len(blocks) == 0 {
encoded, err := kitutil.Marshal("")
return encoded, true, err
}
if len(blocks) != 1 {
return nil, false, fmt.Errorf("Responses MCP output cannot preserve %d Claude content blocks", len(blocks))
}
var blockType string
if err := kitutil.Unmarshal(blocks[0]["type"], &blockType); err != nil || blockType != "text" {
return nil, false, fmt.Errorf("Responses MCP output can only preserve a Claude text result block")
}
var text string
if err := kitutil.Unmarshal(blocks[0]["text"], &text); err != nil {
return nil, false, fmt.Errorf("decode Claude MCP text result: %w", err)
}
encoded, err := kitutil.Marshal(text)
if err != nil {
return nil, false, fmt.Errorf("encode Responses MCP output: %w", err)
}
return encoded, true, nil
default:
return nil, false, fmt.Errorf("Responses MCP output cannot preserve Claude result type %q", kitutil.GetJsonType(raw))
}
}
func claudeMCPContentFromResponsesString(raw json.RawMessage) (string, error) {
if len(raw) == 0 || kitutil.GetJsonType(raw) != "string" {
return "", fmt.Errorf("Responses MCP output/error must be a JSON string")
}
var content string
if err := kitutil.Unmarshal(raw, &content); err != nil {
return "", fmt.Errorf("decode Responses MCP output/error string: %w", err)
}
return content, nil
}
func claudeHostedResultFailure(blockType string, content json.RawMessage, explicitError *bool, explicitCode string) (bool, string) {
if explicitError != nil && *explicitError {
return true, strings.TrimSpace(explicitCode)
}
if strings.TrimSpace(explicitCode) != "" {
return true, strings.TrimSpace(explicitCode)
}
if !strings.HasSuffix(strings.TrimSpace(blockType), "_tool_result") || kitutil.GetJsonType(content) != "object" {
return false, ""
}
var resultError struct {
Type string `json:"type"`
ErrorCode string `json:"error_code"`
}
if kitutil.Unmarshal(content, &resultError) != nil {
return false, ""
}
if !strings.HasSuffix(strings.TrimSpace(resultError.Type), "_error") && strings.TrimSpace(resultError.ErrorCode) == "" {
return false, ""
}
return true, strings.TrimSpace(resultError.ErrorCode)
}
func responsesMCPErrorFromClaudeContent(raw json.RawMessage, errorCode string) (json.RawMessage, bool, error) {
if errorCode = strings.TrimSpace(errorCode); errorCode != "" {
encoded, err := kitutil.Marshal(errorCode)
return encoded, false, err
}
return responsesMCPStringFromClaudeContent(raw)
}
@@ -0,0 +1,118 @@
package toolconv
import (
"encoding/json"
"github.com/QuantumNous/new-api/relaykit/types"
)
type Kind string
const (
KindFunction Kind = "function"
KindWebSearch Kind = "web_search"
KindFileSearch Kind = "file_search"
KindWebFetch Kind = "web_fetch"
KindCodeExecution Kind = "code_execution"
KindComputerUse Kind = "computer_use"
KindURLContext Kind = "url_context"
KindMCP Kind = "mcp"
KindImage Kind = "image_generation"
KindNative Kind = "native"
)
type Execution string
const (
ExecutionClient Execution = "client"
ExecutionServer Execution = "server"
)
type Function struct {
Name string
Description string
Parameters any
Strict *bool
}
type ApproximateLocation struct {
City string
Region string
Country string
Timezone string
}
type WebSearch struct {
Location *ApproximateLocation
AllowedDomains []string
BlockedDomains []string
SearchContextSize string
MaxUses *int
AllowedCallers []string
ResponseInclusion string
ExternalWebAccess *bool
ReturnTokenBudget json.RawMessage
}
type Definition struct {
Kind Kind
Execution Execution
NativeType string
Name string
Function *Function
WebSearch *WebSearch
Raw json.RawMessage
Group int
}
type ChoiceMode string
const (
ChoiceAuto ChoiceMode = "auto"
ChoiceNone ChoiceMode = "none"
ChoiceRequired ChoiceMode = "required"
ChoiceNamed ChoiceMode = "named"
ChoiceOpaque ChoiceMode = "opaque"
)
type Choice struct {
Mode ChoiceMode
Kind Kind
Name string
AllowedNames []string
NativeType string
DisableParallelToolUse *bool
Raw json.RawMessage
}
type Set struct {
Source types.RelayFormat
Definitions []Definition
Choice *Choice
ParallelAllowed *bool
NativeToolConfig json.RawMessage
History []HostedHistoryItem
}
func (s Set) Empty() bool {
return len(s.Definitions) == 0 && s.Choice == nil && s.ParallelAllowed == nil && len(s.NativeToolConfig) == 0 && len(s.History) == 0
}
type HostedHistoryItem struct {
Kind Kind
NativeType string
Role string
MessageIndex int
BlockIndex int
MessageHasRegular bool
Sequence int
ID string
CallID string
Name string
ServerName string
Status string
Action json.RawMessage
Results json.RawMessage
Caller json.RawMessage
Raw json.RawMessage
}
@@ -0,0 +1,103 @@
package toolconv
import (
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func geminiCodeExecutionRequest(t *testing.T) *dto.GeminiChatRequest {
t.Helper()
tools, err := kitutil.Marshal([]map[string]any{{"codeExecution": map[string]any{}}})
require.NoError(t, err)
return &dto.GeminiChatRequest{
Contents: []dto.GeminiChatContent{
{Role: "user", Parts: []dto.GeminiPart{{Text: "run this"}}},
},
Tools: tools,
}
}
func hasDiagnosticCode(diagnostics []types.ConversionDiagnostic, code string) bool {
for _, diagnostic := range diagnostics {
if diagnostic.Code == code {
return true
}
}
return false
}
func TestDefaultPolicyAllowsGeminiCodeExecutionToOpenAI(t *testing.T) {
t.Parallel()
_, set, err := ExtractRequest(types.RelayFormatGemini, geminiCodeExecutionRequest(t))
require.NoError(t, err)
target := &dto.GeneralOpenAIRequest{
Model: "gpt-4o",
Messages: []dto.Message{{Role: "user", Content: "run this"}},
}
out, diagnostics, err := AttachRequest(types.RelayFormatOpenAI, target, set, &convmeta.Options{})
require.NoError(t, err)
require.NotNil(t, out)
assert.True(t, hasDiagnosticCode(diagnostics, "unsupported_hosted_tool"))
assert.Equal(t, types.ConversionLossPolicyAllow, (&convmeta.Options{}).EffectiveToolLossPolicy())
}
func TestResponsePhaseNeverRejectsEvenUnderStrictPolicy(t *testing.T) {
t.Parallel()
text := "hello"
resp := &dto.ClaudeResponse{
Type: "message",
Role: "assistant",
StopReason: "pause_turn",
Content: []dto.ClaudeMediaMessage{
{Type: "redacted_thinking", Data: "secret"},
{Type: "text", Text: &text},
},
}
diagnostics := InspectResponse(types.RelayFormatClaude, types.RelayFormatOpenAI, resp)
require.True(t, hasDiagnosticCode(diagnostics, "continuation_state_lost"))
require.Error(t, types.RejectConversionLoss(types.ConversionLossPolicyStrict, diagnostics))
_, hosted, err := ExtractHostedResponse(types.RelayFormatClaude, resp)
require.NoError(t, err)
out, _, err := AttachHostedResponse(
types.RelayFormatOpenAI,
&dto.OpenAITextResponse{},
hosted,
&convmeta.Options{ToolLossPolicy: types.ConversionLossPolicyStrict},
)
require.NoError(t, err)
require.NotNil(t, out)
}
func TestSafePolicyRejectsRequestPhaseHostedToolLoss(t *testing.T) {
t.Parallel()
_, set, err := ExtractRequest(types.RelayFormatGemini, geminiCodeExecutionRequest(t))
require.NoError(t, err)
target := &dto.GeneralOpenAIRequest{
Model: "gpt-4o",
Messages: []dto.Message{{Role: "user", Content: "run this"}},
}
_, diagnostics, err := AttachRequest(
types.RelayFormatOpenAI,
target,
set,
&convmeta.Options{ToolLossPolicy: types.ConversionLossPolicySafe},
)
require.Error(t, err)
var loss *types.ConversionLossError
require.ErrorAs(t, err, &loss)
require.NotEmpty(t, loss.Diagnostics)
assert.True(t, hasDiagnosticCode(loss.Diagnostics, "unsupported_hosted_tool"))
assert.True(t, hasDiagnosticCode(diagnostics, "unsupported_hosted_tool"))
}
@@ -0,0 +1,515 @@
package toolconv
import (
"encoding/json"
"fmt"
"strings"
"unicode/utf8"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
// InspectResponse reports protocol information that the current response
// converters cannot faithfully express. It keeps loss handling centralized so
// direct and multi-step routes behave consistently.
func InspectResponse(from types.RelayFormat, to types.RelayFormat, response any) []types.ConversionDiagnostic {
if from == to {
return nil
}
var diagnostics []types.ConversionDiagnostic
switch value := response.(type) {
case *dto.ClaudeResponse:
diagnostics = inspectClaudeResponse(value, to)
case dto.ClaudeResponse:
diagnostics = inspectClaudeResponse(&value, to)
case *dto.OpenAIResponsesResponse:
diagnostics = inspectOpenAIResponsesResponse(value, to)
case dto.OpenAIResponsesResponse:
diagnostics = inspectOpenAIResponsesResponse(&value, to)
case *dto.ResponsesStreamResponse:
diagnostics = inspectOpenAIResponsesStreamResponse(value)
case dto.ResponsesStreamResponse:
diagnostics = inspectOpenAIResponsesStreamResponse(&value)
case *dto.GeminiChatResponse:
diagnostics = inspectGeminiResponse(value, to)
case dto.GeminiChatResponse:
diagnostics = inspectGeminiResponse(&value, to)
}
for index := range diagnostics {
diagnostics[index].From = from
diagnostics[index].To = to
}
return diagnostics
}
// InspectStreamResponse avoids treating a single Gemini streaming chunk as a
// complete grounding document. Gemini grounding chunk indexes and segment
// offsets are cumulative across the stream; the stateful converter validates
// and resolves them after accumulating prior chunks.
func InspectStreamResponse(from types.RelayFormat, to types.RelayFormat, response any) []types.ConversionDiagnostic {
if from != types.RelayFormatGemini {
return InspectResponse(from, to, response)
}
var value *dto.GeminiChatResponse
switch response := response.(type) {
case *dto.GeminiChatResponse:
value = response
case dto.GeminiChatResponse:
value = &response
default:
return InspectResponse(from, to, response)
}
if value == nil {
return nil
}
var diagnostics []types.ConversionDiagnostic
for index := range value.Candidates {
metadata := value.Candidates[index].GroundingMetadata
if metadata == nil {
continue
}
if len(metadata.WebSearchQueries) > 0 && to != types.RelayFormatOpenAIResponses {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("candidates[%d].groundingMetadata.webSearchQueries", index),
"web_search_call_unrepresentable",
"Gemini grounding confirms a hosted web search, but the target stream converter cannot produce an OpenAI Responses web_search_call lifecycle",
))
}
if len(metadata.WebSearchQueries) == 0 && len(metadata.RetrievalQueries) == 0 && len(metadata.SearchEntryPoint) == 0 && len(metadata.RetrievalMetadata) == 0 && len(metadata.SourceFlaggingUris) == 0 && metadata.GoogleMapsWidgetContextToken == "" {
continue
}
diagnostics = append(diagnostics, responsePresentationLoss(
fmt.Sprintf("candidates[%d].groundingMetadata", index),
"hosted_tool_metadata_reduced",
"Gemini grounding citations are preserved across stream chunks, but provider-specific search metadata has no target-protocol equivalent",
))
}
for index := range diagnostics {
diagnostics[index].From = from
diagnostics[index].To = to
}
return diagnostics
}
func inspectClaudeResponse(response *dto.ClaudeResponse, to types.RelayFormat) []types.ConversionDiagnostic {
if response == nil {
return nil
}
var diagnostics []types.ConversionDiagnostic
if to != types.RelayFormatOpenAIResponses && (response.StopReason == "pause_turn" || response.Delta != nil && response.Delta.StopReason != nil && *response.Delta.StopReason == "pause_turn") {
diagnostics = append(diagnostics, responseSemanticLoss(
"stop_reason",
"continuation_state_lost",
"Claude pause_turn requires protocol-native continuation state that the target response cannot preserve",
))
}
for index := range response.Content {
diagnostics = append(diagnostics, inspectClaudeContentBlock(&response.Content[index], fmt.Sprintf("content[%d]", index), to, false)...)
}
if response.ContentBlock != nil {
diagnostics = append(diagnostics, inspectClaudeContentBlock(response.ContentBlock, "content_block", to, true)...)
}
return diagnostics
}
func inspectClaudeContentBlock(block *dto.ClaudeMediaMessage, path string, to types.RelayFormat, stream bool) []types.ConversionDiagnostic {
if block == nil {
return nil
}
blockType := strings.TrimSpace(block.Type)
var diagnostics []types.ConversionDiagnostic
if isClaudeHostedToolBlock(blockType) {
kind := KindNative
if blockType == "server_tool_use" || blockType == "mcp_tool_use" {
kind = hostedKindFromClaudeCall(blockType, block.Name)
} else {
kind = hostedKindFromClaudeResult(blockType)
}
if to != types.RelayFormatOpenAIResponses || kind != KindWebSearch && kind != KindMCP {
diagnostics = append(diagnostics, responseSemanticLoss(
path,
"hosted_tool_unrepresentable",
fmt.Sprintf("%s cannot losslessly represent Claude hosted-tool response block %q", to, blockType),
))
} else if blockType == "server_tool_use" || blockType == "mcp_tool_use" {
if block.Id == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".id",
"hosted_tool_id_missing",
"Claude hosted-tool call has no id for pairing it with its result",
))
}
if kind == KindMCP && (block.Name == "" || block.ServerName == "") {
diagnostics = append(diagnostics, responseSemanticLoss(
path,
"mcp_identity_missing",
"Claude MCP tool use must include both name and server_name for Responses MCP mapping",
))
}
if rawJSONPresent(block.Caller) {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".caller",
"hosted_tool_caller_unrepresentable",
"OpenAI Responses web_search_call and mcp_call items cannot preserve Claude's hosted-tool caller provenance",
))
}
if !stream {
input, err := kitutil.Marshal(block.Input)
if err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".input",
"hosted_tool_input_invalid",
err.Error(),
))
} else if kind == KindMCP {
if _, err := responsesMCPArgumentsFromClaude(input); err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".input",
"mcp_arguments_unrepresentable",
err.Error(),
))
}
} else if kind == KindWebSearch {
if _, err := dto.NormalizeResponsesWebSearchAction(input); err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".input",
"web_search_action_unrepresentable",
err.Error(),
))
}
}
}
} else if block.ToolUseId == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".tool_use_id",
"hosted_tool_id_missing",
"Claude hosted-tool result has no tool_use_id for pairing it with its call",
))
}
if kind == KindMCP && blockType == "mcp_tool_result" {
content, err := kitutil.Marshal(block.Content)
if err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(path+".content", "mcp_result_unrepresentable", err.Error()))
} else {
failed, errorCode := claudeHostedResultFailure(blockType, content, block.IsError, block.ErrorCode)
var normalized bool
if failed {
_, normalized, err = responsesMCPErrorFromClaudeContent(content, errorCode)
} else {
_, normalized, err = responsesMCPStringFromClaudeContent(content)
}
if err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(path+".content", "mcp_result_unrepresentable", err.Error()))
} else if normalized {
diagnostics = append(diagnostics, responsePresentationLoss(
path+".content",
"mcp_text_result_normalized",
"Claude's single MCP text block is normalized to a Responses output string",
))
}
}
}
}
if blockType == "redacted_thinking" && block.Data != "" {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".data",
"continuation_state_lost",
"Claude encrypted thinking state cannot be represented by the target response",
))
}
return diagnostics
}
func isClaudeHostedToolBlock(blockType string) bool {
if blockType == "server_tool_use" || blockType == "mcp_tool_use" || blockType == "mcp_tool_result" {
return true
}
return strings.HasSuffix(blockType, "_tool_result")
}
func inspectOpenAIResponsesResponse(response *dto.OpenAIResponsesResponse, to types.RelayFormat) []types.ConversionDiagnostic {
if response == nil {
return nil
}
var diagnostics []types.ConversionDiagnostic
for index := range response.Output {
output := &response.Output[index]
if !isResponsesHostedOutput(output.Type) {
continue
}
kind := hostedKindFromResponsesType(output.Type)
if to != types.RelayFormatClaude || kind != KindWebSearch && kind != KindMCP {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d]", index),
"hosted_tool_unrepresentable",
fmt.Sprintf("%s cannot losslessly represent OpenAI Responses hosted-tool output %q", to, output.Type),
))
continue
}
if output.ID == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].id", index),
"hosted_tool_id_missing",
"hosted-tool output has no id for pairing the call with its result",
))
}
if kind == KindMCP {
if output.Name == "" || output.ServerLabel == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d]", index),
"mcp_identity_missing",
"Responses MCP output must include both name and server_label for Claude MCP mapping",
))
}
if output.ApprovalRequestID != "" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].approval_request_id", index),
"mcp_approval_state_unrepresentable",
"Claude MCP response blocks cannot preserve a Responses approval_request_id",
))
}
if _, err := claudeMCPInputFromResponses(output.Arguments); err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].arguments", index),
"mcp_arguments_unrepresentable",
err.Error(),
))
}
if rawJSONPresent(output.Output) && rawJSONPresent(output.ItemError) {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d]", index),
"mcp_result_ambiguous",
"Responses MCP output contains both output and error",
))
}
for _, field := range []struct {
name string
raw json.RawMessage
}{{name: "output", raw: output.Output}, {name: "error", raw: output.ItemError}} {
if !rawJSONPresent(field.raw) {
continue
}
if _, err := claudeMCPContentFromResponsesString(field.raw); err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].%s", index, field.name),
"mcp_result_unrepresentable",
err.Error(),
))
}
}
} else if kind == KindWebSearch {
if _, err := claudeWebSearchInputFromResponses(output.Action); err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].action", index),
"web_search_action_unrepresentable",
err.Error(),
))
}
}
if output.Status != "" && output.Status != "in_progress" && output.Status != "completed" && output.Status != "failed" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].status", index),
"hosted_tool_status_unrepresentable",
fmt.Sprintf("Claude cannot preserve hosted-tool status %q", output.Status),
))
}
if output.Status == "failed" && !rawJSONPresent(output.ItemError) && !rawJSONPresent(output.Output) && !rawJSONPresent(output.Results) {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].status", index),
"hosted_tool_error_missing",
"failed hosted-tool output has no error or output that Claude can preserve",
))
}
}
return diagnostics
}
func inspectOpenAIResponsesStreamResponse(response *dto.ResponsesStreamResponse) []types.ConversionDiagnostic {
if response == nil {
return nil
}
if response.Item != nil && isResponsesHostedOutput(response.Item.Type) {
return []types.ConversionDiagnostic{responseSemanticLoss(
"item",
"hosted_tool_event_unrepresentable",
fmt.Sprintf("OpenAI Responses hosted-tool output %q has no semantic target-protocol stream mapping", response.Item.Type),
)}
}
eventType := strings.TrimSpace(response.Type)
if strings.Contains(eventType, ".web_search_call.") ||
strings.Contains(eventType, ".file_search_call.") ||
strings.Contains(eventType, ".code_interpreter_call.") ||
strings.Contains(eventType, ".computer_tool_call.") ||
strings.Contains(eventType, ".image_generation_call.") ||
strings.Contains(eventType, ".mcp_call.") {
return []types.ConversionDiagnostic{responseSemanticLoss(
"type",
"hosted_tool_event_unrepresentable",
fmt.Sprintf("OpenAI Responses hosted-tool stream event %q has no semantic target-protocol mapping", eventType),
)}
}
return nil
}
func isResponsesHostedOutput(outputType string) bool {
switch strings.TrimSpace(outputType) {
case "", "message", "reasoning", "function_call", "custom_tool_call":
return false
default:
return true
}
}
func inspectGeminiResponse(response *dto.GeminiChatResponse, to types.RelayFormat) []types.ConversionDiagnostic {
if response == nil {
return nil
}
var diagnostics []types.ConversionDiagnostic
for index := range response.Candidates {
metadata := response.Candidates[index].GroundingMetadata
if metadata == nil {
continue
}
path := fmt.Sprintf("candidates[%d].groundingMetadata", index)
if len(metadata.WebSearchQueries) > 0 && to != types.RelayFormatOpenAIResponses {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".webSearchQueries",
"web_search_call_unrepresentable",
"Gemini grounding confirms a hosted web search, but the target converter cannot produce an OpenAI Responses web_search_call item",
))
}
diagnostics = append(diagnostics, inspectGeminiGroundingCitations(response.Candidates[index].Content, metadata, path)...)
if len(metadata.WebSearchQueries) == 0 && len(metadata.RetrievalQueries) == 0 && len(metadata.SearchEntryPoint) == 0 && len(metadata.RetrievalMetadata) == 0 && len(metadata.SourceFlaggingUris) == 0 && metadata.GoogleMapsWidgetContextToken == "" {
continue
}
diagnostics = append(diagnostics, responsePresentationLoss(
path,
"hosted_tool_metadata_reduced",
"Gemini grounding citations are preserved, but provider-specific search metadata has no target-protocol equivalent",
))
}
return diagnostics
}
type groundingSupportForInspection struct {
Segment struct {
PartIndex *int `json:"partIndex,omitempty"`
StartIndex int `json:"startIndex,omitempty"`
EndIndex int `json:"endIndex,omitempty"`
Text string `json:"text,omitempty"`
} `json:"segment"`
GroundingChunkIndices []int `json:"groundingChunkIndices"`
}
type groundingChunkForInspection struct {
Web *groundingSourceForInspection `json:"web,omitempty"`
RetrievedContext *groundingSourceForInspection `json:"retrievedContext,omitempty"`
}
type groundingSourceForInspection struct {
URI string `json:"uri,omitempty"`
}
func inspectGeminiGroundingCitations(content dto.GeminiChatContent, metadata *dto.GeminiGroundingMetadata, path string) []types.ConversionDiagnostic {
if len(metadata.GroundingSupports) == 0 {
return nil
}
var chunks []groundingChunkForInspection
if len(metadata.GroundingChunks) == 0 || kitutil.Unmarshal(metadata.GroundingChunks, &chunks) != nil {
return []types.ConversionDiagnostic{responseSemanticLoss(
path+".groundingChunks",
"grounding_source_invalid",
"Gemini grounding chunks are missing or cannot be decoded",
)}
}
var supports []groundingSupportForInspection
if err := kitutil.Unmarshal(metadata.GroundingSupports, &supports); err != nil {
return []types.ConversionDiagnostic{responseSemanticLoss(
path+".groundingSupports",
"grounding_citation_invalid",
fmt.Sprintf("Gemini grounding supports cannot be decoded: %v", err),
)}
}
textPartCount := 0
soleTextPart := -1
for index := range content.Parts {
if content.Parts[index].Text == "" || content.Parts[index].Thought {
continue
}
textPartCount++
soleTextPart = index
}
var diagnostics []types.ConversionDiagnostic
for index, support := range supports {
segmentPath := fmt.Sprintf("%s.groundingSupports[%d].segment", path, index)
hasSource := false
for _, chunkIndex := range support.GroundingChunkIndices {
if chunkIndex < 0 || chunkIndex >= len(chunks) {
continue
}
source := chunks[chunkIndex].Web
if source == nil {
source = chunks[chunkIndex].RetrievedContext
}
if source != nil && source.URI != "" {
hasSource = true
break
}
}
if !hasSource {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("%s.groundingSupports[%d].groundingChunkIndices", path, index),
"grounding_source_invalid",
"Gemini grounding support does not reference a valid source URI",
))
continue
}
partIndex := soleTextPart
if support.Segment.PartIndex != nil {
partIndex = *support.Segment.PartIndex
} else if textPartCount != 1 {
diagnostics = append(diagnostics, responseSemanticLoss(
segmentPath+".partIndex",
"grounding_part_ambiguous",
"Gemini grounding omitted partIndex while multiple text parts are present, so citation placement is ambiguous",
))
continue
}
if partIndex < 0 || partIndex >= len(content.Parts) || content.Parts[partIndex].Text == "" || content.Parts[partIndex].Thought {
diagnostics = append(diagnostics, responseSemanticLoss(
segmentPath+".partIndex",
"grounding_part_invalid",
fmt.Sprintf("Gemini grounding references non-text part %d", partIndex),
))
continue
}
partText := content.Parts[partIndex].Text
start, end := support.Segment.StartIndex, support.Segment.EndIndex
if start < 0 || end <= start || end > len(partText) || !utf8.ValidString(partText[:start]) || !utf8.ValidString(partText[:end]) {
diagnostics = append(diagnostics, responseSemanticLoss(
segmentPath,
"grounding_offset_invalid",
"Gemini grounding byte offsets do not identify valid UTF-8 boundaries in the referenced part",
))
continue
}
if support.Segment.Text != "" && partText[start:end] != support.Segment.Text {
diagnostics = append(diagnostics, responseSemanticLoss(
segmentPath+".text",
"grounding_text_mismatch",
"Gemini grounding segment text does not match the referenced part range",
))
}
}
return diagnostics
}
func responseSemanticLoss(path string, code string, message string) types.ConversionDiagnostic {
return types.ConversionDiagnostic{Code: code, Path: path, Message: message, Severity: types.ConversionDiagnosticError}
}
func responsePresentationLoss(path string, code string, message string) types.ConversionDiagnostic {
return types.ConversionDiagnostic{Code: code, Path: path, Message: message, Severity: types.ConversionDiagnosticWarning}
}
@@ -0,0 +1,816 @@
package toolconv
import (
"encoding/json"
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
geminichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/gemini_chat"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
type HostedResponseItem struct {
Kind Kind
NativeType string
ID string
CallID string
Name string
Status string
Position int
Action json.RawMessage
Results json.RawMessage
Sources json.RawMessage
Caller json.RawMessage
Arguments json.RawMessage
Output json.RawMessage
Error json.RawMessage
ServerName string
IsError *bool
ApprovalRequestID string
Tools json.RawMessage
ErrorCode string
Raw json.RawMessage
}
type HostedResponseSet struct {
Source types.RelayFormat
Items []HostedResponseItem
SourceLength int
RegularPositions []int
}
type positionedResponsesOutput struct {
position int
output dto.ResponsesOutput
}
type positionedClaudeBlocks struct {
position int
blocks []dto.ClaudeMediaMessage
}
func (s HostedResponseSet) Empty() bool {
return len(s.Items) == 0
}
// ExtractHostedResponse removes server-executed tool artifacts before a
// message converter sees them. The artifacts travel beside multi-step routes,
// just like request tool definitions, so a lossy Chat pivot cannot reclassify
// or discard them.
func ExtractHostedResponse(format types.RelayFormat, response any) (any, HostedResponseSet, error) {
switch format {
case types.RelayFormatClaude:
return extractClaudeHostedResponse(response)
case types.RelayFormatOpenAIResponses:
return extractOpenAIHostedResponse(response)
case types.RelayFormatGemini:
return extractGeminiHostedResponse(response)
default:
return response, HostedResponseSet{Source: format}, nil
}
}
func AttachHostedResponse(format types.RelayFormat, response any, set HostedResponseSet, options *convmeta.Options) (any, []types.ConversionDiagnostic, error) {
if set.Empty() {
return response, nil, nil
}
var (
value any
diagnostics []types.ConversionDiagnostic
err error
)
switch format {
case types.RelayFormatOpenAIResponses:
value, diagnostics, err = attachOpenAIHostedResponse(response, set)
case types.RelayFormatClaude:
value, diagnostics, err = attachClaudeHostedResponse(response, set)
default:
value = response
for index, item := range set.Items {
diagnostics = append(diagnostics, responsePresentationLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"hosted_tool_event_omitted",
fmt.Sprintf("%s cannot represent hosted-tool response %q", format, item.NativeType),
))
}
}
if err != nil {
return nil, diagnostics, err
}
for index := range diagnostics {
diagnostics[index].From = set.Source
diagnostics[index].To = format
}
return value, diagnostics, nil
}
func extractClaudeHostedResponse(response any) (any, HostedResponseSet, error) {
var source *dto.ClaudeResponse
switch value := response.(type) {
case *dto.ClaudeResponse:
source = value
case dto.ClaudeResponse:
source = &value
default:
return nil, HostedResponseSet{}, fmt.Errorf("expected Claude response, got %T", response)
}
clone := *source
clone.Content = make([]dto.ClaudeMediaMessage, 0, len(source.Content))
set := HostedResponseSet{Source: types.RelayFormatClaude, SourceLength: len(source.Content)}
for position := range source.Content {
block := source.Content[position]
blockType := strings.TrimSpace(block.Type)
switch {
case blockType == "server_tool_use" || blockType == "mcp_tool_use":
rawBlock, err := kitutil.Marshal(block)
if err != nil {
return nil, set, fmt.Errorf("content[%d]: %w", position, err)
}
action, err := kitutil.Marshal(block.Input)
if err != nil {
return nil, set, fmt.Errorf("content[%d].input: %w", position, err)
}
item := HostedResponseItem{
Kind: hostedKindFromClaudeCall(blockType, block.Name),
NativeType: blockType,
ID: block.Id,
CallID: block.Id,
Name: block.Name,
Status: "in_progress",
Position: position,
Action: action,
Caller: append(json.RawMessage(nil), block.Caller...),
ServerName: block.ServerName,
Raw: rawBlock,
}
set.Items = append(set.Items, item)
case isClaudeHostedToolBlock(blockType):
rawBlock, err := kitutil.Marshal(block)
if err != nil {
return nil, set, fmt.Errorf("content[%d]: %w", position, err)
}
results, err := kitutil.Marshal(block.Content)
if err != nil {
return nil, set, fmt.Errorf("content[%d].content: %w", position, err)
}
failed, errorCode := claudeHostedResultFailure(blockType, results, block.IsError, block.ErrorCode)
status := "completed"
isError := block.IsError
if failed {
status = "failed"
if isError == nil {
value := true
isError = &value
}
}
set.Items = append(set.Items, HostedResponseItem{
Kind: hostedKindFromClaudeResult(blockType),
NativeType: blockType,
ID: block.ToolUseId,
CallID: block.ToolUseId,
Status: status,
Position: position,
Results: results,
ErrorCode: errorCode,
IsError: isError,
Raw: rawBlock,
})
default:
clone.Content = append(clone.Content, block)
set.RegularPositions = append(set.RegularPositions, position)
}
}
return &clone, set, nil
}
func extractOpenAIHostedResponse(response any) (any, HostedResponseSet, error) {
var source *dto.OpenAIResponsesResponse
switch value := response.(type) {
case *dto.OpenAIResponsesResponse:
source = value
case dto.OpenAIResponsesResponse:
source = &value
default:
return nil, HostedResponseSet{}, fmt.Errorf("expected OpenAI Responses response, got %T", response)
}
clone := *source
clone.Output = make([]dto.ResponsesOutput, 0, len(source.Output))
set := HostedResponseSet{Source: types.RelayFormatOpenAIResponses, SourceLength: len(source.Output)}
for position := range source.Output {
output := source.Output[position]
if !isResponsesHostedOutput(output.Type) {
clone.Output = append(clone.Output, output)
set.RegularPositions = append(set.RegularPositions, position)
continue
}
rawOutput, err := kitutil.Marshal(output)
if err != nil {
return nil, set, fmt.Errorf("output[%d]: %w", position, err)
}
set.Items = append(set.Items, HostedResponseItem{
Kind: hostedKindFromResponsesType(output.Type),
NativeType: output.Type,
ID: output.ID,
CallID: output.CallId,
Name: output.Name,
Status: output.Status,
Position: position,
Action: append(json.RawMessage(nil), output.Action...),
Results: append(json.RawMessage(nil), output.Results...),
Sources: append(json.RawMessage(nil), output.Sources...),
Caller: append(json.RawMessage(nil), output.Caller...),
Arguments: append(json.RawMessage(nil), output.Arguments...),
Output: append(json.RawMessage(nil), output.Output...),
Error: append(json.RawMessage(nil), output.ItemError...),
ServerName: output.ServerLabel,
ApprovalRequestID: output.ApprovalRequestID,
Tools: append(json.RawMessage(nil), output.MCPTools...),
Raw: rawOutput,
})
}
return &clone, set, nil
}
func extractGeminiHostedResponse(response any) (any, HostedResponseSet, error) {
var source *dto.GeminiChatResponse
switch value := response.(type) {
case *dto.GeminiChatResponse:
source = value
case dto.GeminiChatResponse:
source = &value
default:
return nil, HostedResponseSet{}, fmt.Errorf("expected Gemini response, got %T", response)
}
queries := geminichat.GroundingWebSearchQueries(source)
if len(queries) == 0 {
return source, HostedResponseSet{Source: types.RelayFormatGemini}, nil
}
action, err := kitutil.Marshal(map[string]any{
"type": "search",
"queries": queries,
})
if err != nil {
return nil, HostedResponseSet{}, fmt.Errorf("marshal Gemini web-search action: %w", err)
}
// The Chat pivot emits the answer as the regular Responses output. Place
// the hosted call after that output, matching the stream bridge which only
// learns Gemini's queries once grounding metadata arrives near stream end.
set := HostedResponseSet{
Source: types.RelayFormatGemini,
SourceLength: 2,
RegularPositions: []int{0},
Items: []HostedResponseItem{{
Kind: KindWebSearch,
NativeType: "googleSearch",
ID: fmt.Sprintf("ws_%s", kitutil.GetUUID()),
Status: "completed",
Position: 1,
Action: action,
}},
}
return source, set, nil
}
func attachOpenAIHostedResponse(response any, set HostedResponseSet) (any, []types.ConversionDiagnostic, error) {
target, ok := response.(*dto.OpenAIResponsesResponse)
if !ok || target == nil {
return nil, nil, fmt.Errorf("expected OpenAI Responses response, got %T", response)
}
var diagnostics []types.ConversionDiagnostic
hostedOutput := make([]positionedResponsesOutput, 0, len(set.Items))
convertedByID := make(map[string]int, len(set.Items)*2)
for index, item := range set.Items {
if set.Source == types.RelayFormatOpenAIResponses && len(item.Raw) > 0 {
var output dto.ResponsesOutput
if err := kitutil.Unmarshal(item.Raw, &output); err != nil {
return nil, diagnostics, fmt.Errorf("hosted_tools[%d]: %w", index, err)
}
hostedOutput = append(hostedOutput, positionedResponsesOutput{position: item.Position, output: output})
continue
}
outputType := responsesTypeFromHostedKind(item.Kind)
if outputType == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"hosted_tool_unrepresentable",
fmt.Sprintf("OpenAI Responses has no lossless response mapping for %q", item.NativeType),
))
continue
}
if isClaudeHostedResult(item.NativeType) {
outputIndex, exists := convertedByID[item.CallID]
if !exists {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d].tool_use_id", index),
"hosted_tool_result_orphaned",
fmt.Sprintf("hosted-tool result references unknown call %q", item.CallID),
))
continue
}
output := &hostedOutput[outputIndex].output
output.Status = hostedCompletionStatus(item)
if item.Kind == KindMCP {
failed := hostedItemFailed(item)
var (
encoded json.RawMessage
normalized bool
err error
)
if failed {
encoded, normalized, err = responsesMCPErrorFromClaudeContent(item.Results, item.ErrorCode)
} else {
encoded, normalized, err = responsesMCPStringFromClaudeContent(item.Results)
}
if err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d].content", index),
"mcp_result_unrepresentable",
err.Error(),
))
continue
}
if failed {
output.Output = nil
output.ItemError = encoded
} else {
output.Output = encoded
output.ItemError = nil
}
if normalized {
diagnostics = append(diagnostics, responsePresentationLoss(
fmt.Sprintf("hosted_tools[%d].content", index),
"mcp_text_result_normalized",
"Claude's single MCP text block was normalized to a Responses output string",
))
}
} else if item.Kind == KindWebSearch && rawJSONPresent(item.Results) {
diagnostics = append(diagnostics, responsePresentationLoss(
fmt.Sprintf("hosted_tools[%d].content", index),
"web_search_result_omitted",
"Claude web-search result content is provider-private and has no field on an OpenAI Responses web_search_call; completion status and citations remain available",
))
}
continue
}
output := dto.ResponsesOutput{
Type: outputType,
ID: firstNonEmpty(item.ID, item.CallID),
Status: hostedCompletionStatus(item),
}
switch item.Kind {
case KindWebSearch:
action, err := dto.NormalizeResponsesWebSearchAction(item.Action)
if err != nil {
return nil, diagnostics, fmt.Errorf("hosted_tools[%d].action: %w", index, err)
}
output.Action = action
case KindMCP:
if strings.TrimSpace(item.Name) == "" || strings.TrimSpace(item.ServerName) == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"mcp_identity_missing",
"Claude MCP output requires both name and server_name for Responses mapping",
))
continue
}
output.CallId = item.CallID
output.Name = item.Name
output.Caller = append(json.RawMessage(nil), item.Caller...)
output.ServerLabel = item.ServerName
output.ApprovalRequestID = item.ApprovalRequestID
output.MCPTools = append(json.RawMessage(nil), item.Tools...)
arguments := item.Arguments
if len(arguments) == 0 {
arguments = item.Action
}
encodedArguments, argumentErr := responsesMCPArgumentsFromClaude(arguments)
if argumentErr != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d].input", index),
"mcp_arguments_unrepresentable",
argumentErr.Error(),
))
continue
}
output.Arguments = encodedArguments
}
outputIndex := len(hostedOutput)
for _, key := range []string{item.ID, item.CallID} {
if key != "" {
convertedByID[key] = outputIndex
}
}
hostedOutput = append(hostedOutput, positionedResponsesOutput{position: item.Position, output: output})
if set.Source != types.RelayFormatOpenAIResponses {
diagnostics = append(diagnostics, responsePresentationLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"hosted_tool_result_approximated",
"hosted-tool execution is preserved, but provider-specific result fields may differ",
))
}
}
merged, orderingDiagnostics := mergeResponsesOutput(target.Output, hostedOutput, set)
diagnostics = append(diagnostics, orderingDiagnostics...)
target.Output = merged
return target, diagnostics, nil
}
func attachClaudeHostedResponse(response any, set HostedResponseSet) (any, []types.ConversionDiagnostic, error) {
target, ok := response.(*dto.ClaudeResponse)
if !ok || target == nil {
return nil, nil, fmt.Errorf("expected Claude response, got %T", response)
}
var diagnostics []types.ConversionDiagnostic
hostedContent := make([]positionedClaudeBlocks, 0, len(set.Items))
for index, item := range set.Items {
if set.Source == types.RelayFormatClaude && len(item.Raw) > 0 {
var block dto.ClaudeMediaMessage
if err := kitutil.Unmarshal(item.Raw, &block); err != nil {
return nil, diagnostics, fmt.Errorf("hosted_tools[%d]: %w", index, err)
}
hostedContent = append(hostedContent, positionedClaudeBlocks{position: item.Position, blocks: []dto.ClaudeMediaMessage{block}})
continue
}
if set.Source == types.RelayFormatOpenAIResponses && item.Kind == KindWebSearch {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"web_search_response_unrepresentable",
"Responses web-search execution cannot reconstruct Claude's required encrypted web_search_tool_result continuation state",
))
continue
}
name := claudeNameFromHostedKind(item.Kind)
if item.Kind == KindMCP {
name = item.Name
}
if name == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"hosted_tool_unrepresentable",
fmt.Sprintf("Claude has no lossless response mapping for %q", item.NativeType),
))
continue
}
var input any = map[string]any{}
if item.Kind == KindWebSearch {
webInput, inputErr := claudeWebSearchInputFromResponses(item.Action)
if inputErr != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d].action", index),
"web_search_action_unrepresentable",
inputErr.Error(),
))
continue
}
input = webInput
} else if item.Kind == KindMCP {
mcpInput, inputErr := claudeMCPInputFromResponses(item.Arguments)
if inputErr != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d].arguments", index),
"mcp_arguments_unrepresentable",
inputErr.Error(),
))
continue
}
input = mcpInput
} else if len(item.Action) > 0 {
if err := kitutil.Unmarshal(item.Action, &input); err != nil {
return nil, diagnostics, fmt.Errorf("hosted_tools[%d].action: %w", index, err)
}
}
callType := "server_tool_use"
if item.Kind == KindMCP {
callType = "mcp_tool_use"
}
blocks := []dto.ClaudeMediaMessage{{
Type: callType,
Id: item.ID,
Name: name,
Input: input,
Caller: append(json.RawMessage(nil), item.Caller...),
ServerName: item.ServerName,
}}
result := item.Results
if item.Kind == KindMCP {
result = item.Output
if hostedItemFailed(item) && rawJSONPresent(item.Error) {
result = item.Error
}
} else if len(result) == 0 {
result = item.Sources
}
if len(result) > 0 && !(set.Source == types.RelayFormatOpenAIResponses && item.Kind == KindWebSearch) {
var content any
if item.Kind == KindMCP {
decoded, resultErr := claudeMCPContentFromResponsesString(result)
if resultErr != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d].output", index),
"mcp_result_unrepresentable",
resultErr.Error(),
))
continue
}
content = decoded
} else if err := kitutil.Unmarshal(result, &content); err != nil {
return nil, diagnostics, fmt.Errorf("hosted_tools[%d].results: %w", index, err)
}
isError := hostedItemFailed(item)
blocks = append(blocks, dto.ClaudeMediaMessage{
Type: claudeResultTypeFromHostedKind(item.Kind),
ToolUseId: item.ID,
Content: content,
IsError: &isError,
ErrorCode: item.ErrorCode,
})
} else if len(result) > 0 && item.Kind == KindWebSearch {
diagnostics = append(diagnostics, responsePresentationLoss(
fmt.Sprintf("hosted_tools[%d].results", index),
"web_search_result_omitted",
"Responses web-search source metadata cannot reconstruct Claude's encrypted web_search_tool_result",
))
} else if item.Kind == KindMCP && (item.Status == "completed" || item.Status == "failed") {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"mcp_result_missing",
fmt.Sprintf("Responses MCP output has status %q but no output or error", item.Status),
))
}
hostedContent = append(hostedContent, positionedClaudeBlocks{position: item.Position, blocks: blocks})
if set.Source != types.RelayFormatClaude {
diagnostics = append(diagnostics, responsePresentationLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"hosted_tool_result_approximated",
"hosted-tool execution is preserved, but provider-specific result fields may differ",
))
}
}
merged, orderingDiagnostics := mergeClaudeContent(target.Content, hostedContent, set)
diagnostics = append(diagnostics, orderingDiagnostics...)
target.Content = merged
return target, diagnostics, nil
}
func mergeResponsesOutput(regular []dto.ResponsesOutput, hosted []positionedResponsesOutput, set HostedResponseSet) ([]dto.ResponsesOutput, []types.ConversionDiagnostic) {
if len(hosted) == 0 {
return regular, nil
}
if len(regular) == len(set.RegularPositions) {
byPosition := make(map[int][]dto.ResponsesOutput, len(hosted))
for _, item := range hosted {
byPosition[item.position] = append(byPosition[item.position], item.output)
}
regularByPosition := make(map[int]dto.ResponsesOutput, len(regular))
for index, position := range set.RegularPositions {
regularByPosition[position] = regular[index]
}
merged := make([]dto.ResponsesOutput, 0, len(regular)+len(hosted))
for position := 0; position < set.SourceLength; position++ {
merged = append(merged, byPosition[position]...)
if output, exists := regularByPosition[position]; exists {
merged = append(merged, output)
}
}
return merged, nil
}
before, after, exact := hostedOutsideRegularRange(hostedPositions(hosted), set.RegularPositions)
if exact {
merged := make([]dto.ResponsesOutput, 0, len(regular)+len(hosted))
for _, item := range before {
merged = append(merged, hosted[item].output)
}
merged = append(merged, regular...)
for _, item := range after {
merged = append(merged, hosted[item].output)
}
return merged, nil
}
merged := make([]dto.ResponsesOutput, 0, len(regular)+len(hosted))
for _, item := range hosted {
merged = append(merged, item.output)
}
merged = append(merged, regular...)
return merged, []types.ConversionDiagnostic{responseSemanticLoss(
"output",
"hosted_tool_order_unrepresentable",
"hosted-tool items were interleaved with content that the target converter coalesced, so their original order cannot be reconstructed",
)}
}
func mergeClaudeContent(regular []dto.ClaudeMediaMessage, hosted []positionedClaudeBlocks, set HostedResponseSet) ([]dto.ClaudeMediaMessage, []types.ConversionDiagnostic) {
if len(hosted) == 0 {
return regular, nil
}
if len(regular) == len(set.RegularPositions) {
byPosition := make(map[int][]dto.ClaudeMediaMessage, len(hosted))
for _, item := range hosted {
byPosition[item.position] = append(byPosition[item.position], item.blocks...)
}
regularByPosition := make(map[int]dto.ClaudeMediaMessage, len(regular))
for index, position := range set.RegularPositions {
regularByPosition[position] = regular[index]
}
merged := make([]dto.ClaudeMediaMessage, 0, len(regular)+len(hosted)*2)
for position := 0; position < set.SourceLength; position++ {
merged = append(merged, byPosition[position]...)
if block, exists := regularByPosition[position]; exists {
merged = append(merged, block)
}
}
return merged, nil
}
before, after, exact := hostedOutsideRegularRange(claudeHostedPositions(hosted), set.RegularPositions)
if exact {
merged := make([]dto.ClaudeMediaMessage, 0, len(regular)+len(hosted)*2)
for _, item := range before {
merged = append(merged, hosted[item].blocks...)
}
merged = append(merged, regular...)
for _, item := range after {
merged = append(merged, hosted[item].blocks...)
}
return merged, nil
}
merged := make([]dto.ClaudeMediaMessage, 0, len(regular)+len(hosted)*2)
for _, item := range hosted {
merged = append(merged, item.blocks...)
}
merged = append(merged, regular...)
return merged, []types.ConversionDiagnostic{responseSemanticLoss(
"content",
"hosted_tool_order_unrepresentable",
"hosted-tool blocks were interleaved with content that the target converter coalesced, so their original order cannot be reconstructed",
)}
}
func hostedPositions(items []positionedResponsesOutput) []int {
positions := make([]int, len(items))
for index := range items {
positions[index] = items[index].position
}
return positions
}
func claudeHostedPositions(items []positionedClaudeBlocks) []int {
positions := make([]int, len(items))
for index := range items {
positions[index] = items[index].position
}
return positions
}
func hostedOutsideRegularRange(hosted []int, regular []int) (before []int, after []int, exact bool) {
if len(regular) == 0 {
indices := make([]int, len(hosted))
for index := range hosted {
indices[index] = index
}
return indices, nil, true
}
firstRegular, lastRegular := regular[0], regular[len(regular)-1]
for index, position := range hosted {
switch {
case position < firstRegular:
before = append(before, index)
case position > lastRegular:
after = append(after, index)
default:
return nil, nil, false
}
}
return before, after, true
}
func hostedKindFromClaudeCall(blockType string, name string) Kind {
if blockType == "mcp_tool_use" {
return KindMCP
}
switch strings.TrimSpace(name) {
case "web_search":
return KindWebSearch
case "web_fetch":
return KindWebFetch
case "code_execution":
return KindCodeExecution
default:
return KindNative
}
}
func hostedKindFromClaudeResult(blockType string) Kind {
switch strings.TrimSuffix(blockType, "_tool_result") {
case "web_search":
return KindWebSearch
case "web_fetch":
return KindWebFetch
case "code_execution":
return KindCodeExecution
case "mcp":
return KindMCP
default:
return KindNative
}
}
func hostedKindFromResponsesType(outputType string) Kind {
normalized := strings.TrimSpace(outputType)
normalized = strings.TrimSuffix(normalized, "_output")
normalized = strings.TrimSuffix(normalized, "_call")
switch normalized {
case "web_search":
return KindWebSearch
case "file_search":
return KindFileSearch
case "code_interpreter", "local_shell":
return KindCodeExecution
case "computer":
return KindComputerUse
case "image_generation":
return KindImage
case "mcp":
return KindMCP
default:
return KindNative
}
}
func responsesTypeFromHostedKind(kind Kind) string {
switch kind {
case KindWebSearch:
return "web_search_call"
case KindMCP:
return "mcp_call"
default:
return ""
}
}
func claudeNameFromHostedKind(kind Kind) string {
switch kind {
case KindWebSearch:
return "web_search"
case KindWebFetch:
return "web_fetch"
case KindCodeExecution:
return ""
case KindMCP:
return "mcp"
default:
return ""
}
}
func isClaudeHostedResult(nativeType string) bool {
return nativeType == "mcp_tool_result" || strings.HasSuffix(nativeType, "_tool_result")
}
func hostedCompletionStatus(item HostedResponseItem) string {
if hostedItemFailed(item) {
return "failed"
}
if item.Status != "" && item.Status != "in_progress" || len(item.Results) > 0 || len(item.Output) > 0 {
return "completed"
}
return "in_progress"
}
func hostedItemFailed(item HostedResponseItem) bool {
return item.Status == "failed" || item.ErrorCode != "" || rawJSONPresent(item.Error) || item.IsError != nil && *item.IsError
}
func hostedErrorValue(item HostedResponseItem) json.RawMessage {
if len(item.Error) > 0 {
return append(json.RawMessage(nil), item.Error...)
}
if item.ErrorCode == "" {
return nil
}
encoded, _ := kitutil.Marshal(item.ErrorCode)
return encoded
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
func rawJSONPresent(value json.RawMessage) bool {
normalized := strings.TrimSpace(string(value))
return normalized != "" && normalized != "null"
}
func claudeResultTypeFromHostedKind(kind Kind) string {
name := claudeNameFromHostedKind(kind)
if name == "mcp" {
return "mcp_tool_result"
}
return name + "_tool_result"
}
+135
View File
@@ -0,0 +1,135 @@
// Package kitutil holds the dependency-free helpers shared by the conversion
// kit packages (dto, types, relayconvert). It moved out of the host's common
// package as part of the relaykit extraction; common re-exports these for
// host code.
package kitutil
import (
"bytes"
"encoding/json"
"io"
"unsafe"
)
// Codec is the JSON engine used by every kitutil JSON helper. The host may
// replace it once at startup via SetCodec; relaykit itself never depends on a
// third-party JSON library.
type Codec interface {
Marshal(v any) ([]byte, error)
Unmarshal(data []byte, v any) error
Decode(r io.Reader, v any) error
Valid(data []byte) bool
}
// stdCodec is the default Codec, backed by encoding/json. It is the only place
// in relaykit that calls the standard library's JSON functions directly.
type stdCodec struct{}
func (stdCodec) Marshal(v any) ([]byte, error) {
return json.Marshal(v)
}
func (stdCodec) Unmarshal(data []byte, v any) error {
return json.Unmarshal(data, v)
}
func (stdCodec) Decode(r io.Reader, v any) error {
return json.NewDecoder(r).Decode(v)
}
func (stdCodec) Valid(data []byte) bool {
return json.Valid(data)
}
var codec Codec = stdCodec{}
// SetCodec installs the JSON engine behind every kitutil JSON helper, which
// also covers the custom (Un)MarshalJSON methods on relaykit DTOs. Like
// SetLogging it is meant to be called once during host startup before any
// request is served; it is not synchronized against concurrent helper calls.
// A nil codec is ignored so the standard-library default stays in place.
func SetCodec(c Codec) {
if c == nil {
return
}
codec = c
}
func Unmarshal(data []byte, v any) error {
return codec.Unmarshal(data, v)
}
func UnmarshalJsonStr(data string, v any) error {
return codec.Unmarshal(StringToByteSlice(data), v)
}
func DecodeJson(reader io.Reader, v any) error {
return codec.Decode(reader, v)
}
func Marshal(v any) ([]byte, error) {
return codec.Marshal(v)
}
// Valid reports whether data is a syntactically valid JSON document.
func Valid(data []byte) bool {
return codec.Valid(data)
}
func GetJsonType(data json.RawMessage) string {
trimmed := bytes.TrimSpace(data)
if len(trimmed) == 0 {
return "unknown"
}
firstChar := trimmed[0]
switch firstChar {
case '{':
return "object"
case '[':
return "array"
case '"':
return "string"
case 't', 'f':
return "boolean"
case 'n':
return "null"
default:
return "number"
}
}
// JsonRawMessageToString returns JSON strings as their decoded value and other JSON values as raw text.
func JsonRawMessageToString(data json.RawMessage) string {
trimmed := bytes.TrimSpace(data)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
return ""
}
if trimmed[0] != '"' {
return string(trimmed)
}
var value string
if err := Unmarshal(trimmed, &value); err != nil {
return string(trimmed)
}
return value
}
func StringToByteSlice(s string) []byte {
tmp1 := (*[2]uintptr)(unsafe.Pointer(&s))
tmp2 := [3]uintptr{tmp1[0], tmp1[1], tmp1[1]}
return *(*[]byte)(unsafe.Pointer(&tmp2))
}
func Any2Type[T any](data any) (T, error) {
var zero T
encoded, err := Marshal(data)
if err != nil {
return zero, err
}
var res T
err = Unmarshal(encoded, &res)
if err != nil {
return zero, err
}
return res, nil
}
@@ -0,0 +1,83 @@
package kitutil
import (
"encoding/json"
"io"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// recordingCodec notes which Codec method each helper reaches and delegates to
// the standard-library default so the helpers still produce real results.
type recordingCodec struct {
calls []string
}
func (c *recordingCodec) Marshal(v any) ([]byte, error) {
c.calls = append(c.calls, "Marshal")
return stdCodec{}.Marshal(v)
}
func (c *recordingCodec) Unmarshal(data []byte, v any) error {
c.calls = append(c.calls, "Unmarshal")
return stdCodec{}.Unmarshal(data, v)
}
func (c *recordingCodec) Decode(r io.Reader, v any) error {
c.calls = append(c.calls, "Decode")
return stdCodec{}.Decode(r, v)
}
func (c *recordingCodec) Valid(data []byte) bool {
c.calls = append(c.calls, "Valid")
return stdCodec{}.Valid(data)
}
func TestJSONHelpersRouteThroughInjectedCodec(t *testing.T) {
t.Cleanup(func() { SetCodec(stdCodec{}) })
fake := &recordingCodec{}
SetCodec(fake)
// A nil codec must not displace the installed one.
SetCodec(nil)
encoded, err := Marshal(map[string]int{"a": 1})
require.NoError(t, err)
assert.Equal(t, `{"a":1}`, string(encoded))
var fromBytes map[string]int
require.NoError(t, Unmarshal([]byte(`{"b":2}`), &fromBytes))
assert.Equal(t, map[string]int{"b": 2}, fromBytes)
var fromString map[string]int
require.NoError(t, UnmarshalJsonStr(`{"c":3}`, &fromString))
assert.Equal(t, map[string]int{"c": 3}, fromString)
var decoded map[string]int
require.NoError(t, DecodeJson(strings.NewReader(`{"d":4}`), &decoded))
assert.Equal(t, map[string]int{"d": 4}, decoded)
assert.True(t, Valid([]byte(`[]`)))
assert.False(t, Valid([]byte(`[`)))
converted, err := Any2Type[map[string]int](map[string]any{"e": 5})
require.NoError(t, err)
assert.Equal(t, map[string]int{"e": 5}, converted)
assert.Equal(t, "hello", JsonRawMessageToString(json.RawMessage(`"hello"`)))
assert.Equal(t, []string{
"Marshal", // Marshal
"Unmarshal", // Unmarshal
"Unmarshal", // UnmarshalJsonStr
"Decode", // DecodeJson
"Valid", // Valid (well-formed)
"Valid", // Valid (malformed)
"Marshal", // Any2Type encode
"Unmarshal", // Any2Type decode
"Unmarshal", // JsonRawMessageToString string literal
}, fake.calls)
}
+65
View File
@@ -0,0 +1,65 @@
package kitutil
import (
"fmt"
"os"
"sync/atomic"
)
// Kit packages log rare data-shape anomalies through these hooks. The host
// redirects them into its logging system at startup; standalone relaykit users
// get stderr defaults.
type LogFunc func(message string)
var (
logInfo atomic.Pointer[LogFunc]
logError atomic.Pointer[LogFunc]
logSystemError atomic.Pointer[LogFunc]
)
func SetLogging(info LogFunc, errorFn LogFunc) {
if info != nil {
logInfo.Store(&info)
}
if errorFn != nil {
logError.Store(&errorFn)
}
}
// SetSystemErrorLogging configures the hook for internal converter failures.
func SetSystemErrorLogging(errorFn LogFunc) {
if errorFn != nil {
logSystemError.Store(&errorFn)
}
}
func LogInfo(message string) {
if fn := logInfo.Load(); fn != nil {
(*fn)(message)
return
}
fmt.Fprintf(os.Stderr, "[relaykit] %s\n", message)
}
func LogError(message string) {
if fn := logError.Load(); fn != nil {
(*fn)(message)
return
}
fmt.Fprintf(os.Stderr, "[relaykit] ERROR %s\n", message)
}
// LogSystemError reports an internal converter failure through its dedicated
// hook, keeping it distinct from malformed request-data diagnostics.
func LogSystemError(message string) {
if fn := logSystemError.Load(); fn != nil {
(*fn)(message)
return
}
fmt.Fprintf(os.Stderr, "[relaykit] SYSTEM ERROR %s\n", message)
}
// Debug reports whether verbose kit diagnostics are enabled. The host sets
// this once at startup (new-api mirrors common.DebugEnabled into it).
var Debug atomic.Bool
Loaded 100 of 2381 files, more files were not shown because too many files have changed in this diff. Show more