feat: publish sanitized custom.97 source
This commit is contained in:
1022 files changed
+69903
-19207
No files matched your search
+100
-63
@@ -1,58 +1,73 @@
|
||||
# RelayKit
|
||||
|
||||
RelayKit 是从 [new-api](https://github.com/QuantumNous/new-api) 中拆分出的独立 Go 模块,提供常用大模型文本协议的 DTO、请求转换、响应转换和流式事件转换。
|
||||
RelayKit 是一个 Go 模块,在 OpenAI Chat Completions、OpenAI Responses、Anthropic Messages 和 Gemini `generateContent` 四种协议之间转换请求、非流式响应和流式事件。
|
||||
|
||||
它只负责协议层的数据建模与语义转换,不包含 HTTP 服务、上游请求发送、渠道调度、鉴权、计费或数据库逻辑。因此可以脱离 new-api 主模块,嵌入其他 Go 网关或代理服务。
|
||||
|
||||
## 能力
|
||||
|
||||
- 在 OpenAI Chat Completions、OpenAI Responses、Anthropic Messages 和 Gemini `generateContent` 之间转换
|
||||
- 同时支持请求、非流式响应和增量流式响应
|
||||
- 自动根据 DTO 类型识别源协议,并选择内置的直接或多跳转换路径
|
||||
- 返回转换器 ID、质量等级、实际转换步骤和统一 usage,方便审计与调试
|
||||
- 支持常用的文本、多模态内容、工具调用、推理内容和 usage 映射
|
||||
- 作为独立 Go module 构建,不依赖 new-api 主模块、Gin、数据库或全局设置
|
||||
它从 [new-api](https://github.com/QuantumNous/new-api) 中拆出,模块路径是 `github.com/QuantumNous/new-api/relaykit`,运行时依赖是 uuid、lo、gjson 和 sjson 四个包。它做协议数据的建模和转换,HTTP 收发、SSE 读写、渠道选择、鉴权和计费由宿主程序完成,所以可以嵌进其他 Go 网关或代理。
|
||||
|
||||
## 支持矩阵
|
||||
|
||||
以下四种文本协议支持任意两种格式之间的转换:
|
||||
四种协议两两之间都能转换,请求、非流式响应和流式响应覆盖同一张表:
|
||||
|
||||
| 源格式 \ 目标格式 | 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 | — |
|
||||
| OpenAI Chat | | `good` | `fair` | `fair` |
|
||||
| OpenAI Responses | `good` | | `fair` | `fair` |
|
||||
| Claude Messages | `fair` | `fair` | | `discouraged` |
|
||||
| Gemini | `fair` | `fair` | `discouraged` | |
|
||||
|
||||
质量等级表示协议之间的语义匹配程度:
|
||||
`good` 表示两种协议结构接近,字段基本一一对应。`fair` 表示主要内容都能转,协议独有的字段以诊断报告。其中 Gemini 转 Responses 的请求和响应、Claude 转 Responses 的响应、Responses 转 Gemini 的响应经 OpenAI Chat 中转。`discouraged` 表示 Claude 和 Gemini 之间请求和响应都经 OpenAI Chat 中转两跳,丢字段的机会更多。实际走的路径在转换结果的 `Steps` 里,等级在 `Quality` 里。
|
||||
|
||||
- `Good`:两种协议的核心结构较接近
|
||||
- `Fair`:主要能力可转换,但部分协议特性可能需要适配或无法完整保留
|
||||
- `Discouraged`:目前需要经过中间协议转换,语义损失风险更高
|
||||
## 功能
|
||||
|
||||
请求、非流式响应和流式响应均覆盖上述矩阵。实际采用的路径可从转换结果的 `Steps` 和 `Quality` 字段中读取。
|
||||
### 自动选路
|
||||
|
||||
`ConvertRequest` 和 `ConvertResponse` 按传入 DTO 的具体类型判断源协议,`NewResponseStreamState` 按参数里的源格式和目标格式,都查表选直接转换器或多跳路径。结果里有源格式、目标格式、转换器 ID、质量等级、实际经过的每一步和诊断列表。要固定路径时用 `ConvertRequestVia`,要按 ID 执行时用 `ConvertRequestByID`、`ConvertResponseByID` 和 `NewResponseStreamStateByID`。
|
||||
|
||||
### 工具调用
|
||||
|
||||
function tool 的定义、调用和结果在四种协议之间互转。Responses 的 custom tool 和带 namespace 的工具发往 Chat、Claude、Gemini 上游时改成普通 function tool,编码方式记在 `convmeta.ResponsesToolState` 里,响应转换时按这份记录还原成 Responses 的形状。托管 web search 按目标协议重新编码:Chat 用 `web_search_options`,Responses、Claude 和 Gemini 各用自己协议的 web search 工具定义,宿主可以通过 `Options.WebSearch` 为某个上游改写编码。其他托管工具在目标协议里表达不了的部分,转换照常完成,缺失写进诊断。
|
||||
|
||||
### 推理设置
|
||||
|
||||
reasoning effort、thinking budget 和是否返回思考内容用 `dto.ReasoningConversionState` 在转换步骤之间传递,多跳路径上的每一步都能读到精确的 budget 和 effort。Claude 的 max_tokens 和 budget 超出模型范围时会调整,Gemini 的 thinkingConfig 按模型归一化,这些改动都以诊断返回,例如 `claude_max_tokens_raised` 和 `claude_budget_adjusted`。
|
||||
|
||||
### 多模态
|
||||
|
||||
图片和文件内容块在协议之间转换。需要下载 URL 或解析 data URL 时,RelayKit 调用宿主用 `SetMediaResolver` 注册的函数,未注册时转换返回错误。
|
||||
|
||||
### Usage
|
||||
|
||||
每个响应转换都返回统一的 `dto.Usage`,含输入、输出、缓存命中,以及 Claude 缓存写入按 5 分钟和 1 小时的拆分。`Usage.BillingUsage` 同时带着上游原始的 usage 结构和它来自哪个协议,宿主可以按上游的计量规则计费。流式转换把分散在多个事件里的 usage 累加到 `ResponseStreamState`。`dto.MergeUsageNonZero` 一类函数合并两份 usage 时,后到的非零字段覆盖已有值,后到的零值保留已有的计数。
|
||||
|
||||
### 流式
|
||||
|
||||
每条上游流对应一个 `ResponseStreamState`,跨事件的工具调用拼接、usage 和结束状态都存在这里。上游结束后调用 `FinalizeStreamResponse`,转换器在这一步补发终止事件和最终 usage。目标是 Responses 时,上游中途出错可以调用 `FailResponsesStream` 生成 Responses 协议的错误事件,`ResponseStreamOptions.EmitSequenceNumber` 打开后每个事件带 `sequence_number`。
|
||||
|
||||
### 宿主钩子
|
||||
|
||||
JSON 编解码默认用 `encoding/json`,宿主可以在启动时用 `kitutil.SetCodec` 换成别的实现,DTO 上自定义的 `MarshalJSON` 和 `UnmarshalJSON` 也走同一个编解码器。数据形状异常的日志通过 `kitutil.SetLogging` 和 `kitutil.SetSystemErrorLogging` 交给宿主,默认写到 stderr。
|
||||
|
||||
## 安装
|
||||
|
||||
RelayKit 要求 Go 1.25.1 或更高版本。
|
||||
RelayKit 要求 Go 1.25.1 或更高版本。tag 的形式是 `relaykit/vX.Y.Z`,`@latest` 解析到最新的一个。
|
||||
|
||||
```bash
|
||||
go get github.com/QuantumNous/new-api/relaykit@latest
|
||||
```
|
||||
|
||||
主要包:
|
||||
|
||||
| 包 | 用途 |
|
||||
| 包 | 内容 |
|
||||
|---|---|
|
||||
| `relaykit/dto` | 各协议的请求、响应、流式事件和 usage DTO |
|
||||
| `relaykit/types` | 协议格式、错误、文件来源及共享类型 |
|
||||
| `relaykit/dto` | 四种协议的请求、响应、流式事件和 usage 结构,以及 new-api 宿主共用的渠道、定价和用户设置结构 |
|
||||
| `relaykit/types` | 协议格式、错误、诊断、文件来源等共享类型 |
|
||||
| `relaykit/relayconvert` | 请求、响应和流式转换入口 |
|
||||
| `relaykit/relayconvert/convmeta` | 与宿主实现解耦的转换上下文和选项 |
|
||||
| `relaykit/reasonmap` | 不同协议之间的结束原因映射 |
|
||||
| `relaykit/relayconvert/convmeta` | 转换上下文接口 `Meta`、默认实现 `Values` 和选项 `Options` |
|
||||
| `relaykit/relayconvert/reasoning` | 推理设置的解析、合并和渲染,包括模型名尾缀解析 |
|
||||
| `relaykit/relayconvert/kitutil` | JSON 编解码和日志钩子 |
|
||||
| `relaykit/reasonmap` | Claude 和 OpenAI 之间的结束原因映射 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
下面将 OpenAI Chat Completions 请求转换为 Claude Messages 请求:
|
||||
下面把 OpenAI Chat Completions 请求转换成 Claude Messages 请求:
|
||||
|
||||
```go
|
||||
package main
|
||||
@@ -102,11 +117,11 @@ func main() {
|
||||
}
|
||||
```
|
||||
|
||||
`ConvertRequest` 根据请求的具体 DTO 类型推断源格式。传入原始 JSON、`map[string]any` 或不受支持的 DTO 会返回错误。
|
||||
`ConvertRequest` 按请求的具体 DTO 类型推断源格式。传入 nil、原始 JSON、`map[string]any` 或其他类型时返回错误。
|
||||
|
||||
### 非流式响应
|
||||
|
||||
响应转换使用相同的目标格式模型:
|
||||
响应转换用同一套目标格式参数:
|
||||
|
||||
```go
|
||||
result, err := relayconvert.ConvertResponse(
|
||||
@@ -134,16 +149,17 @@ usage := result.Usage
|
||||
|
||||
### 流式响应
|
||||
|
||||
流式转换可能需要跨事件保存工具调用、usage 和结束状态。每条上游流应创建独立的 `ResponseStreamState`,并在上游结束后调用 `FinalizeStreamResponse`:
|
||||
每条上游流创建一个 `ResponseStreamState`,上游结束后调用 `FinalizeStreamResponse`:
|
||||
|
||||
```go
|
||||
state, err := relayconvert.NewResponseStreamState(
|
||||
types.RelayFormatOpenAI,
|
||||
types.RelayFormatOpenAIResponses,
|
||||
relayconvert.ResponseStreamOptions{
|
||||
ID: "resp_123",
|
||||
Model: "gpt-4.1",
|
||||
IncludeUsage: true,
|
||||
ID: "resp_123",
|
||||
Model: "gpt-4.1",
|
||||
IncludeUsage: true,
|
||||
EmitSequenceNumber: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -169,15 +185,16 @@ for _, result := range finalResults {
|
||||
}
|
||||
|
||||
usage := state.Usage()
|
||||
diagnostics := state.Diagnostics()
|
||||
```
|
||||
|
||||
RelayKit 不负责 SSE 的读取和写入。宿主需要将每个 SSE 事件解析为对应 DTO,并将转换结果重新编码后发送给下游。不要省略 `FinalizeStreamResponse`,部分转换器会在该阶段补发终止事件或最终 usage。
|
||||
SSE 的读取和写入由宿主完成:把每个上游事件解析成对应的 DTO 传进来,再把转换结果编码后发给下游。`FinalizeStreamResponse` 每条流都要调用,部分转换器在这一步才补发终止事件和最终 usage。
|
||||
|
||||
## 转换上下文
|
||||
|
||||
大多数基础转换可以传入 `nil` 作为 `convmeta.Meta`。需要模型映射、推理适配、安全设置或流式状态时,应使用 `convmeta.Values`,或在宿主中实现 `convmeta.Meta`。
|
||||
大多数基础转换的 `convmeta.Meta` 参数可以传 `nil`。需要模型名、推理设置、工具编码记录或流式状态时,用 `convmeta.Values`,或者在宿主里实现 `convmeta.Meta`。自己实现时,指针类型的每个方法都要能在 nil receiver 上返回零值,接口注释写了原因。
|
||||
|
||||
常用选项通过 `convmeta.Options` 按请求传入:
|
||||
按请求传入的选项放在 `convmeta.Options`:
|
||||
|
||||
```go
|
||||
meta := &convmeta.Values{
|
||||
@@ -194,18 +211,39 @@ meta := &convmeta.Values{
|
||||
}
|
||||
```
|
||||
|
||||
需要注意:
|
||||
| 选项 | 作用 |
|
||||
|---|---|
|
||||
| `Claude.DefaultMaxTokens` | 源请求没有 max_tokens 时,转 Claude 用这个函数取值 |
|
||||
| `Claude.ThinkingAdapterEnabled`、`Gemini.ThinkingAdapterEnabled` | 模型名尾缀适配开关,见下文 |
|
||||
| `Claude.ThinkingAdapterBudgetTokensPercentage`、`Gemini.ThinkingAdapterBudgetTokensPercentage` | 尾缀适配触发时,thinking budget 占 max_tokens 的比例 |
|
||||
| `Claude.WebSearchToolVersion` | 转 Claude 时 web search 工具的版本,空值用 `web_search_20250305` |
|
||||
| `Gemini.FunctionCallThoughtSignatureEnabled` | 给 function call 部分附加 thoughtSignature |
|
||||
| `Gemini.SupportsImagine` | 判断模型是否支持生图,支持时切换响应模态 |
|
||||
| `Gemini.SafetySetting` | 按分类返回 safetySettings 的阈值 |
|
||||
| `ToolLossPolicy` | 跨协议丢失工具行为时的处理,见下文 |
|
||||
| `OpenRouterDialect` | 上游是 OpenRouter 时打开,转换器会输出它额外接受的字段 |
|
||||
| `WebSearch` | 为某个上游改写托管 web search 的编码 |
|
||||
| `PreserveThinkingSuffix`、`PreserveEffortTail` | 判断哪些模型名要原样保留尾缀 |
|
||||
|
||||
- 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`)传入。同时把发给上游的模型名裁成无后缀基础名。
|
||||
转 Claude 时请求需要 `max_tokens`。源请求没有时,RelayKit 调用 `Claude.DefaultMaxTokens` 取值,两者都没有时返回错误。
|
||||
|
||||
## 多模态内容
|
||||
RelayKit 按请求里的 `Model` 生成上游请求,调用前把它设成上游使用的模型名。带 `-thinking`、`-nothinking`、`-thinking-<budget>` 或 `-high` 这类尾缀的 Claude、Gemini 模型名由调用方在转换前解析:调用 `reasoning.ParseClaudeModelSuffix` 或 `reasoning.ParseGeminiModelSuffix`,`allowSuffixAlias` 参数传 `ThinkingAdapterEnabled`,把返回的 `Intent` 用 `reasoning.StateFromIntent` 写进 `Values.ReasoningConversion`,再把裁掉尾缀的模型名放进请求。开关关闭时这两个函数原样返回模型名,推理设置来自请求字段。
|
||||
|
||||
某些跨协议的图片转换需要下载 URL 内容或解析 data URL。宿主应在启动时配置媒体解析器:
|
||||
## 诊断与损耗策略
|
||||
|
||||
每个转换结果都带 `Diagnostics`,每条诊断有代码、字段路径、说明、级别和源目标格式。级别 `warning` 表示展示层面的差异,`error` 表示工具行为或内容发生了变化。常见代码有 `hosted_web_search_omitted`、`trailing_assistant_omitted` 和 `unsupported_media_type`。
|
||||
|
||||
`Options.ToolLossPolicy` 决定请求阶段怎么处理诊断:
|
||||
|
||||
- `allow` 是默认值,转换完成,所有损耗以诊断返回。
|
||||
- `safe` 在请求阶段遇到 `error` 级诊断时返回 `*types.ConversionLossError`。
|
||||
- `strict` 在请求阶段遇到任何诊断都返回这个错误。
|
||||
|
||||
响应和流式转换在三种策略下都完成转换并返回诊断。流式用 `state.Diagnostics()` 读到目前为止累计的诊断,包括没有产生下游事件的那些。
|
||||
|
||||
## 媒体解析
|
||||
|
||||
跨协议的图片转换有时需要下载 URL 内容或解析 data URL。宿主在启动时注册解析函数:
|
||||
|
||||
```go
|
||||
relayconvert.SetMediaResolver(relayconvert.MediaResolver{
|
||||
@@ -214,39 +252,38 @@ relayconvert.SetMediaResolver(relayconvert.MediaResolver{
|
||||
})
|
||||
```
|
||||
|
||||
两个回调的签名由 `relayconvert.MediaResolver` 定义。需要媒体解析而未配置对应回调时,转换会明确返回错误;RelayKit 本身不会发起网络请求。
|
||||
两个函数的签名见 `relayconvert.MediaResolver`。需要媒体解析而没有注册时,转换返回错误。网络请求由这两个函数发起。
|
||||
|
||||
## 转换结果
|
||||
|
||||
请求转换返回 `relayconvert.RequestResult`,响应转换返回 `relayconvert.ResponseResult`。除 `Value` 外,建议关注:
|
||||
请求转换返回 `relayconvert.RequestResult`,响应转换返回 `relayconvert.ResponseResult`,字段有:
|
||||
|
||||
- `From` / `To`:源格式和目标格式
|
||||
- `Converter`:所选转换器 ID
|
||||
- `Quality`:转换质量等级
|
||||
- `Steps`:直接转换或多跳转换的实际路径
|
||||
- `Usage`:响应转换后的统一 token usage
|
||||
- `Stream`:结果是否来自流式转换
|
||||
|
||||
如果需要固定转换路径,可使用 `ConvertRequestVia`;如果需要按转换器 ID 执行,可使用 `ConvertRequestByID`、`ConvertResponseByID` 和 `NewResponseStreamStateByID`。
|
||||
- `Value`:转换后的 DTO
|
||||
- `From`、`To`:源格式和目标格式
|
||||
- `Converter`:转换器 ID
|
||||
- `Quality`:质量等级
|
||||
- `Steps`:实际经过的每一步
|
||||
- `Diagnostics`:诊断列表
|
||||
- `Usage`、`Stream`:响应结果独有,统一 usage 和是否来自流式转换
|
||||
|
||||
## 开发
|
||||
|
||||
RelayKit 必须始终保持独立可构建。修改模块后,在 `relaykit` 目录运行:
|
||||
在 `relaykit` 目录运行:
|
||||
|
||||
```bash
|
||||
GOWORK=off go test ./...
|
||||
GOWORK=off go build ./...
|
||||
GOWORK=off go test ./...
|
||||
```
|
||||
|
||||
转换矩阵由 golden tests 覆盖。确认协议输出变化是预期行为后,可更新快照:
|
||||
`GOWORK=off` 让模块脱离仓库的 workspace 单独构建,这是 new-api 对 RelayKit 改动的检查项。转换矩阵由 golden test 覆盖,协议输出变化确认是预期行为后更新快照:
|
||||
|
||||
```bash
|
||||
GOWORK=off go test ./relayconvert -run TestGolden -update
|
||||
```
|
||||
|
||||
## 版本与兼容性
|
||||
## 版本
|
||||
|
||||
RelayKit 当前使用 `v0.x` 版本。公开 API 和 DTO 仍可能在小版本中调整,升级前请检查发布说明和实际序列化结果。协议之间并非完全同构,建议对业务实际使用的工具调用、多模态、推理和流式场景增加端到端测试。
|
||||
RelayKit 处于 `v0.x`。公开 API 和 DTO 可能在小版本里变化,升级前对照发布说明和序列化结果。每对协议都有对方没有的字段,建议对业务里用到的工具调用、多模态、推理和流式场景加端到端测试。
|
||||
|
||||
## 许可证
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -21,12 +22,46 @@ type ChannelSettings struct {
|
||||
ResponsesAstraModelGuard *bool `json:"responses_astra_model_guard,omitempty"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||
SystemPromptOverride bool `json:"system_prompt_override,omitempty"`
|
||||
// TaskExtendPluginKeys lists the task plugins a New API channel (type 60)
|
||||
// is extended with. The upstream gateway may host many plugins, so the
|
||||
// channel serves every listed plugin's models while the request still pins
|
||||
// the executing plugin. TaskPluginKey remains the single type-61 binding
|
||||
// and stays valid on a New API channel as well.
|
||||
TaskExtendPluginKeys []string `json:"task_extend_plugin_keys,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"`
|
||||
// TLSInsecureSkipVerify accepts upstream certificates that fail
|
||||
// verification, such as self-signed ones, for this channel only.
|
||||
TLSInsecureSkipVerify bool `json:"tls_insecure_skip_verify,omitempty"`
|
||||
}
|
||||
|
||||
// BindsTaskPlugin reports whether the channel is bound to the task plugin,
|
||||
// either through the single binding or the New API extension list.
|
||||
func (s ChannelSettings) BindsTaskPlugin(key string) bool {
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
return s.TaskPluginKey == key || slices.Contains(s.TaskExtendPluginKeys, key)
|
||||
}
|
||||
|
||||
// TaskPluginBindings returns the sorted, de-duplicated set of task plugins the
|
||||
// channel binds through either field, so callers can compare bindings as a set.
|
||||
func (s ChannelSettings) TaskPluginBindings() []string {
|
||||
bindings := make([]string, 0, len(s.TaskExtendPluginKeys)+1)
|
||||
if s.TaskPluginKey != "" {
|
||||
bindings = append(bindings, s.TaskPluginKey)
|
||||
}
|
||||
for _, key := range s.TaskExtendPluginKeys {
|
||||
if key != "" && !slices.Contains(bindings, key) {
|
||||
bindings = append(bindings, key)
|
||||
}
|
||||
}
|
||||
slices.Sort(bindings)
|
||||
return bindings
|
||||
}
|
||||
|
||||
func (s ChannelSettings) AstraModelGuardEnabled() bool {
|
||||
@@ -73,9 +108,20 @@ const (
|
||||
AwsKeyTypeApiKey AwsKeyType = "api_key"
|
||||
)
|
||||
|
||||
type AwsApiType string
|
||||
|
||||
const (
|
||||
AwsApiTypeInvokeModel AwsApiType = "invoke_model"
|
||||
AwsApiTypeMantle AwsApiType = "mantle"
|
||||
)
|
||||
|
||||
type ChannelOtherSettings struct {
|
||||
// Exact upstream model names whose Messages input_tokens includes cache reads.
|
||||
// Empty preserves standard Anthropic accounting; cache writes remain separate.
|
||||
ClaudeInputIncludesCacheReadModels []string `json:"claude_input_includes_cache_read_models,omitempty"`
|
||||
AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
|
||||
VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key"
|
||||
VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key"
|
||||
VertexProjectID string `json:"vertex_project_id,omitempty"` // 仅当 vertex_key_type=api_key 时使用;JSON 模式下从 service account 解析
|
||||
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 透传(默认过滤以避免额外计费)
|
||||
@@ -86,6 +132,7 @@ type ChannelOtherSettings struct {
|
||||
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"`
|
||||
AwsApiType AwsApiType `json:"aws_api_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"` // 上次检测时间
|
||||
@@ -134,6 +181,10 @@ const (
|
||||
advancedCustomConverterOpenAIResponsesToGemini = "openai_responses_to_gemini_generate_content"
|
||||
advancedCustomConverterGeminiContentToOpenAIChat = "gemini_generate_content_to_openai_chat_completions"
|
||||
advancedCustomConverterOpenAIChatToGeminiContent = "openai_chat_completions_to_gemini_generate_content"
|
||||
// Same values as relayconvert.ConverterClaudeMessagesToOpenAIResponses and
|
||||
// relayconvert.ConverterGeminiContentToOpenAIResponses.
|
||||
advancedCustomConverterClaudeMessagesToOpenAIResponses = "claude_messages_to_openai_responses"
|
||||
advancedCustomConverterGeminiContentToOpenAIResponses = "gemini_generate_content_to_openai_responses"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -181,6 +232,8 @@ const (
|
||||
advancedCustomEndpointPathOpenAIResponses = "/v1/responses"
|
||||
advancedCustomEndpointPathOpenAIResponsesCompact = "/v1/responses/compact"
|
||||
advancedCustomEndpointPathOpenAIAlphaSearch = "/v1/alpha/search"
|
||||
advancedCustomEndpointPathDeepSeekToolsSearch = "/v1/tools/search"
|
||||
advancedCustomEndpointPathDeepSeekToolsFetch = "/v1/tools/fetch"
|
||||
advancedCustomEndpointPathClaudeMessages = "/v1/messages"
|
||||
advancedCustomEndpointPathJinaRerank = "/v1/rerank"
|
||||
advancedCustomEndpointPathImageGeneration = "/v1/images/generations"
|
||||
@@ -306,6 +359,10 @@ func advancedCustomEndpointTypeFromIncomingPath(incomingPath string) (types.Endp
|
||||
return types.EndpointTypeOpenAIResponseCompact, true
|
||||
case advancedCustomEndpointPathOpenAIAlphaSearch:
|
||||
return types.EndpointTypeOpenAIAlphaSearch, true
|
||||
case advancedCustomEndpointPathDeepSeekToolsSearch:
|
||||
return types.EndpointTypeDeepSeekToolsSearch, true
|
||||
case advancedCustomEndpointPathDeepSeekToolsFetch:
|
||||
return types.EndpointTypeDeepSeekToolsFetch, true
|
||||
case advancedCustomEndpointPathClaudeMessages:
|
||||
return types.EndpointTypeAnthropic, true
|
||||
case advancedCustomEndpointPathJinaRerank:
|
||||
@@ -411,7 +468,9 @@ func IsAdvancedCustomConverterAllowed(converter string) bool {
|
||||
advancedCustomConverterOpenAIResponsesToOpenAIChat,
|
||||
advancedCustomConverterOpenAIResponsesToGemini,
|
||||
advancedCustomConverterGeminiContentToOpenAIChat,
|
||||
advancedCustomConverterOpenAIChatToGeminiContent:
|
||||
advancedCustomConverterOpenAIChatToGeminiContent,
|
||||
advancedCustomConverterClaudeMessagesToOpenAIResponses,
|
||||
advancedCustomConverterGeminiContentToOpenAIResponses:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -606,7 +665,8 @@ func validateAdvancedCustomConverterPath(index int, incomingPath string, convert
|
||||
switch converter {
|
||||
case advancedCustomConverterNone:
|
||||
return nil
|
||||
case advancedCustomConverterClaudeMessagesToOpenAIChat:
|
||||
case advancedCustomConverterClaudeMessagesToOpenAIChat,
|
||||
advancedCustomConverterClaudeMessagesToOpenAIResponses:
|
||||
if incomingPath == "/v1/messages" {
|
||||
return nil
|
||||
}
|
||||
@@ -624,7 +684,8 @@ func validateAdvancedCustomConverterPath(index int, incomingPath string, convert
|
||||
if incomingPath == "/v1/responses" {
|
||||
return nil
|
||||
}
|
||||
case advancedCustomConverterGeminiContentToOpenAIChat:
|
||||
case advancedCustomConverterGeminiContentToOpenAIChat,
|
||||
advancedCustomConverterGeminiContentToOpenAIResponses:
|
||||
if strings.Contains(incomingPath, ":generateContent") || strings.Contains(incomingPath, ":streamGenerateContent") {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -676,20 +676,24 @@ func TestChannelSettingsHTTPTransportJSONRoundTrip(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(encoded), "http_protocol")
|
||||
assert.NotContains(t, string(encoded), "http2_connection_shards")
|
||||
assert.NotContains(t, string(encoded), "tls_insecure_skip_verify")
|
||||
|
||||
explicit := ChannelSettings{
|
||||
Proxy: "socks5://127.0.0.1:1080",
|
||||
HTTPProtocol: HTTPProtocolHTTP1,
|
||||
HTTP2ConnectionShards: 1,
|
||||
TLSInsecureSkipVerify: true,
|
||||
}
|
||||
encoded, err = json.Marshal(explicit)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(encoded), `"http_protocol":"http1"`)
|
||||
assert.Contains(t, string(encoded), `"tls_insecure_skip_verify":true`)
|
||||
|
||||
var decoded ChannelSettings
|
||||
require.NoError(t, json.Unmarshal(encoded, &decoded))
|
||||
assert.Equal(t, explicit.HTTPProtocol, decoded.HTTPProtocol)
|
||||
assert.Equal(t, 1, decoded.HTTP2ConnectionShards)
|
||||
assert.True(t, decoded.TLSInsecureSkipVerify)
|
||||
|
||||
sharded := ChannelSettings{HTTP2ConnectionShards: 4}
|
||||
encoded, err = json.Marshal(sharded)
|
||||
|
||||
+80
-15
@@ -3,6 +3,7 @@ package dto
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -135,6 +136,9 @@ type ClaudeMessageSource struct {
|
||||
type ClaudeMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content any `json:"content"`
|
||||
// OutputConfig carries per-message effort (beta): an effort-only system
|
||||
// message has empty content and the new level in output_config.effort.
|
||||
OutputConfig json.RawMessage `json:"output_config,omitempty"`
|
||||
}
|
||||
|
||||
func (c *ClaudeMessage) IsStringContent() bool {
|
||||
@@ -238,6 +242,7 @@ type ClaudeRequest struct {
|
||||
TopK *int `json:"top_k,omitempty"`
|
||||
Stream *bool `json:"stream,omitempty"`
|
||||
Tools any `json:"tools,omitempty"`
|
||||
Safeguards json.RawMessage `json:"safeguards,omitempty"`
|
||||
ContextManagement json.RawMessage `json:"context_management,omitempty"`
|
||||
OutputConfig json.RawMessage `json:"output_config,omitempty"`
|
||||
OutputFormat json.RawMessage `json:"output_format,omitempty"`
|
||||
@@ -332,9 +337,25 @@ func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
texts = append(texts, string(b))
|
||||
}
|
||||
case "tool_result":
|
||||
if media.Content != nil {
|
||||
b, _ := kitutil.Marshal(media.Content)
|
||||
texts = append(texts, string(b))
|
||||
// Count tool output as the text the model reads; JSON-encoding it
|
||||
// would escape newlines and quotes and count image base64 as text.
|
||||
if media.IsStringContent() {
|
||||
texts = append(texts, media.GetStringContent())
|
||||
break
|
||||
}
|
||||
for _, part := range media.ParseMediaContent() {
|
||||
if source := part.ToFileSource(); source != nil {
|
||||
fileType := types.FileTypeFile
|
||||
if part.Type == "image" {
|
||||
fileType = types.FileTypeImage
|
||||
}
|
||||
fileMeta = append(fileMeta, &types.FileMeta{FileType: fileType, Source: source})
|
||||
} else if part.Type == "text" {
|
||||
texts = append(texts, part.GetText())
|
||||
} else {
|
||||
b, _ := kitutil.Marshal(part)
|
||||
texts = append(texts, string(b))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -391,18 +412,6 @@ func (c *ClaudeRequest) SetModelName(modelName string) {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -456,6 +465,18 @@ func ProcessTools(tools []any) ([]*Tool, []*ClaudeWebSearchTool) {
|
||||
normalTools = append(normalTools, &t)
|
||||
case ClaudeWebSearchTool:
|
||||
webSearchTools = append(webSearchTools, &t)
|
||||
case map[string]any:
|
||||
// Tools decoded from client JSON arrive as maps.
|
||||
toolType, _ := t["type"].(string)
|
||||
if strings.HasPrefix(toolType, "web_search") {
|
||||
if webSearchTool, err := kitutil.Any2Type[ClaudeWebSearchTool](t); err == nil {
|
||||
webSearchTools = append(webSearchTools, &webSearchTool)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if normalTool, err := kitutil.Any2Type[Tool](t); err == nil {
|
||||
normalTools = append(normalTools, &normalTool)
|
||||
}
|
||||
default:
|
||||
// 未知类型,跳过
|
||||
continue
|
||||
@@ -473,6 +494,50 @@ type Thinking struct {
|
||||
// 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"`
|
||||
// BlockBinding carries the client's preserved-thinking controls, such as
|
||||
// {"prefix_mismatch_behavior":"drop_block"} (beta
|
||||
// thinking-binding-controls-2026-08-01). It is forwarded verbatim and
|
||||
// never added by the gateway.
|
||||
BlockBinding json.RawMessage `json:"block_binding,omitempty"`
|
||||
|
||||
// Extra keeps thinking fields this struct does not model yet, so a native
|
||||
// Claude request forwards them instead of silently dropping them. It is
|
||||
// exported so common.DeepCopy copies it; MarshalJSON writes it back.
|
||||
Extra map[string]json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
func (c *Thinking) UnmarshalJSON(data []byte) error {
|
||||
type thinkingFields Thinking
|
||||
var fields thinkingFields
|
||||
if err := kitutil.Unmarshal(data, &fields); err != nil {
|
||||
return err
|
||||
}
|
||||
var all map[string]json.RawMessage
|
||||
if err := kitutil.Unmarshal(data, &all); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, known := range []string{"type", "budget_tokens", "display", "block_binding"} {
|
||||
delete(all, known)
|
||||
}
|
||||
*c = Thinking(fields)
|
||||
if len(all) > 0 {
|
||||
c.Extra = all
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Thinking) MarshalJSON() ([]byte, error) {
|
||||
type thinkingFields Thinking
|
||||
data, err := kitutil.Marshal(thinkingFields(c))
|
||||
if err != nil || len(c.Extra) == 0 {
|
||||
return data, err
|
||||
}
|
||||
merged := make(map[string]json.RawMessage, len(c.Extra)+4)
|
||||
maps.Copy(merged, c.Extra)
|
||||
if err := kitutil.Unmarshal(data, &merged); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return kitutil.Marshal(merged)
|
||||
}
|
||||
|
||||
func (c *Thinking) GetBudgetTokens() int {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
)
|
||||
|
||||
// DeepSeekToolsRequest is a DeepSeek Search or Fetch API request
|
||||
// (/v1/tools/search, /v1/tools/fetch). The body names no model; Model holds
|
||||
// the routing name. RawBody preserves the original JSON so the body is
|
||||
// forwarded with unknown fields intact.
|
||||
type DeepSeekToolsRequest struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
RawBody json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
func (r *DeepSeekToolsRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
return &types.TokenCountMeta{
|
||||
CombineText: string(r.RawBody),
|
||||
TokenType: types.TokenTypeTokenizer,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *DeepSeekToolsRequest) IsStream(_ *http.Request) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *DeepSeekToolsRequest) SetModelName(modelName string) {
|
||||
if modelName != "" {
|
||||
r.Model = modelName
|
||||
}
|
||||
}
|
||||
+52
-2
@@ -2,7 +2,9 @@ package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
@@ -74,13 +76,47 @@ func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
}
|
||||
|
||||
var inputTexts []string
|
||||
if r.SystemInstructions != nil {
|
||||
for _, part := range r.SystemInstructions.Parts {
|
||||
if part.Text != "" {
|
||||
inputTexts = append(inputTexts, part.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
// Function calls and responses replayed in agent loops are part of the prompt.
|
||||
if part.FunctionCall != nil {
|
||||
args, _ := kitutil.Marshal(part.FunctionCall.Arguments)
|
||||
inputTexts = append(inputTexts, part.FunctionCall.FunctionName, string(args))
|
||||
}
|
||||
mediaParts := []GeminiPart{part}
|
||||
if part.FunctionResponse != nil {
|
||||
inputTexts = append(inputTexts, part.FunctionResponse.Name)
|
||||
// Count tool output as the text the model reads, not JSON-escaped.
|
||||
for _, key := range slices.Sorted(maps.Keys(part.FunctionResponse.Response)) {
|
||||
if text, ok := part.FunctionResponse.Response[key].(string); ok {
|
||||
inputTexts = append(inputTexts, text)
|
||||
continue
|
||||
}
|
||||
value, _ := kitutil.Marshal(part.FunctionResponse.Response[key])
|
||||
inputTexts = append(inputTexts, string(value))
|
||||
}
|
||||
// Multimodal function responses carry media such as agent screenshots.
|
||||
var responseParts []GeminiPart
|
||||
if kitutil.Unmarshal(part.FunctionResponse.Parts, &responseParts) == nil {
|
||||
mediaParts = append(mediaParts, responseParts...)
|
||||
}
|
||||
}
|
||||
for _, media := range mediaParts {
|
||||
source := media.InlineData.ToFileSource()
|
||||
if source == nil {
|
||||
continue
|
||||
}
|
||||
mimeType := media.InlineData.MimeType
|
||||
var fileType types.FileType
|
||||
if strings.HasPrefix(mimeType, "image/") {
|
||||
fileType = types.FileTypeImage
|
||||
@@ -91,13 +127,26 @@ func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
} else {
|
||||
fileType = types.FileTypeFile
|
||||
}
|
||||
// Image cost depends on the media resolution; a part's own level
|
||||
// overrides the request-wide one.
|
||||
detail := string(r.GenerationConfig.MediaResolution)
|
||||
var partResolution struct {
|
||||
Level string `json:"level"`
|
||||
}
|
||||
if kitutil.Unmarshal(media.MediaResolution, &partResolution) == nil && partResolution.Level != "" {
|
||||
detail = partResolution.Level
|
||||
}
|
||||
files = append(files, &types.FileMeta{
|
||||
FileType: fileType,
|
||||
Source: source,
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(r.Tools) > 0 {
|
||||
inputTexts = append(inputTexts, string(r.Tools))
|
||||
}
|
||||
|
||||
inputText := strings.Join(inputTexts, "\n")
|
||||
return &types.TokenCountMeta{
|
||||
@@ -467,6 +516,7 @@ type GeminiChatCandidate struct {
|
||||
|
||||
type GeminiGroundingMetadata struct {
|
||||
WebSearchQueries []string `json:"webSearchQueries,omitempty"`
|
||||
ImageSearchQueries []string `json:"imageSearchQueries,omitempty"`
|
||||
RetrievalQueries []string `json:"retrievalQueries,omitempty"`
|
||||
GroundingChunks json.RawMessage `json:"groundingChunks,omitempty"`
|
||||
GroundingSupports json.RawMessage `json:"groundingSupports,omitempty"`
|
||||
|
||||
@@ -93,6 +93,7 @@ type GeneralOpenAIRequest struct {
|
||||
ThinkingBudget json.RawMessage `json:"thinking_budget,omitempty"`
|
||||
ChatTemplateKwargs json.RawMessage `json:"chat_template_kwargs,omitempty"`
|
||||
EnableSearch json.RawMessage `json:"enable_search,omitempty"`
|
||||
SearchOptions json.RawMessage `json:"search_options,omitempty"`
|
||||
// ollama Params
|
||||
Think json.RawMessage `json:"think,omitempty"`
|
||||
// baidu v2
|
||||
@@ -251,6 +252,14 @@ func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Assistant tool calls replayed in agent loops are part of the prompt.
|
||||
for _, toolCall := range message.ParseToolCalls() {
|
||||
if len(toolCall.Custom) > 0 {
|
||||
texts = append(texts, string(toolCall.Custom))
|
||||
continue
|
||||
}
|
||||
texts = append(texts, toolCall.Function.Name, toolCall.Function.Arguments)
|
||||
}
|
||||
}
|
||||
|
||||
tools := r.Tools
|
||||
@@ -259,12 +268,17 @@ func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
}
|
||||
for _, tool := range tools {
|
||||
tokenCountMeta.ToolsCount++
|
||||
if len(tool.Custom) > 0 {
|
||||
texts = append(texts, string(tool.Custom))
|
||||
continue
|
||||
}
|
||||
texts = append(texts, tool.Function.Name)
|
||||
if tool.Function.Description != "" {
|
||||
texts = append(texts, tool.Function.Description)
|
||||
}
|
||||
if tool.Function.Parameters != nil {
|
||||
texts = append(texts, fmt.Sprintf("%v", tool.Function.Parameters))
|
||||
parameters, _ := kitutil.Marshal(tool.Function.Parameters)
|
||||
texts = append(texts, string(parameters))
|
||||
}
|
||||
}
|
||||
//toolTokens := CountTokenInput(countStr, request.Model)
|
||||
@@ -329,7 +343,8 @@ func GetOpenAIChatCapabilities(modelName, reasoningEffort string) OpenAIChatCapa
|
||||
}
|
||||
|
||||
isGPT5Model := IsOpenAIGPT5Model(modelName)
|
||||
if !isGPT5Model && !isOpenAIModelSnapshot(modelName, "gpt-6-astra") {
|
||||
isGPT6SolLuna := isOpenAIModelSnapshot(modelName, "gpt-6-sol") || isOpenAIModelSnapshot(modelName, "gpt-6-luna")
|
||||
if !isGPT5Model && !isGPT6SolLuna && !isOpenAIModelSnapshot(modelName, "gpt-6-astra") {
|
||||
return capabilities
|
||||
}
|
||||
capabilities.UseMaxCompletionTokens = true
|
||||
@@ -337,12 +352,15 @@ func GetOpenAIChatCapabilities(modelName, reasoningEffort string) OpenAIChatCapa
|
||||
|
||||
// These standard GPT-5 models default to none and support sampling only
|
||||
// without reasoning. Named variants (pro, codex, chat-latest, etc.) do not
|
||||
// inherit this exception. GPT-6 Astra never supports these parameters.
|
||||
// inherit this exception. GPT-6 Sol and Luna follow the same rule. GPT-6
|
||||
// Astra never supports these parameters.
|
||||
// https://developers.openai.com/api/docs/guides/latest-model?model=gpt-5.2
|
||||
// https://developers.openai.com/api/docs/guides/latest-model?model=gpt-5.4
|
||||
// https://developers.openai.com/api/docs/guides/latest-model?model=gpt-6-astra
|
||||
// https://developers.openai.com/api/docs/guides/latest-model?model=gpt-6-luna
|
||||
supportsSampling := false
|
||||
if isGPT5Model && (reasoningEffort == "" || reasoningEffort == "none") {
|
||||
if reasoningEffort == "" || reasoningEffort == "none" {
|
||||
supportsSampling = isGPT6SolLuna
|
||||
for _, model := range []string{"gpt-5.1", "gpt-5.2", "gpt-5.4"} {
|
||||
if isOpenAIModelSnapshot(modelName, model) {
|
||||
supportsSampling = true
|
||||
@@ -390,6 +408,19 @@ type ToolCallRequest struct {
|
||||
Type string `json:"type"`
|
||||
Function FunctionRequest `json:"function"`
|
||||
Custom json.RawMessage `json:"custom,omitempty"`
|
||||
// Native is a complete vendor tool object sent instead of the fields
|
||||
// above. Function has no omitempty, so a non-function tool would
|
||||
// otherwise carry "function":{"name":""}, which vendor schemas with
|
||||
// additionalProperties:false reject. Never read from client JSON.
|
||||
Native json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
func (t ToolCallRequest) MarshalJSON() ([]byte, error) {
|
||||
if len(t.Native) > 0 {
|
||||
return t.Native, nil
|
||||
}
|
||||
type Alias ToolCallRequest
|
||||
return kitutil.Marshal((*Alias)(&t))
|
||||
}
|
||||
|
||||
type FunctionRequest struct {
|
||||
@@ -567,9 +598,11 @@ func (m *MediaContent) ToFileSource() types.FileSource {
|
||||
}
|
||||
|
||||
type MessageImageUrl struct {
|
||||
Url string `json:"url"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
MimeType string
|
||||
Url string `json:"url"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
// MimeType is in-memory metadata for converters and token counting; it is
|
||||
// not part of the Chat image_url object.
|
||||
MimeType string `json:"-"`
|
||||
}
|
||||
|
||||
func (m *MessageImageUrl) IsRemoteImage() bool {
|
||||
@@ -743,9 +776,9 @@ func (m *Message) ParseContent() []MediaContent {
|
||||
|
||||
case ContentTypeImageURL:
|
||||
imageUrl := contentItem["image_url"]
|
||||
temp := &MessageImageUrl{
|
||||
Detail: "high",
|
||||
}
|
||||
// An omitted detail stays empty: upstream defaults it to auto, which
|
||||
// costs more than high on gpt-5.5 and later.
|
||||
temp := &MessageImageUrl{}
|
||||
switch v := imageUrl.(type) {
|
||||
case string:
|
||||
temp.Url = v
|
||||
@@ -1103,7 +1136,12 @@ func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
}
|
||||
|
||||
if len(r.Instructions) > 0 {
|
||||
texts = append(texts, string(r.Instructions))
|
||||
var instructions string
|
||||
if kitutil.GetJsonType(r.Instructions) == "string" && kitutil.Unmarshal(r.Instructions, &instructions) == nil {
|
||||
texts = append(texts, instructions)
|
||||
} else {
|
||||
texts = append(texts, string(r.Instructions))
|
||||
}
|
||||
}
|
||||
|
||||
if len(r.Metadata) > 0 {
|
||||
@@ -1162,6 +1200,11 @@ type Input struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Content json.RawMessage `json:"content,omitempty"`
|
||||
// Tool-call items replayed in agent loops.
|
||||
Name string `json:"name,omitempty"`
|
||||
Arguments json.RawMessage `json:"arguments,omitempty"`
|
||||
ToolInput json.RawMessage `json:"input,omitempty"`
|
||||
Output json.RawMessage `json:"output,omitempty"`
|
||||
}
|
||||
|
||||
type MediaInput struct {
|
||||
@@ -1176,7 +1219,8 @@ type MediaInput struct {
|
||||
// Reference implementation mirrors Message.ParseContent:
|
||||
// - input can be a string, treated as an input_text item
|
||||
// - input can be an array of objects with a `type` field
|
||||
// supported types: input_text, input_image, input_file
|
||||
// supported types: input_text, output_text, input_image, input_file
|
||||
// - tool-call items contribute their name, arguments or input, and output as input_text
|
||||
func (r *OpenAIResponsesRequest) ParseInput() []MediaInput {
|
||||
if r.Input == nil {
|
||||
return nil
|
||||
@@ -1201,15 +1245,28 @@ func (r *OpenAIResponsesRequest) ParseInput() []MediaInput {
|
||||
var inputs []Input
|
||||
_ = kitutil.Unmarshal(r.Input, &inputs)
|
||||
for _, input := range inputs {
|
||||
if kitutil.GetJsonType(input.Content) == "string" {
|
||||
content := input.Content
|
||||
switch input.Type {
|
||||
case "function_call":
|
||||
content = input.Arguments
|
||||
case "custom_tool_call":
|
||||
content = input.ToolInput
|
||||
case "function_call_output", "custom_tool_call_output", "local_shell_call_output":
|
||||
content = input.Output
|
||||
}
|
||||
if input.Name != "" {
|
||||
mediaInputs = append(mediaInputs, MediaInput{Type: "input_text", Text: input.Name})
|
||||
}
|
||||
|
||||
if kitutil.GetJsonType(content) == "string" {
|
||||
var str string
|
||||
_ = kitutil.Unmarshal(input.Content, &str)
|
||||
_ = kitutil.Unmarshal(content, &str)
|
||||
mediaInputs = append(mediaInputs, MediaInput{Type: "input_text", Text: str})
|
||||
}
|
||||
|
||||
if kitutil.GetJsonType(input.Content) == "array" {
|
||||
if kitutil.GetJsonType(content) == "array" {
|
||||
var array []any
|
||||
_ = kitutil.Unmarshal(input.Content, &array)
|
||||
_ = kitutil.Unmarshal(content, &array)
|
||||
for _, itemAny := range array {
|
||||
// Already parsed MediaContent
|
||||
if media, ok := itemAny.(MediaInput); ok {
|
||||
@@ -1228,7 +1285,7 @@ func (r *OpenAIResponsesRequest) ParseInput() []MediaInput {
|
||||
continue
|
||||
}
|
||||
switch typeVal {
|
||||
case "input_text":
|
||||
case "input_text", "output_text":
|
||||
text, _ := item["text"].(string)
|
||||
mediaInputs = append(mediaInputs, MediaInput{Type: "input_text", Text: text})
|
||||
case "input_image":
|
||||
|
||||
@@ -374,7 +374,9 @@ type ResponsesOutput struct {
|
||||
Result string `json:"result,omitempty"`
|
||||
CallId string `json:"call_id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
Arguments json.RawMessage `json:"arguments,omitempty"`
|
||||
Input json.RawMessage `json:"input,omitempty"`
|
||||
Action json.RawMessage `json:"action,omitempty"`
|
||||
Queries json.RawMessage `json:"queries,omitempty"`
|
||||
Results json.RawMessage `json:"results,omitempty"`
|
||||
@@ -393,9 +395,24 @@ type ResponsesOutput struct {
|
||||
|
||||
// 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.
|
||||
// calls, whose fields must not leak into web_search_call, mcp_call, or
|
||||
// custom_tool_call items.
|
||||
func (r ResponsesOutput) MarshalJSON() ([]byte, error) {
|
||||
switch r.Type {
|
||||
case "custom_tool_call":
|
||||
input := r.Input
|
||||
if len(input) == 0 {
|
||||
input = json.RawMessage(`""`)
|
||||
}
|
||||
return kitutil.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
CallID string `json:"call_id"`
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
}{Type: r.Type, ID: r.ID, Status: r.Status, CallID: r.CallId, Name: r.Name, Namespace: r.Namespace, Input: input})
|
||||
case "web_search_call":
|
||||
return kitutil.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
@@ -425,6 +442,16 @@ func (r ResponsesOutput) MarshalJSON() ([]byte, error) {
|
||||
Error: r.ItemError,
|
||||
ApprovalRequestID: r.ApprovalRequestID,
|
||||
})
|
||||
case "reasoning":
|
||||
// summary is required on a reasoning item, even before any summary text exists.
|
||||
type responsesOutputAlias ResponsesOutput
|
||||
if len(r.Summary) > 0 {
|
||||
return kitutil.Marshal(responsesOutputAlias(r))
|
||||
}
|
||||
return kitutil.Marshal(struct {
|
||||
responsesOutputAlias
|
||||
Summary []ResponsesReasoningSummaryPart `json:"summary"`
|
||||
}{responsesOutputAlias(r), []ResponsesReasoningSummaryPart{}})
|
||||
default:
|
||||
type responsesOutputAlias ResponsesOutput
|
||||
return kitutil.Marshal(responsesOutputAlias(r))
|
||||
@@ -555,6 +582,7 @@ type ResponsesStreamResponse struct {
|
||||
Param string `json:"param,omitempty"`
|
||||
Delta string `json:"delta,omitempty"`
|
||||
Arguments *string `json:"arguments,omitempty"`
|
||||
Input *string `json:"input,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Text *string `json:"text,omitempty"`
|
||||
Item *ResponsesOutput `json:"item,omitempty"`
|
||||
@@ -564,6 +592,8 @@ type ResponsesStreamResponse struct {
|
||||
Obfuscation string `json:"obfuscation,omitempty"`
|
||||
// - response.function_call_arguments.delta
|
||||
// - response.function_call_arguments.done
|
||||
// - response.custom_tool_call_input.delta
|
||||
// - response.custom_tool_call_input.done
|
||||
OutputIndex *int `json:"output_index,omitempty"`
|
||||
ContentIndex *int `json:"content_index,omitempty"`
|
||||
SummaryIndex *int `json:"summary_index,omitempty"`
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// Agent loops replay tool definitions, tool calls, and tool output on every
|
||||
// turn; the prompt estimate must see all of them as the model reads them.
|
||||
func TestTokenCountMetaCountsAgentLoopContent(t *testing.T) {
|
||||
const toolOutput = "package main\n\nfunc main() {\n\tprintln(\"hi\")\n}"
|
||||
const imageData = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
raw string
|
||||
request interface{ GetTokenCountMeta() *types.TokenCountMeta }
|
||||
contains []string
|
||||
files int
|
||||
detail string
|
||||
toolCount int
|
||||
}{
|
||||
{
|
||||
name: "chat",
|
||||
raw: `{"model":"m","messages":[
|
||||
{"role":"user","content":[{"type":"text","text":"show main.go"},{"type":"image_url","image_url":{"url":"data:image/png;base64,` + imageData + `"}}]},
|
||||
{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"main.go\"}"}}]},
|
||||
{"role":"tool","tool_call_id":"c1","content":"package main\n\nfunc main() {\n\tprintln(\"hi\")\n}"}],
|
||||
"tools":[{"type":"function","function":{"name":"read_file","description":"Read a file","parameters":{"type":"object","properties":{"path":{"type":"string"}}}}}]}`,
|
||||
request: &GeneralOpenAIRequest{},
|
||||
contains: []string{"read_file", `{"path":"main.go"}`, toolOutput, "Read a file", `"properties":{"path":{"type":"string"}}`},
|
||||
// An omitted detail stays empty so the estimate uses the upstream default (auto).
|
||||
files: 1,
|
||||
toolCount: 1,
|
||||
},
|
||||
{
|
||||
name: "responses",
|
||||
raw: `{"model":"m","instructions":"Be brief.\nUse tools.","input":[
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"show main.go"}]},
|
||||
{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Reading it now."}]},
|
||||
{"type":"function_call","call_id":"c1","name":"read_file","arguments":"{\"path\":\"main.go\"}"},
|
||||
{"type":"function_call_output","call_id":"c1","output":"package main\n\nfunc main() {\n\tprintln(\"hi\")\n}"},
|
||||
{"type":"custom_tool_call","call_id":"c2","name":"apply_patch","input":"*** Begin Patch"},
|
||||
{"type":"custom_tool_call_output","call_id":"c2","output":[{"type":"input_text","text":"Done!"}]}]}`,
|
||||
request: &OpenAIResponsesRequest{},
|
||||
contains: []string{"Be brief.\nUse tools.", "Reading it now.", "read_file", `{"path":"main.go"}`, toolOutput, "apply_patch", "*** Begin Patch", "Done!"},
|
||||
},
|
||||
{
|
||||
name: "claude",
|
||||
raw: `{"model":"m","max_tokens":1,"messages":[
|
||||
{"role":"user","content":"show main.go"},
|
||||
{"role":"assistant","content":[{"type":"tool_use","id":"c1","name":"read_file","input":{"path":"main.go"}}]},
|
||||
{"role":"user","content":[
|
||||
{"type":"tool_result","tool_use_id":"c1","content":"package main\n\nfunc main() {\n\tprintln(\"hi\")\n}"},
|
||||
{"type":"tool_result","tool_use_id":"c2","content":[{"type":"text","text":"screenshot taken"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"` + imageData + `"}}]}]}],
|
||||
"tools":[{"name":"read_file","description":"Read a file","input_schema":{"type":"object","properties":{"path":{"type":"string"}}}}]}`,
|
||||
request: &ClaudeRequest{},
|
||||
contains: []string{"read_file", `{"path":"main.go"}`, toolOutput, "screenshot taken", "Read a file", `"properties":{"path":{"type":"string"}}`},
|
||||
files: 1,
|
||||
toolCount: 1,
|
||||
},
|
||||
{
|
||||
name: "gemini",
|
||||
raw: `{"systemInstruction":{"parts":[{"text":"Be brief."}]},"contents":[
|
||||
{"role":"user","parts":[{"text":"show main.go"}]},
|
||||
{"role":"model","parts":[{"functionCall":{"name":"read_file","args":{"path":"main.go"}}}]},
|
||||
{"role":"user","parts":[{"functionResponse":{"name":"read_file","response":{"output":"package main\n\nfunc main() {\n\tprintln(\"hi\")\n}","exit_code":0}}}]},
|
||||
{"role":"user","parts":[{"functionResponse":{"name":"screenshot","response":{},"parts":[{"inlineData":{"mimeType":"image/png","data":"` + imageData + `"}}]}}]}],
|
||||
"generationConfig":{"mediaResolution":"MEDIA_RESOLUTION_LOW"},
|
||||
"tools":[{"functionDeclarations":[{"name":"read_file","description":"Read a file"}]}]}`,
|
||||
request: &GeminiChatRequest{},
|
||||
contains: []string{"Be brief.", "read_file", `{"path":"main.go"}`, toolOutput, "Read a file"},
|
||||
files: 1,
|
||||
detail: "MEDIA_RESOLUTION_LOW",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.NoError(t, kitutil.Unmarshal([]byte(tc.raw), tc.request))
|
||||
meta := tc.request.GetTokenCountMeta()
|
||||
for _, want := range tc.contains {
|
||||
assert.Contains(t, meta.CombineText, want)
|
||||
}
|
||||
// Tool output and instructions are counted as raw text, not JSON-escaped,
|
||||
// and images stay out of the text.
|
||||
assert.NotContains(t, meta.CombineText, `\n`)
|
||||
assert.NotContains(t, meta.CombineText, "map[")
|
||||
assert.NotContains(t, meta.CombineText, imageData)
|
||||
require.Len(t, meta.Files, tc.files)
|
||||
for _, file := range meta.Files {
|
||||
assert.Equal(t, types.FileTypeImage, file.FileType)
|
||||
assert.Equal(t, tc.detail, file.Detail)
|
||||
}
|
||||
assert.Equal(t, tc.toolCount, meta.ToolsCount)
|
||||
})
|
||||
}
|
||||
}
|
||||
+18
-13
@@ -264,23 +264,28 @@ func normalizeGeminiModality(modality string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(modality))
|
||||
}
|
||||
|
||||
// mergeGeminiTokenDetails overlays per-modality counts the same way as the
|
||||
// scalar counts: every stream frame carries a cumulative snapshot, so a
|
||||
// modality the incoming frame reports replaces all earlier entries for it,
|
||||
// and a modality it omits keeps its earlier value. Duplicate entries inside
|
||||
// one snapshot are kept as-is; settlement sums them.
|
||||
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
|
||||
reported := make(map[string]bool, len(incoming))
|
||||
for _, detail := range incoming {
|
||||
if detail.TokenCount > 0 {
|
||||
reported[normalizeGeminiModality(detail.Modality)] = true
|
||||
}
|
||||
}
|
||||
merged := make([]GeminiPromptTokensDetails, 0, len(current)+len(incoming))
|
||||
for _, detail := range current {
|
||||
if !reported[normalizeGeminiModality(detail.Modality)] {
|
||||
merged = append(merged, detail)
|
||||
}
|
||||
}
|
||||
for _, detail := range incoming {
|
||||
if detail.TokenCount <= 0 {
|
||||
continue
|
||||
if detail.TokenCount > 0 {
|
||||
merged = append(merged, detail)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -72,37 +72,95 @@ func TestGeminiModalityKeysSettleConsistentlyAndDuplicateEntriesSum(t *testing.T
|
||||
})
|
||||
}
|
||||
|
||||
// A case variant in a later cumulative frame is the same modality and
|
||||
// replaces the earlier value.
|
||||
mergedDetails := mergeGeminiTokenDetails(
|
||||
[]GeminiPromptTokensDetails{{Modality: "AUDIO", TokenCount: 10}},
|
||||
[]GeminiPromptTokensDetails{{Modality: "audio", TokenCount: 15}},
|
||||
)
|
||||
require.Len(t, mergedDetails, 1)
|
||||
assert.Equal(t, 25, mergedDetails[0].TokenCount)
|
||||
assert.Equal(t, []GeminiPromptTokensDetails{{Modality: "audio", TokenCount: 15}}, mergedDetails)
|
||||
|
||||
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{
|
||||
// Duplicate entries inside one snapshot settle as their sum, and merging
|
||||
// that snapshot again (every frame repeats it) does not add it twice.
|
||||
snapshot := &GeminiUsageMetadata{
|
||||
PromptTokenCount: 25,
|
||||
PromptTokensDetails: []GeminiPromptTokensDetails{
|
||||
{Modality: "AUDIO", TokenCount: 10},
|
||||
{Modality: "audio", TokenCount: 15},
|
||||
},
|
||||
}).CanonicalUsage()
|
||||
}
|
||||
streamMerged := MergeGeminiUsageMetadataNonZero(MergeGeminiUsageMetadataNonZero(nil, snapshot), snapshot)
|
||||
require.NotNil(t, streamMerged)
|
||||
streamUsage, ok := NewGeminiChatBillingUsage(streamMerged).CanonicalUsage()
|
||||
require.True(t, ok)
|
||||
|
||||
decodedUsage, ok := NewGeminiChatBillingUsage(snapshot).CanonicalUsage()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, decodedUsage.PromptTokensDetails.AudioTokens, streamUsage.PromptTokensDetails.AudioTokens)
|
||||
assert.Equal(t, 25, decodedUsage.PromptTokensDetails.AudioTokens)
|
||||
assert.Equal(t, 25, streamUsage.PromptTokensDetails.AudioTokens)
|
||||
}
|
||||
|
||||
func TestMergeGeminiUsageMetadataStreamDetailsAreCumulativeSnapshots(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := []GeminiPromptTokensDetails{
|
||||
{Modality: "IMAGE", TokenCount: 1089},
|
||||
{Modality: "TEXT", TokenCount: 42},
|
||||
}
|
||||
frames := []*GeminiUsageMetadata{
|
||||
{PromptTokenCount: 1131, TotalTokenCount: 1131, PromptTokensDetails: prompt},
|
||||
{
|
||||
PromptTokenCount: 1131,
|
||||
ToolUsePromptTokenCount: 7,
|
||||
CandidatesTokenCount: 5,
|
||||
TotalTokenCount: 1143,
|
||||
PromptTokensDetails: prompt,
|
||||
ToolUsePromptTokensDetails: []GeminiPromptTokensDetails{{Modality: "TEXT", TokenCount: 7}},
|
||||
CandidatesTokensDetails: []GeminiPromptTokensDetails{{Modality: "TEXT", TokenCount: 5}},
|
||||
},
|
||||
{
|
||||
PromptTokenCount: 1131,
|
||||
ToolUsePromptTokenCount: 7,
|
||||
CandidatesTokenCount: 1310,
|
||||
ThoughtsTokenCount: 76,
|
||||
TotalTokenCount: 2524,
|
||||
PromptTokensDetails: prompt,
|
||||
ToolUsePromptTokensDetails: []GeminiPromptTokensDetails{{Modality: "TEXT", TokenCount: 7}},
|
||||
CandidatesTokensDetails: []GeminiPromptTokensDetails{
|
||||
{Modality: "TEXT", TokenCount: 20},
|
||||
{Modality: "IMAGE", TokenCount: 1290},
|
||||
},
|
||||
},
|
||||
// A trailing frame without details keeps the last reported ones.
|
||||
{PromptTokenCount: 1131, CandidatesTokenCount: 1310, ThoughtsTokenCount: 76, TotalTokenCount: 2524},
|
||||
}
|
||||
var merged *GeminiUsageMetadata
|
||||
for _, frame := range frames {
|
||||
merged = MergeGeminiUsageMetadataNonZero(merged, frame)
|
||||
}
|
||||
require.NotNil(t, merged)
|
||||
assert.Equal(t, prompt, merged.PromptTokensDetails)
|
||||
assert.Equal(t, []GeminiPromptTokensDetails{{Modality: "TEXT", TokenCount: 7}}, merged.ToolUsePromptTokensDetails)
|
||||
assert.Equal(t, []GeminiPromptTokensDetails{
|
||||
{Modality: "TEXT", TokenCount: 20},
|
||||
{Modality: "IMAGE", TokenCount: 1290},
|
||||
}, merged.CandidatesTokensDetails)
|
||||
|
||||
streamUsage, ok := NewGeminiChatBillingUsage(merged).CanonicalUsage()
|
||||
require.True(t, ok)
|
||||
nonStreamUsage, ok := NewGeminiChatBillingUsage(frames[2]).CanonicalUsage()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, nonStreamUsage, streamUsage)
|
||||
assert.Equal(t, 1089, streamUsage.PromptTokensDetails.ImageTokens)
|
||||
assert.Equal(t, 1290, streamUsage.CompletionTokenDetails.ImageTokens)
|
||||
|
||||
// Converted streams remember the same cumulative sidecar at several
|
||||
// pipeline steps; repeated merges must not change the result.
|
||||
sidecar := NewGeminiChatBillingUsage(merged)
|
||||
again := MergeBillingUsageNonZero(MergeBillingUsageNonZero(sidecar, sidecar), sidecar)
|
||||
repeatedUsage, ok := again.CanonicalUsage()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, nonStreamUsage, repeatedUsage)
|
||||
}
|
||||
|
||||
func TestClaudeCacheCreationSubObjectZeroDoesNotReviveFlatLegacyFields(t *testing.T) {
|
||||
@@ -172,8 +230,8 @@ func TestMergeClaudeUsageNonZeroPreservesBillingUsage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
currentSidecar := NewGeminiChatBillingUsage(&GeminiUsageMetadata{
|
||||
PromptTokenCount: 3868,
|
||||
TotalTokenCount: 3868,
|
||||
PromptTokenCount: 3868,
|
||||
TotalTokenCount: 3868,
|
||||
CachedContentTokenCount: 20,
|
||||
})
|
||||
incomingSidecar := NewGeminiChatBillingUsage(&GeminiUsageMetadata{
|
||||
|
||||
@@ -3,6 +3,7 @@ package dto
|
||||
type UserSetting struct {
|
||||
NotifyType string `json:"notify_type,omitempty"` // QuotaWarningType 额度预警类型
|
||||
QuotaWarningThreshold float64 `json:"quota_warning_threshold,omitempty"` // QuotaWarningThreshold 额度预警阈值
|
||||
QuotaWarningEnabled *bool `json:"quota_warning_enabled,omitempty"` // 是否接收余额不足提醒,未设置时接收
|
||||
WebhookUrl string `json:"webhook_url,omitempty"` // WebhookUrl webhook地址
|
||||
WebhookSecret string `json:"webhook_secret,omitempty"` // WebhookSecret webhook密钥
|
||||
NotificationEmail string `json:"notification_email,omitempty"` // NotificationEmail 通知邮箱地址
|
||||
@@ -11,6 +12,7 @@ type UserSetting struct {
|
||||
GotifyToken string `json:"gotify_token,omitempty"` // GotifyToken Gotify应用令牌
|
||||
GotifyPriority int `json:"gotify_priority"` // GotifyPriority Gotify消息优先级
|
||||
UpstreamModelUpdateNotifyEnabled bool `json:"upstream_model_update_notify_enabled,omitempty"` // 是否接收上游模型更新定时检测通知(仅管理员)
|
||||
ChannelNotifyEnabled *bool `json:"channel_notify_enabled,omitempty"` // 是否接收渠道通知(仅管理员),未设置时 root 接收、其他管理员不接收
|
||||
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 左侧边栏模块配置
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
package convmeta
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
)
|
||||
@@ -40,6 +42,15 @@ type Meta interface {
|
||||
// nil receiver may return a temporary initialized state.
|
||||
EnsureClaudeConvertInfo() *ClaudeConvertInfo
|
||||
|
||||
// ResponsesToolState returns the Responses tool encoding recorded by the
|
||||
// latest request conversion, or nil when none was recorded. Response
|
||||
// converters read it to restore Responses-only tool call shapes.
|
||||
ResponsesToolState() *ResponsesToolState
|
||||
// SetResponsesToolState replaces that record; nil clears it. Request
|
||||
// conversion calls it on every attempt so retries never inherit a record
|
||||
// from another channel.
|
||||
SetResponsesToolState(state *ResponsesToolState)
|
||||
|
||||
// GetSendResponseCount / IncrSendResponseCount expose the shared
|
||||
// downstream-chunk counter (the host may also increment it).
|
||||
GetSendResponseCount() int
|
||||
@@ -80,6 +91,60 @@ type ClaudeStreamToolCall struct {
|
||||
Started bool
|
||||
}
|
||||
|
||||
// ResponsesToolState records how Responses-only tool definitions were encoded
|
||||
// for the upstream protocol, so the matching response can be restored.
|
||||
type ResponsesToolState struct {
|
||||
// CustomToolNames lists Responses custom (freeform) tools that were sent
|
||||
// upstream as function tools taking one string "input" argument. Function
|
||||
// calls with these names are custom tool calls.
|
||||
CustomToolNames map[string]struct{}
|
||||
// Namespaces maps an upstream function name to the Responses tool
|
||||
// namespace it was flattened from; see NamespacedToolName.
|
||||
Namespaces map[string]string
|
||||
}
|
||||
|
||||
// CustomToolInputArgument is the single function argument that carries a
|
||||
// Responses custom tool input through an upstream function call.
|
||||
const CustomToolInputArgument = "input"
|
||||
|
||||
// DefaultResponsesToolNamespace is the namespace Codex declares its ordinary
|
||||
// tools in. OpenAI returns calls to its tools by bare name without a
|
||||
// namespace field, so its tools keep their names upstream.
|
||||
const DefaultResponsesToolNamespace = "functions"
|
||||
|
||||
// NamespacedToolName is the upstream function name of a tool declared in a
|
||||
// Responses tool namespace: "<namespace>__<name>", or the bare name for the
|
||||
// default namespace.
|
||||
func NamespacedToolName(namespace string, name string) string {
|
||||
if namespace == "" || namespace == DefaultResponsesToolNamespace {
|
||||
return name
|
||||
}
|
||||
return namespace + "__" + name
|
||||
}
|
||||
|
||||
// IsCustomTool reports whether name was encoded from a Responses custom tool.
|
||||
func (s *ResponsesToolState) IsCustomTool(name string) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := s.CustomToolNames[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ResponsesToolName restores the Responses namespace and tool name of an
|
||||
// upstream function name. Names that were not flattened from a namespace
|
||||
// return an empty namespace and the name unchanged.
|
||||
func (s *ResponsesToolState) ResponsesToolName(upstreamName string) (string, string) {
|
||||
if s == nil {
|
||||
return "", upstreamName
|
||||
}
|
||||
namespace, ok := s.Namespaces[upstreamName]
|
||||
if !ok {
|
||||
return "", upstreamName
|
||||
}
|
||||
return namespace, strings.TrimPrefix(upstreamName, namespace+"__")
|
||||
}
|
||||
|
||||
const (
|
||||
LastMessageTypeNone = "none"
|
||||
LastMessageTypeText = "text"
|
||||
@@ -101,6 +166,7 @@ type Values struct {
|
||||
EstimatePromptTokens int
|
||||
|
||||
ClaudeConvertInfo *ClaudeConvertInfo
|
||||
ResponsesTools *ResponsesToolState
|
||||
SendResponseCount int
|
||||
ConversionChain []types.RelayFormat
|
||||
|
||||
@@ -182,6 +248,19 @@ func (v *Values) EnsureClaudeConvertInfo() *ClaudeConvertInfo {
|
||||
return v.ClaudeConvertInfo
|
||||
}
|
||||
|
||||
func (v *Values) ResponsesToolState() *ResponsesToolState {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return v.ResponsesTools
|
||||
}
|
||||
|
||||
func (v *Values) SetResponsesToolState(state *ResponsesToolState) {
|
||||
if v != nil {
|
||||
v.ResponsesTools = state
|
||||
}
|
||||
}
|
||||
|
||||
func (v *Values) GetSendResponseCount() int {
|
||||
if v == nil {
|
||||
return 0
|
||||
@@ -240,6 +319,14 @@ func OptionsOf(m Meta) *Options {
|
||||
return m.ConvOptions()
|
||||
}
|
||||
|
||||
// ResponsesToolStateOf is a nil-safe reader for Meta.ResponsesToolState.
|
||||
func ResponsesToolStateOf(m Meta) *ResponsesToolState {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return m.ResponsesToolState()
|
||||
}
|
||||
|
||||
// ReasoningStateOf is a nil-safe reader for Meta.ReasoningState.
|
||||
func ReasoningStateOf(m Meta) *dto.ReasoningConversionState {
|
||||
if m == nil {
|
||||
|
||||
@@ -23,6 +23,12 @@ type Options struct {
|
||||
// it from the channel type.
|
||||
OpenRouterDialect bool
|
||||
|
||||
// WebSearch encodes hosted web search for the upstream dialect. Nil, or a
|
||||
// nil result, keeps the default: web_search_options on Chat and the native
|
||||
// hosted tool on Responses, Claude, and Gemini. The host sets it from the
|
||||
// channel adaptor's Init.
|
||||
WebSearch WebSearchEncoder
|
||||
|
||||
// PreserveThinkingSuffix reports models whose -thinking/-nothinking/effort
|
||||
// suffix must be kept on the outgoing model name (host blacklist lookup).
|
||||
// Nil means "never preserve".
|
||||
@@ -34,9 +40,13 @@ type Options struct {
|
||||
}
|
||||
|
||||
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 is the host thinking-suffix adapter toggle. The
|
||||
// host passes it to reasoning.ParseClaudeModelSuffix, so -thinking,
|
||||
// -nothinking, -thinking-<budget>, and effort tails such as -high on
|
||||
// claude-* names become reasoning intent only while it is on; when off the
|
||||
// model name is sent verbatim. Explicit @ modifiers are unaffected. 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.
|
||||
@@ -57,9 +67,12 @@ type ClaudeOptions struct {
|
||||
}
|
||||
|
||||
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 is the host thinking-suffix adapter toggle. The
|
||||
// host and gemini_chat request conversion pass it to
|
||||
// reasoning.ParseGeminiModelSuffix, so -thinking, -nothinking,
|
||||
// -thinking-<budget>, and effort tails such as -high on gemini-* names
|
||||
// become reasoning intent only while it is on; when off the model name is
|
||||
// sent verbatim. Explicit @ modifiers are unaffected.
|
||||
ThinkingAdapterEnabled bool
|
||||
// ThinkingAdapterBudgetTokensPercentage sizes thinkingBudget as a fraction
|
||||
// of maxOutputTokens when the adapter fires.
|
||||
@@ -70,6 +83,12 @@ type GeminiOptions struct {
|
||||
// SupportsImagine reports whether the model supports image generation
|
||||
// (switches response modalities). Nil means "never".
|
||||
SupportsImagine func(modelName string) bool
|
||||
// SupportsJSONSchema reports whether the upstream accepts JSON Schema in
|
||||
// parametersJsonSchema and responseJsonSchema for the model. A schema that
|
||||
// Gemini's typed Schema cannot hold without loss is then sent there
|
||||
// unchanged; otherwise it is cleaned to the typed Schema and the loss is
|
||||
// reported as a diagnostic. Nil means "never".
|
||||
SupportsJSONSchema 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
|
||||
@@ -86,6 +105,10 @@ func (o *GeminiOptions) SupportsImagineModel(modelName string) bool {
|
||||
return o != nil && o.SupportsImagine != nil && o.SupportsImagine(modelName)
|
||||
}
|
||||
|
||||
func (o *GeminiOptions) SupportsJSONSchemaModel(modelName string) bool {
|
||||
return o != nil && o.SupportsJSONSchema != nil && o.SupportsJSONSchema(modelName)
|
||||
}
|
||||
|
||||
func (o *GeminiOptions) SafetySettingFor(category string) string {
|
||||
if o == nil || o.SafetySetting == nil {
|
||||
return ""
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package convmeta
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
)
|
||||
|
||||
// WebSearch is the protocol-neutral hosted web-search specification decoded
|
||||
// from the source request.
|
||||
type WebSearch struct {
|
||||
Location *ApproximateLocation
|
||||
AllowedDomains []string
|
||||
BlockedDomains []string
|
||||
SearchContextSize string
|
||||
MaxUses *int
|
||||
AllowedCallers []string
|
||||
ResponseInclusion string
|
||||
ExternalWebAccess *bool
|
||||
ReturnTokenBudget json.RawMessage
|
||||
}
|
||||
|
||||
type ApproximateLocation struct {
|
||||
City string
|
||||
Region string
|
||||
Country string
|
||||
Timezone string
|
||||
}
|
||||
|
||||
// WebSearchCall is one hosted web-search definition about to be encoded for
|
||||
// the target request. Exactly one of Chat, Responses, Claude, Gemini is set;
|
||||
// the encoder may write top-level fields of that request directly.
|
||||
type WebSearchCall struct {
|
||||
Source types.RelayFormat
|
||||
Target types.RelayFormat
|
||||
// NativeType is the source tool type, for example web_search_preview,
|
||||
// web_search_20250305, or googleSearch.
|
||||
NativeType string
|
||||
// Index is the position in the source tool list. Chat Completions
|
||||
// carries one hosted web search, so a Chat encoder sees only the first
|
||||
// definition; the other targets call the encoder once per definition.
|
||||
Index int
|
||||
// Forced reports that the source tool_choice named the web-search tool.
|
||||
Forced bool
|
||||
Spec WebSearch
|
||||
|
||||
Chat *dto.GeneralOpenAIRequest
|
||||
Responses *dto.OpenAIResponsesRequest
|
||||
Claude *dto.ClaudeRequest
|
||||
Gemini *dto.GeminiChatRequest
|
||||
}
|
||||
|
||||
// WebSearchEncoding is what an encoder produced for one WebSearchCall.
|
||||
type WebSearchEncoding struct {
|
||||
// Tool is appended to the target tool list in the target protocol's own
|
||||
// shape. A Chat target requires a dto.ToolCallRequest (any other type
|
||||
// fails the conversion); the others take a JSON-marshalable value (usually
|
||||
// map[string]any). Nil means the encoder wrote top-level request fields
|
||||
// instead.
|
||||
Tool any
|
||||
// Omitted reports that no hosted search reaches the upstream. relaykit
|
||||
// records hosted_web_search_omitted (error severity) so ToolLossPolicy
|
||||
// can reject it, and appends no tool.
|
||||
Omitted bool
|
||||
// Diagnostics are merged into the conversion result. Error-severity
|
||||
// diagnostics count as semantic loss: under the channel's "safe" or
|
||||
// "strict" ToolLossPolicy they reject the request, so return
|
||||
// ConversionDiagnosticError only when the search behavior the client
|
||||
// asked for is actually lost, and ConversionDiagnosticWarning for tuning
|
||||
// that does not change whether or what is searched.
|
||||
Diagnostics []types.ConversionDiagnostic
|
||||
}
|
||||
|
||||
// WebSearchEncoder encodes hosted web search for an upstream whose dialect
|
||||
// differs from the target protocol's default. A nil result keeps relaykit's
|
||||
// default encoding; an error fails the conversion and is reserved for
|
||||
// encodings that cannot be produced at all, never for ordinary loss.
|
||||
type WebSearchEncoder func(call WebSearchCall) (*WebSearchEncoding, error)
|
||||
|
||||
// Path is the diagnostic path of the definition in the source tool list.
|
||||
func (c WebSearchCall) Path() string {
|
||||
return fmt.Sprintf("tools[%d]", c.Index)
|
||||
}
|
||||
|
||||
// SemanticLoss builds an error-severity diagnostic for this definition.
|
||||
func (c WebSearchCall) SemanticLoss(code, message string) types.ConversionDiagnostic {
|
||||
return types.ConversionDiagnostic{Code: code, Path: c.Path(), Message: message, Severity: types.ConversionDiagnosticError}
|
||||
}
|
||||
|
||||
// PresentationLoss builds a warning-severity diagnostic for this definition.
|
||||
func (c WebSearchCall) PresentationLoss(code, message string) types.ConversionDiagnostic {
|
||||
return types.ConversionDiagnostic{Code: code, Path: c.Path(), Message: message, Severity: types.ConversionDiagnosticWarning}
|
||||
}
|
||||
@@ -7,8 +7,10 @@ import (
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -154,7 +156,13 @@ func ClaudeMessagesRequestToOpenAIChat(ctx context.Context, claudeRequest dto.Cl
|
||||
}
|
||||
}
|
||||
|
||||
for _, claudeMessage := range claudeRequest.Messages {
|
||||
type unnamedToolResult struct {
|
||||
index int
|
||||
id string
|
||||
}
|
||||
toolNames := make(map[string]string)
|
||||
var unnamedToolResults []unnamedToolResult
|
||||
for messageIndex, claudeMessage := range claudeRequest.Messages {
|
||||
openAIMessage := dto.Message{
|
||||
Role: claudeMessage.Role,
|
||||
}
|
||||
@@ -168,7 +176,10 @@ func ClaudeMessagesRequestToOpenAIChat(ctx context.Context, claudeRequest dto.Cl
|
||||
var toolCalls []dto.ToolCallRequest
|
||||
mediaMessages := make([]dto.MediaContent, 0, len(content))
|
||||
|
||||
for _, mediaMsg := range content {
|
||||
for blockIndex, mediaMsg := range content {
|
||||
if _, exists := toolNames[mediaMsg.Id]; !exists {
|
||||
toolNames[mediaMsg.Id] = mediaMsg.Name
|
||||
}
|
||||
switch mediaMsg.Type {
|
||||
case "text", "input_text":
|
||||
message := dto.MediaContent{
|
||||
@@ -178,12 +189,50 @@ func ClaudeMessagesRequestToOpenAIChat(ctx context.Context, claudeRequest dto.Cl
|
||||
}
|
||||
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},
|
||||
url := claudeImageSourceURL(mediaMsg.Source)
|
||||
if url == "" {
|
||||
sourceType := ""
|
||||
if mediaMsg.Source != nil {
|
||||
sourceType = mediaMsg.Source.Type
|
||||
}
|
||||
convdiag.Add(ctx, types.ConversionDiagnostic{
|
||||
Code: "unsupported_media_source",
|
||||
Path: fmt.Sprintf("messages[%d].content[%d].source", messageIndex, blockIndex),
|
||||
Message: fmt.Sprintf("OpenAI Chat Completions cannot reference a Claude image with source type %q; the image was omitted", sourceType),
|
||||
Severity: types.ConversionDiagnosticError,
|
||||
})
|
||||
continue
|
||||
}
|
||||
mediaMessages = append(mediaMessages, mediaMessage)
|
||||
mediaMessages = append(mediaMessages, dto.MediaContent{
|
||||
Type: "image_url",
|
||||
ImageUrl: &dto.MessageImageUrl{Url: url},
|
||||
})
|
||||
case "document":
|
||||
if mediaMsg.Source != nil && mediaMsg.Source.Type == "text" {
|
||||
// A plain-text document keeps its content as a Chat text part.
|
||||
mediaMessages = append(mediaMessages, dto.MediaContent{Type: "text", Text: kitutil.Interface2String(mediaMsg.Source.Data)})
|
||||
continue
|
||||
}
|
||||
if mediaMsg.Source == nil || mediaMsg.Source.Type != "base64" || mediaMsg.Source.MediaType != "application/pdf" {
|
||||
sourceType, mediaType := "", ""
|
||||
if mediaMsg.Source != nil {
|
||||
sourceType, mediaType = mediaMsg.Source.Type, mediaMsg.Source.MediaType
|
||||
}
|
||||
convdiag.Add(ctx, types.ConversionDiagnostic{
|
||||
Code: "unsupported_media_source",
|
||||
Path: fmt.Sprintf("messages[%d].content[%d].source", messageIndex, blockIndex),
|
||||
Message: fmt.Sprintf("OpenAI Chat Completions carries only base64 PDF and plain-text documents, not a %q source of type %q; the document was omitted", sourceType, mediaType),
|
||||
Severity: types.ConversionDiagnosticError,
|
||||
})
|
||||
continue
|
||||
}
|
||||
mediaMessages = append(mediaMessages, dto.MediaContent{
|
||||
Type: dto.ContentTypeFile,
|
||||
File: &dto.MessageFile{
|
||||
FileName: "document.pdf",
|
||||
FileData: "data:application/pdf;base64," + kitutil.Interface2String(mediaMsg.Source.Data),
|
||||
},
|
||||
})
|
||||
case "tool_use":
|
||||
toolCall := dto.ToolCallRequest{
|
||||
ID: mediaMsg.Id,
|
||||
@@ -197,7 +246,7 @@ func ClaudeMessagesRequestToOpenAIChat(ctx context.Context, claudeRequest dto.Cl
|
||||
case "tool_result":
|
||||
toolName := mediaMsg.Name
|
||||
if toolName == "" {
|
||||
toolName = claudeRequest.SearchToolNameByToolCallId(mediaMsg.ToolUseId)
|
||||
unnamedToolResults = append(unnamedToolResults, unnamedToolResult{index: len(openAIMessages), id: mediaMsg.ToolUseId})
|
||||
}
|
||||
oaiToolMessage := dto.Message{
|
||||
Role: "tool",
|
||||
@@ -207,9 +256,12 @@ func ClaudeMessagesRequestToOpenAIChat(ctx context.Context, claudeRequest dto.Cl
|
||||
if mediaMsg.IsStringContent() {
|
||||
oaiToolMessage.SetStringContent(mediaMsg.GetStringContent())
|
||||
} else {
|
||||
mediaContents := mediaMsg.ParseMediaContent()
|
||||
encodedJSON, _ := kitutil.Marshal(mediaContents)
|
||||
oaiToolMessage.SetStringContent(string(encodedJSON))
|
||||
content, media := claudeToolResultToChat(mediaMsg.ParseMediaContent())
|
||||
oaiToolMessage.SetStringContent(content)
|
||||
// A Chat tool message only carries text. Images from the tool result
|
||||
// join the user message this Claude message becomes, which lands right
|
||||
// after the tool batch and keeps the tool messages contiguous.
|
||||
mediaMessages = append(mediaMessages, media...)
|
||||
}
|
||||
openAIMessages = append(openAIMessages, oaiToolMessage)
|
||||
}
|
||||
@@ -218,7 +270,7 @@ func ClaudeMessagesRequestToOpenAIChat(ctx context.Context, claudeRequest dto.Cl
|
||||
if len(toolCalls) > 0 {
|
||||
openAIMessage.SetToolCalls(toolCalls)
|
||||
}
|
||||
if len(mediaMessages) > 0 && len(toolCalls) == 0 {
|
||||
if len(mediaMessages) > 0 {
|
||||
openAIMessage.SetMediaContent(mediaMessages)
|
||||
}
|
||||
}
|
||||
@@ -226,11 +278,66 @@ func ClaudeMessagesRequestToOpenAIChat(ctx context.Context, claudeRequest dto.Cl
|
||||
openAIMessages = append(openAIMessages, openAIMessage)
|
||||
}
|
||||
}
|
||||
for _, result := range unnamedToolResults {
|
||||
*openAIMessages[result.index].Name = toolNames[result.id]
|
||||
}
|
||||
|
||||
openAIRequest.Messages = openAIMessages
|
||||
return &openAIRequest, nil
|
||||
}
|
||||
|
||||
// claudeToolResultToChat maps a structured tool_result content array onto Chat Completions,
|
||||
// where a tool message may only carry text. Text blocks stay on the tool message and image
|
||||
// blocks come back in Chat shape for the caller to place on the following user message;
|
||||
// stringifying them instead would hand base64 image data to the upstream text tokenizer.
|
||||
// Arrays holding any other block type keep the historical stringified form so nothing is lost.
|
||||
func claudeToolResultToChat(blocks []dto.ClaudeMediaMessage) (string, []dto.MediaContent) {
|
||||
texts := make([]string, 0, len(blocks))
|
||||
media := make([]dto.MediaContent, 0, len(blocks))
|
||||
for _, block := range blocks {
|
||||
switch {
|
||||
case block.Type == "text" || block.Type == "input_text":
|
||||
if text := block.GetText(); text != "" {
|
||||
texts = append(texts, text)
|
||||
}
|
||||
case block.Type == "image":
|
||||
url := claudeImageSourceURL(block.Source)
|
||||
if url == "" {
|
||||
return requestToJSONString(blocks), nil
|
||||
}
|
||||
media = append(media, dto.MediaContent{Type: "image_url", ImageUrl: &dto.MessageImageUrl{Url: url}})
|
||||
default:
|
||||
return requestToJSONString(blocks), nil
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(texts) == 0 && len(media) == 0:
|
||||
return requestToJSONString(blocks), nil
|
||||
case len(texts) == 0:
|
||||
// Upstreams reject empty tool content; the images ride on the following user message.
|
||||
return "[image]", media
|
||||
default:
|
||||
return strings.Join(texts, "\n"), media
|
||||
}
|
||||
}
|
||||
|
||||
// claudeImageSourceURL is the Chat image_url for a Claude image source: a data
|
||||
// URL for base64 data or the URL of a url source. Other sources, such as Files
|
||||
// API references, have no Chat equivalent and yield "".
|
||||
func claudeImageSourceURL(source *dto.ClaudeMessageSource) string {
|
||||
if source == nil || source.Type == "file" {
|
||||
return ""
|
||||
}
|
||||
if source.Url != "" {
|
||||
return source.Url
|
||||
}
|
||||
data := kitutil.Interface2String(source.Data)
|
||||
if data == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("data:%s;base64,%s", source.MediaType, data)
|
||||
}
|
||||
|
||||
func requestToJSONString(v any) string {
|
||||
b, err := kitutil.Marshal(v)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package claudemessages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestClaudeMessagesRequestToOpenAIChatToolResultContent(t *testing.T) {
|
||||
const imageData = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
const dataURL = "data:image/png;base64," + imageData
|
||||
imageBlock := dto.ClaudeMediaMessage{
|
||||
Type: "image",
|
||||
Source: &dto.ClaudeMessageSource{Type: "base64", MediaType: "image/png", Data: imageData},
|
||||
}
|
||||
textBlock := func(text string) dto.ClaudeMediaMessage {
|
||||
return dto.ClaudeMediaMessage{Type: "text", Text: lo.ToPtr(text)}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
content any
|
||||
wantContent string
|
||||
wantImages []string
|
||||
}{
|
||||
{
|
||||
name: "string passes through",
|
||||
content: "done",
|
||||
wantContent: "done",
|
||||
},
|
||||
{
|
||||
name: "text and image keep text on tool message",
|
||||
content: []dto.ClaudeMediaMessage{textBlock("screenshot taken"), imageBlock},
|
||||
wantContent: "screenshot taken",
|
||||
wantImages: []string{dataURL},
|
||||
},
|
||||
{
|
||||
name: "image only uses placeholder",
|
||||
content: []dto.ClaudeMediaMessage{imageBlock},
|
||||
wantContent: "[image]",
|
||||
wantImages: []string{dataURL},
|
||||
},
|
||||
{
|
||||
name: "url image source is forwarded as is",
|
||||
content: []dto.ClaudeMediaMessage{{
|
||||
Type: "image",
|
||||
Source: &dto.ClaudeMessageSource{Type: "url", Url: "https://example.com/shot.png"},
|
||||
}},
|
||||
wantContent: "[image]",
|
||||
wantImages: []string{"https://example.com/shot.png"},
|
||||
},
|
||||
{
|
||||
name: "text blocks join with newline",
|
||||
content: []dto.ClaudeMediaMessage{textBlock("first"), textBlock(""), textBlock("second")},
|
||||
wantContent: "first\nsecond",
|
||||
},
|
||||
{
|
||||
name: "unknown block keeps whole array",
|
||||
content: []dto.ClaudeMediaMessage{textBlock("see doc"), {Type: "document", Data: "opaque"}},
|
||||
wantContent: `[{"type":"text","text":"see doc"},{"type":"document","data":"opaque"}]`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ClaudeMessagesRequestToOpenAIChat(context.Background(), dto.ClaudeRequest{
|
||||
Model: "claude-test",
|
||||
MaxTokens: lo.ToPtr(uint(64)),
|
||||
Messages: []dto.ClaudeMessage{
|
||||
{Role: "user", Content: "take a screenshot"},
|
||||
{Role: "assistant", Content: []dto.ClaudeMediaMessage{
|
||||
{Type: "tool_use", Id: "toolu_1", Name: "screenshot", Input: map[string]any{}},
|
||||
}},
|
||||
{Role: "user", Content: []dto.ClaudeMediaMessage{
|
||||
{Type: "tool_result", ToolUseId: "toolu_1", Content: tt.content},
|
||||
textBlock("what do you see?"),
|
||||
}},
|
||||
},
|
||||
}, &convmeta.Values{})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, got.Messages, 4)
|
||||
tool := got.Messages[2]
|
||||
assert.Equal(t, "tool", tool.Role)
|
||||
assert.Equal(t, "toolu_1", tool.ToolCallId)
|
||||
if tt.wantImages == nil && tt.wantContent[0] == '[' {
|
||||
assert.JSONEq(t, tt.wantContent, tool.StringContent())
|
||||
} else {
|
||||
assert.Equal(t, tt.wantContent, tool.StringContent())
|
||||
}
|
||||
|
||||
user := got.Messages[3]
|
||||
assert.Equal(t, "user", user.Role)
|
||||
parts := user.ParseContent()
|
||||
require.Len(t, parts, len(tt.wantImages)+1)
|
||||
for i, wantURL := range tt.wantImages {
|
||||
assert.Equal(t, dto.ContentTypeImageURL, parts[i].Type)
|
||||
require.NotNil(t, parts[i].GetImageMedia())
|
||||
assert.Equal(t, wantURL, parts[i].GetImageMedia().Url)
|
||||
}
|
||||
assert.Equal(t, dto.ContentTypeText, parts[len(parts)-1].Type)
|
||||
assert.Equal(t, "what do you see?", parts[len(parts)-1].Text)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeMessagesRequestToOpenAIChatKeepsParallelToolResultsContiguous(t *testing.T) {
|
||||
const dataURL = "data:image/png;base64,iVBORw0KGgo="
|
||||
got, err := ClaudeMessagesRequestToOpenAIChat(context.Background(), dto.ClaudeRequest{
|
||||
Model: "claude-test",
|
||||
MaxTokens: lo.ToPtr(uint(64)),
|
||||
Messages: []dto.ClaudeMessage{
|
||||
{Role: "user", Content: "screenshot and read the note"},
|
||||
{Role: "assistant", Content: []dto.ClaudeMediaMessage{
|
||||
{Type: "text", Text: lo.ToPtr("Let me look.")},
|
||||
{Type: "tool_use", Id: "toolu_shot", Name: "screenshot", Input: map[string]any{}},
|
||||
{Type: "tool_use", Id: "toolu_read", Name: "read_file", Input: map[string]any{"path": "note.txt"}},
|
||||
}},
|
||||
{Role: "user", Content: []dto.ClaudeMediaMessage{
|
||||
{Type: "tool_result", ToolUseId: "toolu_shot", Content: []dto.ClaudeMediaMessage{{
|
||||
Type: "image",
|
||||
Source: &dto.ClaudeMessageSource{Type: "base64", MediaType: "image/png", Data: "iVBORw0KGgo="},
|
||||
}}},
|
||||
{Type: "tool_result", ToolUseId: "toolu_read", Content: "note contents"},
|
||||
}},
|
||||
},
|
||||
}, &convmeta.Values{})
|
||||
require.NoError(t, err)
|
||||
|
||||
roles := make([]string, 0, len(got.Messages))
|
||||
for _, message := range got.Messages {
|
||||
roles = append(roles, message.Role)
|
||||
}
|
||||
assert.Equal(t, []string{"user", "assistant", "tool", "tool", "user"}, roles)
|
||||
assert.Len(t, got.Messages[1].ParseToolCalls(), 2)
|
||||
assert.Equal(t, "[image]", got.Messages[2].StringContent())
|
||||
assert.Equal(t, "toolu_shot", got.Messages[2].ToolCallId)
|
||||
assert.Equal(t, "note contents", got.Messages[3].StringContent())
|
||||
assert.NotContains(t, got.Messages[2].StringContent()+got.Messages[3].StringContent(), "base64")
|
||||
|
||||
parts := got.Messages[4].ParseContent()
|
||||
require.Len(t, parts, 1)
|
||||
assert.Equal(t, dto.ContentTypeImageURL, parts[0].Type)
|
||||
require.NotNil(t, parts[0].GetImageMedia())
|
||||
assert.Equal(t, dataURL, parts[0].GetImageMedia().Url)
|
||||
}
|
||||
@@ -19,6 +19,8 @@ type ClaudeResponseInfo struct {
|
||||
Created int64
|
||||
Model string
|
||||
ResponseText strings.Builder
|
||||
// ToolUseCount counts streamed tool_use blocks for the output-token fallback.
|
||||
ToolUseCount int
|
||||
Usage *dto.Usage
|
||||
Done bool
|
||||
|
||||
@@ -524,6 +526,9 @@ func FormatClaudeResponseInfo(claudeResponse *dto.ClaudeResponse, oaiResponse *d
|
||||
if claudeResponse.Delta.Thinking != nil {
|
||||
claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Thinking)
|
||||
}
|
||||
if claudeResponse.Delta.PartialJson != nil {
|
||||
claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.PartialJson)
|
||||
}
|
||||
}
|
||||
} else if claudeResponse.Type == "message_delta" {
|
||||
if claudeResponse.Usage != nil {
|
||||
@@ -552,6 +557,11 @@ func FormatClaudeResponseInfo(claudeResponse *dto.ClaudeResponse, oaiResponse *d
|
||||
|
||||
claudeInfo.Done = true
|
||||
} else if claudeResponse.Type == "content_block_start" {
|
||||
// Tool calls are most of an agent turn's output; count them for usage fallback.
|
||||
if block := claudeResponse.ContentBlock; block != nil && block.Type == "tool_use" {
|
||||
claudeInfo.ResponseText.WriteString(block.Name)
|
||||
claudeInfo.ToolUseCount++
|
||||
}
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
|
||||
relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
|
||||
)
|
||||
@@ -177,6 +178,7 @@ func claudeMessagesToResponsesInput(messages []dto.ClaudeMessage) (json.RawMessa
|
||||
contentParts = append(contentParts, map[string]any{
|
||||
"type": "input_image",
|
||||
"image_url": source,
|
||||
"detail": relaymedia.DefaultResponsesImageDetail,
|
||||
})
|
||||
}
|
||||
case "document":
|
||||
@@ -298,7 +300,11 @@ func claudeToolResultToResponsesOutput(content any) (any, error) {
|
||||
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})
|
||||
parts = append(parts, map[string]any{
|
||||
"type": "input_image",
|
||||
"image_url": source,
|
||||
"detail": relaymedia.DefaultResponsesImageDetail,
|
||||
})
|
||||
}
|
||||
case "document":
|
||||
if source := claudeSourceURL(block.Source); source != "" {
|
||||
|
||||
@@ -9,8 +9,10 @@ import (
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/jsonutil"
|
||||
relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
|
||||
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 GeminiGenerateContentRequestToOpenAIChat(ctx context.Context, geminiRequest *dto.GeminiChatRequest, info convmeta.Meta) (*dto.GeneralOpenAIRequest, error) {
|
||||
@@ -72,7 +74,7 @@ func GeminiGenerateContentRequestToOpenAIChat(ctx context.Context, geminiRequest
|
||||
|
||||
callHistory := newGeminiFunctionCallHistory(geminiRequest.Contents)
|
||||
var messages []dto.Message
|
||||
for _, content := range geminiRequest.Contents {
|
||||
for contentIndex, content := range geminiRequest.Contents {
|
||||
message := dto.Message{
|
||||
Role: convertGeminiRoleToOpenAI(content.Role),
|
||||
}
|
||||
@@ -80,7 +82,7 @@ func GeminiGenerateContentRequestToOpenAIChat(ctx context.Context, geminiRequest
|
||||
var mediaContents []dto.MediaContent
|
||||
var toolCalls []dto.ToolCallRequest
|
||||
var reasoningTexts []string
|
||||
for _, part := range content.Parts {
|
||||
for partIndex, part := range content.Parts {
|
||||
if part.Text != "" {
|
||||
if part.Thought {
|
||||
reasoningTexts = append(reasoningTexts, part.Text)
|
||||
@@ -91,24 +93,20 @@ func GeminiGenerateContentRequestToOpenAIChat(ctx context.Context, geminiRequest
|
||||
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,
|
||||
},
|
||||
} else if part.InlineData != nil || part.FileData != nil {
|
||||
mediaContent, reason := geminiMediaToChatContent(part.InlineData, part.FileData)
|
||||
if reason != "" {
|
||||
kind := "inlineData"
|
||||
if part.InlineData == nil {
|
||||
kind = "fileData"
|
||||
}
|
||||
convdiag.Add(ctx, types.ConversionDiagnostic{
|
||||
Code: "unsupported_media_type",
|
||||
Path: fmt.Sprintf("contents[%d].parts[%d].%s", contentIndex, partIndex, kind),
|
||||
Message: reason + "; the part was omitted",
|
||||
Severity: types.ConversionDiagnosticError,
|
||||
})
|
||||
continue
|
||||
}
|
||||
mediaContents = append(mediaContents, mediaContent)
|
||||
} else if part.FunctionCall != nil {
|
||||
@@ -208,6 +206,71 @@ func GeminiGenerateContentRequestToOpenAIChat(ctx context.Context, geminiRequest
|
||||
return openaiRequest, nil
|
||||
}
|
||||
|
||||
// chatImageMediaTypes are the image formats Chat Completions accepts.
|
||||
var chatImageMediaTypes = map[string]struct{}{
|
||||
"image/png": {},
|
||||
"image/jpeg": {},
|
||||
"image/webp": {},
|
||||
"image/gif": {},
|
||||
}
|
||||
|
||||
// geminiMediaToChatContent maps Gemini inlineData or fileData onto the Chat
|
||||
// content part for its MIME type: PNG, JPEG, WebP, or GIF images inline or by
|
||||
// URL, PDF as an inline file, text files as text, and WAV or MP3 as inline
|
||||
// audio. Whether the model accepts the part is left to the upstream. A
|
||||
// non-empty reason means the media has no Chat content part.
|
||||
func geminiMediaToChatContent(inlineData *dto.GeminiInlineData, fileData *dto.GeminiFileData) (dto.MediaContent, string) {
|
||||
if inlineData == nil {
|
||||
mimeType := strings.ToLower(strings.TrimSpace(fileData.MimeType))
|
||||
if mimeType == "image/jpg" {
|
||||
mimeType = "image/jpeg"
|
||||
}
|
||||
if _, supported := chatImageMediaTypes[mimeType]; mimeType != "" && !supported {
|
||||
return dto.MediaContent{}, fmt.Sprintf("Chat Completions content cannot reference a Gemini file of MIME type %q", fileData.MimeType)
|
||||
}
|
||||
return dto.MediaContent{
|
||||
Type: dto.ContentTypeImageURL,
|
||||
ImageUrl: &dto.MessageImageUrl{Url: fileData.FileUri, Detail: "auto", MimeType: mimeType},
|
||||
}, ""
|
||||
}
|
||||
mimeType := strings.ToLower(strings.TrimSpace(inlineData.MimeType))
|
||||
if mimeType == "image/jpg" {
|
||||
mimeType = "image/jpeg"
|
||||
}
|
||||
if _, supported := chatImageMediaTypes[mimeType]; supported {
|
||||
return dto.MediaContent{
|
||||
Type: dto.ContentTypeImageURL,
|
||||
ImageUrl: &dto.MessageImageUrl{Url: fmt.Sprintf("data:%s;base64,%s", mimeType, inlineData.Data), Detail: "auto", MimeType: mimeType},
|
||||
}, ""
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(mimeType, "image/"):
|
||||
return dto.MediaContent{}, fmt.Sprintf("Chat Completions content accepts only PNG, JPEG, WebP, and GIF images, not %q", inlineData.MimeType)
|
||||
case mimeType == "application/pdf":
|
||||
return dto.MediaContent{
|
||||
Type: dto.ContentTypeFile,
|
||||
File: &dto.MessageFile{FileName: "document.pdf", FileData: "data:application/pdf;base64," + inlineData.Data},
|
||||
}, ""
|
||||
case relaymedia.IsTextMimeType(mimeType):
|
||||
text, ok := relaymedia.DecodeText(inlineData.Data)
|
||||
if !ok {
|
||||
return dto.MediaContent{}, fmt.Sprintf("Gemini inlineData of MIME type %q could not be decoded as UTF-8 text", inlineData.MimeType)
|
||||
}
|
||||
return dto.MediaContent{Type: dto.ContentTypeText, Text: text}, ""
|
||||
case mimeType == "audio/wav" || mimeType == "audio/x-wav" || mimeType == "audio/wave":
|
||||
return dto.MediaContent{
|
||||
Type: dto.ContentTypeInputAudio,
|
||||
InputAudio: &dto.MessageInputAudio{Data: inlineData.Data, Format: "wav"},
|
||||
}, ""
|
||||
case mimeType == "audio/mp3" || mimeType == "audio/mpeg":
|
||||
return dto.MediaContent{
|
||||
Type: dto.ContentTypeInputAudio,
|
||||
InputAudio: &dto.MessageInputAudio{Data: inlineData.Data, Format: "mp3"},
|
||||
}, ""
|
||||
}
|
||||
return dto.MediaContent{}, fmt.Sprintf("Gemini inlineData of MIME type %q has no Chat Completions content part", inlineData.MimeType)
|
||||
}
|
||||
|
||||
type geminiPendingFunctionCall struct {
|
||||
id string
|
||||
name string
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
)
|
||||
|
||||
@@ -44,3 +47,37 @@ func DecodeBase64FileData(base64String string) (string, string, error) {
|
||||
}
|
||||
return resolver(base64String)
|
||||
}
|
||||
|
||||
// DefaultResponsesImageDetail is used when the source protocol carries no detail
|
||||
// hint. Sending the default explicitly also supports strict Responses validators
|
||||
// that reject input_image parts without detail.
|
||||
const DefaultResponsesImageDetail = "auto"
|
||||
|
||||
// ResponsesImageDetail preserves supported image detail levels and defaults
|
||||
// missing or unrecognized hints to "auto".
|
||||
func ResponsesImageDetail(detail string) string {
|
||||
switch normalized := strings.ToLower(strings.TrimSpace(detail)); normalized {
|
||||
case "low", "high", "auto", "original":
|
||||
return normalized
|
||||
default:
|
||||
return DefaultResponsesImageDetail
|
||||
}
|
||||
}
|
||||
|
||||
// IsTextMimeType reports whether a MIME type is plain text that a target can
|
||||
// carry as text: text/* and application/json, ignoring parameters.
|
||||
func IsTextMimeType(mimeType string) bool {
|
||||
mediaType, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(mimeType)), ";")
|
||||
mediaType = strings.TrimSpace(mediaType)
|
||||
return strings.HasPrefix(mediaType, "text/") || mediaType == "application/json"
|
||||
}
|
||||
|
||||
// DecodeText decodes base64 file data as text. It reports false when the data
|
||||
// is not valid base64 or the bytes are not UTF-8.
|
||||
func DecodeText(base64Data string) (string, bool) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(base64Data))
|
||||
if err != nil || !utf8.Valid(decoded) {
|
||||
return "", false
|
||||
}
|
||||
return string(decoded), true
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package oaichat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"context"
|
||||
|
||||
@@ -13,6 +12,7 @@ import (
|
||||
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/QuantumNous/new-api/relaykit/types"
|
||||
)
|
||||
|
||||
func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, textRequest dto.GeneralOpenAIRequest) (*dto.ClaudeRequest, error) {
|
||||
@@ -265,24 +265,27 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te
|
||||
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())
|
||||
// Claude content blocks carry images, PDF documents, and text
|
||||
// documents; audio and video have no Claude block.
|
||||
reason := fmt.Sprintf("Claude Messages cannot carry %s content", mediaMessage.Type)
|
||||
var block dto.ClaudeMediaMessage
|
||||
if mediaMessage.Type != dto.ContentTypeInputAudio && mediaMessage.Type != dto.ContentTypeVideoUrl {
|
||||
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())
|
||||
}
|
||||
block, reason = sharedclaude.MediaBlock(base64Data, mimeType, mediaMessage.Type == dto.ContentTypeImageURL)
|
||||
}
|
||||
claudeMediaMessage := dto.ClaudeMediaMessage{
|
||||
Source: &dto.ClaudeMessageSource{
|
||||
Type: "base64",
|
||||
},
|
||||
if reason != "" {
|
||||
convdiag.Add(c, types.ConversionDiagnostic{
|
||||
Code: "unsupported_media_type",
|
||||
Path: "messages.content",
|
||||
Message: reason + "; the part was omitted",
|
||||
Severity: types.ConversionDiagnosticError,
|
||||
})
|
||||
continue
|
||||
}
|
||||
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)
|
||||
claudeMediaMessages = append(claudeMediaMessages, block)
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -303,6 +306,10 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(claudeMediaMessages) == 0 {
|
||||
// Every part was omitted; Claude rejects an empty content array.
|
||||
claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{Type: "text", Text: kitutil.GetPointer("...")})
|
||||
}
|
||||
claudeMessage.Content = claudeMediaMessages
|
||||
}
|
||||
claudeMessages = append(claudeMessages, claudeMessage)
|
||||
|
||||
@@ -241,9 +241,6 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
|
||||
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 {
|
||||
@@ -339,55 +336,58 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
|
||||
state.Index = state.ToolCallBaseIndex + len(state.ToolCalls) - 1
|
||||
}
|
||||
} else {
|
||||
// Some upstreams put the last reasoning token and the first answer
|
||||
// text in one chunk: the thinking block closes before the text block opens.
|
||||
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{
|
||||
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
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Index: kitutil.GetPointer[int](state.Index),
|
||||
Type: "content_block_delta",
|
||||
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{
|
||||
},
|
||||
})
|
||||
}
|
||||
if textContent != "" {
|
||||
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
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Index: kitutil.GetPointer[int](state.Index),
|
||||
Type: "content_block_delta",
|
||||
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 {
|
||||
|
||||
@@ -186,11 +186,8 @@ func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto
|
||||
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
|
||||
}
|
||||
sharedgemini.ApplyResponseSchema(c, &geminiRequest.GenerationConfig, textRequest.ResponseFormat.JsonSchema,
|
||||
"response_format.json_schema.schema", opts.Gemini.SupportsJSONSchemaModel(upstreamModelName))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,7 +336,7 @@ func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto
|
||||
return nil, fmt.Errorf("get file data from '%s' failed: %w", source.GetIdentifier(), err)
|
||||
}
|
||||
|
||||
if _, ok := sharedgemini.SupportedMimeTypes[strings.ToLower(mimeType)]; !ok {
|
||||
if !sharedgemini.IsSupportedMimeType(mimeType) {
|
||||
return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", mimeType, source.GetIdentifier(), sharedgemini.SupportedMimeTypesList())
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
|
||||
relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
|
||||
"github.com/samber/lo"
|
||||
@@ -227,9 +228,14 @@ func ChatCompletionsRequestToResponsesRequest(ctx context.Context, req *dto.Gene
|
||||
"text": part.Text,
|
||||
})
|
||||
case dto.ContentTypeImageURL:
|
||||
var sourceDetail string
|
||||
if img := part.GetImageMedia(); img != nil {
|
||||
sourceDetail = img.Detail
|
||||
}
|
||||
contentParts = append(contentParts, map[string]any{
|
||||
"type": "input_image",
|
||||
"image_url": normalizeChatImageURLToString(part.ImageUrl),
|
||||
"detail": relaymedia.ResponsesImageDetail(sourceDetail),
|
||||
})
|
||||
case dto.ContentTypeInputAudio:
|
||||
contentParts = append(contentParts, map[string]any{
|
||||
@@ -237,10 +243,20 @@ func ChatCompletionsRequestToResponsesRequest(ctx context.Context, req *dto.Gene
|
||||
"input_audio": part.InputAudio,
|
||||
})
|
||||
case dto.ContentTypeFile:
|
||||
contentParts = append(contentParts, map[string]any{
|
||||
"type": "input_file",
|
||||
"file": part.File,
|
||||
})
|
||||
// Responses carries the file fields on the input_file part itself.
|
||||
inputFile := map[string]any{"type": "input_file"}
|
||||
if file := part.GetFile(); file != nil {
|
||||
if file.FileName != "" {
|
||||
inputFile["filename"] = file.FileName
|
||||
}
|
||||
if file.FileData != "" {
|
||||
inputFile["file_data"] = file.FileData
|
||||
}
|
||||
if file.FileId != "" {
|
||||
inputFile["file_id"] = file.FileId
|
||||
}
|
||||
}
|
||||
contentParts = append(contentParts, inputFile)
|
||||
case dto.ContentTypeVideoUrl:
|
||||
contentParts = append(contentParts, map[string]any{
|
||||
"type": "input_video",
|
||||
|
||||
@@ -40,6 +40,37 @@ func TestChatCompletionsRequestToResponsesRequestInstructionsAndTools(t *testing
|
||||
assert.Equal(t, "function_call_output", gjson.GetBytes(got.Input, "3.type").String())
|
||||
}
|
||||
|
||||
func TestChatCompletionsRequestToResponsesRequestImageDetail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
imageURL any
|
||||
want string
|
||||
}{
|
||||
{name: "object without detail defaults to auto", imageURL: map[string]any{"url": "https://example.test/a.png"}, want: "auto"},
|
||||
{name: "low detail is preserved", imageURL: map[string]any{"url": "https://example.test/a.png", "detail": "low"}, want: "low"},
|
||||
{name: "high detail is preserved", imageURL: map[string]any{"url": "https://example.test/a.png", "detail": "high"}, want: "high"},
|
||||
{name: "auto detail is preserved", imageURL: map[string]any{"url": "https://example.test/a.png", "detail": "auto"}, want: "auto"},
|
||||
{name: "original detail is preserved", imageURL: map[string]any{"url": "https://example.test/a.png", "detail": "original"}, want: "original"},
|
||||
{name: "unknown detail falls back to auto", imageURL: map[string]any{"url": "https://example.test/a.png", "detail": "bogus"}, want: "auto"},
|
||||
{name: "plain string url defaults to auto", imageURL: "https://example.test/a.png", want: "auto"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ChatCompletionsRequestToResponsesRequest(context.Background(), &dto.GeneralOpenAIRequest{
|
||||
Model: "gpt-test",
|
||||
Messages: []dto.Message{{Role: "user", Content: []any{
|
||||
map[string]any{"type": "image_url", "image_url": tt.imageURL},
|
||||
}}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "input_image", gjson.GetBytes(got.Input, "0.content.0.type").String())
|
||||
assert.Equal(t, tt.want, gjson.GetBytes(got.Input, "0.content.0.detail").String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsRequestToResponsesRequestPreservesPromptCacheKey(t *testing.T) {
|
||||
t.Run("present", func(t *testing.T) {
|
||||
key := "session-\"quoted\"\\path\n世界"
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
)
|
||||
|
||||
@@ -24,9 +25,14 @@ const (
|
||||
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"
|
||||
responsesEventReasoningSummaryPartAdded = "response.reasoning_summary_part.added"
|
||||
responsesEventReasoningSummaryDelta = "response.reasoning_summary_text.delta"
|
||||
responsesEventReasoningSummaryDone = "response.reasoning_summary_text.done"
|
||||
responsesEventReasoningSummaryPartDone = "response.reasoning_summary_part.done"
|
||||
responsesOutputTypeFunctionCall = "function_call"
|
||||
responsesOutputTypeCustomToolCall = "custom_tool_call"
|
||||
responsesOutputTypeMessage = "message"
|
||||
responsesOutputTypeReasoning = "reasoning"
|
||||
responsesIncompleteReasonContentFilter = "content_filter"
|
||||
@@ -34,6 +40,12 @@ const (
|
||||
)
|
||||
|
||||
func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id string) (*dto.OpenAIResponsesResponse, *dto.Usage, error) {
|
||||
return ChatCompletionsResponseToResponsesResponseWithTools(resp, id, nil)
|
||||
}
|
||||
|
||||
// ChatCompletionsResponseToResponsesResponseWithTools also restores function
|
||||
// calls that tools marks as Responses custom tools into custom_tool_call items.
|
||||
func ChatCompletionsResponseToResponsesResponseWithTools(resp *dto.OpenAITextResponse, id string, tools *convmeta.ResponsesToolState) (*dto.OpenAIResponsesResponse, *dto.Usage, error) {
|
||||
if resp == nil {
|
||||
return nil, nil, errors.New("response is nil")
|
||||
}
|
||||
@@ -93,7 +105,10 @@ func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id
|
||||
}
|
||||
|
||||
for i, toolCall := range choice.Message.ParseToolCalls() {
|
||||
toolOutput, err := chatToolCallToResponsesOutput(toolCall, id, i, responseOutputStatus(out))
|
||||
if (toolCall.Type == "" || toolCall.Type == "function") && strings.TrimSpace(toolCall.Function.Name) == "" {
|
||||
continue
|
||||
}
|
||||
toolOutput, err := chatToolCallToResponsesOutput(toolCall, id, i, responseOutputStatus(out), tools)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -202,18 +217,31 @@ func responseStatusString(resp *dto.OpenAIResponsesResponse) string {
|
||||
return strings.TrimSpace(status)
|
||||
}
|
||||
|
||||
func chatToolCallToResponsesOutput(toolCall dto.ToolCallRequest, responseID string, index int, status string) (dto.ResponsesOutput, error) {
|
||||
func chatToolCallToResponsesOutput(toolCall dto.ToolCallRequest, responseID string, index int, status string, tools *convmeta.ResponsesToolState) (dto.ResponsesOutput, error) {
|
||||
callID := strings.TrimSpace(toolCall.ID)
|
||||
if callID == "" {
|
||||
callID = fmt.Sprintf("%s_call_%d", responseID, index)
|
||||
}
|
||||
if toolCall.Type == "" || toolCall.Type == "function" {
|
||||
namespace, name := tools.ResponsesToolName(toolCall.Function.Name)
|
||||
if tools.IsCustomTool(toolCall.Function.Name) {
|
||||
return dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeCustomToolCall,
|
||||
ID: callID,
|
||||
Status: status,
|
||||
CallId: callID,
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
Input: chatArgumentsRawMessage(customToolInputFromArguments(toolCall.Function.Arguments)),
|
||||
}, nil
|
||||
}
|
||||
return dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeFunctionCall,
|
||||
ID: callID,
|
||||
Status: status,
|
||||
CallId: callID,
|
||||
Name: toolCall.Function.Name,
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
Arguments: chatArgumentsRawMessage(toolCall.Function.Arguments),
|
||||
}, nil
|
||||
}
|
||||
@@ -226,6 +254,28 @@ func chatToolCallToResponsesOutput(toolCall dto.ToolCallRequest, responseID stri
|
||||
}, nil
|
||||
}
|
||||
|
||||
// customToolInputFromArguments unwraps the raw custom tool input from the
|
||||
// {"input": ...} arguments a Chat function call carries. Arguments of any other
|
||||
// shape are returned unchanged so the model's output is never dropped.
|
||||
func customToolInputFromArguments(arguments string) string {
|
||||
var value map[string]any
|
||||
if err := kitutil.UnmarshalJsonStr(arguments, &value); err != nil {
|
||||
return arguments
|
||||
}
|
||||
input, ok := value[convmeta.CustomToolInputArgument]
|
||||
if !ok {
|
||||
return arguments
|
||||
}
|
||||
if text, ok := input.(string); ok {
|
||||
return text
|
||||
}
|
||||
raw, err := kitutil.Marshal(input)
|
||||
if err != nil {
|
||||
return arguments
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func chatArgumentsRawMessage(arguments string) []byte {
|
||||
raw, err := kitutil.Marshal(arguments)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,8 @@ 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/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -41,6 +43,54 @@ func TestChatCompletionsResponseToResponsesPreservesTextToolCallsAndUsage(t *tes
|
||||
assert.Equal(t, `"{\"q\":\"x\"}"`, string(resp.Output[1].Arguments))
|
||||
}
|
||||
|
||||
func TestChatCompletionsResponseToResponsesDropsEmptyNameToolCalls(t *testing.T) {
|
||||
usage := dto.Usage{PromptTokens: 20, CompletionTokens: 5, TotalTokens: 25}
|
||||
usage.PromptTokensDetails.CachedTokens = 7
|
||||
usage.BillingUsage = dto.NewOpenAIChatBillingUsage(&usage)
|
||||
message := dto.Message{Role: "assistant"}
|
||||
message.SetToolCalls([]dto.ToolCallRequest{
|
||||
{
|
||||
ID: "call_invalid",
|
||||
Type: "function",
|
||||
Function: dto.FunctionRequest{
|
||||
Arguments: `{"ok":true}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "call_whitespace",
|
||||
Function: dto.FunctionRequest{Name: " \t\n", Arguments: `{}`},
|
||||
},
|
||||
{
|
||||
ID: "call_valid",
|
||||
Type: "function",
|
||||
Function: dto.FunctionRequest{
|
||||
Name: "lookup",
|
||||
Arguments: `{"q":"x"}`,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
resp, convertedUsage, err := ChatCompletionsResponseToResponsesResponse(&dto.OpenAITextResponse{
|
||||
Usage: usage,
|
||||
Choices: []dto.OpenAITextResponseChoice{{
|
||||
Message: message,
|
||||
FinishReason: "tool_calls",
|
||||
}},
|
||||
}, "resp_1")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, resp.Output, 1)
|
||||
assert.Equal(t, "call_valid", resp.Output[0].CallId)
|
||||
assert.Equal(t, "lookup", resp.Output[0].Name)
|
||||
assert.Equal(t, `"{\"q\":\"x\"}"`, string(resp.Output[0].Arguments))
|
||||
assert.Equal(t, 20, convertedUsage.InputTokens)
|
||||
assert.Equal(t, 5, convertedUsage.OutputTokens)
|
||||
assert.Equal(t, 25, convertedUsage.TotalTokens)
|
||||
require.NotNil(t, convertedUsage.InputTokensDetails)
|
||||
assert.Equal(t, 7, convertedUsage.InputTokensDetails.CachedTokens)
|
||||
assert.Equal(t, usage.BillingUsage, convertedUsage.BillingUsage)
|
||||
}
|
||||
|
||||
func TestChatCompletionsResponseToResponsesEmitsReasoningSummaryBeforeText(t *testing.T) {
|
||||
message := dto.Message{Role: "assistant", Content: "final answer"}
|
||||
message.ReasoningContent = lo.ToPtr("thinking summary")
|
||||
@@ -153,6 +203,366 @@ func TestChatCompletionsStreamToResponsesEventsAggregatesUsageAndToolArgs(t *tes
|
||||
assert.Equal(t, `"{\"q\":\"x\"}"`, string(events[9].Payload.Response.Output[1].Arguments))
|
||||
}
|
||||
|
||||
func TestChatCompletionsStreamToResponsesEmitsReasoningSummaryPartLifecycle(t *testing.T) {
|
||||
state := NewChatToResponsesStreamState("resp_1", "gpt-test")
|
||||
stop := "stop"
|
||||
|
||||
var events []ChatToResponsesStreamEvent
|
||||
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
|
||||
Id: "chatcmpl_1", Model: "gpt-test",
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{{
|
||||
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ReasoningContent: lo.ToPtr("think")},
|
||||
}},
|
||||
})...)
|
||||
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{{
|
||||
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: lo.ToPtr("answer")},
|
||||
FinishReason: &stop,
|
||||
}},
|
||||
})...)
|
||||
events = append(events, FinalizeChatCompletionsStreamToResponses(state)...)
|
||||
|
||||
types := make([]string, 0, len(events))
|
||||
for _, event := range events {
|
||||
types = append(types, event.Type)
|
||||
}
|
||||
assert.Equal(t, []string{
|
||||
responsesEventCreated,
|
||||
responsesEventOutputItemAdded,
|
||||
responsesEventReasoningSummaryPartAdded,
|
||||
responsesEventReasoningSummaryDelta,
|
||||
responsesEventOutputItemAdded,
|
||||
responsesEventOutputTextDelta,
|
||||
"response.output_text.done",
|
||||
responsesEventOutputItemDone,
|
||||
responsesEventReasoningSummaryDone,
|
||||
responsesEventReasoningSummaryPartDone,
|
||||
responsesEventOutputItemDone,
|
||||
responsesEventCompleted,
|
||||
}, types)
|
||||
|
||||
// The Responses schema requires summary on every reasoning item, including
|
||||
// the one announced before any summary text exists.
|
||||
itemAdded, err := kitutil.Marshal(events[1].Payload)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(itemAdded), `"summary":[]`)
|
||||
|
||||
partAdded := events[2].Payload
|
||||
assert.Equal(t, "resp_1_reasoning_0", partAdded.ItemID)
|
||||
assert.Equal(t, 0, lo.FromPtr(partAdded.OutputIndex))
|
||||
assert.Equal(t, 0, lo.FromPtr(partAdded.SummaryIndex))
|
||||
require.NotNil(t, partAdded.Part)
|
||||
assert.Equal(t, dto.ResponsesReasoningSummaryPart{Type: "summary_text"}, *partAdded.Part)
|
||||
|
||||
partDone := events[9].Payload
|
||||
assert.Equal(t, "resp_1_reasoning_0", partDone.ItemID)
|
||||
require.NotNil(t, partDone.Part)
|
||||
assert.Equal(t, dto.ResponsesReasoningSummaryPart{Type: "summary_text", Text: "think"}, *partDone.Part)
|
||||
}
|
||||
|
||||
func TestChatCompletionsStreamToResponsesReopensItemsAfterMidStreamFinishReason(t *testing.T) {
|
||||
for _, emitSequenceNumber := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "legacy", true: "sequence_numbers"}[emitSequenceNumber], func(t *testing.T) {
|
||||
state := NewChatToResponsesStreamState("resp_1", "gpt-test")
|
||||
state.EmitSequenceNumber = emitSequenceNumber
|
||||
toolIndex := 0
|
||||
toolCalls := "tool_calls"
|
||||
stop := "stop"
|
||||
|
||||
chunks := []*dto.ChatCompletionsStreamResponse{
|
||||
{Id: "chatcmpl_1", Model: "gpt-test", Choices: []dto.ChatCompletionsStreamResponseChoice{{
|
||||
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ReasoningContent: lo.ToPtr("round 1")},
|
||||
}}},
|
||||
{Choices: []dto.ChatCompletionsStreamResponseChoice{{
|
||||
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: lo.ToPtr("calling")},
|
||||
}}},
|
||||
{Choices: []dto.ChatCompletionsStreamResponseChoice{{
|
||||
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{{
|
||||
Index: &toolIndex, ID: "call_1", Type: "function",
|
||||
Function: dto.FunctionResponse{Name: "lookup", Arguments: "{}"},
|
||||
}}},
|
||||
}}},
|
||||
{Choices: []dto.ChatCompletionsStreamResponseChoice{{FinishReason: &toolCalls}}},
|
||||
{Choices: []dto.ChatCompletionsStreamResponseChoice{{
|
||||
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ReasoningContent: lo.ToPtr("round 2")},
|
||||
}}},
|
||||
{Choices: []dto.ChatCompletionsStreamResponseChoice{{
|
||||
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: lo.ToPtr("final")},
|
||||
FinishReason: &stop,
|
||||
}}},
|
||||
}
|
||||
var events []ChatToResponsesStreamEvent
|
||||
for _, chunk := range chunks {
|
||||
events = append(events, mustResponsesEventsFromChatChunk(t, state, chunk)...)
|
||||
}
|
||||
events = append(events, FinalizeChatCompletionsStreamToResponses(state)...)
|
||||
|
||||
open := map[string]bool{}
|
||||
partAdded, partDone := 0, 0
|
||||
for _, event := range events {
|
||||
itemID := event.Payload.ItemID
|
||||
if event.Payload.Item != nil {
|
||||
itemID = event.Payload.Item.ID
|
||||
}
|
||||
switch event.Type {
|
||||
case responsesEventOutputItemAdded:
|
||||
assert.Falsef(t, open[itemID], "item %q added twice", itemID)
|
||||
open[itemID] = true
|
||||
case responsesEventOutputItemDone:
|
||||
assert.Truef(t, open[itemID], "item %q done without being open", itemID)
|
||||
delete(open, itemID)
|
||||
case responsesEventReasoningSummaryPartAdded:
|
||||
partAdded++
|
||||
assert.Truef(t, open[itemID], "part added for closed item %q", itemID)
|
||||
case responsesEventReasoningSummaryPartDone:
|
||||
partDone++
|
||||
assert.Truef(t, open[itemID], "part done for closed item %q", itemID)
|
||||
case responsesEventReasoningSummaryDelta, responsesEventOutputTextDelta:
|
||||
assert.Truef(t, open[itemID], "%s for %q arrived without an active item", event.Type, itemID)
|
||||
}
|
||||
}
|
||||
assert.Empty(t, open)
|
||||
assert.Equal(t, 2, partAdded)
|
||||
assert.Equal(t, 2, partDone)
|
||||
|
||||
completed := events[len(events)-1]
|
||||
require.Equal(t, responsesEventCompleted, completed.Type)
|
||||
require.NotNil(t, completed.Payload.Response)
|
||||
output := completed.Payload.Response.Output
|
||||
require.Len(t, output, 5)
|
||||
assert.Equal(t, []string{
|
||||
responsesOutputTypeReasoning, responsesOutputTypeMessage, responsesOutputTypeFunctionCall,
|
||||
responsesOutputTypeReasoning, responsesOutputTypeMessage,
|
||||
}, []string{output[0].Type, output[1].Type, output[2].Type, output[3].Type, output[4].Type})
|
||||
assert.Equal(t, "resp_1_reasoning_0", output[0].ID)
|
||||
assert.Equal(t, "round 1", output[0].Summary[0].Text)
|
||||
assert.Equal(t, "resp_1_msg_0", output[1].ID)
|
||||
assert.Equal(t, "calling", output[1].Content[0].Text)
|
||||
assert.Equal(t, "call_1", output[2].CallId)
|
||||
assert.Equal(t, "resp_1_reasoning_1", output[3].ID)
|
||||
assert.Equal(t, "round 2", output[3].Summary[0].Text)
|
||||
assert.Equal(t, "resp_1_msg_1", output[4].ID)
|
||||
assert.Equal(t, "final", output[4].Content[0].Text)
|
||||
for _, item := range output {
|
||||
assert.Equal(t, "completed", item.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func execCustomToolState() *convmeta.ResponsesToolState {
|
||||
return &convmeta.ResponsesToolState{CustomToolNames: map[string]struct{}{"exec": {}}}
|
||||
}
|
||||
|
||||
func TestChatCompletionsResponseToResponsesRestoresCustomToolCalls(t *testing.T) {
|
||||
message := dto.Message{Role: "assistant"}
|
||||
message.SetToolCalls([]dto.ToolCallRequest{
|
||||
{ID: "call_exec", Type: "function", Function: dto.FunctionRequest{Name: "exec", Arguments: `{"input":"echo \"hi\" && ls"}`}},
|
||||
{ID: "call_wait", Type: "function", Function: dto.FunctionRequest{Name: "wait", Arguments: `{"ms":1}`}},
|
||||
})
|
||||
chat := &dto.OpenAITextResponse{
|
||||
Model: "gpt-test",
|
||||
Choices: []dto.OpenAITextResponseChoice{{Message: message, FinishReason: "tool_calls"}},
|
||||
}
|
||||
|
||||
resp, _, err := ChatCompletionsResponseToResponsesResponseWithTools(chat, "resp_1", execCustomToolState())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, resp.Output, 2)
|
||||
custom, err := kitutil.Marshal(resp.Output[0])
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `{"type":"custom_tool_call","id":"call_exec","status":"completed","call_id":"call_exec","name":"exec","input":"echo \"hi\" && ls"}`, string(custom))
|
||||
assert.Equal(t, responsesOutputTypeFunctionCall, resp.Output[1].Type)
|
||||
assert.Equal(t, `"{\"ms\":1}"`, string(resp.Output[1].Arguments))
|
||||
|
||||
plain, _, err := ChatCompletionsResponseToResponsesResponse(chat, "resp_1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, responsesOutputTypeFunctionCall, plain.Output[0].Type)
|
||||
}
|
||||
|
||||
func TestCustomToolInputFromArguments(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
`{"input":"ls -la"}`: "ls -la",
|
||||
`{"input":""}`: "",
|
||||
`{"input":{"cmd":"ls"}}`: `{"cmd":"ls"}`,
|
||||
`{"cmd":"ls"}`: `{"cmd":"ls"}`,
|
||||
`ls -la`: "ls -la",
|
||||
``: "",
|
||||
`{"input":"a","extra":true}`: "a",
|
||||
`{"input":"\u003cdiv\u003e"}`: "<div>",
|
||||
}
|
||||
for arguments, want := range tests {
|
||||
assert.Equalf(t, want, customToolInputFromArguments(arguments), "arguments %q", arguments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsStreamToResponsesRestoresCustomToolCalls(t *testing.T) {
|
||||
state := NewChatToResponsesStreamState("resp_1", "gpt-test")
|
||||
state.EmitSequenceNumber = true
|
||||
state.Tools = execCustomToolState()
|
||||
execIndex, waitIndex := 0, 1
|
||||
|
||||
var events []ChatToResponsesStreamEvent
|
||||
for _, delta := range []dto.ToolCallResponse{
|
||||
{Index: &execIndex, ID: "call_exec", Type: "function", Function: dto.FunctionResponse{Name: "exec"}},
|
||||
{Index: &execIndex, Function: dto.FunctionResponse{Arguments: `{"input":"echo `}},
|
||||
{Index: &execIndex, Function: dto.FunctionResponse{Arguments: `\"hi\""}`}},
|
||||
{Index: &waitIndex, ID: "call_wait", Type: "function", Function: dto.FunctionResponse{Name: "wait", Arguments: `{"ms":1}`}},
|
||||
} {
|
||||
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
||||
{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{delta}}},
|
||||
},
|
||||
})...)
|
||||
}
|
||||
events = append(events, FinalizeChatCompletionsStreamToResponses(state)...)
|
||||
|
||||
types := make([]string, 0, len(events))
|
||||
for _, event := range events {
|
||||
types = append(types, event.Type)
|
||||
}
|
||||
assert.Equal(t, []string{
|
||||
responsesEventCreated,
|
||||
responsesEventOutputItemAdded,
|
||||
responsesEventOutputItemAdded,
|
||||
responsesEventFunctionArgsDelta,
|
||||
responsesEventCustomToolInputDelta,
|
||||
responsesEventCustomToolInputDone,
|
||||
responsesEventOutputItemDone,
|
||||
responsesEventFunctionArgsDone,
|
||||
responsesEventOutputItemDone,
|
||||
responsesEventCompleted,
|
||||
}, types)
|
||||
|
||||
added, err := kitutil.Marshal(events[1].Payload.Item)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `{"type":"custom_tool_call","id":"call_exec","status":"in_progress","call_id":"call_exec","name":"exec","input":""}`, string(added))
|
||||
assert.Equal(t, `echo "hi"`, events[4].Payload.Delta)
|
||||
assert.Equal(t, "call_exec", events[4].Payload.ItemID)
|
||||
require.NotNil(t, events[5].Payload.Input)
|
||||
assert.Equal(t, `echo "hi"`, *events[5].Payload.Input)
|
||||
assert.Equal(t, responsesOutputTypeCustomToolCall, events[6].Payload.Item.Type)
|
||||
assert.Equal(t, `"echo \"hi\""`, string(events[6].Payload.Item.Input))
|
||||
|
||||
output := events[len(events)-1].Payload.Response.Output
|
||||
require.Len(t, output, 2)
|
||||
assert.Equal(t, responsesOutputTypeCustomToolCall, output[0].Type)
|
||||
assert.Equal(t, `"echo \"hi\""`, string(output[0].Input))
|
||||
assert.Equal(t, responsesOutputTypeFunctionCall, output[1].Type)
|
||||
assert.Equal(t, `"{\"ms\":1}"`, string(output[1].Arguments))
|
||||
}
|
||||
|
||||
func TestChatCompletionsStreamToResponsesHoldsNamelessToolUntilNameArrives(t *testing.T) {
|
||||
toolIndex := 0
|
||||
chunk := func(call dto.ToolCallResponse) *dto.ChatCompletionsStreamResponse {
|
||||
return &dto.ChatCompletionsStreamResponse{
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
||||
{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{call}}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("custom tool", func(t *testing.T) {
|
||||
state := NewChatToResponsesStreamState("resp_1", "gpt-test")
|
||||
state.Tools = execCustomToolState()
|
||||
first := mustResponsesEventsFromChatChunk(t, state, chunk(dto.ToolCallResponse{Index: &toolIndex, ID: "call_exec", Function: dto.FunctionResponse{Arguments: `{"input":`}}))
|
||||
for _, event := range first {
|
||||
assert.NotEqual(t, responsesEventOutputItemAdded, event.Type)
|
||||
assert.NotEqual(t, responsesEventFunctionArgsDelta, event.Type)
|
||||
}
|
||||
second := mustResponsesEventsFromChatChunk(t, state, chunk(dto.ToolCallResponse{Index: &toolIndex, Function: dto.FunctionResponse{Name: "exec", Arguments: `"ls"}`}}))
|
||||
require.Len(t, second, 1)
|
||||
assert.Equal(t, responsesEventOutputItemAdded, second[0].Type)
|
||||
assert.Equal(t, responsesOutputTypeCustomToolCall, second[0].Payload.Item.Type)
|
||||
done := FinalizeChatCompletionsStreamToResponses(state)
|
||||
require.NotEmpty(t, done)
|
||||
output := done[len(done)-1].Payload.Response.Output
|
||||
require.Len(t, output, 1)
|
||||
assert.Equal(t, `"ls"`, string(output[0].Input))
|
||||
})
|
||||
|
||||
t.Run("function tool flushes held arguments", func(t *testing.T) {
|
||||
state := NewChatToResponsesStreamState("resp_1", "gpt-test")
|
||||
first := mustResponsesEventsFromChatChunk(t, state, chunk(dto.ToolCallResponse{Index: &toolIndex, Type: "function", Function: dto.FunctionResponse{Arguments: `{"ms":`}}))
|
||||
require.Len(t, first, 1)
|
||||
assert.Equal(t, responsesEventCreated, first[0].Type)
|
||||
second := mustResponsesEventsFromChatChunk(t, state, chunk(dto.ToolCallResponse{Index: &toolIndex, ID: "call_wait", Function: dto.FunctionResponse{Name: "wait", Arguments: `1}`}}))
|
||||
require.Len(t, second, 2)
|
||||
assert.Equal(t, responsesEventOutputItemAdded, second[0].Type)
|
||||
assert.Equal(t, responsesOutputTypeFunctionCall, second[0].Payload.Item.Type)
|
||||
assert.Equal(t, "call_wait", second[0].Payload.Item.ID)
|
||||
assert.Equal(t, "call_wait", second[0].Payload.Item.CallId)
|
||||
assert.Equal(t, responsesEventFunctionArgsDelta, second[1].Type)
|
||||
assert.Equal(t, "call_wait", second[1].Payload.ItemID)
|
||||
assert.Equal(t, `{"ms":1}`, second[1].Payload.Delta)
|
||||
done := FinalizeChatCompletionsStreamToResponses(state)
|
||||
output := done[len(done)-1].Payload.Response.Output
|
||||
require.Len(t, output, 1)
|
||||
assert.Equal(t, "call_wait", output[0].ID)
|
||||
assert.Equal(t, `"{\"ms\":1}"`, string(output[0].Arguments))
|
||||
})
|
||||
|
||||
t.Run("held tool takes the next output index when announced", func(t *testing.T) {
|
||||
state := NewChatToResponsesStreamState("resp_1", "gpt-test")
|
||||
state.Tools = execCustomToolState()
|
||||
waitIndex := 1
|
||||
var events []ChatToResponsesStreamEvent
|
||||
events = append(events, mustResponsesEventsFromChatChunk(t, state, chunk(dto.ToolCallResponse{Index: &toolIndex, ID: "call_exec"}))...)
|
||||
events = append(events, mustResponsesEventsFromChatChunk(t, state, chunk(dto.ToolCallResponse{Index: &waitIndex, ID: "call_wait", Function: dto.FunctionResponse{Name: "wait", Arguments: `{}`}}))...)
|
||||
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: lo.ToPtr("done")}}},
|
||||
})...)
|
||||
events = append(events, mustResponsesEventsFromChatChunk(t, state, chunk(dto.ToolCallResponse{Index: &toolIndex, Function: dto.FunctionResponse{Name: "exec", Arguments: `{"input":"ls"}`}}))...)
|
||||
events = append(events, FinalizeChatCompletionsStreamToResponses(state)...)
|
||||
|
||||
var addedIndexes []int
|
||||
var addedTypes []string
|
||||
for _, event := range events {
|
||||
if event.Type == responsesEventOutputItemAdded {
|
||||
addedIndexes = append(addedIndexes, lo.FromPtr(event.Payload.OutputIndex))
|
||||
addedTypes = append(addedTypes, event.Payload.Item.Type)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, []int{0, 1, 2}, addedIndexes)
|
||||
assert.Equal(t, []string{responsesOutputTypeFunctionCall, responsesOutputTypeMessage, responsesOutputTypeCustomToolCall}, addedTypes)
|
||||
output := events[len(events)-1].Payload.Response.Output
|
||||
require.Len(t, output, 3)
|
||||
assert.Equal(t, addedTypes, []string{output[0].Type, output[1].Type, output[2].Type})
|
||||
})
|
||||
|
||||
t.Run("name never arrives", func(t *testing.T) {
|
||||
for _, tools := range []*convmeta.ResponsesToolState{nil, execCustomToolState()} {
|
||||
state := NewChatToResponsesStreamState("resp_1", "gpt-test")
|
||||
state.EmitSequenceNumber = true
|
||||
state.Tools = tools
|
||||
missingIndex, validIndex, blankIndex := 0, 1, 2
|
||||
var events []ChatToResponsesStreamEvent
|
||||
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{
|
||||
{Index: &missingIndex, ID: "call_missing", Type: "function", Function: dto.FunctionResponse{Arguments: `{"ok":true}`}},
|
||||
{Index: &validIndex, ID: "call_valid", Type: "function", Function: dto.FunctionResponse{Name: "lookup", Arguments: `{"q":"x"}`}},
|
||||
{Index: &blankIndex, ID: "call_blank", Function: dto.FunctionResponse{Name: " \t\n", Arguments: `{}`}},
|
||||
}}}},
|
||||
})...)
|
||||
events = append(events, FinalizeChatCompletionsStreamToResponses(state)...)
|
||||
|
||||
for i, event := range events {
|
||||
require.NotNil(t, event.Payload.SequenceNumber)
|
||||
assert.Equal(t, i, *event.Payload.SequenceNumber)
|
||||
if event.Payload.OutputIndex != nil {
|
||||
assert.Equal(t, 0, *event.Payload.OutputIndex)
|
||||
}
|
||||
if event.Payload.Item != nil {
|
||||
assert.Equal(t, "call_valid", event.Payload.Item.CallId)
|
||||
}
|
||||
}
|
||||
output := events[len(events)-1].Payload.Response.Output
|
||||
require.Len(t, output, 1)
|
||||
assert.Equal(t, "call_valid", output[0].CallId)
|
||||
assert.Equal(t, "lookup", output[0].Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func mustResponsesEventsFromChatChunk(t *testing.T, state *ChatToResponsesStreamState, chunk *dto.ChatCompletionsStreamResponse) []ChatToResponsesStreamEvent {
|
||||
t.Helper()
|
||||
events, err := ChatCompletionsStreamChunkToResponsesEvents(chunk, state)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
)
|
||||
|
||||
@@ -25,6 +26,9 @@ type ChatToResponsesStreamState struct {
|
||||
// EmitSequenceNumber enables the required sequence_number field for current
|
||||
// Responses API SSE consumers while preserving the legacy relaykit default.
|
||||
EmitSequenceNumber bool
|
||||
// Tools marks the Chat function calls that encode Responses custom tools;
|
||||
// they are streamed back as custom_tool_call items.
|
||||
Tools *convmeta.ResponsesToolState
|
||||
|
||||
status string
|
||||
incompleteDetails *dto.IncompleteDetails
|
||||
@@ -44,6 +48,17 @@ type ChatToResponsesStreamState struct {
|
||||
text strings.Builder
|
||||
annotations []any
|
||||
reasoning strings.Builder
|
||||
// usageText collects every text, reasoning, and tool argument delta sent
|
||||
// to the client. It is the missing-usage estimate and tells whether any
|
||||
// output reached the client.
|
||||
usageText strings.Builder
|
||||
// A mid-stream finish_reason closes the message and reasoning items. Deltas that
|
||||
// arrive afterwards open a fresh item (next segment ordinal) instead of touching the
|
||||
// closed one; the closed items are snapshotted so the final response keeps them.
|
||||
textSegment int
|
||||
reasoningSegment int
|
||||
closedMessages []dto.ResponsesOutput
|
||||
closedReasonings []dto.ResponsesOutput
|
||||
}
|
||||
|
||||
type chatToResponsesStreamTool struct {
|
||||
@@ -53,11 +68,16 @@ type chatToResponsesStreamTool struct {
|
||||
CallID string
|
||||
Name string
|
||||
Arguments strings.Builder
|
||||
Done bool
|
||||
// Announced records that output_item.added was sent. Custom reports that
|
||||
// the call restores a Responses custom tool; it is fixed at announcement.
|
||||
Announced bool
|
||||
Custom bool
|
||||
Done bool
|
||||
}
|
||||
|
||||
type chatToResponsesOutputRef struct {
|
||||
Kind string
|
||||
Segment int
|
||||
ToolIndex int
|
||||
HostedID string
|
||||
}
|
||||
@@ -386,12 +406,13 @@ func (s *ChatToResponsesStreamState) UsageText() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.text.String()
|
||||
return s.usageText.String()
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) appendTextDelta(delta string) []ChatToResponsesStreamEvent {
|
||||
events := s.startText()
|
||||
s.text.WriteString(delta)
|
||||
s.usageText.WriteString(delta)
|
||||
events = append(events, s.event(responsesEventOutputTextDelta, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventOutputTextDelta,
|
||||
OutputIndex: intPtr(s.textOutputIndex),
|
||||
@@ -403,9 +424,16 @@ func (s *ChatToResponsesStreamState) appendTextDelta(delta string) []ChatToRespo
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) startText() []ChatToResponsesStreamEvent {
|
||||
if s.textDone {
|
||||
s.textSegment++
|
||||
s.textStarted = false
|
||||
s.textDone = false
|
||||
s.text.Reset()
|
||||
s.annotations = nil
|
||||
}
|
||||
if !s.textStarted {
|
||||
s.textStarted = true
|
||||
s.textOutputIndex = s.nextIndex("message", -1)
|
||||
s.textOutputIndex = s.nextIndex(chatToResponsesOutputRef{Kind: "message", Segment: s.textSegment})
|
||||
return []ChatToResponsesStreamEvent{s.event(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventOutputItemAdded,
|
||||
OutputIndex: intPtr(s.textOutputIndex),
|
||||
@@ -447,10 +475,16 @@ func (s *ChatToResponsesStreamState) appendAnnotationDelta(raw []byte) ([]ChatTo
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) appendReasoningDelta(delta string) []ChatToResponsesStreamEvent {
|
||||
events := make([]ChatToResponsesStreamEvent, 0, 2)
|
||||
events := make([]ChatToResponsesStreamEvent, 0, 3)
|
||||
if s.reasoningDone {
|
||||
s.reasoningSegment++
|
||||
s.reasoningStarted = false
|
||||
s.reasoningDone = false
|
||||
s.reasoning.Reset()
|
||||
}
|
||||
if !s.reasoningStarted {
|
||||
s.reasoningStarted = true
|
||||
s.reasoningIndex = s.nextIndex("reasoning", -1)
|
||||
s.reasoningIndex = s.nextIndex(chatToResponsesOutputRef{Kind: "reasoning", Segment: s.reasoningSegment})
|
||||
events = append(events, s.event(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventOutputItemAdded,
|
||||
OutputIndex: intPtr(s.reasoningIndex),
|
||||
@@ -461,8 +495,16 @@ func (s *ChatToResponsesStreamState) appendReasoningDelta(delta string) []ChatTo
|
||||
Summary: []dto.ResponsesReasoningSummaryPart{},
|
||||
},
|
||||
}))
|
||||
events = append(events, s.event(responsesEventReasoningSummaryPartAdded, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventReasoningSummaryPartAdded,
|
||||
OutputIndex: intPtr(s.reasoningIndex),
|
||||
SummaryIndex: intPtr(0),
|
||||
ItemID: s.reasoningID(),
|
||||
Part: &dto.ResponsesReasoningSummaryPart{Type: "summary_text"},
|
||||
}))
|
||||
}
|
||||
s.reasoning.WriteString(delta)
|
||||
s.usageText.WriteString(delta)
|
||||
events = append(events, s.event(responsesEventReasoningSummaryDelta, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventReasoningSummaryDelta,
|
||||
OutputIndex: intPtr(s.reasoningIndex),
|
||||
@@ -483,29 +525,15 @@ func (s *ChatToResponsesStreamState) appendToolCallDelta(toolCall dto.ToolCallRe
|
||||
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),
|
||||
ChatIndex: 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)
|
||||
@@ -515,6 +543,9 @@ func (s *ChatToResponsesStreamState) appendToolCallDelta(toolCall dto.ToolCallRe
|
||||
return nil, fmt.Errorf("tool-call stream index %d changed id from %q to %q", chatIndex, tool.CallID, incomingID)
|
||||
}
|
||||
tool.CallID = incomingID
|
||||
if !tool.Announced {
|
||||
tool.ItemID = incomingID
|
||||
}
|
||||
}
|
||||
incomingName := strings.TrimSpace(toolCall.Function.Name)
|
||||
if incomingName != "" {
|
||||
@@ -523,18 +554,59 @@ func (s *ChatToResponsesStreamState) appendToolCallDelta(toolCall dto.ToolCallRe
|
||||
}
|
||||
tool.Name = incomingName
|
||||
}
|
||||
if toolCall.Function.Arguments != "" {
|
||||
tool.Arguments.WriteString(toolCall.Function.Arguments)
|
||||
delta := toolCall.Function.Arguments
|
||||
tool.Arguments.WriteString(delta)
|
||||
// A nameless call is invalid Responses output, so hold it back until a
|
||||
// valid name arrives. Announcement also determines whether it is custom.
|
||||
if !tool.Announced && tool.Name != "" {
|
||||
events = append(events, s.announceTool(tool))
|
||||
delta = tool.Arguments.String()
|
||||
}
|
||||
if tool.Announced {
|
||||
// Announced calls reach the client; custom tool input at completion.
|
||||
s.usageText.WriteString(delta)
|
||||
}
|
||||
// Custom tool input is unwrapped from the complete arguments at the end.
|
||||
if tool.Announced && !tool.Custom && delta != "" {
|
||||
events = append(events, s.event(responsesEventFunctionArgsDelta, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventFunctionArgsDelta,
|
||||
OutputIndex: intPtr(tool.OutputIndex),
|
||||
ItemID: tool.ItemID,
|
||||
Delta: toolCall.Function.Arguments,
|
||||
Delta: delta,
|
||||
}))
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// announceTool also allocates the output index, so a held tool never ends up
|
||||
// behind items that were added to the stream before it.
|
||||
func (s *ChatToResponsesStreamState) announceTool(tool *chatToResponsesStreamTool) ChatToResponsesStreamEvent {
|
||||
tool.Announced = true
|
||||
tool.OutputIndex = s.nextIndex(chatToResponsesOutputRef{Kind: "tool", ToolIndex: tool.ChatIndex})
|
||||
tool.Custom = s.Tools.IsCustomTool(tool.Name)
|
||||
namespace, name := s.Tools.ResponsesToolName(tool.Name)
|
||||
item := &dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeFunctionCall,
|
||||
ID: tool.ItemID,
|
||||
Status: "in_progress",
|
||||
CallId: tool.callID(),
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
Arguments: []byte(`""`),
|
||||
}
|
||||
if tool.Custom {
|
||||
item.Type = responsesOutputTypeCustomToolCall
|
||||
item.Arguments = nil
|
||||
item.Input = []byte(`""`)
|
||||
}
|
||||
return s.event(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventOutputItemAdded,
|
||||
OutputIndex: intPtr(tool.OutputIndex),
|
||||
ItemID: tool.ItemID,
|
||||
Item: item,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) doneDeltaEvents() []ChatToResponsesStreamEvent {
|
||||
events := make([]ChatToResponsesStreamEvent, 0)
|
||||
status := s.outputStatus()
|
||||
@@ -550,11 +622,13 @@ func (s *ChatToResponsesStreamState) doneDeltaEvents() []ChatToResponsesStreamEv
|
||||
textDone.Text = kitutil.GetPointer(s.text.String())
|
||||
}
|
||||
events = append(events, s.event("response.output_text.done", textDone))
|
||||
closedMessage := s.messageOutput(status)
|
||||
events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventOutputItemDone,
|
||||
OutputIndex: intPtr(s.textOutputIndex),
|
||||
Item: s.messageOutput(status),
|
||||
Item: closedMessage,
|
||||
}))
|
||||
s.closedMessages = append(s.closedMessages, *closedMessage)
|
||||
}
|
||||
if s.reasoningStarted && !s.reasoningDone {
|
||||
s.reasoningDone = true
|
||||
@@ -573,27 +647,71 @@ func (s *ChatToResponsesStreamState) doneDeltaEvents() []ChatToResponsesStreamEv
|
||||
reasoningDone.Part = nil
|
||||
}
|
||||
events = append(events, s.event(responsesEventReasoningSummaryDone, reasoningDone))
|
||||
events = append(events, s.event(responsesEventReasoningSummaryPartDone, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventReasoningSummaryPartDone,
|
||||
OutputIndex: intPtr(s.reasoningIndex),
|
||||
SummaryIndex: intPtr(0),
|
||||
ItemID: s.reasoningID(),
|
||||
Part: &dto.ResponsesReasoningSummaryPart{
|
||||
Type: "summary_text",
|
||||
Text: s.reasoning.String(),
|
||||
},
|
||||
}))
|
||||
closedReasoning := s.reasoningOutput(status)
|
||||
events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventOutputItemDone,
|
||||
OutputIndex: intPtr(s.reasoningIndex),
|
||||
Item: s.reasoningOutput(status),
|
||||
Item: closedReasoning,
|
||||
}))
|
||||
s.closedReasonings = append(s.closedReasonings, *closedReasoning)
|
||||
}
|
||||
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 !tool.Announced {
|
||||
if tool.Name == "" {
|
||||
continue
|
||||
}
|
||||
events = append(events, s.announceTool(tool))
|
||||
if !tool.Custom && tool.Arguments.Len() > 0 {
|
||||
events = append(events, s.event(responsesEventFunctionArgsDelta, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventFunctionArgsDelta,
|
||||
OutputIndex: intPtr(tool.OutputIndex),
|
||||
ItemID: tool.ItemID,
|
||||
Delta: tool.Arguments.String(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
if s.EmitSequenceNumber {
|
||||
argumentsDone.Arguments = kitutil.GetPointer(tool.Arguments.String())
|
||||
argumentsDone.Name = tool.Name
|
||||
if tool.Custom {
|
||||
input := customToolInputFromArguments(tool.Arguments.String())
|
||||
if input != "" {
|
||||
events = append(events, s.event(responsesEventCustomToolInputDelta, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventCustomToolInputDelta,
|
||||
OutputIndex: intPtr(tool.OutputIndex),
|
||||
ItemID: tool.ItemID,
|
||||
Delta: input,
|
||||
}))
|
||||
}
|
||||
events = append(events, s.event(responsesEventCustomToolInputDone, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventCustomToolInputDone,
|
||||
OutputIndex: intPtr(tool.OutputIndex),
|
||||
ItemID: tool.ItemID,
|
||||
Input: kitutil.GetPointer(input),
|
||||
}))
|
||||
} else {
|
||||
argumentsDone := dto.ResponsesStreamResponse{
|
||||
Type: responsesEventFunctionArgsDone,
|
||||
OutputIndex: intPtr(tool.OutputIndex),
|
||||
ItemID: tool.ItemID,
|
||||
}
|
||||
if s.EmitSequenceNumber {
|
||||
argumentsDone.Arguments = kitutil.GetPointer(tool.Arguments.String())
|
||||
_, argumentsDone.Name = s.Tools.ResponsesToolName(tool.Name)
|
||||
}
|
||||
events = append(events, s.event(responsesEventFunctionArgsDone, argumentsDone))
|
||||
}
|
||||
events = append(events, s.event(responsesEventFunctionArgsDone, argumentsDone))
|
||||
events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
|
||||
Type: responsesEventOutputItemDone,
|
||||
OutputIndex: intPtr(tool.OutputIndex),
|
||||
@@ -649,8 +767,20 @@ func (s *ChatToResponsesStreamState) finalResponse() *dto.OpenAIResponsesRespons
|
||||
for _, ref := range s.outputOrder {
|
||||
switch ref.Kind {
|
||||
case "message":
|
||||
if ref.Segment < len(s.closedMessages) {
|
||||
closed := s.closedMessages[ref.Segment]
|
||||
closed.Status = status
|
||||
output = append(output, closed)
|
||||
continue
|
||||
}
|
||||
output = append(output, *s.messageOutput(status))
|
||||
case "reasoning":
|
||||
if ref.Segment < len(s.closedReasonings) {
|
||||
closed := s.closedReasonings[ref.Segment]
|
||||
closed.Status = status
|
||||
output = append(output, closed)
|
||||
continue
|
||||
}
|
||||
output = append(output, *s.reasoningOutput(status))
|
||||
case "tool":
|
||||
if tool := s.toolsByIndex[ref.ToolIndex]; tool != nil {
|
||||
@@ -685,10 +815,10 @@ func (s *ChatToResponsesStreamState) createdResponse() *dto.OpenAIResponsesRespo
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) nextIndex(kind string, toolIndex int) int {
|
||||
func (s *ChatToResponsesStreamState) nextIndex(ref chatToResponsesOutputRef) int {
|
||||
index := s.nextOutputIndex
|
||||
s.nextOutputIndex++
|
||||
s.outputOrder = append(s.outputOrder, chatToResponsesOutputRef{Kind: kind, ToolIndex: toolIndex})
|
||||
s.outputOrder = append(s.outputOrder, ref)
|
||||
return index
|
||||
}
|
||||
|
||||
@@ -720,11 +850,11 @@ func (s *ChatToResponsesStreamState) outputStatus() string {
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) messageID() string {
|
||||
return fmt.Sprintf("%s_msg_0", s.ID)
|
||||
return fmt.Sprintf("%s_msg_%d", s.ID, s.textSegment)
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) reasoningID() string {
|
||||
return fmt.Sprintf("%s_reasoning_0", s.ID)
|
||||
return fmt.Sprintf("%s_reasoning_%d", s.ID, s.reasoningSegment)
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) messageOutput(status string) *dto.ResponsesOutput {
|
||||
@@ -762,12 +892,25 @@ func (s *ChatToResponsesStreamState) reasoningOutput(status string) *dto.Respons
|
||||
}
|
||||
|
||||
func (s *ChatToResponsesStreamState) toolOutput(tool *chatToResponsesStreamTool, status string) *dto.ResponsesOutput {
|
||||
namespace, name := s.Tools.ResponsesToolName(tool.Name)
|
||||
if tool.Custom {
|
||||
return &dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeCustomToolCall,
|
||||
ID: tool.ItemID,
|
||||
Status: status,
|
||||
CallId: tool.callID(),
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
Input: chatArgumentsRawMessage(customToolInputFromArguments(tool.Arguments.String())),
|
||||
}
|
||||
}
|
||||
return &dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeFunctionCall,
|
||||
ID: tool.ItemID,
|
||||
Status: status,
|
||||
CallId: tool.callID(),
|
||||
Name: tool.Name,
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
Arguments: chatArgumentsRawMessage(tool.Arguments.String()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package oairesponses
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
)
|
||||
@@ -58,6 +60,29 @@ func InputItems(raw []byte) ([]map[string]any, error) {
|
||||
return responsesInputItems(raw)
|
||||
}
|
||||
|
||||
// omitTrailingAssistantText drops the assistant turns without tool calls that
|
||||
// end the converted conversation after a user turn or tool result. In
|
||||
// Responses they are history and the model answers with a new item; Claude and
|
||||
// Gemini would continue them as a prefill, which newer models reject with a
|
||||
// 400. Codex leaves them at the end of input when it retries a stream that
|
||||
// failed after an assistant message completed.
|
||||
func omitTrailingAssistantText[T any](c context.Context, turns []T, isUser, isAssistantText func(T) bool) []T {
|
||||
end := len(turns)
|
||||
for end > 0 && isAssistantText(turns[end-1]) {
|
||||
end--
|
||||
}
|
||||
if end == len(turns) || end == 0 || !isUser(turns[end-1]) {
|
||||
return turns
|
||||
}
|
||||
convdiag.Add(c, types.ConversionDiagnostic{
|
||||
Code: "trailing_assistant_omitted",
|
||||
Path: "input",
|
||||
Message: "assistant output after the last user message or tool result was omitted; the target protocol would continue it as a prefill",
|
||||
Severity: types.ConversionDiagnosticWarning,
|
||||
})
|
||||
return turns[:end]
|
||||
}
|
||||
|
||||
func responsesContentParts(content any) ([]map[string]any, error) {
|
||||
switch typed := content.(type) {
|
||||
case nil:
|
||||
|
||||
@@ -3,6 +3,7 @@ package oairesponses
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
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/QuantumNous/new-api/relaykit/types"
|
||||
)
|
||||
|
||||
func convertOpenAIResponsesRequestToClaudeMessages(c context.Context, info convmeta.Meta, request any) (any, error) {
|
||||
@@ -94,11 +96,26 @@ func OpenAIResponsesRequestToClaudeMessages(c context.Context, info convmeta.Met
|
||||
itemType := strings.TrimSpace(kitutil.Interface2String(item["type"]))
|
||||
switch itemType {
|
||||
case ResponsesInputTypeFunctionCall:
|
||||
claudeRequest.Messages = appendClaudeToolUse(claudeRequest.Messages, responsesFunctionCallItemToClaudeToolUse(item, "arguments"))
|
||||
claudeRequest.Messages = appendClaudeToolUse(claudeRequest.Messages, responsesFunctionCallItemToClaudeToolUse(item))
|
||||
case ResponsesInputTypeCustomToolCall:
|
||||
claudeRequest.Messages = appendClaudeToolUse(claudeRequest.Messages, responsesFunctionCallItemToClaudeToolUse(item, "input"))
|
||||
name := responsesCallName(item)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("custom_tool_call item is missing name")
|
||||
}
|
||||
// The custom tool is declared as a function taking one string
|
||||
// argument, so its raw input is replayed in that shape.
|
||||
claudeRequest.Messages = appendClaudeToolUse(claudeRequest.Messages, dto.ClaudeMediaMessage{
|
||||
Type: "tool_use",
|
||||
Id: CallID(item),
|
||||
Name: name,
|
||||
Input: map[string]any{convmeta.CustomToolInputArgument: responsesArgumentsString(item["input"])},
|
||||
})
|
||||
case ResponsesInputTypeFunctionCallOutput, ResponsesInputTypeCustomToolOutput:
|
||||
claudeRequest.Messages = appendClaudeToolResult(claudeRequest.Messages, responsesFunctionOutputItemToClaudeToolResult(item))
|
||||
claudeRequest.Messages = appendClaudeToolResult(claudeRequest.Messages, dto.ClaudeMediaMessage{
|
||||
Type: "tool_result",
|
||||
ToolUseId: CallID(item),
|
||||
Content: responsesToolOutputToClaude(c, item["output"]),
|
||||
})
|
||||
default:
|
||||
sourceRole := strings.TrimSpace(kitutil.Interface2String(item["role"]))
|
||||
role := responsesClaudeRole(sourceRole)
|
||||
@@ -131,6 +148,11 @@ func OpenAIResponsesRequestToClaudeMessages(c context.Context, info convmeta.Met
|
||||
})
|
||||
}
|
||||
}
|
||||
claudeRequest.Messages = omitTrailingAssistantText(c, claudeRequest.Messages,
|
||||
func(message dto.ClaudeMessage) bool { return message.Role == "user" },
|
||||
func(message dto.ClaudeMessage) bool {
|
||||
return message.Role == "assistant" && !slices.ContainsFunc(claudeMessageContentParts(message.Content), func(part dto.ClaudeMediaMessage) bool { return part.Type == "tool_use" })
|
||||
})
|
||||
|
||||
if len(systemMessages) > 0 {
|
||||
claudeRequest.System = systemMessages
|
||||
@@ -181,50 +203,81 @@ func responsesInputContentToClaudeMediaMessages(c context.Context, content any)
|
||||
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())
|
||||
// Claude content blocks carry images, PDF documents, and text
|
||||
// documents; audio and video have no Claude block.
|
||||
reason := fmt.Sprintf("Claude Messages cannot carry %s content", partType)
|
||||
var block dto.ClaudeMediaMessage
|
||||
if partType == "input_image" || partType == "input_file" {
|
||||
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())
|
||||
}
|
||||
block, reason = sharedclaude.MediaBlock(base64Data, mimeType, partType == "input_image")
|
||||
}
|
||||
claudePart := dto.ClaudeMediaMessage{
|
||||
Source: &dto.ClaudeMessageSource{
|
||||
Type: "base64",
|
||||
MediaType: mimeType,
|
||||
Data: base64Data,
|
||||
},
|
||||
if reason != "" {
|
||||
convdiag.Add(c, types.ConversionDiagnostic{
|
||||
Code: "unsupported_media_type",
|
||||
Path: "input.content",
|
||||
Message: reason + "; the part was omitted",
|
||||
Severity: types.ConversionDiagnosticError,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(mimeType, "application/pdf") {
|
||||
claudePart.Type = "document"
|
||||
} else {
|
||||
claudePart.Type = "image"
|
||||
}
|
||||
parts = append(parts, claudePart)
|
||||
parts = append(parts, block)
|
||||
}
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
func responsesFunctionCallItemToClaudeToolUse(item map[string]any, inputKey string) dto.ClaudeMediaMessage {
|
||||
func responsesFunctionCallItemToClaudeToolUse(item map[string]any) dto.ClaudeMediaMessage {
|
||||
return dto.ClaudeMediaMessage{
|
||||
Type: "tool_use",
|
||||
Id: CallID(item),
|
||||
Name: strings.TrimSpace(kitutil.Interface2String(item["name"])),
|
||||
Input: ObjectValue(item[inputKey], inputKey),
|
||||
Name: responsesCallName(item),
|
||||
Input: ObjectValue(item["arguments"], "arguments"),
|
||||
}
|
||||
}
|
||||
|
||||
func responsesFunctionOutputItemToClaudeToolResult(item map[string]any) dto.ClaudeMediaMessage {
|
||||
return dto.ClaudeMediaMessage{
|
||||
Type: "tool_result",
|
||||
ToolUseId: CallID(item),
|
||||
Content: responsesToolOutputValue(item["output"]),
|
||||
// responsesToolOutputToClaude maps a function_call_output payload onto Claude
|
||||
// tool_result content. A Responses content-part array becomes the text, image,
|
||||
// and document blocks tool_result accepts; stringifying it instead would hand
|
||||
// base64 media to the upstream text tokenizer. An array holding any other part
|
||||
// type, or media that cannot be resolved, keeps the stringified form, and other
|
||||
// payload shapes pass through unchanged.
|
||||
func responsesToolOutputToClaude(c context.Context, value any) any {
|
||||
rawParts, ok := value.([]any)
|
||||
if !ok || len(rawParts) == 0 {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func responsesToolOutputValue(value any) any {
|
||||
if value == nil {
|
||||
return ""
|
||||
labels := make([]string, 0, len(rawParts))
|
||||
for _, rawPart := range rawParts {
|
||||
part, isMap := rawPart.(map[string]any)
|
||||
if !isMap {
|
||||
return responseToolOutputToChatContent(value)
|
||||
}
|
||||
switch partType := strings.TrimSpace(kitutil.Interface2String(part["type"])); partType {
|
||||
case "input_text", "output_text", "text":
|
||||
case "input_image", "input_file", "input_audio", "input_video":
|
||||
if label := "[" + strings.TrimPrefix(partType, "input_") + "]"; !slices.Contains(labels, label) {
|
||||
labels = append(labels, label)
|
||||
}
|
||||
default:
|
||||
return responseToolOutputToChatContent(value)
|
||||
}
|
||||
}
|
||||
return value
|
||||
blocks, err := responsesInputContentToClaudeMediaMessages(c, rawParts)
|
||||
if err != nil {
|
||||
return responseToolOutputToChatContent(value)
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
// Every media part was omitted with a diagnostic; name what was there
|
||||
// instead of sending the payload as text.
|
||||
return strings.Join(labels, " ")
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
func appendClaudeToolUse(messages []dto.ClaudeMessage, toolUse dto.ClaudeMediaMessage) []dto.ClaudeMessage {
|
||||
|
||||
@@ -2,6 +2,7 @@ package oairesponses
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"context"
|
||||
@@ -13,21 +14,9 @@ import (
|
||||
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"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
)
|
||||
|
||||
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 {
|
||||
@@ -59,7 +48,7 @@ func OpenAIResponsesRequestToGeminiChat(c context.Context, req *dto.OpenAIRespon
|
||||
if opts.Gemini.SupportsImagineModel(upstreamModelName) {
|
||||
geminiRequest.GenerationConfig.ResponseModalities = []string{"TEXT", "IMAGE"}
|
||||
}
|
||||
if err := applyResponsesTextToGemini(req.Text, geminiRequest); err != nil {
|
||||
if err := applyResponsesTextToGemini(c, req.Text, geminiRequest, opts.Gemini.SupportsJSONSchemaModel(upstreamModelName)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reasoningIntent, diagnostics, err := reasoning.FromOpenAIResponses(req)
|
||||
@@ -134,12 +123,17 @@ func OpenAIResponsesRequestToGeminiChat(c context.Context, req *dto.OpenAIRespon
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Multimodal function responses (media in functionResponse.parts) are
|
||||
// documented for Gemini 3. Gemini 1.x and 2.x get tool media as ordinary
|
||||
// inlineData parts after the function responses of the same turn.
|
||||
modelID := strings.TrimPrefix(strings.ToLower(upstreamModelName), "models/")
|
||||
toolMediaInFunctionResponse := !strings.HasPrefix(modelID, "gemini-1.") && !strings.HasPrefix(modelID, "gemini-2.")
|
||||
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)
|
||||
case ResponsesInputTypeFunctionCall, ResponsesInputTypeCustomToolCall:
|
||||
part, callID, err := responsesFunctionCallItemToGeminiPart(item, itemType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -148,12 +142,14 @@ func OpenAIResponsesRequestToGeminiChat(c context.Context, req *dto.OpenAIRespon
|
||||
callNames[callID] = part.FunctionCall.FunctionName
|
||||
}
|
||||
appendGeminiContentPart(geminiRequest, "model", part)
|
||||
case ResponsesInputTypeFunctionCallOutput:
|
||||
part, err := responsesFunctionOutputItemToGeminiPart(item, callNames)
|
||||
case ResponsesInputTypeFunctionCallOutput, ResponsesInputTypeCustomToolOutput:
|
||||
part, media, err := responsesFunctionOutputItemToGeminiPart(c, item, callNames, toolMediaInFunctionResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appendGeminiContentPart(geminiRequest, "user", part)
|
||||
turn := &geminiRequest.Contents[len(geminiRequest.Contents)-1]
|
||||
turn.Parts = append(turn.Parts, media...)
|
||||
default:
|
||||
role := responsesGeminiRole(item)
|
||||
parts, err := responsesInputContentToGeminiParts(c, item["content"])
|
||||
@@ -176,6 +172,11 @@ func OpenAIResponsesRequestToGeminiChat(c context.Context, req *dto.OpenAIRespon
|
||||
}
|
||||
}
|
||||
}
|
||||
geminiRequest.Contents = omitTrailingAssistantText(c, geminiRequest.Contents,
|
||||
func(content dto.GeminiChatContent) bool { return content.Role == "user" },
|
||||
func(content dto.GeminiChatContent) bool {
|
||||
return content.Role == "model" && !slices.ContainsFunc(content.Parts, func(part dto.GeminiPart) bool { return part.FunctionCall != nil })
|
||||
})
|
||||
|
||||
if len(systemTexts) > 0 {
|
||||
geminiRequest.SystemInstructions = &dto.GeminiChatContent{
|
||||
@@ -186,7 +187,7 @@ func OpenAIResponsesRequestToGeminiChat(c context.Context, req *dto.OpenAIRespon
|
||||
return geminiRequest, nil
|
||||
}
|
||||
|
||||
func applyResponsesTextToGemini(raw []byte, geminiRequest *dto.GeminiChatRequest) error {
|
||||
func applyResponsesTextToGemini(c context.Context, raw []byte, geminiRequest *dto.GeminiChatRequest, jsonSchema bool) error {
|
||||
responseFormat, err := RequestTextToChatResponseFormat(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -200,11 +201,7 @@ func applyResponsesTextToGemini(raw []byte, geminiRequest *dto.GeminiChatRequest
|
||||
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)
|
||||
sharedgemini.ApplyResponseSchema(c, &geminiRequest.GenerationConfig, responseFormat.JsonSchema, "text.format.schema", jsonSchema)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -243,7 +240,7 @@ func responsesContentPartToGeminiParts(c context.Context, part map[string]any) (
|
||||
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 {
|
||||
if !sharedgemini.IsSupportedMimeType(mimeType) {
|
||||
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{
|
||||
@@ -259,58 +256,168 @@ func responsesContentPartToGeminiParts(c context.Context, part map[string]any) (
|
||||
}
|
||||
}
|
||||
|
||||
func responsesFunctionCallItemToGeminiPart(item map[string]any) (dto.GeminiPart, string, error) {
|
||||
name := strings.TrimSpace(kitutil.Interface2String(item["name"]))
|
||||
func responsesFunctionCallItemToGeminiPart(item map[string]any, itemType string) (dto.GeminiPart, string, error) {
|
||||
name := responsesCallName(item)
|
||||
if name == "" {
|
||||
return dto.GeminiPart{}, "", fmt.Errorf("function_call item is missing name")
|
||||
return dto.GeminiPart{}, "", fmt.Errorf("%s item is missing name", itemType)
|
||||
}
|
||||
var arguments map[string]any
|
||||
if itemType == ResponsesInputTypeCustomToolCall {
|
||||
// The custom tool is declared as a function taking one string
|
||||
// argument, so its raw input is replayed in that shape.
|
||||
arguments = map[string]any{convmeta.CustomToolInputArgument: responsesArgumentsString(item["input"])}
|
||||
} else {
|
||||
arguments = ObjectValue(item["arguments"], "arguments")
|
||||
}
|
||||
callID := CallID(item)
|
||||
return dto.GeminiPart{
|
||||
FunctionCall: &dto.FunctionCall{
|
||||
ID: callID,
|
||||
FunctionName: name,
|
||||
Arguments: ObjectValue(item["arguments"], "arguments"),
|
||||
Arguments: arguments,
|
||||
},
|
||||
}, callID, nil
|
||||
}
|
||||
|
||||
func responsesFunctionOutputItemToGeminiPart(item map[string]any, callNames map[string]string) (dto.GeminiPart, error) {
|
||||
// responsesFunctionOutputItemToGeminiPart returns the functionResponse part
|
||||
// and, unless mediaInParts puts them into functionResponse.parts, the tool
|
||||
// media the caller sends as separate parts after the turn's function
|
||||
// responses.
|
||||
func responsesFunctionOutputItemToGeminiPart(c context.Context, item map[string]any, callNames map[string]string, mediaInParts bool) (dto.GeminiPart, []dto.GeminiPart, error) {
|
||||
callID := CallID(item)
|
||||
name := strings.TrimSpace(kitutil.Interface2String(item["name"]))
|
||||
if name == "" {
|
||||
name = callNames[callID]
|
||||
}
|
||||
output, media := responsesToolOutputToGemini(c, item["output"], mediaInParts)
|
||||
response := &dto.GeminiFunctionResponse{
|
||||
Name: name,
|
||||
Response: GeminiResponseMap(item["output"]),
|
||||
Response: output,
|
||||
}
|
||||
if mediaInParts && len(media) > 0 {
|
||||
parts, err := kitutil.Marshal(media)
|
||||
if err != nil {
|
||||
return dto.GeminiPart{}, nil, fmt.Errorf("failed to marshal function response parts: %w", err)
|
||||
}
|
||||
response.Parts = parts
|
||||
media = nil
|
||||
}
|
||||
if callID != "" {
|
||||
id, err := kitutil.Marshal(callID)
|
||||
if err != nil {
|
||||
return dto.GeminiPart{}, fmt.Errorf("failed to marshal function response ID: %w", err)
|
||||
return dto.GeminiPart{}, nil, fmt.Errorf("failed to marshal function response ID: %w", err)
|
||||
}
|
||||
response.ID = id
|
||||
}
|
||||
return dto.GeminiPart{
|
||||
FunctionResponse: response,
|
||||
}, nil
|
||||
}, media, nil
|
||||
}
|
||||
|
||||
// responsesToolOutputToGemini maps a function_call_output payload onto a Gemini
|
||||
// functionResponse. A Responses content-part array keeps its text in response
|
||||
// and returns its media as inlineData parts; stringifying them instead would
|
||||
// hand base64 media to the upstream text tokenizer. Multimodal function
|
||||
// responses (mediaInParts) carry images and documents only; separate parts
|
||||
// carry every type Gemini accepts. Media that cannot be loaded or carried is
|
||||
// omitted with a diagnostic. An array holding any other part type, and every
|
||||
// other payload shape, keeps the historical response map.
|
||||
func responsesToolOutputToGemini(c context.Context, value any, mediaInParts bool) (map[string]any, []dto.GeminiPart) {
|
||||
rawParts, ok := value.([]any)
|
||||
if !ok || len(rawParts) == 0 {
|
||||
return GeminiResponseMap(value), nil
|
||||
}
|
||||
parts := make([]map[string]any, 0, len(rawParts))
|
||||
for _, rawPart := range rawParts {
|
||||
part, isMap := rawPart.(map[string]any)
|
||||
if !isMap {
|
||||
return GeminiResponseMap(value), nil
|
||||
}
|
||||
switch strings.TrimSpace(kitutil.Interface2String(part["type"])) {
|
||||
case "input_text", "output_text", "text", "input_image", "input_file", "input_audio", "input_video":
|
||||
default:
|
||||
return GeminiResponseMap(value), nil
|
||||
}
|
||||
parts = append(parts, part)
|
||||
}
|
||||
|
||||
texts := make([]string, 0, len(parts))
|
||||
labels := make([]string, 0, len(parts))
|
||||
media := make([]dto.GeminiPart, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
partType := strings.TrimSpace(kitutil.Interface2String(part["type"]))
|
||||
switch partType {
|
||||
case "input_text", "output_text", "text":
|
||||
if text := kitutil.Interface2String(part["text"]); text != "" {
|
||||
texts = append(texts, text)
|
||||
}
|
||||
continue
|
||||
}
|
||||
kind := strings.TrimPrefix(partType, "input_")
|
||||
if label := "[" + kind + "]"; !slices.Contains(labels, label) {
|
||||
labels = append(labels, label)
|
||||
}
|
||||
source := ContentPartToFileSource(part)
|
||||
if source == nil {
|
||||
continue
|
||||
}
|
||||
// The documented MIME list for multimodal function responses has no
|
||||
// audio or video, and Gemini 3 ignores audio sent there.
|
||||
reason := fmt.Sprintf("Gemini function responses cannot carry %s content", kind)
|
||||
if !mediaInParts || kind == "image" || kind == "file" {
|
||||
base64Data, mimeType, err := relaymedia.ResolveBase64Data(c, source, "formatting Responses tool output for Gemini")
|
||||
topLevelType, _, _ := strings.Cut(strings.ToLower(mimeType), "/")
|
||||
switch {
|
||||
case err != nil:
|
||||
reason = fmt.Sprintf("the %s could not be loaded", kind)
|
||||
case sharedgemini.IsSupportedMimeType(mimeType) && (!mediaInParts || topLevelType != "audio" && topLevelType != "video"):
|
||||
media = append(media, dto.GeminiPart{InlineData: &dto.GeminiInlineData{MimeType: mimeType, Data: base64Data}})
|
||||
continue
|
||||
default:
|
||||
reason = fmt.Sprintf("Gemini function responses cannot carry %s content", mimeType)
|
||||
}
|
||||
}
|
||||
convdiag.Add(c, types.ConversionDiagnostic{
|
||||
Code: "unsupported_media_type",
|
||||
Path: "input.output",
|
||||
Message: reason + "; the part was omitted",
|
||||
Severity: types.ConversionDiagnosticError,
|
||||
})
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(texts) > 0:
|
||||
return GeminiResponseMap(strings.Join(texts, "\n")), media
|
||||
case len(media) > 0:
|
||||
return map[string]any{}, media
|
||||
default:
|
||||
// Every media part was omitted; name what was there instead of
|
||||
// sending the payload as text.
|
||||
return GeminiResponseMap(strings.Join(labels, " ")), 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
|
||||
parts := req.Contents[len(req.Contents)-1].Parts
|
||||
insertAt := len(parts)
|
||||
switch {
|
||||
case role == "model" && part.FunctionCall != nil:
|
||||
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
|
||||
case part.FunctionResponse != nil:
|
||||
// Tool media sent as separate parts stay after every function
|
||||
// response of the turn, so a later response goes before them.
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
if parts[i].FunctionResponse != nil {
|
||||
insertAt = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
req.Contents[len(req.Contents)-1].Parts = append(req.Contents[len(req.Contents)-1].Parts, part)
|
||||
req.Contents[len(req.Contents)-1].Parts = slices.Insert(parts, insertAt, part)
|
||||
return
|
||||
}
|
||||
req.Contents = append(req.Contents, dto.GeminiChatContent{
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
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"
|
||||
}
|
||||
@@ -5,12 +5,16 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
|
||||
relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -38,7 +42,7 @@ func ResponsesRequestToChatCompletionsRequest(ctx context.Context, req *dto.Open
|
||||
return nil, err
|
||||
}
|
||||
|
||||
messages, err := responsesRequestMessagesToChat(req)
|
||||
messages, err := responsesRequestMessagesToChat(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -139,7 +143,7 @@ func ValidateRequestChatUnsupportedFields(req *dto.OpenAIResponsesRequest) error
|
||||
return validateResponsesRequestChatUnsupportedFields(req)
|
||||
}
|
||||
|
||||
func responsesRequestMessagesToChat(req *dto.OpenAIResponsesRequest) ([]dto.Message, error) {
|
||||
func responsesRequestMessagesToChat(ctx context.Context, req *dto.OpenAIResponsesRequest) ([]dto.Message, error) {
|
||||
messages := make([]dto.Message, 0)
|
||||
if rawJSONPresent(req.Instructions) {
|
||||
instructions, err := responsesJSONString(req.Instructions)
|
||||
@@ -168,12 +172,25 @@ func responsesRequestMessagesToChat(req *dto.OpenAIResponsesRequest) ([]dto.Mess
|
||||
if err := kitutil.Unmarshal(req.Input, &items); err != nil {
|
||||
return nil, fmt.Errorf("invalid input array: %w", err)
|
||||
}
|
||||
// Chat Completions requires the tool messages answering one assistant tool_calls
|
||||
// batch to stay contiguous, so media hoisted out of tool output items is
|
||||
// held back and emitted as a single user message once the batch ends.
|
||||
var pendingMedia []any
|
||||
for _, item := range items {
|
||||
nextMessages, err := responsesInputItemToChatMessages(item, messages)
|
||||
itemType := strings.TrimSpace(kitutil.Interface2String(item["type"]))
|
||||
if len(pendingMedia) > 0 && itemType != responsesInputTypeFunctionCallOutput && itemType != responsesInputTypeCustomToolOutput {
|
||||
messages = append(messages, dto.Message{Role: "user", Content: pendingMedia})
|
||||
pendingMedia = nil
|
||||
}
|
||||
nextMessages, media, err := responsesInputItemToChatMessages(ctx, item, messages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
messages = nextMessages
|
||||
pendingMedia = append(pendingMedia, media...)
|
||||
}
|
||||
if len(pendingMedia) > 0 {
|
||||
messages = append(messages, dto.Message{Role: "user", Content: pendingMedia})
|
||||
}
|
||||
return messages, nil
|
||||
default:
|
||||
@@ -181,39 +198,42 @@ func responsesRequestMessagesToChat(req *dto.OpenAIResponsesRequest) ([]dto.Mess
|
||||
}
|
||||
}
|
||||
|
||||
func responsesInputItemToChatMessages(item map[string]any, messages []dto.Message) ([]dto.Message, error) {
|
||||
// responsesInputItemToChatMessages appends the Chat messages for one Responses input item.
|
||||
// The second result carries media content parts hoisted out of a tool output item,
|
||||
// already in Chat shape; the caller decides where that user message lands.
|
||||
func responsesInputItemToChatMessages(ctx context.Context, item map[string]any, messages []dto.Message) ([]dto.Message, []any, error) {
|
||||
itemType := strings.TrimSpace(kitutil.Interface2String(item["type"]))
|
||||
switch itemType {
|
||||
case responsesInputTypeFunctionCall:
|
||||
toolCall, err := responsesFunctionCallItemToChatToolCall(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
return appendToolCallToLastAssistant(messages, toolCall), nil
|
||||
return appendToolCallToLastAssistant(messages, toolCall), nil, nil
|
||||
case responsesInputTypeCustomToolCall:
|
||||
toolCall, err := responsesCustomToolCallItemToChatToolCall(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
return appendToolCallToLastAssistant(messages, toolCall), nil
|
||||
case responsesInputTypeFunctionCallOutput:
|
||||
return appendToolCallToLastAssistant(messages, toolCall), nil, nil
|
||||
case responsesInputTypeFunctionCallOutput, responsesInputTypeCustomToolOutput:
|
||||
callID := strings.TrimSpace(kitutil.Interface2String(item["call_id"]))
|
||||
content := responseToolOutputToChatContent(item["output"])
|
||||
return append(messages, dto.Message{Role: "tool", ToolCallId: callID, Content: content}), nil
|
||||
content, media := responsesToolOutputToChat(ctx, item["output"])
|
||||
return append(messages, dto.Message{Role: "tool", ToolCallId: callID, Content: content}), media, nil
|
||||
}
|
||||
|
||||
role := strings.TrimSpace(kitutil.Interface2String(item["role"]))
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
content, err := responsesInputContentToChatContent(item["content"])
|
||||
content, err := responsesInputContentToChatContent(ctx, item["content"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
return append(messages, dto.Message{Role: role, Content: content}), nil
|
||||
return append(messages, dto.Message{Role: role, Content: content}), nil, nil
|
||||
}
|
||||
|
||||
func responsesInputContentToChatContent(content any) (any, error) {
|
||||
func responsesInputContentToChatContent(ctx context.Context, content any) (any, error) {
|
||||
if content == nil {
|
||||
return "", nil
|
||||
}
|
||||
@@ -222,22 +242,23 @@ func responsesInputContentToChatContent(content any) (any, error) {
|
||||
case string:
|
||||
return value, nil
|
||||
case []any:
|
||||
return responsesContentPartsToChatContent(value)
|
||||
return responsesContentPartsToChatContent(ctx, value)
|
||||
case []map[string]any:
|
||||
parts := make([]any, 0, len(value))
|
||||
for _, part := range value {
|
||||
parts = append(parts, part)
|
||||
}
|
||||
return responsesContentPartsToChatContent(parts)
|
||||
return responsesContentPartsToChatContent(ctx, parts)
|
||||
default:
|
||||
return content, nil
|
||||
}
|
||||
}
|
||||
|
||||
func responsesContentPartsToChatContent(parts []any) (any, error) {
|
||||
func responsesContentPartsToChatContent(ctx context.Context, parts []any) (any, error) {
|
||||
chatParts := make([]any, 0, len(parts))
|
||||
var textOnly strings.Builder
|
||||
onlyText := true
|
||||
droppedFile := false
|
||||
|
||||
for _, rawPart := range parts {
|
||||
part, ok := rawPart.(map[string]any)
|
||||
@@ -264,10 +285,18 @@ func responsesContentPartsToChatContent(parts []any) (any, error) {
|
||||
})
|
||||
case "input_file":
|
||||
onlyText = false
|
||||
chatParts = append(chatParts, map[string]any{
|
||||
"type": dto.ContentTypeFile,
|
||||
"file": responsesFilePartToChatFile(part),
|
||||
})
|
||||
chatPart, reason := responsesFilePartToChat(ctx, part)
|
||||
if reason != "" {
|
||||
convdiag.Add(ctx, types.ConversionDiagnostic{
|
||||
Code: "unsupported_media_type",
|
||||
Path: "input.content",
|
||||
Message: reason + "; the file was omitted",
|
||||
Severity: types.ConversionDiagnosticError,
|
||||
})
|
||||
droppedFile = true
|
||||
continue
|
||||
}
|
||||
chatParts = append(chatParts, chatPart)
|
||||
case "input_audio":
|
||||
onlyText = false
|
||||
chatParts = append(chatParts, map[string]any{
|
||||
@@ -289,11 +318,15 @@ func responsesContentPartsToChatContent(parts []any) (any, error) {
|
||||
if onlyText {
|
||||
return textOnly.String(), nil
|
||||
}
|
||||
if len(chatParts) == 0 && droppedFile {
|
||||
// Chat rejects an empty content array; name what was omitted instead.
|
||||
return "[file]", nil
|
||||
}
|
||||
return chatParts, nil
|
||||
}
|
||||
|
||||
func responsesFunctionCallItemToChatToolCall(item map[string]any) (dto.ToolCallRequest, error) {
|
||||
name := strings.TrimSpace(kitutil.Interface2String(item["name"]))
|
||||
name := responsesCallName(item)
|
||||
if name == "" {
|
||||
return dto.ToolCallRequest{}, errors.New("function_call item is missing name")
|
||||
}
|
||||
@@ -307,18 +340,26 @@ func responsesFunctionCallItemToChatToolCall(item map[string]any) (dto.ToolCallR
|
||||
}, nil
|
||||
}
|
||||
|
||||
// responsesCustomToolCallItemToChatToolCall encodes a custom tool call the same
|
||||
// way its definition reaches Chat Completions: a function call whose arguments
|
||||
// carry the raw input under the single "input" key.
|
||||
func responsesCustomToolCallItemToChatToolCall(item map[string]any) (dto.ToolCallRequest, error) {
|
||||
raw, err := kitutil.Marshal(item)
|
||||
name := responsesCallName(item)
|
||||
if name == "" {
|
||||
return dto.ToolCallRequest{}, errors.New("custom_tool_call item is missing name")
|
||||
}
|
||||
arguments, err := kitutil.Marshal(map[string]string{
|
||||
convmeta.CustomToolInputArgument: responsesArgumentsString(item["input"]),
|
||||
})
|
||||
if err != nil {
|
||||
return dto.ToolCallRequest{}, err
|
||||
}
|
||||
return dto.ToolCallRequest{
|
||||
ID: responsesCallID(item),
|
||||
Type: dto.CustomType,
|
||||
Custom: raw,
|
||||
ID: responsesCallID(item),
|
||||
Type: "function",
|
||||
Function: dto.FunctionRequest{
|
||||
Name: strings.TrimSpace(kitutil.Interface2String(item["name"])),
|
||||
Arguments: responsesArgumentsString(item["input"]),
|
||||
Name: name,
|
||||
Arguments: string(arguments),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -443,6 +484,12 @@ func RequestTextToChatResponseFormat(raw json.RawMessage) (*dto.ResponseFormat,
|
||||
|
||||
func responsesImagePartToChatImageURL(part map[string]any) any {
|
||||
if imageURL, ok := part["image_url"]; ok {
|
||||
// Chat Completions takes the URL inside an object. detail is left out:
|
||||
// Chat upstreams that validate it with older OpenAI SDKs reject newer
|
||||
// values such as "original".
|
||||
if url, ok := imageURL.(string); ok {
|
||||
return map[string]any{"url": url}
|
||||
}
|
||||
return imageURL
|
||||
}
|
||||
imageURL := map[string]any{}
|
||||
@@ -457,20 +504,52 @@ func responsesImagePartToChatImageURL(part map[string]any) any {
|
||||
return imageURL
|
||||
}
|
||||
|
||||
func responsesFilePartToChatFile(part map[string]any) any {
|
||||
if file, ok := part["file"]; ok {
|
||||
return file
|
||||
// responsesFilePartToChat maps a Responses input_file onto Chat Completions,
|
||||
// whose file part takes only PDF data or an uploaded file_id and no file URL.
|
||||
// Files given by data or URL are loaded through the media resolver: PDF
|
||||
// becomes a Chat file part with inline data and text files become a text
|
||||
// part. A non-empty reason means Chat cannot carry the file.
|
||||
func responsesFilePartToChat(ctx context.Context, part map[string]any) (map[string]any, string) {
|
||||
fields := part
|
||||
if nested, ok := part["file"].(map[string]any); ok {
|
||||
fields = nested
|
||||
}
|
||||
file := map[string]any{}
|
||||
for _, key := range []string{"file_id", "file_data", "filename", "file_url"} {
|
||||
if value, ok := part[key]; ok {
|
||||
file[key] = value
|
||||
filename := strings.TrimSpace(kitutil.Interface2String(fields["filename"]))
|
||||
source := ContentPartToFileSource(part)
|
||||
if source == nil {
|
||||
fileID := strings.TrimSpace(kitutil.Interface2String(fields["file_id"]))
|
||||
if fileID == "" {
|
||||
return nil, "input_file carries no file_data, file_url, or file_id"
|
||||
}
|
||||
file := map[string]any{"file_id": fileID}
|
||||
if filename != "" {
|
||||
file["filename"] = filename
|
||||
}
|
||||
return map[string]any{"type": dto.ContentTypeFile, "file": file}, ""
|
||||
}
|
||||
if len(file) == 0 {
|
||||
return part
|
||||
base64Data, mimeType, err := relaymedia.ResolveBase64Data(ctx, source, "formatting Responses input file for Chat")
|
||||
if err != nil {
|
||||
return nil, fmt.Sprintf("input_file could not be loaded: %s", err.Error())
|
||||
}
|
||||
return file
|
||||
mediaType, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(mimeType)), ";")
|
||||
mediaType = strings.TrimSpace(mediaType)
|
||||
switch {
|
||||
case mediaType == "application/pdf":
|
||||
if filename == "" {
|
||||
filename = "document.pdf"
|
||||
}
|
||||
return map[string]any{"type": dto.ContentTypeFile, "file": map[string]any{
|
||||
"filename": filename,
|
||||
"file_data": "data:application/pdf;base64," + base64Data,
|
||||
}}, ""
|
||||
case relaymedia.IsTextMimeType(mediaType):
|
||||
text, ok := relaymedia.DecodeText(base64Data)
|
||||
if !ok {
|
||||
return nil, fmt.Sprintf("input_file of MIME type %q could not be decoded as UTF-8 text", mimeType)
|
||||
}
|
||||
return map[string]any{"type": dto.ContentTypeText, "text": text}, ""
|
||||
}
|
||||
return nil, fmt.Sprintf("OpenAI Chat Completions file parts accept only PDF, not %q", mimeType)
|
||||
}
|
||||
|
||||
func responsesVideoPartToChatVideoURL(part map[string]any) any {
|
||||
@@ -514,6 +593,17 @@ func CallID(item map[string]any) string {
|
||||
return responsesCallID(item)
|
||||
}
|
||||
|
||||
// responsesCallName is the upstream function name of a function_call or
|
||||
// custom_tool_call item: a call made in a tool namespace uses the name its
|
||||
// flattened definition was sent with.
|
||||
func responsesCallName(item map[string]any) string {
|
||||
name := strings.TrimSpace(kitutil.Interface2String(item["name"]))
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
return convmeta.NamespacedToolName(strings.TrimSpace(kitutil.Interface2String(item["namespace"])), name)
|
||||
}
|
||||
|
||||
func responsesArgumentsString(value any) string {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
@@ -544,6 +634,57 @@ func responseToolOutputToChatContent(value any) any {
|
||||
}
|
||||
}
|
||||
|
||||
// responsesToolOutputToChat maps a function_call_output payload onto Chat Completions, where a
|
||||
// tool message may only carry text. A Responses content-part array keeps its text on the tool
|
||||
// message and returns the media parts in Chat shape for the caller to hoist into a user message;
|
||||
// stringifying them instead would hand base64 image data to the upstream text tokenizer. Any
|
||||
// other payload shape (string, object, plain JSON array) keeps the historical stringified form.
|
||||
func responsesToolOutputToChat(ctx context.Context, value any) (any, []any) {
|
||||
rawParts, ok := value.([]any)
|
||||
if !ok || len(rawParts) == 0 {
|
||||
return responseToolOutputToChatContent(value), nil
|
||||
}
|
||||
|
||||
texts := make([]string, 0, len(rawParts))
|
||||
mediaParts := make([]any, 0, len(rawParts))
|
||||
labels := make([]string, 0, len(rawParts))
|
||||
for _, rawPart := range rawParts {
|
||||
part, ok := rawPart.(map[string]any)
|
||||
if !ok {
|
||||
return responseToolOutputToChatContent(value), nil
|
||||
}
|
||||
partType := strings.TrimSpace(kitutil.Interface2String(part["type"]))
|
||||
switch partType {
|
||||
case "input_text", "output_text", "text":
|
||||
if text := kitutil.Interface2String(part["text"]); text != "" {
|
||||
texts = append(texts, text)
|
||||
}
|
||||
case "input_image", "input_file", "input_audio", "input_video":
|
||||
mediaParts = append(mediaParts, part)
|
||||
label := "[" + strings.TrimPrefix(partType, "input_") + "]"
|
||||
if !slices.Contains(labels, label) {
|
||||
labels = append(labels, label)
|
||||
}
|
||||
default:
|
||||
return responseToolOutputToChatContent(value), nil
|
||||
}
|
||||
}
|
||||
if len(mediaParts) == 0 {
|
||||
return strings.Join(texts, "\n"), nil
|
||||
}
|
||||
|
||||
converted, err := responsesContentPartsToChatContent(ctx, mediaParts)
|
||||
if err != nil {
|
||||
return responseToolOutputToChatContent(value), nil
|
||||
}
|
||||
media, _ := converted.([]any)
|
||||
if len(texts) == 0 {
|
||||
// Upstreams reject empty tool content; the media itself rides on the hoisted user message.
|
||||
return strings.Join(labels, " "), media
|
||||
}
|
||||
return strings.Join(texts, "\n"), media
|
||||
}
|
||||
|
||||
func responsesRawFloat(raw json.RawMessage) (*float64, error) {
|
||||
if !rawJSONPresent(raw) {
|
||||
return nil, nil
|
||||
|
||||
@@ -233,29 +233,44 @@ func TestResponsesRequestToChatCompletionsRequestToolsToolChoiceAndTextFormat(t
|
||||
assert.True(t, gjson.GetBytes(got.ResponseFormat.JsonSchema, "strict").Bool())
|
||||
}
|
||||
|
||||
func TestResponsesRequestToChatCompletionsRequestCustomToolCallPreservesRawShape(t *testing.T) {
|
||||
func TestResponsesRequestToChatCompletionsRequestEncodesCustomToolHistoryAsFunction(t *testing.T) {
|
||||
got, err := ResponsesRequestToChatCompletionsRequest(context.Background(), &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",
|
||||
"name": "exec",
|
||||
"input": "echo \"hi\" && ls",
|
||||
},
|
||||
{
|
||||
"type": "custom_tool_call_output",
|
||||
"call_id": "call_custom",
|
||||
"output": "hi",
|
||||
},
|
||||
}),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, got.Messages, 1)
|
||||
require.Len(t, got.Messages, 2)
|
||||
toolCalls := got.Messages[0].ParseToolCalls()
|
||||
require.Len(t, toolCalls, 1)
|
||||
assert.Equal(t, dto.CustomType, toolCalls[0].Type)
|
||||
assert.Equal(t, "function", 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())
|
||||
assert.Equal(t, "exec", toolCalls[0].Function.Name)
|
||||
assert.Equal(t, `echo "hi" && ls`, gjson.Get(toolCalls[0].Function.Arguments, "input").String())
|
||||
assert.Empty(t, toolCalls[0].Custom)
|
||||
assert.Equal(t, dto.Message{Role: "tool", ToolCallId: "call_custom", Content: "hi"}, got.Messages[1])
|
||||
}
|
||||
|
||||
func TestResponsesRequestToChatCompletionsRequestRejectsNamelessCustomToolCall(t *testing.T) {
|
||||
_, err := ResponsesRequestToChatCompletionsRequest(context.Background(), &dto.OpenAIResponsesRequest{
|
||||
Model: "gpt-test",
|
||||
Input: mustRawMessage(t, []map[string]any{
|
||||
{"type": "custom_tool_call", "call_id": "call_custom", "input": "ls"},
|
||||
}),
|
||||
})
|
||||
require.ErrorContains(t, err, "custom_tool_call item is missing name")
|
||||
}
|
||||
|
||||
func TestResponsesRequestToChatCompletionsRequestRejectsStatefulFields(t *testing.T) {
|
||||
@@ -351,6 +366,178 @@ func TestResponsesRequestToChatCompletionsRequestRejectsMalformedPenalty(t *test
|
||||
assert.Contains(t, err.Error(), "frequency_penalty")
|
||||
}
|
||||
|
||||
func TestResponsesRequestToChatCompletionsRequestToolOutputContentParts(t *testing.T) {
|
||||
const dataURL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
imagePart := map[string]any{"type": "input_image", "image_url": dataURL}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
output any
|
||||
wantContent string
|
||||
jsonContent bool
|
||||
wantMedia []string
|
||||
}{
|
||||
{
|
||||
name: "text and image keep text on tool message",
|
||||
output: []any{map[string]any{"type": "input_text", "text": "screenshot taken"}, imagePart},
|
||||
wantContent: "screenshot taken",
|
||||
wantMedia: []string{dto.ContentTypeImageURL},
|
||||
},
|
||||
{
|
||||
name: "image only uses placeholder",
|
||||
output: []any{imagePart},
|
||||
wantContent: "[image]",
|
||||
wantMedia: []string{dto.ContentTypeImageURL},
|
||||
},
|
||||
{
|
||||
name: "mixed media dedupes placeholder labels",
|
||||
output: []any{imagePart, map[string]any{"type": "input_file", "file_id": "file_1"}, imagePart},
|
||||
wantContent: "[image] [file]",
|
||||
wantMedia: []string{dto.ContentTypeImageURL, dto.ContentTypeFile, dto.ContentTypeImageURL},
|
||||
},
|
||||
{
|
||||
name: "text parts join with newline",
|
||||
output: []any{
|
||||
map[string]any{"type": "input_text", "text": "first"},
|
||||
map[string]any{"type": "output_text", "text": ""},
|
||||
map[string]any{"type": "text", "text": "second"},
|
||||
},
|
||||
wantContent: "first\nsecond",
|
||||
},
|
||||
{
|
||||
name: "string passes through",
|
||||
output: "done",
|
||||
wantContent: "done",
|
||||
},
|
||||
{
|
||||
name: "object stays json",
|
||||
output: map[string]any{"ok": true},
|
||||
wantContent: `{"ok":true}`,
|
||||
jsonContent: true,
|
||||
},
|
||||
{
|
||||
name: "plain array stays json",
|
||||
output: []any{1, 2},
|
||||
wantContent: `[1,2]`,
|
||||
jsonContent: true,
|
||||
},
|
||||
{
|
||||
name: "unknown part keeps whole array",
|
||||
output: []any{imagePart, map[string]any{"type": "refusal", "refusal": "no"}},
|
||||
wantContent: `[{"type":"input_image","image_url":"` + dataURL + `"},{"type":"refusal","refusal":"no"}]`,
|
||||
jsonContent: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ResponsesRequestToChatCompletionsRequest(context.Background(), &dto.OpenAIResponsesRequest{
|
||||
Model: "gpt-test",
|
||||
Input: mustRawMessage(t, []map[string]any{
|
||||
{"type": "function_call", "call_id": "call_1", "name": "view_image", "arguments": "{}"},
|
||||
{"type": "function_call_output", "call_id": "call_1", "output": tt.output},
|
||||
{"role": "user", "content": "next"},
|
||||
}),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
wantLen := 3
|
||||
if tt.wantMedia != nil {
|
||||
wantLen = 4
|
||||
}
|
||||
require.Len(t, got.Messages, wantLen)
|
||||
assert.Equal(t, "tool", got.Messages[1].Role)
|
||||
assert.Equal(t, "call_1", got.Messages[1].ToolCallId)
|
||||
if tt.jsonContent {
|
||||
assert.JSONEq(t, tt.wantContent, got.Messages[1].StringContent())
|
||||
} else {
|
||||
assert.Equal(t, tt.wantContent, got.Messages[1].StringContent())
|
||||
}
|
||||
assert.Equal(t, dto.Message{Role: "user", Content: "next"}, got.Messages[wantLen-1])
|
||||
if tt.wantMedia == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "user", got.Messages[2].Role)
|
||||
parts := got.Messages[2].ParseContent()
|
||||
require.Len(t, parts, len(tt.wantMedia))
|
||||
for i, wantType := range tt.wantMedia {
|
||||
assert.Equal(t, wantType, parts[i].Type)
|
||||
}
|
||||
require.NotNil(t, parts[0].GetImageMedia())
|
||||
assert.Equal(t, dataURL, parts[0].GetImageMedia().Url)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesRequestToChatCompletionsRequestHoistsToolOutputMediaAfterToolBatch(t *testing.T) {
|
||||
const dataURL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
imageOutput := []any{map[string]any{"type": "input_image", "image_url": dataURL}}
|
||||
|
||||
t.Run("parallel outputs stay contiguous", func(t *testing.T) {
|
||||
got, err := ResponsesRequestToChatCompletionsRequest(context.Background(), &dto.OpenAIResponsesRequest{
|
||||
Model: "gpt-test",
|
||||
Input: mustRawMessage(t, []map[string]any{
|
||||
{"type": "function_call", "call_id": "call_1", "name": "screenshot", "arguments": "{}"},
|
||||
{"type": "function_call", "call_id": "call_2", "name": "read_file", "arguments": "{}"},
|
||||
{"type": "function_call_output", "call_id": "call_1", "output": imageOutput},
|
||||
{"type": "function_call_output", "call_id": "call_2", "output": "file contents"},
|
||||
{"role": "user", "content": "what do you see?"},
|
||||
}),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, got.Messages, 5)
|
||||
assert.Equal(t, "assistant", got.Messages[0].Role)
|
||||
assert.Len(t, got.Messages[0].ParseToolCalls(), 2)
|
||||
assert.Equal(t, dto.Message{Role: "tool", ToolCallId: "call_1", Content: "[image]"}, got.Messages[1])
|
||||
assert.Equal(t, dto.Message{Role: "tool", ToolCallId: "call_2", Content: "file contents"}, got.Messages[2])
|
||||
assert.Equal(t, "user", got.Messages[3].Role)
|
||||
parts := got.Messages[3].ParseContent()
|
||||
require.Len(t, parts, 1)
|
||||
assert.Equal(t, dto.ContentTypeImageURL, parts[0].Type)
|
||||
assert.Equal(t, dto.Message{Role: "user", Content: "what do you see?"}, got.Messages[4])
|
||||
})
|
||||
|
||||
t.Run("custom tool outputs stay in the same batch", func(t *testing.T) {
|
||||
got, err := ResponsesRequestToChatCompletionsRequest(context.Background(), &dto.OpenAIResponsesRequest{
|
||||
Model: "gpt-test",
|
||||
Input: mustRawMessage(t, []map[string]any{
|
||||
{"type": "function_call", "call_id": "call_1", "name": "screenshot", "arguments": "{}"},
|
||||
{"type": "custom_tool_call", "call_id": "call_2", "name": "exec", "input": "ls"},
|
||||
{"type": "function_call_output", "call_id": "call_1", "output": imageOutput},
|
||||
{"type": "custom_tool_call_output", "call_id": "call_2", "output": "file.txt"},
|
||||
}),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, got.Messages, 4)
|
||||
assert.Len(t, got.Messages[0].ParseToolCalls(), 2)
|
||||
assert.Equal(t, dto.Message{Role: "tool", ToolCallId: "call_1", Content: "[image]"}, got.Messages[1])
|
||||
assert.Equal(t, dto.Message{Role: "tool", ToolCallId: "call_2", Content: "file.txt"}, got.Messages[2])
|
||||
assert.Equal(t, "user", got.Messages[3].Role)
|
||||
})
|
||||
|
||||
t.Run("trailing output flushes media at end of input", func(t *testing.T) {
|
||||
got, err := ResponsesRequestToChatCompletionsRequest(context.Background(), &dto.OpenAIResponsesRequest{
|
||||
Model: "gpt-test",
|
||||
Input: mustRawMessage(t, []map[string]any{
|
||||
{"type": "function_call", "call_id": "call_1", "name": "screenshot", "arguments": "{}"},
|
||||
{"type": "function_call_output", "call_id": "call_1", "output": imageOutput},
|
||||
}),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, got.Messages, 3)
|
||||
assert.Equal(t, dto.Message{Role: "tool", ToolCallId: "call_1", Content: "[image]"}, got.Messages[1])
|
||||
assert.Equal(t, "user", got.Messages[2].Role)
|
||||
parts := got.Messages[2].ParseContent()
|
||||
require.Len(t, parts, 1)
|
||||
require.NotNil(t, parts[0].GetImageMedia())
|
||||
assert.Equal(t, dataURL, parts[0].GetImageMedia().Url)
|
||||
})
|
||||
}
|
||||
|
||||
func mustRawMessage(t *testing.T, value any) []byte {
|
||||
t.Helper()
|
||||
raw, err := kitutil.Marshal(value)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
|
||||
)
|
||||
|
||||
// imageMediaTypes are the image formats Claude accepts.
|
||||
var imageMediaTypes = map[string]struct{}{
|
||||
"image/jpeg": {},
|
||||
"image/png": {},
|
||||
"image/gif": {},
|
||||
"image/webp": {},
|
||||
}
|
||||
|
||||
// MediaBlock builds the Claude content block for resolved base64 media: an
|
||||
// image in a format Claude accepts, a PDF document, or a plain-text document
|
||||
// for text files. declaredImage marks media sent as an image part, which
|
||||
// passes through when its MIME type is unknown. A non-empty reason means
|
||||
// Claude cannot carry the media and the block must be omitted.
|
||||
func MediaBlock(base64Data string, mimeType string, declaredImage bool) (dto.ClaudeMediaMessage, string) {
|
||||
mediaType, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(mimeType)), ";")
|
||||
mediaType = strings.TrimSpace(mediaType)
|
||||
if mediaType == "image/jpg" {
|
||||
mediaType = "image/jpeg"
|
||||
}
|
||||
switch {
|
||||
case mediaType == "application/pdf":
|
||||
return dto.ClaudeMediaMessage{
|
||||
Type: "document",
|
||||
Source: &dto.ClaudeMessageSource{Type: "base64", MediaType: mediaType, Data: base64Data},
|
||||
}, ""
|
||||
case relaymedia.IsTextMimeType(mediaType):
|
||||
text, ok := relaymedia.DecodeText(base64Data)
|
||||
if !ok {
|
||||
return dto.ClaudeMediaMessage{}, fmt.Sprintf("text file of MIME type %q could not be decoded as UTF-8 text", mimeType)
|
||||
}
|
||||
return dto.ClaudeMediaMessage{
|
||||
Type: "document",
|
||||
Source: &dto.ClaudeMessageSource{Type: "text", MediaType: "text/plain", Data: text},
|
||||
}, ""
|
||||
case mediaType == "" && declaredImage:
|
||||
return dto.ClaudeMediaMessage{
|
||||
Type: "image",
|
||||
Source: &dto.ClaudeMessageSource{Type: "base64", MediaType: mimeType, Data: base64Data},
|
||||
}, ""
|
||||
case mediaType == "application/octet-stream" && declaredImage:
|
||||
return dto.ClaudeMediaMessage{}, "the image format could not be identified"
|
||||
}
|
||||
if _, ok := imageMediaTypes[mediaType]; ok {
|
||||
return dto.ClaudeMediaMessage{
|
||||
Type: "image",
|
||||
Source: &dto.ClaudeMessageSource{Type: "base64", MediaType: mediaType, Data: base64Data},
|
||||
}, ""
|
||||
}
|
||||
if strings.HasPrefix(mediaType, "image/") {
|
||||
return dto.ClaudeMediaMessage{}, fmt.Sprintf("Claude accepts only JPEG, PNG, GIF, and WebP images, not %q", mimeType)
|
||||
}
|
||||
return dto.ClaudeMediaMessage{}, fmt.Sprintf("Claude Messages cannot carry content of MIME type %q", mimeType)
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func ApplyReasoning(ctx context.Context, req *dto.ClaudeRequest, info convmeta.M
|
||||
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)
|
||||
effort = string(reasoning.ClaudeDefaultEffort(req.Model))
|
||||
}
|
||||
}
|
||||
info.SetReasoningEffort(effort)
|
||||
@@ -81,6 +81,16 @@ func ApplyReasoning(ctx context.Context, req *dto.ClaudeRequest, info convmeta.M
|
||||
intent.Effort = reasoning.EffortHigh
|
||||
}
|
||||
}
|
||||
requestEffort, err := reasoning.ParseEffort(req.GetEfforts())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if knownClaudeModel && intent.Mode == reasoning.ModeDisabled && (intent.Effort == "" || intent.Effort == reasoning.EffortNone) &&
|
||||
requestEffort != "" && requestEffort != reasoning.EffortNone {
|
||||
// Effort works without thinking, so the request's own effort is
|
||||
// rendered for the thinking-off state rather than left unchecked.
|
||||
intent.Effort = requestEffort
|
||||
}
|
||||
if req.MaxTokens == nil && intent.HasStrength() {
|
||||
// Adapter-provided defaults may be raised to accommodate an exact
|
||||
// cross-protocol budget. Values too small for manual thinking are
|
||||
@@ -117,7 +127,14 @@ func ApplyReasoning(ctx context.Context, req *dto.ClaudeRequest, info convmeta.M
|
||||
if rendered.Thinking != nil {
|
||||
req.Thinking = rendered.Thinking
|
||||
}
|
||||
if rendered.OutputEffort != "" {
|
||||
outputEffort := rendered.OutputEffort
|
||||
// An effort the request still carries reaches a known model only at a
|
||||
// level it accepts, and is removed where the model takes none.
|
||||
rewriteRequestEffort := outputEffort == "" && knownClaudeModel && requestEffort != ""
|
||||
if rewriteRequestEffort {
|
||||
outputEffort = reasoning.ClaudeEffort(capabilityModel, requestEffort)
|
||||
}
|
||||
if outputEffort != "" || rewriteRequestEffort {
|
||||
outputConfig := make(map[string]any)
|
||||
if len(req.OutputConfig) > 0 {
|
||||
if kitutil.GetJsonType(req.OutputConfig) != "object" {
|
||||
@@ -130,12 +147,20 @@ func ApplyReasoning(ctx context.Context, req *dto.ClaudeRequest, info convmeta.M
|
||||
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)
|
||||
if outputEffort != "" {
|
||||
outputConfig["effort"] = string(outputEffort)
|
||||
} else {
|
||||
delete(outputConfig, "effort")
|
||||
}
|
||||
if len(outputConfig) == 0 {
|
||||
req.OutputConfig = nil
|
||||
} else {
|
||||
encoded, err := kitutil.Marshal(outputConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal Claude output_config: %w", err)
|
||||
}
|
||||
req.OutputConfig = encoded
|
||||
}
|
||||
req.OutputConfig = encoded
|
||||
}
|
||||
if rendered.ClearSampling {
|
||||
if req.Temperature != nil || req.TopP != nil || req.TopK != nil {
|
||||
|
||||
@@ -3,6 +3,8 @@ package gemini
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -13,26 +15,43 @@ import (
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
)
|
||||
|
||||
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,
|
||||
// supportedMimeTypes follows the Blob.mimeType list in the Gemini API
|
||||
// reference (https://ai.google.dev/api/generate-content#Blob). Audio and
|
||||
// video are listed there as audio/* and video/*; IsSupportedMimeType accepts
|
||||
// them by prefix.
|
||||
var supportedMimeTypes = map[string]bool{
|
||||
"image/png": true,
|
||||
"image/jpeg": true,
|
||||
"image/jpg": true,
|
||||
"image/webp": true,
|
||||
"image/heic": true,
|
||||
"image/heif": true,
|
||||
"image/gif": true,
|
||||
"image/avif": true,
|
||||
"text/plain": true,
|
||||
"text/html": true,
|
||||
"text/css": true,
|
||||
"text/javascript": true,
|
||||
"text/x-typescript": true,
|
||||
"text/csv": true,
|
||||
"text/markdown": true,
|
||||
"text/x-python": true,
|
||||
"text/xml": true,
|
||||
"text/rtf": true,
|
||||
"application/x-javascript": true,
|
||||
"application/x-typescript": true,
|
||||
"application/x-python-code": true,
|
||||
"application/json": true,
|
||||
"application/x-ipynb+json": true,
|
||||
"application/rtf": true,
|
||||
"application/pdf": true,
|
||||
}
|
||||
|
||||
// IsSupportedMimeType reports whether Gemini accepts inline data of the MIME
|
||||
// type.
|
||||
func IsSupportedMimeType(mimeType string) bool {
|
||||
mimeType = strings.ToLower(mimeType)
|
||||
return supportedMimeTypes[mimeType] || strings.HasPrefix(mimeType, "audio/") || strings.HasPrefix(mimeType, "video/")
|
||||
}
|
||||
|
||||
var SafetySettingCategories = []string{
|
||||
@@ -272,9 +291,5 @@ func HasFunctionCallContent(call *dto.FunctionCall) bool {
|
||||
}
|
||||
|
||||
func SupportedMimeTypesList() []string {
|
||||
keys := make([]string, 0, len(SupportedMimeTypes))
|
||||
for key := range SupportedMimeTypes {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys
|
||||
return append(slices.Sorted(maps.Keys(supportedMimeTypes)), "audio/*", "video/*")
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
)
|
||||
|
||||
var geminiOpenAPISchemaAllowedFields = map[string]struct{}{
|
||||
@@ -56,6 +64,7 @@ func cleanGeminiFunctionParametersWithDepth(params interface{}, depth int) inter
|
||||
}
|
||||
|
||||
normalizeGeminiSchemaTypeAndNullable(cleanedMap)
|
||||
sanitizeGeminiEnum(cleanedMap)
|
||||
|
||||
if props, ok := cleanedMap["properties"].(map[string]interface{}); ok && props != nil {
|
||||
cleanedProps := make(map[string]interface{})
|
||||
@@ -102,6 +111,7 @@ func cleanGeminiFunctionParametersShallow(params interface{}) interface{} {
|
||||
}
|
||||
}
|
||||
normalizeGeminiSchemaTypeAndNullable(cleanedMap)
|
||||
sanitizeGeminiEnum(cleanedMap)
|
||||
delete(cleanedMap, "properties")
|
||||
delete(cleanedMap, "items")
|
||||
delete(cleanedMap, "anyOf")
|
||||
@@ -113,6 +123,30 @@ func cleanGeminiFunctionParametersShallow(params interface{}) interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeGeminiEnum keeps the enum constraint only when every element is a
|
||||
// string, the only form Gemini's legacy Schema accepts. Any other shape is
|
||||
// dropped (e.g. boolean or number elements, which valid JSON Schema allows but
|
||||
// Gemini rejects with a 400). This widens the value domain for that schema, a
|
||||
// deliberate lossy relaxation: the declared type is preserved, string enums
|
||||
// pass through untouched.
|
||||
func sanitizeGeminiEnum(schema map[string]interface{}) {
|
||||
raw, exists := schema["enum"]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
values, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
delete(schema, "enum")
|
||||
return
|
||||
}
|
||||
for _, value := range values {
|
||||
if _, isString := value.(string); !isString {
|
||||
delete(schema, "enum")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeGeminiSchemaTypeAndNullable(schema map[string]interface{}) {
|
||||
rawType, ok := schema["type"]
|
||||
if !ok || rawType == nil {
|
||||
@@ -175,6 +209,148 @@ func normalizeGeminiSchemaTypeAndNullable(schema map[string]interface{}) {
|
||||
}
|
||||
}
|
||||
|
||||
// FunctionParameters returns the Gemini function declaration field that
|
||||
// carries schema, and its value. A schema that fits Gemini's typed Schema is
|
||||
// cleaned as before, and still left out when its properties are empty. Any
|
||||
// other schema goes unchanged to parametersJsonSchema when jsonSchema is
|
||||
// true; otherwise it is cleaned anyway and misfits names what the typed
|
||||
// Schema cannot carry.
|
||||
func FunctionParameters(schema any, jsonSchema bool) (field string, value any, misfits []string) {
|
||||
misfits = schemaMisfits(schema)
|
||||
object, _ := schema.(map[string]any)
|
||||
properties, hasProperties := object["properties"].(map[string]any)
|
||||
emptyObject := hasProperties && len(properties) == 0
|
||||
if emptyObject {
|
||||
for key, item := range object {
|
||||
switch key {
|
||||
case "type", "properties":
|
||||
continue
|
||||
case "required":
|
||||
if required, _ := item.([]any); len(required) == 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if _, allowed := geminiOpenAPISchemaAllowedFields[key]; allowed {
|
||||
misfits = append(misfits, "constraints on an empty object")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(misfits) > 0 && jsonSchema {
|
||||
return "parametersJsonSchema", schema, nil
|
||||
}
|
||||
if emptyObject {
|
||||
return "parameters", nil, misfits
|
||||
}
|
||||
return "parameters", CleanFunctionParameters(schema), misfits
|
||||
}
|
||||
|
||||
// ApplyResponseSchema sets the structured output schema from the json_schema
|
||||
// object of an OpenAI response format. A schema that fits Gemini's typed
|
||||
// Schema keeps the responseSchema cleanup. Any other schema is sent unchanged
|
||||
// as responseJsonSchema when jsonSchema is true; otherwise it is cleaned
|
||||
// anyway and a diagnostic at path names what responseSchema cannot carry.
|
||||
func ApplyResponseSchema(ctx context.Context, config *dto.GeminiChatGenerationConfig, rawFormat json.RawMessage, path string, jsonSchema bool) {
|
||||
var format dto.FormatJsonSchema
|
||||
if err := kitutil.Unmarshal(rawFormat, &format); err != nil {
|
||||
return
|
||||
}
|
||||
misfits := schemaMisfits(format.Schema)
|
||||
if len(misfits) > 0 && jsonSchema {
|
||||
// The raw member keeps the client's key order.
|
||||
var raw struct {
|
||||
Schema json.RawMessage `json:"schema"`
|
||||
}
|
||||
if err := kitutil.Unmarshal(rawFormat, &raw); err == nil {
|
||||
config.ResponseJsonSchema = raw.Schema
|
||||
return
|
||||
}
|
||||
}
|
||||
config.ResponseSchema = RemoveAdditionalProperties(format.Schema, 0)
|
||||
if len(misfits) > 0 {
|
||||
convdiag.Add(ctx, types.ConversionDiagnostic{
|
||||
Code: "gemini_schema_keyword_dropped",
|
||||
Path: path,
|
||||
Message: fmt.Sprintf("Gemini response schema cannot carry %s", strings.Join(misfits, ", ")),
|
||||
Severity: types.ConversionDiagnosticWarning,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// schemaMisfits lists what Gemini's typed Schema cannot hold of a JSON Schema
|
||||
// as written: keywords outside its fields, type arrays, non-string enum
|
||||
// values, tuple items, boolean schemas, and nesting deeper than
|
||||
// CleanFunctionParameters keeps. additionalProperties and $schema are not
|
||||
// misfits: agent tools and OpenAI strict schemas carry them on nearly every
|
||||
// object, and dropping them as before keeps those requests unchanged for
|
||||
// upstreams that do not pass the JSON Schema fields through.
|
||||
func schemaMisfits(schema any) []string {
|
||||
found := make(map[string]struct{})
|
||||
collectSchemaMisfits(schema, 0, found)
|
||||
misfits := make([]string, 0, len(found))
|
||||
for misfit := range found {
|
||||
misfits = append(misfits, misfit)
|
||||
}
|
||||
slices.Sort(misfits)
|
||||
return misfits
|
||||
}
|
||||
|
||||
func collectSchemaMisfits(schema any, depth int, found map[string]struct{}) {
|
||||
if depth > geminiFunctionSchemaMaxDepth {
|
||||
found[fmt.Sprintf("nesting deeper than %d levels", geminiFunctionSchemaMaxDepth)] = struct{}{}
|
||||
return
|
||||
}
|
||||
value, ok := schema.(map[string]any)
|
||||
if !ok {
|
||||
if _, isBool := schema.(bool); isBool {
|
||||
found["boolean schemas"] = struct{}{}
|
||||
}
|
||||
return
|
||||
}
|
||||
for key, item := range value {
|
||||
if _, allowed := geminiOpenAPISchemaAllowedFields[key]; !allowed {
|
||||
if key != "additionalProperties" && key != "$schema" {
|
||||
found[key] = struct{}{}
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "type":
|
||||
if _, isArray := item.([]any); isArray {
|
||||
found["type arrays"] = struct{}{}
|
||||
}
|
||||
case "enum":
|
||||
values, _ := item.([]any)
|
||||
if slices.ContainsFunc(values, func(value any) bool {
|
||||
_, isString := value.(string)
|
||||
return !isString
|
||||
}) {
|
||||
found["non-string enum values"] = struct{}{}
|
||||
}
|
||||
case "properties":
|
||||
properties, _ := item.(map[string]any)
|
||||
for _, property := range properties {
|
||||
collectSchemaMisfits(property, depth+1, found)
|
||||
}
|
||||
case "items":
|
||||
entries, isTuple := item.([]any)
|
||||
if !isTuple {
|
||||
collectSchemaMisfits(item, depth+1, found)
|
||||
continue
|
||||
}
|
||||
found["tuple items"] = struct{}{}
|
||||
for _, entry := range entries {
|
||||
collectSchemaMisfits(entry, depth+1, found)
|
||||
}
|
||||
case "anyOf":
|
||||
variants, _ := item.([]any)
|
||||
for _, variant := range variants {
|
||||
collectSchemaMisfits(variant, depth+1, found)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func RemoveAdditionalProperties(schema interface{}, depth int) interface{} {
|
||||
if depth >= 5 {
|
||||
return schema
|
||||
@@ -254,3 +430,112 @@ func OpenAIToolChoiceToConfig(toolChoice any) *dto.ToolConfig {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// geminiSchemaInt64Fields are Gemini Schema bounds typed int64, which the
|
||||
// proto JSON encoding may send as strings.
|
||||
var geminiSchemaInt64Fields = map[string]struct{}{
|
||||
"maxItems": {},
|
||||
"minItems": {},
|
||||
"maxLength": {},
|
||||
"minLength": {},
|
||||
"maxProperties": {},
|
||||
"minProperties": {},
|
||||
}
|
||||
|
||||
// OpenAPISchemaToJSONSchema rewrites a schema in Gemini's OpenAPI subset as
|
||||
// JSON Schema: type names are lowercased, nullable becomes a "null" type,
|
||||
// example becomes examples, and int64 bounds sent as strings become numbers.
|
||||
// It also returns the Gemini-only keywords JSON Schema cannot express, which
|
||||
// are dropped (propertyOrdering).
|
||||
func OpenAPISchemaToJSONSchema(schema any) (any, []string) {
|
||||
dropped := make(map[string]struct{})
|
||||
converted := openAPISchemaToJSONSchema(schema, 0, dropped)
|
||||
keywords := make([]string, 0, len(dropped))
|
||||
for keyword := range dropped {
|
||||
keywords = append(keywords, keyword)
|
||||
}
|
||||
slices.Sort(keywords)
|
||||
return converted, keywords
|
||||
}
|
||||
|
||||
func openAPISchemaToJSONSchema(schema any, depth int, dropped map[string]struct{}) any {
|
||||
value, ok := schema.(map[string]any)
|
||||
if !ok || depth >= geminiFunctionSchemaMaxDepth {
|
||||
return schema
|
||||
}
|
||||
converted := make(map[string]any, len(value))
|
||||
nullable := false
|
||||
for key, item := range value {
|
||||
switch key {
|
||||
case "type":
|
||||
typeName, isString := item.(string)
|
||||
if !isString {
|
||||
converted[key] = item
|
||||
continue
|
||||
}
|
||||
typeName = strings.ToLower(strings.TrimSpace(typeName))
|
||||
if typeName != "" && typeName != "type_unspecified" {
|
||||
converted[key] = typeName
|
||||
}
|
||||
case "nullable":
|
||||
nullable, _ = item.(bool)
|
||||
case "propertyOrdering":
|
||||
dropped[key] = struct{}{}
|
||||
case "example":
|
||||
if _, exists := value["examples"]; exists {
|
||||
dropped[key] = struct{}{}
|
||||
continue
|
||||
}
|
||||
converted["examples"] = []any{item}
|
||||
case "properties":
|
||||
properties, isMap := item.(map[string]any)
|
||||
if !isMap {
|
||||
converted[key] = item
|
||||
continue
|
||||
}
|
||||
convertedProperties := make(map[string]any, len(properties))
|
||||
for name, property := range properties {
|
||||
convertedProperties[name] = openAPISchemaToJSONSchema(property, depth+1, dropped)
|
||||
}
|
||||
converted[key] = convertedProperties
|
||||
case "items":
|
||||
converted[key] = openAPISchemaToJSONSchema(item, depth+1, dropped)
|
||||
case "anyOf":
|
||||
variants, isArray := item.([]any)
|
||||
if !isArray {
|
||||
converted[key] = item
|
||||
continue
|
||||
}
|
||||
convertedVariants := make([]any, len(variants))
|
||||
for index, variant := range variants {
|
||||
convertedVariants[index] = openAPISchemaToJSONSchema(variant, depth+1, dropped)
|
||||
}
|
||||
converted[key] = convertedVariants
|
||||
default:
|
||||
if _, isInt64 := geminiSchemaInt64Fields[key]; isInt64 {
|
||||
if text, isString := item.(string); isString {
|
||||
if number, err := strconv.ParseInt(strings.TrimSpace(text), 10, 64); err == nil {
|
||||
converted[key] = number
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
converted[key] = item
|
||||
}
|
||||
}
|
||||
if !nullable {
|
||||
return converted
|
||||
}
|
||||
switch typed := converted["type"].(type) {
|
||||
case string:
|
||||
converted["type"] = []any{typed, "null"}
|
||||
default:
|
||||
if variants, isArray := converted["anyOf"].([]any); isArray {
|
||||
converted["anyOf"] = append(variants, map[string]any{"type": "null"})
|
||||
}
|
||||
}
|
||||
if values, isArray := converted["enum"].([]any); isArray && !slices.Contains(values, nil) {
|
||||
converted["enum"] = append(values, nil)
|
||||
}
|
||||
return converted
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"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/types"
|
||||
)
|
||||
@@ -142,11 +143,11 @@ func extractOpenAIResponsesRequest(request any) (any, Set, error) {
|
||||
return nil, Set{}, fmt.Errorf("invalid Responses tools: %w", err)
|
||||
}
|
||||
for index, rawTool := range rawTools {
|
||||
definition, err := decodeOpenAIResponsesDefinition(rawTool)
|
||||
definitions, err := decodeOpenAIResponsesDefinitions(rawTool)
|
||||
if err != nil {
|
||||
return nil, Set{}, fmt.Errorf("tools[%d]: %w", index, err)
|
||||
}
|
||||
set.Definitions = append(set.Definitions, definition)
|
||||
set.Definitions = append(set.Definitions, definitions...)
|
||||
}
|
||||
}
|
||||
choice, err := decodeOpenAIResponsesChoice(source.ToolChoice)
|
||||
@@ -155,11 +156,41 @@ func extractOpenAIResponsesRequest(request any) (any, Set, error) {
|
||||
}
|
||||
set.Choice = choice
|
||||
|
||||
input, additionalDefinitions, additionalDiagnostics, err := extractOpenAIResponsesAdditionalTools(source.Input)
|
||||
if err != nil {
|
||||
return nil, Set{}, err
|
||||
}
|
||||
set.Definitions = append(set.Definitions, additionalDefinitions...)
|
||||
set.Diagnostics = append(set.Diagnostics, additionalDiagnostics...)
|
||||
// Flattened namespace names can collide with other tools.
|
||||
seenNames := make(map[string]struct{}, len(set.Definitions))
|
||||
seenNamespaces := make(map[string]struct{})
|
||||
definitions := set.Definitions[:0]
|
||||
for _, definition := range set.Definitions {
|
||||
if _, seen := seenNamespaces[definition.Namespace]; !seen && definition.Namespace != "" && definition.Namespace != convmeta.DefaultResponsesToolNamespace {
|
||||
seenNamespaces[definition.Namespace] = struct{}{}
|
||||
set.Diagnostics = append(set.Diagnostics, presentationLoss("tools", "tool_namespace_flattened",
|
||||
fmt.Sprintf("tools in namespace %q are sent as functions named %q", definition.Namespace, convmeta.NamespacedToolName(definition.Namespace, "<name>"))))
|
||||
}
|
||||
name := definition.Name
|
||||
_, duplicate := seenNames[name]
|
||||
if definition.Namespace != "" && name != "" && duplicate {
|
||||
set.Diagnostics = append(set.Diagnostics, semanticLoss("tools", "namespaced_tool_dropped",
|
||||
fmt.Sprintf("tool %q from namespace %q shares its flattened name with another tool and was omitted", name, definition.Namespace)))
|
||||
continue
|
||||
}
|
||||
if name != "" {
|
||||
seenNames[name] = struct{}{}
|
||||
}
|
||||
definitions = append(definitions, definition)
|
||||
}
|
||||
set.Definitions = definitions
|
||||
|
||||
clone := *source
|
||||
clone.Tools = nil
|
||||
clone.ToolChoice = nil
|
||||
clone.ParallelToolCalls = nil
|
||||
sanitizedInput, history, err := extractOpenAIResponsesHostedHistory(source.Input)
|
||||
sanitizedInput, history, err := extractOpenAIResponsesHostedHistory(input)
|
||||
if err != nil {
|
||||
return nil, Set{}, err
|
||||
}
|
||||
@@ -168,6 +199,103 @@ func extractOpenAIResponsesRequest(request any) (any, Set, error) {
|
||||
return &clone, set, nil
|
||||
}
|
||||
|
||||
// extractOpenAIResponsesAdditionalTools removes the additional_tools input
|
||||
// items Codex uses to declare tools and returns their definitions. No target
|
||||
// protocol has an input item that declares tools, so they join the top-level
|
||||
// tool list.
|
||||
func extractOpenAIResponsesAdditionalTools(input json.RawMessage) (json.RawMessage, []Definition, []types.ConversionDiagnostic, error) {
|
||||
if len(input) == 0 || kitutil.GetJsonType(input) != "array" {
|
||||
return input, nil, nil, nil
|
||||
}
|
||||
var rawItems []json.RawMessage
|
||||
if err := kitutil.Unmarshal(input, &rawItems); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("invalid Responses input: %w", err)
|
||||
}
|
||||
var (
|
||||
filtered []json.RawMessage
|
||||
definitions []Definition
|
||||
diagnostics []types.ConversionDiagnostic
|
||||
)
|
||||
for index, rawItem := range rawItems {
|
||||
var item struct {
|
||||
Type string `json:"type"`
|
||||
Tools []json.RawMessage `json:"tools"`
|
||||
}
|
||||
if err := kitutil.Unmarshal(rawItem, &item); err != nil || strings.TrimSpace(item.Type) != "additional_tools" {
|
||||
filtered = append(filtered, rawItem)
|
||||
continue
|
||||
}
|
||||
for toolIndex, rawTool := range item.Tools {
|
||||
decoded, err := decodeOpenAIResponsesDefinitions(rawTool)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("input[%d].tools[%d]: %w", index, toolIndex, err)
|
||||
}
|
||||
definitions = append(definitions, decoded...)
|
||||
}
|
||||
diagnostics = append(diagnostics, presentationLoss(fmt.Sprintf("input[%d]", index), "additional_tools_flattened",
|
||||
"additional_tools items are sent as top-level tools; their position in the input is not preserved"))
|
||||
}
|
||||
if len(diagnostics) == 0 {
|
||||
return input, nil, nil, nil
|
||||
}
|
||||
encoded, err := kitutil.Marshal(filtered)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
return encoded, definitions, diagnostics, nil
|
||||
}
|
||||
|
||||
// decodeOpenAIResponsesDefinitions decodes one Responses tool. A namespace
|
||||
// tool expands into its member tools, each renamed with
|
||||
// convmeta.NamespacedToolName and described with the namespace description.
|
||||
func decodeOpenAIResponsesDefinitions(raw json.RawMessage) ([]Definition, error) {
|
||||
var namespace struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Tools []map[string]any `json:"tools"`
|
||||
}
|
||||
if err := kitutil.Unmarshal(raw, &namespace); err != nil || strings.TrimSpace(namespace.Type) != "namespace" {
|
||||
definition, err := decodeOpenAIResponsesDefinition(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []Definition{definition}, nil
|
||||
}
|
||||
namespaceName := strings.TrimSpace(namespace.Name)
|
||||
if namespaceName == "" {
|
||||
return nil, fmt.Errorf("namespace tool requires name")
|
||||
}
|
||||
definitions := make([]Definition, 0, len(namespace.Tools))
|
||||
for index, tool := range namespace.Tools {
|
||||
name := strings.TrimSpace(kitutil.Interface2String(tool["name"]))
|
||||
if name != "" {
|
||||
tool["name"] = convmeta.NamespacedToolName(namespaceName, name)
|
||||
}
|
||||
description := strings.TrimSpace(kitutil.Interface2String(tool["description"]))
|
||||
if namespaceDescription := strings.TrimSpace(namespace.Description); namespaceDescription != "" {
|
||||
description = strings.TrimSpace(namespaceDescription + "\n\n" + description)
|
||||
}
|
||||
if description != "" {
|
||||
tool["description"] = description
|
||||
}
|
||||
encoded, err := kitutil.Marshal(tool)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
definition, err := decodeOpenAIResponsesDefinition(encoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("namespace %q tools[%d]: %w", namespaceName, index, err)
|
||||
}
|
||||
definition.Namespace = namespaceName
|
||||
if definition.Name == "" {
|
||||
definition.Name = kitutil.Interface2String(tool["name"])
|
||||
}
|
||||
definitions = append(definitions, definition)
|
||||
}
|
||||
return definitions, nil
|
||||
}
|
||||
|
||||
func extractClaudeRequest(request any) (any, Set, error) {
|
||||
source, ok := request.(*dto.ClaudeRequest)
|
||||
if !ok {
|
||||
@@ -428,9 +556,10 @@ func decodeGeminiDefinitions(raw json.RawMessage) ([]Definition, error) {
|
||||
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,
|
||||
Name: strings.TrimSpace(kitutil.Interface2String(function["name"])),
|
||||
Description: kitutil.Interface2String(function["description"]),
|
||||
Parameters: parameters,
|
||||
OpenAPISchema: function["parameters"] != nil,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -610,6 +739,7 @@ func decodeOpenAIResponsesChoice(raw json.RawMessage) (*Choice, error) {
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("Responses function tool_choice requires name")
|
||||
}
|
||||
name = convmeta.NamespacedToolName(strings.TrimSpace(kitutil.Interface2String(value["namespace"])), name)
|
||||
return &Choice{Mode: ChoiceNamed, Kind: KindFunction, Name: name}, nil
|
||||
}
|
||||
if isOpenAIResponsesWebSearchType(toolType) {
|
||||
|
||||
@@ -24,16 +24,17 @@ func AttachRequest(format types.RelayFormat, request any, set Set, options *conv
|
||||
)
|
||||
switch format {
|
||||
case types.RelayFormatOpenAI:
|
||||
value, diagnostics, err = attachOpenAIChatRequest(request, set)
|
||||
value, diagnostics, err = attachOpenAIChatRequest(request, set, options)
|
||||
case types.RelayFormatOpenAIResponses:
|
||||
value, diagnostics, err = attachOpenAIResponsesRequest(request, set)
|
||||
value, diagnostics, err = attachOpenAIResponsesRequest(request, set, options)
|
||||
case types.RelayFormatClaude:
|
||||
value, diagnostics, err = attachClaudeRequest(request, set, options)
|
||||
case types.RelayFormatGemini:
|
||||
value, diagnostics, err = attachGeminiRequest(request, set)
|
||||
value, diagnostics, err = attachGeminiRequest(request, set, options)
|
||||
default:
|
||||
value = request
|
||||
}
|
||||
diagnostics = append(set.Diagnostics, diagnostics...)
|
||||
if err != nil {
|
||||
return nil, diagnostics, err
|
||||
}
|
||||
@@ -47,24 +48,30 @@ func AttachRequest(format types.RelayFormat, request any, set Set, options *conv
|
||||
return value, diagnostics, nil
|
||||
}
|
||||
|
||||
func attachOpenAIChatRequest(request any, set Set) (any, []types.ConversionDiagnostic, error) {
|
||||
func attachOpenAIChatRequest(request any, set Set, options *convmeta.Options) (any, []types.ConversionDiagnostic, error) {
|
||||
target, ok := request.(*dto.GeneralOpenAIRequest)
|
||||
if !ok || target == nil {
|
||||
return nil, nil, fmt.Errorf("expected OpenAI chat completions request, got %T", request)
|
||||
}
|
||||
var diagnostics []types.ConversionDiagnostic
|
||||
customTools, diagnostics := responsesCustomTools(set)
|
||||
// chatWebSearchEncoded reports that the host encoder handled the hosted
|
||||
// web search; it may have written a tools[] entry or top-level fields
|
||||
// instead of web_search_options.
|
||||
chatWebSearchEncoded := false
|
||||
for index, definition := range set.Definitions {
|
||||
switch definition.Kind {
|
||||
case KindFunction:
|
||||
if definition.Function == nil {
|
||||
continue
|
||||
}
|
||||
parameters, schemaDiagnostics := jsonSchemaParameters(index, definition.Function)
|
||||
diagnostics = append(diagnostics, schemaDiagnostics...)
|
||||
target.Tools = append(target.Tools, dto.ToolCallRequest{
|
||||
Type: "function",
|
||||
Function: dto.FunctionRequest{
|
||||
Name: definition.Function.Name,
|
||||
Description: definition.Function.Description,
|
||||
Parameters: definition.Function.Parameters,
|
||||
Parameters: parameters,
|
||||
Strict: definition.Function.Strict,
|
||||
},
|
||||
})
|
||||
@@ -72,23 +79,52 @@ func attachOpenAIChatRequest(request any, set Set) (any, []types.ConversionDiagn
|
||||
if set.Source == types.RelayFormatGemini {
|
||||
diagnostics = append(diagnostics, geminiNativeWebSearchDiagnostics(index, definition, types.RelayFormatOpenAI)...)
|
||||
}
|
||||
if target.WebSearchOptions != nil {
|
||||
return nil, diagnostics, fmt.Errorf("tools[%d]: multiple hosted web-search definitions cannot be represented by Chat Completions", index)
|
||||
if target.WebSearchOptions != nil || chatWebSearchEncoded {
|
||||
diagnostics = append(diagnostics, semanticLoss(
|
||||
fmt.Sprintf("tools[%d]", index),
|
||||
"duplicate_hosted_web_search",
|
||||
"Chat Completions carries one hosted web search; this definition was dropped",
|
||||
))
|
||||
continue
|
||||
}
|
||||
options := &dto.WebSearchOptions{}
|
||||
call := newWebSearchCall(set, index, definition, types.RelayFormatOpenAI)
|
||||
call.Chat = target
|
||||
tool, encoderDiagnostics, handled, err := encodeWebSearch(call, options)
|
||||
if err != nil {
|
||||
return nil, diagnostics, err
|
||||
}
|
||||
if handled {
|
||||
diagnostics = append(diagnostics, encoderDiagnostics...)
|
||||
chatWebSearchEncoded = true
|
||||
if tool != nil {
|
||||
chatTool, ok := tool.(dto.ToolCallRequest)
|
||||
if !ok {
|
||||
return nil, diagnostics, fmt.Errorf("%s: Chat web-search encoder returned %T, want dto.ToolCallRequest", call.Path(), tool)
|
||||
}
|
||||
target.Tools = append(target.Tools, chatTool)
|
||||
}
|
||||
continue
|
||||
}
|
||||
webSearchOptions := &dto.WebSearchOptions{}
|
||||
if definition.WebSearch != nil {
|
||||
options.SearchContextSize = definition.WebSearch.SearchContextSize
|
||||
webSearchOptions.SearchContextSize = definition.WebSearch.SearchContextSize
|
||||
if definition.WebSearch.Location != nil {
|
||||
location := map[string]any{
|
||||
"type": "approximate",
|
||||
"approximate": locationMap(definition.WebSearch.Location),
|
||||
}
|
||||
options.UserLocation, _ = kitutil.Marshal(location)
|
||||
webSearchOptions.UserLocation, _ = kitutil.Marshal(location)
|
||||
}
|
||||
diagnostics = append(diagnostics, openAIChatWebSearchDiagnostics(index, definition.WebSearch)...)
|
||||
}
|
||||
target.WebSearchOptions = options
|
||||
target.WebSearchOptions = webSearchOptions
|
||||
default:
|
||||
if tool, handled := customTools[index]; handled {
|
||||
if tool != nil {
|
||||
target.Tools = append(target.Tools, tool.chatTool())
|
||||
}
|
||||
continue
|
||||
}
|
||||
diagnostics = append(diagnostics, semanticLoss(
|
||||
fmt.Sprintf("tools[%d]", index),
|
||||
"unsupported_hosted_tool",
|
||||
@@ -97,17 +133,281 @@ func attachOpenAIChatRequest(request any, set Set) (any, []types.ConversionDiagn
|
||||
}
|
||||
}
|
||||
|
||||
normalizedChoice, allowedChoiceDiagnostics := narrowAllowedFunctionChoice(set.Choice, types.RelayFormatOpenAI)
|
||||
diagnostics = append(diagnostics, unsupportedHostedHistoryDiagnostics(types.RelayFormatOpenAI, set.History)...)
|
||||
if chatWebSearchEncoded && forcedWebSearch(set.Choice) {
|
||||
// The encoder received Forced and expressed it in its own encoding.
|
||||
set.Choice = nil
|
||||
}
|
||||
if len(target.Tools) == 0 {
|
||||
return target, append(diagnostics, toolChoiceWithoutToolsDiagnostics(set)...), nil
|
||||
}
|
||||
normalizedChoice, allowedChoiceDiagnostics := narrowAllowedFunctionChoice(responsesCustomToolChoice(set.Choice, customTools), types.RelayFormatOpenAI)
|
||||
choice, choiceDiagnostics := encodeOpenAIChatChoice(normalizedChoice)
|
||||
target.ToolChoice = choice
|
||||
target.ParallelTooCalls = set.ParallelAllowed
|
||||
diagnostics = append(diagnostics, allowedChoiceDiagnostics...)
|
||||
diagnostics = append(diagnostics, choiceDiagnostics...)
|
||||
diagnostics = append(diagnostics, unsupportedHostedHistoryDiagnostics(types.RelayFormatOpenAI, set.History)...)
|
||||
return target, diagnostics, nil
|
||||
}
|
||||
|
||||
func attachOpenAIResponsesRequest(request any, set Set) (any, []types.ConversionDiagnostic, error) {
|
||||
// newWebSearchCall describes one hosted web-search definition for the host
|
||||
// encoder; the caller sets the typed target request.
|
||||
func newWebSearchCall(set Set, index int, definition Definition, target types.RelayFormat) convmeta.WebSearchCall {
|
||||
call := convmeta.WebSearchCall{
|
||||
Source: set.Source,
|
||||
Target: target,
|
||||
NativeType: definition.NativeType,
|
||||
Index: index,
|
||||
Forced: forcedWebSearch(set.Choice),
|
||||
}
|
||||
if definition.WebSearch != nil {
|
||||
call.Spec = *definition.WebSearch
|
||||
}
|
||||
return call
|
||||
}
|
||||
|
||||
func forcedWebSearch(choice *Choice) bool {
|
||||
return choice != nil && choice.Mode == ChoiceNamed && choice.Kind == KindWebSearch
|
||||
}
|
||||
|
||||
// encodeWebSearch runs the host web-search encoder for one definition.
|
||||
// handled is false when no encoder is set or it returned nil; the caller then
|
||||
// keeps its default encoding and default diagnostics. When handled, the
|
||||
// encoder's diagnostics replace the default ones and tool (possibly nil) is
|
||||
// what the caller appends.
|
||||
func encodeWebSearch(call convmeta.WebSearchCall, options *convmeta.Options) (tool any, diagnostics []types.ConversionDiagnostic, handled bool, err error) {
|
||||
if options == nil || options.WebSearch == nil {
|
||||
return nil, nil, false, nil
|
||||
}
|
||||
encoding, err := options.WebSearch(call)
|
||||
if err != nil {
|
||||
return nil, nil, false, fmt.Errorf("%s: %w", call.Path(), err)
|
||||
}
|
||||
if encoding == nil {
|
||||
return nil, nil, false, nil
|
||||
}
|
||||
if encoding.Omitted {
|
||||
return nil, append(encoding.Diagnostics, call.SemanticLoss(
|
||||
"hosted_web_search_omitted",
|
||||
"the upstream has no hosted web search for this protocol; the web search tool was omitted",
|
||||
)), true, nil
|
||||
}
|
||||
return encoding.Tool, encoding.Diagnostics, true, nil
|
||||
}
|
||||
|
||||
// appendEncodedWebSearch runs the host encoder for a Responses, Claude, or
|
||||
// Gemini target. When the encoder handled the definition, its diagnostics and
|
||||
// tool (if any) are appended and the caller skips its default encoding.
|
||||
func appendEncodedWebSearch(tools *[]any, diagnostics *[]types.ConversionDiagnostic, call convmeta.WebSearchCall, options *convmeta.Options) (bool, error) {
|
||||
tool, encoderDiagnostics, handled, err := encodeWebSearch(call, options)
|
||||
if err != nil || !handled {
|
||||
return false, err
|
||||
}
|
||||
*diagnostics = append(*diagnostics, encoderDiagnostics...)
|
||||
if tool != nil {
|
||||
*tools = append(*tools, tool)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// toolChoiceWithoutToolsDiagnostics reports the tool_choice and
|
||||
// parallel_tool_calls controls omitted because no tool reached the target;
|
||||
// upstreams reject either control in a request without tools.
|
||||
func toolChoiceWithoutToolsDiagnostics(set Set) []types.ConversionDiagnostic {
|
||||
if set.Choice == nil && set.ParallelAllowed == nil {
|
||||
return nil
|
||||
}
|
||||
return []types.ConversionDiagnostic{presentationLoss("tool_choice", "tool_choice_without_tools",
|
||||
"the target request has no function tools, so tool_choice and parallel_tool_calls were omitted")}
|
||||
}
|
||||
|
||||
// jsonSchemaParameters returns function parameters as JSON Schema for a
|
||||
// non-Gemini target, rewriting Gemini's OpenAPI schema subset.
|
||||
func jsonSchemaParameters(index int, function *Function) (any, []types.ConversionDiagnostic) {
|
||||
if !function.OpenAPISchema {
|
||||
return function.Parameters, nil
|
||||
}
|
||||
parameters, dropped := sharedgemini.OpenAPISchemaToJSONSchema(function.Parameters)
|
||||
if len(dropped) == 0 {
|
||||
return parameters, nil
|
||||
}
|
||||
return parameters, []types.ConversionDiagnostic{presentationLoss(
|
||||
fmt.Sprintf("tools[%d]", index),
|
||||
"gemini_schema_keyword_dropped",
|
||||
fmt.Sprintf("JSON Schema has no equivalent for Gemini schema keywords %s; they were dropped", strings.Join(dropped, ", ")),
|
||||
)}
|
||||
}
|
||||
|
||||
// responsesCustomTool is a Responses custom (freeform) tool sent upstream as
|
||||
// a function whose only argument is the raw input string.
|
||||
type responsesCustomTool struct {
|
||||
name string
|
||||
description string
|
||||
}
|
||||
|
||||
// inputSchema is the parameters schema of the function that carries a custom
|
||||
// tool: a single required string argument holding the raw input.
|
||||
func (tool *responsesCustomTool) inputSchema() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
convmeta.CustomToolInputArgument: map[string]any{
|
||||
"type": "string",
|
||||
"description": "Raw input for the tool.",
|
||||
},
|
||||
},
|
||||
"required": []string{convmeta.CustomToolInputArgument},
|
||||
"additionalProperties": false,
|
||||
}
|
||||
}
|
||||
|
||||
func (tool *responsesCustomTool) chatTool() dto.ToolCallRequest {
|
||||
return dto.ToolCallRequest{
|
||||
Type: "function",
|
||||
Function: dto.FunctionRequest{
|
||||
Name: tool.name,
|
||||
Description: tool.description,
|
||||
Parameters: tool.inputSchema(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const responsesCustomToolType = "custom"
|
||||
|
||||
// ResponsesCustomToolNames returns the Responses custom tools that
|
||||
// AttachRequest sends upstream as functions. The response side restores
|
||||
// function calls with these names as custom_tool_call items.
|
||||
func ResponsesCustomToolNames(set Set) map[string]struct{} {
|
||||
customTools, _ := responsesCustomTools(set)
|
||||
var names map[string]struct{}
|
||||
for _, tool := range customTools {
|
||||
if tool == nil {
|
||||
continue
|
||||
}
|
||||
if names == nil {
|
||||
names = make(map[string]struct{}, len(customTools))
|
||||
}
|
||||
names[tool.name] = struct{}{}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// ResponsesToolNamespaces maps the upstream name of every tool flattened
|
||||
// from a non-default Responses tool namespace to that namespace. The response
|
||||
// side restores the namespace on calls to these tools.
|
||||
func ResponsesToolNamespaces(set Set) map[string]string {
|
||||
if set.Source != types.RelayFormatOpenAIResponses {
|
||||
return nil
|
||||
}
|
||||
var namespaces map[string]string
|
||||
for _, definition := range set.Definitions {
|
||||
if definition.Namespace == "" || definition.Namespace == convmeta.DefaultResponsesToolNamespace || definition.Name == "" {
|
||||
continue
|
||||
}
|
||||
if namespaces == nil {
|
||||
namespaces = make(map[string]string)
|
||||
}
|
||||
namespaces[definition.Name] = definition.Namespace
|
||||
}
|
||||
return namespaces
|
||||
}
|
||||
|
||||
// responsesCustomTools selects, by definition index, the Responses custom
|
||||
// tools that the upstream receives as functions. A nil entry marks a custom
|
||||
// tool that was dropped with a diagnostic; indexes without an entry fall back
|
||||
// to the generic unsupported-tool loss. Request encoding and
|
||||
// ResponsesCustomToolNames share this selection so the restored names always
|
||||
// match the functions that were sent.
|
||||
func responsesCustomTools(set Set) (map[int]*responsesCustomTool, []types.ConversionDiagnostic) {
|
||||
if set.Source != types.RelayFormatOpenAIResponses {
|
||||
return nil, nil
|
||||
}
|
||||
functionNames := make(map[string]struct{})
|
||||
for _, definition := range set.Definitions {
|
||||
if definition.Kind == KindFunction && definition.Function != nil {
|
||||
functionNames[definition.Function.Name] = struct{}{}
|
||||
}
|
||||
}
|
||||
var (
|
||||
tools map[int]*responsesCustomTool
|
||||
diagnostics []types.ConversionDiagnostic
|
||||
)
|
||||
for index, definition := range set.Definitions {
|
||||
if definition.Kind != KindNative || definition.NativeType != responsesCustomToolType {
|
||||
continue
|
||||
}
|
||||
tool := decodeResponsesCustomTool(definition.Raw)
|
||||
if tool == nil {
|
||||
continue
|
||||
}
|
||||
if tools == nil {
|
||||
tools = make(map[int]*responsesCustomTool)
|
||||
}
|
||||
path := fmt.Sprintf("tools[%d]", index)
|
||||
if _, exists := functionNames[tool.name]; exists {
|
||||
tools[index] = nil
|
||||
diagnostics = append(diagnostics, semanticLoss(path, "custom_tool_name_conflict",
|
||||
fmt.Sprintf("custom tool %q shares its name with another tool, so the target protocol cannot tell their calls apart", tool.name)))
|
||||
continue
|
||||
}
|
||||
functionNames[tool.name] = struct{}{}
|
||||
tools[index] = tool
|
||||
diagnostics = append(diagnostics, presentationLoss(path, "custom_tool_as_function",
|
||||
fmt.Sprintf("the target protocol has no freeform tools; custom tool %q is sent as a function with one string argument and its input format becomes a description hint", tool.name)))
|
||||
}
|
||||
return tools, diagnostics
|
||||
}
|
||||
|
||||
func decodeResponsesCustomTool(raw json.RawMessage) *responsesCustomTool {
|
||||
var value map[string]any
|
||||
if err := kitutil.Unmarshal(raw, &value); err != nil {
|
||||
return nil
|
||||
}
|
||||
name := strings.TrimSpace(kitutil.Interface2String(value["name"]))
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
parts := make([]string, 0, 3)
|
||||
if description := strings.TrimSpace(kitutil.Interface2String(value["description"])); description != "" {
|
||||
parts = append(parts, description)
|
||||
}
|
||||
parts = append(parts, `This tool takes freeform text. Put the complete raw text in the "input" argument.`)
|
||||
if format, ok := value["format"].(map[string]any); ok && strings.TrimSpace(kitutil.Interface2String(format["type"])) == "grammar" {
|
||||
if definition := strings.TrimSpace(kitutil.Interface2String(format["definition"])); definition != "" {
|
||||
switch syntax := strings.TrimSpace(kitutil.Interface2String(format["syntax"])); syntax {
|
||||
case "lark":
|
||||
parts = append(parts, "The input must match this Lark grammar:\n"+definition)
|
||||
case "regex":
|
||||
parts = append(parts, "The input must match this regular expression:\n"+definition)
|
||||
default:
|
||||
parts = append(parts, fmt.Sprintf("The input must match this %s grammar:\n%s", syntax, definition))
|
||||
}
|
||||
}
|
||||
}
|
||||
return &responsesCustomTool{name: name, description: strings.Join(parts, "\n\n")}
|
||||
}
|
||||
|
||||
// responsesCustomToolChoice rewrites a Responses {"type":"custom"} tool choice
|
||||
// into a named function choice when that custom tool was sent as a function.
|
||||
// Other choices are returned unchanged.
|
||||
func responsesCustomToolChoice(choice *Choice, customTools map[int]*responsesCustomTool) *Choice {
|
||||
if choice == nil || choice.Mode != ChoiceOpaque || choice.NativeType != responsesCustomToolType {
|
||||
return choice
|
||||
}
|
||||
var value map[string]any
|
||||
if err := kitutil.Unmarshal(choice.Raw, &value); err != nil {
|
||||
return choice
|
||||
}
|
||||
name := convmeta.NamespacedToolName(strings.TrimSpace(kitutil.Interface2String(value["namespace"])), strings.TrimSpace(kitutil.Interface2String(value["name"])))
|
||||
for _, tool := range customTools {
|
||||
if tool != nil && tool.name == name {
|
||||
return &Choice{Mode: ChoiceNamed, Kind: KindFunction, Name: name}
|
||||
}
|
||||
}
|
||||
return choice
|
||||
}
|
||||
|
||||
func attachOpenAIResponsesRequest(request any, set Set, options *convmeta.Options) (any, []types.ConversionDiagnostic, error) {
|
||||
target, ok := request.(*dto.OpenAIResponsesRequest)
|
||||
if !ok || target == nil {
|
||||
return nil, nil, fmt.Errorf("expected OpenAI Responses request, got %T", request)
|
||||
@@ -120,11 +420,13 @@ func attachOpenAIResponsesRequest(request any, set Set) (any, []types.Conversion
|
||||
if definition.Function == nil {
|
||||
continue
|
||||
}
|
||||
parameters, schemaDiagnostics := jsonSchemaParameters(index, definition.Function)
|
||||
diagnostics = append(diagnostics, schemaDiagnostics...)
|
||||
tool := map[string]any{
|
||||
"type": "function",
|
||||
"name": definition.Function.Name,
|
||||
"description": definition.Function.Description,
|
||||
"parameters": definition.Function.Parameters,
|
||||
"parameters": parameters,
|
||||
}
|
||||
if definition.Function.Strict != nil {
|
||||
tool["strict"] = *definition.Function.Strict
|
||||
@@ -135,6 +437,15 @@ func attachOpenAIResponsesRequest(request any, set Set) (any, []types.Conversion
|
||||
if set.Source == types.RelayFormatGemini {
|
||||
diagnostics = append(diagnostics, geminiNativeWebSearchDiagnostics(index, definition, types.RelayFormatOpenAIResponses)...)
|
||||
}
|
||||
call := newWebSearchCall(set, index, definition, types.RelayFormatOpenAIResponses)
|
||||
call.Responses = target
|
||||
handled, err := appendEncodedWebSearch(&tools, &diagnostics, call, options)
|
||||
if err != nil {
|
||||
return nil, diagnostics, err
|
||||
}
|
||||
if handled {
|
||||
continue
|
||||
}
|
||||
webSearch := definition.WebSearch
|
||||
toolType := "web_search"
|
||||
if set.Source == types.RelayFormatOpenAIResponses && definition.NativeType != "" {
|
||||
@@ -182,13 +493,19 @@ func attachOpenAIResponsesRequest(request any, set Set) (any, []types.Conversion
|
||||
))
|
||||
}
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
encoded, err := kitutil.Marshal(tools)
|
||||
if err != nil {
|
||||
return nil, diagnostics, err
|
||||
}
|
||||
target.Tools = encoded
|
||||
historyDiagnostics, err := appendHostedHistoryToOpenAIResponses(target, set)
|
||||
if err != nil {
|
||||
return nil, diagnostics, err
|
||||
}
|
||||
diagnostics = append(diagnostics, historyDiagnostics...)
|
||||
if len(tools) == 0 {
|
||||
return target, append(diagnostics, toolChoiceWithoutToolsDiagnostics(set)...), nil
|
||||
}
|
||||
encoded, err := kitutil.Marshal(tools)
|
||||
if err != nil {
|
||||
return nil, diagnostics, err
|
||||
}
|
||||
target.Tools = encoded
|
||||
normalizedChoice, allowedChoiceDiagnostics := narrowAllowedFunctionChoice(set.Choice, types.RelayFormatOpenAIResponses)
|
||||
choice, choiceDiagnostics, err := encodeOpenAIResponsesChoice(normalizedChoice, set.Source)
|
||||
if err != nil {
|
||||
@@ -200,11 +517,6 @@ func attachOpenAIResponsesRequest(request any, set Set) (any, []types.Conversion
|
||||
}
|
||||
diagnostics = append(diagnostics, allowedChoiceDiagnostics...)
|
||||
diagnostics = append(diagnostics, choiceDiagnostics...)
|
||||
historyDiagnostics, err := appendHostedHistoryToOpenAIResponses(target, set)
|
||||
if err != nil {
|
||||
return nil, diagnostics, err
|
||||
}
|
||||
diagnostics = append(diagnostics, historyDiagnostics...)
|
||||
return target, diagnostics, nil
|
||||
}
|
||||
|
||||
@@ -214,14 +526,16 @@ func attachClaudeRequest(request any, set Set, options *convmeta.Options) (any,
|
||||
return nil, nil, fmt.Errorf("expected Claude Messages request, got %T", request)
|
||||
}
|
||||
tools := make([]any, 0, len(set.Definitions))
|
||||
var diagnostics []types.ConversionDiagnostic
|
||||
customTools, diagnostics := responsesCustomTools(set)
|
||||
for index, definition := range set.Definitions {
|
||||
switch definition.Kind {
|
||||
case KindFunction:
|
||||
if definition.Function == nil {
|
||||
continue
|
||||
}
|
||||
inputSchema, err := functionParametersMap(definition.Function.Parameters)
|
||||
parameters, schemaDiagnostics := jsonSchemaParameters(index, definition.Function)
|
||||
diagnostics = append(diagnostics, schemaDiagnostics...)
|
||||
inputSchema, err := functionParametersMap(parameters)
|
||||
if err != nil {
|
||||
return nil, diagnostics, fmt.Errorf("tools[%d].input_schema: %w", index, err)
|
||||
}
|
||||
@@ -235,6 +549,15 @@ func attachClaudeRequest(request any, set Set, options *convmeta.Options) (any,
|
||||
if set.Source == types.RelayFormatGemini {
|
||||
diagnostics = append(diagnostics, geminiNativeWebSearchDiagnostics(index, definition, types.RelayFormatClaude)...)
|
||||
}
|
||||
call := newWebSearchCall(set, index, definition, types.RelayFormatClaude)
|
||||
call.Claude = target
|
||||
handled, err := appendEncodedWebSearch(&tools, &diagnostics, call, options)
|
||||
if err != nil {
|
||||
return nil, diagnostics, err
|
||||
}
|
||||
if handled {
|
||||
continue
|
||||
}
|
||||
toolType := "web_search_20250305"
|
||||
if set.Source == types.RelayFormatClaude && isKnownClaudeWebSearchType(definition.NativeType) {
|
||||
toolType = definition.NativeType
|
||||
@@ -285,6 +608,16 @@ func attachClaudeRequest(request any, set Set, options *convmeta.Options) (any,
|
||||
tools = append(tools, tool)
|
||||
continue
|
||||
}
|
||||
if tool, handled := customTools[index]; handled {
|
||||
if tool != nil {
|
||||
tools = append(tools, &dto.Tool{
|
||||
Name: tool.name,
|
||||
Description: tool.description,
|
||||
InputSchema: tool.inputSchema(),
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
diagnostics = append(diagnostics, semanticLoss(
|
||||
fmt.Sprintf("tools[%d]", index),
|
||||
"unsupported_hosted_tool",
|
||||
@@ -292,23 +625,33 @@ func attachClaudeRequest(request any, set Set, options *convmeta.Options) (any,
|
||||
))
|
||||
}
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
target.Tools = tools
|
||||
}
|
||||
normalizedChoice, allowedChoiceDiagnostics := narrowAllowedFunctionChoice(set.Choice, types.RelayFormatClaude)
|
||||
choice, choiceDiagnostics := encodeClaudeChoice(normalizedChoice, set.ParallelAllowed, set.Source)
|
||||
target.ToolChoice = choice
|
||||
diagnostics = append(diagnostics, allowedChoiceDiagnostics...)
|
||||
diagnostics = append(diagnostics, choiceDiagnostics...)
|
||||
historyDiagnostics, err := appendHostedHistoryToClaude(target, set)
|
||||
if err != nil {
|
||||
return nil, diagnostics, err
|
||||
}
|
||||
diagnostics = append(diagnostics, historyDiagnostics...)
|
||||
if len(tools) == 0 {
|
||||
return target, append(diagnostics, toolChoiceWithoutToolsDiagnostics(set)...), nil
|
||||
}
|
||||
target.Tools = tools
|
||||
normalizedChoice, allowedChoiceDiagnostics := narrowAllowedFunctionChoice(responsesCustomToolChoice(set.Choice, customTools), types.RelayFormatClaude)
|
||||
diagnostics = append(diagnostics, allowedChoiceDiagnostics...)
|
||||
forced := normalizedChoice != nil && (normalizedChoice.Mode == ChoiceRequired || normalizedChoice.Mode == ChoiceNamed)
|
||||
manualThinking := target.Thinking != nil && target.Thinking.Type == "enabled"
|
||||
if forced && manualThinking && set.Source != types.RelayFormatClaude {
|
||||
downgraded := *normalizedChoice
|
||||
downgraded.Mode = ChoiceAuto
|
||||
normalizedChoice = &downgraded
|
||||
diagnostics = append(diagnostics, semanticLoss("tool_choice", "forced_tool_choice_downgraded",
|
||||
"Claude rejects forced tool use while manual extended thinking is enabled; tool_choice was sent as auto, so the model may answer without calling a tool"))
|
||||
}
|
||||
choice, choiceDiagnostics := encodeClaudeChoice(normalizedChoice, set.ParallelAllowed, set.Source)
|
||||
target.ToolChoice = choice
|
||||
diagnostics = append(diagnostics, choiceDiagnostics...)
|
||||
return target, diagnostics, nil
|
||||
}
|
||||
|
||||
func attachGeminiRequest(request any, set Set) (any, []types.ConversionDiagnostic, error) {
|
||||
func attachGeminiRequest(request any, set Set, options *convmeta.Options) (any, []types.ConversionDiagnostic, error) {
|
||||
target, ok := request.(*dto.GeminiChatRequest)
|
||||
if !ok || target == nil {
|
||||
return nil, nil, fmt.Errorf("expected Gemini generateContent request, got %T", request)
|
||||
@@ -334,33 +677,34 @@ func attachGeminiRequest(request any, set Set) (any, []types.ConversionDiagnosti
|
||||
return target, unsupportedHostedHistoryDiagnostics(types.RelayFormatGemini, set.History), nil
|
||||
}
|
||||
var (
|
||||
functions []map[string]any
|
||||
tools []map[string]any
|
||||
diagnostics []types.ConversionDiagnostic
|
||||
functions []map[string]any
|
||||
tools []any
|
||||
)
|
||||
jsonSchema := options != nil && options.Gemini.SupportsJSONSchemaModel(set.Model)
|
||||
customTools, diagnostics := responsesCustomTools(set)
|
||||
for index, definition := range set.Definitions {
|
||||
switch definition.Kind {
|
||||
case KindFunction:
|
||||
if definition.Function == nil {
|
||||
continue
|
||||
}
|
||||
parameters := definition.Function.Parameters
|
||||
parametersField, parameters := "parameters", definition.Function.Parameters
|
||||
if parameters != nil {
|
||||
cloned, err := kitutil.Any2Type[any](parameters)
|
||||
if err != nil {
|
||||
return nil, diagnostics, fmt.Errorf("tools[%d].parameters: %w", index, err)
|
||||
}
|
||||
if params, ok := cloned.(map[string]any); ok {
|
||||
if properties, exists := params["properties"].(map[string]any); exists && len(properties) == 0 {
|
||||
cloned = nil
|
||||
}
|
||||
var misfits []string
|
||||
parametersField, parameters, misfits = sharedgemini.FunctionParameters(cloned, jsonSchema)
|
||||
if len(misfits) > 0 {
|
||||
diagnostics = append(diagnostics, presentationLoss(fmt.Sprintf("tools[%d].parameters", index), "gemini_schema_keyword_dropped",
|
||||
fmt.Sprintf("Gemini function parameters cannot carry %s", strings.Join(misfits, ", "))))
|
||||
}
|
||||
parameters = sharedgemini.CleanFunctionParameters(cloned)
|
||||
}
|
||||
function := map[string]any{
|
||||
"name": definition.Function.Name,
|
||||
"description": definition.Function.Description,
|
||||
"parameters": parameters,
|
||||
"name": definition.Function.Name,
|
||||
"description": definition.Function.Description,
|
||||
parametersField: parameters,
|
||||
}
|
||||
deleteEmptyStrings(function)
|
||||
functions = append(functions, function)
|
||||
@@ -368,6 +712,15 @@ func attachGeminiRequest(request any, set Set) (any, []types.ConversionDiagnosti
|
||||
diagnostics = append(diagnostics, presentationLoss(fmt.Sprintf("tools[%d].strict", index), "unsupported_function_strict", "Gemini does not expose OpenAI function strictness"))
|
||||
}
|
||||
case KindWebSearch:
|
||||
call := newWebSearchCall(set, index, definition, types.RelayFormatGemini)
|
||||
call.Gemini = target
|
||||
handled, err := appendEncodedWebSearch(&tools, &diagnostics, call, options)
|
||||
if err != nil {
|
||||
return nil, diagnostics, err
|
||||
}
|
||||
if handled {
|
||||
continue
|
||||
}
|
||||
if set.Source == types.RelayFormatGemini && len(definition.Raw) > 0 {
|
||||
var tool map[string]any
|
||||
if err := kitutil.Unmarshal(definition.Raw, &tool); err != nil {
|
||||
@@ -401,19 +754,18 @@ func attachGeminiRequest(request any, set Set) (any, []types.ConversionDiagnosti
|
||||
tools = append(tools, tool)
|
||||
continue
|
||||
}
|
||||
// The established Responses-to-Gemini compatibility path removes
|
||||
// free-form/unknown tools together with custom call history in
|
||||
// PrepareOpenAIResponsesRequest. Keep that explicit downgrade as a
|
||||
// diagnostic; other opaque tools may be server-executed and remain a
|
||||
// semantic loss under the default Safe policy.
|
||||
if set.Source == types.RelayFormatOpenAIResponses && (definition.NativeType == "custom" || definition.NativeType == "unknown") {
|
||||
diagnostics = append(diagnostics, presentationLoss(
|
||||
fmt.Sprintf("tools[%d]", index),
|
||||
"custom_tool_omitted",
|
||||
"Gemini cannot represent this OpenAI free-form or unknown tool; its preprocessed call history and definition were omitted",
|
||||
))
|
||||
if tool, handled := customTools[index]; handled {
|
||||
if tool != nil {
|
||||
functions = append(functions, map[string]any{
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"parameters": sharedgemini.CleanFunctionParameters(tool.inputSchema()),
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Other opaque tools may be server-executed and remain a semantic
|
||||
// loss under the default Safe policy.
|
||||
if set.Source == types.RelayFormatOpenAIResponses && definition.Kind == KindNative {
|
||||
diagnostics = append(diagnostics, semanticLoss(
|
||||
fmt.Sprintf("tools[%d]", index),
|
||||
@@ -439,7 +791,11 @@ func attachGeminiRequest(request any, set Set) (any, []types.ConversionDiagnosti
|
||||
}
|
||||
target.Tools = encoded
|
||||
}
|
||||
config, choiceDiagnostics := encodeGeminiChoice(set.Choice)
|
||||
if len(functions) == 0 {
|
||||
diagnostics = append(diagnostics, unsupportedHostedHistoryDiagnostics(types.RelayFormatGemini, set.History)...)
|
||||
return target, append(diagnostics, toolChoiceWithoutToolsDiagnostics(set)...), nil
|
||||
}
|
||||
config, choiceDiagnostics := encodeGeminiChoice(responsesCustomToolChoice(set.Choice, customTools))
|
||||
target.ToolConfig = config
|
||||
diagnostics = append(diagnostics, choiceDiagnostics...)
|
||||
if set.ParallelAllowed != nil && !*set.ParallelAllowed {
|
||||
|
||||
@@ -3,6 +3,7 @@ package toolconv
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
)
|
||||
|
||||
@@ -33,26 +34,18 @@ type Function struct {
|
||||
Description string
|
||||
Parameters any
|
||||
Strict *bool
|
||||
// OpenAPISchema marks Parameters written in Gemini's OpenAPI schema
|
||||
// subset (uppercase type names, nullable, propertyOrdering) rather than
|
||||
// JSON Schema.
|
||||
OpenAPISchema 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
|
||||
}
|
||||
// ApproximateLocation and WebSearch are the convmeta types, so a host
|
||||
// web-search encoder receives the decoded specification unchanged.
|
||||
type (
|
||||
ApproximateLocation = convmeta.ApproximateLocation
|
||||
WebSearch = convmeta.WebSearch
|
||||
)
|
||||
|
||||
type Definition struct {
|
||||
Kind Kind
|
||||
@@ -63,6 +56,9 @@ type Definition struct {
|
||||
WebSearch *WebSearch
|
||||
Raw json.RawMessage
|
||||
Group int
|
||||
// Namespace is the Responses tool namespace the definition was declared
|
||||
// in. Name and Function.Name already hold the flattened upstream name.
|
||||
Namespace string
|
||||
}
|
||||
|
||||
type ChoiceMode string
|
||||
@@ -92,10 +88,16 @@ type Set struct {
|
||||
ParallelAllowed *bool
|
||||
NativeToolConfig json.RawMessage
|
||||
History []HostedHistoryItem
|
||||
// Diagnostics reports losses found while extracting the definitions; they
|
||||
// are returned with the attach diagnostics.
|
||||
Diagnostics []types.ConversionDiagnostic
|
||||
// Model is the upstream model the definitions are attached for; target
|
||||
// capability checks read it. The caller of AttachRequest sets it.
|
||||
Model string
|
||||
}
|
||||
|
||||
func (s Set) Empty() bool {
|
||||
return len(s.Definitions) == 0 && s.Choice == nil && s.ParallelAllowed == nil && len(s.NativeToolConfig) == 0 && len(s.History) == 0
|
||||
return len(s.Definitions) == 0 && s.Choice == nil && s.ParallelAllowed == nil && len(s.NativeToolConfig) == 0 && len(s.History) == 0 && len(s.Diagnostics) == 0
|
||||
}
|
||||
|
||||
type HostedHistoryItem struct {
|
||||
|
||||
@@ -101,3 +101,181 @@ func TestSafePolicyRejectsRequestPhaseHostedToolLoss(t *testing.T) {
|
||||
assert.True(t, hasDiagnosticCode(loss.Diagnostics, "unsupported_hosted_tool"))
|
||||
assert.True(t, hasDiagnosticCode(diagnostics, "unsupported_hosted_tool"))
|
||||
}
|
||||
|
||||
func codexResponsesToolsRequest(t *testing.T, toolChoice any, extraTools ...map[string]any) *dto.OpenAIResponsesRequest {
|
||||
t.Helper()
|
||||
tools := []map[string]any{
|
||||
{
|
||||
"type": "custom",
|
||||
"name": "exec",
|
||||
"description": "Run code.",
|
||||
"format": map[string]any{"type": "grammar", "syntax": "lark", "definition": "start: /.+/"},
|
||||
},
|
||||
{"type": "function", "name": "wait", "parameters": map[string]any{"type": "object"}},
|
||||
}
|
||||
tools = append(tools, extraTools...)
|
||||
rawTools, err := kitutil.Marshal(tools)
|
||||
require.NoError(t, err)
|
||||
request := &dto.OpenAIResponsesRequest{Model: "gpt-test", Tools: rawTools}
|
||||
if toolChoice != nil {
|
||||
request.ToolChoice, err = kitutil.Marshal(toolChoice)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
// responsesCustomToolTargets are the upstream protocols that receive a
|
||||
// Responses custom tool as a function taking one string argument.
|
||||
var responsesCustomToolTargets = []struct {
|
||||
format types.RelayFormat
|
||||
request func() any
|
||||
}{
|
||||
{types.RelayFormatOpenAI, func() any { return &dto.GeneralOpenAIRequest{Model: "gpt-test"} }},
|
||||
{types.RelayFormatClaude, func() any { return &dto.ClaudeRequest{Model: "claude-test"} }},
|
||||
{types.RelayFormatGemini, func() any { return &dto.GeminiChatRequest{} }},
|
||||
}
|
||||
|
||||
// attachedFunctions returns the function declarations and the tool choice
|
||||
// that AttachRequest wrote into a Chat, Claude, or Gemini request, decoded
|
||||
// from their wire JSON.
|
||||
func attachedFunctions(t *testing.T, out any) ([]map[string]any, any) {
|
||||
t.Helper()
|
||||
var (
|
||||
functions []map[string]any
|
||||
choice any
|
||||
err error
|
||||
)
|
||||
switch target := out.(type) {
|
||||
case *dto.GeneralOpenAIRequest:
|
||||
for _, tool := range target.Tools {
|
||||
function, err := kitutil.Any2Type[map[string]any](tool.Function)
|
||||
require.NoError(t, err)
|
||||
functions = append(functions, function)
|
||||
}
|
||||
choice, err = kitutil.Any2Type[any](target.ToolChoice)
|
||||
case *dto.ClaudeRequest:
|
||||
functions, err = kitutil.Any2Type[[]map[string]any](target.Tools)
|
||||
require.NoError(t, err)
|
||||
choice, err = kitutil.Any2Type[any](target.ToolChoice)
|
||||
case *dto.GeminiChatRequest:
|
||||
var groups []struct {
|
||||
FunctionDeclarations []map[string]any `json:"functionDeclarations"`
|
||||
}
|
||||
require.NoError(t, kitutil.Unmarshal(target.Tools, &groups))
|
||||
require.Len(t, groups, 1)
|
||||
functions = groups[0].FunctionDeclarations
|
||||
choice, err = kitutil.Any2Type[any](target.ToolConfig)
|
||||
default:
|
||||
require.FailNow(t, "unexpected request type", "%T", out)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
return functions, choice
|
||||
}
|
||||
|
||||
func TestResponsesCustomToolReachesEachTargetAsStringInputFunction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const execDescription = "Run code.\n\nThis tool takes freeform text. Put the complete raw text in the \"input\" argument.\n\nThe input must match this Lark grammar:\nstart: /.+/"
|
||||
inputSchema := map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"input": map[string]any{"type": "string", "description": "Raw input for the tool."},
|
||||
},
|
||||
"required": []any{"input"},
|
||||
"additionalProperties": false,
|
||||
}
|
||||
expected := map[types.RelayFormat]struct {
|
||||
functions []map[string]any
|
||||
choice any
|
||||
}{
|
||||
types.RelayFormatOpenAI: {
|
||||
functions: []map[string]any{
|
||||
{"name": "exec", "description": execDescription, "parameters": inputSchema},
|
||||
{"name": "wait", "parameters": map[string]any{"type": "object"}},
|
||||
},
|
||||
choice: map[string]any{"type": "function", "function": map[string]any{"name": "exec"}},
|
||||
},
|
||||
types.RelayFormatClaude: {
|
||||
functions: []map[string]any{
|
||||
{"name": "exec", "description": execDescription, "input_schema": inputSchema},
|
||||
{"name": "wait", "input_schema": map[string]any{"type": "object", "properties": map[string]any{}}},
|
||||
},
|
||||
choice: map[string]any{"type": "tool", "name": "exec"},
|
||||
},
|
||||
// Gemini receives the cleaned OpenAPI schema without additionalProperties.
|
||||
types.RelayFormatGemini: {
|
||||
functions: []map[string]any{
|
||||
{"name": "exec", "description": execDescription, "parameters": map[string]any{
|
||||
"type": "OBJECT",
|
||||
"properties": map[string]any{
|
||||
"input": map[string]any{"type": "STRING", "description": "Raw input for the tool."},
|
||||
},
|
||||
"required": []any{"input"},
|
||||
}},
|
||||
{"name": "wait", "parameters": map[string]any{"type": "OBJECT"}},
|
||||
},
|
||||
choice: map[string]any{"functionCallingConfig": map[string]any{"mode": "ANY", "allowedFunctionNames": []any{"exec"}}},
|
||||
},
|
||||
}
|
||||
|
||||
_, set, err := ExtractRequest(types.RelayFormatOpenAIResponses, codexResponsesToolsRequest(t, map[string]any{"type": "custom", "name": "exec"}))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]struct{}{"exec": {}}, ResponsesCustomToolNames(set))
|
||||
|
||||
for _, target := range responsesCustomToolTargets {
|
||||
t.Run(string(target.format), func(t *testing.T) {
|
||||
out, diagnostics, err := AttachRequest(target.format, target.request(), set, &convmeta.Options{ToolLossPolicy: types.ConversionLossPolicySafe})
|
||||
require.NoError(t, err)
|
||||
functions, choice := attachedFunctions(t, out)
|
||||
assert.Equal(t, expected[target.format].functions, functions)
|
||||
assert.Equal(t, expected[target.format].choice, choice)
|
||||
assert.True(t, hasDiagnosticCode(diagnostics, "custom_tool_as_function"))
|
||||
assert.False(t, hasDiagnosticCode(diagnostics, "unsupported_hosted_tool"))
|
||||
assert.False(t, hasDiagnosticCode(diagnostics, "unsupported_tool_choice"))
|
||||
|
||||
_, _, err = AttachRequest(target.format, target.request(), set, &convmeta.Options{ToolLossPolicy: types.ConversionLossPolicyStrict})
|
||||
var loss *types.ConversionLossError
|
||||
require.ErrorAs(t, err, &loss)
|
||||
assert.True(t, hasDiagnosticCode(loss.Diagnostics, "custom_tool_as_function"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesCustomToolNameConflictIsDropped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The custom exec loses to the function exec, so a choice naming it has no
|
||||
// sent tool to point at and must stay unconverted.
|
||||
request := codexResponsesToolsRequest(t, map[string]any{"type": "custom", "name": "exec"},
|
||||
map[string]any{"type": "function", "name": "exec", "parameters": map[string]any{"type": "object"}},
|
||||
map[string]any{"type": "custom", "name": "apply_patch"},
|
||||
map[string]any{"type": "custom", "name": "apply_patch", "description": "duplicate"},
|
||||
)
|
||||
_, set, err := ExtractRequest(types.RelayFormatOpenAIResponses, request)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]struct{}{"apply_patch": {}}, ResponsesCustomToolNames(set))
|
||||
|
||||
for _, target := range responsesCustomToolTargets {
|
||||
t.Run(string(target.format), func(t *testing.T) {
|
||||
out, diagnostics, err := AttachRequest(target.format, target.request(), set, &convmeta.Options{})
|
||||
require.NoError(t, err)
|
||||
functions, choice := attachedFunctions(t, out)
|
||||
require.Len(t, functions, 3)
|
||||
names := make([]string, 0, len(functions))
|
||||
for _, function := range functions {
|
||||
names = append(names, function["name"].(string))
|
||||
}
|
||||
assert.Equal(t, []string{"wait", "exec", "apply_patch"}, names)
|
||||
// Only the custom definitions carry a description, so the kept exec
|
||||
// is the function one.
|
||||
assert.NotContains(t, functions[1], "description")
|
||||
assert.NotContains(t, functions[2]["description"], "duplicate")
|
||||
assert.Nil(t, choice)
|
||||
assert.True(t, hasDiagnosticCode(diagnostics, "custom_tool_name_conflict"))
|
||||
assert.True(t, hasDiagnosticCode(diagnostics, "unsupported_tool_choice"))
|
||||
|
||||
_, _, err = AttachRequest(target.format, target.request(), set, &convmeta.Options{ToolLossPolicy: types.ConversionLossPolicySafe})
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,25 @@
|
||||
package kitutil
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"net"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
maskURLPattern = regexp.MustCompile(`(http|https)://[^\s/$.?#].[^\s]*`)
|
||||
maskDomainPattern = regexp.MustCompile(`\b(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\b`)
|
||||
maskIPPattern = regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`)
|
||||
// maskURLPattern stops at characters a URL cannot contain unescaped, such
|
||||
// as double quotes and angle or curly brackets, so a URL quoted in an error
|
||||
// keeps its delimiters. Apostrophes and parentheses are valid inside a URL;
|
||||
// the caller trims them, like other punctuation, only when they trail it.
|
||||
maskURLPattern = regexp.MustCompile(`(?:https?|wss?)://[^\s"<>{}]+`)
|
||||
maskIPPattern = regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`)
|
||||
// maskBracketedIPv6Pattern matches the bracketed form Go network errors use
|
||||
// for IPv6 addresses, as in "dial tcp [2001:db8::1]:443", with an optional
|
||||
// zone; the caller confirms the content parses as an IP.
|
||||
maskBracketedIPv6Pattern = regexp.MustCompile(`\[([0-9A-Fa-f:.]*:[0-9A-Fa-f:.]*)(%[^\]\s]*)?\]`)
|
||||
// maskApiKeyPattern matches patterns like 'api_key:xxx' or "api_key:xxx" to mask the API key value
|
||||
maskApiKeyPattern = regexp.MustCompile(`(['"]?)api_key:([^\s'"]+)(['"]?)`)
|
||||
)
|
||||
@@ -53,30 +63,111 @@ func maskHostForPlainDomain(domain string) string {
|
||||
return stars + "." + strings.Join(tail, ".")
|
||||
}
|
||||
|
||||
// MaskSensitiveInfo masks sensitive information like URLs, IPs, and domain names in a string
|
||||
// Example:
|
||||
// http://example.com -> http://***.com
|
||||
// https://api.test.org/v1/users/123?key=secret -> https://***.org/***/***/?key=***
|
||||
// https://sub.domain.co.uk/path/to/resource -> https://***.co.uk/***/***
|
||||
// 192.168.1.1 -> ***.***.***.***
|
||||
// openai.com -> ***.com
|
||||
// www.openai.com -> ***.***.com
|
||||
// api.openai.com -> ***.***.com
|
||||
func MaskSensitiveInfo(str string) string {
|
||||
// Mask URLs
|
||||
str = maskURLPattern.ReplaceAllStringFunc(str, func(urlStr string) string {
|
||||
// maskURLQuery keeps the parameter names of a raw query and masks the values.
|
||||
// Example: key=secret&alt=sse -> ?alt=***&key=***
|
||||
func maskURLQuery(rawQuery string) string {
|
||||
if rawQuery == "" {
|
||||
return ""
|
||||
}
|
||||
values, err := url.ParseQuery(rawQuery)
|
||||
if err != nil {
|
||||
// If can't parse query, just mask the whole query string
|
||||
return "?***"
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
maskedParams := make([]string, 0, len(values))
|
||||
for _, key := range slices.Sorted(maps.Keys(values)) {
|
||||
maskedParams = append(maskedParams, key+"=***")
|
||||
}
|
||||
return "?" + strings.Join(maskedParams, "&")
|
||||
}
|
||||
|
||||
// MaskSensitiveInfo masks the upstream addresses in str and leaves every other
|
||||
// URL and domain readable. upstreamURLs are the channel base URL and the URL
|
||||
// the gateway actually requested, which differs for routes such as absolute
|
||||
// Advanced Custom paths; empty entries are skipped. Credentials and IPv4
|
||||
// addresses are masked regardless of the upstream: API key values, the query
|
||||
// values and userinfo of every URL, which carry secrets such as client_secret
|
||||
// or key on provider endpoints, and IPs, which in network errors include the
|
||||
// gateway's own address.
|
||||
// Example with upstream https://api.upstream.example.com:
|
||||
// https://api.upstream.example.com/v1/chat?key=secret -> https://***.com/***/***?key=***
|
||||
// lookup api.upstream.example.com -> lookup ***.***.***.com
|
||||
// dial tcp 192.168.1.1:443 -> dial tcp ***.***.***.***:443
|
||||
// https://docs.example.org/errors -> unchanged
|
||||
// https://auth.example.org/token?client_secret=s -> https://auth.example.org/token?client_secret=***
|
||||
func MaskSensitiveInfo(str string, upstreamURLs ...string) string {
|
||||
// Mask API keys (e.g., "api_key:test-api-key-for-redaction" -> "api_key:***")
|
||||
str = maskApiKeyPattern.ReplaceAllString(str, "${1}api_key:***${3}")
|
||||
|
||||
upstreamHosts := make([]string, 0, len(upstreamURLs))
|
||||
for _, upstreamURL := range upstreamURLs {
|
||||
upstreamURL = strings.TrimSpace(upstreamURL)
|
||||
if upstreamURL == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(upstreamURL, "://") {
|
||||
// A base URL saved without a scheme would otherwise parse as a path.
|
||||
upstreamURL = "//" + upstreamURL
|
||||
}
|
||||
parsed, err := url.Parse(upstreamURL)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// A fully qualified host keeps its trailing root dot; errors may omit it.
|
||||
if host := strings.TrimSuffix(parsed.Hostname(), "."); host != "" {
|
||||
upstreamHosts = append(upstreamHosts, host)
|
||||
}
|
||||
}
|
||||
// Mask URLs on an upstream host, and the query values of every other URL
|
||||
str = maskURLPattern.ReplaceAllStringFunc(str, func(match string) string {
|
||||
urlStr := match
|
||||
for urlStr != "" {
|
||||
last := urlStr[len(urlStr)-1]
|
||||
if strings.IndexByte(".,;:!?'", last) < 0 && (last != ')' || strings.Count(urlStr, "(") >= strings.Count(urlStr, ")")) {
|
||||
break
|
||||
}
|
||||
urlStr = urlStr[:len(urlStr)-1]
|
||||
}
|
||||
suffix := match[len(urlStr):]
|
||||
u, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return urlStr
|
||||
return match
|
||||
}
|
||||
host := strings.TrimSuffix(u.Hostname(), ".")
|
||||
if !slices.ContainsFunc(upstreamHosts, func(upstreamHost string) bool { return strings.EqualFold(upstreamHost, host) }) {
|
||||
if u.RawQuery == "" && u.User == nil {
|
||||
return match
|
||||
}
|
||||
// Userinfo is a credential wherever the URL points.
|
||||
if u.User != nil {
|
||||
scheme, afterScheme, _ := strings.Cut(urlStr, "://")
|
||||
authorityEnd := strings.IndexAny(afterScheme, "/?#")
|
||||
if authorityEnd < 0 {
|
||||
authorityEnd = len(afterScheme)
|
||||
}
|
||||
urlStr = scheme + "://***@" + afterScheme[strings.LastIndex(afterScheme[:authorityEnd], "@")+1:]
|
||||
}
|
||||
if u.RawQuery == "" {
|
||||
return urlStr + suffix
|
||||
}
|
||||
beforeQuery, _, _ := strings.Cut(urlStr, "?")
|
||||
fragment := ""
|
||||
if u.Fragment != "" {
|
||||
fragment = "#" + u.EscapedFragment()
|
||||
}
|
||||
return beforeQuery + maskURLQuery(u.RawQuery) + fragment + suffix
|
||||
}
|
||||
|
||||
host := u.Host
|
||||
if host == "" {
|
||||
return urlStr
|
||||
}
|
||||
|
||||
// Mask host with unified logic
|
||||
maskedHost := maskHostForURL(host)
|
||||
if ip := net.ParseIP(host); ip != nil && ip.To4() != nil {
|
||||
maskedHost = "***.***.***.***"
|
||||
}
|
||||
if port := u.Port(); port != "" {
|
||||
maskedHost += ":" + port
|
||||
}
|
||||
|
||||
result := u.Scheme + "://" + maskedHost
|
||||
|
||||
@@ -96,36 +187,35 @@ func MaskSensitiveInfo(str string) string {
|
||||
result += "/"
|
||||
}
|
||||
|
||||
// Mask query parameters
|
||||
if u.RawQuery != "" {
|
||||
values, err := url.ParseQuery(u.RawQuery)
|
||||
if err != nil {
|
||||
// If can't parse query, just mask the whole query string
|
||||
result += "?***"
|
||||
} else {
|
||||
maskedParams := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
maskedParams = append(maskedParams, key+"=***")
|
||||
}
|
||||
if len(maskedParams) > 0 {
|
||||
result += "?" + strings.Join(maskedParams, "&")
|
||||
}
|
||||
}
|
||||
return result + maskURLQuery(u.RawQuery) + suffix
|
||||
})
|
||||
|
||||
// Mask IP addresses: in network errors they are the resolved upstream
|
||||
// address or the gateway's own, so they are masked even without an
|
||||
// upstream address, and an IP upstream host is covered too. IPv6 goes
|
||||
// first so an embedded IPv4 such as [::ffff:10.0.0.1] still parses.
|
||||
str = maskBracketedIPv6Pattern.ReplaceAllStringFunc(str, func(match string) string {
|
||||
if net.ParseIP(maskBracketedIPv6Pattern.FindStringSubmatch(match)[1]) == nil {
|
||||
return match
|
||||
}
|
||||
|
||||
return result
|
||||
return "[***]"
|
||||
})
|
||||
|
||||
// Mask domain names without protocol (like openai.com, www.openai.com)
|
||||
str = maskDomainPattern.ReplaceAllStringFunc(str, func(domain string) string {
|
||||
return maskHostForPlainDomain(domain)
|
||||
})
|
||||
|
||||
// Mask IP addresses
|
||||
str = maskIPPattern.ReplaceAllString(str, "***.***.***.***")
|
||||
if len(upstreamHosts) == 0 {
|
||||
return str
|
||||
}
|
||||
|
||||
// Mask API keys (e.g., "api_key:test-api-key-for-redaction" -> "api_key:***")
|
||||
str = maskApiKeyPattern.ReplaceAllString(str, "${1}api_key:***${3}")
|
||||
// Mask an upstream host where it appears without a scheme, as in
|
||||
// "dial tcp: lookup api.example.com: no such host". Longer hosts go first
|
||||
// so a subdomain upstream is masked whole before its parent base host.
|
||||
slices.SortFunc(upstreamHosts, func(a, b string) int { return len(b) - len(a) })
|
||||
for _, host := range upstreamHosts {
|
||||
if net.ParseIP(host) != nil {
|
||||
continue
|
||||
}
|
||||
hostPattern := regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(host) + `\b`)
|
||||
str = hostPattern.ReplaceAllStringFunc(str, maskHostForPlainDomain)
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package kitutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMaskSensitiveInfoMasksOnlyTheUpstreamAddress(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
upstreamURLs []string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "no upstream address leaves hosts readable but masks IPs",
|
||||
upstreamURLs: []string{"", ""},
|
||||
input: `Post "https://api.upstream.example.com/v1/chat/completions": dial tcp 10.0.0.1:443: connect: connection refused`,
|
||||
want: `Post "https://api.upstream.example.com/v1/chat/completions": dial tcp ***.***.***.***:443: connect: connection refused`,
|
||||
},
|
||||
{
|
||||
name: "bracketed IPv6 addresses are masked without an upstream address",
|
||||
upstreamURLs: nil,
|
||||
input: `dial tcp [2a01:4f8::1]:443: i/o timeout; lookup api.example.com on [::1]:53; dial tcp [fe80::1%eth0]:443; dial tcp [::ffff:10.0.0.1]:443; items[0] and [1:2] stay`,
|
||||
want: `dial tcp [***]:443: i/o timeout; lookup api.example.com on [***]:53; dial tcp [***]:443; dial tcp [***]:443; items[0] and [1:2] stay`,
|
||||
},
|
||||
{
|
||||
name: "IPv6 upstream base URL in a WebSocket dial error",
|
||||
upstreamURLs: []string{"http://[2001:db8::1]:8080"},
|
||||
input: `dial failed to ws://[2001:db8::1]:8080/v1/realtime: dial tcp [2001:db8::1]:8080: connect: connection refused`,
|
||||
want: `dial failed to ws://***:8080/***/***: dial tcp [***]:8080: connect: connection refused`,
|
||||
},
|
||||
{
|
||||
name: "no upstream address still masks API key values",
|
||||
upstreamURLs: nil,
|
||||
input: `invalid api_key:test-api-key-for-redaction`,
|
||||
want: `invalid api_key:***`,
|
||||
},
|
||||
{
|
||||
name: "query values carry credentials on any host",
|
||||
upstreamURLs: []string{"https://custom.base.example"},
|
||||
input: `Post "https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=id-1&client_secret=secret-1": dial tcp: i/o timeout, see https://docs.example.org/errors?lang=en#retry`,
|
||||
want: `Post "https://aip.baidubce.com/oauth/2.0/token?client_id=***&client_secret=***&grant_type=***": dial tcp: i/o timeout, see https://docs.example.org/errors?lang=***#retry`,
|
||||
},
|
||||
{
|
||||
name: "parentheses inside a URL keep its query masked",
|
||||
upstreamURLs: nil,
|
||||
input: `failed to download https://cdn.example/image(1).png?token=secret (status 403)`,
|
||||
want: `failed to download https://cdn.example/image(1).png?token=*** (status 403)`,
|
||||
},
|
||||
{
|
||||
name: "apostrophes inside a URL keep its query and userinfo masked",
|
||||
upstreamURLs: nil,
|
||||
input: `failed to download https://cdn.example/John's-photo.png?token=secret from 'https://o'neil:pw@cdn.example/a?sig=1'`,
|
||||
want: `failed to download https://cdn.example/John's-photo.png?token=*** from 'https://***@cdn.example/a?sig=***'`,
|
||||
},
|
||||
{
|
||||
name: "userinfo is masked on any host",
|
||||
upstreamURLs: nil,
|
||||
input: `downloading https://alice:secret@cdn.example/file.png and https://bob@cdn.example/a?sig=1`,
|
||||
want: `downloading https://***@cdn.example/file.png and https://***@cdn.example/a?sig=***`,
|
||||
},
|
||||
{
|
||||
name: "query values are masked without an upstream address",
|
||||
upstreamURLs: nil,
|
||||
input: `request to https://generativelanguage.googleapis.com/v1beta/models?key=AIza-secret failed`,
|
||||
want: `request to https://generativelanguage.googleapis.com/v1beta/models?key=*** failed`,
|
||||
},
|
||||
{
|
||||
name: "upstream URL, its IP and other URLs",
|
||||
upstreamURLs: []string{"https://api.upstream.example.com/openai"},
|
||||
input: `request to https://api.upstream.example.com/openai/v1/chat/completions?key=secret failed: dial tcp 10.0.0.1:443: i/o timeout, see https://docs.example.org/errors`,
|
||||
want: `request to https://***.com/***/***/***/***?key=*** failed: dial tcp ***.***.***.***:443: i/o timeout, see https://docs.example.org/errors`,
|
||||
},
|
||||
{
|
||||
name: "bare upstream host in a DNS error",
|
||||
upstreamURLs: []string{"https://API.Upstream.example.com"},
|
||||
input: `dial tcp: lookup api.upstream.example.com: no such host`,
|
||||
want: `dial tcp: lookup ***.***.***.com: no such host`,
|
||||
},
|
||||
{
|
||||
name: "fully qualified base URL with a trailing dot",
|
||||
upstreamURLs: []string{"https://private.example.com./v1"},
|
||||
input: `request to https://private.example.com./v1/responses failed: dial tcp: lookup private.example.com.: no such host`,
|
||||
want: `request to https://***.com/***/*** failed: dial tcp: lookup ***.***.com.: no such host`,
|
||||
},
|
||||
{
|
||||
name: "base URL saved without a scheme",
|
||||
upstreamURLs: []string{"relay.example.co.uk"},
|
||||
input: `request to https://relay.example.co.uk/v1/messages failed: EOF`,
|
||||
want: `request to https://***.co.uk/***/*** failed: EOF`,
|
||||
},
|
||||
{
|
||||
name: "quoted URL keeps its delimiters and trailing punctuation",
|
||||
upstreamURLs: []string{"https://api.upstream.example.com"},
|
||||
input: `Post "https://api.upstream.example.com/v1/chat/completions": EOF; retry https://api.upstream.example.com/v1/models.`,
|
||||
want: `Post "https://***.com/***/***/***": EOF; retry https://***.com/***/***.`,
|
||||
},
|
||||
{
|
||||
name: "port does not hide a country-code TLD",
|
||||
upstreamURLs: []string{"https://relay.example.co.uk:8443"},
|
||||
input: `request to https://relay.example.co.uk:8443/v1/messages failed`,
|
||||
want: `request to https://***.co.uk:8443/***/*** failed`,
|
||||
},
|
||||
{
|
||||
name: "IP upstream host is fully masked",
|
||||
upstreamURLs: []string{"http://10.0.0.1:8080"},
|
||||
input: `request to http://10.0.0.1:8080/v1/chat failed`,
|
||||
want: `request to http://***.***.***.***:8080/***/*** failed`,
|
||||
},
|
||||
{
|
||||
name: "requested origin of an absolute route besides the base URL",
|
||||
upstreamURLs: []string{"https://api.upstream.example.com", "wss://realtime.private.example.net"},
|
||||
input: `dial failed to wss://realtime.private.example.net/v1/responses: dial tcp: lookup realtime.private.example.net: no such host`,
|
||||
want: `dial failed to wss://***.net/***/***: dial tcp: lookup ***.***.***.net: no such host`,
|
||||
},
|
||||
{
|
||||
name: "subdomain upstream is masked whole before its parent base host",
|
||||
upstreamURLs: []string{"https://api.example.com", "wss://tenant.api.example.com"},
|
||||
input: `dial tcp: lookup tenant.api.example.com: no such host`,
|
||||
want: `dial tcp: lookup ***.***.***.com: no such host`,
|
||||
},
|
||||
{
|
||||
name: "other domains and dotted identifiers stay readable",
|
||||
upstreamURLs: []string{"https://api.upstream.example.com"},
|
||||
input: `Unknown event response.output_text.delta from model gpt-4.1, contact support@example.org`,
|
||||
want: `Unknown event response.output_text.delta from model gpt-4.1, contact support@example.org`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, MaskSensitiveInfo(tt.input, tt.upstreamURLs...))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -26,15 +26,25 @@ type claudeCapabilities struct {
|
||||
supportsManual bool
|
||||
defaultThinking bool
|
||||
supportsDisable bool
|
||||
supportsEffort bool
|
||||
supportsXHigh bool
|
||||
supportsMax bool
|
||||
strictSampling bool
|
||||
// disableAsBetweenTools marks models whose lowest thinking setting is
|
||||
// {"type":"between_tools"} instead of {"type":"disabled"}.
|
||||
disableAsBetweenTools bool
|
||||
supportsEffort bool
|
||||
supportsXHigh bool
|
||||
supportsMax bool
|
||||
strictSampling bool
|
||||
// offEffortAtMostHigh marks models that accept effort high or below only
|
||||
// while thinking is off (disabled or between_tools).
|
||||
offEffortAtMostHigh bool
|
||||
// defaultEffort is the effort the model runs at when a request sets none:
|
||||
// medium on Claude Opus 5.5 and Haiku 5.5 and high on every other model
|
||||
// (https://platform.claude.com/docs/en/build-with-claude/effort).
|
||||
defaultEffort Effort
|
||||
}
|
||||
|
||||
func claudeCapabilitiesFor(model string) claudeCapabilities {
|
||||
model = strings.ToLower(model)
|
||||
capabilities := claudeCapabilities{supportsManual: true, supportsDisable: true}
|
||||
capabilities := claudeCapabilities{supportsManual: true, supportsDisable: true, defaultEffort: EffortHigh}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(model, "claude-fable-5"),
|
||||
@@ -43,6 +53,7 @@ func claudeCapabilitiesFor(model string) claudeCapabilities {
|
||||
capabilities.supportsManual = false
|
||||
capabilities.defaultThinking = true
|
||||
capabilities.supportsDisable = false
|
||||
capabilities.supportsEffort = true
|
||||
capabilities.supportsXHigh = true
|
||||
capabilities.supportsMax = true
|
||||
capabilities.strictSampling = true
|
||||
@@ -50,17 +61,41 @@ func claudeCapabilitiesFor(model string) claudeCapabilities {
|
||||
capabilities.adaptive = true
|
||||
capabilities.defaultThinking = true
|
||||
capabilities.supportsDisable = false
|
||||
capabilities.supportsEffort = true
|
||||
capabilities.supportsMax = true
|
||||
capabilities.strictSampling = true
|
||||
case strings.HasPrefix(model, "claude-opus-5-5"),
|
||||
strings.HasPrefix(model, "claude-sonnet-5-5"):
|
||||
// Opus 5.5 cannot turn thinking off at all; Sonnet 5.5 turns it off
|
||||
// with between_tools, which takes effort high or below only.
|
||||
capabilities.adaptive = true
|
||||
capabilities.supportsManual = false
|
||||
capabilities.defaultThinking = true
|
||||
capabilities.supportsDisable = false
|
||||
capabilities.disableAsBetweenTools = strings.HasPrefix(model, "claude-sonnet-5-5")
|
||||
capabilities.offEffortAtMostHigh = capabilities.disableAsBetweenTools
|
||||
if strings.HasPrefix(model, "claude-opus-5-5") {
|
||||
capabilities.defaultEffort = EffortMedium
|
||||
}
|
||||
capabilities.supportsEffort = true
|
||||
capabilities.supportsXHigh = true
|
||||
capabilities.supportsMax = true
|
||||
capabilities.strictSampling = true
|
||||
case strings.HasPrefix(model, "claude-opus-5"),
|
||||
strings.HasPrefix(model, "claude-sonnet-5"),
|
||||
strings.HasPrefix(model, "claude-haiku-5"),
|
||||
strings.HasPrefix(model, "claude-opus-4-8"),
|
||||
strings.HasPrefix(model, "claude-opus-4-7"):
|
||||
capabilities.adaptive = true
|
||||
capabilities.supportsManual = false
|
||||
if strings.HasPrefix(model, "claude-opus-5") || strings.HasPrefix(model, "claude-sonnet-5") {
|
||||
if strings.HasPrefix(model, "claude-opus-5") || strings.HasPrefix(model, "claude-sonnet-5") || strings.HasPrefix(model, "claude-haiku-5") {
|
||||
capabilities.defaultThinking = true
|
||||
}
|
||||
// Opus 5 and Haiku 5.5 reject disabled thinking with xhigh or max.
|
||||
capabilities.offEffortAtMostHigh = strings.HasPrefix(model, "claude-opus-5") || strings.HasPrefix(model, "claude-haiku-5")
|
||||
if strings.HasPrefix(model, "claude-haiku-5") {
|
||||
capabilities.defaultEffort = EffortMedium
|
||||
}
|
||||
capabilities.supportsEffort = true
|
||||
capabilities.supportsXHigh = true
|
||||
capabilities.supportsMax = true
|
||||
@@ -97,12 +132,6 @@ func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPer
|
||||
diagnostics = append(diagnostics, normalizeDiagnostics...)
|
||||
}
|
||||
capabilities := claudeCapabilitiesFor(model)
|
||||
if disabledWithEffort {
|
||||
diagnostics = append(diagnostics, claudeReasoningDiagnostic(
|
||||
"claude_disabled_effort_ignored",
|
||||
fmt.Sprintf("model %q cannot apply effort %q while thinking is disabled; the effort was ignored", model, intent.Effort),
|
||||
))
|
||||
}
|
||||
if !intent.HasStrength() {
|
||||
if intent.IncludeThoughts != nil && capabilities.adaptive && capabilities.defaultThinking {
|
||||
thinking := &dto.Thinking{Type: "adaptive"}
|
||||
@@ -113,17 +142,43 @@ func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPer
|
||||
}
|
||||
return ClaudeRender{
|
||||
Thinking: thinking,
|
||||
EffectiveEffort: EffortHigh,
|
||||
EffectiveEffort: capabilities.defaultEffort,
|
||||
ClearSampling: capabilities.strictSampling,
|
||||
}, nil
|
||||
}
|
||||
if capabilities.defaultThinking {
|
||||
return ClaudeRender{EffectiveEffort: EffortHigh, ClearSampling: capabilities.strictSampling}, nil
|
||||
return ClaudeRender{EffectiveEffort: capabilities.defaultEffort, ClearSampling: capabilities.strictSampling}, nil
|
||||
}
|
||||
return ClaudeRender{ClearSampling: capabilities.strictSampling}, nil
|
||||
}
|
||||
|
||||
if intent.Mode == ModeDisabled || intent.Effort == EffortNone {
|
||||
// Effort works without thinking, so a requested level is sent at one
|
||||
// the model accepts while thinking is off.
|
||||
outputEffort := Effort("")
|
||||
if disabledWithEffort && (capabilities.supportsDisable || capabilities.disableAsBetweenTools) {
|
||||
outputEffort = ClaudeEffort(model, intent.Effort)
|
||||
if capabilities.offEffortAtMostHigh && (outputEffort == EffortXHigh || outputEffort == EffortMax) {
|
||||
outputEffort = EffortHigh
|
||||
}
|
||||
diagnostics = append(diagnostics, claudeEffortDiagnostics(model, intent.Effort, outputEffort)...)
|
||||
}
|
||||
if capabilities.disableAsBetweenTools {
|
||||
// between_tools takes no other thinking field and runs at the
|
||||
// default effort or the requested one, both within its "high or
|
||||
// below" limit.
|
||||
diagnostics = append(diagnostics, claudeReasoningDiagnostic(
|
||||
"claude_thinking_disable_as_between_tools",
|
||||
fmt.Sprintf("model %q rejects disabled thinking; using between_tools, its lowest thinking setting", model),
|
||||
))
|
||||
return ClaudeRender{
|
||||
Thinking: &dto.Thinking{Type: "between_tools"},
|
||||
OutputEffort: outputEffort,
|
||||
EffectiveEffort: EffortNone,
|
||||
ClearSampling: capabilities.strictSampling,
|
||||
Diagnostics: diagnostics,
|
||||
}, nil
|
||||
}
|
||||
if !capabilities.supportsDisable {
|
||||
diagnostics = append(diagnostics, claudeReasoningDiagnostic(
|
||||
"claude_thinking_disable_unsupported",
|
||||
@@ -138,8 +193,7 @@ func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPer
|
||||
thinking.Display = "omitted"
|
||||
}
|
||||
}
|
||||
outputEffort := Effort("")
|
||||
effectiveEffort := EffortHigh
|
||||
effectiveEffort := capabilities.defaultEffort
|
||||
if capabilities.supportsEffort {
|
||||
outputEffort = EffortLow
|
||||
effectiveEffort = EffortLow
|
||||
@@ -156,6 +210,7 @@ func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPer
|
||||
}
|
||||
return ClaudeRender{
|
||||
Thinking: &dto.Thinking{Type: "disabled"},
|
||||
OutputEffort: outputEffort,
|
||||
EffectiveEffort: EffortNone,
|
||||
ClearSampling: capabilities.strictSampling,
|
||||
Diagnostics: diagnostics,
|
||||
@@ -175,7 +230,7 @@ func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPer
|
||||
effort = EffortFromBudget(*intent.BudgetTokens)
|
||||
}
|
||||
if effort == "" && intent.Mode == ModeEnabled {
|
||||
effort = EffortHigh
|
||||
effort = capabilities.defaultEffort
|
||||
}
|
||||
normalizedEffort := normalizeClaudeEffort(effort, capabilities)
|
||||
if effort != "" && normalizedEffort != effort {
|
||||
@@ -187,7 +242,7 @@ func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPer
|
||||
effort = normalizedEffort
|
||||
effectiveEffort := effort
|
||||
if effectiveEffort == "" && intent.Mode == ModeAdaptive {
|
||||
effectiveEffort = EffortHigh
|
||||
effectiveEffort = capabilities.defaultEffort
|
||||
}
|
||||
|
||||
// Claude effort can be used without enabling thinking. Preserve that
|
||||
@@ -227,11 +282,22 @@ func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPer
|
||||
))
|
||||
intent.Mode = ModeEnabled
|
||||
if intent.Effort == "" {
|
||||
intent.Effort = EffortHigh
|
||||
intent.Effort = capabilities.defaultEffort
|
||||
}
|
||||
}
|
||||
if intent.Mode == ModeUnset {
|
||||
return ClaudeRender{OutputEffort: intent.Effort, EffectiveEffort: intent.Effort, Diagnostics: diagnostics}, nil
|
||||
// Known models get the effort they accept. They think only when asked,
|
||||
// so a removed effort leaves none.
|
||||
effort := intent.Effort
|
||||
if isKnownClaudeModel(model) {
|
||||
effort = ClaudeEffort(model, intent.Effort)
|
||||
diagnostics = append(diagnostics, claudeEffortDiagnostics(model, intent.Effort, effort)...)
|
||||
}
|
||||
effectiveEffort := effort
|
||||
if effectiveEffort == "" {
|
||||
effectiveEffort = EffortNone
|
||||
}
|
||||
return ClaudeRender{OutputEffort: effort, EffectiveEffort: effectiveEffort, Diagnostics: diagnostics}, nil
|
||||
}
|
||||
var raisedMaxTokens *uint
|
||||
if maxTokens == nil || *maxTokens <= 1024 {
|
||||
@@ -289,9 +355,8 @@ func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPer
|
||||
} else if effectiveEffort == "" {
|
||||
effectiveEffort = EffortFromBudget(budget)
|
||||
}
|
||||
outputEffort := Effort("")
|
||||
if capabilities.supportsEffort && intent.Effort != "" {
|
||||
outputEffort = normalizeClaudeEffort(intent.Effort, capabilities)
|
||||
outputEffort := ClaudeEffort(model, intent.Effort)
|
||||
if outputEffort != "" {
|
||||
effectiveEffort = outputEffort
|
||||
}
|
||||
thinking := &dto.Thinking{Type: "enabled", BudgetTokens: &budget}
|
||||
@@ -322,6 +387,25 @@ func claudeReasoningDiagnostic(code string, message string) types.ConversionDiag
|
||||
}
|
||||
}
|
||||
|
||||
// claudeEffortDiagnostics reports a requested effort that is sent at another
|
||||
// level or removed because the model takes none.
|
||||
func claudeEffortDiagnostics(model string, requested Effort, sent Effort) []types.ConversionDiagnostic {
|
||||
switch {
|
||||
case requested == "" || requested == EffortNone || requested == sent:
|
||||
return nil
|
||||
case sent == "":
|
||||
return []types.ConversionDiagnostic{claudeReasoningDiagnostic(
|
||||
"claude_effort_unsupported",
|
||||
fmt.Sprintf("model %q does not support effort %q; the effort was removed", model, requested),
|
||||
)}
|
||||
default:
|
||||
return []types.ConversionDiagnostic{claudeReasoningDiagnostic(
|
||||
"claude_effort_adjusted",
|
||||
fmt.Sprintf("model %q does not support effort %q with the selected thinking mode; using %q", model, requested, sent),
|
||||
)}
|
||||
}
|
||||
}
|
||||
|
||||
// ClaudeUsesManualThinking reports whether an exact numeric budget is rendered
|
||||
// as legacy extended thinking rather than being reduced to adaptive effort.
|
||||
func ClaudeUsesManualThinking(model string, intent Intent) bool {
|
||||
@@ -333,12 +417,39 @@ func IsKnownClaudeModel(model string) bool {
|
||||
return isKnownClaudeModel(model)
|
||||
}
|
||||
|
||||
// ClaudeDefaultEffort is the effort the model runs at when a request sets
|
||||
// none.
|
||||
func ClaudeDefaultEffort(model string) Effort {
|
||||
return claudeCapabilitiesFor(model).defaultEffort
|
||||
}
|
||||
|
||||
// ClaudeEffort returns the effort level the model accepts in place of the
|
||||
// requested one. It returns empty when no effort is requested or the model
|
||||
// takes none, which includes models missing from the capability table.
|
||||
func ClaudeEffort(model string, effort Effort) Effort {
|
||||
capabilities := claudeCapabilitiesFor(model)
|
||||
if !capabilities.supportsEffort || effort == "" || effort == EffortNone {
|
||||
return ""
|
||||
}
|
||||
return normalizeClaudeEffort(effort, capabilities)
|
||||
}
|
||||
|
||||
// ResolveClaudeDefault makes the Claude model's default thinking explicit for
|
||||
// a non-Claude target: no intent becomes adaptive thinking on models that
|
||||
// think by default, and thinking without a strength takes the model's
|
||||
// default effort.
|
||||
func ResolveClaudeDefault(model string, intent Intent) Intent {
|
||||
if intent.HasStrength() || !claudeCapabilitiesFor(model).defaultThinking {
|
||||
return intent
|
||||
capabilities := claudeCapabilitiesFor(model)
|
||||
switch {
|
||||
case !intent.HasStrength():
|
||||
if !capabilities.defaultThinking {
|
||||
return intent
|
||||
}
|
||||
intent.Mode = ModeAdaptive
|
||||
intent.Effort = capabilities.defaultEffort
|
||||
case intent.Effort == "" && intent.BudgetTokens == nil && (intent.Mode == ModeAdaptive || intent.Mode == ModeEnabled):
|
||||
intent.Effort = capabilities.defaultEffort
|
||||
}
|
||||
intent.Mode = ModeAdaptive
|
||||
intent.Effort = EffortHigh
|
||||
return intent
|
||||
}
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ func RenderGemini(model string, intent Intent, maxOutputTokens *uint, adapterBud
|
||||
}
|
||||
hasBudget = true
|
||||
} else if intent.Effort != "" {
|
||||
budget = gemini25BudgetForEffort(intent.Effort)
|
||||
budget = gemini25BudgetForEffort(intent.Effort, capabilities.maxBudget)
|
||||
hasBudget = true
|
||||
} else if intent.Mode != ModeUnset && maxOutputTokens != nil && *maxOutputTokens > 0 {
|
||||
if uint64(*maxOutputTokens) > uint64(math.MaxInt) {
|
||||
@@ -295,7 +295,7 @@ func NormalizeGeminiThinkingConfig(model string, generation *dto.GeminiChatGener
|
||||
|
||||
if capabilities.kind == geminiThinkingBudget {
|
||||
if config.ThinkingLevel != "" {
|
||||
budget := gemini25BudgetForEffort(intent.Effort)
|
||||
budget := gemini25BudgetForEffort(intent.Effort, capabilities.maxBudget)
|
||||
diagnostics = append(diagnostics, geminiReasoningDiagnostic(
|
||||
"gemini_level_to_budget",
|
||||
fmt.Sprintf("model %q uses thinkingBudget; thinkingLevel %q was converted to budget %d", model, config.ThinkingLevel, budget),
|
||||
@@ -476,14 +476,19 @@ func EquivalentGeminiStrength(model string, left Intent, right Intent) (bool, er
|
||||
return leftConfig.ThinkingBudget == nil || *leftConfig.ThinkingBudget == *rightConfig.ThinkingBudget, nil
|
||||
}
|
||||
|
||||
func gemini25BudgetForEffort(effort Effort) int {
|
||||
// gemini25BudgetForEffort follows Google's OpenAI-compatible mapping, where
|
||||
// high is 24576; xhigh and max, which that mapping lacks, take the model's
|
||||
// largest budget.
|
||||
func gemini25BudgetForEffort(effort Effort, maxBudget int) int {
|
||||
switch effort {
|
||||
case EffortMinimal, EffortLow:
|
||||
return 1024
|
||||
case EffortMedium:
|
||||
return 8192
|
||||
case EffortHigh, EffortXHigh, EffortMax:
|
||||
case EffortHigh:
|
||||
return 24576
|
||||
case EffortXHigh, EffortMax:
|
||||
return maxBudget
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ var EffortSuffixes = []string{"-max", "-xhigh", "-high", "-medium", "-low", "-mi
|
||||
|
||||
var OpenAIEffortSuffixes = []string{"-max", "-xhigh", "-high", "-medium", "-low", "-minimal", "-none"}
|
||||
|
||||
var DeepSeekV4EffortSuffixes = []string{"-none", "-max"}
|
||||
var DeepSeekV4EffortSuffixes = []string{"-none", "-low", "-max"}
|
||||
|
||||
var (
|
||||
legacyOpenAIModelPattern = regexp.MustCompile(`^(gpt-[a-z0-9][a-z0-9._-]*|o[1-9][a-z0-9._-]*)$`)
|
||||
@@ -136,12 +136,16 @@ func ParseOpenAIReasoningEffortFromModelSuffix(modelName string, preserveEffortT
|
||||
return effort, baseModel
|
||||
}
|
||||
|
||||
func ParseClaudeModelSuffix(modelName string, allowThinkingAlias bool) (string, Intent, bool, error) {
|
||||
// ParseClaudeModelSuffix folds the naked claude-* aliases (-thinking,
|
||||
// -nothinking, -thinking-<budget>, and effort tails such as -high) into an
|
||||
// Intent. allowSuffixAlias is the host thinking-adapter toggle; when false the
|
||||
// name is returned verbatim and no intent is produced.
|
||||
func ParseClaudeModelSuffix(modelName string, allowSuffixAlias bool) (string, Intent, bool, error) {
|
||||
prefix, bare := splitModelNamespace(modelName)
|
||||
if !strings.HasPrefix(bare, "claude-") {
|
||||
return modelName, Intent{}, false, nil
|
||||
}
|
||||
base, intent, found, err := parseProviderModelSuffix(bare, "claude-", allowThinkingAlias, true)
|
||||
base, intent, found, err := parseProviderModelSuffix(bare, "claude-", allowSuffixAlias, true)
|
||||
if err != nil || !found || !legacyClaudeModelPattern.MatchString(base) {
|
||||
return modelName, Intent{}, false, err
|
||||
}
|
||||
@@ -157,7 +161,7 @@ func isKnownClaudeModel(modelName string) bool {
|
||||
}
|
||||
knownPrefixes := []string{
|
||||
"claude-fable-5", "claude-mythos-5", "claude-mythos-preview",
|
||||
"claude-opus-5", "claude-sonnet-5", "claude-opus-4-8",
|
||||
"claude-opus-5", "claude-sonnet-5", "claude-haiku-5", "claude-opus-4-8",
|
||||
"claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6",
|
||||
"claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5",
|
||||
"claude-opus-4-1", "claude-opus-4-", "claude-sonnet-4-",
|
||||
@@ -171,12 +175,14 @@ func isKnownClaudeModel(modelName string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func ParseGeminiModelSuffix(modelName string, allowThinkingAlias bool) (string, Intent, bool, error) {
|
||||
// ParseGeminiModelSuffix is the gemini-* counterpart of ParseClaudeModelSuffix
|
||||
// and honours allowSuffixAlias the same way.
|
||||
func ParseGeminiModelSuffix(modelName string, allowSuffixAlias bool) (string, Intent, bool, error) {
|
||||
prefix, bare := splitModelNamespace(modelName)
|
||||
if !strings.HasPrefix(bare, "gemini-") {
|
||||
return modelName, Intent{}, false, nil
|
||||
}
|
||||
base, intent, found, err := parseProviderModelSuffix(bare, "gemini-", allowThinkingAlias, true)
|
||||
base, intent, found, err := parseProviderModelSuffix(bare, "gemini-", allowSuffixAlias, true)
|
||||
if err != nil || !found || !legacyGeminiModelPattern.MatchString(base) {
|
||||
return modelName, Intent{}, false, err
|
||||
}
|
||||
@@ -186,13 +192,13 @@ func ParseGeminiModelSuffix(modelName string, allowThinkingAlias bool) (string,
|
||||
// ParseKnownProviderModelSuffix extracts a canonical intent only when the
|
||||
// origin identifies a provider family whose suffix vocabulary is defined by
|
||||
// relaykit. Unknown OpenAI-compatible model names are deliberately untouched.
|
||||
func ParseKnownProviderModelSuffix(modelName string, allowThinkingAlias bool) (string, Intent, bool, error) {
|
||||
func ParseKnownProviderModelSuffix(modelName string, allowSuffixAlias bool) (string, Intent, bool, error) {
|
||||
bare := lastModelPathSegment(modelName)
|
||||
if strings.HasPrefix(bare, "claude-") {
|
||||
return ParseClaudeModelSuffix(modelName, allowThinkingAlias)
|
||||
return ParseClaudeModelSuffix(modelName, allowSuffixAlias)
|
||||
}
|
||||
if strings.HasPrefix(bare, "gemini-") {
|
||||
return ParseGeminiModelSuffix(modelName, allowThinkingAlias)
|
||||
return ParseGeminiModelSuffix(modelName, allowSuffixAlias)
|
||||
}
|
||||
return modelName, Intent{}, false, nil
|
||||
}
|
||||
@@ -214,37 +220,39 @@ func TrimGeminiThinkingSuffix(modelName string) (string, bool) {
|
||||
return baseModel, ok && err == nil
|
||||
}
|
||||
|
||||
func parseProviderModelSuffix(modelName string, requiredPrefix string, allowThinkingAlias bool, includeThoughts bool) (string, Intent, bool, error) {
|
||||
if allowThinkingAlias {
|
||||
if marker := strings.LastIndex(modelName, "-thinking-"); marker >= 0 {
|
||||
baseModel := modelName[:marker]
|
||||
if !strings.HasPrefix(baseModel, requiredPrefix) {
|
||||
return modelName, Intent{}, false, nil
|
||||
}
|
||||
budget, err := strconv.Atoi(modelName[marker+len("-thinking-"):])
|
||||
if err != nil {
|
||||
return modelName, Intent{}, false, fmt.Errorf("invalid thinking budget suffix on model %q: %w", modelName, err)
|
||||
}
|
||||
intent := Intent{BudgetTokens: &budget, Source: SourceSuffix, BudgetSource: SourceSuffix}
|
||||
if includeThoughts {
|
||||
value := true
|
||||
intent.IncludeThoughts = &value
|
||||
}
|
||||
return baseModel, intent, true, nil
|
||||
// parseProviderModelSuffix recognises a provider family's naked model-name
|
||||
// aliases. allowSuffixAlias gates all of them, including effort tails, so a
|
||||
// disabled thinking adapter leaves the model name untouched.
|
||||
func parseProviderModelSuffix(modelName string, requiredPrefix string, allowSuffixAlias bool, includeThoughts bool) (string, Intent, bool, error) {
|
||||
if !allowSuffixAlias {
|
||||
return modelName, Intent{}, false, nil
|
||||
}
|
||||
if marker := strings.LastIndex(modelName, "-thinking-"); marker >= 0 {
|
||||
baseModel := modelName[:marker]
|
||||
if !strings.HasPrefix(baseModel, requiredPrefix) {
|
||||
return modelName, Intent{}, false, nil
|
||||
}
|
||||
if before, ok := strings.CutSuffix(modelName, "-nothinking"); ok {
|
||||
baseModel := before
|
||||
return baseModel, Intent{Mode: ModeDisabled, Effort: EffortNone, Source: SourceSuffix}, true, nil
|
||||
budget, err := strconv.Atoi(modelName[marker+len("-thinking-"):])
|
||||
if err != nil {
|
||||
return modelName, Intent{}, false, fmt.Errorf("invalid thinking budget suffix on model %q: %w", modelName, err)
|
||||
}
|
||||
if before, ok := strings.CutSuffix(modelName, "-thinking"); ok {
|
||||
baseModel := before
|
||||
intent := Intent{Mode: ModeEnabled, Source: SourceSuffix}
|
||||
if includeThoughts {
|
||||
value := true
|
||||
intent.IncludeThoughts = &value
|
||||
}
|
||||
return baseModel, intent, true, nil
|
||||
intent := Intent{BudgetTokens: &budget, Source: SourceSuffix, BudgetSource: SourceSuffix}
|
||||
if includeThoughts {
|
||||
value := true
|
||||
intent.IncludeThoughts = &value
|
||||
}
|
||||
return baseModel, intent, true, nil
|
||||
}
|
||||
if baseModel, ok := strings.CutSuffix(modelName, "-nothinking"); ok {
|
||||
return baseModel, Intent{Mode: ModeDisabled, Effort: EffortNone, Source: SourceSuffix}, true, nil
|
||||
}
|
||||
if baseModel, ok := strings.CutSuffix(modelName, "-thinking"); ok {
|
||||
intent := Intent{Mode: ModeEnabled, Source: SourceSuffix}
|
||||
if includeThoughts {
|
||||
value := true
|
||||
intent.IncludeThoughts = &value
|
||||
}
|
||||
return baseModel, intent, true, nil
|
||||
}
|
||||
|
||||
suffixes := []string{"-max", "-xhigh", "-high", "-medium", "-low", "-minimal", "-none"}
|
||||
@@ -266,16 +274,19 @@ func parseProviderModelSuffix(modelName string, requiredPrefix string, allowThin
|
||||
return baseModel, intent, true, nil
|
||||
}
|
||||
|
||||
// ParseDeepSeekV4ThinkingSuffix maps the -none/-low/-max tails of
|
||||
// deepseek-flash and deepseek-v4-* onto DeepSeek's thinking switch and
|
||||
// effort. A bare name keeps DeepSeek's default (thinking on, effort high).
|
||||
func ParseDeepSeekV4ThinkingSuffix(modelName string) (baseModel string, thinkingType string, effort string, ok bool) {
|
||||
baseModel, suffix, ok := TrimEffortSuffixWithSuffixes(modelName, DeepSeekV4EffortSuffixes)
|
||||
if !ok || !strings.HasPrefix(baseModel, "deepseek-v4-") {
|
||||
if !ok || baseModel != "deepseek-flash" && !strings.HasPrefix(baseModel, "deepseek-v4-") {
|
||||
return modelName, "", "", false
|
||||
}
|
||||
switch suffix {
|
||||
case "none":
|
||||
return baseModel, "disabled", "", true
|
||||
case "max":
|
||||
return baseModel, "enabled", "max", true
|
||||
case "low", "max":
|
||||
return baseModel, "enabled", suffix, true
|
||||
default:
|
||||
return modelName, "", "", false
|
||||
}
|
||||
|
||||
@@ -68,9 +68,9 @@ var (
|
||||
|
||||
const (
|
||||
requestConverterClaudeToGemini = "claude_messages_to_gemini_generate_content"
|
||||
requestConverterClaudeToResponses = "claude_messages_to_openai_responses"
|
||||
requestConverterClaudeToResponses = ConverterClaudeMessagesToOpenAIResponses
|
||||
requestConverterGeminiToClaude = "gemini_generate_content_to_claude_messages"
|
||||
requestConverterGeminiToResponses = "gemini_generate_content_to_openai_responses"
|
||||
requestConverterGeminiToResponses = ConverterGeminiContentToOpenAIResponses
|
||||
requestConverterResponsesToClaude = ConverterOpenAIResponsesToClaudeMessages
|
||||
)
|
||||
|
||||
@@ -84,6 +84,8 @@ const (
|
||||
ConverterOpenAIResponsesToGemini = "openai_responses_to_gemini_generate_content"
|
||||
ConverterGeminiContentToOpenAIChat = "gemini_generate_content_to_openai_chat_completions"
|
||||
ConverterOpenAIChatToGeminiContent = "openai_chat_completions_to_gemini_generate_content"
|
||||
ConverterClaudeMessagesToOpenAIResponses = "claude_messages_to_openai_responses"
|
||||
ConverterGeminiContentToOpenAIResponses = "gemini_generate_content_to_openai_responses"
|
||||
)
|
||||
|
||||
func registerBuiltinRequestConverter(spec RequestConverterSpec) {
|
||||
@@ -247,11 +249,6 @@ func executeRequestSteps(c context.Context, info convmeta.Meta, from types.Relay
|
||||
}
|
||||
steps := make([]RequestStep, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
current, err = prepareRequestForStep(current, spec, target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var step RequestStep
|
||||
current, step, err = executeRequestStep(c, info, spec, current)
|
||||
if err != nil {
|
||||
@@ -260,6 +257,7 @@ func executeRequestSteps(c context.Context, info convmeta.Meta, from types.Relay
|
||||
steps = append(steps, step)
|
||||
}
|
||||
|
||||
tools.Model = convmeta.UpstreamModelName(info)
|
||||
current, toolDiagnostics, err := toolconv.AttachRequest(target, current, tools, convmeta.OptionsOf(info))
|
||||
diagnostics := append(diagnosticCollector.Diagnostics(), toolDiagnostics...)
|
||||
for i := range diagnostics {
|
||||
@@ -284,6 +282,9 @@ func executeRequestSteps(c context.Context, info convmeta.Meta, from types.Relay
|
||||
for _, step := range steps {
|
||||
info.AppendRequestConversion(step.To)
|
||||
}
|
||||
if from == types.RelayFormatOpenAIResponses {
|
||||
info.SetResponsesToolState(responsesToolState(tools))
|
||||
}
|
||||
}
|
||||
|
||||
converters := make([]string, 0, len(steps))
|
||||
@@ -304,6 +305,19 @@ func executeRequestSteps(c context.Context, info convmeta.Meta, from types.Relay
|
||||
}, nil
|
||||
}
|
||||
|
||||
// responsesToolState records which Responses custom tools were sent upstream
|
||||
// as functions and which tool names were flattened from namespaces, so the
|
||||
// response side can restore their calls. It returns nil when there is nothing
|
||||
// to restore so a retry never reuses another attempt's record.
|
||||
func responsesToolState(tools toolconv.Set) *convmeta.ResponsesToolState {
|
||||
names := toolconv.ResponsesCustomToolNames(tools)
|
||||
namespaces := toolconv.ResponsesToolNamespaces(tools)
|
||||
if len(names) == 0 && len(namespaces) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &convmeta.ResponsesToolState{CustomToolNames: names, Namespaces: namespaces}
|
||||
}
|
||||
|
||||
func expandRequestConverterSteps(spec RequestConverterSpec) ([]RequestConverterSpec, error) {
|
||||
if len(spec.StepConverters) == 0 {
|
||||
if spec.Convert == nil {
|
||||
@@ -353,28 +367,6 @@ func executeRequestStep(c context.Context, info convmeta.Meta, spec RequestConve
|
||||
}, nil
|
||||
}
|
||||
|
||||
func prepareRequestForStep(request any, spec RequestConverterSpec, finalTarget types.RelayFormat) (any, error) {
|
||||
if spec.From != types.RelayFormatOpenAIResponses || finalTarget != types.RelayFormatGemini {
|
||||
return request, nil
|
||||
}
|
||||
|
||||
responsesRequest, ok := request.(*dto.OpenAIResponsesRequest)
|
||||
if !ok {
|
||||
if value, ok := request.(dto.OpenAIResponsesRequest); ok {
|
||||
responsesRequest = &value
|
||||
}
|
||||
}
|
||||
if responsesRequest == nil {
|
||||
return nil, fmt.Errorf("expected OpenAI responses request, got %T", request)
|
||||
}
|
||||
|
||||
prepared, err := oairesponses.PrepareOpenAIResponsesRequest(*responsesRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &prepared, nil
|
||||
}
|
||||
|
||||
func lookupRequestRoute(from types.RelayFormat, to types.RelayFormat) (RequestConverterSpec, bool) {
|
||||
requestConverterMu.RLock()
|
||||
defer requestConverterMu.RUnlock()
|
||||
@@ -521,12 +513,7 @@ func convertOpenAIResponsesRequestToGeminiChat(c context.Context, info convmeta.
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prepared, err := oairesponses.PrepareOpenAIResponsesRequest(*responsesRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return oairesponses.OpenAIResponsesRequestToGeminiChat(c, &prepared, info)
|
||||
return oairesponses.OpenAIResponsesRequestToGeminiChat(c, responsesRequest, info)
|
||||
}
|
||||
|
||||
func convertResponsesRequestToChat(c context.Context, _ convmeta.Meta, request any) (any, error) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package relayconvert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
@@ -8,10 +9,90 @@ import (
|
||||
sharedgemini "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/gemini"
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestClaudeMessagesToOpenAIChatPreservesAssistantTextWithToolCalls(t *testing.T) {
|
||||
text := "I will look that up."
|
||||
request := dto.ClaudeRequest{
|
||||
Model: "claude-test",
|
||||
Messages: []dto.ClaudeMessage{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: []dto.ClaudeMediaMessage{
|
||||
{Type: "text", Text: &text},
|
||||
{
|
||||
Type: "tool_use",
|
||||
Id: "call_1",
|
||||
Name: "lookup",
|
||||
Input: map[string]any{"query": "weather"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ConvertRequest(
|
||||
context.Background(),
|
||||
&convmeta.Values{},
|
||||
types.RelayFormatOpenAI,
|
||||
&request,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
converted, ok := result.Value.(*dto.GeneralOpenAIRequest)
|
||||
require.True(t, ok)
|
||||
require.Len(t, converted.Messages, 1)
|
||||
|
||||
assistant := converted.Messages[0]
|
||||
assert.Equal(t, "assistant", assistant.Role)
|
||||
toolCalls := assistant.ParseToolCalls()
|
||||
require.Len(t, toolCalls, 1)
|
||||
assert.Equal(t, "call_1", toolCalls[0].ID)
|
||||
require.Len(t, assistant.ParseContent(), 1)
|
||||
assert.Equal(t, "text", assistant.ParseContent()[0].Type)
|
||||
assert.Equal(t, text, assistant.ParseContent()[0].Text)
|
||||
}
|
||||
|
||||
func TestClaudeMessagesToOpenAIChatKeepsToolOnlyContentEmpty(t *testing.T) {
|
||||
request := dto.ClaudeRequest{
|
||||
Model: "claude-test",
|
||||
Messages: []dto.ClaudeMessage{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: []dto.ClaudeMediaMessage{
|
||||
{
|
||||
Type: "tool_use",
|
||||
Id: "call_1",
|
||||
Name: "lookup",
|
||||
Input: map[string]any{"query": "weather"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ConvertRequest(
|
||||
context.Background(),
|
||||
&convmeta.Values{},
|
||||
types.RelayFormatOpenAI,
|
||||
&request,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
converted, ok := result.Value.(*dto.GeneralOpenAIRequest)
|
||||
require.True(t, ok)
|
||||
require.Len(t, converted.Messages, 1)
|
||||
|
||||
assistant := converted.Messages[0]
|
||||
assert.Empty(t, assistant.ParseContent())
|
||||
toolCalls := assistant.ParseToolCalls()
|
||||
require.Len(t, toolCalls, 1)
|
||||
assert.Equal(t, "call_1", toolCalls[0].ID)
|
||||
}
|
||||
|
||||
func TestRequestConverterRegistryListsSupportedTextConverters(t *testing.T) {
|
||||
tests := []struct {
|
||||
converter string
|
||||
@@ -38,10 +119,11 @@ func TestRequestConverterRegistryListsSupportedTextConverters(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
converter: requestConverterClaudeToResponses,
|
||||
from: types.RelayFormatClaude,
|
||||
to: types.RelayFormatOpenAIResponses,
|
||||
quality: RequestConverterQualityFair,
|
||||
converter: requestConverterClaudeToResponses,
|
||||
from: types.RelayFormatClaude,
|
||||
to: types.RelayFormatOpenAIResponses,
|
||||
quality: RequestConverterQualityFair,
|
||||
advancedCustom: true,
|
||||
},
|
||||
{
|
||||
converter: requestConverterGeminiToClaude,
|
||||
@@ -62,6 +144,7 @@ func TestRequestConverterRegistryListsSupportedTextConverters(t *testing.T) {
|
||||
ConverterGeminiContentToOpenAIChat,
|
||||
ConverterOpenAIChatToOpenAIResponses,
|
||||
},
|
||||
advancedCustom: true,
|
||||
},
|
||||
{
|
||||
converter: requestConverterResponsesToClaude,
|
||||
@@ -158,6 +241,52 @@ func TestConvertRequestClaudeToResponsesUsesDirectPath(t *testing.T) {
|
||||
assert.Equal(t, []types.RelayFormat{types.RelayFormatClaude, types.RelayFormatOpenAIResponses}, info.ConversionChain)
|
||||
}
|
||||
|
||||
func TestConvertRequestClaudeToChatResolvesToolResultNames(t *testing.T) {
|
||||
req := &dto.ClaudeRequest{
|
||||
Model: "claude-test",
|
||||
Messages: []dto.ClaudeMessage{
|
||||
{Role: "user", Content: []dto.ClaudeMediaMessage{
|
||||
{Type: "tool_result", ToolUseId: "call_1", Content: "before call"},
|
||||
}},
|
||||
{Role: "assistant", Content: []dto.ClaudeMediaMessage{
|
||||
{Type: "tool_use", Id: "call_1", Name: "first", Input: map[string]any{}},
|
||||
}},
|
||||
{Role: "user", Content: []dto.ClaudeMediaMessage{
|
||||
{Type: "tool_result", ToolUseId: "call_1", Content: "after call"},
|
||||
{Type: "tool_result", ToolUseId: "missing", Content: "unknown"},
|
||||
{Type: "tool_result", ToolUseId: "call_1", Name: "explicit", Content: "named"},
|
||||
}},
|
||||
{Role: "assistant", Content: []dto.ClaudeMediaMessage{
|
||||
{Type: "tool_use", Id: "call_1", Name: "later", Input: map[string]any{}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ConvertRequestByID(nil, nil, ConverterClaudeMessagesToOpenAIChat, req)
|
||||
require.NoError(t, err)
|
||||
chatReq, ok := result.Value.(*dto.GeneralOpenAIRequest)
|
||||
require.True(t, ok)
|
||||
require.Len(t, chatReq.Messages, 6)
|
||||
for _, tt := range []struct {
|
||||
index int
|
||||
id string
|
||||
name string
|
||||
}{
|
||||
{0, "call_1", "first"},
|
||||
{2, "call_1", "first"},
|
||||
{3, "missing", ""},
|
||||
{4, "call_1", "explicit"},
|
||||
} {
|
||||
message := chatReq.Messages[tt.index]
|
||||
assert.Equal(t, "tool", message.Role)
|
||||
assert.Equal(t, tt.id, message.ToolCallId)
|
||||
require.NotNil(t, message.Name)
|
||||
assert.Equal(t, tt.name, *message.Name)
|
||||
}
|
||||
assert.Equal(t, "assistant", chatReq.Messages[1].Role)
|
||||
assert.Equal(t, "assistant", chatReq.Messages[5].Role)
|
||||
}
|
||||
|
||||
func TestConvertRequestClaudeToResponsesPreservesMixedBlockOrder(t *testing.T) {
|
||||
info := &convmeta.Values{ConversionChain: []types.RelayFormat{types.RelayFormatClaude}}
|
||||
stream := true
|
||||
@@ -219,6 +348,43 @@ func TestConvertRequestClaudeToResponsesPreservesMixedBlockOrder(t *testing.T) {
|
||||
assert.Equal(t, "continue", inputContentText(t, input[5]))
|
||||
}
|
||||
|
||||
func TestConvertRequestClaudeToResponsesDefaultsImageDetail(t *testing.T) {
|
||||
const imageData = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+Xoy8AAAAASUVORK5CYII="
|
||||
req := &dto.ClaudeRequest{
|
||||
Model: "gpt-test",
|
||||
Messages: []dto.ClaudeMessage{
|
||||
{Role: "user", Content: []dto.ClaudeMediaMessage{{
|
||||
Type: "image",
|
||||
Source: &dto.ClaudeMessageSource{Type: "url", Url: "https://example.test/a.png"},
|
||||
}}},
|
||||
{Role: "assistant", Content: []dto.ClaudeMediaMessage{{
|
||||
Type: "tool_use", Id: "call_1", Name: "screenshot", Input: map[string]any{},
|
||||
}}},
|
||||
{Role: "user", Content: []dto.ClaudeMediaMessage{{
|
||||
Type: "tool_result", ToolUseId: "call_1",
|
||||
Content: []dto.ClaudeMediaMessage{{
|
||||
Type: "image",
|
||||
Source: &dto.ClaudeMessageSource{Type: "base64", MediaType: "image/png", Data: imageData},
|
||||
}},
|
||||
}}},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ConvertRequestByID(context.Background(), nil, ConverterClaudeMessagesToOpenAIResponses, req)
|
||||
require.NoError(t, err)
|
||||
responsesReq, ok := result.Value.(*dto.OpenAIResponsesRequest)
|
||||
require.True(t, ok)
|
||||
assert.JSONEq(t, `[
|
||||
{"role":"user","content":[
|
||||
{"type":"input_image","image_url":"https://example.test/a.png","detail":"auto"}
|
||||
]},
|
||||
{"type":"function_call","call_id":"call_1","name":"screenshot","arguments":"{}"},
|
||||
{"type":"function_call_output","call_id":"call_1","output":[
|
||||
{"type":"input_image","image_url":"data:image/png;base64,`+imageData+`","detail":"auto"}
|
||||
]}
|
||||
]`, string(responsesReq.Input))
|
||||
}
|
||||
|
||||
func TestConvertRequestClaudeToResponsesDropsIncompatibleContextManagement(t *testing.T) {
|
||||
req := &dto.ClaudeRequest{
|
||||
Model: "gpt-test",
|
||||
@@ -374,61 +540,121 @@ func TestConvertRequestViaExecutesExplicitPath(t *testing.T) {
|
||||
assert.Equal(t, []types.RelayFormat{types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses}, info.ConversionChain)
|
||||
}
|
||||
|
||||
func TestConvertRequestResponsesToGeminiAppliesResponsesPreprocess(t *testing.T) {
|
||||
info := &convmeta.Values{
|
||||
ConversionChain: []types.RelayFormat{types.RelayFormatOpenAIResponses},
|
||||
ChannelMetaAttached: true,
|
||||
UpstreamModelName: "gemini-test",
|
||||
// customToolInputs are custom_tool_call inputs that must reach the upstream
|
||||
// as the raw string, including ones that happen to look like JSON.
|
||||
var customToolInputs = []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"json object", `{"a":1}`},
|
||||
{"json array", `[1,2]`},
|
||||
{"plain text", "*** Begin Patch\n*** End Patch"},
|
||||
}
|
||||
|
||||
func customToolHistoryRequest(t *testing.T, items ...map[string]any) *dto.OpenAIResponsesRequest {
|
||||
t.Helper()
|
||||
input := append([]map[string]any{{"role": "user", "content": "apply the patch"}}, items...)
|
||||
return &dto.OpenAIResponsesRequest{
|
||||
Model: "model-test",
|
||||
MaxOutputTokens: lo.ToPtr(uint(64)),
|
||||
Input: mustRawMessage(t, input),
|
||||
Tools: mustRawMessage(t, []map[string]any{{"type": "custom", "name": "apply_patch"}}),
|
||||
}
|
||||
req := &dto.OpenAIResponsesRequest{
|
||||
Model: "gemini-test",
|
||||
Input: mustRawMessage(t, []map[string]any{
|
||||
{
|
||||
"role": "user",
|
||||
"content": "next turn",
|
||||
},
|
||||
{
|
||||
"type": "custom_tool_call",
|
||||
"call_id": "call_custom",
|
||||
"name": "apply_patch",
|
||||
"input": "patch body",
|
||||
},
|
||||
{
|
||||
"type": "custom_tool_call_output",
|
||||
"call_id": "call_custom",
|
||||
"output": "ok",
|
||||
},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_custom",
|
||||
"output": "legacy custom output",
|
||||
},
|
||||
}),
|
||||
Tools: mustRawMessage(t, []map[string]any{
|
||||
{"type": "custom", "name": "apply_patch"},
|
||||
}),
|
||||
}
|
||||
|
||||
func TestConvertRequestResponsesToClaudeKeepsCustomToolHistory(t *testing.T) {
|
||||
for _, tc := range customToolInputs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := customToolHistoryRequest(t,
|
||||
map[string]any{"type": "custom_tool_call", "call_id": "call_custom", "name": "apply_patch", "input": tc.input},
|
||||
map[string]any{"type": "custom_tool_call_output", "call_id": "call_custom", "output": "ok"},
|
||||
)
|
||||
|
||||
result, err := ConvertRequest(nil, &convmeta.Values{}, types.RelayFormatClaude, req)
|
||||
require.NoError(t, err)
|
||||
claudeReq, ok := result.Value.(*dto.ClaudeRequest)
|
||||
require.True(t, ok)
|
||||
require.Len(t, claudeReq.Messages, 3)
|
||||
|
||||
assert.Equal(t, "assistant", claudeReq.Messages[1].Role)
|
||||
toolUse, err := claudeReq.Messages[1].ParseContent()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []dto.ClaudeMediaMessage{{
|
||||
Type: "tool_use",
|
||||
Id: "call_custom",
|
||||
Name: "apply_patch",
|
||||
Input: map[string]any{"input": tc.input},
|
||||
}}, toolUse)
|
||||
|
||||
assert.Equal(t, "user", claudeReq.Messages[2].Role)
|
||||
toolResult, err := claudeReq.Messages[2].ParseContent()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []dto.ClaudeMediaMessage{{Type: "tool_result", ToolUseId: "call_custom", Content: "ok"}}, toolResult)
|
||||
})
|
||||
}
|
||||
|
||||
result, err := ConvertRequest(nil, info, types.RelayFormatGemini, req)
|
||||
t.Run("missing name", func(t *testing.T) {
|
||||
req := customToolHistoryRequest(t, map[string]any{"type": "custom_tool_call", "call_id": "call_custom", "input": "ls"})
|
||||
_, err := ConvertRequest(nil, &convmeta.Values{}, types.RelayFormatClaude, req)
|
||||
require.ErrorContains(t, err, "custom_tool_call item is missing name")
|
||||
})
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
geminiReq, ok := result.Value.(*dto.GeminiChatRequest)
|
||||
require.True(t, ok)
|
||||
assert.Empty(t, geminiReq.GetTools())
|
||||
require.Len(t, geminiReq.Contents, 1)
|
||||
assert.Equal(t, "user", geminiReq.Contents[0].Role)
|
||||
require.Len(t, geminiReq.Contents[0].Parts, 1)
|
||||
assert.Equal(t, "next turn", geminiReq.Contents[0].Parts[0].Text)
|
||||
assert.Equal(t, ConverterOpenAIResponsesToGemini, result.Converter)
|
||||
assert.Equal(t, RequestConverterQualityFair, result.Quality)
|
||||
assert.Equal(t, []RequestStep{
|
||||
{
|
||||
Converter: ConverterOpenAIResponsesToGemini,
|
||||
From: types.RelayFormatOpenAIResponses,
|
||||
To: types.RelayFormatGemini,
|
||||
},
|
||||
}, result.Steps)
|
||||
assert.Equal(t, []types.RelayFormat{types.RelayFormatOpenAIResponses, types.RelayFormatGemini}, info.ConversionChain)
|
||||
func TestConvertRequestResponsesToGeminiKeepsCustomToolHistory(t *testing.T) {
|
||||
info := func() *convmeta.Values {
|
||||
return &convmeta.Values{Options: &convmeta.Options{Gemini: convmeta.GeminiOptions{FunctionCallThoughtSignatureEnabled: true}}}
|
||||
}
|
||||
for _, tc := range customToolInputs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Older Codex history may answer a custom call with function_call_output.
|
||||
req := customToolHistoryRequest(t,
|
||||
map[string]any{"type": "custom_tool_call", "call_id": "call_custom", "name": "apply_patch", "input": tc.input},
|
||||
map[string]any{"type": "custom_tool_call_output", "call_id": "call_custom", "output": "ok"},
|
||||
map[string]any{"type": "function_call_output", "call_id": "call_custom", "output": "legacy"},
|
||||
)
|
||||
|
||||
result, err := ConvertRequest(nil, info(), types.RelayFormatGemini, req)
|
||||
require.NoError(t, err)
|
||||
geminiReq, ok := result.Value.(*dto.GeminiChatRequest)
|
||||
require.True(t, ok)
|
||||
tools := geminiReq.GetTools()
|
||||
require.Len(t, tools, 1)
|
||||
functions, err := kitutil.Any2Type[[]dto.FunctionRequest](tools[0].FunctionDeclarations)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, functions, 1)
|
||||
assert.Equal(t, "apply_patch", functions[0].Name)
|
||||
require.Len(t, geminiReq.Contents, 3)
|
||||
|
||||
model := geminiReq.Contents[1]
|
||||
assert.Equal(t, "model", model.Role)
|
||||
require.Len(t, model.Parts, 1)
|
||||
assert.Equal(t, &dto.FunctionCall{
|
||||
ID: "call_custom",
|
||||
FunctionName: "apply_patch",
|
||||
Arguments: map[string]any{"input": tc.input},
|
||||
}, model.Parts[0].FunctionCall)
|
||||
var thoughtSignature string
|
||||
require.NoError(t, kitutil.Unmarshal(model.Parts[0].ThoughtSignature, &thoughtSignature))
|
||||
assert.Equal(t, sharedgemini.ThoughtSignatureBypassValue, thoughtSignature)
|
||||
|
||||
user := geminiReq.Contents[2]
|
||||
assert.Equal(t, "user", user.Role)
|
||||
require.Len(t, user.Parts, 2)
|
||||
for index, output := range []string{"ok", "legacy"} {
|
||||
assert.Equal(t, &dto.GeminiFunctionResponse{
|
||||
Name: "apply_patch",
|
||||
Response: map[string]any{"content": output},
|
||||
ID: []byte(`"call_custom"`),
|
||||
}, user.Parts[index].FunctionResponse)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("missing name", func(t *testing.T) {
|
||||
req := customToolHistoryRequest(t, map[string]any{"type": "custom_tool_call", "call_id": "call_custom", "input": "ls"})
|
||||
_, err := ConvertRequest(nil, info(), types.RelayFormatGemini, req)
|
||||
require.ErrorContains(t, err, "custom_tool_call item is missing name")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConvertRequestResponsesToGeminiUsesDirectConverter(t *testing.T) {
|
||||
@@ -781,3 +1007,38 @@ func inputContentText(t *testing.T, item map[string]any) string {
|
||||
require.True(t, ok)
|
||||
return text
|
||||
}
|
||||
|
||||
func TestConvertRequestRecordsResponsesCustomToolsForEachTarget(t *testing.T) {
|
||||
codexRequest := func(tools ...map[string]any) *dto.OpenAIResponsesRequest {
|
||||
return &dto.OpenAIResponsesRequest{
|
||||
Model: "model-test",
|
||||
MaxOutputTokens: lo.ToPtr(uint(64)),
|
||||
Input: mustMarshalRequestJSON(t, "hello"),
|
||||
Tools: mustMarshalRequestJSON(t, tools),
|
||||
}
|
||||
}
|
||||
execTool := map[string]any{"type": "custom", "name": "exec", "description": "Run code", "format": map[string]any{"type": "text"}}
|
||||
waitTool := map[string]any{"type": "function", "name": "wait", "parameters": map[string]any{"type": "object"}}
|
||||
|
||||
for _, target := range []types.RelayFormat{types.RelayFormatOpenAI, types.RelayFormatClaude, types.RelayFormatGemini} {
|
||||
t.Run(string(target), func(t *testing.T) {
|
||||
info := &convmeta.Values{}
|
||||
_, err := ConvertRequest(nil, info, target, codexRequest(execTool, waitTool))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, info.ResponsesTools)
|
||||
assert.True(t, info.ResponsesTools.IsCustomTool("exec"))
|
||||
assert.False(t, info.ResponsesTools.IsCustomTool("wait"))
|
||||
|
||||
_, err = ConvertRequest(nil, info, target, codexRequest(waitTool))
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, info.ResponsesTools, "a retry without custom tools must not keep the previous record")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustMarshalRequestJSON(t *testing.T, value any) []byte {
|
||||
t.Helper()
|
||||
raw, err := kitutil.Marshal(value)
|
||||
require.NoError(t, err)
|
||||
return raw
|
||||
}
|
||||
@@ -893,7 +893,7 @@ func usageFromClaudeResponse(resp *dto.ClaudeResponse) *dto.Usage {
|
||||
return nil
|
||||
}
|
||||
|
||||
func convertOAIChatResponseToOAIResponses(_ context.Context, _ convmeta.Meta, response any) (any, *dto.Usage, error) {
|
||||
func convertOAIChatResponseToOAIResponses(_ context.Context, info convmeta.Meta, response any) (any, *dto.Usage, error) {
|
||||
chatResponse, err := asOAIChatResponse(response)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -902,7 +902,7 @@ func convertOAIChatResponseToOAIResponses(_ context.Context, _ convmeta.Meta, re
|
||||
if id == "" {
|
||||
id = fmt.Sprintf("resp_%s", kitutil.GetUUID())
|
||||
}
|
||||
return ChatCompletionsResponseToResponsesResponse(chatResponse, id)
|
||||
return oaichat.ChatCompletionsResponseToResponsesResponseWithTools(chatResponse, id, convmeta.ResponsesToolStateOf(info))
|
||||
}
|
||||
|
||||
func convertOAIResponsesResponseToOAIChat(_ context.Context, _ convmeta.Meta, response any) (any, *dto.Usage, error) {
|
||||
@@ -938,7 +938,7 @@ func newOAIChatToOAIResponsesStreamState(options ResponseStreamOptions) any {
|
||||
return state
|
||||
}
|
||||
|
||||
func convertOAIChatStreamResponseToOAIResponses(_ context.Context, _ convmeta.Meta, response any, state any) ([]any, *dto.Usage, error) {
|
||||
func convertOAIChatStreamResponseToOAIResponses(_ context.Context, info convmeta.Meta, response any, state any) ([]any, *dto.Usage, error) {
|
||||
chatResponse, err := asOAIChatStreamResponse(response)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -947,6 +947,9 @@ func convertOAIChatStreamResponseToOAIResponses(_ context.Context, _ convmeta.Me
|
||||
if !ok || streamState == nil {
|
||||
return nil, nil, errors.New("OAI chat to OAI responses stream state is required")
|
||||
}
|
||||
if streamState.Tools == nil {
|
||||
streamState.Tools = convmeta.ResponsesToolStateOf(info)
|
||||
}
|
||||
events, err := ChatCompletionsStreamChunkToResponsesEvents(chatResponse, streamState)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package relayconvert
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
@@ -699,3 +700,104 @@ func textRegistryResponsesResponse() *dto.OpenAIResponsesResponse {
|
||||
func respPtr[T any](value T) *T {
|
||||
return &value
|
||||
}
|
||||
|
||||
func TestConvertResponseToResponsesRestoresRecordedCustomTools(t *testing.T) {
|
||||
info := &convmeta.Values{ResponsesTools: &convmeta.ResponsesToolState{CustomToolNames: map[string]struct{}{"exec": {}}}}
|
||||
chatMessage := dto.Message{Role: "assistant"}
|
||||
chatMessage.SetToolCalls([]dto.ToolCallRequest{{ID: "call_exec", Type: "function", Function: dto.FunctionRequest{Name: "exec", Arguments: `{"input":"ls"}`}}})
|
||||
geminiExecCall := `{"candidates":[{"finishReason":"STOP","content":{"role":"model","parts":[{"functionCall":{"name":"exec","args":{"input":"ls"}}}]}}],"usageMetadata":{"promptTokenCount":4,"candidatesTokenCount":2,"totalTokenCount":6}}`
|
||||
tests := []struct {
|
||||
name string
|
||||
from types.RelayFormat
|
||||
response any
|
||||
stream []any
|
||||
}{
|
||||
{
|
||||
name: "chat",
|
||||
from: types.RelayFormatOpenAI,
|
||||
response: &dto.OpenAITextResponse{
|
||||
Id: "chatcmpl_1",
|
||||
Model: "gpt-test",
|
||||
Choices: []dto.OpenAITextResponseChoice{{Message: chatMessage, FinishReason: "tool_calls"}},
|
||||
},
|
||||
stream: []any{
|
||||
chatStreamChunk(`{"id":"chatcmpl_1","model":"gpt-test","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_exec","type":"function","function":{"name":"exec","arguments":"{\"input\":"}}]}}]}`),
|
||||
chatStreamChunk(`{"id":"chatcmpl_1","model":"gpt-test","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"ls\"}"}}]},"finish_reason":"tool_calls"}]}`),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "claude",
|
||||
from: types.RelayFormatClaude,
|
||||
response: &dto.ClaudeResponse{
|
||||
Id: "msg_1",
|
||||
Type: "message",
|
||||
Role: "assistant",
|
||||
Model: "claude-test",
|
||||
StopReason: "tool_use",
|
||||
Content: []dto.ClaudeMediaMessage{{Type: "tool_use", Id: "toolu_exec", Name: "exec", Input: map[string]any{"input": "ls"}}},
|
||||
},
|
||||
stream: []any{
|
||||
claudeStreamChunk(`{"type":"message_start","message":{"id":"msg_1","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":"tool_use","id":"toolu_exec","name":"exec","input":{}}}`),
|
||||
claudeStreamChunk(`{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"input\":"}}`),
|
||||
claudeStreamChunk(`{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\"ls\"}"}}`),
|
||||
claudeStreamChunk(`{"type":"content_block_stop","index":0}`),
|
||||
claudeStreamChunk(`{"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":2}}`),
|
||||
claudeStreamChunk(`{"type":"message_stop"}`),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gemini",
|
||||
from: types.RelayFormatGemini,
|
||||
response: geminiStreamChunk(geminiExecCall),
|
||||
stream: []any{geminiStreamChunk(geminiExecCall)},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := ConvertResponse(nil, info, types.RelayFormatOpenAIResponses, tt.response)
|
||||
require.NoError(t, err)
|
||||
responses, ok := result.Value.(*dto.OpenAIResponsesResponse)
|
||||
require.True(t, ok)
|
||||
require.Len(t, responses.Output, 1)
|
||||
assert.Equal(t, "custom_tool_call", responses.Output[0].Type)
|
||||
assert.Equal(t, "exec", responses.Output[0].Name)
|
||||
assert.Equal(t, `"ls"`, string(responses.Output[0].Input))
|
||||
|
||||
state, err := NewResponseStreamState(tt.from, types.RelayFormatOpenAIResponses, ResponseStreamOptions{ID: "resp_1", Model: "model-test"})
|
||||
require.NoError(t, err)
|
||||
var results []ResponseResult
|
||||
for _, chunk := range tt.stream {
|
||||
chunkResults, err := ConvertStreamResponseChunk(nil, info, state, chunk)
|
||||
require.NoError(t, err)
|
||||
results = append(results, chunkResults...)
|
||||
}
|
||||
finals, err := FinalizeStreamResponse(nil, info, state)
|
||||
require.NoError(t, err)
|
||||
results = append(results, finals...)
|
||||
|
||||
var toolEvents []string
|
||||
for _, result := range results {
|
||||
event, ok := result.Value.(ChatToResponsesStreamEvent)
|
||||
require.True(t, ok)
|
||||
switch {
|
||||
case event.Payload.Item != nil && event.Payload.Item.Type != "message":
|
||||
toolEvents = append(toolEvents, event.Type+" "+event.Payload.Item.Type+" "+string(event.Payload.Item.Input))
|
||||
case event.Type == "response.custom_tool_call_input.delta":
|
||||
toolEvents = append(toolEvents, event.Type+" "+event.Payload.Delta)
|
||||
case event.Type == "response.custom_tool_call_input.done":
|
||||
require.NotNil(t, event.Payload.Input)
|
||||
toolEvents = append(toolEvents, event.Type+" "+*event.Payload.Input)
|
||||
case strings.Contains(event.Type, "function_call_arguments"):
|
||||
toolEvents = append(toolEvents, event.Type)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, []string{
|
||||
`response.output_item.added custom_tool_call ""`,
|
||||
"response.custom_tool_call_input.delta ls",
|
||||
"response.custom_tool_call_input.done ls",
|
||||
`response.output_item.done custom_tool_call "ls"`,
|
||||
}, toolEvents)
|
||||
})
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -13,7 +13,9 @@
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"image_url": "https://example.com/cat.png",
|
||||
"image_url": {
|
||||
"url": "https://example.com/cat.png"
|
||||
},
|
||||
"type": "image_url"
|
||||
}
|
||||
]
|
||||
|
||||
+1
@@ -8,6 +8,7 @@
|
||||
"type": "input_text"
|
||||
},
|
||||
{
|
||||
"detail": "high",
|
||||
"image_url": "https://example.com/cat.png",
|
||||
"type": "input_image"
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ var builtinTextConverters = []TextConverterSpec{
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: requestConverterClaudeToResponses,
|
||||
ID: ConverterClaudeMessagesToOpenAIResponses,
|
||||
From: types.RelayFormatClaude,
|
||||
To: types.RelayFormatOpenAIResponses,
|
||||
Quality: TextConverterQualityFair,
|
||||
@@ -199,7 +199,7 @@ var builtinTextConverters = []TextConverterSpec{
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: requestConverterGeminiToResponses,
|
||||
ID: ConverterGeminiContentToOpenAIResponses,
|
||||
From: types.RelayFormatGemini,
|
||||
To: types.RelayFormatOpenAIResponses,
|
||||
Quality: TextConverterQualityFair,
|
||||
|
||||
@@ -10,6 +10,8 @@ const (
|
||||
EndpointTypeOpenAIResponse EndpointType = "openai-response"
|
||||
EndpointTypeOpenAIResponseCompact EndpointType = "openai-response-compact"
|
||||
EndpointTypeOpenAIAlphaSearch EndpointType = "openai-alpha-search"
|
||||
EndpointTypeDeepSeekToolsSearch EndpointType = "deepseek-tools-search"
|
||||
EndpointTypeDeepSeekToolsFetch EndpointType = "deepseek-tools-fetch"
|
||||
EndpointTypeAnthropic EndpointType = "anthropic"
|
||||
EndpointTypeGemini EndpointType = "gemini"
|
||||
EndpointTypeJinaRerank EndpointType = "jina-rerank"
|
||||
|
||||
+13
-8
@@ -145,7 +145,9 @@ func (e *NewAPIError) ErrorWithStatusCode() string {
|
||||
return fmt.Sprintf("status_code=%d, %s", e.StatusCode, msg)
|
||||
}
|
||||
|
||||
func (e *NewAPIError) MaskSensitiveError() string {
|
||||
// MaskSensitiveError masks the upstream addresses of the channel that produced
|
||||
// the error in the error text; see kitutil.MaskSensitiveInfo.
|
||||
func (e *NewAPIError) MaskSensitiveError(upstreamURLs ...string) string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
@@ -156,14 +158,14 @@ func (e *NewAPIError) MaskSensitiveError() string {
|
||||
if e.errorCode == ErrorCodeCountTokenFailed {
|
||||
return errStr
|
||||
}
|
||||
return kitutil.MaskSensitiveInfo(errStr)
|
||||
return kitutil.MaskSensitiveInfo(errStr, upstreamURLs...)
|
||||
}
|
||||
|
||||
func (e *NewAPIError) MaskSensitiveErrorWithStatusCode() string {
|
||||
func (e *NewAPIError) MaskSensitiveErrorWithStatusCode(upstreamURLs ...string) string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
msg := e.MaskSensitiveError()
|
||||
msg := e.MaskSensitiveError(upstreamURLs...)
|
||||
if e.StatusCode == 0 {
|
||||
return msg
|
||||
}
|
||||
@@ -177,7 +179,9 @@ func (e *NewAPIError) SetMessage(message string) {
|
||||
e.Err = errors.New(message)
|
||||
}
|
||||
|
||||
func (e *NewAPIError) ToOpenAIError() OpenAIError {
|
||||
// ToOpenAIError masks the upstream addresses in the message; pass those of the
|
||||
// channel that produced the error, or none when no channel did.
|
||||
func (e *NewAPIError) ToOpenAIError(upstreamURLs ...string) OpenAIError {
|
||||
var result OpenAIError
|
||||
switch e.errorType {
|
||||
case ErrorTypeOpenAIError:
|
||||
@@ -202,7 +206,7 @@ func (e *NewAPIError) ToOpenAIError() OpenAIError {
|
||||
}
|
||||
}
|
||||
if e.errorCode != ErrorCodeCountTokenFailed {
|
||||
result.Message = kitutil.MaskSensitiveInfo(result.Message)
|
||||
result.Message = kitutil.MaskSensitiveInfo(result.Message, upstreamURLs...)
|
||||
}
|
||||
if result.Message == "" {
|
||||
result.Message = string(e.errorType)
|
||||
@@ -210,7 +214,8 @@ func (e *NewAPIError) ToOpenAIError() OpenAIError {
|
||||
return result
|
||||
}
|
||||
|
||||
func (e *NewAPIError) ToClaudeError() ClaudeError {
|
||||
// ToClaudeError masks the upstream addresses like ToOpenAIError.
|
||||
func (e *NewAPIError) ToClaudeError(upstreamURLs ...string) ClaudeError {
|
||||
var result ClaudeError
|
||||
switch e.errorType {
|
||||
case ErrorTypeOpenAIError:
|
||||
@@ -231,7 +236,7 @@ func (e *NewAPIError) ToClaudeError() ClaudeError {
|
||||
}
|
||||
}
|
||||
if e.errorCode != ErrorCodeCountTokenFailed {
|
||||
result.Message = kitutil.MaskSensitiveInfo(result.Message)
|
||||
result.Message = kitutil.MaskSensitiveInfo(result.Message, upstreamURLs...)
|
||||
}
|
||||
if result.Message == "" {
|
||||
result.Message = string(e.errorType)
|
||||
|
||||
@@ -9,6 +9,7 @@ const (
|
||||
RelayFormatOpenAIResponses = "openai_responses"
|
||||
RelayFormatOpenAIResponsesCompaction = "openai_responses_compaction"
|
||||
RelayFormatOpenAIAlphaSearch = "openai_alpha_search"
|
||||
RelayFormatDeepSeekTools = "deepseek_tools"
|
||||
RelayFormatOpenAIAudio = "openai_audio"
|
||||
RelayFormatOpenAIImage = "openai_image"
|
||||
RelayFormatOpenAIRealtime = "openai_realtime"
|
||||
|
||||
Reference in new issue
Block a user