形式的 data URL,
+// 返回 MIME 类型与 base64 载荷。
+func parseImageDataURL(dataURL string) (mimeType, data string, ok bool) {
+ if !strings.HasPrefix(dataURL, "data:") {
+ return "", "", false
+ }
+ commaIdx := strings.IndexByte(dataURL, ',')
+ if commaIdx < 0 {
+ return "", "", false
+ }
+ header := dataURL[5:commaIdx]
+ if !strings.HasSuffix(header, ";base64") {
+ return "", "", false
+ }
+ return strings.TrimSuffix(header, ";base64"), dataURL[commaIdx+1:], true
+}
+
// buildOllamaOptions 从 OpenAI 请求参数构建 Ollama options 映射。
// 映射关系:temperature→temperature、top_p→top_p、max_tokens/max_completion_tokens→num_predict、
// stop→stop、frequency_penalty→frequency_penalty、presence_penalty→presence_penalty。
diff --git a/relaykit/relayconvert/internal/shared/gemini_schema.go b/relaykit/relayconvert/internal/shared/gemini_schema.go
new file mode 100644
index 0000000..3e8cb1b
--- /dev/null
+++ b/relaykit/relayconvert/internal/shared/gemini_schema.go
@@ -0,0 +1,168 @@
+package shared
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/qianfree/team-api/relaykit/dto"
+)
+
+// geminiSchemaAllowedKeys Gemini Schema 支持的 JSON Schema 关键字白名单。
+// OpenAI 的 function.parameters 与 Claude 的 input_schema 同源,都可能携带
+// $schema、additionalProperties、$defs 等 Gemini 会拒绝的关键字,需按此过滤。
+var geminiSchemaAllowedKeys = map[string]struct{}{
+ "type": {}, "description": {}, "properties": {}, "required": {}, "items": {},
+ "anyOf": {}, "default": {}, "enum": {}, "format": {}, "maxLength": {},
+ "minLength": {}, "maximum": {}, "minimum": {}, "pattern": {}, "title": {},
+ "nullable": {}, "maxItems": {}, "minItems": {}, "maxProperties": {},
+ "minProperties": {}, "example": {},
+}
+
+// CleanGeminiToolParams 把 OpenAI function.parameters / Claude input_schema 归一化
+// 为 Gemini Schema 接受的形态,claude→gemini 与 openai→gemini 两条请求路径共用。
+//
+// 早期版本只做顶层白名单过滤、嵌套原样透传,但 Gemini 实际并不宽容——线上真实流量
+// (Claude Code 的 Grep 工具)携带 JSON Schema 2020-12 的 prefixItems 元组语法,
+// Gemini 丢弃该未知关键字后留下无 items 的裸数组,直接 400:
+//
+// GenerateContentRequest...properties[where].items.items: missing field
+//
+// 因此这里递归做四件事:
+// 1. 逐层白名单过滤($schema / $defs / additionalProperties 等 Gemini 拒绝的关键字)
+// 2. prefixItems 元组折叠为 items.anyOf(Gemini 无元组语义,取各位置 schema 的并集近似)
+// 3. 缺 type 的节点按内容推断兜底(properties→object / items→array / enum→string,
+// 其余→string)——Gemini 要求每个 Schema 节点有确定类型,空节点同样被拒
+// 4. type=array 却无 items 的节点补 items(Gemini 对数组元素 schema 有硬性要求)
+//
+// 非对象输入(含 nil)原样返回。
+func CleanGeminiToolParams(params any) any {
+ return cleanGeminiSchema(params)
+}
+
+func cleanGeminiSchema(node any) any {
+ m, ok := node.(map[string]any)
+ if !ok {
+ return node
+ }
+ out := make(map[string]any, len(m)+1)
+ for k, v := range m {
+ if _, allowed := geminiSchemaAllowedKeys[k]; !allowed {
+ continue
+ }
+ switch k {
+ case "properties":
+ if props, ok := v.(map[string]any); ok {
+ converted := make(map[string]any, len(props))
+ for pk, pv := range props {
+ converted[pk] = cleanGeminiSchema(pv)
+ }
+ out[k] = converted
+ } else {
+ out[k] = v
+ }
+ case "items":
+ out[k] = cleanGeminiSchema(v)
+ case "anyOf":
+ if arr, ok := v.([]any); ok {
+ converted := make([]any, len(arr))
+ for i, el := range arr {
+ converted[i] = cleanGeminiSchema(el)
+ }
+ out[k] = converted
+ } else {
+ out[k] = v
+ }
+ default:
+ out[k] = v
+ }
+ }
+
+ // prefixItems 元组 → items.anyOf 并集近似(prefixItems 不在白名单内,
+ // 上面的循环不会写出它;这里从原始节点读取后折叠,避免裸数组漏出去)
+ if arr, ok := m["prefixItems"].([]any); ok && len(arr) > 0 {
+ anyOf := make([]any, 0, len(arr)+1)
+ for _, el := range arr {
+ anyOf = append(anyOf, cleanGeminiSchema(el))
+ }
+ // 2020-12 语义:items 约束 prefixItems 之后的尾部元素,并入并集
+ if prev, ok := out["items"].(map[string]any); ok {
+ anyOf = append(anyOf, prev)
+ }
+ out["items"] = map[string]any{"anyOf": anyOf}
+ }
+
+ // type 兜底。携带 anyOf 的并集节点不加 type——那是 Gemini 自身的 union 形态
+ if _, hasType := out["type"]; !hasType {
+ if _, hasAnyOf := out["anyOf"]; !hasAnyOf {
+ switch {
+ case out["properties"] != nil:
+ out["type"] = "object"
+ case out["items"] != nil:
+ out["type"] = "array"
+ case out["enum"] != nil:
+ out["type"] = "string"
+ default:
+ out["type"] = "string"
+ }
+ }
+ }
+
+ // 数组必须携带元素 schema:Gemini 对裸数组直接 400(missing field),
+ // 无法推断元素类型时退化为 string(工具参数 schema 只是对模型的提示,
+ // 合法性优先于精确性)
+ if t, _ := out["type"].(string); strings.EqualFold(t, "array") {
+ if _, hasItems := out["items"]; !hasItems {
+ out["items"] = map[string]any{"type": "string"}
+ }
+ }
+ return out
+}
+
+// ToolResultToGeminiResponse 把工具输出归一化为 Gemini functionResponse.response
+// 可接受的值。Gemini 的该字段是 google.protobuf.Struct——**必须是 JSON 对象**:
+// 纯文本(shell 输出等)、数组、标量一律包成 {"result": ...},原样传字符串会被
+// protojson 直接 400(线上实测:codex 的 shell 工具输出 "Chunk ID: ...\nOutput:\n{...}"
+// 混合文本 → Invalid value at function_response.response (Struct))。
+// 对象原样透传;nil / 空串返回空对象。
+func ToolResultToGeminiResponse(content any) any {
+ switch v := content.(type) {
+ case nil:
+ return map[string]any{}
+ case map[string]any:
+ return v
+ case string:
+ if v == "" {
+ return map[string]any{}
+ }
+ var parsed any
+ if json.Unmarshal([]byte(v), &parsed) == nil {
+ if m, ok := parsed.(map[string]any); ok {
+ return m
+ }
+ }
+ return map[string]any{"result": v}
+ case []any:
+ var texts []string
+ for _, item := range v {
+ m, ok := item.(map[string]any)
+ if !ok || m["type"] != "text" {
+ continue
+ }
+ if text, ok := m["text"].(string); ok {
+ texts = append(texts, text)
+ }
+ }
+ return map[string]any{"result": strings.Join(texts, "\n")}
+ case []dto.ClaudeContentBlock:
+ var texts []string
+ for i := range v {
+ if v[i].Text != nil {
+ texts = append(texts, *v[i].Text)
+ }
+ }
+ return map[string]any{"result": strings.Join(texts, "\n")}
+ default:
+ return map[string]any{"result": fmt.Sprintf("%v", v)}
+ }
+}
diff --git a/relaykit/relayconvert/internal/shared/gemini_schema_test.go b/relaykit/relayconvert/internal/shared/gemini_schema_test.go
new file mode 100644
index 0000000..1bb1c88
--- /dev/null
+++ b/relaykit/relayconvert/internal/shared/gemini_schema_test.go
@@ -0,0 +1,184 @@
+package shared
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+// assertGeminiSchemaValid 递归校验输出满足 Gemini 的硬性约束:
+// - 每个节点都有 type,或是 anyOf 并集节点
+// - type=array 的节点必须带 items
+// - 不含白名单外的关键字(如 prefixItems / $schema / additionalProperties)
+func assertGeminiSchemaValid(t *testing.T, node any, path string) {
+ t.Helper()
+ m, ok := node.(map[string]any)
+ if !ok {
+ t.Fatalf("%s: 应为对象节点,实际 %T", path, node)
+ }
+ _, hasType := m["type"]
+ _, hasAnyOf := m["anyOf"]
+ if !hasType && !hasAnyOf {
+ t.Errorf("%s: 节点缺 type(Gemini 拒绝空/无类型 Schema): %v", path, m)
+ }
+ if hasType {
+ if typ, _ := m["type"].(string); strings.EqualFold(typ, "array") {
+ if _, hasItems := m["items"]; !hasItems {
+ t.Errorf("%s: 数组节点缺 items(Gemini 报 missing field)", path)
+ }
+ }
+ }
+ for k, v := range m {
+ if _, allowed := geminiSchemaAllowedKeys[k]; !allowed {
+ t.Errorf("%s: 含白名单外关键字 %q", path, k)
+ }
+ switch k {
+ case "properties":
+ for pk, pv := range v.(map[string]any) {
+ assertGeminiSchemaValid(t, pv, path+".properties."+pk)
+ }
+ case "items":
+ assertGeminiSchemaValid(t, v, path+".items")
+ case "anyOf":
+ for i, el := range v.([]any) {
+ assertGeminiSchemaValid(t, el, path+".anyOf["+string(rune('0'+i))+"]")
+ }
+ }
+ }
+}
+
+// TestCleanGeminiToolParams_PrefixItemsTuple 线上真实形状(2026-09 采集的 Claude Code
+// Grep 工具 where 参数):JSON Schema 2020-12 的 prefixItems 元组 + 空元素节点。
+// 曾导致 Gemini 400:GenerateContentRequest...properties[where].items.items: missing field
+// (Gemini 丢弃 prefixItems 后剩下无 items 的裸数组)。
+func TestCleanGeminiToolParams_PrefixItemsTuple(t *testing.T) {
+ // 取自语料 real_inputs 的真实 schema(工具名与枚举值已脱敏,结构原样)
+ raw := `{
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "object",
+ "properties": {
+ "where": {
+ "type": "array",
+ "maxItems": 10,
+ "items": {
+ "type": "array",
+ "prefixItems": [
+ {"type": "string"},
+ {"enum": ["AND", "OR"], "type": "string"},
+ {}
+ ]
+ }
+ },
+ "path": {"type": "array", "items": {"type": "string"}},
+ "pattern": {"type": "string"}
+ }
+ }
+ }
+}`
+ var in map[string]any
+ if err := json.Unmarshal([]byte(raw), &in); err != nil {
+ t.Fatal(err)
+ }
+
+ out := CleanGeminiToolParams(in).(map[string]any)
+ assertGeminiSchemaValid(t, out, "schema")
+
+ if s := JSONString(t, out); strings.Contains(s, "prefixItems") {
+ t.Errorf("prefixItems 应被折叠,不应出现在输出: %s", s)
+ }
+ // where.items 应变为 {type:array, items:{anyOf:[...]}},三元组成员各带 type
+ where := out["properties"].(map[string]any)["query"].(map[string]any)["properties"].(map[string]any)["where"].(map[string]any)
+ items := where["items"].(map[string]any)
+ folded, ok := items["items"].(map[string]any)
+ if !ok {
+ t.Fatalf("where.items.items 应为折叠后的 anyOf 容器: %s", JSONString(t, items))
+ }
+ anyOf, ok := folded["anyOf"].([]any)
+ if !ok || len(anyOf) != 3 {
+ t.Fatalf("where.items.items.anyOf 应含 3 个成员(含空节点兜底): %s", JSONString(t, items))
+ }
+}
+
+// TestCleanGeminiToolParams_NestedWhitelist 白名单过滤必须递归到嵌套层级——
+// 早期版本只过滤顶层,嵌套的 $defs / additionalProperties 原样透传。
+func TestCleanGeminiToolParams_NestedWhitelist(t *testing.T) {
+ raw := `{
+ "type": "object",
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "loc": {
+ "type": "object",
+ "properties": {"city": {"type": "string", "$defs": {}, "additionalProperties": false}},
+ "additionalProperties": false
+ }
+ }
+}`
+ var in map[string]any
+ if err := json.Unmarshal([]byte(raw), &in); err != nil {
+ t.Fatal(err)
+ }
+ out := CleanGeminiToolParams(in).(map[string]any)
+ assertGeminiSchemaValid(t, out, "schema")
+ if s := JSONString(t, out); strings.Contains(s, "$schema") || strings.Contains(s, "additionalProperties") || strings.Contains(s, "$defs") {
+ t.Errorf("嵌套层级的非白名单关键字未被过滤: %s", s)
+ }
+}
+
+// TestCleanGeminiToolParams_BareArrayAndTypeFallback 裸数组补 items、
+// 无 type 节点按内容推断兜底。
+func TestCleanGeminiToolParams_BareArrayAndTypeFallback(t *testing.T) {
+ raw := `{
+ "type": "object",
+ "properties": {
+ "tags": {"type": "array", "maxItems": 10},
+ "note": {"description": "无类型字段"},
+ "meta": {"properties": {"k": {"enum": ["a", "b"]}}},
+ "empty": {}
+ }
+}`
+ var in map[string]any
+ if err := json.Unmarshal([]byte(raw), &in); err != nil {
+ t.Fatal(err)
+ }
+ out := CleanGeminiToolParams(in).(map[string]any)
+ assertGeminiSchemaValid(t, out, "schema")
+
+ props := out["properties"].(map[string]any)
+ if got := JSONString(t, props["tags"]); !strings.Contains(got, `"items"`) {
+ t.Errorf("裸数组应补 items: %s", got)
+ }
+ if got := props["note"].(map[string]any)["type"]; got != "string" {
+ t.Errorf("无类型节点应兜底 string,实际 %v", got)
+ }
+ if got := props["meta"].(map[string]any)["type"]; got != "object" {
+ t.Errorf("含 properties 的节点应兜底 object,实际 %v", got)
+ }
+ if got := props["meta"].(map[string]any)["properties"].(map[string]any)["k"].(map[string]any)["type"]; got != "string" {
+ t.Errorf("含 enum 的节点应兜底 string,实际 %v", got)
+ }
+ if got := props["empty"].(map[string]any)["type"]; got != "string" {
+ t.Errorf("空节点应兜底 string,实际 %v", got)
+ }
+}
+
+// TestCleanGeminiToolParams_NonMapPassthrough 非对象输入(nil / 字符串等)原样返回。
+func TestCleanGeminiToolParams_NonMapPassthrough(t *testing.T) {
+ if got := CleanGeminiToolParams(nil); got != nil {
+ t.Errorf("nil 应原样返回,实际 %v", got)
+ }
+ s := "not-a-schema"
+ if got := CleanGeminiToolParams(s); got != s {
+ t.Errorf("字符串应原样返回,实际 %v", got)
+ }
+}
+
+func JSONString(t *testing.T, v any) string {
+ t.Helper()
+ raw, err := json.Marshal(v)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(raw)
+}
diff --git a/relaykit/relayconvert/internal/shared/grounding.go b/relaykit/relayconvert/internal/shared/grounding.go
new file mode 100644
index 0000000..2e6b179
--- /dev/null
+++ b/relaykit/relayconvert/internal/shared/grounding.go
@@ -0,0 +1,302 @@
+package shared
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/qianfree/team-api/relaykit/dto"
+)
+
+// 服务端联网搜索的**响应侧**证据(引用来源)跨协议中间表示。
+//
+// 请求侧的能力映射见 websearch.go:客户端要求联网 → 各格式的原生搜索构件。
+// 但上游真的去搜了之后,「搜到了什么」同样需要还原回客户端协议,否则客户端只拿到
+// 一段被搜索增强的纯文本——**看不到来源、无法核查、也无法展示引用**,而这正是
+// 联网搜索的主要价值。各格式的表达方式:
+//
+// Gemini candidate.groundingMetadata:groundingChunks(来源)+
+// groundingSupports(文本片段→来源索引)+ webSearchQueries(实际查询)
+// Claude content 块对:server_tool_use(发起搜索)+ web_search_tool_result(结果列表)
+// 另有 usage.server_tool_use.web_search_requests 供按次计费
+// OpenAI message.annotations:[{type:"url_citation", url_citation:{url,title,start_index,end_index}}]
+// Responses output_text.annotations:[{type:"url_citation", url, title, start_index, end_index}]
+//
+// 本文件只做协议还原;按次计费的定价挂接属宿主计费层,此处仅把
+// web_search_requests 计数透出到 usage,使计费层有据可依。
+
+// GroundingSource 一条被引用的网页来源。
+type GroundingSource struct {
+ URL string
+ Title string
+}
+
+// GroundingSupport 回答文本的一个片段与其来源的对应关系。
+// StartIndex / EndIndex 是**字节**偏移(与 Gemini 一致,Go 字符串切片同为字节口径)。
+type GroundingSupport struct {
+ StartIndex int
+ EndIndex int
+ Text string // 片段原文,用于校验偏移是否仍然对齐
+ SourceIndices []int // 指向 GroundingCitations.Sources
+}
+
+// GroundingCitations 一次带搜索的回答所附带的全部引用证据。
+type GroundingCitations struct {
+ Queries []string
+ Sources []GroundingSource
+ Supports []GroundingSupport
+}
+
+// IsEmpty 无来源即视为没有可还原的引用证据(只有 queries 而无来源时,
+// 客户端拿不到任何可核查的东西,不值得合成空的工具结果块)。
+func (c *GroundingCitations) IsEmpty() bool {
+ return c == nil || len(c.Sources) == 0
+}
+
+// SearchRequestCount 本次回答实际发起的搜索次数,供计费层按次计价。
+// Gemini 以 webSearchQueries 列出实际查询;缺失但有来源时按 1 次计。
+func (c *GroundingCitations) SearchRequestCount() int {
+ if c.IsEmpty() {
+ return 0
+ }
+ if n := len(c.Queries); n > 0 {
+ return n
+ }
+ return 1
+}
+
+// queryText 把多个查询合并为单个 query 字符串。
+// Claude 的 server_tool_use.input 是单个 query,而 Gemini 把多次查询的结果**汇集**在
+// 一组 groundingChunks 里、无法回溯每条来源属于哪次查询,故合并表达而非拆成多对
+// (拆开会导致来源在各对之间重复,虚增引用数)。真实次数由 SearchRequestCount 承载。
+func (c *GroundingCitations) queryText() string {
+ if len(c.Queries) == 0 {
+ return ""
+ }
+ return strings.Join(c.Queries, ", ")
+}
+
+// ---------- 解析:Gemini groundingMetadata ----------
+
+// geminiGroundingMetadata 是 groundingMetadata 的解析形态。
+// DTO 侧保持 `any` 以保证原生透传方向的 wire 保真(未知字段不丢),
+// 这里按需重解析出所需字段。
+type geminiGroundingMetadata struct {
+ WebSearchQueries []string `json:"webSearchQueries"`
+ GroundingChunks []struct {
+ Web *struct {
+ URI string `json:"uri"`
+ Title string `json:"title"`
+ } `json:"web"`
+ } `json:"groundingChunks"`
+ GroundingSupports []struct {
+ Segment *struct {
+ StartIndex int `json:"startIndex"`
+ EndIndex int `json:"endIndex"`
+ Text string `json:"text"`
+ } `json:"segment"`
+ GroundingChunkIndices []int `json:"groundingChunkIndices"`
+ } `json:"groundingSupports"`
+}
+
+// ParseGeminiGrounding 从 candidate.groundingMetadata 提取引用证据。
+// 无 grounding、解析失败、或无任何网页来源时返回 nil(调用方据此不做还原)。
+func ParseGeminiGrounding(meta any) *GroundingCitations {
+ if meta == nil {
+ return nil
+ }
+ raw, err := json.Marshal(meta)
+ if err != nil {
+ return nil
+ }
+ var parsed geminiGroundingMetadata
+ if err := json.Unmarshal(raw, &parsed); err != nil {
+ return nil
+ }
+
+ citations := &GroundingCitations{Queries: parsed.WebSearchQueries}
+ // chunk 下标 → Sources 下标的重映射:非 web 类型的 chunk(如检索库片段)
+ // 不产生来源,会让两侧下标错位,必须显式重映射而非直接沿用原下标
+ chunkToSource := make(map[int]int, len(parsed.GroundingChunks))
+ for i, chunk := range parsed.GroundingChunks {
+ if chunk.Web == nil || chunk.Web.URI == "" {
+ continue
+ }
+ chunkToSource[i] = len(citations.Sources)
+ citations.Sources = append(citations.Sources, GroundingSource{
+ URL: chunk.Web.URI,
+ Title: chunk.Web.Title,
+ })
+ }
+ if len(citations.Sources) == 0 {
+ return nil
+ }
+
+ for _, support := range parsed.GroundingSupports {
+ if support.Segment == nil {
+ continue
+ }
+ indices := make([]int, 0, len(support.GroundingChunkIndices))
+ for _, ci := range support.GroundingChunkIndices {
+ if si, ok := chunkToSource[ci]; ok {
+ indices = append(indices, si)
+ }
+ }
+ if len(indices) == 0 {
+ continue
+ }
+ citations.Supports = append(citations.Supports, GroundingSupport{
+ StartIndex: support.Segment.StartIndex,
+ EndIndex: support.Segment.EndIndex,
+ Text: support.Segment.Text,
+ SourceIndices: indices,
+ })
+ }
+ return citations
+}
+
+// AlignSupports 校正片段偏移,使其对齐**转换后**的回答文本。
+//
+// 为什么必须校正:Gemini 的偏移是相对上游原始候选文本的,而转换后的正文可能与之不同
+// (思考 part 被剥离、多个 text part 被拼接、前缀被裁剪)。偏移错位会让客户端把引用
+// 高亮在错误的位置上——比没有引用更糟。
+//
+// 策略:segment.text 给出了片段原文,据此校验;对不上就在正文里重新定位;
+// 仍找不到则丢弃该片段的偏移(保留来源本身,只是不带位置)。
+func (c *GroundingCitations) AlignSupports(text string) {
+ if c == nil || len(c.Supports) == 0 {
+ return
+ }
+ aligned := make([]GroundingSupport, 0, len(c.Supports))
+ for _, s := range c.Supports {
+ if s.Text == "" {
+ continue // 无原文可校验,不敢保留偏移
+ }
+ // 原偏移仍然对齐:直接沿用
+ if s.StartIndex >= 0 && s.EndIndex <= len(text) && s.StartIndex < s.EndIndex &&
+ text[s.StartIndex:s.EndIndex] == s.Text {
+ aligned = append(aligned, s)
+ continue
+ }
+ // 否则在正文中重新定位
+ if idx := strings.Index(text, s.Text); idx >= 0 {
+ s.StartIndex = idx
+ s.EndIndex = idx + len(s.Text)
+ aligned = append(aligned, s)
+ }
+ // 找不到:该片段不带偏移地丢弃,来源本身仍在 Sources 里
+ }
+ c.Supports = aligned
+}
+
+// ---------- 产出:Claude ----------
+
+// ToClaudeSearchBlocks 产出 Claude 的 server_tool_use + web_search_tool_result 块对。
+// Gemini 不提供 Anthropic 的 encrypted_content / page_age,仅还原 url + title——
+// 能力(来源可见、可核查)保住,细粒度字段静默降级。
+func (c *GroundingCitations) ToClaudeSearchBlocks(idSeed string) []dto.ClaudeContentBlock {
+ if c.IsEmpty() {
+ return nil
+ }
+ toolUseID := fmt.Sprintf("srvtoolu_%s", idSeed)
+
+ results := make([]map[string]any, 0, len(c.Sources))
+ for _, src := range c.Sources {
+ results = append(results, map[string]any{
+ "type": "web_search_result",
+ "url": src.URL,
+ "title": src.Title,
+ })
+ }
+
+ return []dto.ClaudeContentBlock{
+ {
+ Type: "server_tool_use",
+ ID: toolUseID,
+ Name: "web_search",
+ Input: map[string]any{"query": c.queryText()},
+ },
+ {
+ Type: "web_search_tool_result",
+ ToolUseID: toolUseID,
+ Content: results,
+ },
+ }
+}
+
+// ---------- 产出:OpenAI chat / Responses 的 url_citation ----------
+
+// ToOpenAIAnnotations 产出 chat 的 annotations(url_citation 形态)。
+// 有对齐后的片段时按「片段 × 来源」逐条产出并带偏移,否则每个来源产出一条无偏移的引用。
+func (c *GroundingCitations) ToOpenAIAnnotations() []any {
+ if c.IsEmpty() {
+ return nil
+ }
+ out := make([]any, 0, len(c.Sources))
+ emit := func(src GroundingSource, start, end int, withRange bool) {
+ citation := map[string]any{"url": src.URL, "title": src.Title}
+ if withRange {
+ citation["start_index"] = start
+ citation["end_index"] = end
+ }
+ out = append(out, map[string]any{"type": "url_citation", "url_citation": citation})
+ }
+
+ if len(c.Supports) > 0 {
+ for _, s := range c.Supports {
+ for _, si := range s.SourceIndices {
+ if si < len(c.Sources) {
+ emit(c.Sources[si], s.StartIndex, s.EndIndex, true)
+ }
+ }
+ }
+ return out
+ }
+ for _, src := range c.Sources {
+ emit(src, 0, 0, false)
+ }
+ return out
+}
+
+// ToResponsesAnnotations 产出 Responses 的 output_text.annotations(url_citation 形态)。
+func (c *GroundingCitations) ToResponsesAnnotations() []dto.ResponsesAnnotation {
+ if c.IsEmpty() {
+ return nil
+ }
+ out := make([]dto.ResponsesAnnotation, 0, len(c.Sources))
+ if len(c.Supports) > 0 {
+ for _, s := range c.Supports {
+ for _, si := range s.SourceIndices {
+ if si >= len(c.Sources) {
+ continue
+ }
+ out = append(out, dto.ResponsesAnnotation{
+ Type: "url_citation",
+ URL: c.Sources[si].URL,
+ Title: c.Sources[si].Title,
+ StartIndex: s.StartIndex,
+ EndIndex: s.EndIndex,
+ })
+ }
+ }
+ return out
+ }
+ for _, src := range c.Sources {
+ out = append(out, dto.ResponsesAnnotation{Type: "url_citation", URL: src.URL, Title: src.Title})
+ }
+ return out
+}
+
+// ToResponsesWebSearchCallItem 产出 Responses 的 web_search_call 输出项,
+// 让客户端看到「模型发起过搜索」这一事实(与 annotations 互补:前者是动作,后者是引用)。
+func (c *GroundingCitations) ToResponsesWebSearchCallItem(idSeed string) map[string]any {
+ if c.IsEmpty() {
+ return nil
+ }
+ return map[string]any{
+ "type": "web_search_call",
+ "id": fmt.Sprintf("ws_%s", idSeed),
+ "status": "completed",
+ "action": map[string]any{"type": "search", "query": c.queryText()},
+ }
+}
diff --git a/relaykit/relayconvert/internal/shared/grounding_test.go b/relaykit/relayconvert/internal/shared/grounding_test.go
new file mode 100644
index 0000000..c57bbc8
--- /dev/null
+++ b/relaykit/relayconvert/internal/shared/grounding_test.go
@@ -0,0 +1,257 @@
+package shared
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+// geminiGroundingFixture 一份贴合真实形态的 groundingMetadata(含两个来源、
+// 一个覆盖片段、两次查询,另含一个非 web 类型的 chunk 用于验证下标重映射)。
+const geminiGroundingFixture = `{
+ "searchEntryPoint": {"renderedContent": "Google Search Suggestions
"},
+ "webSearchQueries": ["weather boston", "boston forecast"],
+ "groundingChunks": [
+ {"web": {"uri": "https://weather.example/boston", "title": "weather.example"}},
+ {"retrievedContext": {"title": "内部文档"}},
+ {"web": {"uri": "https://forecast.example/ma", "title": "forecast.example"}}
+ ],
+ "groundingSupports": [
+ {
+ "segment": {"startIndex": 0, "endIndex": 11, "text": "It is 68F in"},
+ "groundingChunkIndices": [0, 2]
+ }
+ ]
+}`
+
+func parseFixture(t *testing.T, raw string) any {
+ t.Helper()
+ var v any
+ if err := json.Unmarshal([]byte(raw), &v); err != nil {
+ t.Fatalf("语料不是合法 JSON: %v", err)
+ }
+ return v
+}
+
+func TestParseGeminiGrounding(t *testing.T) {
+ c := ParseGeminiGrounding(parseFixture(t, geminiGroundingFixture))
+ if c.IsEmpty() {
+ t.Fatal("应解析出引用证据")
+ }
+ if len(c.Sources) != 2 {
+ t.Fatalf("Sources 数 = %d, want 2(非 web 类型 chunk 不产生来源): %+v", len(c.Sources), c.Sources)
+ }
+ if c.Sources[0].URL != "https://weather.example/boston" || c.Sources[1].URL != "https://forecast.example/ma" {
+ t.Errorf("来源顺序/内容不符: %+v", c.Sources)
+ }
+ if len(c.Queries) != 2 {
+ t.Errorf("Queries = %v, want 2 条", c.Queries)
+ }
+
+ // 关键:chunk 下标 [0,2] 必须重映射为 Sources 下标 [0,1]。
+ // 直接沿用原下标会让第二条引用指向不存在的 Sources[2](越界或错位到别的来源)。
+ if len(c.Supports) != 1 {
+ t.Fatalf("Supports 数 = %d, want 1", len(c.Supports))
+ }
+ got := c.Supports[0].SourceIndices
+ if len(got) != 2 || got[0] != 0 || got[1] != 1 {
+ t.Errorf("chunk 下标未正确重映射: got %v, want [0 1]", got)
+ }
+}
+
+func TestParseGeminiGrounding_NoSources(t *testing.T) {
+ cases := map[string]string{
+ "nil": "",
+ "空对象": `{}`,
+ "只有查询无来源": `{"webSearchQueries":["q"]}`,
+ "chunk 全为非 web": `{"groundingChunks":[{"retrievedContext":{"title":"x"}}]}`,
+ "web 缺 uri": `{"groundingChunks":[{"web":{"title":"x"}}]}`,
+ }
+ for name, raw := range cases {
+ t.Run(name, func(t *testing.T) {
+ var meta any
+ if raw != "" {
+ meta = parseFixture(t, raw)
+ }
+ if c := ParseGeminiGrounding(meta); !c.IsEmpty() {
+ t.Errorf("无可核查来源时应视为空, got %+v", c)
+ }
+ })
+ }
+}
+
+func TestSearchRequestCount(t *testing.T) {
+ c := ParseGeminiGrounding(parseFixture(t, geminiGroundingFixture))
+ if got := c.SearchRequestCount(); got != 2 {
+ t.Errorf("SearchRequestCount = %d, want 2(按 webSearchQueries 条数)", got)
+ }
+ // 有来源但无 queries:按 1 次计,不能算 0(否则按次计费漏记)
+ noQueries := ParseGeminiGrounding(parseFixture(t, `{"groundingChunks":[{"web":{"uri":"https://a.example"}}]}`))
+ if got := noQueries.SearchRequestCount(); got != 1 {
+ t.Errorf("无 queries 但有来源时 = %d, want 1", got)
+ }
+ var empty *GroundingCitations
+ if got := empty.SearchRequestCount(); got != 0 {
+ t.Errorf("空证据 = %d, want 0", got)
+ }
+}
+
+// TestAlignSupports 片段偏移校正:Gemini 的偏移相对上游原始文本,
+// 转换后的正文可能不同(剥离思考 part、拼接多个 text part),
+// 错位的偏移会让客户端把引用高亮在错误位置——比没有引用更糟。
+func TestAlignSupports(t *testing.T) {
+ t.Run("偏移已对齐则沿用", func(t *testing.T) {
+ c := &GroundingCitations{
+ Sources: []GroundingSource{{URL: "https://a.example"}},
+ Supports: []GroundingSupport{{StartIndex: 0, EndIndex: 5, Text: "Hello", SourceIndices: []int{0}}},
+ }
+ c.AlignSupports("Hello world")
+ if len(c.Supports) != 1 || c.Supports[0].StartIndex != 0 || c.Supports[0].EndIndex != 5 {
+ t.Errorf("对齐的偏移不应被改动: %+v", c.Supports)
+ }
+ })
+
+ t.Run("偏移错位则按原文重新定位", func(t *testing.T) {
+ c := &GroundingCitations{
+ Sources: []GroundingSource{{URL: "https://a.example"}},
+ // 上游偏移 0..5,但转换后正文前面多了前缀
+ Supports: []GroundingSupport{{StartIndex: 0, EndIndex: 5, Text: "Hello", SourceIndices: []int{0}}},
+ }
+ c.AlignSupports("prefix: Hello world")
+ if len(c.Supports) != 1 {
+ t.Fatalf("应保留并校正该片段: %+v", c.Supports)
+ }
+ if c.Supports[0].StartIndex != 8 || c.Supports[0].EndIndex != 13 {
+ t.Errorf("重新定位错误: start=%d end=%d, want 8/13", c.Supports[0].StartIndex, c.Supports[0].EndIndex)
+ }
+ })
+
+ t.Run("正文中找不到则丢弃偏移但保留来源", func(t *testing.T) {
+ c := &GroundingCitations{
+ Sources: []GroundingSource{{URL: "https://a.example"}},
+ Supports: []GroundingSupport{{StartIndex: 0, EndIndex: 5, Text: "Hello", SourceIndices: []int{0}}},
+ }
+ c.AlignSupports("完全不同的正文")
+ if len(c.Supports) != 0 {
+ t.Errorf("对不上的片段应丢弃偏移: %+v", c.Supports)
+ }
+ if len(c.Sources) != 1 {
+ t.Error("来源本身必须保留(客户端仍应看到引用了哪些网页)")
+ }
+ })
+
+ t.Run("越界偏移不得 panic", func(t *testing.T) {
+ c := &GroundingCitations{
+ Sources: []GroundingSource{{URL: "https://a.example"}},
+ Supports: []GroundingSupport{{StartIndex: 100, EndIndex: 999, Text: "x", SourceIndices: []int{0}}},
+ }
+ c.AlignSupports("短")
+ })
+
+ t.Run("多字节字符按字节偏移", func(t *testing.T) {
+ c := &GroundingCitations{
+ Sources: []GroundingSource{{URL: "https://a.example"}},
+ Supports: []GroundingSupport{{Text: "波士顿", SourceIndices: []int{0}}},
+ }
+ answer := "今天波士顿很冷"
+ c.AlignSupports(answer)
+ if len(c.Supports) != 1 {
+ t.Fatal("应定位到多字节片段")
+ }
+ s := c.Supports[0]
+ if answer[s.StartIndex:s.EndIndex] != "波士顿" {
+ t.Errorf("多字节偏移错误: %q", answer[s.StartIndex:s.EndIndex])
+ }
+ })
+}
+
+func TestToClaudeSearchBlocks(t *testing.T) {
+ c := ParseGeminiGrounding(parseFixture(t, geminiGroundingFixture))
+ blocks := c.ToClaudeSearchBlocks("seed1")
+ if len(blocks) != 2 {
+ t.Fatalf("应产出 server_tool_use + web_search_tool_result 两块: %+v", blocks)
+ }
+ if blocks[0].Type != "server_tool_use" || blocks[0].Name != "web_search" {
+ t.Errorf("首块形态不符: %+v", blocks[0])
+ }
+ if blocks[1].Type != "web_search_tool_result" {
+ t.Errorf("次块形态不符: %+v", blocks[1])
+ }
+ // 两块必须以同一个 id 关联,否则 Anthropic SDK 无法把结果挂到调用上
+ if blocks[1].ToolUseID != blocks[0].ID {
+ t.Errorf("tool_use_id (%s) 与 server_tool_use.id (%s) 不一致", blocks[1].ToolUseID, blocks[0].ID)
+ }
+ results, ok := blocks[1].Content.([]map[string]any)
+ if !ok || len(results) != 2 {
+ t.Fatalf("结果列表应含 2 条来源: %+v", blocks[1].Content)
+ }
+ if results[0]["type"] != "web_search_result" || results[0]["url"] != "https://weather.example/boston" {
+ t.Errorf("结果项形态不符: %+v", results[0])
+ }
+
+ var empty *GroundingCitations
+ if empty.ToClaudeSearchBlocks("s") != nil {
+ t.Error("空证据不应产出块")
+ }
+}
+
+func TestToOpenAIAnnotations(t *testing.T) {
+ c := ParseGeminiGrounding(parseFixture(t, geminiGroundingFixture))
+ c.AlignSupports("It is 68F in Boston today")
+
+ anns := c.ToOpenAIAnnotations()
+ if len(anns) != 2 {
+ t.Fatalf("一个片段引用两个来源应产出 2 条 annotation: %+v", anns)
+ }
+ first := anns[0].(map[string]any)
+ if first["type"] != "url_citation" {
+ t.Errorf("type = %v, want url_citation", first["type"])
+ }
+ citation := first["url_citation"].(map[string]any)
+ if citation["url"] != "https://weather.example/boston" {
+ t.Errorf("url 不符: %+v", citation)
+ }
+ if _, ok := citation["start_index"]; !ok {
+ t.Error("对齐后的片段应带偏移")
+ }
+
+ // 无片段时每个来源一条、不带偏移(宁可无位置,也不给错误位置)
+ noSupport := &GroundingCitations{Sources: []GroundingSource{{URL: "https://a.example"}}}
+ anns = noSupport.ToOpenAIAnnotations()
+ if len(anns) != 1 {
+ t.Fatalf("应按来源产出: %+v", anns)
+ }
+ citation = anns[0].(map[string]any)["url_citation"].(map[string]any)
+ if _, ok := citation["start_index"]; ok {
+ t.Error("无可信片段时不得附带偏移")
+ }
+}
+
+func TestToResponsesAnnotationsAndSearchCall(t *testing.T) {
+ c := ParseGeminiGrounding(parseFixture(t, geminiGroundingFixture))
+ c.AlignSupports("It is 68F in Boston today")
+
+ anns := c.ToResponsesAnnotations()
+ if len(anns) != 2 {
+ t.Fatalf("annotations 数 = %d, want 2: %+v", len(anns), anns)
+ }
+ if anns[0].Type != "url_citation" || anns[0].URL != "https://weather.example/boston" {
+ t.Errorf("annotation 形态不符: %+v", anns[0])
+ }
+
+ item := c.ToResponsesWebSearchCallItem("seed1")
+ if item == nil {
+ t.Fatal("应产出 web_search_call 动作项")
+ }
+ if item["type"] != "web_search_call" || item["status"] != "completed" {
+ t.Errorf("动作项形态不符: %+v", item)
+ }
+ action := item["action"].(map[string]any)
+ if action["query"] != "weather boston, boston forecast" {
+ t.Errorf("query 合并结果 = %v", action["query"])
+ }
+
+ var empty *GroundingCitations
+ if empty.ToResponsesWebSearchCallItem("s") != nil {
+ t.Error("空证据不应产出动作项")
+ }
+}
diff --git a/relaykit/relayconvert/internal/shared/message_mapper.go b/relaykit/relayconvert/internal/shared/message_mapper.go
index 24e3356..5577b8a 100644
--- a/relaykit/relayconvert/internal/shared/message_mapper.go
+++ b/relaykit/relayconvert/internal/shared/message_mapper.go
@@ -3,9 +3,39 @@
package shared
import (
+ "encoding/json"
+
"github.com/qianfree/team-api/relaykit/dto"
)
+// NormalizeContentParts 将 OpenAI 消息 content 的各种真实形态统一为类型化 ContentPart 列表。
+//
+// 线上存在两种多模态形态:宿主对入站请求裸 json.Unmarshal 产生的 []any(元素为
+// map[string]any),以及链式转换(如 Responses→OpenAI→Claude)中上游转换器构造的
+// []dto.ContentPart。任一形态缺失处理都会导致多模态内容(文本/图片/音频)静默丢失,
+// 由 register 包的能力守恒测试守护。ok=false 表示 content 不是部件列表(如纯字符串)。
+func NormalizeContentParts(content any) ([]dto.ContentPart, bool) {
+ switch v := content.(type) {
+ case []dto.ContentPart:
+ return v, true
+ case []any:
+ parts := make([]dto.ContentPart, 0, len(v))
+ for _, item := range v {
+ switch it := item.(type) {
+ case dto.ContentPart:
+ parts = append(parts, it)
+ case map[string]any:
+ var p dto.ContentPart
+ if data, err := json.Marshal(it); err == nil && json.Unmarshal(data, &p) == nil && p.Type != "" {
+ parts = append(parts, p)
+ }
+ }
+ }
+ return parts, true
+ }
+ return nil, false
+}
+
// MapTextContent 从多种内容格式中抽取纯文本内容。
// 未找到文本时返回空字符串。
func MapTextContent(content any) string {
@@ -20,8 +50,18 @@ func MapTextContent(content any) string {
}
case []any:
for _, item := range v {
- if part, ok := item.(dto.ContentPart); ok && part.Type == "text" {
- return part.Text
+ switch part := item.(type) {
+ case dto.ContentPart:
+ if part.Type == "text" {
+ return part.Text
+ }
+ case map[string]any:
+ // 宿主裸 unmarshal 的 wire 形态
+ if t, _ := part["type"].(string); t == "text" {
+ if text, ok := part["text"].(string); ok {
+ return text
+ }
+ }
}
}
}
diff --git a/relaykit/relayconvert/internal/shared/reasoning_responses.go b/relaykit/relayconvert/internal/shared/reasoning_responses.go
new file mode 100644
index 0000000..6924d8a
--- /dev/null
+++ b/relaykit/relayconvert/internal/shared/reasoning_responses.go
@@ -0,0 +1,78 @@
+package shared
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/qianfree/team-api/relaykit/dto"
+)
+
+// Responses 协议的「思考内容」跨协议映射。
+//
+// 四种协议表达模型思考过程的方式各不相同:
+//
+// OpenAI chat message.reasoning_content(字符串)
+// Claude content 块 {type: "thinking", thinking: "...", signature: "..."}
+// Gemini part {text: "...", thought: true}
+// Responses 输出项 {type: "reasoning", id: "rs_x", summary: [{type: "summary_text", text: "..."}]}
+//
+// 非流式的 Responses 方向此前一律跳过思考内容,理由注释写的是「Responses 非流式
+// 无对应物」——但这是误判:reasoning 输出项本身就是非流式形态(流式侧的
+// response.reasoning_summary_text.delta 只是它的增量表达)。于是推理模型经这些方向
+// 转换后,客户端完全看不到思考过程,且**看不出丢过东西**。
+
+// responsesReasoningItemType Responses 输出项中思考项的 type 值。
+const responsesReasoningItemType = "reasoning"
+
+// responsesSummaryTextType reasoning.summary 数组元素的 type 值。
+const responsesSummaryTextType = "summary_text"
+
+// BuildResponsesReasoningOutput 把一段思考文本构造为 Responses 的 reasoning 输出项。
+// thinking 为空时返回 nil(调用方据此不追加输出项,避免产出空 reasoning 壳)。
+//
+// idSeed 用于生成稳定的项 id:同一响应内多次调用应传入同一 seed,
+// 使 id 可复现(金样本比对依赖此性质)。
+func BuildResponsesReasoningOutput(idSeed, thinking string) *dto.ResponsesOutput {
+ if thinking == "" {
+ return nil
+ }
+ return &dto.ResponsesOutput{
+ Type: responsesReasoningItemType,
+ ID: fmt.Sprintf("rs_%s", idSeed),
+ Summary: []dto.ResponsesSummaryPart{{
+ Type: responsesSummaryTextType,
+ Text: thinking,
+ }},
+ }
+}
+
+// ExtractResponsesReasoning 从 Responses 输出项中提取思考文本,
+// 多个 reasoning 项与多段 summary 按顺序拼接;无思考内容返回空串。
+func ExtractResponsesReasoning(outputs []dto.ResponsesOutput) string {
+ var parts []string
+ for _, out := range outputs {
+ if out.Type != responsesReasoningItemType {
+ continue
+ }
+ for _, s := range out.Summary {
+ if s.Text != "" {
+ parts = append(parts, s.Text)
+ }
+ }
+ }
+ return strings.Join(parts, "")
+}
+
+// ExtractClaudeThinking 从 Claude content 块中提取思考文本(thinking / redacted_thinking)。
+func ExtractClaudeThinking(blocks []dto.ClaudeContentBlock) string {
+ var parts []string
+ for _, b := range blocks {
+ switch b.Type {
+ case "thinking", "redacted_thinking":
+ if b.Thinking != nil && *b.Thinking != "" {
+ parts = append(parts, *b.Thinking)
+ }
+ }
+ }
+ return strings.Join(parts, "")
+}
diff --git a/relaykit/relayconvert/internal/shared/reasoning_responses_test.go b/relaykit/relayconvert/internal/shared/reasoning_responses_test.go
new file mode 100644
index 0000000..057cc56
--- /dev/null
+++ b/relaykit/relayconvert/internal/shared/reasoning_responses_test.go
@@ -0,0 +1,110 @@
+package shared
+
+import (
+ "testing"
+
+ "github.com/qianfree/team-api/relaykit/dto"
+)
+
+// Responses 思考内容的构造与提取:任一侧失准都会让推理模型的思考过程在转换中静默消失。
+
+func TestBuildResponsesReasoningOutput(t *testing.T) {
+ item := BuildResponsesReasoningOutput("abc", "让我想想")
+ if item == nil {
+ t.Fatal("非空思考内容应产出 reasoning 项")
+ }
+ if item.Type != "reasoning" {
+ t.Errorf("Type = %q, want \"reasoning\"", item.Type)
+ }
+ if item.ID != "rs_abc" {
+ t.Errorf("ID = %q, want \"rs_abc\"(id 须由 seed 稳定派生,金样本比对依赖此性质)", item.ID)
+ }
+ if len(item.Summary) != 1 || item.Summary[0].Type != "summary_text" || item.Summary[0].Text != "让我想想" {
+ t.Errorf("Summary 形态不符: %+v", item.Summary)
+ }
+
+ // 空思考内容不得产出空壳
+ if BuildResponsesReasoningOutput("abc", "") != nil {
+ t.Error("空思考内容应返回 nil,避免产出空 reasoning 壳")
+ }
+}
+
+func TestExtractResponsesReasoning(t *testing.T) {
+ tests := []struct {
+ name string
+ outputs []dto.ResponsesOutput
+ want string
+ }{
+ {
+ name: "单个 reasoning 项",
+ outputs: []dto.ResponsesOutput{
+ {Type: "reasoning", Summary: []dto.ResponsesSummaryPart{{Type: "summary_text", Text: "思考"}}},
+ {Type: "message"},
+ },
+ want: "思考",
+ },
+ {
+ name: "多段 summary 按序拼接",
+ outputs: []dto.ResponsesOutput{
+ {Type: "reasoning", Summary: []dto.ResponsesSummaryPart{
+ {Type: "summary_text", Text: "第一段"},
+ {Type: "summary_text", Text: "第二段"},
+ }},
+ },
+ want: "第一段第二段",
+ },
+ {
+ name: "多个 reasoning 项按序拼接",
+ outputs: []dto.ResponsesOutput{
+ {Type: "reasoning", Summary: []dto.ResponsesSummaryPart{{Text: "A"}}},
+ {Type: "message"},
+ {Type: "reasoning", Summary: []dto.ResponsesSummaryPart{{Text: "B"}}},
+ },
+ want: "AB",
+ },
+ {name: "无 reasoning 项", outputs: []dto.ResponsesOutput{{Type: "message"}}, want: ""},
+ {name: "空 summary", outputs: []dto.ResponsesOutput{{Type: "reasoning"}}, want: ""},
+ {name: "空输出", outputs: nil, want: ""},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := ExtractResponsesReasoning(tt.outputs); got != tt.want {
+ t.Errorf("ExtractResponsesReasoning() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestExtractClaudeThinking(t *testing.T) {
+ think := "想一想"
+ empty := ""
+ blocks := []dto.ClaudeContentBlock{
+ {Type: "thinking", Thinking: &think},
+ {Type: "redacted_thinking", Thinking: &empty},
+ {Type: "text"},
+ }
+ if got := ExtractClaudeThinking(blocks); got != "想一想" {
+ t.Errorf("ExtractClaudeThinking() = %q, want %q", got, "想一想")
+ }
+ // 无 thinking 块
+ if got := ExtractClaudeThinking([]dto.ClaudeContentBlock{{Type: "text"}}); got != "" {
+ t.Errorf("无思考块应返回空串, got %q", got)
+ }
+ // nil Thinking 指针不得 panic
+ if got := ExtractClaudeThinking([]dto.ClaudeContentBlock{{Type: "thinking"}}); got != "" {
+ t.Errorf("nil Thinking 应返回空串, got %q", got)
+ }
+}
+
+// TestResponsesReasoning_RoundTrip 构造后再提取必须还原原文——
+// 这是「chat reasoning_content ↔ Responses reasoning」双向转换不丢内容的前提。
+func TestResponsesReasoning_RoundTrip(t *testing.T) {
+ const original = "第一步:分析问题\n第二步:得出结论"
+ item := BuildResponsesReasoningOutput("seed", original)
+ if item == nil {
+ t.Fatal("构造失败")
+ }
+ if got := ExtractResponsesReasoning([]dto.ResponsesOutput{*item}); got != original {
+ t.Errorf("往返后思考内容变化:\n got=%q\nwant=%q", got, original)
+ }
+}
diff --git a/relaykit/relayconvert/internal/shared/thinking_adapter.go b/relaykit/relayconvert/internal/shared/thinking_adapter.go
deleted file mode 100644
index 00a9298..0000000
--- a/relaykit/relayconvert/internal/shared/thinking_adapter.go
+++ /dev/null
@@ -1,112 +0,0 @@
-package shared
-
-import (
- "strings"
-
- "github.com/qianfree/team-api/relaykit/dto"
- "github.com/qianfree/team-api/relaykit/relayconvert/convmeta"
- "github.com/qianfree/team-api/relaykit/relayconvert/reasoning"
-)
-
-// ThinkingInfo 保存从模型名后缀解析得到的 thinking 配置。
-type ThinkingInfo struct {
- BaseModel string // 不带后缀的模型名
- IsThinking bool // 带 -thinking 后缀
- IsNoThinking bool // 带 -nothinking 后缀
- EffortLevel string // low/medium/high/xhigh/max/minimal/none
-}
-
-// ParseThinkingSuffix 从模型名中解析 thinking 相关后缀。
-// 处理:-thinking、-nothinking、-low、-medium、-high、-xhigh、-max、-minimal、-none
-func ParseThinkingSuffix(modelName string) ThinkingInfo {
- info := ThinkingInfo{
- BaseModel: modelName,
- }
-
- // 检查 -thinking 后缀
- if strings.HasSuffix(modelName, "-thinking") {
- info.IsThinking = true
- info.BaseModel = strings.TrimSuffix(modelName, "-thinking")
- return info
- }
-
- // 检查 -nothinking 后缀
- if strings.HasSuffix(modelName, "-nothinking") {
- info.IsNoThinking = true
- info.BaseModel = strings.TrimSuffix(modelName, "-nothinking")
- return info
- }
-
- // 检查 effort 等级后缀
- baseModel, effort, found := reasoning.TrimEffortSuffix(modelName)
- if found {
- info.BaseModel = baseModel
- info.EffortLevel = effort
- }
-
- return info
-}
-
-// ApplyThinkingToClaude 将 thinking 配置应用到 Claude 请求。
-func ApplyThinkingToClaude(req *dto.ClaudeRequest, info ThinkingInfo, opts convmeta.ClaudeOptions) {
- if !opts.ThinkingAdapterEnabled {
- return
- }
-
- // 显式禁用 thinking
- if info.IsNoThinking {
- return
- }
-
- // 启用 thinking
- if info.IsThinking || info.EffortLevel != "" {
- req.Thinking = &dto.ClaudeThinking{
- Type: "enabled",
- }
-
- // 若已配置则设置 budget tokens
- if req.MaxTokens != nil && opts.ThinkingAdapterBudgetTokensPercentage > 0 {
- budgetTokens := int(float64(*req.MaxTokens) * opts.ThinkingAdapterBudgetTokensPercentage)
- if budgetTokens > 0 {
- req.Thinking.BudgetTokens = &budgetTokens
- }
- }
- }
-}
-
-// ApplyThinkingToGemini 将 thinking 配置应用到 Gemini 请求。
-func ApplyThinkingToGemini(config *dto.GeminiGenerationConfig, info ThinkingInfo, opts convmeta.GeminiOptions) {
- if !opts.ThinkingAdapterEnabled {
- return
- }
-
- // 显式禁用 thinking
- if info.IsNoThinking {
- return
- }
-
- // 启用 thinking
- if info.IsThinking || info.EffortLevel != "" {
- thinkingConfig := &dto.GeminiThinkingConfig{
- IncludeThoughts: true,
- }
-
- // 将 effort 等级映射为 thinking budget
- if config.MaxOutputTokens != nil && opts.ThinkingAdapterBudgetTokensPercentage > 0 {
- thinkingBudget := int(float64(*config.MaxOutputTokens) * opts.ThinkingAdapterBudgetTokensPercentage)
- if thinkingBudget > 0 {
- thinkingConfig.ThoughtBudget = &thinkingBudget
- }
- }
-
- config.ThinkingConfig = thinkingConfig
- }
-}
-
-// ShouldPreserveThinkingSuffix 检查是否应在上游模型名上保留 thinking 后缀。
-func ShouldPreserveThinkingSuffix(modelName string, opts *convmeta.Options) bool {
- if opts == nil {
- return false
- }
- return opts.ShouldPreserveThinkingSuffix(modelName)
-}
diff --git a/relaykit/relayconvert/internal/shared/thinking_adapter_test.go b/relaykit/relayconvert/internal/shared/thinking_adapter_test.go
deleted file mode 100644
index 7d57cb9..0000000
--- a/relaykit/relayconvert/internal/shared/thinking_adapter_test.go
+++ /dev/null
@@ -1,226 +0,0 @@
-package shared
-
-import (
- "testing"
-
- "github.com/qianfree/team-api/relaykit/dto"
- "github.com/qianfree/team-api/relaykit/relayconvert/convmeta"
-)
-
-func TestParseThinkingSuffix(t *testing.T) {
- tests := []struct {
- name string
- modelName string
- expectBase string
- expectThink bool
- expectNoThink bool
- expectEffort string
- }{
- {
- name: "thinking suffix",
- modelName: "gpt-4-thinking",
- expectBase: "gpt-4",
- expectThink: true,
- },
- {
- name: "nothinking suffix",
- modelName: "gpt-4-nothinking",
- expectBase: "gpt-4",
- expectNoThink: true,
- },
- {
- name: "low effort",
- modelName: "gpt-4-low",
- expectBase: "gpt-4",
- expectEffort: "low",
- },
- {
- name: "high effort",
- modelName: "gpt-4-high",
- expectBase: "gpt-4",
- expectEffort: "high",
- },
- {
- name: "no suffix",
- modelName: "gpt-4",
- expectBase: "gpt-4",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- info := ParseThinkingSuffix(tt.modelName)
-
- if info.BaseModel != tt.expectBase {
- t.Errorf("BaseModel = %q, want %q", info.BaseModel, tt.expectBase)
- }
-
- if info.IsThinking != tt.expectThink {
- t.Errorf("IsThinking = %v, want %v", info.IsThinking, tt.expectThink)
- }
-
- if info.IsNoThinking != tt.expectNoThink {
- t.Errorf("IsNoThinking = %v, want %v", info.IsNoThinking, tt.expectNoThink)
- }
-
- if info.EffortLevel != tt.expectEffort {
- t.Errorf("EffortLevel = %q, want %q", info.EffortLevel, tt.expectEffort)
- }
- })
- }
-}
-
-func TestApplyThinkingToClaude(t *testing.T) {
- tests := []struct {
- name string
- info ThinkingInfo
- opts convmeta.ClaudeOptions
- maxTokens *uint
- expectThinking bool
- expectBudget bool
- }{
- {
- name: "adapter disabled",
- info: ThinkingInfo{IsThinking: true},
- opts: convmeta.ClaudeOptions{
- ThinkingAdapterEnabled: false,
- },
- expectThinking: false,
- },
- {
- name: "thinking enabled without budget",
- info: ThinkingInfo{IsThinking: true},
- opts: convmeta.ClaudeOptions{
- ThinkingAdapterEnabled: true,
- },
- expectThinking: true,
- expectBudget: false,
- },
- {
- name: "thinking enabled with budget",
- info: ThinkingInfo{IsThinking: true},
- maxTokens: uintPtr(1000),
- opts: convmeta.ClaudeOptions{
- ThinkingAdapterEnabled: true,
- ThinkingAdapterBudgetTokensPercentage: 0.2,
- },
- expectThinking: true,
- expectBudget: true,
- },
- {
- name: "nothinking suppresses thinking",
- info: ThinkingInfo{IsNoThinking: true},
- opts: convmeta.ClaudeOptions{
- ThinkingAdapterEnabled: true,
- },
- expectThinking: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- req := &dto.ClaudeRequest{
- MaxTokens: tt.maxTokens,
- }
-
- ApplyThinkingToClaude(req, tt.info, tt.opts)
-
- if tt.expectThinking {
- if req.Thinking == nil {
- t.Error("Expected Thinking to be set, got nil")
- } else {
- if req.Thinking.Type != "enabled" {
- t.Errorf("Expected Thinking.Type = 'enabled', got %q", req.Thinking.Type)
- }
-
- if tt.expectBudget {
- if req.Thinking.BudgetTokens == nil {
- t.Error("Expected BudgetTokens to be set, got nil")
- }
- }
- }
- } else {
- if req.Thinking != nil {
- t.Error("Expected Thinking to be nil, got non-nil")
- }
- }
- })
- }
-}
-
-func TestApplyThinkingToGemini(t *testing.T) {
- tests := []struct {
- name string
- info ThinkingInfo
- opts convmeta.GeminiOptions
- maxTokens *uint
- expectThinking bool
- expectBudget bool
- }{
- {
- name: "adapter disabled",
- info: ThinkingInfo{IsThinking: true},
- opts: convmeta.GeminiOptions{
- ThinkingAdapterEnabled: false,
- },
- expectThinking: false,
- },
- {
- name: "thinking enabled with effort",
- info: ThinkingInfo{EffortLevel: "high"},
- opts: convmeta.GeminiOptions{
- ThinkingAdapterEnabled: true,
- },
- expectThinking: true,
- },
- {
- name: "thinking with budget",
- info: ThinkingInfo{IsThinking: true},
- maxTokens: uintPtr(2000),
- opts: convmeta.GeminiOptions{
- ThinkingAdapterEnabled: true,
- ThinkingAdapterBudgetTokensPercentage: 0.3,
- },
- expectThinking: true,
- expectBudget: true,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- config := &dto.GeminiGenerationConfig{
- MaxOutputTokens: tt.maxTokens,
- }
-
- ApplyThinkingToGemini(config, tt.info, tt.opts)
-
- if tt.expectThinking {
- if config.ThinkingConfig == nil {
- t.Error("Expected ThinkingConfig to be set, got nil")
- } else {
- if !config.ThinkingConfig.IncludeThoughts {
- t.Error("Expected IncludeThoughts = true, got false")
- }
-
- if tt.expectBudget {
- if config.ThinkingConfig.ThoughtBudget == nil {
- t.Error("Expected ThoughtBudget to be set, got nil")
- }
- }
- }
- } else {
- if config.ThinkingConfig != nil {
- t.Error("Expected ThinkingConfig to be nil, got non-nil")
- }
- }
- })
- }
-}
-
-func uintPtr(i uint) *uint {
- return &i
-}
-
-func intPtr(i int) *int {
- return &i
-}
diff --git a/relaykit/relayconvert/internal/shared/tool_mapper.go b/relaykit/relayconvert/internal/shared/tool_mapper.go
index db13295..1b09771 100644
--- a/relaykit/relayconvert/internal/shared/tool_mapper.go
+++ b/relaykit/relayconvert/internal/shared/tool_mapper.go
@@ -32,10 +32,15 @@ func MapOpenAIToolsToClaudeTools(tools []dto.Tool) []dto.ClaudeTool {
}
// MapClaudeToolsToOpenAITools 将 Claude ClaudeTool[] 转换为 OpenAI Tool[]。
+// 服务端内置工具(type 非空且非 custom,如 web_search_*)在 chat 协议无对应物,
+// 跳过而非伪装成自定义函数。
func MapClaudeToolsToOpenAITools(tools []dto.ClaudeTool) []dto.Tool {
openaiTools := make([]dto.Tool, 0, len(tools))
for _, tool := range tools {
+ if tool.Type != "" && tool.Type != "custom" {
+ continue
+ }
openaiTool := dto.Tool{
Type: "function",
Function: dto.FunctionDef{
diff --git a/relaykit/relayconvert/internal/shared/websearch.go b/relaykit/relayconvert/internal/shared/websearch.go
new file mode 100644
index 0000000..740f092
--- /dev/null
+++ b/relaykit/relayconvert/internal/shared/websearch.go
@@ -0,0 +1,178 @@
+package shared
+
+import (
+ "encoding/json"
+ "strings"
+
+ "github.com/qianfree/team-api/relaykit/dto"
+)
+
+// 服务端联网搜索(server-side web search)的跨协议中间表示。
+//
+// 四种文本协议都有「让模型自己去联网搜」的原生构件,但表达方式完全不同:
+//
+// OpenAI chat 顶层参数 web_search_options: {search_context_size, user_location}
+// Claude 工具项 {type: "web_search_20250305", name: "web_search", max_uses, allowed_domains, ...}
+// Gemini 工具项 {googleSearch: {}}
+// Responses 工具项 {type: "web_search"}(旧版 web_search_preview)
+// Ollama 无 —— 真实能力缺失,只能降级
+//
+// 此前各方向要么原样丢弃、要么把它当成自定义函数伪造,导致**静默能力丢失**:
+// 客户端明确要求联网、请求却以「没搜索」的方式成功返回,比直接报错更难排查。
+// 本文件把它抽成一个格式无关的 spec:各转换器只负责「从源格式探测」与「产出目标格式」,
+// 于是经 OpenAI 中枢的两跳链(如 Gemini→OpenAI→Claude)也能自动保住该能力
+// —— chat 的 web_search_options 正是链路中枢的载体。
+
+// claudeWebSearchToolType 产出 Claude web_search 工具时使用的版本化类型标识。
+// Anthropic 的服务端工具按日期版本化,跨协议映射时取当前稳定版。
+const claudeWebSearchToolType = "web_search_20250305"
+
+// claudeWebSearchToolName Claude web_search 工具的固定名称。
+const claudeWebSearchToolName = "web_search"
+
+// WebSearchSpec 服务端联网搜索的格式无关描述。
+// 除 Enabled 外的字段是各协议的可选约束,目标格式无对应物时静默降级
+// (能力本身保住,细粒度约束丢失——这与「整个搜索能力丢失」是两个量级)。
+type WebSearchSpec struct {
+ // MaxUses 最大搜索次数(Claude 独有)
+ MaxUses *int
+ // AllowedDomains / BlockedDomains 域名白/黑名单(Claude 独有)
+ AllowedDomains []string
+ BlockedDomains []string
+ // UserLocation 地理位置提示(Claude 与 OpenAI chat 均支持,形态不同,原样透传)
+ UserLocation any
+ // SearchContextSize 搜索上下文规模 low/medium/high(OpenAI chat 独有)
+ SearchContextSize string
+}
+
+// DetectWebSearchFromOpenAI 从 chat 请求的 web_search_options 探测搜索能力。
+// 该字段存在(哪怕是空对象 {})即表示启用——这是 OpenAI 的约定。
+func DetectWebSearchFromOpenAI(opts json.RawMessage) *WebSearchSpec {
+ if len(opts) == 0 || string(opts) == "null" {
+ return nil
+ }
+ spec := &WebSearchSpec{}
+ var parsed struct {
+ SearchContextSize string `json:"search_context_size"`
+ UserLocation any `json:"user_location"`
+ }
+ if err := json.Unmarshal(opts, &parsed); err == nil {
+ spec.SearchContextSize = parsed.SearchContextSize
+ spec.UserLocation = parsed.UserLocation
+ }
+ return spec
+}
+
+// DetectWebSearchFromClaudeTools 从 Claude tools 中探测 web_search 内置工具。
+// 内置工具带日期版本后缀(web_search_20250305 等),按前缀识别。
+func DetectWebSearchFromClaudeTools(tools []dto.ClaudeTool) *WebSearchSpec {
+ for _, t := range tools {
+ if !strings.HasPrefix(t.Type, "web_search") {
+ continue
+ }
+ return &WebSearchSpec{
+ MaxUses: t.MaxUses,
+ AllowedDomains: t.AllowedDomains,
+ BlockedDomains: t.BlockedDomains,
+ UserLocation: t.UserLocation,
+ }
+ }
+ return nil
+}
+
+// DetectWebSearchFromGeminiTools 从 Gemini tools 中探测 googleSearch(含旧版
+// googleSearchRetrieval)条目。tools 为 repeated Tool,需逐条目检查。
+func DetectWebSearchFromGeminiTools(toolsJSON json.RawMessage) *WebSearchSpec {
+ if len(toolsJSON) == 0 {
+ return nil
+ }
+ var entries []map[string]json.RawMessage
+ if err := json.Unmarshal(toolsJSON, &entries); err != nil {
+ return nil
+ }
+ for _, entry := range entries {
+ if _, ok := entry["googleSearch"]; ok {
+ return &WebSearchSpec{}
+ }
+ if _, ok := entry["googleSearchRetrieval"]; ok {
+ return &WebSearchSpec{}
+ }
+ }
+ return nil
+}
+
+// DetectWebSearchFromResponsesTools 从 Responses tools 中探测 web_search 工具// (含 web_search_preview / web_search_2025xx 等变体,按前缀识别)。
+func DetectWebSearchFromResponsesTools(toolsJSON json.RawMessage) *WebSearchSpec {
+ if len(toolsJSON) == 0 {
+ return nil
+ }
+ var tools []map[string]any
+ if err := json.Unmarshal(toolsJSON, &tools); err != nil {
+ return nil
+ }
+ for _, tool := range tools {
+ typ, _ := tool["type"].(string)
+ if !strings.HasPrefix(typ, "web_search") {
+ continue
+ }
+ spec := &WebSearchSpec{}
+ if loc, ok := tool["user_location"]; ok {
+ spec.UserLocation = loc
+ }
+ if size, ok := tool["search_context_size"].(string); ok {
+ spec.SearchContextSize = size
+ }
+ return spec
+ }
+ return nil
+}
+
+// ToOpenAIOptions 产出 chat 的 web_search_options。
+// 无可表达的约束时产出空对象 {}——对 OpenAI 而言「字段存在」即启用。
+func (s *WebSearchSpec) ToOpenAIOptions() json.RawMessage {
+ if s == nil {
+ return nil
+ }
+ opts := map[string]any{}
+ if s.SearchContextSize != "" {
+ opts["search_context_size"] = s.SearchContextSize
+ }
+ if s.UserLocation != nil {
+ opts["user_location"] = s.UserLocation
+ }
+ raw, err := json.Marshal(opts)
+ if err != nil {
+ return json.RawMessage(`{}`)
+ }
+ return raw
+}
+
+// ToClaudeTool 产出 Claude 的 web_search 内置工具项。
+func (s *WebSearchSpec) ToClaudeTool() dto.ClaudeTool {
+ return dto.ClaudeTool{
+ Type: claudeWebSearchToolType,
+ Name: claudeWebSearchToolName,
+ MaxUses: s.MaxUses,
+ AllowedDomains: s.AllowedDomains,
+ BlockedDomains: s.BlockedDomains,
+ UserLocation: s.UserLocation,
+ }
+}
+
+// ToResponsesTool 产出 Responses 的 web_search 工具项。
+func (s *WebSearchSpec) ToResponsesTool() map[string]any {
+ tool := map[string]any{"type": "web_search"}
+ if s.UserLocation != nil {
+ tool["user_location"] = s.UserLocation
+ }
+ if s.SearchContextSize != "" {
+ tool["search_context_size"] = s.SearchContextSize
+ }
+ return tool
+}
+
+// GeminiGoogleSearchEntry 产出 Gemini tools 数组中的 googleSearch 条目。
+// Gemini 侧无任何可承载的约束字段,MaxUses / 域名白名单等一律静默降级。
+func GeminiGoogleSearchEntry() map[string]any {
+ return map[string]any{"googleSearch": map[string]any{}}
+}
diff --git a/relaykit/relayconvert/internal/shared/websearch_test.go b/relaykit/relayconvert/internal/shared/websearch_test.go
new file mode 100644
index 0000000..8733d30
--- /dev/null
+++ b/relaykit/relayconvert/internal/shared/websearch_test.go
@@ -0,0 +1,184 @@
+package shared
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/qianfree/team-api/relaykit/dto"
+)
+
+// 服务端联网搜索的跨协议中间表示:探测与产出两侧都要覆盖,
+// 任一侧失准都会让能力在转换中静默丢失(客户端要求联网、却以没搜索的方式成功返回)。
+
+func TestDetectWebSearchFromOpenAI(t *testing.T) {
+ tests := []struct {
+ name string
+ opts string
+ wantNil bool
+ wantSize string
+ }{
+ {"空对象即启用", `{}`, false, ""},
+ {"带上下文规模", `{"search_context_size":"high"}`, false, "high"},
+ {"带位置", `{"user_location":{"type":"approximate","approximate":{"city":"SH"}}}`, false, ""},
+ {"字段缺失", ``, true, ""},
+ {"显式 null", `null`, true, ""},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := DetectWebSearchFromOpenAI(json.RawMessage(tt.opts))
+ if tt.wantNil {
+ if got != nil {
+ t.Fatalf("期望未探测到搜索,got %+v", got)
+ }
+ return
+ }
+ if got == nil {
+ t.Fatal("应探测到搜索能力")
+ }
+ if got.SearchContextSize != tt.wantSize {
+ t.Errorf("SearchContextSize = %q, want %q", got.SearchContextSize, tt.wantSize)
+ }
+ })
+ }
+}
+
+func TestDetectWebSearchFromClaudeTools(t *testing.T) {
+ maxUses := 5
+ tools := []dto.ClaudeTool{
+ {Name: "get_weather", InputSchema: map[string]any{}},
+ {Type: "web_search_20250305", Name: "web_search", MaxUses: &maxUses, AllowedDomains: []string{"example.com"}},
+ }
+ got := DetectWebSearchFromClaudeTools(tools)
+ if got == nil {
+ t.Fatal("应在混合工具列表中探测到 web_search")
+ }
+ if got.MaxUses == nil || *got.MaxUses != 5 {
+ t.Errorf("MaxUses 丢失: %v", got.MaxUses)
+ }
+ if len(got.AllowedDomains) != 1 || got.AllowedDomains[0] != "example.com" {
+ t.Errorf("AllowedDomains 丢失: %v", got.AllowedDomains)
+ }
+
+ // 仅自定义函数时不得误报
+ if DetectWebSearchFromClaudeTools([]dto.ClaudeTool{{Name: "f"}}) != nil {
+ t.Error("纯自定义函数不应被识别为搜索")
+ }
+ // 其他内置工具不得误报
+ if DetectWebSearchFromClaudeTools([]dto.ClaudeTool{{Type: "code_execution_20250522", Name: "code"}}) != nil {
+ t.Error("code_execution 不应被识别为搜索")
+ }
+}
+
+func TestDetectWebSearchFromGeminiTools(t *testing.T) {
+ cases := []struct {
+ name string
+ tools string
+ want bool
+ }{
+ {"googleSearch", `[{"googleSearch":{}}]`, true},
+ {"旧版 googleSearchRetrieval", `[{"googleSearchRetrieval":{}}]`, true},
+ {"与函数声明共存", `[{"functionDeclarations":[{"name":"f"}]},{"googleSearch":{}}]`, true},
+ {"仅函数声明", `[{"functionDeclarations":[{"name":"f"}]}]`, false},
+ {"空", ``, false},
+ {"畸形 JSON", `{not json`, false},
+ // 对象形态(非数组)不是合法 Gemini tools,不应误判
+ {"对象形态", `{"googleSearch":{}}`, false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := DetectWebSearchFromGeminiTools(json.RawMessage(tc.tools)) != nil
+ if got != tc.want {
+ t.Errorf("探测结果 = %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestDetectWebSearchFromResponsesTools(t *testing.T) {
+ cases := []struct {
+ name string
+ tools string
+ want bool
+ }{
+ {"web_search", `[{"type":"web_search"}]`, true},
+ {"web_search_preview 变体", `[{"type":"web_search_preview"}]`, true},
+ {"与函数共存", `[{"type":"function","name":"f"},{"type":"web_search"}]`, true},
+ {"仅函数", `[{"type":"function","name":"f"}]`, false},
+ {"空", ``, false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := DetectWebSearchFromResponsesTools(json.RawMessage(tc.tools)) != nil
+ if got != tc.want {
+ t.Errorf("探测结果 = %v, want %v", got, tc.want)
+ }
+ })
+ }
+
+ spec := DetectWebSearchFromResponsesTools(json.RawMessage(`[{"type":"web_search","search_context_size":"low"}]`))
+ if spec == nil || spec.SearchContextSize != "low" {
+ t.Errorf("search_context_size 未提取: %+v", spec)
+ }
+}
+
+// TestWebSearchSpec_RoundTripAcrossFormats 跨格式往返:任一格式产出的构件
+// 再被对应的探测器识别,能力都不得丢失——这是两跳链(经 chat 中枢)能保住搜索的前提。
+func TestWebSearchSpec_RoundTripAcrossFormats(t *testing.T) {
+ maxUses := 3
+ origin := &WebSearchSpec{MaxUses: &maxUses, SearchContextSize: "high"}
+
+ // → chat → 回探测
+ chatOpts := origin.ToOpenAIOptions()
+ back := DetectWebSearchFromOpenAI(chatOpts)
+ if back == nil {
+ t.Fatal("chat 往返后能力丢失")
+ }
+ if back.SearchContextSize != "high" {
+ t.Errorf("chat 往返丢失 search_context_size: %+v", back)
+ }
+
+ // → Claude → 回探测
+ claudeTool := origin.ToClaudeTool()
+ back = DetectWebSearchFromClaudeTools([]dto.ClaudeTool{claudeTool})
+ if back == nil {
+ t.Fatal("Claude 往返后能力丢失")
+ }
+ if back.MaxUses == nil || *back.MaxUses != 3 {
+ t.Errorf("Claude 往返丢失 max_uses: %+v", back.MaxUses)
+ }
+
+ // → Responses → 回探测
+ respToolRaw, err := json.Marshal([]any{origin.ToResponsesTool()})
+ if err != nil {
+ t.Fatalf("marshal 失败: %v", err)
+ }
+ if DetectWebSearchFromResponsesTools(respToolRaw) == nil {
+ t.Fatal("Responses 往返后能力丢失")
+ }
+
+ // → Gemini → 回探测(Gemini 侧无可承载的约束字段,只保能力本身)
+ geminiRaw, err := json.Marshal([]any{GeminiGoogleSearchEntry()})
+ if err != nil {
+ t.Fatalf("marshal 失败: %v", err)
+ }
+ if DetectWebSearchFromGeminiTools(geminiRaw) == nil {
+ t.Fatal("Gemini 往返后能力丢失")
+ }
+}
+
+// TestWebSearchSpec_ToOpenAIOptions_EmptyIsEnabled 无约束时必须产出空对象而非 null:
+// OpenAI 的约定是「字段存在即启用」,产出 null 等于没启用。
+func TestWebSearchSpec_ToOpenAIOptions_EmptyIsEnabled(t *testing.T) {
+ opts := (&WebSearchSpec{}).ToOpenAIOptions()
+ if string(opts) != `{}` {
+ t.Errorf("空 spec 应产出 {}, got %s", opts)
+ }
+ if DetectWebSearchFromOpenAI(opts) == nil {
+ t.Error("产出的 options 应能被自身探测器识别为启用")
+ }
+ // nil 接收者产出 nil(调用方据此判断不注入字段)
+ var nilSpec *WebSearchSpec
+ if nilSpec.ToOpenAIOptions() != nil {
+ t.Error("nil spec 应产出 nil")
+ }
+}
diff --git a/relaykit/relayconvert/reasoning/suffix.go b/relaykit/relayconvert/reasoning/suffix.go
deleted file mode 100644
index 49f1c7a..0000000
--- a/relaykit/relayconvert/reasoning/suffix.go
+++ /dev/null
@@ -1,51 +0,0 @@
-package reasoning
-
-import (
- "strings"
-
- "github.com/samber/lo"
-)
-
-var EffortSuffixes = []string{"-max", "-xhigh", "-high", "-medium", "-low", "-minimal"}
-
-var OpenAIEffortSuffixes = []string{"-high", "-minimal", "-low", "-medium", "-none", "-xhigh"}
-
-var DeepSeekV4EffortSuffixes = []string{"-none", "-max"}
-
-// TrimEffortSuffix 返回 (去除后缀的 modelName, effort 等级如 "low", 是否匹配到后缀)。
-func TrimEffortSuffix(modelName string) (string, string, bool) {
- return TrimEffortSuffixWithSuffixes(modelName, EffortSuffixes)
-}
-
-func TrimEffortSuffixWithSuffixes(modelName string, suffixes []string) (string, string, bool) {
- suffix, found := lo.Find(suffixes, func(s string) bool {
- return strings.HasSuffix(modelName, s)
- })
- if !found {
- return modelName, "", false
- }
- return strings.TrimSuffix(modelName, suffix), strings.TrimPrefix(suffix, "-"), true
-}
-
-func ParseOpenAIReasoningEffortFromModelSuffix(modelName string) (string, string) {
- baseModel, effort, ok := TrimEffortSuffixWithSuffixes(modelName, OpenAIEffortSuffixes)
- if !ok {
- return "", modelName
- }
- return effort, baseModel
-}
-
-func ParseDeepSeekV4ThinkingSuffix(modelName string) (baseModel string, thinkingType string, effort string, ok bool) {
- baseModel, suffix, ok := TrimEffortSuffixWithSuffixes(modelName, DeepSeekV4EffortSuffixes)
- if !ok || !strings.HasPrefix(baseModel, "deepseek-v4-") {
- return modelName, "", "", false
- }
- switch suffix {
- case "none":
- return baseModel, "disabled", "", true
- case "max":
- return baseModel, "enabled", "max", true
- default:
- return modelName, "", "", false
- }
-}
diff --git a/relaykit/relayconvert/reasoning/suffix_test.go b/relaykit/relayconvert/reasoning/suffix_test.go
deleted file mode 100644
index 3a36082..0000000
--- a/relaykit/relayconvert/reasoning/suffix_test.go
+++ /dev/null
@@ -1,84 +0,0 @@
-package reasoning
-
-import (
- "testing"
-
- "github.com/stretchr/testify/assert"
-)
-
-func TestTrimEffortSuffix(t *testing.T) {
- cases := []struct {
- model string
- wantBase string
- wantEffort string
- wantFound bool
- }{
- {"gpt-low", "gpt", "low", true},
- {"gpt-medium", "gpt", "medium", true},
- {"gpt-high", "gpt", "high", true},
- {"gpt-xhigh", "gpt", "xhigh", true},
- {"gpt-max", "gpt", "max", true},
- {"gpt-minimal", "gpt", "minimal", true},
- {"gpt", "gpt", "", false},
- }
- for _, c := range cases {
- base, effort, ok := TrimEffortSuffix(c.model)
- assert.Equal(t, c.wantBase, base, "model=%q base", c.model)
- assert.Equal(t, c.wantEffort, effort, "model=%q effort", c.model)
- assert.Equal(t, c.wantFound, ok, "model=%q found", c.model)
- }
-}
-
-func TestTrimEffortSuffixWithSuffixes(t *testing.T) {
- base, effort, ok := TrimEffortSuffixWithSuffixes("m-custom", []string{"-custom"})
- assert.True(t, ok)
- assert.Equal(t, "m", base)
- assert.Equal(t, "custom", effort)
-
- // 无匹配
- base, _, ok = TrimEffortSuffixWithSuffixes("m", []string{"-custom"})
- assert.False(t, ok)
- assert.Equal(t, "m", base)
-}
-
-func TestParseOpenAIReasoningEffortFromModelSuffix(t *testing.T) {
- effort, base := ParseOpenAIReasoningEffortFromModelSuffix("o3-high")
- assert.Equal(t, "high", effort)
- assert.Equal(t, "o3", base)
-
- effort, base = ParseOpenAIReasoningEffortFromModelSuffix("o3-none")
- assert.Equal(t, "none", effort)
- assert.Equal(t, "o3", base)
-
- // 无后缀:effort 为空,baseModel 为原名
- effort, base = ParseOpenAIReasoningEffortFromModelSuffix("o3")
- assert.Equal(t, "", effort)
- assert.Equal(t, "o3", base)
-}
-
-func TestParseDeepSeekV4ThinkingSuffix(t *testing.T) {
- // 合法:deepseek-v4--max / -none
- base, thinking, effort, ok := ParseDeepSeekV4ThinkingSuffix("deepseek-v4-chat-max")
- assert.True(t, ok)
- assert.Equal(t, "deepseek-v4-chat", base)
- assert.Equal(t, "enabled", thinking)
- assert.Equal(t, "max", effort)
-
- base, thinking, effort, ok = ParseDeepSeekV4ThinkingSuffix("deepseek-v4-chat-none")
- assert.True(t, ok)
- assert.Equal(t, "deepseek-v4-chat", base)
- assert.Equal(t, "disabled", thinking)
- assert.Equal(t, "", effort)
-
- // 无第二段(deepseek-v4-max):baseModel 缺少 "deepseek-v4-" 前缀 → false
- _, _, _, ok = ParseDeepSeekV4ThinkingSuffix("deepseek-v4-max")
- assert.False(t, ok)
-
- // 非 deepseek 前缀
- _, _, _, ok = ParseDeepSeekV4ThinkingSuffix("other-model-max")
- assert.False(t, ok)
-
- // 无后缀
- _, _, _, ok = ParseDeepSeekV4ThinkingSuffix("deepseek-v4-chat")
- assert.False(t, ok)
-}
diff --git a/relaykit/relayconvert/register/capability_invariant_test.go b/relaykit/relayconvert/register/capability_invariant_test.go
new file mode 100644
index 0000000..514fbeb
--- /dev/null
+++ b/relaykit/relayconvert/register/capability_invariant_test.go
@@ -0,0 +1,978 @@
+package register
+
+// capability_invariant_test.go — 能力守恒不变量测试。
+//
+// 目的:防止协议转换在某个方向上「静默丢能力」(工具定义丢失、联网搜索被吞、
+// 图片被丢、多轮工具循环断链、系统提示消失等)。与金样本(精确字节比对)不同,
+// 本测试断言的是结构性不变量:一份「满配」入站请求经每个已注册方向转换后,
+// 目标格式请求中各能力构件的数量与标识必须符合该方向的期望档案。
+//
+// 关键设计:
+// - 输入用 wire JSON 并以宿主 parseInboundRequest 同款方式(裸 json.Unmarshal
+// 进 DTO)解码——转换器收到的形状与线上完全一致(如 OpenAI 多模态 content
+// 实际是 []any 而非 []dto.ContentPart);
+// - 断言在转换结果 marshal 回 JSON 之后进行——检查的是上游真正收到的字节形状;
+// - 期望档案按转换器 ID 显式登记(capExpectations)。测试枚举注册表全部请求
+// 转换器,未登记期望的方向直接失败并打印实测档案——新增方向必须同步声明
+// 其能力预期,堵住「矩阵接管但能力静默丢失」的盲区;
+// - 已知的合理能力缺口(目标格式无法表达)以注释形式写在期望条目上,
+// 改动导致缺口扩大时测试失败。
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "sort"
+ "strings"
+ "testing"
+
+ "github.com/qianfree/team-api/relaykit/dto"
+ "github.com/qianfree/team-api/relaykit/relayconvert"
+ "github.com/qianfree/team-api/relaykit/relayconvert/convmeta"
+ "github.com/qianfree/team-api/relaykit/types"
+ "github.com/stretchr/testify/require"
+)
+
+// ---------- 满配入站请求(wire JSON,形状与真实客户端一致) ----------
+
+// 各 marker 全局唯一,用于跨格式检查文本是否幸存。
+const (
+ mkSystem = "CAP_MARKER_SYSTEM"
+ mkUser = "CAP_MARKER_USER"
+ mkToolResult = "CAP_MARKER_TOOL_RESULT"
+ mkFollowup = "CAP_MARKER_FOLLOWUP"
+ imgDataURL = "data:image/jpeg;base64,/9j/4AAQSkZJRg=="
+)
+
+var fullWireRequests = map[types.RelayFormat]string{
+ types.RelayFormatOpenAI: `{
+ "model": "origin-model",
+ "messages": [
+ {"role": "system", "content": "` + mkSystem + ` you are helpful"},
+ {"role": "user", "content": [
+ {"type": "text", "text": "` + mkUser + ` describe this image"},
+ {"type": "image_url", "image_url": {"url": "` + imgDataURL + `"}}
+ ]},
+ {"role": "assistant", "content": null, "tool_calls": [
+ {"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": "{\"location\": \"Boston\"}"}}
+ ]},
+ {"role": "tool", "tool_call_id": "call_1", "content": "` + mkToolResult + ` 68F"},
+ {"role": "user", "content": "` + mkFollowup + ` thanks"}
+ ],
+ "tools": [
+ {"type": "function", "function": {"name": "get_weather", "description": "get weather", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}}},
+ {"type": "function", "function": {"name": "lookup_stock", "description": "stock quote", "parameters": {"type": "object", "properties": {"symbol": {"type": "string"}}}}}
+ ],
+ "tool_choice": {"type": "function", "function": {"name": "get_weather"}},
+ "max_tokens": 1024,
+ "stop": ["END_TOKEN"],
+ "temperature": 0.7,
+ "stream": true
+ }`,
+ types.RelayFormatClaude: `{
+ "model": "origin-model",
+ "max_tokens": 1024,
+ "system": "` + mkSystem + ` you are helpful",
+ "messages": [
+ {"role": "user", "content": [
+ {"type": "text", "text": "` + mkUser + ` describe this image"},
+ {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "/9j/4AAQSkZJRg=="}}
+ ]},
+ {"role": "assistant", "content": [
+ {"type": "text", "text": "checking weather"},
+ {"type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": {"location": "Boston"}}
+ ]},
+ {"role": "user", "content": [
+ {"type": "tool_result", "tool_use_id": "toolu_1", "content": "` + mkToolResult + ` 68F"}
+ ]},
+ {"role": "user", "content": "` + mkFollowup + ` thanks"}
+ ],
+ "tools": [
+ {"name": "get_weather", "description": "get weather", "input_schema": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}},
+ {"name": "lookup_stock", "description": "stock quote", "input_schema": {"type": "object", "properties": {"symbol": {"type": "string"}}}}
+ ],
+ "tool_choice": {"type": "tool", "name": "get_weather"},
+ "stop_sequences": ["END_TOKEN"],
+ "temperature": 0.7,
+ "stream": true
+ }`,
+ types.RelayFormatGemini: `{
+ "systemInstruction": {"parts": [{"text": "` + mkSystem + ` you are helpful"}]},
+ "contents": [
+ {"role": "user", "parts": [
+ {"text": "` + mkUser + ` describe this image"},
+ {"inlineData": {"mimeType": "image/jpeg", "data": "/9j/4AAQSkZJRg=="}}
+ ]},
+ {"role": "model", "parts": [
+ {"text": "checking weather"},
+ {"functionCall": {"name": "get_weather", "args": {"location": "Boston"}}}
+ ]},
+ {"role": "user", "parts": [
+ {"functionResponse": {"name": "get_weather", "response": {"result": "` + mkToolResult + ` 68F"}}}
+ ]},
+ {"role": "user", "parts": [{"text": "` + mkFollowup + ` thanks"}]}
+ ],
+ "tools": [
+ {"functionDeclarations": [
+ {"name": "get_weather", "description": "get weather", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}},
+ {"name": "lookup_stock", "description": "stock quote", "parameters": {"type": "object", "properties": {"symbol": {"type": "string"}}}}
+ ]}
+ ],
+ "toolConfig": {"functionCallingConfig": {"mode": "ANY", "allowedFunctionNames": ["get_weather"]}},
+ "generationConfig": {"maxOutputTokens": 1024, "stopSequences": ["END_TOKEN"], "temperature": 0.7}
+ }`,
+ types.RelayFormatOpenAIResponses: `{
+ "model": "origin-model",
+ "instructions": "` + mkSystem + ` you are helpful",
+ "input": [
+ {"role": "user", "content": [
+ {"type": "input_text", "text": "` + mkUser + ` describe this image"},
+ {"type": "input_image", "image_url": "` + imgDataURL + `"}
+ ]},
+ {"type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": "{\"location\": \"Boston\"}"},
+ {"type": "function_call_output", "call_id": "call_1", "output": "` + mkToolResult + ` 68F"},
+ {"role": "user", "content": [{"type": "input_text", "text": "` + mkFollowup + ` thanks"}]}
+ ],
+ "tools": [
+ {"type": "function", "name": "get_weather", "description": "get weather", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}},
+ {"type": "function", "name": "lookup_stock", "description": "stock quote", "parameters": {"type": "object", "properties": {"symbol": {"type": "string"}}}}
+ ],
+ "tool_choice": {"type": "function", "name": "get_weather"},
+ "max_output_tokens": 1024,
+ "temperature": 0.7,
+ "stream": true
+ }`,
+}
+
+// parseWireRequest 与宿主 relay/handler/relaykit_bridge.go parseInboundRequest 同款:
+// 裸 json.Unmarshal 进对应 DTO 指针,保证转换器收到的形状与线上一致。
+func parseWireRequest(t *testing.T, format types.RelayFormat, body string) any {
+ t.Helper()
+ switch format {
+ case types.RelayFormatOpenAI:
+ var req dto.GeneralOpenAIRequest
+ require.NoError(t, json.Unmarshal([]byte(body), &req))
+ return &req
+ case types.RelayFormatClaude:
+ var req dto.ClaudeRequest
+ require.NoError(t, json.Unmarshal([]byte(body), &req))
+ return &req
+ case types.RelayFormatGemini:
+ var req dto.GeminiChatRequest
+ require.NoError(t, json.Unmarshal([]byte(body), &req))
+ return &req
+ case types.RelayFormatOpenAIResponses:
+ var req dto.OpenAIResponsesRequest
+ require.NoError(t, json.Unmarshal([]byte(body), &req))
+ return &req
+ }
+ t.Fatalf("未支持的入站格式: %s", format)
+ return nil
+}
+
+// ---------- 能力画像与提取器 ----------
+
+// capProfile 是从某格式请求 JSON 中提取出的标准化能力画像。
+type capProfile struct {
+ ToolNames []string // 自定义函数工具名(升序)
+ ForcedToolName string // 强制 tool_choice 指向的函数名(无法表达/未强制为空)
+ HasWebSearch bool // 目标格式的原生服务端搜索构件是否存在
+ ImageCount int // 图片内容块数量
+ ToolCallCount int // 模型侧函数调用数量(多轮历史中的 assistant tool_call)
+ ToolResultCount int // 工具结果块数量
+ StopCount int // stop 序列数量
+ MaxTokensSet bool // 输出上限是否已表达
+ SystemMarker bool // 系统提示 marker 是否幸存(不限承载位置)
+ UserMarker bool // 首条用户文本 marker 是否幸存
+ ToolResultText bool // 工具结果文本 marker 是否幸存
+ FollowupMarker bool // 多轮后续用户消息 marker 是否幸存
+}
+
+// extractProfile 将转换结果 marshal 回 JSON 后按目标格式提取能力画像。
+func extractProfile(t *testing.T, target types.RelayFormat, converted any) capProfile {
+ t.Helper()
+ raw, err := json.Marshal(converted)
+ require.NoError(t, err, "marshal 转换结果")
+ var doc map[string]any
+ require.NoError(t, json.Unmarshal(raw, &doc))
+
+ var p capProfile
+ body := string(raw)
+ p.SystemMarker = strings.Contains(body, mkSystem)
+ p.UserMarker = strings.Contains(body, mkUser)
+ p.ToolResultText = strings.Contains(body, mkToolResult)
+ p.FollowupMarker = strings.Contains(body, mkFollowup)
+
+ switch target {
+ case types.RelayFormatOpenAI:
+ extractOpenAIStructure(doc, &p)
+ case types.RelayFormatClaude:
+ extractClaudeStructure(doc, &p)
+ case types.RelayFormatGemini:
+ extractGeminiStructure(doc, &p)
+ case types.RelayFormatOpenAIResponses:
+ extractResponsesStructure(doc, &p)
+ case types.RelayFormatOllama:
+ extractOllamaStructure(doc, &p)
+ default:
+ t.Fatalf("未支持的目标格式: %s", target)
+ }
+ sort.Strings(p.ToolNames)
+ return p
+}
+
+// JSON 取值助手
+func asMap(v any) map[string]any { m, _ := v.(map[string]any); return m }
+func asArr(v any) []any { a, _ := v.([]any); return a }
+func asStr(v any) string { s, _ := v.(string); return s }
+func stopLen(v any) int {
+ switch s := v.(type) {
+ case string:
+ if s != "" {
+ return 1
+ }
+ case []any:
+ return len(s)
+ }
+ return 0
+}
+
+func extractOpenAIStructure(doc map[string]any, p *capProfile) {
+ for _, tl := range asArr(doc["tools"]) {
+ tm := asMap(tl)
+ if asStr(tm["type"]) == "function" {
+ if name := asStr(asMap(tm["function"])["name"]); name != "" {
+ p.ToolNames = append(p.ToolNames, name)
+ }
+ }
+ }
+ if tc := asMap(doc["tool_choice"]); tc != nil && asStr(tc["type"]) == "function" {
+ p.ForcedToolName = asStr(asMap(tc["function"])["name"])
+ }
+ p.HasWebSearch = doc["web_search_options"] != nil
+ for _, msg := range asArr(doc["messages"]) {
+ mm := asMap(msg)
+ for _, part := range asArr(mm["content"]) {
+ if asStr(asMap(part)["type"]) == "image_url" {
+ p.ImageCount++
+ }
+ }
+ p.ToolCallCount += len(asArr(mm["tool_calls"]))
+ if asStr(mm["role"]) == "tool" {
+ p.ToolResultCount++
+ }
+ }
+ p.StopCount = stopLen(doc["stop"])
+ p.MaxTokensSet = doc["max_tokens"] != nil || doc["max_completion_tokens"] != nil
+}
+
+func extractClaudeStructure(doc map[string]any, p *capProfile) {
+ for _, tl := range asArr(doc["tools"]) {
+ tm := asMap(tl)
+ typ := asStr(tm["type"])
+ if strings.HasPrefix(typ, "web_search") {
+ p.HasWebSearch = true
+ continue
+ }
+ if typ == "" || typ == "custom" {
+ if name := asStr(tm["name"]); name != "" {
+ p.ToolNames = append(p.ToolNames, name)
+ }
+ }
+ }
+ if tc := asMap(doc["tool_choice"]); tc != nil && asStr(tc["type"]) == "tool" {
+ p.ForcedToolName = asStr(tc["name"])
+ }
+ for _, msg := range asArr(doc["messages"]) {
+ for _, blk := range asArr(asMap(msg)["content"]) {
+ switch asStr(asMap(blk)["type"]) {
+ case "image":
+ p.ImageCount++
+ case "tool_use":
+ p.ToolCallCount++
+ case "tool_result":
+ p.ToolResultCount++
+ }
+ }
+ }
+ p.StopCount = len(asArr(doc["stop_sequences"]))
+ p.MaxTokensSet = doc["max_tokens"] != nil
+}
+
+func extractGeminiStructure(doc map[string]any, p *capProfile) {
+ for _, tl := range asArr(doc["tools"]) {
+ tm := asMap(tl)
+ if tm["googleSearch"] != nil || tm["googleSearchRetrieval"] != nil {
+ p.HasWebSearch = true
+ }
+ for _, fd := range asArr(tm["functionDeclarations"]) {
+ if name := asStr(asMap(fd)["name"]); name != "" {
+ p.ToolNames = append(p.ToolNames, name)
+ }
+ }
+ }
+ if fcc := asMap(asMap(doc["toolConfig"])["functionCallingConfig"]); fcc != nil {
+ if asStr(fcc["mode"]) == "ANY" {
+ if names := asArr(fcc["allowedFunctionNames"]); len(names) == 1 {
+ p.ForcedToolName = asStr(names[0])
+ }
+ }
+ }
+ for _, content := range asArr(doc["contents"]) {
+ for _, part := range asArr(asMap(content)["parts"]) {
+ pm := asMap(part)
+ if pm["inlineData"] != nil {
+ p.ImageCount++
+ }
+ if pm["functionCall"] != nil {
+ p.ToolCallCount++
+ }
+ if pm["functionResponse"] != nil {
+ p.ToolResultCount++
+ }
+ }
+ }
+ gc := asMap(doc["generationConfig"])
+ p.StopCount = len(asArr(gc["stopSequences"]))
+ p.MaxTokensSet = gc["maxOutputTokens"] != nil
+}
+
+func extractResponsesStructure(doc map[string]any, p *capProfile) {
+ for _, tl := range asArr(doc["tools"]) {
+ tm := asMap(tl)
+ typ := asStr(tm["type"])
+ if strings.HasPrefix(typ, "web_search") {
+ p.HasWebSearch = true
+ continue
+ }
+ if typ == "function" {
+ if name := asStr(tm["name"]); name != "" {
+ p.ToolNames = append(p.ToolNames, name)
+ }
+ }
+ }
+ if tc := asMap(doc["tool_choice"]); tc != nil && asStr(tc["type"]) == "function" {
+ p.ForcedToolName = asStr(tc["name"])
+ }
+ for _, item := range asArr(doc["input"]) {
+ im := asMap(item)
+ switch asStr(im["type"]) {
+ case "function_call":
+ p.ToolCallCount++
+ case "function_call_output":
+ p.ToolResultCount++
+ }
+ for _, part := range asArr(im["content"]) {
+ if asStr(asMap(part)["type"]) == "input_image" {
+ p.ImageCount++
+ }
+ }
+ }
+ p.MaxTokensSet = doc["max_output_tokens"] != nil
+ // Responses API 无请求级 stop 序列,StopCount 恒 0
+}
+
+func extractOllamaStructure(doc map[string]any, p *capProfile) {
+ for _, tl := range asArr(doc["tools"]) {
+ tm := asMap(tl)
+ if asStr(tm["type"]) == "function" {
+ if name := asStr(asMap(tm["function"])["name"]); name != "" {
+ p.ToolNames = append(p.ToolNames, name)
+ }
+ }
+ }
+ for _, msg := range asArr(doc["messages"]) {
+ mm := asMap(msg)
+ p.ImageCount += len(asArr(mm["images"]))
+ p.ToolCallCount += len(asArr(mm["tool_calls"]))
+ if asStr(mm["role"]) == "tool" {
+ p.ToolResultCount++
+ }
+ }
+ opts := asMap(doc["options"])
+ p.StopCount = stopLen(opts["stop"])
+ p.MaxTokensSet = opts["num_predict"] != nil
+ // Ollama 无 tool_choice / 服务端搜索
+}
+
+// ---------- 期望档案表 ----------
+
+// capMeta 构造转换所需 Meta(与宿主构造方式对齐:模型名 + 渠道选项)。
+func capMeta() *convmeta.Values {
+ return &convmeta.Values{
+ ChannelMetaAttached: true,
+ OriginModelName: "origin-model",
+ UpstreamModelName: "upstream-model",
+ Options: &convmeta.Options{
+ Claude: convmeta.ClaudeOptions{DefaultMaxTokens: func(string) int { return 4096 }},
+ Gemini: convmeta.GeminiOptions{WebSearchToGoogleSearch: true},
+ },
+ }
+}
+
+// capExpectations 每个已注册请求转换器在满配请求下的期望能力画像。
+// 新注册方向必须在此登记(TestCapabilityInvariants_Full 枚举注册表并强制检查),
+// 已知合理缺口必须注释说明原因。
+var capExpectations = map[string]capProfile{
+ // 满配画像基准:两个自定义工具 + 强制工具选择 + 1 图片 + 1 轮工具循环 + stop + max_tokens + 全部文本 marker
+ relayconvert.ConverterOpenAIChatToClaudeMessages: {
+ ToolNames: []string{"get_weather", "lookup_stock"}, ForcedToolName: "get_weather",
+ ImageCount: 1, ToolCallCount: 1, ToolResultCount: 1, StopCount: 1, MaxTokensSet: true,
+ SystemMarker: true, UserMarker: true, ToolResultText: true, FollowupMarker: true,
+ },
+ relayconvert.ConverterOpenAIChatToGeminiContent: {
+ ToolNames: []string{"get_weather", "lookup_stock"}, ForcedToolName: "get_weather",
+ ImageCount: 1, ToolCallCount: 1, ToolResultCount: 1, StopCount: 1, MaxTokensSet: true,
+ SystemMarker: true, UserMarker: true, ToolResultText: true, FollowupMarker: true,
+ },
+ relayconvert.ConverterOpenAIChatToOllama: {
+ // 缺口:Ollama /api/chat 无 tool_choice 语义,强制工具选择无法表达
+ ToolNames: []string{"get_weather", "lookup_stock"}, ForcedToolName: "",
+ ImageCount: 1, ToolCallCount: 1, ToolResultCount: 1, StopCount: 1, MaxTokensSet: true,
+ SystemMarker: true, UserMarker: true, ToolResultText: true, FollowupMarker: true,
+ },
+ relayconvert.ConverterOpenAIChatToOpenAIResponses: {
+ // 缺口:Responses API 无请求级 stop 参数
+ ToolNames: []string{"get_weather", "lookup_stock"}, ForcedToolName: "get_weather",
+ ImageCount: 1, ToolCallCount: 1, ToolResultCount: 1, StopCount: 0, MaxTokensSet: true,
+ SystemMarker: true, UserMarker: true, ToolResultText: true, FollowupMarker: true,
+ },
+ relayconvert.ConverterClaudeMessagesToOpenAIChat: {
+ ToolNames: []string{"get_weather", "lookup_stock"}, ForcedToolName: "get_weather",
+ ImageCount: 1, ToolCallCount: 1, ToolResultCount: 1, StopCount: 1, MaxTokensSet: true,
+ SystemMarker: true, UserMarker: true, ToolResultText: true, FollowupMarker: true,
+ },
+ relayconvert.ConverterClaudeMessagesToGeminiContent: {
+ ToolNames: []string{"get_weather", "lookup_stock"}, ForcedToolName: "get_weather",
+ ImageCount: 1, ToolCallCount: 1, ToolResultCount: 1, StopCount: 1, MaxTokensSet: true,
+ SystemMarker: true, UserMarker: true, ToolResultText: true, FollowupMarker: true,
+ },
+ relayconvert.ConverterGeminiContentToOpenAIChat: {
+ ToolNames: []string{"get_weather", "lookup_stock"}, ForcedToolName: "get_weather",
+ ImageCount: 1, ToolCallCount: 1, ToolResultCount: 1, StopCount: 1, MaxTokensSet: true,
+ SystemMarker: true, UserMarker: true, ToolResultText: true, FollowupMarker: true,
+ },
+ relayconvert.ConverterGeminiContentToClaudeMessages: {
+ ToolNames: []string{"get_weather", "lookup_stock"}, ForcedToolName: "get_weather",
+ ImageCount: 1, ToolCallCount: 1, ToolResultCount: 1, StopCount: 1, MaxTokensSet: true,
+ SystemMarker: true, UserMarker: true, ToolResultText: true, FollowupMarker: true,
+ },
+ relayconvert.ConverterOpenAIResponsesToOpenAIChat: {
+ // StopCount 0:Responses 入站请求本身无 stop 概念(非转换丢失)
+ ToolNames: []string{"get_weather", "lookup_stock"}, ForcedToolName: "get_weather",
+ ImageCount: 1, ToolCallCount: 1, ToolResultCount: 1, StopCount: 0, MaxTokensSet: true,
+ SystemMarker: true, UserMarker: true, ToolResultText: true, FollowupMarker: true,
+ },
+ relayconvert.ConverterResponsesToClaudeMessages: {
+ ToolNames: []string{"get_weather", "lookup_stock"}, ForcedToolName: "get_weather",
+ ImageCount: 1, ToolCallCount: 1, ToolResultCount: 1, StopCount: 0, MaxTokensSet: true,
+ SystemMarker: true, UserMarker: true, ToolResultText: true, FollowupMarker: true,
+ },
+ relayconvert.ConverterOpenAIResponsesToGemini: {
+ ToolNames: []string{"get_weather", "lookup_stock"}, ForcedToolName: "get_weather",
+ ImageCount: 1, ToolCallCount: 1, ToolResultCount: 1, StopCount: 0, MaxTokensSet: true,
+ SystemMarker: true, UserMarker: true, ToolResultText: true, FollowupMarker: true,
+ },
+}
+
+func TestCapabilityInvariants_Full(t *testing.T) {
+ ctx := context.Background()
+
+ for _, id := range relayconvert.ListRequestConverterIDs() {
+ spec, ok := relayconvert.LookupRequestConverter(id)
+ require.True(t, ok)
+
+ input, hasInput := fullWireRequests[spec.From]
+ if !hasInput {
+ t.Errorf("转换器 %s 的入站格式 %s 没有满配请求语料,请在 fullWireRequests 补充", id, spec.From)
+ continue
+ }
+
+ t.Run(id, func(t *testing.T) {
+ parsed := parseWireRequest(t, spec.From, input)
+ converted, err := relayconvert.ConvertRequestByID(ctx, capMeta(), id, parsed)
+ require.NoError(t, err, "转换失败")
+
+ actual := extractProfile(t, spec.To, converted)
+
+ expected, registered := capExpectations[id]
+ if !registered {
+ t.Errorf("转换器 %s 未登记能力期望档案。实测档案(确认合理后粘贴进 capExpectations):\n%s: %s,",
+ id, fmt.Sprintf("%q", id), profileLiteral(actual))
+ return
+ }
+ require.Equal(t, expected, actual, "能力画像与期望不符(若为有意变更,请同步更新 capExpectations 并说明缺口原因)")
+ })
+ }
+}
+
+// profileLiteral 输出可直接粘贴进期望表的 Go 字面量。
+func profileLiteral(p capProfile) string {
+ return fmt.Sprintf("{ToolNames: %#v, ForcedToolName: %q, HasWebSearch: %v, ImageCount: %d, ToolCallCount: %d, ToolResultCount: %d, StopCount: %d, MaxTokensSet: %v, SystemMarker: %v, UserMarker: %v, ToolResultText: %v, FollowupMarker: %v}",
+ p.ToolNames, p.ForcedToolName, p.HasWebSearch, p.ImageCount, p.ToolCallCount, p.ToolResultCount, p.StopCount, p.MaxTokensSet, p.SystemMarker, p.UserMarker, p.ToolResultText, p.FollowupMarker)
+}
+
+// ---------- 响应侧能力守恒 ----------
+
+// 响应侧 marker
+const (
+ mkAssistant = "CAP_MARKER_ASSISTANT_TEXT"
+ mkThinking = "CAP_MARKER_THINKING"
+)
+
+// upstreamWireResponses 各上游格式的满配非流式响应(wire JSON):
+// assistant 文本 + thinking(格式支持时)+ 1 个函数调用 + usage。
+var upstreamWireResponses = map[types.RelayFormat]string{
+ types.RelayFormatOpenAI: `{
+ "id": "chatcmpl-abc123",
+ "object": "chat.completion",
+ "created": 1741569952,
+ "model": "upstream-model",
+ "choices": [{
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": "` + mkAssistant + ` the weather is 68F",
+ "reasoning_content": "` + mkThinking + ` user asks weather",
+ "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": "{\"location\": \"Boston\"}"}}]
+ },
+ "finish_reason": "tool_calls"
+ }],
+ "usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150}
+ }`,
+ types.RelayFormatClaude: `{
+ "id": "msg_abc123",
+ "type": "message",
+ "role": "assistant",
+ "model": "upstream-model",
+ "content": [
+ {"type": "thinking", "thinking": "` + mkThinking + ` user asks weather", "signature": "sig_abc"},
+ {"type": "text", "text": "` + mkAssistant + ` the weather is 68F"},
+ {"type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": {"location": "Boston"}}
+ ],
+ "stop_reason": "tool_use",
+ "usage": {"input_tokens": 100, "output_tokens": 50}
+ }`,
+ types.RelayFormatGemini: `{
+ "candidates": [{
+ "content": {"role": "model", "parts": [
+ {"text": "` + mkThinking + ` user asks weather", "thought": true},
+ {"text": "` + mkAssistant + ` the weather is 68F", "thoughtSignature": "sig_abc"},
+ {"functionCall": {"name": "get_weather", "args": {"location": "Boston"}}}
+ ]},
+ "finishReason": "STOP",
+ "index": 0
+ }],
+ "usageMetadata": {"promptTokenCount": 100, "candidatesTokenCount": 50, "totalTokenCount": 150},
+ "modelVersion": "upstream-model"
+ }`,
+ types.RelayFormatOpenAIResponses: `{
+ "id": "resp_abc123",
+ "object": "response",
+ "created_at": 1741476542,
+ "status": "completed",
+ "model": "upstream-model",
+ "output": [
+ {"type": "reasoning", "id": "rs_1", "summary": [{"type": "summary_text", "text": "` + mkThinking + ` user asks weather"}]},
+ {"type": "message", "id": "msg_1", "status": "completed", "role": "assistant", "content": [{"type": "output_text", "text": "` + mkAssistant + ` the weather is 68F", "annotations": []}]},
+ {"type": "function_call", "id": "fc_1", "call_id": "call_1", "name": "get_weather", "arguments": "{\"location\": \"Boston\"}", "status": "completed"}
+ ],
+ "usage": {"input_tokens": 100, "output_tokens": 50, "total_tokens": 150}
+ }`,
+ types.RelayFormatOllama: `{
+ "model": "upstream-model",
+ "created_at": "2026-01-01T00:00:00Z",
+ "message": {
+ "role": "assistant",
+ "content": "` + mkAssistant + ` the weather is 68F",
+ "thinking": "` + mkThinking + ` user asks weather",
+ "tool_calls": [{"function": {"name": "get_weather", "arguments": {"location": "Boston"}}}]
+ },
+ "done": true,
+ "done_reason": "stop",
+ "prompt_eval_count": 100,
+ "eval_count": 50
+ }`,
+}
+
+// parseWireResponse 按上游格式把响应体解析为对应 DTO 指针(与宿主响应解析一致)。
+func parseWireResponse(t *testing.T, format types.RelayFormat, body string) any {
+ t.Helper()
+ switch format {
+ case types.RelayFormatOpenAI:
+ var resp dto.ChatCompletionResponse
+ require.NoError(t, json.Unmarshal([]byte(body), &resp))
+ return &resp
+ case types.RelayFormatClaude:
+ var resp dto.ClaudeResponse
+ require.NoError(t, json.Unmarshal([]byte(body), &resp))
+ return &resp
+ case types.RelayFormatGemini:
+ var resp dto.GeminiChatResponse
+ require.NoError(t, json.Unmarshal([]byte(body), &resp))
+ return &resp
+ case types.RelayFormatOpenAIResponses:
+ var resp dto.OpenAIResponsesResponse
+ require.NoError(t, json.Unmarshal([]byte(body), &resp))
+ return &resp
+ case types.RelayFormatOllama:
+ var resp dto.OllamaChatResponse
+ require.NoError(t, json.Unmarshal([]byte(body), &resp))
+ return &resp
+ }
+ t.Fatalf("未支持的上游格式: %s", format)
+ return nil
+}
+
+// respProfile 客户端格式响应的能力画像。
+type respProfile struct {
+ ToolCallNames []string // 函数调用名(升序)
+ TextMarker bool // assistant 文本 marker 幸存
+ ThinkMarker bool // thinking 内容 marker 幸存(承载位置不限)
+ UsageIn bool // 输入 token 数 > 0
+ UsageOut bool // 输出 token 数 > 0
+}
+
+func extractRespProfile(t *testing.T, client types.RelayFormat, converted any) respProfile {
+ t.Helper()
+ raw, err := json.Marshal(converted)
+ require.NoError(t, err)
+ var doc map[string]any
+ require.NoError(t, json.Unmarshal(raw, &doc))
+
+ var p respProfile
+ body := string(raw)
+ p.TextMarker = strings.Contains(body, mkAssistant)
+ p.ThinkMarker = strings.Contains(body, mkThinking)
+
+ num := func(m map[string]any, key string) float64 { f, _ := m[key].(float64); return f }
+
+ switch client {
+ case types.RelayFormatOpenAI:
+ for _, ch := range asArr(doc["choices"]) {
+ msg := asMap(asMap(ch)["message"])
+ for _, tc := range asArr(msg["tool_calls"]) {
+ if name := asStr(asMap(asMap(tc)["function"])["name"]); name != "" {
+ p.ToolCallNames = append(p.ToolCallNames, name)
+ }
+ }
+ }
+ usage := asMap(doc["usage"])
+ p.UsageIn = num(usage, "prompt_tokens") > 0
+ p.UsageOut = num(usage, "completion_tokens") > 0
+ case types.RelayFormatClaude:
+ for _, blk := range asArr(doc["content"]) {
+ bm := asMap(blk)
+ if asStr(bm["type"]) == "tool_use" {
+ if name := asStr(bm["name"]); name != "" {
+ p.ToolCallNames = append(p.ToolCallNames, name)
+ }
+ }
+ }
+ usage := asMap(doc["usage"])
+ p.UsageIn = num(usage, "input_tokens") > 0
+ p.UsageOut = num(usage, "output_tokens") > 0
+ case types.RelayFormatGemini:
+ for _, cand := range asArr(doc["candidates"]) {
+ for _, part := range asArr(asMap(asMap(cand)["content"])["parts"]) {
+ if fc := asMap(asMap(part)["functionCall"]); fc != nil {
+ if name := asStr(fc["name"]); name != "" {
+ p.ToolCallNames = append(p.ToolCallNames, name)
+ }
+ }
+ }
+ }
+ usage := asMap(doc["usageMetadata"])
+ p.UsageIn = num(usage, "promptTokenCount") > 0
+ p.UsageOut = num(usage, "candidatesTokenCount") > 0
+ case types.RelayFormatOpenAIResponses:
+ for _, item := range asArr(doc["output"]) {
+ im := asMap(item)
+ if asStr(im["type"]) == "function_call" {
+ if name := asStr(im["name"]); name != "" {
+ p.ToolCallNames = append(p.ToolCallNames, name)
+ }
+ }
+ }
+ usage := asMap(doc["usage"])
+ p.UsageIn = num(usage, "input_tokens") > 0
+ p.UsageOut = num(usage, "output_tokens") > 0
+ default:
+ t.Fatalf("未支持的客户端格式: %s", client)
+ }
+ sort.Strings(p.ToolCallNames)
+ return p
+}
+
+// respExpectations 各方向(响应转换:上游 To 格式 → 客户端 From 格式)的期望画像。
+// 新注册方向必须在此登记;已知缺口注明原因,修复后同步翻转期望值。
+var respExpectations = map[string]respProfile{
+ relayconvert.ConverterOpenAIChatToClaudeMessages: {
+ ToolCallNames: []string{"get_weather"}, TextMarker: true, ThinkMarker: true, UsageIn: true, UsageOut: true,
+ },
+ relayconvert.ConverterOpenAIChatToGeminiContent: {
+ ToolCallNames: []string{"get_weather"}, TextMarker: true, ThinkMarker: true, UsageIn: true, UsageOut: true,
+ },
+ relayconvert.ConverterOpenAIChatToOllama: {
+ ToolCallNames: []string{"get_weather"}, TextMarker: true, ThinkMarker: true, UsageIn: true, UsageOut: true,
+ },
+ relayconvert.ConverterOpenAIChatToOpenAIResponses: {
+ // Responses 上游的 reasoning summary 回填 chat 的 reasoning_content
+ ToolCallNames: []string{"get_weather"}, TextMarker: true, ThinkMarker: true, UsageIn: true, UsageOut: true,
+ },
+ relayconvert.ConverterClaudeMessagesToOpenAIChat: {
+ ToolCallNames: []string{"get_weather"}, TextMarker: true, ThinkMarker: true, UsageIn: true, UsageOut: true,
+ },
+ relayconvert.ConverterClaudeMessagesToGeminiContent: {
+ ToolCallNames: []string{"get_weather"}, TextMarker: true, ThinkMarker: true, UsageIn: true, UsageOut: true,
+ },
+ relayconvert.ConverterGeminiContentToOpenAIChat: {
+ ToolCallNames: []string{"get_weather"}, TextMarker: true, ThinkMarker: true, UsageIn: true, UsageOut: true,
+ },
+ relayconvert.ConverterGeminiContentToClaudeMessages: {
+ ToolCallNames: []string{"get_weather"}, TextMarker: true, ThinkMarker: true, UsageIn: true, UsageOut: true,
+ },
+ // Responses 客户端方向:上游 thinking(chat reasoning_content / Claude thinking 块 /
+ // Gemini thought part)合成为 Responses 的 reasoning 输出项
+ //({type:"reasoning", summary:[{type:"summary_text", text:...}]},排在 message 之前)
+ relayconvert.ConverterOpenAIResponsesToOpenAIChat: {
+ ToolCallNames: []string{"get_weather"}, TextMarker: true, ThinkMarker: true, UsageIn: true, UsageOut: true,
+ },
+ relayconvert.ConverterResponsesToClaudeMessages: {
+ ToolCallNames: []string{"get_weather"}, TextMarker: true, ThinkMarker: true, UsageIn: true, UsageOut: true,
+ },
+ relayconvert.ConverterOpenAIResponsesToGemini: {
+ ToolCallNames: []string{"get_weather"}, TextMarker: true, ThinkMarker: true, UsageIn: true, UsageOut: true,
+ },
+}
+
+func TestCapabilityInvariants_Response(t *testing.T) {
+ ctx := context.Background()
+
+ for _, id := range relayconvert.ListResponseConverterIDs() {
+ spec, ok := relayconvert.LookupResponseConverter(id)
+ require.True(t, ok)
+ if spec.Convert == nil {
+ continue // 纯流式登记(无非流式转换器)不在本测试范围
+ }
+
+ input, hasInput := upstreamWireResponses[spec.To]
+ if !hasInput {
+ t.Errorf("响应转换器 %s 的上游格式 %s 没有响应语料,请在 upstreamWireResponses 补充", id, spec.To)
+ continue
+ }
+
+ t.Run(id, func(t *testing.T) {
+ parsed := parseWireResponse(t, spec.To, input)
+ converted, _, err := spec.Convert(ctx, capMeta(), parsed)
+ require.NoError(t, err, "响应转换失败")
+
+ actual := extractRespProfile(t, spec.From, converted)
+
+ expected, registered := respExpectations[id]
+ if !registered {
+ t.Errorf("响应转换器 %s 未登记期望档案。实测:\n%q: {ToolCallNames: %#v, TextMarker: %v, ThinkMarker: %v, UsageIn: %v, UsageOut: %v},",
+ id, id, actual.ToolCallNames, actual.TextMarker, actual.ThinkMarker, actual.UsageIn, actual.UsageOut)
+ return
+ }
+ require.Equal(t, expected, actual, "响应能力画像与期望不符(若为有意变更请更新 respExpectations 并注明原因)")
+ })
+ }
+}
+
+// ---------- 联网搜索场景 ----------
+
+// webSearchWireRequests 各入站格式的「仅服务端搜索工具」请求(无自定义函数——
+// Claude→Gemini 的 googleSearch 映射采取保守策略,与 functionDeclarations 共存时不附加)。
+var webSearchWireRequests = map[types.RelayFormat]string{
+ types.RelayFormatOpenAI: `{
+ "model": "origin-model",
+ "messages": [{"role": "user", "content": "` + mkUser + ` search the latest news"}],
+ "web_search_options": {},
+ "max_tokens": 1024
+ }`,
+ types.RelayFormatClaude: `{
+ "model": "origin-model",
+ "max_tokens": 1024,
+ "messages": [{"role": "user", "content": "` + mkUser + ` search the latest news"}],
+ "tools": [{"type": "web_search_20250305", "name": "web_search", "max_uses": 5}]
+ }`,
+ types.RelayFormatGemini: `{
+ "contents": [{"role": "user", "parts": [{"text": "` + mkUser + ` search the latest news"}]}],
+ "tools": [{"googleSearch": {}}],
+ "generationConfig": {"maxOutputTokens": 1024}
+ }`,
+ types.RelayFormatOpenAIResponses: `{
+ "model": "origin-model",
+ "input": [{"role": "user", "content": [{"type": "input_text", "text": "` + mkUser + ` search the latest news"}]}],
+ "tools": [{"type": "web_search"}],
+ "max_output_tokens": 1024
+ }`,
+}
+
+// webSearchExpectations 各方向转换后目标格式是否保留了服务端搜索能力。
+//
+// 这是用户最担心的静默丢失能力之一:客户端明确要求联网、请求却以「没搜索」的方式
+// 成功返回,比直接报错更难排查。四种文本协议都有原生构件(chat 的 web_search_options、
+// Claude 的 web_search_* 工具、Gemini 的 googleSearch、Responses 的 web_search 工具),
+// 由 shared/websearch.go 的 WebSearchSpec 做跨协议中间表示;chat 作为两跳链的中枢
+// 承载该能力,因此跨原生方向(Gemini→Claude 等)也能保住。
+//
+// 为 false 的方向必须是**目标协议真实无此能力**,并注明原因;单纯「没实现」不得置 false。
+var webSearchExpectations = map[string]bool{
+ // OpenAI chat 入站 web_search_options
+ relayconvert.ConverterOpenAIChatToClaudeMessages: true, // → Claude web_search 内置工具
+ relayconvert.ConverterOpenAIChatToGeminiContent: true, // → googleSearch(渠道开启 WebSearchToGoogleSearch 时;本测试开启)
+ relayconvert.ConverterOpenAIChatToOllama: false, // Ollama 无服务端搜索能力(协议真实缺失)
+ relayconvert.ConverterOpenAIChatToOpenAIResponses: true, // → Responses web_search 工具
+
+ // Claude 入站 web_search_* 工具
+ relayconvert.ConverterClaudeMessagesToOpenAIChat: true, // → chat web_search_options
+ relayconvert.ConverterClaudeMessagesToGeminiContent: true, // → googleSearch(直连方向,渠道开关控制)
+
+ // Gemini 入站 googleSearch 工具
+ relayconvert.ConverterGeminiContentToOpenAIChat: true, // → chat web_search_options
+ relayconvert.ConverterGeminiContentToClaudeMessages: true, // 两跳链 g2o→o2c,经中枢 web_search_options 保住
+
+ // Responses 入站 web_search 工具
+ relayconvert.ConverterOpenAIResponsesToOpenAIChat: true, // → chat web_search_options
+ relayconvert.ConverterResponsesToClaudeMessages: true, // 两跳链 r2o→o2c
+ relayconvert.ConverterOpenAIResponsesToGemini: true, // 两跳链 r2o→o2g
+}
+
+func TestCapabilityInvariants_WebSearch(t *testing.T) {
+ ctx := context.Background()
+
+ for _, id := range relayconvert.ListRequestConverterIDs() {
+ spec, ok := relayconvert.LookupRequestConverter(id)
+ require.True(t, ok)
+
+ input, hasInput := webSearchWireRequests[spec.From]
+ if !hasInput {
+ t.Errorf("转换器 %s 的入站格式 %s 没有搜索场景语料,请在 webSearchWireRequests 补充", id, spec.From)
+ continue
+ }
+
+ t.Run(id, func(t *testing.T) {
+ parsed := parseWireRequest(t, spec.From, input)
+ converted, err := relayconvert.ConvertRequestByID(ctx, capMeta(), id, parsed)
+ require.NoError(t, err, "转换失败")
+
+ actual := extractProfile(t, spec.To, converted)
+
+ expected, registered := webSearchExpectations[id]
+ if !registered {
+ t.Errorf("转换器 %s 未登记搜索能力期望(实测 HasWebSearch=%v),请在 webSearchExpectations 补充", id, actual.HasWebSearch)
+ return
+ }
+ require.Equal(t, expected, actual.HasWebSearch,
+ "服务端搜索能力保留情况与期望不符(若为有意变更请更新 webSearchExpectations 并注明原因)")
+ // 搜索场景无自定义函数:不得凭空多出函数工具(如把 web_search 错当自定义函数转换)
+ require.Empty(t, actual.ToolNames, "搜索场景不应产出自定义函数工具")
+ // 用户文本必须幸存
+ require.True(t, actual.UserMarker, "用户文本在搜索场景转换后丢失")
+ })
+ }
+}
+
+// ---------- 响应侧 grounding 还原 ----------
+
+// geminiGroundedResponse 带 groundingMetadata 的 Gemini 上游响应(满配搜索场景):
+// 模型真的去搜了,回答附带两条网页来源与一个覆盖片段。
+const geminiGroundedResponse = `{
+ "candidates": [{
+ "content": {"role": "model", "parts": [
+ {"text": "` + mkAssistant + ` the weather is 68F"}
+ ]},
+ "finishReason": "STOP",
+ "index": 0,
+ "groundingMetadata": {
+ "webSearchQueries": ["boston weather"],
+ "groundingChunks": [
+ {"web": {"uri": "https://weather.example/boston", "title": "weather.example"}},
+ {"web": {"uri": "https://forecast.example/ma", "title": "forecast.example"}}
+ ],
+ "groundingSupports": [{
+ "segment": {"startIndex": 0, "endIndex": 5, "text": "` + mkAssistant + `"},
+ "groundingChunkIndices": [0, 1]
+ }]
+ }
+ }],
+ "usageMetadata": {"promptTokenCount": 100, "candidatesTokenCount": 50, "totalTokenCount": 150},
+ "modelVersion": "upstream-model"
+}`
+
+// groundingRestoreExpectations 各客户端方向是否把上游的搜索来源还原为本协议的引用构件。
+//
+// 与请求侧(webSearchExpectations)对称:请求侧保住「要求联网」的能力,
+// 响应侧保住「搜到了什么」的证据。只还原请求、不还原响应,客户端拿到的是一段
+// 被搜索增强却无出处的文本——看不到来源、无法核查,联网搜索的主要价值随之丢失。
+//
+// 键为**响应转换方向的配对转换器 ID**(From=客户端格式、To=Gemini 上游)。
+var groundingRestoreExpectations = map[string]bool{
+ relayconvert.ConverterOpenAIChatToGeminiContent: true, // → chat message.annotations(url_citation)
+ relayconvert.ConverterClaudeMessagesToGeminiContent: true, // → server_tool_use + web_search_tool_result 块
+ relayconvert.ConverterOpenAIResponsesToGemini: true, // → web_search_call 项 + output_text.annotations
+}
+
+// TestCapabilityInvariants_GroundingRestore 响应侧搜索证据还原:
+// 上游 Gemini 返回带 groundingMetadata 的响应时,来源 URL 必须出现在客户端可见的响应里。
+func TestCapabilityInvariants_GroundingRestore(t *testing.T) {
+ ctx := context.Background()
+
+ for _, id := range relayconvert.ListResponseConverterIDs() {
+ spec, ok := relayconvert.LookupResponseConverter(id)
+ require.True(t, ok)
+ // 只考察上游为 Gemini 的方向(grounding 是 Gemini 侧的构件)
+ if spec.Convert == nil || spec.To != types.RelayFormatGemini {
+ continue
+ }
+
+ t.Run(id, func(t *testing.T) {
+ parsed := parseWireResponse(t, types.RelayFormatGemini, geminiGroundedResponse)
+ converted, _, err := spec.Convert(ctx, capMeta(), parsed)
+ require.NoError(t, err, "响应转换失败")
+
+ raw, err := json.Marshal(converted)
+ require.NoError(t, err)
+ body := string(raw)
+
+ expected, registered := groundingRestoreExpectations[id]
+ if !registered {
+ t.Errorf("响应转换器 %s 未登记 grounding 还原期望(实测含来源=%v),请在 groundingRestoreExpectations 补充",
+ id, strings.Contains(body, "weather.example/boston"))
+ return
+ }
+
+ hasSource := strings.Contains(body, "https://weather.example/boston") &&
+ strings.Contains(body, "https://forecast.example/ma")
+ require.Equal(t, expected, hasSource,
+ "搜索来源还原情况与期望不符(若为有意变更请更新 groundingRestoreExpectations 并注明原因)\n实际响应: %s", body)
+
+ // 回答正文不得丢失(还原引用不能以吞掉正文为代价)
+ require.Contains(t, body, mkAssistant, "回答正文在 grounding 还原后丢失")
+ })
+ }
+}
+
+// TestCapabilityInvariants_GroundingSearchCountForBilling Claude 客户端方向必须把
+// 搜索次数透出到 usage.server_tool_use.web_search_requests:
+// Google 在 token 之外按搜索次数单独计价,不透出则计费层无从得知发起过几次搜索。
+func TestCapabilityInvariants_GroundingSearchCountForBilling(t *testing.T) {
+ ctx := context.Background()
+
+ spec, ok := relayconvert.LookupResponseConverter(relayconvert.ConverterClaudeMessagesToGeminiContent)
+ require.True(t, ok)
+ require.NotNil(t, spec.Convert)
+
+ parsed := parseWireResponse(t, types.RelayFormatGemini, geminiGroundedResponse)
+ converted, _, err := spec.Convert(ctx, capMeta(), parsed)
+ require.NoError(t, err)
+
+ claudeResp, ok := converted.(*dto.ClaudeResponse)
+ require.True(t, ok, "期望 *dto.ClaudeResponse, got %T", converted)
+ require.NotNil(t, claudeResp.Usage, "usage 缺失")
+ require.NotNil(t, claudeResp.Usage.ServerToolUse, "usage.server_tool_use 缺失(按次计费无据可依)")
+ require.Equal(t, 1, claudeResp.Usage.ServerToolUse.WebSearchRequests,
+ "web_search_requests 应等于 webSearchQueries 条数")
+}
diff --git a/relaykit/relayconvert/register/fuzz_test.go b/relaykit/relayconvert/register/fuzz_test.go
new file mode 100644
index 0000000..159ad84
--- /dev/null
+++ b/relaykit/relayconvert/register/fuzz_test.go
@@ -0,0 +1,383 @@
+package register
+
+// fuzz_test.go — 协议转换的模糊测试(注册表级,覆盖全部已注册方向)。
+//
+// 为什么需要:宿主对客户端请求体是**裸 json.Unmarshal**,解析出的 content 有
+// `[]any`(map) 与 `[]dto.ContentPart` 两种并存形态,转换器要靠
+// shared.NormalizeContentParts 归一;再加上各格式大量可选/多态字段
+//(Responses 的 action 多态、Claude 的 content 块联合体、Gemini 的 parts 联合体),
+// 「结构合法但语义刁钻」的输入空间远大于手写语料能覆盖的范围。
+//
+// 断言两条不变量(对任意输入都必须成立):
+// 1. 不 panic——转换器崩溃会让该请求 500,且在共享进程里污染日志与指标;
+// 2. 转换成功 ⇒ 结果必须可 json.Marshal——宿主紧接着就要序列化发往上游
+// (relaykit_bridge 的 marshal 失败是 hard-fail),产出不可序列化的值
+// (NaN/Inf 浮点、含 chan/func 的字段)等同于静默制造 500。
+//
+// 运行方式:
+// - 常规回归:go test ./relayconvert/register/ —— 只跑种子语料(含金样本全部输入);
+// - 主动模糊:go test -run XXX -fuzz FuzzRequestConversion ./relayconvert/register/
+// 失败输入由 go 自动落盘到 testdata/fuzz//,随仓库提交后即成为永久回归种子。
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/qianfree/team-api/relaykit/dto"
+ "github.com/qianfree/team-api/relaykit/relayconvert"
+ "github.com/qianfree/team-api/relaykit/types"
+)
+
+// fuzzFormats 模糊测试覆盖的入站格式(与宿主 parseInboundRequest 支持的集合一致)。
+var fuzzFormats = []types.RelayFormat{
+ types.RelayFormatOpenAI,
+ types.RelayFormatClaude,
+ types.RelayFormatGemini,
+ types.RelayFormatOpenAIResponses,
+}
+
+// fuzzParseRequest 按格式解析请求体(解析失败返回 nil,由调用方跳过——
+// 宿主对解析失败是 hard-fail 拒绝请求,不进入转换器)。
+func fuzzParseRequest(format types.RelayFormat, body []byte) any {
+ switch format {
+ case types.RelayFormatOpenAI:
+ var req dto.GeneralOpenAIRequest
+ if json.Unmarshal(body, &req) != nil {
+ return nil
+ }
+ return &req
+ case types.RelayFormatClaude:
+ var req dto.ClaudeRequest
+ if json.Unmarshal(body, &req) != nil {
+ return nil
+ }
+ return &req
+ case types.RelayFormatGemini:
+ var req dto.GeminiChatRequest
+ if json.Unmarshal(body, &req) != nil {
+ return nil
+ }
+ return &req
+ case types.RelayFormatOpenAIResponses:
+ var req dto.OpenAIResponsesRequest
+ if json.Unmarshal(body, &req) != nil {
+ return nil
+ }
+ return &req
+ }
+ return nil
+}
+
+// addSeedsFromDir 把目录下匹配后缀的语料按「文件名前缀 → 格式下标」加入种子集。
+// 文件名形如 <格式>__<场景>.,与金样本套件共用同一份语料。
+func addSeedsFromDir(f *testing.F, dir, ext string) {
+ f.Helper()
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return // 语料目录缺失不阻断模糊测试(仍有手写种子)
+ }
+ for _, e := range entries {
+ if !strings.HasSuffix(e.Name(), ext) {
+ continue
+ }
+ data, err := os.ReadFile(filepath.Join(dir, e.Name()))
+ if err != nil {
+ continue
+ }
+ formatName := strings.SplitN(strings.TrimSuffix(e.Name(), ext), "__", 2)[0]
+ for i, format := range fuzzFormats {
+ if string(format) == formatName {
+ f.Add(i, string(data))
+ break
+ }
+ }
+ }
+}
+
+// fuzzAdversarialSeeds 手写对抗种子:结构合法但语义刁钻,专打已知易错点。
+var fuzzAdversarialSeeds = []struct {
+ formatIdx int
+ body string
+}{
+ // content 双形态:字符串 / 数组 / 空数组 / 类型错位的元素
+ {0, `{"model":"m","messages":[{"role":"user","content":[]}]}`},
+ {0, `{"model":"m","messages":[{"role":"user","content":[{"type":"text"}]}]}`},
+ {0, `{"model":"m","messages":[{"role":"user","content":[{"type":"image_url","image_url":{}}]}]}`},
+ {0, `{"model":"m","messages":[{"role":"user","content":[null,123,"x"]}]}`},
+ {0, `{"model":"m","messages":[{"role":"user","content":{}}]}`},
+ // 工具调用:空参数 / 非法 JSON 参数字符串 / 缺 id
+ {0, `{"model":"m","messages":[{"role":"assistant","tool_calls":[{"id":"","type":"function","function":{"name":"","arguments":""}}]}]}`},
+ {0, `{"model":"m","messages":[{"role":"assistant","tool_calls":[{"id":"c","type":"function","function":{"name":"f","arguments":"{not json"}}]}]}`},
+ {0, `{"model":"m","messages":[{"role":"tool","tool_call_id":"nonexistent","content":"r"}]}`},
+ // tool_choice 多态
+ {0, `{"model":"m","messages":[],"tool_choice":{"type":"function","function":{"name":"f"}}}`},
+ {0, `{"model":"m","messages":[],"tool_choice":"required"}`},
+ // 极端数值(NaN/Inf 无法用 JSON 表达,但超大数与负值可以)
+ {0, `{"model":"m","messages":[],"temperature":1e308,"max_tokens":-1,"top_p":-5}`},
+ // Claude:空 content 块数组 / thinking 块 / 服务端工具
+ {1, `{"model":"m","max_tokens":1,"messages":[{"role":"user","content":[]}]}`},
+ {1, `{"model":"m","max_tokens":1,"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"t","signature":""}]}]}`},
+ {1, `{"model":"m","max_tokens":1,"messages":[],"tools":[{"type":"web_search_20250305","name":"web_search"}]}`},
+ {1, `{"model":"m","max_tokens":1,"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"x","content":[]}]}]}`},
+ {1, `{"model":"m","max_tokens":0,"messages":[],"system":[]}`},
+ // Gemini:空 parts / 多态 part / 无 role
+ {2, `{"contents":[{"parts":[]}]}`},
+ {2, `{"contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"","data":""}}]}]}`},
+ {2, `{"contents":[{"role":"model","parts":[{"functionCall":{"name":"","args":null}}]}]}`},
+ {2, `{"contents":[],"tools":[{"googleSearch":{}}],"generationConfig":{"thinkingConfig":{"thinkingBudget":-1}}}`},
+ // Responses:input 字符串形态 / 多态 action / 空 output
+ {3, `{"model":"m","input":"plain string input"}`},
+ {3, `{"model":"m","input":[{"type":"function_call","call_id":"c","name":"f","arguments":""}]}`},
+ {3, `{"model":"m","input":[{"role":"user","content":[]}],"tools":[{"type":"web_search"}]}`},
+ {3, `{"model":"m","input":[],"reasoning":{"effort":"high"},"max_output_tokens":0}`},
+ // 空对象与空数组(最小合法输入)
+ {0, `{}`}, {1, `{}`}, {2, `{}`}, {3, `{}`},
+}
+
+// FuzzRequestConversion 对全部已注册请求转换方向做模糊测试。
+func FuzzRequestConversion(f *testing.F) {
+ addSeedsFromDir(f, filepath.Join("golden", "inputs"), ".json")
+ for _, s := range fuzzAdversarialSeeds {
+ f.Add(s.formatIdx, s.body)
+ }
+
+ f.Fuzz(func(t *testing.T, formatIdx int, body string) {
+ if formatIdx < 0 {
+ return
+ }
+ format := fuzzFormats[formatIdx%len(fuzzFormats)]
+
+ parsed := fuzzParseRequest(format, []byte(body))
+ if parsed == nil {
+ return // 解析失败:宿主在进入转换器前就已拒绝
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ for _, id := range relayconvert.ListRequestConverterIDs() {
+ spec, ok := relayconvert.LookupRequestConverter(id)
+ if !ok || spec.From != format {
+ continue
+ }
+
+ // 不变量 1:不得 panic(panic 会直接冒泡为测试失败并记录输入)
+ converted, err := relayconvert.ConvertRequestByID(ctx, capMeta(), id, parsed)
+ if err != nil {
+ continue // 返回错误是合法行为(宿主按 hard-fail 拒绝请求)
+ }
+ if converted == nil {
+ t.Fatalf("转换器 %s 返回了 (nil, nil):宿主会对 nil 做 marshal 并发出 \"null\" 请求体", id)
+ }
+
+ // 不变量 2:成功的结果必须可序列化(宿主紧接着 json.Marshal 发往上游)
+ if _, err := json.Marshal(converted); err != nil {
+ t.Fatalf("转换器 %s 产出了不可序列化的结果(宿主 marshal 将 hard-fail 该请求): %v", id, err)
+ }
+ }
+ })
+}
+
+// FuzzResponseConversion 对全部已注册响应转换方向做模糊测试
+// (输入为上游响应体,由上游供应商控制——网关不能假设其良构)。
+func FuzzResponseConversion(f *testing.F) {
+ // 上游格式集合比入站多一个 Ollama
+ upstreamFormats := append(append([]types.RelayFormat{}, fuzzFormats...), types.RelayFormatOllama)
+
+ seeds := []struct {
+ idx int
+ body string
+ }{
+ {0, `{"id":"c","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`},
+ {0, `{"choices":[]}`},
+ {0, `{"choices":[{"index":0,"message":{"role":"assistant","content":null,"tool_calls":[{"id":"","type":"function","function":{"name":"","arguments":"{bad"}}]}}]}`},
+ {1, `{"id":"m","type":"message","role":"assistant","content":[{"type":"text","text":"hi"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`},
+ {1, `{"type":"message","content":[]}`},
+ {1, `{"type":"message","content":[{"type":"tool_use","id":"","name":"","input":null}]}`},
+ {2, `{"candidates":[{"content":{"role":"model","parts":[{"text":"hi"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2}}`},
+ {2, `{"candidates":[]}`},
+ {2, `{"candidates":[{"content":{"parts":[{"functionCall":{"name":"f"}}]},"finishReason":"SAFETY"}]}`},
+ {2, `{"promptFeedback":{"blockReason":"SAFETY"}}`},
+ {3, `{"id":"r","object":"response","status":"completed","output":[{"type":"message","id":"m","role":"assistant","content":[{"type":"output_text","text":"hi"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`},
+ {3, `{"status":"failed","error":{"code":"e","message":"m"},"output":[]}`},
+ {4, `{"model":"m","message":{"role":"assistant","content":"hi"},"done":true,"prompt_eval_count":1,"eval_count":1}`},
+ {4, `{"done":false}`},
+ {0, `{}`}, {1, `{}`}, {2, `{}`}, {3, `{}`}, {4, `{}`},
+ }
+ for _, s := range seeds {
+ f.Add(s.idx, s.body)
+ }
+
+ f.Fuzz(func(t *testing.T, formatIdx int, body string) {
+ if formatIdx < 0 {
+ return
+ }
+ upstream := upstreamFormats[formatIdx%len(upstreamFormats)]
+
+ parsed := fuzzParseResponse(upstream, []byte(body))
+ if parsed == nil {
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ for _, id := range relayconvert.ListResponseConverterIDs() {
+ spec, ok := relayconvert.LookupResponseConverter(id)
+ if !ok || spec.Convert == nil || spec.To != upstream {
+ continue
+ }
+
+ converted, _, err := spec.Convert(ctx, capMeta(), parsed)
+ if err != nil {
+ continue
+ }
+ if converted == nil {
+ t.Fatalf("响应转换器 %s 返回了 (nil, nil)", id)
+ }
+ if _, err := json.Marshal(converted); err != nil {
+ t.Fatalf("响应转换器 %s 产出了不可序列化的结果: %v", id, err)
+ }
+ }
+ })
+}
+
+// fuzzParseResponse 按上游格式解析响应体(解析失败返回 nil)。
+func fuzzParseResponse(format types.RelayFormat, body []byte) any {
+ switch format {
+ case types.RelayFormatOpenAI:
+ var r dto.ChatCompletionResponse
+ if json.Unmarshal(body, &r) != nil {
+ return nil
+ }
+ return &r
+ case types.RelayFormatClaude:
+ var r dto.ClaudeResponse
+ if json.Unmarshal(body, &r) != nil {
+ return nil
+ }
+ return &r
+ case types.RelayFormatGemini:
+ var r dto.GeminiChatResponse
+ if json.Unmarshal(body, &r) != nil {
+ return nil
+ }
+ return &r
+ case types.RelayFormatOpenAIResponses:
+ var r dto.OpenAIResponsesResponse
+ if json.Unmarshal(body, &r) != nil {
+ return nil
+ }
+ return &r
+ case types.RelayFormatOllama:
+ var r dto.OllamaChatResponse
+ if json.Unmarshal(body, &r) != nil {
+ return nil
+ }
+ return &r
+ }
+ return nil
+}
+
+// FuzzStreamConversion 对全部已注册流式方向做模糊测试。
+// 输入为上游 SSE / NDJSON 原始字节——由上游供应商控制,且流式解析器自己做分帧,
+// 是「畸形输入」暴露面最大的一段(半个事件、超长行、嵌套引号、截断 JSON)。
+func FuzzStreamConversion(f *testing.F) {
+ streamFormats := []types.RelayFormat{
+ types.RelayFormatOpenAI,
+ types.RelayFormatClaude,
+ types.RelayFormatGemini,
+ types.RelayFormatOpenAIResponses,
+ types.RelayFormatOllama,
+ }
+
+ // 种子:金样本流式语料(文件名前缀即上游格式)
+ entries, _ := os.ReadDir(filepath.Join("golden", "stream_inputs"))
+ for _, e := range entries {
+ if !strings.HasSuffix(e.Name(), ".txt") {
+ continue
+ }
+ data, err := os.ReadFile(filepath.Join("golden", "stream_inputs", e.Name()))
+ if err != nil {
+ continue
+ }
+ formatName := strings.SplitN(strings.TrimSuffix(e.Name(), ".txt"), "__", 2)[0]
+ for i, format := range streamFormats {
+ if string(format) == formatName {
+ f.Add(i, string(data))
+ break
+ }
+ }
+ }
+
+ // 对抗种子:畸形分帧
+ for _, s := range []struct {
+ idx int
+ body string
+ }{
+ {0, "data: \n\n"},
+ {0, "data: {\n\n"},
+ {0, "data: [DONE]\n\n"},
+ {0, "data: null\n\n"},
+ {0, "garbage without prefix\n\n"},
+ {0, "data: {\"choices\":[]}\n\ndata: [DONE]\n\n"},
+ {1, "event: message_start\ndata: {}\n\n"},
+ {1, "event: error\ndata: {\"type\":\"error\",\"error\":{}}\n\n"},
+ {1, "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":99,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\"}}\n\n"},
+ {1, "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"},
+ {2, "data: {\"candidates\":null}\n\n"},
+ {2, "data: {\"candidates\":[{\"content\":{\"parts\":null}}]}\n\n"},
+ {3, "event: response.completed\ndata: {\"type\":\"response.completed\"}\n\n"},
+ {3, "event: response.failed\ndata: {\"type\":\"response.failed\",\"response\":{}}\n\n"},
+ {4, "{\"done\":true}\n"},
+ {4, "not json\n{\"done\":true}\n"},
+ {0, ""},
+ } {
+ f.Add(s.idx, s.body)
+ }
+
+ f.Fuzz(func(t *testing.T, formatIdx int, data string) {
+ if formatIdx < 0 {
+ return
+ }
+ upstream := streamFormats[formatIdx%len(streamFormats)]
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ for _, spec := range relayconvert.ListStreamConverterSpecs() {
+ if spec.From != upstream {
+ continue
+ }
+ fn, ok := relayconvert.LookupStreamConverterByID(spec.ID)
+ if !ok {
+ continue
+ }
+
+ // 不变量:不 panic;每个写出的 chunk 必须可序列化
+ //(宿主经 WriteSSEDataJSON/WriteSSEEventJSON 序列化,失败即该帧被丢弃,
+ // 客户端收到不完整的流却看不到任何错误)
+ _ = fn(ctx, capMeta(), strings.NewReader(data), func(chunk any) error {
+ if chunk == nil {
+ return nil
+ }
+ payload := chunk
+ if ev, isEvent := chunk.(*relayconvert.StreamEvent); isEvent {
+ if ev == nil || ev.Data == nil {
+ return nil
+ }
+ payload = ev.Data
+ }
+ if _, err := json.Marshal(payload); err != nil {
+ t.Fatalf("流式转换器 %s 产出了不可序列化的 chunk(宿主会静默丢帧): %v", spec.ID, err)
+ }
+ return nil
+ })
+ }
+ })
+}
diff --git a/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/basic.request.json b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/basic.request.json
new file mode 100644
index 0000000..061ffeb
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/basic.request.json
@@ -0,0 +1,15 @@
+{
+ "max_tokens": 256,
+ "messages": [
+ {
+ "content": "You are helpful.",
+ "role": "system"
+ },
+ {
+ "content": "Hello, how are you?",
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model",
+ "temperature": 0.7
+}
diff --git a/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/full.request.json b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/full.request.json
new file mode 100644
index 0000000..4ab320f
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/full.request.json
@@ -0,0 +1,95 @@
+{
+ "max_tokens": 1024,
+ "messages": [
+ {
+ "content": "You are a weather and finance assistant.",
+ "role": "system"
+ },
+ {
+ "content": [
+ {
+ "text": "Describe this image and check the weather in Boston.",
+ "type": "text"
+ },
+ {
+ "image_url": {
+ "detail": "auto",
+ "url": "data:image/jpeg;base64,/9j/4AAQSkZJRg=="
+ },
+ "type": "image_url"
+ }
+ ],
+ "role": "user"
+ },
+ {
+ "content": "checking weather",
+ "role": "assistant",
+ "tool_calls": [
+ {
+ "function": {
+ "arguments": "{\"location\":\"Boston\"}",
+ "name": "get_weather"
+ },
+ "id": "toolu_1",
+ "type": "function"
+ }
+ ]
+ },
+ {
+ "content": "68F, sunny",
+ "role": "tool",
+ "tool_call_id": "toolu_1"
+ },
+ {
+ "content": "Thanks! What about the stock price of ACME?",
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model",
+ "stop": [
+ "END_TOKEN"
+ ],
+ "stream": true,
+ "temperature": 0.7,
+ "tool_choice": {
+ "function": {
+ "name": "get_weather"
+ },
+ "type": "function"
+ },
+ "tools": [
+ {
+ "function": {
+ "description": "get weather",
+ "name": "get_weather",
+ "parameters": {
+ "properties": {
+ "location": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "location"
+ ],
+ "type": "object"
+ }
+ },
+ "type": "function"
+ },
+ {
+ "function": {
+ "description": "stock quote",
+ "name": "lookup_stock",
+ "parameters": {
+ "properties": {
+ "symbol": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ },
+ "type": "function"
+ }
+ ]
+}
diff --git a/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/full.response.json b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/full.response.json
new file mode 100644
index 0000000..dacca56
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/full.response.json
@@ -0,0 +1,31 @@
+{
+ "content": [
+ {
+ "thinking": "CAP_MARKER_THINKING user asks weather",
+ "type": "thinking"
+ },
+ {
+ "text": "CAP_MARKER_ASSISTANT_TEXT the weather is 68F",
+ "type": "text"
+ },
+ {
+ "id": "\u003cscrubbed\u003e",
+ "input": {
+ "location": "Boston"
+ },
+ "name": "get_weather",
+ "type": "tool_use"
+ }
+ ],
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "role": "assistant",
+ "stop_reason": "tool_use",
+ "type": "message",
+ "usage": {
+ "cache_creation_input_tokens": 0,
+ "cache_read_input_tokens": 0,
+ "input_tokens": 100,
+ "output_tokens": 50
+ }
+}
diff --git a/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/schema-variants.request.json b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/schema-variants.request.json
new file mode 100644
index 0000000..a2f28ab
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/schema-variants.request.json
@@ -0,0 +1,71 @@
+{
+ "max_tokens": 512,
+ "messages": [
+ {
+ "content": "Search the repo with the where filter.",
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model",
+ "tools": [
+ {
+ "function": {
+ "description": "search repo",
+ "name": "grep",
+ "parameters": {
+ "properties": {
+ "query": {
+ "properties": {
+ "paths": {
+ "type": "array"
+ },
+ "pattern": {
+ "type": "string"
+ },
+ "tag": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "number"
+ }
+ ]
+ },
+ "where": {
+ "items": {
+ "prefixItems": [
+ {
+ "type": "string"
+ },
+ {
+ "enum": [
+ "AND",
+ "OR"
+ ],
+ "type": "string"
+ },
+ {}
+ ],
+ "type": "array"
+ },
+ "maxItems": 10,
+ "type": "array"
+ }
+ },
+ "required": [
+ "pattern"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "query"
+ ],
+ "type": "object"
+ }
+ },
+ "type": "function"
+ }
+ ]
+}
diff --git a/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/thinking.request.json b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/thinking.request.json
new file mode 100644
index 0000000..9a5f2b0
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/thinking.request.json
@@ -0,0 +1,11 @@
+{
+ "max_tokens": 4096,
+ "messages": [
+ {
+ "content": "Prove that sqrt(2) is irrational.",
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model",
+ "reasoning_effort": "low"
+}
diff --git a/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/websearch.request.json b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/websearch.request.json
new file mode 100644
index 0000000..5c41e04
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/websearch.request.json
@@ -0,0 +1,11 @@
+{
+ "max_tokens": 1024,
+ "messages": [
+ {
+ "content": "Search the latest AI news.",
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model",
+ "web_search_options": {}
+}
diff --git a/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions_stream/basic.stream.json b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions_stream/basic.stream.json
new file mode 100644
index 0000000..c03d97a
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions_stream/basic.stream.json
@@ -0,0 +1,85 @@
+[
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": "",
+ "role": "assistant"
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": "Hello",
+ "role": ""
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": ", world!",
+ "role": ""
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": null,
+ "role": ""
+ },
+ "finish_reason": "stop",
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": {
+ "completion_tokens": 7,
+ "prompt_tokens": 16,
+ "prompt_tokens_details": {
+ "cached_tokens": 4
+ },
+ "total_tokens": 23
+ }
+ }
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions_stream/thinking.stream.json b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions_stream/thinking.stream.json
new file mode 100644
index 0000000..208cff3
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions_stream/thinking.stream.json
@@ -0,0 +1,83 @@
+[
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": "",
+ "role": "assistant"
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": null,
+ "reasoning_content": "Let me think...",
+ "role": ""
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": "The answer is 42.",
+ "role": ""
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": null,
+ "role": ""
+ },
+ "finish_reason": "stop",
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": {
+ "completion_tokens": 50,
+ "prompt_tokens": 10,
+ "total_tokens": 60
+ }
+ }
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions_stream/tools.stream.json b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions_stream/tools.stream.json
new file mode 100644
index 0000000..8406516
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions_stream/tools.stream.json
@@ -0,0 +1,150 @@
+[
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": "",
+ "role": "assistant"
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": "Let me check the weather.",
+ "role": ""
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": null,
+ "role": "",
+ "tool_calls": [
+ {
+ "function": {
+ "arguments": "",
+ "name": "get_weather"
+ },
+ "id": "\u003cscrubbed\u003e",
+ "type": "function"
+ }
+ ]
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": null,
+ "role": "",
+ "tool_calls": [
+ {
+ "function": {
+ "arguments": "{\"city\":",
+ "name": ""
+ },
+ "id": "\u003cscrubbed\u003e",
+ "type": ""
+ }
+ ]
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": null,
+ "role": "",
+ "tool_calls": [
+ {
+ "function": {
+ "arguments": "\"Shanghai\"}",
+ "name": ""
+ },
+ "id": "\u003cscrubbed\u003e",
+ "type": ""
+ }
+ ]
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": null,
+ "role": ""
+ },
+ "finish_reason": "tool_calls",
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": {
+ "completion_tokens": 9,
+ "prompt_tokens": 20,
+ "total_tokens": 29
+ }
+ }
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_chat_stream_resp/basic.stream.json b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_chat_stream_resp/basic.stream.json
new file mode 100644
index 0000000..203dae7
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_chat_stream_resp/basic.stream.json
@@ -0,0 +1,63 @@
+[
+ {
+ "data": {
+ "candidates": [
+ {
+ "content": {
+ "parts": [
+ {
+ "text": "Hello"
+ }
+ ],
+ "role": "model"
+ }
+ }
+ ],
+ "modelVersion": "origin-model"
+ }
+ },
+ {
+ "data": {
+ "candidates": [
+ {
+ "content": {
+ "parts": [
+ {
+ "text": ", world!"
+ }
+ ],
+ "role": "model"
+ }
+ }
+ ],
+ "modelVersion": "origin-model"
+ }
+ },
+ {
+ "data": {
+ "candidates": [
+ {
+ "content": {
+ "role": "model"
+ },
+ "finishReason": "STOP"
+ }
+ ],
+ "modelVersion": "origin-model",
+ "usageMetadata": {
+ "cachedContentTokenCount": 4,
+ "candidatesTokenCount": 7,
+ "promptTokenCount": 16,
+ "totalTokenCount": 23
+ }
+ },
+ "usage": {
+ "completion_tokens": 7,
+ "prompt_tokens": 12,
+ "prompt_tokens_details": {
+ "cached_tokens": 4
+ },
+ "total_tokens": 19
+ }
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_chat_stream_resp/thinking.stream.json b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_chat_stream_resp/thinking.stream.json
new file mode 100644
index 0000000..1f6fb95
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_chat_stream_resp/thinking.stream.json
@@ -0,0 +1,79 @@
+[
+ {
+ "data": {
+ "candidates": [
+ {
+ "content": {
+ "parts": [
+ {
+ "text": "Let me think...",
+ "thought": true
+ }
+ ],
+ "role": "model"
+ }
+ }
+ ],
+ "modelVersion": "origin-model"
+ }
+ },
+ {
+ "data": {
+ "candidates": [
+ {
+ "content": {
+ "parts": [
+ {
+ "thought": true,
+ "thoughtSignature": "sig_abc123"
+ }
+ ],
+ "role": "model"
+ }
+ }
+ ],
+ "modelVersion": "origin-model"
+ }
+ },
+ {
+ "data": {
+ "candidates": [
+ {
+ "content": {
+ "parts": [
+ {
+ "text": "The answer is 42."
+ }
+ ],
+ "role": "model"
+ }
+ }
+ ],
+ "modelVersion": "origin-model"
+ }
+ },
+ {
+ "data": {
+ "candidates": [
+ {
+ "content": {
+ "role": "model"
+ },
+ "finishReason": "STOP"
+ }
+ ],
+ "modelVersion": "origin-model",
+ "usageMetadata": {
+ "candidatesTokenCount": 50,
+ "promptTokenCount": 10,
+ "totalTokenCount": 60
+ }
+ },
+ "usage": {
+ "completion_tokens": 50,
+ "prompt_tokens": 10,
+ "prompt_tokens_details": {},
+ "total_tokens": 60
+ }
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_chat_stream_resp/tools.stream.json b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_chat_stream_resp/tools.stream.json
new file mode 100644
index 0000000..43a692f
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_chat_stream_resp/tools.stream.json
@@ -0,0 +1,66 @@
+[
+ {
+ "data": {
+ "candidates": [
+ {
+ "content": {
+ "parts": [
+ {
+ "text": "Let me check the weather."
+ }
+ ],
+ "role": "model"
+ }
+ }
+ ],
+ "modelVersion": "origin-model"
+ }
+ },
+ {
+ "data": {
+ "candidates": [
+ {
+ "content": {
+ "parts": [
+ {
+ "functionCall": {
+ "args": {
+ "city": "Shanghai"
+ },
+ "id": "\u003cscrubbed\u003e",
+ "name": "get_weather"
+ }
+ }
+ ],
+ "role": "model"
+ }
+ }
+ ],
+ "modelVersion": "origin-model"
+ }
+ },
+ {
+ "data": {
+ "candidates": [
+ {
+ "content": {
+ "role": "model"
+ },
+ "finishReason": "STOP"
+ }
+ ],
+ "modelVersion": "origin-model",
+ "usageMetadata": {
+ "candidatesTokenCount": 9,
+ "promptTokenCount": 20,
+ "totalTokenCount": 29
+ }
+ },
+ "usage": {
+ "completion_tokens": 9,
+ "prompt_tokens": 20,
+ "prompt_tokens_details": {},
+ "total_tokens": 29
+ }
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/basic.request.json b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/basic.request.json
new file mode 100644
index 0000000..04405eb
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/basic.request.json
@@ -0,0 +1,42 @@
+{
+ "contents": [
+ {
+ "parts": [
+ {
+ "text": "Hello, how are you?"
+ }
+ ],
+ "role": "user"
+ }
+ ],
+ "generationConfig": {
+ "maxOutputTokens": 256,
+ "temperature": 0.7
+ },
+ "safetySettings": [
+ {
+ "category": "HARM_CATEGORY_HARASSMENT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_HATE_SPEECH",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ }
+ ],
+ "systemInstruction": {
+ "parts": [
+ {
+ "text": "You are helpful."
+ }
+ ],
+ "role": ""
+ }
+}
diff --git a/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/full.request.json b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/full.request.json
new file mode 100644
index 0000000..cbc277e
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/full.request.json
@@ -0,0 +1,131 @@
+{
+ "contents": [
+ {
+ "parts": [
+ {
+ "text": "Describe this image and check the weather in Boston."
+ },
+ {
+ "inlineData": {
+ "data": "/9j/4AAQSkZJRg==",
+ "mimeType": "image/jpeg"
+ }
+ }
+ ],
+ "role": "user"
+ },
+ {
+ "parts": [
+ {
+ "text": "checking weather"
+ },
+ {
+ "functionCall": {
+ "args": {
+ "location": "Boston"
+ },
+ "id": "toolu_1",
+ "name": "get_weather"
+ }
+ }
+ ],
+ "role": "model"
+ },
+ {
+ "parts": [
+ {
+ "functionResponse": {
+ "id": "toolu_1",
+ "name": "get_weather",
+ "response": {
+ "result": "68F, sunny"
+ }
+ }
+ }
+ ],
+ "role": "user"
+ },
+ {
+ "parts": [
+ {
+ "text": "Thanks! What about the stock price of ACME?"
+ }
+ ],
+ "role": "user"
+ }
+ ],
+ "generationConfig": {
+ "maxOutputTokens": 1024,
+ "stopSequences": [
+ "END_TOKEN"
+ ],
+ "temperature": 0.7
+ },
+ "safetySettings": [
+ {
+ "category": "HARM_CATEGORY_HARASSMENT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_HATE_SPEECH",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ }
+ ],
+ "systemInstruction": {
+ "parts": [
+ {
+ "text": "You are a weather and finance assistant."
+ }
+ ],
+ "role": ""
+ },
+ "toolConfig": {
+ "functionCallingConfig": {
+ "allowedFunctionNames": [
+ "get_weather"
+ ],
+ "mode": "ANY"
+ }
+ },
+ "tools": [
+ {
+ "functionDeclarations": [
+ {
+ "description": "get weather",
+ "name": "get_weather",
+ "parameters": {
+ "properties": {
+ "location": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "location"
+ ],
+ "type": "object"
+ }
+ },
+ {
+ "description": "stock quote",
+ "name": "lookup_stock",
+ "parameters": {
+ "properties": {
+ "symbol": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ }
+ ]
+ }
+ ]
+}
diff --git a/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/full.response.json b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/full.response.json
new file mode 100644
index 0000000..dacca56
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/full.response.json
@@ -0,0 +1,31 @@
+{
+ "content": [
+ {
+ "thinking": "CAP_MARKER_THINKING user asks weather",
+ "type": "thinking"
+ },
+ {
+ "text": "CAP_MARKER_ASSISTANT_TEXT the weather is 68F",
+ "type": "text"
+ },
+ {
+ "id": "\u003cscrubbed\u003e",
+ "input": {
+ "location": "Boston"
+ },
+ "name": "get_weather",
+ "type": "tool_use"
+ }
+ ],
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "role": "assistant",
+ "stop_reason": "tool_use",
+ "type": "message",
+ "usage": {
+ "cache_creation_input_tokens": 0,
+ "cache_read_input_tokens": 0,
+ "input_tokens": 100,
+ "output_tokens": 50
+ }
+}
diff --git a/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/schema-variants.request.json b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/schema-variants.request.json
new file mode 100644
index 0000000..bbd058a
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/schema-variants.request.json
@@ -0,0 +1,102 @@
+{
+ "contents": [
+ {
+ "parts": [
+ {
+ "text": "Search the repo with the where filter."
+ }
+ ],
+ "role": "user"
+ }
+ ],
+ "generationConfig": {
+ "maxOutputTokens": 512
+ },
+ "safetySettings": [
+ {
+ "category": "HARM_CATEGORY_HARASSMENT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_HATE_SPEECH",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ }
+ ],
+ "tools": [
+ {
+ "functionDeclarations": [
+ {
+ "description": "search repo",
+ "name": "grep",
+ "parameters": {
+ "properties": {
+ "query": {
+ "properties": {
+ "paths": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "pattern": {
+ "type": "string"
+ },
+ "tag": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "number"
+ }
+ ]
+ },
+ "where": {
+ "items": {
+ "items": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "enum": [
+ "AND",
+ "OR"
+ ],
+ "type": "string"
+ },
+ {
+ "type": "string"
+ }
+ ]
+ },
+ "type": "array"
+ },
+ "maxItems": 10,
+ "type": "array"
+ }
+ },
+ "required": [
+ "pattern"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "query"
+ ],
+ "type": "object"
+ }
+ }
+ ]
+ }
+ ]
+}
diff --git a/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/thinking.request.json b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/thinking.request.json
new file mode 100644
index 0000000..35fd191
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/thinking.request.json
@@ -0,0 +1,37 @@
+{
+ "contents": [
+ {
+ "parts": [
+ {
+ "text": "Prove that sqrt(2) is irrational."
+ }
+ ],
+ "role": "user"
+ }
+ ],
+ "generationConfig": {
+ "maxOutputTokens": 4096,
+ "thinkingConfig": {
+ "includeThoughts": true,
+ "thinkingBudget": 2048
+ }
+ },
+ "safetySettings": [
+ {
+ "category": "HARM_CATEGORY_HARASSMENT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_HATE_SPEECH",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ }
+ ]
+}
diff --git a/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/websearch.request.json b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/websearch.request.json
new file mode 100644
index 0000000..39692f3
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/websearch.request.json
@@ -0,0 +1,38 @@
+{
+ "contents": [
+ {
+ "parts": [
+ {
+ "text": "Search the latest AI news."
+ }
+ ],
+ "role": "user"
+ }
+ ],
+ "generationConfig": {
+ "maxOutputTokens": 1024
+ },
+ "safetySettings": [
+ {
+ "category": "HARM_CATEGORY_HARASSMENT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_HATE_SPEECH",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ }
+ ],
+ "tools": [
+ {
+ "googleSearch": {}
+ }
+ ]
+}
diff --git a/relaykit/relayconvert/register/golden/expected/claude_messages_to_oai_responses_stream_resp/basic.stream.json b/relaykit/relayconvert/register/golden/expected/claude_messages_to_oai_responses_stream_resp/basic.stream.json
new file mode 100644
index 0000000..c73f728
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/claude_messages_to_oai_responses_stream_resp/basic.stream.json
@@ -0,0 +1,204 @@
+[
+ {
+ "data": {
+ "response": {
+ "created_at": 0,
+ "error": null,
+ "id": "\u003cscrubbed\u003e",
+ "incomplete_details": null,
+ "instructions": null,
+ "max_output_tokens": null,
+ "metadata": {},
+ "model": "origin-model",
+ "object": "response",
+ "output": [],
+ "parallel_tool_calls": true,
+ "previous_response_id": null,
+ "reasoning": {
+ "effort": null,
+ "summary": null
+ },
+ "status": "in_progress",
+ "store": false,
+ "temperature": 1,
+ "text": {
+ "format": {
+ "type": "text"
+ }
+ },
+ "tool_choice": "auto",
+ "tools": [],
+ "top_p": 1,
+ "truncation": "disabled",
+ "user": null
+ },
+ "type": "response.created"
+ },
+ "event": "response.created"
+ },
+ {
+ "data": {
+ "item": {
+ "content": [],
+ "id": "\u003cscrubbed\u003e",
+ "role": "assistant",
+ "status": "in_progress",
+ "type": "message"
+ },
+ "output_index": 0,
+ "type": "response.output_item.added"
+ },
+ "event": "response.output_item.added"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "item_id": "\u003cscrubbed\u003e",
+ "output_index": 0,
+ "part": {
+ "annotations": [],
+ "text": "",
+ "type": "output_text"
+ },
+ "type": "response.content_part.added"
+ },
+ "event": "response.content_part.added"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "delta": "Hello",
+ "item_id": "\u003cscrubbed\u003e",
+ "output_index": 0,
+ "type": "response.output_text.delta"
+ },
+ "event": "response.output_text.delta"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "delta": ", world!",
+ "item_id": "\u003cscrubbed\u003e",
+ "output_index": 0,
+ "type": "response.output_text.delta"
+ },
+ "event": "response.output_text.delta"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "item_id": "\u003cscrubbed\u003e",
+ "output_index": 0,
+ "text": "Hello, world!",
+ "type": "response.output_text.done"
+ },
+ "event": "response.output_text.done"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "item_id": "\u003cscrubbed\u003e",
+ "output_index": 0,
+ "part": {
+ "annotations": [],
+ "text": "Hello, world!",
+ "type": "output_text"
+ },
+ "type": "response.content_part.done"
+ },
+ "event": "response.content_part.done"
+ },
+ {
+ "data": {
+ "item": {
+ "content": [
+ {
+ "annotations": [],
+ "text": "Hello, world!",
+ "type": "output_text"
+ }
+ ],
+ "id": "\u003cscrubbed\u003e",
+ "role": "assistant",
+ "status": "completed",
+ "type": "message"
+ },
+ "output_index": 0,
+ "type": "response.output_item.done"
+ },
+ "event": "response.output_item.done"
+ },
+ {
+ "data": {
+ "response": {
+ "completed_at": 0,
+ "created_at": 0,
+ "error": null,
+ "id": "\u003cscrubbed\u003e",
+ "incomplete_details": null,
+ "instructions": null,
+ "max_output_tokens": null,
+ "metadata": {},
+ "model": "origin-model",
+ "object": "response",
+ "output": [
+ {
+ "content": [
+ {
+ "annotations": [],
+ "text": "Hello, world!",
+ "type": "output_text"
+ }
+ ],
+ "id": "\u003cscrubbed\u003e",
+ "role": "assistant",
+ "status": "completed",
+ "type": "message"
+ }
+ ],
+ "parallel_tool_calls": true,
+ "previous_response_id": null,
+ "reasoning": {
+ "effort": null,
+ "summary": null
+ },
+ "status": "completed",
+ "store": false,
+ "temperature": 1,
+ "text": {
+ "format": {
+ "type": "text"
+ }
+ },
+ "tool_choice": "auto",
+ "tools": [],
+ "top_p": 1,
+ "truncation": "disabled",
+ "usage": {
+ "input_tokens": 16,
+ "input_tokens_details": {
+ "audio_tokens": 0,
+ "cache_write_tokens": 0,
+ "cached_tokens": 4
+ },
+ "output_tokens": 7,
+ "output_tokens_details": {
+ "reasoning_tokens": 0
+ },
+ "total_tokens": 23
+ },
+ "user": null
+ },
+ "type": "response.completed"
+ },
+ "event": "response.completed",
+ "usage": {
+ "completion_tokens": 7,
+ "prompt_tokens": 12,
+ "prompt_tokens_details": {
+ "cached_tokens": 4
+ },
+ "total_tokens": 19
+ }
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/claude_messages_to_oai_responses_stream_resp/thinking.stream.json b/relaykit/relayconvert/register/golden/expected/claude_messages_to_oai_responses_stream_resp/thinking.stream.json
new file mode 100644
index 0000000..22cfe25
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/claude_messages_to_oai_responses_stream_resp/thinking.stream.json
@@ -0,0 +1,202 @@
+[
+ {
+ "data": {
+ "response": {
+ "created_at": 0,
+ "error": null,
+ "id": "\u003cscrubbed\u003e",
+ "incomplete_details": null,
+ "instructions": null,
+ "max_output_tokens": null,
+ "metadata": {},
+ "model": "origin-model",
+ "object": "response",
+ "output": [],
+ "parallel_tool_calls": true,
+ "previous_response_id": null,
+ "reasoning": {
+ "effort": null,
+ "summary": null
+ },
+ "status": "in_progress",
+ "store": false,
+ "temperature": 1,
+ "text": {
+ "format": {
+ "type": "text"
+ }
+ },
+ "tool_choice": "auto",
+ "tools": [],
+ "top_p": 1,
+ "truncation": "disabled",
+ "user": null
+ },
+ "type": "response.created"
+ },
+ "event": "response.created"
+ },
+ {
+ "data": {
+ "item": {
+ "content": [],
+ "id": "\u003cscrubbed\u003e",
+ "role": "assistant",
+ "status": "in_progress",
+ "type": "message"
+ },
+ "output_index": 0,
+ "type": "response.output_item.added"
+ },
+ "event": "response.output_item.added"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "item_id": "\u003cscrubbed\u003e",
+ "output_index": 0,
+ "part": {
+ "annotations": [],
+ "text": "",
+ "type": "output_text"
+ },
+ "type": "response.content_part.added"
+ },
+ "event": "response.content_part.added"
+ },
+ {
+ "data": {
+ "delta": "Let me think...",
+ "item_id": "\u003cscrubbed\u003e",
+ "output_index": 0,
+ "summary_index": 0,
+ "type": "response.reasoning_summary_text.delta"
+ },
+ "event": "response.reasoning_summary_text.delta"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "delta": "The answer is 42.",
+ "item_id": "\u003cscrubbed\u003e",
+ "output_index": 0,
+ "type": "response.output_text.delta"
+ },
+ "event": "response.output_text.delta"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "item_id": "\u003cscrubbed\u003e",
+ "output_index": 0,
+ "text": "The answer is 42.",
+ "type": "response.output_text.done"
+ },
+ "event": "response.output_text.done"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "item_id": "\u003cscrubbed\u003e",
+ "output_index": 0,
+ "part": {
+ "annotations": [],
+ "text": "The answer is 42.",
+ "type": "output_text"
+ },
+ "type": "response.content_part.done"
+ },
+ "event": "response.content_part.done"
+ },
+ {
+ "data": {
+ "item": {
+ "content": [
+ {
+ "annotations": [],
+ "text": "The answer is 42.",
+ "type": "output_text"
+ }
+ ],
+ "id": "\u003cscrubbed\u003e",
+ "role": "assistant",
+ "status": "completed",
+ "type": "message"
+ },
+ "output_index": 0,
+ "type": "response.output_item.done"
+ },
+ "event": "response.output_item.done"
+ },
+ {
+ "data": {
+ "response": {
+ "completed_at": 0,
+ "created_at": 0,
+ "error": null,
+ "id": "\u003cscrubbed\u003e",
+ "incomplete_details": null,
+ "instructions": null,
+ "max_output_tokens": null,
+ "metadata": {},
+ "model": "origin-model",
+ "object": "response",
+ "output": [
+ {
+ "content": [
+ {
+ "annotations": [],
+ "text": "The answer is 42.",
+ "type": "output_text"
+ }
+ ],
+ "id": "\u003cscrubbed\u003e",
+ "role": "assistant",
+ "status": "completed",
+ "type": "message"
+ }
+ ],
+ "parallel_tool_calls": true,
+ "previous_response_id": null,
+ "reasoning": {
+ "effort": null,
+ "summary": null
+ },
+ "status": "completed",
+ "store": false,
+ "temperature": 1,
+ "text": {
+ "format": {
+ "type": "text"
+ }
+ },
+ "tool_choice": "auto",
+ "tools": [],
+ "top_p": 1,
+ "truncation": "disabled",
+ "usage": {
+ "input_tokens": 10,
+ "input_tokens_details": {
+ "audio_tokens": 0,
+ "cache_write_tokens": 0,
+ "cached_tokens": 0
+ },
+ "output_tokens": 50,
+ "output_tokens_details": {
+ "reasoning_tokens": 0
+ },
+ "total_tokens": 60
+ },
+ "user": null
+ },
+ "type": "response.completed"
+ },
+ "event": "response.completed",
+ "usage": {
+ "completion_tokens": 50,
+ "prompt_tokens": 10,
+ "prompt_tokens_details": {},
+ "total_tokens": 60
+ }
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/claude_messages_to_oai_responses_stream_resp/tools.stream.json b/relaykit/relayconvert/register/golden/expected/claude_messages_to_oai_responses_stream_resp/tools.stream.json
new file mode 100644
index 0000000..0ba3028
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/claude_messages_to_oai_responses_stream_resp/tools.stream.json
@@ -0,0 +1,256 @@
+[
+ {
+ "data": {
+ "response": {
+ "created_at": 0,
+ "error": null,
+ "id": "\u003cscrubbed\u003e",
+ "incomplete_details": null,
+ "instructions": null,
+ "max_output_tokens": null,
+ "metadata": {},
+ "model": "origin-model",
+ "object": "response",
+ "output": [],
+ "parallel_tool_calls": true,
+ "previous_response_id": null,
+ "reasoning": {
+ "effort": null,
+ "summary": null
+ },
+ "status": "in_progress",
+ "store": false,
+ "temperature": 1,
+ "text": {
+ "format": {
+ "type": "text"
+ }
+ },
+ "tool_choice": "auto",
+ "tools": [],
+ "top_p": 1,
+ "truncation": "disabled",
+ "user": null
+ },
+ "type": "response.created"
+ },
+ "event": "response.created"
+ },
+ {
+ "data": {
+ "item": {
+ "content": [],
+ "id": "\u003cscrubbed\u003e",
+ "role": "assistant",
+ "status": "in_progress",
+ "type": "message"
+ },
+ "output_index": 0,
+ "type": "response.output_item.added"
+ },
+ "event": "response.output_item.added"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "item_id": "\u003cscrubbed\u003e",
+ "output_index": 0,
+ "part": {
+ "annotations": [],
+ "text": "",
+ "type": "output_text"
+ },
+ "type": "response.content_part.added"
+ },
+ "event": "response.content_part.added"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "delta": "Let me check the weather.",
+ "item_id": "\u003cscrubbed\u003e",
+ "output_index": 0,
+ "type": "response.output_text.delta"
+ },
+ "event": "response.output_text.delta"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "item_id": "\u003cscrubbed\u003e",
+ "output_index": 0,
+ "text": "Let me check the weather.",
+ "type": "response.output_text.done"
+ },
+ "event": "response.output_text.done"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "item_id": "\u003cscrubbed\u003e",
+ "output_index": 0,
+ "part": {
+ "annotations": [],
+ "text": "Let me check the weather.",
+ "type": "output_text"
+ },
+ "type": "response.content_part.done"
+ },
+ "event": "response.content_part.done"
+ },
+ {
+ "data": {
+ "item": {
+ "content": [
+ {
+ "annotations": [],
+ "text": "Let me check the weather.",
+ "type": "output_text"
+ }
+ ],
+ "id": "\u003cscrubbed\u003e",
+ "role": "assistant",
+ "status": "completed",
+ "type": "message"
+ },
+ "output_index": 0,
+ "type": "response.output_item.done"
+ },
+ "event": "response.output_item.done"
+ },
+ {
+ "data": {
+ "item": {
+ "call_id": "toolu_1",
+ "id": "\u003cscrubbed\u003e",
+ "name": "get_weather",
+ "status": "in_progress",
+ "type": "function_call"
+ },
+ "output_index": 1,
+ "type": "response.output_item.added"
+ },
+ "event": "response.output_item.added"
+ },
+ {
+ "data": {
+ "delta": "{\"city\":",
+ "item_id": "toolu_1",
+ "output_index": 1,
+ "type": "response.function_call_arguments.delta"
+ },
+ "event": "response.function_call_arguments.delta"
+ },
+ {
+ "data": {
+ "delta": "\"Shanghai\"}",
+ "item_id": "toolu_1",
+ "output_index": 1,
+ "type": "response.function_call_arguments.delta"
+ },
+ "event": "response.function_call_arguments.delta"
+ },
+ {
+ "data": {
+ "arguments": "{\"city\":\"Shanghai\"}",
+ "item_id": "toolu_1",
+ "output_index": 1,
+ "type": "response.function_call_arguments.done"
+ },
+ "event": "response.function_call_arguments.done"
+ },
+ {
+ "data": {
+ "item": {
+ "arguments": "{\"city\":\"Shanghai\"}",
+ "call_id": "toolu_1",
+ "id": "\u003cscrubbed\u003e",
+ "name": "get_weather",
+ "status": "completed",
+ "type": "function_call"
+ },
+ "output_index": 1,
+ "type": "response.output_item.done"
+ },
+ "event": "response.output_item.done"
+ },
+ {
+ "data": {
+ "response": {
+ "completed_at": 0,
+ "created_at": 0,
+ "error": null,
+ "id": "\u003cscrubbed\u003e",
+ "incomplete_details": null,
+ "instructions": null,
+ "max_output_tokens": null,
+ "metadata": {},
+ "model": "origin-model",
+ "object": "response",
+ "output": [
+ {
+ "content": [
+ {
+ "annotations": [],
+ "text": "Let me check the weather.",
+ "type": "output_text"
+ }
+ ],
+ "id": "\u003cscrubbed\u003e",
+ "role": "assistant",
+ "status": "completed",
+ "type": "message"
+ },
+ {
+ "arguments": "{\"city\":\"Shanghai\"}",
+ "call_id": "toolu_1",
+ "id": "\u003cscrubbed\u003e",
+ "name": "get_weather",
+ "status": "completed",
+ "type": "function_call"
+ }
+ ],
+ "parallel_tool_calls": true,
+ "previous_response_id": null,
+ "reasoning": {
+ "effort": null,
+ "summary": null
+ },
+ "status": "completed",
+ "store": false,
+ "temperature": 1,
+ "text": {
+ "format": {
+ "type": "text"
+ }
+ },
+ "tool_choice": "auto",
+ "tools": [],
+ "top_p": 1,
+ "truncation": "disabled",
+ "usage": {
+ "input_tokens": 20,
+ "input_tokens_details": {
+ "audio_tokens": 0,
+ "cache_write_tokens": 0,
+ "cached_tokens": 0
+ },
+ "output_tokens": 9,
+ "output_tokens_details": {
+ "reasoning_tokens": 0
+ },
+ "total_tokens": 29
+ },
+ "user": null
+ },
+ "type": "response.completed"
+ },
+ "event": "response.completed",
+ "usage": {
+ "completion_tokens": 9,
+ "prompt_tokens": 20,
+ "prompt_tokens_details": {},
+ "total_tokens": 29
+ }
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/gemini_chat_to_claude_messages_stream_resp/basic.stream.json b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_claude_messages_stream_resp/basic.stream.json
new file mode 100644
index 0000000..8a2741e
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_claude_messages_stream_resp/basic.stream.json
@@ -0,0 +1,93 @@
+[
+ {
+ "data": {
+ "message": {
+ "content": [],
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "role": "assistant",
+ "stop_reason": null,
+ "stop_sequence": null,
+ "type": "message",
+ "usage": {
+ "cache_creation_input_tokens": 0,
+ "cache_read_input_tokens": 0,
+ "input_tokens": 0,
+ "output_tokens": 0
+ }
+ },
+ "type": "message_start"
+ },
+ "event": "message_start"
+ },
+ {
+ "data": {
+ "content_block": {
+ "text": "",
+ "type": "text"
+ },
+ "index": 0,
+ "type": "content_block_start"
+ },
+ "event": "content_block_start"
+ },
+ {
+ "data": {
+ "delta": {
+ "text": "Hello",
+ "type": "text_delta"
+ },
+ "index": 0,
+ "type": "content_block_delta"
+ },
+ "event": "content_block_delta"
+ },
+ {
+ "data": {
+ "delta": {
+ "text": ", world!",
+ "type": "text_delta"
+ },
+ "index": 0,
+ "type": "content_block_delta"
+ },
+ "event": "content_block_delta"
+ },
+ {
+ "data": {
+ "index": 0,
+ "type": "content_block_stop"
+ },
+ "event": "content_block_stop"
+ },
+ {
+ "data": {
+ "delta": {
+ "stop_reason": "end_turn"
+ },
+ "type": "message_delta",
+ "usage": {
+ "cache_creation_input_tokens": 0,
+ "cache_read_input_tokens": 4,
+ "input_tokens": 8,
+ "output_tokens": 7
+ }
+ },
+ "event": "message_delta",
+ "usage": {
+ "completion_tokens": 7,
+ "completion_tokens_details": {},
+ "prompt_tokens": 12,
+ "prompt_tokens_details": {
+ "cached_tokens": 4
+ },
+ "total_tokens": 19
+ }
+ },
+ {
+ "data": {
+ "type": "message_stop"
+ },
+ "event": "message_stop"
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/gemini_chat_to_claude_messages_stream_resp/thinking.stream.json b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_claude_messages_stream_resp/thinking.stream.json
new file mode 100644
index 0000000..930c075
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_claude_messages_stream_resp/thinking.stream.json
@@ -0,0 +1,122 @@
+[
+ {
+ "data": {
+ "message": {
+ "content": [],
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "role": "assistant",
+ "stop_reason": null,
+ "stop_sequence": null,
+ "type": "message",
+ "usage": {
+ "cache_creation_input_tokens": 0,
+ "cache_read_input_tokens": 0,
+ "input_tokens": 0,
+ "output_tokens": 0
+ }
+ },
+ "type": "message_start"
+ },
+ "event": "message_start"
+ },
+ {
+ "data": {
+ "content_block": {
+ "thinking": "",
+ "type": "thinking"
+ },
+ "index": 0,
+ "type": "content_block_start"
+ },
+ "event": "content_block_start"
+ },
+ {
+ "data": {
+ "delta": {
+ "thinking": "Let me think...",
+ "type": "thinking_delta"
+ },
+ "index": 0,
+ "type": "content_block_delta"
+ },
+ "event": "content_block_delta"
+ },
+ {
+ "data": {
+ "delta": {
+ "signature": "sig_abc123",
+ "type": "signature_delta"
+ },
+ "index": 0,
+ "type": "content_block_delta"
+ },
+ "event": "content_block_delta"
+ },
+ {
+ "data": {
+ "index": 0,
+ "type": "content_block_stop"
+ },
+ "event": "content_block_stop"
+ },
+ {
+ "data": {
+ "content_block": {
+ "text": "",
+ "type": "text"
+ },
+ "index": 1,
+ "type": "content_block_start"
+ },
+ "event": "content_block_start"
+ },
+ {
+ "data": {
+ "delta": {
+ "text": "The answer is 42.",
+ "type": "text_delta"
+ },
+ "index": 1,
+ "type": "content_block_delta"
+ },
+ "event": "content_block_delta"
+ },
+ {
+ "data": {
+ "index": 1,
+ "type": "content_block_stop"
+ },
+ "event": "content_block_stop"
+ },
+ {
+ "data": {
+ "delta": {
+ "stop_reason": "end_turn"
+ },
+ "type": "message_delta",
+ "usage": {
+ "cache_creation_input_tokens": 0,
+ "cache_read_input_tokens": 0,
+ "input_tokens": 10,
+ "output_tokens": 80
+ }
+ },
+ "event": "message_delta",
+ "usage": {
+ "completion_tokens": 80,
+ "completion_tokens_details": {
+ "reasoning_tokens": 30
+ },
+ "prompt_tokens": 10,
+ "prompt_tokens_details": {},
+ "total_tokens": 60
+ }
+ },
+ {
+ "data": {
+ "type": "message_stop"
+ },
+ "event": "message_stop"
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/gemini_chat_to_claude_messages_stream_resp/tools.stream.json b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_claude_messages_stream_resp/tools.stream.json
new file mode 100644
index 0000000..7898b29
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_claude_messages_stream_resp/tools.stream.json
@@ -0,0 +1,111 @@
+[
+ {
+ "data": {
+ "message": {
+ "content": [],
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "role": "assistant",
+ "stop_reason": null,
+ "stop_sequence": null,
+ "type": "message",
+ "usage": {
+ "cache_creation_input_tokens": 0,
+ "cache_read_input_tokens": 0,
+ "input_tokens": 0,
+ "output_tokens": 0
+ }
+ },
+ "type": "message_start"
+ },
+ "event": "message_start"
+ },
+ {
+ "data": {
+ "content_block": {
+ "text": "",
+ "type": "text"
+ },
+ "index": 0,
+ "type": "content_block_start"
+ },
+ "event": "content_block_start"
+ },
+ {
+ "data": {
+ "delta": {
+ "text": "Let me check the weather.",
+ "type": "text_delta"
+ },
+ "index": 0,
+ "type": "content_block_delta"
+ },
+ "event": "content_block_delta"
+ },
+ {
+ "data": {
+ "index": 0,
+ "type": "content_block_stop"
+ },
+ "event": "content_block_stop"
+ },
+ {
+ "data": {
+ "content_block": {
+ "id": "\u003cscrubbed\u003e",
+ "input": {},
+ "name": "get_weather",
+ "type": "tool_use"
+ },
+ "index": 1,
+ "type": "content_block_start"
+ },
+ "event": "content_block_start"
+ },
+ {
+ "data": {
+ "delta": {
+ "partial_json": "{\"city\":\"Shanghai\"}",
+ "type": "input_json_delta"
+ },
+ "index": 1,
+ "type": "content_block_delta"
+ },
+ "event": "content_block_delta"
+ },
+ {
+ "data": {
+ "index": 1,
+ "type": "content_block_stop"
+ },
+ "event": "content_block_stop"
+ },
+ {
+ "data": {
+ "delta": {
+ "stop_reason": "tool_use"
+ },
+ "type": "message_delta",
+ "usage": {
+ "cache_creation_input_tokens": 0,
+ "cache_read_input_tokens": 0,
+ "input_tokens": 20,
+ "output_tokens": 9
+ }
+ },
+ "event": "message_delta",
+ "usage": {
+ "completion_tokens": 9,
+ "completion_tokens_details": {},
+ "prompt_tokens": 20,
+ "prompt_tokens_details": {},
+ "total_tokens": 29
+ }
+ },
+ {
+ "data": {
+ "type": "message_stop"
+ },
+ "event": "message_stop"
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_chat_stream_resp/basic.stream.json b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_chat_stream_resp/basic.stream.json
new file mode 100644
index 0000000..fd4c9a3
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_chat_stream_resp/basic.stream.json
@@ -0,0 +1,85 @@
+[
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": "",
+ "role": "assistant"
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": "Hello",
+ "role": ""
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": ", world!",
+ "role": ""
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": null,
+ "role": ""
+ },
+ "finish_reason": "stop",
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": {
+ "completion_tokens": 7,
+ "prompt_tokens": 12,
+ "prompt_tokens_details": {
+ "cached_tokens": 4
+ },
+ "total_tokens": 19
+ }
+ }
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_chat_stream_resp/thinking.stream.json b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_chat_stream_resp/thinking.stream.json
new file mode 100644
index 0000000..929a609
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_chat_stream_resp/thinking.stream.json
@@ -0,0 +1,86 @@
+[
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": "",
+ "role": "assistant"
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": null,
+ "reasoning_content": "Let me think...",
+ "role": ""
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": "The answer is 42.",
+ "role": ""
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": null,
+ "role": ""
+ },
+ "finish_reason": "stop",
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": {
+ "completion_tokens": 80,
+ "completion_tokens_details": {
+ "reasoning_tokens": 30
+ },
+ "prompt_tokens": 10,
+ "total_tokens": 60
+ }
+ }
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_chat_stream_resp/tools.stream.json b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_chat_stream_resp/tools.stream.json
new file mode 100644
index 0000000..104340b
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_chat_stream_resp/tools.stream.json
@@ -0,0 +1,92 @@
+[
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": "",
+ "role": "assistant"
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": "Let me check the weather.",
+ "role": ""
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": null,
+ "role": "",
+ "tool_calls": [
+ {
+ "function": {
+ "arguments": "{\"city\":\"Shanghai\"}",
+ "name": "get_weather"
+ },
+ "id": "\u003cscrubbed\u003e",
+ "type": "function"
+ }
+ ]
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": null,
+ "role": ""
+ },
+ "finish_reason": "stop",
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": {
+ "completion_tokens": 9,
+ "prompt_tokens": 20,
+ "total_tokens": 29
+ }
+ }
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_responses_stream_resp/basic.stream.json b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_responses_stream_resp/basic.stream.json
new file mode 100644
index 0000000..efc6946
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_responses_stream_resp/basic.stream.json
@@ -0,0 +1,208 @@
+[
+ {
+ "data": {
+ "response": {
+ "created_at": 0,
+ "error": null,
+ "id": "\u003cscrubbed\u003e",
+ "incomplete_details": null,
+ "instructions": null,
+ "max_output_tokens": null,
+ "metadata": {},
+ "model": "origin-model",
+ "object": "response",
+ "output": [],
+ "parallel_tool_calls": true,
+ "previous_response_id": null,
+ "reasoning": {
+ "effort": null,
+ "summary": null
+ },
+ "status": "in_progress",
+ "store": false,
+ "temperature": 1,
+ "text": {
+ "format": {
+ "type": "text"
+ }
+ },
+ "tool_choice": "auto",
+ "tools": [],
+ "top_p": 1,
+ "truncation": "disabled",
+ "user": null
+ },
+ "type": "response.created"
+ },
+ "event": "response.created"
+ },
+ {
+ "data": {
+ "item": {
+ "content": [],
+ "id": "\u003cscrubbed\u003e",
+ "role": "assistant",
+ "status": "in_progress",
+ "type": "message"
+ },
+ "output_index": 0,
+ "type": "response.output_item.added"
+ },
+ "event": "response.output_item.added"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "item_id": "\u003cscrubbed\u003e",
+ "output_index": 0,
+ "part": {
+ "annotations": [],
+ "text": "",
+ "type": "output_text"
+ },
+ "type": "response.content_part.added"
+ },
+ "event": "response.content_part.added"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "delta": "Hello",
+ "item_id": "\u003cscrubbed\u003e",
+ "output_index": 0,
+ "type": "response.output_text.delta"
+ },
+ "event": "response.output_text.delta"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "delta": ", world!",
+ "item_id": "\u003cscrubbed\u003e",
+ "output_index": 0,
+ "type": "response.output_text.delta"
+ },
+ "event": "response.output_text.delta"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "item_id": "\u003cscrubbed\u003e",
+ "output_index": 0,
+ "text": "Hello, world!",
+ "type": "response.output_text.done"
+ },
+ "event": "response.output_text.done"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "item_id": "\u003cscrubbed\u003e",
+ "output_index": 0,
+ "part": {
+ "annotations": [],
+ "text": "Hello, world!",
+ "type": "output_text"
+ },
+ "type": "response.content_part.done"
+ },
+ "event": "response.content_part.done"
+ },
+ {
+ "data": {
+ "item": {
+ "content": [
+ {
+ "annotations": [],
+ "text": "Hello, world!",
+ "type": "output_text"
+ }
+ ],
+ "id": "\u003cscrubbed\u003e",
+ "role": "assistant",
+ "status": "completed",
+ "type": "message"
+ },
+ "output_index": 0,
+ "type": "response.output_item.done"
+ },
+ "event": "response.output_item.done"
+ },
+ {
+ "data": {
+ "response": {
+ "completed_at": 0,
+ "created_at": 0,
+ "error": null,
+ "id": "\u003cscrubbed\u003e",
+ "incomplete_details": null,
+ "instructions": null,
+ "max_output_tokens": null,
+ "metadata": {},
+ "model": "origin-model",
+ "object": "response",
+ "output": [
+ {
+ "content": [
+ {
+ "annotations": [],
+ "text": "Hello, world!",
+ "type": "output_text"
+ }
+ ],
+ "id": "\u003cscrubbed\u003e",
+ "role": "assistant",
+ "status": "completed",
+ "type": "message"
+ }
+ ],
+ "parallel_tool_calls": true,
+ "previous_response_id": null,
+ "reasoning": {
+ "effort": null,
+ "summary": null
+ },
+ "status": "completed",
+ "store": false,
+ "temperature": 1,
+ "text": {
+ "format": {
+ "type": "text"
+ }
+ },
+ "tool_choice": "auto",
+ "tools": [],
+ "top_p": 1,
+ "truncation": "disabled",
+ "usage": {
+ "input_tokens": 12,
+ "input_tokens_details": {
+ "audio_tokens": 0,
+ "cache_write_tokens": 0,
+ "cached_tokens": 4
+ },
+ "output_tokens": 7,
+ "output_tokens_details": {
+ "accepted_prediction_tokens": 0,
+ "audio_tokens": 0,
+ "reasoning_tokens": 0,
+ "rejected_prediction_tokens": 0
+ },
+ "total_tokens": 19
+ },
+ "user": null
+ },
+ "type": "response.completed"
+ },
+ "event": "response.completed",
+ "usage": {
+ "completion_tokens": 7,
+ "completion_tokens_details": {},
+ "prompt_tokens": 12,
+ "prompt_tokens_details": {
+ "cached_tokens": 4
+ },
+ "total_tokens": 19
+ }
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_responses_stream_resp/thinking.stream.json b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_responses_stream_resp/thinking.stream.json
new file mode 100644
index 0000000..8ea2260
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_responses_stream_resp/thinking.stream.json
@@ -0,0 +1,208 @@
+[
+ {
+ "data": {
+ "response": {
+ "created_at": 0,
+ "error": null,
+ "id": "\u003cscrubbed\u003e",
+ "incomplete_details": null,
+ "instructions": null,
+ "max_output_tokens": null,
+ "metadata": {},
+ "model": "origin-model",
+ "object": "response",
+ "output": [],
+ "parallel_tool_calls": true,
+ "previous_response_id": null,
+ "reasoning": {
+ "effort": null,
+ "summary": null
+ },
+ "status": "in_progress",
+ "store": false,
+ "temperature": 1,
+ "text": {
+ "format": {
+ "type": "text"
+ }
+ },
+ "tool_choice": "auto",
+ "tools": [],
+ "top_p": 1,
+ "truncation": "disabled",
+ "user": null
+ },
+ "type": "response.created"
+ },
+ "event": "response.created"
+ },
+ {
+ "data": {
+ "item": {
+ "content": [],
+ "id": "\u003cscrubbed\u003e",
+ "role": "assistant",
+ "status": "in_progress",
+ "type": "message"
+ },
+ "output_index": 0,
+ "type": "response.output_item.added"
+ },
+ "event": "response.output_item.added"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "item_id": "\u003cscrubbed\u003e",
+ "output_index": 0,
+ "part": {
+ "annotations": [],
+ "text": "",
+ "type": "output_text"
+ },
+ "type": "response.content_part.added"
+ },
+ "event": "response.content_part.added"
+ },
+ {
+ "data": {
+ "delta": "Let me think...",
+ "item_id": "\u003cscrubbed\u003e",
+ "output_index": 0,
+ "summary_index": 0,
+ "type": "response.reasoning_summary_text.delta"
+ },
+ "event": "response.reasoning_summary_text.delta"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "delta": "The answer is 42.",
+ "item_id": "\u003cscrubbed\u003e",
+ "output_index": 0,
+ "type": "response.output_text.delta"
+ },
+ "event": "response.output_text.delta"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "item_id": "\u003cscrubbed\u003e",
+ "output_index": 0,
+ "text": "The answer is 42.",
+ "type": "response.output_text.done"
+ },
+ "event": "response.output_text.done"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "item_id": "\u003cscrubbed\u003e",
+ "output_index": 0,
+ "part": {
+ "annotations": [],
+ "text": "The answer is 42.",
+ "type": "output_text"
+ },
+ "type": "response.content_part.done"
+ },
+ "event": "response.content_part.done"
+ },
+ {
+ "data": {
+ "item": {
+ "content": [
+ {
+ "annotations": [],
+ "text": "The answer is 42.",
+ "type": "output_text"
+ }
+ ],
+ "id": "\u003cscrubbed\u003e",
+ "role": "assistant",
+ "status": "completed",
+ "type": "message"
+ },
+ "output_index": 0,
+ "type": "response.output_item.done"
+ },
+ "event": "response.output_item.done"
+ },
+ {
+ "data": {
+ "response": {
+ "completed_at": 0,
+ "created_at": 0,
+ "error": null,
+ "id": "\u003cscrubbed\u003e",
+ "incomplete_details": null,
+ "instructions": null,
+ "max_output_tokens": null,
+ "metadata": {},
+ "model": "origin-model",
+ "object": "response",
+ "output": [
+ {
+ "content": [
+ {
+ "annotations": [],
+ "text": "The answer is 42.",
+ "type": "output_text"
+ }
+ ],
+ "id": "\u003cscrubbed\u003e",
+ "role": "assistant",
+ "status": "completed",
+ "type": "message"
+ }
+ ],
+ "parallel_tool_calls": true,
+ "previous_response_id": null,
+ "reasoning": {
+ "effort": null,
+ "summary": null
+ },
+ "status": "completed",
+ "store": false,
+ "temperature": 1,
+ "text": {
+ "format": {
+ "type": "text"
+ }
+ },
+ "tool_choice": "auto",
+ "tools": [],
+ "top_p": 1,
+ "truncation": "disabled",
+ "usage": {
+ "input_tokens": 10,
+ "input_tokens_details": {
+ "audio_tokens": 0,
+ "cache_write_tokens": 0,
+ "cached_tokens": 0
+ },
+ "output_tokens": 80,
+ "output_tokens_details": {
+ "accepted_prediction_tokens": 0,
+ "audio_tokens": 0,
+ "reasoning_tokens": 30,
+ "rejected_prediction_tokens": 0
+ },
+ "total_tokens": 60
+ },
+ "user": null
+ },
+ "type": "response.completed"
+ },
+ "event": "response.completed",
+ "usage": {
+ "completion_tokens": 80,
+ "completion_tokens_details": {
+ "reasoning_tokens": 30
+ },
+ "prompt_tokens": 10,
+ "prompt_tokens_details": {},
+ "total_tokens": 60
+ }
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_responses_stream_resp/tools.stream.json b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_responses_stream_resp/tools.stream.json
new file mode 100644
index 0000000..ad0488e
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_responses_stream_resp/tools.stream.json
@@ -0,0 +1,251 @@
+[
+ {
+ "data": {
+ "response": {
+ "created_at": 0,
+ "error": null,
+ "id": "\u003cscrubbed\u003e",
+ "incomplete_details": null,
+ "instructions": null,
+ "max_output_tokens": null,
+ "metadata": {},
+ "model": "origin-model",
+ "object": "response",
+ "output": [],
+ "parallel_tool_calls": true,
+ "previous_response_id": null,
+ "reasoning": {
+ "effort": null,
+ "summary": null
+ },
+ "status": "in_progress",
+ "store": false,
+ "temperature": 1,
+ "text": {
+ "format": {
+ "type": "text"
+ }
+ },
+ "tool_choice": "auto",
+ "tools": [],
+ "top_p": 1,
+ "truncation": "disabled",
+ "user": null
+ },
+ "type": "response.created"
+ },
+ "event": "response.created"
+ },
+ {
+ "data": {
+ "item": {
+ "content": [],
+ "id": "\u003cscrubbed\u003e",
+ "role": "assistant",
+ "status": "in_progress",
+ "type": "message"
+ },
+ "output_index": 0,
+ "type": "response.output_item.added"
+ },
+ "event": "response.output_item.added"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "item_id": "\u003cscrubbed\u003e",
+ "output_index": 0,
+ "part": {
+ "annotations": [],
+ "text": "",
+ "type": "output_text"
+ },
+ "type": "response.content_part.added"
+ },
+ "event": "response.content_part.added"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "delta": "Let me check the weather.",
+ "item_id": "\u003cscrubbed\u003e",
+ "output_index": 0,
+ "type": "response.output_text.delta"
+ },
+ "event": "response.output_text.delta"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "item_id": "\u003cscrubbed\u003e",
+ "output_index": 0,
+ "text": "Let me check the weather.",
+ "type": "response.output_text.done"
+ },
+ "event": "response.output_text.done"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "item_id": "\u003cscrubbed\u003e",
+ "output_index": 0,
+ "part": {
+ "annotations": [],
+ "text": "Let me check the weather.",
+ "type": "output_text"
+ },
+ "type": "response.content_part.done"
+ },
+ "event": "response.content_part.done"
+ },
+ {
+ "data": {
+ "item": {
+ "content": [
+ {
+ "annotations": [],
+ "text": "Let me check the weather.",
+ "type": "output_text"
+ }
+ ],
+ "id": "\u003cscrubbed\u003e",
+ "role": "assistant",
+ "status": "completed",
+ "type": "message"
+ },
+ "output_index": 0,
+ "type": "response.output_item.done"
+ },
+ "event": "response.output_item.done"
+ },
+ {
+ "data": {
+ "item": {
+ "call_id": "\u003cscrubbed\u003e",
+ "id": "\u003cscrubbed\u003e",
+ "name": "get_weather",
+ "status": "in_progress",
+ "type": "function_call"
+ },
+ "output_index": 1,
+ "type": "response.output_item.added"
+ },
+ "event": "response.output_item.added"
+ },
+ {
+ "data": {
+ "delta": "{\"city\":\"Shanghai\"}",
+ "item_id": "\u003cscrubbed\u003e",
+ "output_index": 1,
+ "type": "response.function_call_arguments.delta"
+ },
+ "event": "response.function_call_arguments.delta"
+ },
+ {
+ "data": {
+ "arguments": "{\"city\":\"Shanghai\"}",
+ "item_id": "\u003cscrubbed\u003e",
+ "output_index": 1,
+ "type": "response.function_call_arguments.done"
+ },
+ "event": "response.function_call_arguments.done"
+ },
+ {
+ "data": {
+ "item": {
+ "arguments": "{\"city\":\"Shanghai\"}",
+ "call_id": "\u003cscrubbed\u003e",
+ "id": "\u003cscrubbed\u003e",
+ "name": "get_weather",
+ "status": "completed",
+ "type": "function_call"
+ },
+ "output_index": 1,
+ "type": "response.output_item.done"
+ },
+ "event": "response.output_item.done"
+ },
+ {
+ "data": {
+ "response": {
+ "completed_at": 0,
+ "created_at": 0,
+ "error": null,
+ "id": "\u003cscrubbed\u003e",
+ "incomplete_details": null,
+ "instructions": null,
+ "max_output_tokens": null,
+ "metadata": {},
+ "model": "origin-model",
+ "object": "response",
+ "output": [
+ {
+ "content": [
+ {
+ "annotations": [],
+ "text": "Let me check the weather.",
+ "type": "output_text"
+ }
+ ],
+ "id": "\u003cscrubbed\u003e",
+ "role": "assistant",
+ "status": "completed",
+ "type": "message"
+ },
+ {
+ "arguments": "{\"city\":\"Shanghai\"}",
+ "call_id": "\u003cscrubbed\u003e",
+ "id": "\u003cscrubbed\u003e",
+ "name": "get_weather",
+ "status": "completed",
+ "type": "function_call"
+ }
+ ],
+ "parallel_tool_calls": true,
+ "previous_response_id": null,
+ "reasoning": {
+ "effort": null,
+ "summary": null
+ },
+ "status": "completed",
+ "store": false,
+ "temperature": 1,
+ "text": {
+ "format": {
+ "type": "text"
+ }
+ },
+ "tool_choice": "auto",
+ "tools": [],
+ "top_p": 1,
+ "truncation": "disabled",
+ "usage": {
+ "input_tokens": 20,
+ "input_tokens_details": {
+ "audio_tokens": 0,
+ "cache_write_tokens": 0,
+ "cached_tokens": 0
+ },
+ "output_tokens": 9,
+ "output_tokens_details": {
+ "accepted_prediction_tokens": 0,
+ "audio_tokens": 0,
+ "reasoning_tokens": 0,
+ "rejected_prediction_tokens": 0
+ },
+ "total_tokens": 29
+ },
+ "user": null
+ },
+ "type": "response.completed"
+ },
+ "event": "response.completed",
+ "usage": {
+ "completion_tokens": 9,
+ "completion_tokens_details": {},
+ "prompt_tokens": 20,
+ "prompt_tokens_details": {},
+ "total_tokens": 29
+ }
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_claude_messages/basic.request.json b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_claude_messages/basic.request.json
new file mode 100644
index 0000000..cdab73b
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_claude_messages/basic.request.json
@@ -0,0 +1,17 @@
+{
+ "max_tokens": 256,
+ "messages": [
+ {
+ "content": [
+ {
+ "text": "Hello, how are you?",
+ "type": "text"
+ }
+ ],
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model",
+ "system": "You are helpful.",
+ "temperature": 0.7
+}
diff --git a/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_claude_messages/full.request.json b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_claude_messages/full.request.json
new file mode 100644
index 0000000..0020d7a
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_claude_messages/full.request.json
@@ -0,0 +1,97 @@
+{
+ "max_tokens": 1024,
+ "messages": [
+ {
+ "content": [
+ {
+ "text": "Describe this image and check the weather in Boston.",
+ "type": "text"
+ },
+ {
+ "source": {
+ "data": "/9j/4AAQSkZJRg==",
+ "media_type": "image/jpeg",
+ "type": "base64"
+ },
+ "type": "image"
+ }
+ ],
+ "role": "user"
+ },
+ {
+ "content": [
+ {
+ "text": "checking weather",
+ "type": "text"
+ },
+ {
+ "id": "call_0",
+ "input": {
+ "location": "Boston"
+ },
+ "name": "get_weather",
+ "type": "tool_use"
+ }
+ ],
+ "role": "assistant"
+ },
+ {
+ "content": [
+ {
+ "content": "{\"result\":\"68F, sunny\"}",
+ "tool_use_id": "call_0",
+ "type": "tool_result"
+ }
+ ],
+ "role": "user"
+ },
+ {
+ "content": [
+ {
+ "text": "Thanks! What about the stock price of ACME?",
+ "type": "text"
+ }
+ ],
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model",
+ "stop_sequences": [
+ "END_TOKEN"
+ ],
+ "system": "You are a weather and finance assistant.",
+ "temperature": 0.7,
+ "tool_choice": {
+ "name": "get_weather",
+ "type": "tool"
+ },
+ "tools": [
+ {
+ "description": "get weather",
+ "input_schema": {
+ "properties": {
+ "location": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "location"
+ ],
+ "type": "object"
+ },
+ "name": "get_weather"
+ },
+ {
+ "description": "stock quote",
+ "input_schema": {
+ "properties": {
+ "symbol": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "name": "lookup_stock"
+ }
+ ]
+}
diff --git a/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_claude_messages/full.response.json b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_claude_messages/full.response.json
new file mode 100644
index 0000000..eb88e2a
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_claude_messages/full.response.json
@@ -0,0 +1,35 @@
+{
+ "candidates": [
+ {
+ "content": {
+ "parts": [
+ {
+ "text": "CAP_MARKER_THINKING user asks weather",
+ "thought": true,
+ "thoughtSignature": "sig_abc"
+ },
+ {
+ "text": "CAP_MARKER_ASSISTANT_TEXT the weather is 68F"
+ },
+ {
+ "functionCall": {
+ "args": {
+ "location": "Boston"
+ },
+ "id": "\u003cscrubbed\u003e",
+ "name": "get_weather"
+ }
+ }
+ ],
+ "role": "model"
+ },
+ "finishReason": "STOP"
+ }
+ ],
+ "modelVersion": "origin-model",
+ "usageMetadata": {
+ "candidatesTokenCount": 50,
+ "promptTokenCount": 100,
+ "totalTokenCount": 150
+ }
+}
diff --git a/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_claude_messages/thinking.request.json b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_claude_messages/thinking.request.json
new file mode 100644
index 0000000..09ded16
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_claude_messages/thinking.request.json
@@ -0,0 +1,15 @@
+{
+ "max_tokens": 4096,
+ "messages": [
+ {
+ "content": [
+ {
+ "text": "Prove that sqrt(2) is irrational.",
+ "type": "text"
+ }
+ ],
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model"
+}
diff --git a/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_claude_messages/websearch.request.json b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_claude_messages/websearch.request.json
new file mode 100644
index 0000000..474160f
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_claude_messages/websearch.request.json
@@ -0,0 +1,21 @@
+{
+ "max_tokens": 1024,
+ "messages": [
+ {
+ "content": [
+ {
+ "text": "Search the latest AI news.",
+ "type": "text"
+ }
+ ],
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model",
+ "tools": [
+ {
+ "name": "web_search",
+ "type": "web_search_20250305"
+ }
+ ]
+}
diff --git a/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_openai_chat_completions/basic.request.json b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_openai_chat_completions/basic.request.json
new file mode 100644
index 0000000..061ffeb
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_openai_chat_completions/basic.request.json
@@ -0,0 +1,15 @@
+{
+ "max_tokens": 256,
+ "messages": [
+ {
+ "content": "You are helpful.",
+ "role": "system"
+ },
+ {
+ "content": "Hello, how are you?",
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model",
+ "temperature": 0.7
+}
diff --git a/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_openai_chat_completions/full.request.json b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_openai_chat_completions/full.request.json
new file mode 100644
index 0000000..741fa8c
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_openai_chat_completions/full.request.json
@@ -0,0 +1,94 @@
+{
+ "max_tokens": 1024,
+ "messages": [
+ {
+ "content": "You are a weather and finance assistant.",
+ "role": "system"
+ },
+ {
+ "content": [
+ {
+ "text": "Describe this image and check the weather in Boston.",
+ "type": "text"
+ },
+ {
+ "image_url": {
+ "detail": "auto",
+ "url": "data:image/jpeg;base64,/9j/4AAQSkZJRg=="
+ },
+ "type": "image_url"
+ }
+ ],
+ "role": "user"
+ },
+ {
+ "content": "checking weather",
+ "role": "assistant",
+ "tool_calls": [
+ {
+ "function": {
+ "arguments": "{\"location\":\"Boston\"}",
+ "name": "get_weather"
+ },
+ "id": "call_0",
+ "type": "function"
+ }
+ ]
+ },
+ {
+ "content": "{\"result\":\"68F, sunny\"}",
+ "role": "tool",
+ "tool_call_id": "call_0"
+ },
+ {
+ "content": "Thanks! What about the stock price of ACME?",
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model",
+ "stop": [
+ "END_TOKEN"
+ ],
+ "temperature": 0.7,
+ "tool_choice": {
+ "function": {
+ "name": "get_weather"
+ },
+ "type": "function"
+ },
+ "tools": [
+ {
+ "function": {
+ "description": "get weather",
+ "name": "get_weather",
+ "parameters": {
+ "properties": {
+ "location": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "location"
+ ],
+ "type": "object"
+ }
+ },
+ "type": "function"
+ },
+ {
+ "function": {
+ "description": "stock quote",
+ "name": "lookup_stock",
+ "parameters": {
+ "properties": {
+ "symbol": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ },
+ "type": "function"
+ }
+ ]
+}
diff --git a/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_openai_chat_completions/full.response.json b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_openai_chat_completions/full.response.json
new file mode 100644
index 0000000..de3584a
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_openai_chat_completions/full.response.json
@@ -0,0 +1,33 @@
+{
+ "candidates": [
+ {
+ "content": {
+ "parts": [
+ {
+ "text": "CAP_MARKER_THINKING user asks weather",
+ "thought": true
+ },
+ {
+ "text": "CAP_MARKER_ASSISTANT_TEXT the weather is 68F"
+ },
+ {
+ "functionCall": {
+ "args": {
+ "location": "Boston"
+ },
+ "id": "\u003cscrubbed\u003e",
+ "name": "get_weather"
+ }
+ }
+ ],
+ "role": "model"
+ },
+ "finishReason": "STOP"
+ }
+ ],
+ "usageMetadata": {
+ "candidatesTokenCount": 50,
+ "promptTokenCount": 100,
+ "totalTokenCount": 150
+ }
+}
diff --git a/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_openai_chat_completions/thinking.request.json b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_openai_chat_completions/thinking.request.json
new file mode 100644
index 0000000..9a5f2b0
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_openai_chat_completions/thinking.request.json
@@ -0,0 +1,11 @@
+{
+ "max_tokens": 4096,
+ "messages": [
+ {
+ "content": "Prove that sqrt(2) is irrational.",
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model",
+ "reasoning_effort": "low"
+}
diff --git a/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_openai_chat_completions/websearch.request.json b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_openai_chat_completions/websearch.request.json
new file mode 100644
index 0000000..5c41e04
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_openai_chat_completions/websearch.request.json
@@ -0,0 +1,11 @@
+{
+ "max_tokens": 1024,
+ "messages": [
+ {
+ "content": "Search the latest AI news.",
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model",
+ "web_search_options": {}
+}
diff --git a/relaykit/relayconvert/register/golden/expected/oai_chat_to_claude_messages_stream_resp/basic.stream.json b/relaykit/relayconvert/register/golden/expected/oai_chat_to_claude_messages_stream_resp/basic.stream.json
new file mode 100644
index 0000000..f4d7389
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/oai_chat_to_claude_messages_stream_resp/basic.stream.json
@@ -0,0 +1,92 @@
+[
+ {
+ "data": {
+ "message": {
+ "content": [],
+ "id": "\u003cscrubbed\u003e",
+ "model": "upstream-model",
+ "role": "assistant",
+ "stop_reason": null,
+ "stop_sequence": null,
+ "type": "message",
+ "usage": {
+ "cache_creation_input_tokens": 0,
+ "cache_read_input_tokens": 0,
+ "input_tokens": 0,
+ "output_tokens": 0
+ }
+ },
+ "type": "message_start"
+ },
+ "event": "message_start"
+ },
+ {
+ "data": {
+ "content_block": {
+ "text": "",
+ "type": "text"
+ },
+ "index": 0,
+ "type": "content_block_start"
+ },
+ "event": "content_block_start"
+ },
+ {
+ "data": {
+ "delta": {
+ "text": "Hello",
+ "type": "text_delta"
+ },
+ "index": 0,
+ "type": "content_block_delta"
+ },
+ "event": "content_block_delta"
+ },
+ {
+ "data": {
+ "delta": {
+ "text": ", world!",
+ "type": "text_delta"
+ },
+ "index": 0,
+ "type": "content_block_delta"
+ },
+ "event": "content_block_delta"
+ },
+ {
+ "data": {
+ "index": 0,
+ "type": "content_block_stop"
+ },
+ "event": "content_block_stop"
+ },
+ {
+ "data": {
+ "delta": {
+ "stop_reason": "end_turn"
+ },
+ "type": "message_delta",
+ "usage": {
+ "cache_creation_input_tokens": 0,
+ "cache_read_input_tokens": 4,
+ "input_tokens": 8,
+ "output_tokens": 7
+ }
+ },
+ "event": "message_delta",
+ "usage": {
+ "completion_tokens": 7,
+ "prompt_tokens": 12,
+ "prompt_tokens_details": {
+ "cached_tokens": 4
+ },
+ "total_tokens": 19
+ }
+ },
+ {
+ "data": {
+ "type": "message_stop"
+ },
+ "event": "message_stop"
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/oai_chat_to_claude_messages_stream_resp/thinking.stream.json b/relaykit/relayconvert/register/golden/expected/oai_chat_to_claude_messages_stream_resp/thinking.stream.json
new file mode 100644
index 0000000..08a0c2f
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/oai_chat_to_claude_messages_stream_resp/thinking.stream.json
@@ -0,0 +1,110 @@
+[
+ {
+ "data": {
+ "message": {
+ "content": [],
+ "id": "\u003cscrubbed\u003e",
+ "model": "upstream-model",
+ "role": "assistant",
+ "stop_reason": null,
+ "stop_sequence": null,
+ "type": "message",
+ "usage": {
+ "cache_creation_input_tokens": 0,
+ "cache_read_input_tokens": 0,
+ "input_tokens": 0,
+ "output_tokens": 0
+ }
+ },
+ "type": "message_start"
+ },
+ "event": "message_start"
+ },
+ {
+ "data": {
+ "content_block": {
+ "thinking": "",
+ "type": "thinking"
+ },
+ "index": 0,
+ "type": "content_block_start"
+ },
+ "event": "content_block_start"
+ },
+ {
+ "data": {
+ "delta": {
+ "thinking": "Let me think...",
+ "type": "thinking_delta"
+ },
+ "index": 0,
+ "type": "content_block_delta"
+ },
+ "event": "content_block_delta"
+ },
+ {
+ "data": {
+ "index": 0,
+ "type": "content_block_stop"
+ },
+ "event": "content_block_stop"
+ },
+ {
+ "data": {
+ "content_block": {
+ "text": "",
+ "type": "text"
+ },
+ "index": 1,
+ "type": "content_block_start"
+ },
+ "event": "content_block_start"
+ },
+ {
+ "data": {
+ "delta": {
+ "text": "The answer is 42.",
+ "type": "text_delta"
+ },
+ "index": 1,
+ "type": "content_block_delta"
+ },
+ "event": "content_block_delta"
+ },
+ {
+ "data": {
+ "index": 1,
+ "type": "content_block_stop"
+ },
+ "event": "content_block_stop"
+ },
+ {
+ "data": {
+ "delta": {
+ "stop_reason": "end_turn"
+ },
+ "type": "message_delta",
+ "usage": {
+ "cache_creation_input_tokens": 0,
+ "cache_read_input_tokens": 0,
+ "input_tokens": 10,
+ "output_tokens": 50
+ }
+ },
+ "event": "message_delta",
+ "usage": {
+ "completion_tokens": 50,
+ "completion_tokens_details": {
+ "reasoning_tokens": 30
+ },
+ "prompt_tokens": 10,
+ "total_tokens": 60
+ }
+ },
+ {
+ "data": {
+ "type": "message_stop"
+ },
+ "event": "message_stop"
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/oai_chat_to_claude_messages_stream_resp/tools.stream.json b/relaykit/relayconvert/register/golden/expected/oai_chat_to_claude_messages_stream_resp/tools.stream.json
new file mode 100644
index 0000000..e89bd57
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/oai_chat_to_claude_messages_stream_resp/tools.stream.json
@@ -0,0 +1,91 @@
+[
+ {
+ "data": {
+ "message": {
+ "content": [],
+ "id": "\u003cscrubbed\u003e",
+ "model": "upstream-model",
+ "role": "assistant",
+ "stop_reason": null,
+ "stop_sequence": null,
+ "type": "message",
+ "usage": {
+ "cache_creation_input_tokens": 0,
+ "cache_read_input_tokens": 0,
+ "input_tokens": 0,
+ "output_tokens": 0
+ }
+ },
+ "type": "message_start"
+ },
+ "event": "message_start"
+ },
+ {
+ "data": {
+ "content_block": {
+ "id": "\u003cscrubbed\u003e",
+ "input": {},
+ "name": "get_weather",
+ "type": "tool_use"
+ },
+ "index": 0,
+ "type": "content_block_start"
+ },
+ "event": "content_block_start"
+ },
+ {
+ "data": {
+ "delta": {
+ "partial_json": "{\"city\":",
+ "type": "input_json_delta"
+ },
+ "index": 0,
+ "type": "content_block_delta"
+ },
+ "event": "content_block_delta"
+ },
+ {
+ "data": {
+ "delta": {
+ "partial_json": "\"Shanghai\"}",
+ "type": "input_json_delta"
+ },
+ "index": 0,
+ "type": "content_block_delta"
+ },
+ "event": "content_block_delta"
+ },
+ {
+ "data": {
+ "index": 0,
+ "type": "content_block_stop"
+ },
+ "event": "content_block_stop"
+ },
+ {
+ "data": {
+ "delta": {
+ "stop_reason": "tool_use"
+ },
+ "type": "message_delta",
+ "usage": {
+ "cache_creation_input_tokens": 0,
+ "cache_read_input_tokens": 0,
+ "input_tokens": 20,
+ "output_tokens": 9
+ }
+ },
+ "event": "message_delta",
+ "usage": {
+ "completion_tokens": 9,
+ "prompt_tokens": 20,
+ "total_tokens": 29
+ }
+ },
+ {
+ "data": {
+ "type": "message_stop"
+ },
+ "event": "message_stop"
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/oai_chat_to_gemini_chat_stream_resp/basic.stream.json b/relaykit/relayconvert/register/golden/expected/oai_chat_to_gemini_chat_stream_resp/basic.stream.json
new file mode 100644
index 0000000..39bcfc8
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/oai_chat_to_gemini_chat_stream_resp/basic.stream.json
@@ -0,0 +1,71 @@
+[
+ {
+ "data": {
+ "candidates": [
+ {
+ "content": {
+ "parts": [
+ {
+ "text": "Hello"
+ }
+ ],
+ "role": "model"
+ }
+ }
+ ]
+ }
+ },
+ {
+ "data": {
+ "candidates": [
+ {
+ "content": {
+ "parts": [
+ {
+ "text": ", world!"
+ }
+ ],
+ "role": "model"
+ }
+ }
+ ]
+ }
+ },
+ {
+ "data": {
+ "candidates": [
+ {
+ "content": {
+ "role": "model"
+ }
+ }
+ ]
+ }
+ },
+ {
+ "data": {
+ "candidates": [
+ {
+ "content": {
+ "role": "model"
+ },
+ "finishReason": "STOP"
+ }
+ ],
+ "usageMetadata": {
+ "cachedContentTokenCount": 4,
+ "candidatesTokenCount": 7,
+ "promptTokenCount": 12,
+ "totalTokenCount": 19
+ }
+ },
+ "usage": {
+ "completion_tokens": 7,
+ "prompt_tokens": 12,
+ "prompt_tokens_details": {
+ "cached_tokens": 4
+ },
+ "total_tokens": 19
+ }
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/oai_chat_to_gemini_chat_stream_resp/thinking.stream.json b/relaykit/relayconvert/register/golden/expected/oai_chat_to_gemini_chat_stream_resp/thinking.stream.json
new file mode 100644
index 0000000..5ee15a4
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/oai_chat_to_gemini_chat_stream_resp/thinking.stream.json
@@ -0,0 +1,72 @@
+[
+ {
+ "data": {
+ "candidates": [
+ {
+ "content": {
+ "parts": [
+ {
+ "text": "Let me think...",
+ "thought": true
+ }
+ ],
+ "role": "model"
+ }
+ }
+ ]
+ }
+ },
+ {
+ "data": {
+ "candidates": [
+ {
+ "content": {
+ "parts": [
+ {
+ "text": "The answer is 42."
+ }
+ ],
+ "role": "model"
+ }
+ }
+ ]
+ }
+ },
+ {
+ "data": {
+ "candidates": [
+ {
+ "content": {
+ "role": "model"
+ }
+ }
+ ]
+ }
+ },
+ {
+ "data": {
+ "candidates": [
+ {
+ "content": {
+ "role": "model"
+ },
+ "finishReason": "STOP"
+ }
+ ],
+ "usageMetadata": {
+ "candidatesTokenCount": 20,
+ "promptTokenCount": 10,
+ "thoughtsTokenCount": 30,
+ "totalTokenCount": 60
+ }
+ },
+ "usage": {
+ "completion_tokens": 50,
+ "completion_tokens_details": {
+ "reasoning_tokens": 30
+ },
+ "prompt_tokens": 10,
+ "total_tokens": 60
+ }
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/oai_chat_to_gemini_chat_stream_resp/tools.stream.json b/relaykit/relayconvert/register/golden/expected/oai_chat_to_gemini_chat_stream_resp/tools.stream.json
new file mode 100644
index 0000000..605ff8e
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/oai_chat_to_gemini_chat_stream_resp/tools.stream.json
@@ -0,0 +1,92 @@
+[
+ {
+ "data": {
+ "candidates": [
+ {
+ "content": {
+ "parts": [
+ {
+ "functionCall": {
+ "id": "\u003cscrubbed\u003e",
+ "name": "get_weather"
+ }
+ }
+ ],
+ "role": "model"
+ }
+ }
+ ]
+ }
+ },
+ {
+ "data": {
+ "candidates": [
+ {
+ "content": {
+ "parts": [
+ {
+ "functionCall": {
+ "args": {},
+ "name": ""
+ }
+ }
+ ],
+ "role": "model"
+ }
+ }
+ ]
+ }
+ },
+ {
+ "data": {
+ "candidates": [
+ {
+ "content": {
+ "parts": [
+ {
+ "functionCall": {
+ "args": {},
+ "name": ""
+ }
+ }
+ ],
+ "role": "model"
+ }
+ }
+ ]
+ }
+ },
+ {
+ "data": {
+ "candidates": [
+ {
+ "content": {
+ "role": "model"
+ }
+ }
+ ]
+ }
+ },
+ {
+ "data": {
+ "candidates": [
+ {
+ "content": {
+ "role": "model"
+ },
+ "finishReason": "STOP"
+ }
+ ],
+ "usageMetadata": {
+ "candidatesTokenCount": 9,
+ "promptTokenCount": 20,
+ "totalTokenCount": 29
+ }
+ },
+ "usage": {
+ "completion_tokens": 9,
+ "prompt_tokens": 20,
+ "total_tokens": 29
+ }
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/oai_chat_to_oai_responses_stream_resp/basic.stream.json b/relaykit/relayconvert/register/golden/expected/oai_chat_to_oai_responses_stream_resp/basic.stream.json
new file mode 100644
index 0000000..09491eb
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/oai_chat_to_oai_responses_stream_resp/basic.stream.json
@@ -0,0 +1,204 @@
+[
+ {
+ "data": {
+ "response": {
+ "created_at": 0,
+ "error": null,
+ "id": "\u003cscrubbed\u003e",
+ "incomplete_details": null,
+ "instructions": null,
+ "max_output_tokens": null,
+ "metadata": {},
+ "model": "origin-model",
+ "object": "response",
+ "output": [],
+ "parallel_tool_calls": true,
+ "previous_response_id": null,
+ "reasoning": {
+ "effort": null,
+ "summary": null
+ },
+ "status": "in_progress",
+ "store": false,
+ "temperature": 1,
+ "text": {
+ "format": {
+ "type": "text"
+ }
+ },
+ "tool_choice": "auto",
+ "tools": [],
+ "top_p": 1,
+ "truncation": "disabled",
+ "user": null
+ },
+ "type": "response.created"
+ },
+ "event": "response.created"
+ },
+ {
+ "data": {
+ "item": {
+ "content": [],
+ "id": "\u003cscrubbed\u003e",
+ "role": "assistant",
+ "status": "in_progress",
+ "type": "message"
+ },
+ "output_index": 0,
+ "type": "response.output_item.added"
+ },
+ "event": "response.output_item.added"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "item_id": "msg_chatcmpl-up",
+ "output_index": 0,
+ "part": {
+ "annotations": [],
+ "text": "",
+ "type": "output_text"
+ },
+ "type": "response.content_part.added"
+ },
+ "event": "response.content_part.added"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "delta": "Hello",
+ "item_id": "msg_chatcmpl-up",
+ "output_index": 0,
+ "type": "response.output_text.delta"
+ },
+ "event": "response.output_text.delta"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "delta": ", world!",
+ "item_id": "msg_chatcmpl-up",
+ "output_index": 0,
+ "type": "response.output_text.delta"
+ },
+ "event": "response.output_text.delta"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "item_id": "msg_chatcmpl-up",
+ "output_index": 0,
+ "text": "Hello, world!",
+ "type": "response.output_text.done"
+ },
+ "event": "response.output_text.done"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "item_id": "msg_chatcmpl-up",
+ "output_index": 0,
+ "part": {
+ "annotations": [],
+ "text": "Hello, world!",
+ "type": "output_text"
+ },
+ "type": "response.content_part.done"
+ },
+ "event": "response.content_part.done"
+ },
+ {
+ "data": {
+ "item": {
+ "content": [
+ {
+ "annotations": [],
+ "text": "Hello, world!",
+ "type": "output_text"
+ }
+ ],
+ "id": "\u003cscrubbed\u003e",
+ "role": "assistant",
+ "status": "completed",
+ "type": "message"
+ },
+ "output_index": 0,
+ "type": "response.output_item.done"
+ },
+ "event": "response.output_item.done"
+ },
+ {
+ "data": {
+ "response": {
+ "completed_at": 0,
+ "created_at": 0,
+ "error": null,
+ "id": "\u003cscrubbed\u003e",
+ "incomplete_details": null,
+ "instructions": null,
+ "max_output_tokens": null,
+ "metadata": {},
+ "model": "origin-model",
+ "object": "response",
+ "output": [
+ {
+ "content": [
+ {
+ "annotations": [],
+ "text": "Hello, world!",
+ "type": "output_text"
+ }
+ ],
+ "id": "\u003cscrubbed\u003e",
+ "role": "assistant",
+ "status": "completed",
+ "type": "message"
+ }
+ ],
+ "parallel_tool_calls": true,
+ "previous_response_id": null,
+ "reasoning": {
+ "effort": null,
+ "summary": null
+ },
+ "status": "completed",
+ "store": false,
+ "temperature": 1,
+ "text": {
+ "format": {
+ "type": "text"
+ }
+ },
+ "tool_choice": "auto",
+ "tools": [],
+ "top_p": 1,
+ "truncation": "disabled",
+ "usage": {
+ "input_tokens": 12,
+ "input_tokens_details": {
+ "audio_tokens": 0,
+ "cache_write_tokens": 0,
+ "cached_tokens": 4
+ },
+ "output_tokens": 7,
+ "output_tokens_details": {
+ "reasoning_tokens": 0
+ },
+ "total_tokens": 19
+ },
+ "user": null
+ },
+ "type": "response.completed"
+ },
+ "event": "response.completed",
+ "usage": {
+ "completion_tokens": 7,
+ "prompt_tokens": 12,
+ "prompt_tokens_details": {
+ "cached_tokens": 4
+ },
+ "total_tokens": 19
+ }
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/oai_chat_to_oai_responses_stream_resp/thinking.stream.json b/relaykit/relayconvert/register/golden/expected/oai_chat_to_oai_responses_stream_resp/thinking.stream.json
new file mode 100644
index 0000000..a285abd
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/oai_chat_to_oai_responses_stream_resp/thinking.stream.json
@@ -0,0 +1,257 @@
+[
+ {
+ "data": {
+ "response": {
+ "created_at": 0,
+ "error": null,
+ "id": "\u003cscrubbed\u003e",
+ "incomplete_details": null,
+ "instructions": null,
+ "max_output_tokens": null,
+ "metadata": {},
+ "model": "origin-model",
+ "object": "response",
+ "output": [],
+ "parallel_tool_calls": true,
+ "previous_response_id": null,
+ "reasoning": {
+ "effort": null,
+ "summary": null
+ },
+ "status": "in_progress",
+ "store": false,
+ "temperature": 1,
+ "text": {
+ "format": {
+ "type": "text"
+ }
+ },
+ "tool_choice": "auto",
+ "tools": [],
+ "top_p": 1,
+ "truncation": "disabled",
+ "user": null
+ },
+ "type": "response.created"
+ },
+ "event": "response.created"
+ },
+ {
+ "data": {
+ "item": {
+ "content": [],
+ "id": "\u003cscrubbed\u003e",
+ "role": "assistant",
+ "status": "in_progress",
+ "type": "message"
+ },
+ "output_index": 0,
+ "type": "response.output_item.added"
+ },
+ "event": "response.output_item.added"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "item_id": "msg_chatcmpl-up",
+ "output_index": 0,
+ "part": {
+ "annotations": [],
+ "text": "",
+ "type": "output_text"
+ },
+ "type": "response.content_part.added"
+ },
+ "event": "response.content_part.added"
+ },
+ {
+ "data": {
+ "item": {
+ "id": "\u003cscrubbed\u003e",
+ "status": "in_progress",
+ "summary": [],
+ "type": "reasoning"
+ },
+ "output_index": 1,
+ "type": "response.output_item.added"
+ },
+ "event": "response.output_item.added"
+ },
+ {
+ "data": {
+ "delta": "Let me think...",
+ "item_id": "rs_resp_chatcmpl-up",
+ "output_index": 1,
+ "summary_index": 0,
+ "type": "response.reasoning_summary_text.delta"
+ },
+ "event": "response.reasoning_summary_text.delta"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "delta": "The answer is 42.",
+ "item_id": "msg_chatcmpl-up",
+ "output_index": 0,
+ "type": "response.output_text.delta"
+ },
+ "event": "response.output_text.delta"
+ },
+ {
+ "data": {
+ "item_id": "rs_resp_chatcmpl-up",
+ "output_index": 1,
+ "summary_index": 0,
+ "text": "Let me think...",
+ "type": "response.reasoning_summary_text.done"
+ },
+ "event": "response.reasoning_summary_text.done"
+ },
+ {
+ "data": {
+ "item": {
+ "id": "\u003cscrubbed\u003e",
+ "status": "completed",
+ "summary": [
+ {
+ "text": "Let me think...",
+ "type": "summary_text"
+ }
+ ],
+ "type": "reasoning"
+ },
+ "output_index": 1,
+ "type": "response.output_item.done"
+ },
+ "event": "response.output_item.done"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "item_id": "msg_chatcmpl-up",
+ "output_index": 0,
+ "text": "The answer is 42.",
+ "type": "response.output_text.done"
+ },
+ "event": "response.output_text.done"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "item_id": "msg_chatcmpl-up",
+ "output_index": 0,
+ "part": {
+ "annotations": [],
+ "text": "The answer is 42.",
+ "type": "output_text"
+ },
+ "type": "response.content_part.done"
+ },
+ "event": "response.content_part.done"
+ },
+ {
+ "data": {
+ "item": {
+ "content": [
+ {
+ "annotations": [],
+ "text": "The answer is 42.",
+ "type": "output_text"
+ }
+ ],
+ "id": "\u003cscrubbed\u003e",
+ "role": "assistant",
+ "status": "completed",
+ "type": "message"
+ },
+ "output_index": 0,
+ "type": "response.output_item.done"
+ },
+ "event": "response.output_item.done"
+ },
+ {
+ "data": {
+ "response": {
+ "completed_at": 0,
+ "created_at": 0,
+ "error": null,
+ "id": "\u003cscrubbed\u003e",
+ "incomplete_details": null,
+ "instructions": null,
+ "max_output_tokens": null,
+ "metadata": {},
+ "model": "origin-model",
+ "object": "response",
+ "output": [
+ {
+ "content": [
+ {
+ "annotations": [],
+ "text": "The answer is 42.",
+ "type": "output_text"
+ }
+ ],
+ "id": "\u003cscrubbed\u003e",
+ "role": "assistant",
+ "status": "completed",
+ "type": "message"
+ },
+ {
+ "id": "\u003cscrubbed\u003e",
+ "status": "completed",
+ "summary": [
+ {
+ "text": "Let me think...",
+ "type": "summary_text"
+ }
+ ],
+ "type": "reasoning"
+ }
+ ],
+ "parallel_tool_calls": true,
+ "previous_response_id": null,
+ "reasoning": {
+ "effort": null,
+ "summary": null
+ },
+ "status": "completed",
+ "store": false,
+ "temperature": 1,
+ "text": {
+ "format": {
+ "type": "text"
+ }
+ },
+ "tool_choice": "auto",
+ "tools": [],
+ "top_p": 1,
+ "truncation": "disabled",
+ "usage": {
+ "input_tokens": 10,
+ "input_tokens_details": {
+ "cached_tokens": 0
+ },
+ "output_tokens": 50,
+ "output_tokens_details": {
+ "accepted_prediction_tokens": 0,
+ "audio_tokens": 0,
+ "reasoning_tokens": 30,
+ "rejected_prediction_tokens": 0
+ },
+ "total_tokens": 60
+ },
+ "user": null
+ },
+ "type": "response.completed"
+ },
+ "event": "response.completed",
+ "usage": {
+ "completion_tokens": 50,
+ "completion_tokens_details": {
+ "reasoning_tokens": 30
+ },
+ "prompt_tokens": 10,
+ "total_tokens": 60
+ }
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/oai_chat_to_oai_responses_stream_resp/tools.stream.json b/relaykit/relayconvert/register/golden/expected/oai_chat_to_oai_responses_stream_resp/tools.stream.json
new file mode 100644
index 0000000..63d665d
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/oai_chat_to_oai_responses_stream_resp/tools.stream.json
@@ -0,0 +1,230 @@
+[
+ {
+ "data": {
+ "response": {
+ "created_at": 0,
+ "error": null,
+ "id": "\u003cscrubbed\u003e",
+ "incomplete_details": null,
+ "instructions": null,
+ "max_output_tokens": null,
+ "metadata": {},
+ "model": "origin-model",
+ "object": "response",
+ "output": [],
+ "parallel_tool_calls": true,
+ "previous_response_id": null,
+ "reasoning": {
+ "effort": null,
+ "summary": null
+ },
+ "status": "in_progress",
+ "store": false,
+ "temperature": 1,
+ "text": {
+ "format": {
+ "type": "text"
+ }
+ },
+ "tool_choice": "auto",
+ "tools": [],
+ "top_p": 1,
+ "truncation": "disabled",
+ "user": null
+ },
+ "type": "response.created"
+ },
+ "event": "response.created"
+ },
+ {
+ "data": {
+ "item": {
+ "content": [],
+ "id": "\u003cscrubbed\u003e",
+ "role": "assistant",
+ "status": "in_progress",
+ "type": "message"
+ },
+ "output_index": 0,
+ "type": "response.output_item.added"
+ },
+ "event": "response.output_item.added"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "item_id": "msg_chatcmpl-up",
+ "output_index": 0,
+ "part": {
+ "annotations": [],
+ "text": "",
+ "type": "output_text"
+ },
+ "type": "response.content_part.added"
+ },
+ "event": "response.content_part.added"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "item_id": "msg_chatcmpl-up",
+ "output_index": 0,
+ "text": "",
+ "type": "response.output_text.done"
+ },
+ "event": "response.output_text.done"
+ },
+ {
+ "data": {
+ "content_index": 0,
+ "item_id": "msg_chatcmpl-up",
+ "output_index": 0,
+ "part": {
+ "annotations": [],
+ "text": "",
+ "type": "output_text"
+ },
+ "type": "response.content_part.done"
+ },
+ "event": "response.content_part.done"
+ },
+ {
+ "data": {
+ "item": {
+ "content": [
+ {
+ "annotations": [],
+ "text": "",
+ "type": "output_text"
+ }
+ ],
+ "id": "\u003cscrubbed\u003e",
+ "role": "assistant",
+ "status": "completed",
+ "type": "message"
+ },
+ "output_index": 0,
+ "type": "response.output_item.done"
+ },
+ "event": "response.output_item.done"
+ },
+ {
+ "data": {
+ "item": {
+ "call_id": "call_1",
+ "id": "\u003cscrubbed\u003e",
+ "name": "get_weather",
+ "status": "in_progress",
+ "type": "function_call"
+ },
+ "output_index": 1,
+ "type": "response.output_item.added"
+ },
+ "event": "response.output_item.added"
+ },
+ {
+ "data": {
+ "delta": "{\"city\":",
+ "item_id": "call_1",
+ "output_index": 1,
+ "type": "response.function_call_arguments.delta"
+ },
+ "event": "response.function_call_arguments.delta"
+ },
+ {
+ "data": {
+ "delta": "\"Shanghai\"}",
+ "item_id": "call_1",
+ "output_index": 1,
+ "type": "response.function_call_arguments.delta"
+ },
+ "event": "response.function_call_arguments.delta"
+ },
+ {
+ "data": {
+ "arguments": "{\"city\":\"Shanghai\"}",
+ "item_id": "call_1",
+ "output_index": 1,
+ "type": "response.function_call_arguments.done"
+ },
+ "event": "response.function_call_arguments.done"
+ },
+ {
+ "data": {
+ "item": {
+ "arguments": "{\"city\":\"Shanghai\"}",
+ "call_id": "call_1",
+ "id": "\u003cscrubbed\u003e",
+ "name": "get_weather",
+ "status": "completed",
+ "type": "function_call"
+ },
+ "output_index": 1,
+ "type": "response.output_item.done"
+ },
+ "event": "response.output_item.done"
+ },
+ {
+ "data": {
+ "response": {
+ "completed_at": 0,
+ "created_at": 0,
+ "error": null,
+ "id": "\u003cscrubbed\u003e",
+ "incomplete_details": null,
+ "instructions": null,
+ "max_output_tokens": null,
+ "metadata": {},
+ "model": "origin-model",
+ "object": "response",
+ "output": [
+ {
+ "arguments": "{\"city\":\"Shanghai\"}",
+ "call_id": "call_1",
+ "id": "\u003cscrubbed\u003e",
+ "name": "get_weather",
+ "status": "completed",
+ "type": "function_call"
+ }
+ ],
+ "parallel_tool_calls": true,
+ "previous_response_id": null,
+ "reasoning": {
+ "effort": null,
+ "summary": null
+ },
+ "status": "completed",
+ "store": false,
+ "temperature": 1,
+ "text": {
+ "format": {
+ "type": "text"
+ }
+ },
+ "tool_choice": "auto",
+ "tools": [],
+ "top_p": 1,
+ "truncation": "disabled",
+ "usage": {
+ "input_tokens": 20,
+ "input_tokens_details": {
+ "cached_tokens": 0
+ },
+ "output_tokens": 9,
+ "output_tokens_details": {
+ "reasoning_tokens": 0
+ },
+ "total_tokens": 29
+ },
+ "user": null
+ },
+ "type": "response.completed"
+ },
+ "event": "response.completed",
+ "usage": {
+ "completion_tokens": 9,
+ "prompt_tokens": 20,
+ "total_tokens": 29
+ }
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/oai_responses_to_oai_chat_stream_resp/basic.stream.json b/relaykit/relayconvert/register/golden/expected/oai_responses_to_oai_chat_stream_resp/basic.stream.json
new file mode 100644
index 0000000..e7bd056
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/oai_responses_to_oai_chat_stream_resp/basic.stream.json
@@ -0,0 +1,95 @@
+[
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": "",
+ "role": "assistant"
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": "Hello",
+ "role": ""
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": ", world!",
+ "role": ""
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": null,
+ "role": ""
+ },
+ "finish_reason": "stop",
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": {
+ "completion_tokens": 7,
+ "prompt_tokens": 12,
+ "prompt_tokens_details": {
+ "cached_tokens": 4
+ },
+ "total_tokens": 19
+ }
+ }
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/oai_responses_to_oai_chat_stream_resp/thinking.stream.json b/relaykit/relayconvert/register/golden/expected/oai_responses_to_oai_chat_stream_resp/thinking.stream.json
new file mode 100644
index 0000000..8d3a00f
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/oai_responses_to_oai_chat_stream_resp/thinking.stream.json
@@ -0,0 +1,96 @@
+[
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": "",
+ "role": "assistant"
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": null,
+ "reasoning_content": "Let me think...",
+ "role": ""
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": "The answer is 42.",
+ "role": ""
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": null,
+ "role": ""
+ },
+ "finish_reason": "stop",
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": {
+ "completion_tokens": 50,
+ "completion_tokens_details": {
+ "reasoning_tokens": 30
+ },
+ "prompt_tokens": 10,
+ "total_tokens": 60
+ }
+ }
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/oai_responses_to_oai_chat_stream_resp/tools.stream.json b/relaykit/relayconvert/register/golden/expected/oai_responses_to_oai_chat_stream_resp/tools.stream.json
new file mode 100644
index 0000000..67b6b82
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/oai_responses_to_oai_chat_stream_resp/tools.stream.json
@@ -0,0 +1,141 @@
+[
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": null,
+ "role": "",
+ "tool_calls": [
+ {
+ "function": {
+ "arguments": "",
+ "name": "get_weather"
+ },
+ "id": "\u003cscrubbed\u003e",
+ "type": "function"
+ }
+ ]
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": null,
+ "role": "",
+ "tool_calls": [
+ {
+ "function": {
+ "arguments": "{\"city\":",
+ "name": ""
+ },
+ "id": "\u003cscrubbed\u003e",
+ "type": "function"
+ }
+ ]
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": null,
+ "role": "",
+ "tool_calls": [
+ {
+ "function": {
+ "arguments": "\"Shanghai\"}",
+ "name": ""
+ },
+ "id": "\u003cscrubbed\u003e",
+ "type": "function"
+ }
+ ]
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": "",
+ "role": "assistant"
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": null,
+ "role": ""
+ },
+ "finish_reason": "tool_calls",
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": {
+ "completion_tokens": 9,
+ "prompt_tokens": 20,
+ "total_tokens": 29
+ }
+ }
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/ollama_chat_to_oai_chat_stream_resp/basic.stream.json b/relaykit/relayconvert/register/golden/expected/ollama_chat_to_oai_chat_stream_resp/basic.stream.json
new file mode 100644
index 0000000..5dbd99d
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/ollama_chat_to_oai_chat_stream_resp/basic.stream.json
@@ -0,0 +1,82 @@
+[
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": "",
+ "role": "assistant"
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": "Hello",
+ "role": ""
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": ", world!",
+ "role": ""
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": null,
+ "role": ""
+ },
+ "finish_reason": "stop",
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": {
+ "completion_tokens": 7,
+ "prompt_tokens": 12,
+ "total_tokens": 19
+ }
+ }
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/ollama_chat_to_oai_chat_stream_resp/thinking.stream.json b/relaykit/relayconvert/register/golden/expected/ollama_chat_to_oai_chat_stream_resp/thinking.stream.json
new file mode 100644
index 0000000..41c80d1
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/ollama_chat_to_oai_chat_stream_resp/thinking.stream.json
@@ -0,0 +1,83 @@
+[
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": "",
+ "role": "assistant"
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": "",
+ "reasoning_content": "Let me think...",
+ "role": ""
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": "The answer is 42.",
+ "role": ""
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": null,
+ "role": ""
+ },
+ "finish_reason": "stop",
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": {
+ "completion_tokens": 50,
+ "prompt_tokens": 10,
+ "total_tokens": 60
+ }
+ }
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/ollama_chat_to_oai_chat_stream_resp/tools.stream.json b/relaykit/relayconvert/register/golden/expected/ollama_chat_to_oai_chat_stream_resp/tools.stream.json
new file mode 100644
index 0000000..de1380a
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/ollama_chat_to_oai_chat_stream_resp/tools.stream.json
@@ -0,0 +1,73 @@
+[
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": "",
+ "role": "assistant"
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": "",
+ "role": "",
+ "tool_calls": [
+ {
+ "function": {
+ "arguments": "{\"city\":\"Shanghai\"}",
+ "name": "get_weather"
+ },
+ "id": "\u003cscrubbed\u003e",
+ "type": "function"
+ }
+ ]
+ },
+ "finish_reason": null,
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": null
+ }
+ },
+ {
+ "data": {
+ "choices": [
+ {
+ "delta": {
+ "content": null,
+ "role": ""
+ },
+ "finish_reason": "tool_calls",
+ "index": 0
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion.chunk",
+ "usage": {
+ "completion_tokens": 9,
+ "prompt_tokens": 20,
+ "total_tokens": 29
+ }
+ }
+ }
+]
diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/basic.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/basic.request.json
new file mode 100644
index 0000000..cdab73b
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/basic.request.json
@@ -0,0 +1,17 @@
+{
+ "max_tokens": 256,
+ "messages": [
+ {
+ "content": [
+ {
+ "text": "Hello, how are you?",
+ "type": "text"
+ }
+ ],
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model",
+ "system": "You are helpful.",
+ "temperature": 0.7
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/full.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/full.request.json
new file mode 100644
index 0000000..6c180ed
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/full.request.json
@@ -0,0 +1,98 @@
+{
+ "max_tokens": 1024,
+ "messages": [
+ {
+ "content": [
+ {
+ "text": "Describe this image and check the weather in Boston.",
+ "type": "text"
+ },
+ {
+ "source": {
+ "data": "/9j/4AAQSkZJRg==",
+ "media_type": "image/jpeg",
+ "type": "base64"
+ },
+ "type": "image"
+ }
+ ],
+ "role": "user"
+ },
+ {
+ "content": [
+ {
+ "text": "",
+ "type": "text"
+ },
+ {
+ "id": "call_1",
+ "input": {
+ "location": "Boston"
+ },
+ "name": "get_weather",
+ "type": "tool_use"
+ }
+ ],
+ "role": "assistant"
+ },
+ {
+ "content": [
+ {
+ "content": "68F, sunny",
+ "tool_use_id": "call_1",
+ "type": "tool_result"
+ }
+ ],
+ "role": "user"
+ },
+ {
+ "content": [
+ {
+ "text": "Thanks! What about the stock price of ACME?",
+ "type": "text"
+ }
+ ],
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model",
+ "stop_sequences": [
+ "END_TOKEN"
+ ],
+ "stream": true,
+ "system": "You are a weather and finance assistant.",
+ "temperature": 0.7,
+ "tool_choice": {
+ "name": "get_weather",
+ "type": "tool"
+ },
+ "tools": [
+ {
+ "description": "get weather",
+ "input_schema": {
+ "properties": {
+ "location": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "location"
+ ],
+ "type": "object"
+ },
+ "name": "get_weather"
+ },
+ {
+ "description": "stock quote",
+ "input_schema": {
+ "properties": {
+ "symbol": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "name": "lookup_stock"
+ }
+ ]
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/full.response.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/full.response.json
new file mode 100644
index 0000000..babdae7
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/full.response.json
@@ -0,0 +1,32 @@
+{
+ "choices": [
+ {
+ "finish_reason": "tool_calls",
+ "index": 0,
+ "message": {
+ "content": "CAP_MARKER_ASSISTANT_TEXT the weather is 68F",
+ "reasoning_content": "CAP_MARKER_THINKING user asks weather",
+ "role": "assistant",
+ "tool_calls": [
+ {
+ "function": {
+ "arguments": "{\"location\":\"Boston\"}",
+ "name": "get_weather"
+ },
+ "id": "\u003cscrubbed\u003e",
+ "type": "function"
+ }
+ ]
+ }
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "upstream-model",
+ "object": "chat.completion",
+ "usage": {
+ "completion_tokens": 50,
+ "prompt_tokens": 100,
+ "total_tokens": 150
+ }
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/schema-variants.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/schema-variants.request.json
new file mode 100644
index 0000000..1793e41
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/schema-variants.request.json
@@ -0,0 +1,73 @@
+{
+ "max_tokens": 512,
+ "messages": [
+ {
+ "content": [
+ {
+ "text": "Search the repo with the where filter.",
+ "type": "text"
+ }
+ ],
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model",
+ "tools": [
+ {
+ "description": "search repo",
+ "input_schema": {
+ "properties": {
+ "query": {
+ "properties": {
+ "paths": {
+ "type": "array"
+ },
+ "pattern": {
+ "type": "string"
+ },
+ "tag": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "number"
+ }
+ ]
+ },
+ "where": {
+ "items": {
+ "prefixItems": [
+ {
+ "type": "string"
+ },
+ {
+ "enum": [
+ "AND",
+ "OR"
+ ],
+ "type": "string"
+ },
+ {}
+ ],
+ "type": "array"
+ },
+ "maxItems": 10,
+ "type": "array"
+ }
+ },
+ "required": [
+ "pattern"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "query"
+ ],
+ "type": "object"
+ },
+ "name": "grep"
+ }
+ ]
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/thinking.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/thinking.request.json
new file mode 100644
index 0000000..09ded16
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/thinking.request.json
@@ -0,0 +1,15 @@
+{
+ "max_tokens": 4096,
+ "messages": [
+ {
+ "content": [
+ {
+ "text": "Prove that sqrt(2) is irrational.",
+ "type": "text"
+ }
+ ],
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model"
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/websearch.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/websearch.request.json
new file mode 100644
index 0000000..474160f
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/websearch.request.json
@@ -0,0 +1,21 @@
+{
+ "max_tokens": 1024,
+ "messages": [
+ {
+ "content": [
+ {
+ "text": "Search the latest AI news.",
+ "type": "text"
+ }
+ ],
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model",
+ "tools": [
+ {
+ "name": "web_search",
+ "type": "web_search_20250305"
+ }
+ ]
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/basic.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/basic.request.json
new file mode 100644
index 0000000..04405eb
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/basic.request.json
@@ -0,0 +1,42 @@
+{
+ "contents": [
+ {
+ "parts": [
+ {
+ "text": "Hello, how are you?"
+ }
+ ],
+ "role": "user"
+ }
+ ],
+ "generationConfig": {
+ "maxOutputTokens": 256,
+ "temperature": 0.7
+ },
+ "safetySettings": [
+ {
+ "category": "HARM_CATEGORY_HARASSMENT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_HATE_SPEECH",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ }
+ ],
+ "systemInstruction": {
+ "parts": [
+ {
+ "text": "You are helpful."
+ }
+ ],
+ "role": ""
+ }
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/full.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/full.request.json
new file mode 100644
index 0000000..ac68e34
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/full.request.json
@@ -0,0 +1,126 @@
+{
+ "contents": [
+ {
+ "parts": [
+ {
+ "text": "Describe this image and check the weather in Boston."
+ },
+ {
+ "inlineData": {
+ "data": "/9j/4AAQSkZJRg==",
+ "mimeType": "image/jpeg"
+ }
+ }
+ ],
+ "role": "user"
+ },
+ {
+ "parts": [
+ {
+ "functionCall": {
+ "args": {
+ "location": "Boston"
+ },
+ "name": "get_weather"
+ }
+ }
+ ],
+ "role": "model"
+ },
+ {
+ "parts": [
+ {
+ "functionResponse": {
+ "name": "get_weather",
+ "response": {
+ "result": "68F, sunny"
+ }
+ }
+ }
+ ],
+ "role": "user"
+ },
+ {
+ "parts": [
+ {
+ "text": "Thanks! What about the stock price of ACME?"
+ }
+ ],
+ "role": "user"
+ }
+ ],
+ "generationConfig": {
+ "maxOutputTokens": 1024,
+ "stopSequences": [
+ "END_TOKEN"
+ ],
+ "temperature": 0.7
+ },
+ "safetySettings": [
+ {
+ "category": "HARM_CATEGORY_HARASSMENT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_HATE_SPEECH",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ }
+ ],
+ "systemInstruction": {
+ "parts": [
+ {
+ "text": "You are a weather and finance assistant."
+ }
+ ],
+ "role": ""
+ },
+ "toolConfig": {
+ "functionCallingConfig": {
+ "allowedFunctionNames": [
+ "get_weather"
+ ],
+ "mode": "ANY"
+ }
+ },
+ "tools": [
+ {
+ "functionDeclarations": [
+ {
+ "description": "get weather",
+ "name": "get_weather",
+ "parameters": {
+ "properties": {
+ "location": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "location"
+ ],
+ "type": "object"
+ }
+ },
+ {
+ "description": "stock quote",
+ "name": "lookup_stock",
+ "parameters": {
+ "properties": {
+ "symbol": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ }
+ ]
+ }
+ ]
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/full.response.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/full.response.json
new file mode 100644
index 0000000..babdae7
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/full.response.json
@@ -0,0 +1,32 @@
+{
+ "choices": [
+ {
+ "finish_reason": "tool_calls",
+ "index": 0,
+ "message": {
+ "content": "CAP_MARKER_ASSISTANT_TEXT the weather is 68F",
+ "reasoning_content": "CAP_MARKER_THINKING user asks weather",
+ "role": "assistant",
+ "tool_calls": [
+ {
+ "function": {
+ "arguments": "{\"location\":\"Boston\"}",
+ "name": "get_weather"
+ },
+ "id": "\u003cscrubbed\u003e",
+ "type": "function"
+ }
+ ]
+ }
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "upstream-model",
+ "object": "chat.completion",
+ "usage": {
+ "completion_tokens": 50,
+ "prompt_tokens": 100,
+ "total_tokens": 150
+ }
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/schema-variants.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/schema-variants.request.json
new file mode 100644
index 0000000..bbd058a
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/schema-variants.request.json
@@ -0,0 +1,102 @@
+{
+ "contents": [
+ {
+ "parts": [
+ {
+ "text": "Search the repo with the where filter."
+ }
+ ],
+ "role": "user"
+ }
+ ],
+ "generationConfig": {
+ "maxOutputTokens": 512
+ },
+ "safetySettings": [
+ {
+ "category": "HARM_CATEGORY_HARASSMENT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_HATE_SPEECH",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ }
+ ],
+ "tools": [
+ {
+ "functionDeclarations": [
+ {
+ "description": "search repo",
+ "name": "grep",
+ "parameters": {
+ "properties": {
+ "query": {
+ "properties": {
+ "paths": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "pattern": {
+ "type": "string"
+ },
+ "tag": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "number"
+ }
+ ]
+ },
+ "where": {
+ "items": {
+ "items": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "enum": [
+ "AND",
+ "OR"
+ ],
+ "type": "string"
+ },
+ {
+ "type": "string"
+ }
+ ]
+ },
+ "type": "array"
+ },
+ "maxItems": 10,
+ "type": "array"
+ }
+ },
+ "required": [
+ "pattern"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "query"
+ ],
+ "type": "object"
+ }
+ }
+ ]
+ }
+ ]
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/thinking.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/thinking.request.json
new file mode 100644
index 0000000..bc184b2
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/thinking.request.json
@@ -0,0 +1,37 @@
+{
+ "contents": [
+ {
+ "parts": [
+ {
+ "text": "Prove that sqrt(2) is irrational."
+ }
+ ],
+ "role": "user"
+ }
+ ],
+ "generationConfig": {
+ "maxOutputTokens": 4096,
+ "thinkingConfig": {
+ "includeThoughts": true,
+ "thinkingBudget": 32768
+ }
+ },
+ "safetySettings": [
+ {
+ "category": "HARM_CATEGORY_HARASSMENT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_HATE_SPEECH",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ }
+ ]
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/websearch.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/websearch.request.json
new file mode 100644
index 0000000..39692f3
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/websearch.request.json
@@ -0,0 +1,38 @@
+{
+ "contents": [
+ {
+ "parts": [
+ {
+ "text": "Search the latest AI news."
+ }
+ ],
+ "role": "user"
+ }
+ ],
+ "generationConfig": {
+ "maxOutputTokens": 1024
+ },
+ "safetySettings": [
+ {
+ "category": "HARM_CATEGORY_HARASSMENT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_HATE_SPEECH",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ }
+ ],
+ "tools": [
+ {
+ "googleSearch": {}
+ }
+ ]
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/basic.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/basic.request.json
new file mode 100644
index 0000000..66f7ed4
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/basic.request.json
@@ -0,0 +1,18 @@
+{
+ "messages": [
+ {
+ "content": "You are helpful.",
+ "role": "system"
+ },
+ {
+ "content": "Hello, how are you?",
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model",
+ "options": {
+ "num_predict": 256,
+ "temperature": 0.7
+ },
+ "stream": false
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/full.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/full.request.json
new file mode 100644
index 0000000..1665637
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/full.request.json
@@ -0,0 +1,84 @@
+{
+ "messages": [
+ {
+ "content": "You are a weather and finance assistant.",
+ "role": "system"
+ },
+ {
+ "content": "Describe this image and check the weather in Boston.",
+ "images": [
+ "/9j/4AAQSkZJRg=="
+ ],
+ "role": "user"
+ },
+ {
+ "content": "",
+ "role": "assistant",
+ "tool_calls": [
+ {
+ "function": {
+ "arguments": {
+ "location": "Boston"
+ },
+ "name": "get_weather"
+ },
+ "id": "call_1"
+ }
+ ]
+ },
+ {
+ "content": "68F, sunny",
+ "role": "tool",
+ "tool_call_id": "call_1",
+ "tool_name": "get_weather"
+ },
+ {
+ "content": "Thanks! What about the stock price of ACME?",
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model",
+ "options": {
+ "num_predict": 1024,
+ "stop": [
+ "END_TOKEN"
+ ],
+ "temperature": 0.7
+ },
+ "stream": false,
+ "tools": [
+ {
+ "function": {
+ "description": "get weather",
+ "name": "get_weather",
+ "parameters": {
+ "properties": {
+ "location": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "location"
+ ],
+ "type": "object"
+ }
+ },
+ "type": "function"
+ },
+ {
+ "function": {
+ "description": "stock quote",
+ "name": "lookup_stock",
+ "parameters": {
+ "properties": {
+ "symbol": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ },
+ "type": "function"
+ }
+ ]
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/full.response.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/full.response.json
new file mode 100644
index 0000000..b9bb77d
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/full.response.json
@@ -0,0 +1,32 @@
+{
+ "choices": [
+ {
+ "finish_reason": "tool_calls",
+ "index": 0,
+ "message": {
+ "content": "CAP_MARKER_ASSISTANT_TEXT the weather is 68F",
+ "reasoning_content": "CAP_MARKER_THINKING user asks weather",
+ "role": "assistant",
+ "tool_calls": [
+ {
+ "function": {
+ "arguments": "{\"location\":\"Boston\"}",
+ "name": "get_weather"
+ },
+ "id": "\u003cscrubbed\u003e",
+ "type": "function"
+ }
+ ]
+ }
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion",
+ "usage": {
+ "completion_tokens": 50,
+ "prompt_tokens": 100,
+ "total_tokens": 150
+ }
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/schema-variants.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/schema-variants.request.json
new file mode 100644
index 0000000..58307cf
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/schema-variants.request.json
@@ -0,0 +1,74 @@
+{
+ "messages": [
+ {
+ "content": "Search the repo with the where filter.",
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model",
+ "options": {
+ "num_predict": 512
+ },
+ "stream": false,
+ "tools": [
+ {
+ "function": {
+ "description": "search repo",
+ "name": "grep",
+ "parameters": {
+ "properties": {
+ "query": {
+ "properties": {
+ "paths": {
+ "type": "array"
+ },
+ "pattern": {
+ "type": "string"
+ },
+ "tag": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "number"
+ }
+ ]
+ },
+ "where": {
+ "items": {
+ "prefixItems": [
+ {
+ "type": "string"
+ },
+ {
+ "enum": [
+ "AND",
+ "OR"
+ ],
+ "type": "string"
+ },
+ {}
+ ],
+ "type": "array"
+ },
+ "maxItems": 10,
+ "type": "array"
+ }
+ },
+ "required": [
+ "pattern"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "query"
+ ],
+ "type": "object"
+ }
+ },
+ "type": "function"
+ }
+ ]
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/thinking.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/thinking.request.json
new file mode 100644
index 0000000..fa166c2
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/thinking.request.json
@@ -0,0 +1,14 @@
+{
+ "messages": [
+ {
+ "content": "Prove that sqrt(2) is irrational.",
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model",
+ "options": {
+ "num_predict": 4096
+ },
+ "stream": false,
+ "think": "high"
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/websearch.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/websearch.request.json
new file mode 100644
index 0000000..9e07bf0
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/websearch.request.json
@@ -0,0 +1,13 @@
+{
+ "messages": [
+ {
+ "content": "Search the latest AI news.",
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model",
+ "options": {
+ "num_predict": 1024
+ },
+ "stream": false
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/basic.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/basic.request.json
new file mode 100644
index 0000000..8d47fd6
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/basic.request.json
@@ -0,0 +1,19 @@
+{
+ "input": [
+ {
+ "content": [
+ {
+ "text": "Hello, how are you?",
+ "type": "input_text"
+ }
+ ],
+ "role": "user",
+ "type": "message"
+ }
+ ],
+ "instructions": "You are helpful.",
+ "max_output_tokens": 256,
+ "model": "upstream-model",
+ "store": false,
+ "temperature": 0.7
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/full.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/full.request.json
new file mode 100644
index 0000000..b141f78
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/full.request.json
@@ -0,0 +1,80 @@
+{
+ "input": [
+ {
+ "content": [
+ {
+ "text": "Describe this image and check the weather in Boston.",
+ "type": "input_text"
+ },
+ {
+ "image_url": "data:image/jpeg;base64,/9j/4AAQSkZJRg==",
+ "type": "input_image"
+ }
+ ],
+ "role": "user",
+ "type": "message"
+ },
+ {
+ "arguments": "{\"location\": \"Boston\"}",
+ "call_id": "call_1",
+ "name": "get_weather",
+ "type": "function_call"
+ },
+ {
+ "call_id": "call_1",
+ "output": "68F, sunny",
+ "type": "function_call_output"
+ },
+ {
+ "content": [
+ {
+ "text": "Thanks! What about the stock price of ACME?",
+ "type": "input_text"
+ }
+ ],
+ "role": "user",
+ "type": "message"
+ }
+ ],
+ "instructions": "You are a weather and finance assistant.",
+ "max_output_tokens": 1024,
+ "model": "upstream-model",
+ "store": false,
+ "stream": true,
+ "temperature": 0.7,
+ "tool_choice": {
+ "name": "get_weather",
+ "type": "function"
+ },
+ "tools": [
+ {
+ "description": "get weather",
+ "name": "get_weather",
+ "parameters": {
+ "properties": {
+ "location": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "location"
+ ],
+ "type": "object"
+ },
+ "type": "function"
+ },
+ {
+ "description": "stock quote",
+ "name": "lookup_stock",
+ "parameters": {
+ "properties": {
+ "symbol": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "type": "function"
+ }
+ ]
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/full.response.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/full.response.json
new file mode 100644
index 0000000..872537d
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/full.response.json
@@ -0,0 +1,32 @@
+{
+ "choices": [
+ {
+ "finish_reason": "stop",
+ "index": 0,
+ "message": {
+ "content": "CAP_MARKER_ASSISTANT_TEXT the weather is 68F",
+ "reasoning_content": "CAP_MARKER_THINKING user asks weather",
+ "role": "assistant",
+ "tool_calls": [
+ {
+ "function": {
+ "arguments": "{\"location\": \"Boston\"}",
+ "name": "get_weather"
+ },
+ "id": "\u003cscrubbed\u003e",
+ "type": "function"
+ }
+ ]
+ }
+ }
+ ],
+ "created": 0,
+ "id": "\u003cscrubbed\u003e",
+ "model": "origin-model",
+ "object": "chat.completion",
+ "usage": {
+ "completion_tokens": 50,
+ "prompt_tokens": 100,
+ "total_tokens": 150
+ }
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/schema-variants.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/schema-variants.request.json
new file mode 100644
index 0000000..5f7415f
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/schema-variants.request.json
@@ -0,0 +1,76 @@
+{
+ "input": [
+ {
+ "content": [
+ {
+ "text": "Search the repo with the where filter.",
+ "type": "input_text"
+ }
+ ],
+ "role": "user",
+ "type": "message"
+ }
+ ],
+ "max_output_tokens": 512,
+ "model": "upstream-model",
+ "store": false,
+ "tools": [
+ {
+ "description": "search repo",
+ "name": "grep",
+ "parameters": {
+ "properties": {
+ "query": {
+ "properties": {
+ "paths": {
+ "type": "array"
+ },
+ "pattern": {
+ "type": "string"
+ },
+ "tag": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "number"
+ }
+ ]
+ },
+ "where": {
+ "items": {
+ "prefixItems": [
+ {
+ "type": "string"
+ },
+ {
+ "enum": [
+ "AND",
+ "OR"
+ ],
+ "type": "string"
+ },
+ {}
+ ],
+ "type": "array"
+ },
+ "maxItems": 10,
+ "type": "array"
+ }
+ },
+ "required": [
+ "pattern"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "query"
+ ],
+ "type": "object"
+ },
+ "type": "function"
+ }
+ ]
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/thinking.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/thinking.request.json
new file mode 100644
index 0000000..84f8723
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/thinking.request.json
@@ -0,0 +1,21 @@
+{
+ "input": [
+ {
+ "content": [
+ {
+ "text": "Prove that sqrt(2) is irrational.",
+ "type": "input_text"
+ }
+ ],
+ "role": "user",
+ "type": "message"
+ }
+ ],
+ "max_output_tokens": 4096,
+ "model": "upstream-model",
+ "reasoning": {
+ "effort": "high",
+ "summary": "detailed"
+ },
+ "store": false
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/websearch.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/websearch.request.json
new file mode 100644
index 0000000..e9f9289
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/websearch.request.json
@@ -0,0 +1,22 @@
+{
+ "input": [
+ {
+ "content": [
+ {
+ "text": "Search the latest AI news.",
+ "type": "input_text"
+ }
+ ],
+ "role": "user",
+ "type": "message"
+ }
+ ],
+ "max_output_tokens": 1024,
+ "model": "upstream-model",
+ "store": false,
+ "tools": [
+ {
+ "type": "web_search"
+ }
+ ]
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/basic.request.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/basic.request.json
new file mode 100644
index 0000000..cdab73b
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/basic.request.json
@@ -0,0 +1,17 @@
+{
+ "max_tokens": 256,
+ "messages": [
+ {
+ "content": [
+ {
+ "text": "Hello, how are you?",
+ "type": "text"
+ }
+ ],
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model",
+ "system": "You are helpful.",
+ "temperature": 0.7
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/full.request.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/full.request.json
new file mode 100644
index 0000000..ae058dd
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/full.request.json
@@ -0,0 +1,95 @@
+{
+ "max_tokens": 1024,
+ "messages": [
+ {
+ "content": [
+ {
+ "text": "Describe this image and check the weather in Boston.",
+ "type": "text"
+ },
+ {
+ "source": {
+ "data": "/9j/4AAQSkZJRg==",
+ "media_type": "image/jpeg",
+ "type": "base64"
+ },
+ "type": "image"
+ }
+ ],
+ "role": "user"
+ },
+ {
+ "content": [
+ {
+ "text": "",
+ "type": "text"
+ },
+ {
+ "id": "call_1",
+ "input": {
+ "location": "Boston"
+ },
+ "name": "get_weather",
+ "type": "tool_use"
+ }
+ ],
+ "role": "assistant"
+ },
+ {
+ "content": [
+ {
+ "content": "68F, sunny",
+ "tool_use_id": "call_1",
+ "type": "tool_result"
+ }
+ ],
+ "role": "user"
+ },
+ {
+ "content": [
+ {
+ "text": "Thanks! What about the stock price of ACME?",
+ "type": "text"
+ }
+ ],
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model",
+ "stream": true,
+ "system": "You are a weather and finance assistant.",
+ "temperature": 0.7,
+ "tool_choice": {
+ "name": "get_weather",
+ "type": "tool"
+ },
+ "tools": [
+ {
+ "description": "get weather",
+ "input_schema": {
+ "properties": {
+ "location": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "location"
+ ],
+ "type": "object"
+ },
+ "name": "get_weather"
+ },
+ {
+ "description": "stock quote",
+ "input_schema": {
+ "properties": {
+ "symbol": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "name": "lookup_stock"
+ }
+ ]
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/full.response.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/full.response.json
new file mode 100644
index 0000000..69df3cf
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/full.response.json
@@ -0,0 +1,77 @@
+{
+ "completed_at": 0,
+ "created_at": 0,
+ "error": null,
+ "id": "\u003cscrubbed\u003e",
+ "incomplete_details": null,
+ "instructions": null,
+ "max_output_tokens": null,
+ "metadata": {},
+ "model": "origin-model",
+ "object": "response",
+ "output": [
+ {
+ "id": "\u003cscrubbed\u003e",
+ "summary": [
+ {
+ "text": "CAP_MARKER_THINKING user asks weather",
+ "type": "summary_text"
+ }
+ ],
+ "type": "reasoning"
+ },
+ {
+ "content": [
+ {
+ "annotations": [],
+ "text": "CAP_MARKER_ASSISTANT_TEXT the weather is 68F",
+ "type": "output_text"
+ }
+ ],
+ "id": "\u003cscrubbed\u003e",
+ "role": "assistant",
+ "status": "completed",
+ "type": "message"
+ },
+ {
+ "arguments": "{\"location\":\"Boston\"}",
+ "call_id": "toolu_1",
+ "id": "\u003cscrubbed\u003e",
+ "name": "get_weather",
+ "status": "completed",
+ "type": "function_call"
+ }
+ ],
+ "parallel_tool_calls": true,
+ "previous_response_id": null,
+ "reasoning": {
+ "effort": null,
+ "summary": null
+ },
+ "status": "completed",
+ "store": false,
+ "temperature": 1,
+ "text": {
+ "format": {
+ "type": "text"
+ }
+ },
+ "tool_choice": "auto",
+ "tools": [],
+ "top_p": 1,
+ "truncation": "disabled",
+ "usage": {
+ "input_tokens": 100,
+ "input_tokens_details": {
+ "audio_tokens": 0,
+ "cache_write_tokens": 0,
+ "cached_tokens": 0
+ },
+ "output_tokens": 50,
+ "output_tokens_details": {
+ "reasoning_tokens": 0
+ },
+ "total_tokens": 150
+ },
+ "user": null
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/reasoning-roundtrip.request.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/reasoning-roundtrip.request.json
new file mode 100644
index 0000000..f6c9d44
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/reasoning-roundtrip.request.json
@@ -0,0 +1,78 @@
+{
+ "max_tokens": 4096,
+ "messages": [
+ {
+ "content": [
+ {
+ "text": "list files",
+ "type": "text"
+ }
+ ],
+ "role": "user"
+ },
+ {
+ "content": [
+ {
+ "text": "",
+ "type": "text"
+ },
+ {
+ "id": "call_1",
+ "input": {
+ "cmd": "ls"
+ },
+ "name": "shell",
+ "type": "tool_use"
+ }
+ ],
+ "role": "assistant"
+ },
+ {
+ "content": [
+ {
+ "content": "Chunk ID: ab12cd\nOutput:\na.go b.go",
+ "tool_use_id": "call_1",
+ "type": "tool_result"
+ }
+ ],
+ "role": "user"
+ },
+ {
+ "content": [
+ {
+ "text": "目录里有两个 Go 文件。",
+ "type": "text"
+ }
+ ],
+ "role": "assistant"
+ },
+ {
+ "content": [
+ {
+ "text": "open a.go",
+ "type": "text"
+ }
+ ],
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model",
+ "stream": true,
+ "system": "You are a coding agent.",
+ "tools": [
+ {
+ "input_schema": {
+ "properties": {
+ "cmd": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "cmd"
+ ],
+ "type": "object"
+ },
+ "name": "shell"
+ }
+ ]
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/thinking.request.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/thinking.request.json
new file mode 100644
index 0000000..09ded16
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/thinking.request.json
@@ -0,0 +1,15 @@
+{
+ "max_tokens": 4096,
+ "messages": [
+ {
+ "content": [
+ {
+ "text": "Prove that sqrt(2) is irrational.",
+ "type": "text"
+ }
+ ],
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model"
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/websearch.request.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/websearch.request.json
new file mode 100644
index 0000000..474160f
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/websearch.request.json
@@ -0,0 +1,21 @@
+{
+ "max_tokens": 1024,
+ "messages": [
+ {
+ "content": [
+ {
+ "text": "Search the latest AI news.",
+ "type": "text"
+ }
+ ],
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model",
+ "tools": [
+ {
+ "name": "web_search",
+ "type": "web_search_20250305"
+ }
+ ]
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/basic.request.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/basic.request.json
new file mode 100644
index 0000000..04405eb
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/basic.request.json
@@ -0,0 +1,42 @@
+{
+ "contents": [
+ {
+ "parts": [
+ {
+ "text": "Hello, how are you?"
+ }
+ ],
+ "role": "user"
+ }
+ ],
+ "generationConfig": {
+ "maxOutputTokens": 256,
+ "temperature": 0.7
+ },
+ "safetySettings": [
+ {
+ "category": "HARM_CATEGORY_HARASSMENT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_HATE_SPEECH",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ }
+ ],
+ "systemInstruction": {
+ "parts": [
+ {
+ "text": "You are helpful."
+ }
+ ],
+ "role": ""
+ }
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/full.request.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/full.request.json
new file mode 100644
index 0000000..57af41a
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/full.request.json
@@ -0,0 +1,123 @@
+{
+ "contents": [
+ {
+ "parts": [
+ {
+ "text": "Describe this image and check the weather in Boston."
+ },
+ {
+ "inlineData": {
+ "data": "/9j/4AAQSkZJRg==",
+ "mimeType": "image/jpeg"
+ }
+ }
+ ],
+ "role": "user"
+ },
+ {
+ "parts": [
+ {
+ "functionCall": {
+ "args": {
+ "location": "Boston"
+ },
+ "name": "get_weather"
+ }
+ }
+ ],
+ "role": "model"
+ },
+ {
+ "parts": [
+ {
+ "functionResponse": {
+ "name": "get_weather",
+ "response": {
+ "result": "68F, sunny"
+ }
+ }
+ }
+ ],
+ "role": "user"
+ },
+ {
+ "parts": [
+ {
+ "text": "Thanks! What about the stock price of ACME?"
+ }
+ ],
+ "role": "user"
+ }
+ ],
+ "generationConfig": {
+ "maxOutputTokens": 1024,
+ "temperature": 0.7
+ },
+ "safetySettings": [
+ {
+ "category": "HARM_CATEGORY_HARASSMENT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_HATE_SPEECH",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ }
+ ],
+ "systemInstruction": {
+ "parts": [
+ {
+ "text": "You are a weather and finance assistant."
+ }
+ ],
+ "role": ""
+ },
+ "toolConfig": {
+ "functionCallingConfig": {
+ "allowedFunctionNames": [
+ "get_weather"
+ ],
+ "mode": "ANY"
+ }
+ },
+ "tools": [
+ {
+ "functionDeclarations": [
+ {
+ "description": "get weather",
+ "name": "get_weather",
+ "parameters": {
+ "properties": {
+ "location": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "location"
+ ],
+ "type": "object"
+ }
+ },
+ {
+ "description": "stock quote",
+ "name": "lookup_stock",
+ "parameters": {
+ "properties": {
+ "symbol": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ }
+ ]
+ }
+ ]
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/full.response.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/full.response.json
new file mode 100644
index 0000000..0c36a93
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/full.response.json
@@ -0,0 +1,80 @@
+{
+ "completed_at": 0,
+ "created_at": 0,
+ "error": null,
+ "id": "\u003cscrubbed\u003e",
+ "incomplete_details": null,
+ "instructions": null,
+ "max_output_tokens": null,
+ "metadata": {},
+ "model": "origin-model",
+ "object": "response",
+ "output": [
+ {
+ "id": "\u003cscrubbed\u003e",
+ "summary": [
+ {
+ "text": "CAP_MARKER_THINKING user asks weather",
+ "type": "summary_text"
+ }
+ ],
+ "type": "reasoning"
+ },
+ {
+ "content": [
+ {
+ "annotations": [],
+ "text": "CAP_MARKER_ASSISTANT_TEXT the weather is 68F",
+ "type": "output_text"
+ }
+ ],
+ "id": "\u003cscrubbed\u003e",
+ "role": "assistant",
+ "status": "completed",
+ "type": "message"
+ },
+ {
+ "arguments": "{\"location\":\"Boston\"}",
+ "call_id": "\u003cscrubbed\u003e",
+ "id": "\u003cscrubbed\u003e",
+ "name": "get_weather",
+ "status": "completed",
+ "type": "function_call"
+ }
+ ],
+ "parallel_tool_calls": true,
+ "previous_response_id": null,
+ "reasoning": {
+ "effort": null,
+ "summary": null
+ },
+ "status": "completed",
+ "store": false,
+ "temperature": 1,
+ "text": {
+ "format": {
+ "type": "text"
+ }
+ },
+ "tool_choice": "auto",
+ "tools": [],
+ "top_p": 1,
+ "truncation": "disabled",
+ "usage": {
+ "input_tokens": 100,
+ "input_tokens_details": {
+ "audio_tokens": 0,
+ "cache_write_tokens": 0,
+ "cached_tokens": 0
+ },
+ "output_tokens": 50,
+ "output_tokens_details": {
+ "accepted_prediction_tokens": 0,
+ "audio_tokens": 0,
+ "reasoning_tokens": 0,
+ "rejected_prediction_tokens": 0
+ },
+ "total_tokens": 150
+ },
+ "user": null
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/reasoning-roundtrip.request.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/reasoning-roundtrip.request.json
new file mode 100644
index 0000000..d7c5304
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/reasoning-roundtrip.request.json
@@ -0,0 +1,109 @@
+{
+ "contents": [
+ {
+ "parts": [
+ {
+ "text": "list files"
+ }
+ ],
+ "role": "user"
+ },
+ {
+ "parts": [
+ {
+ "text": "先用 shell 工具查看目录结构。",
+ "thought": true
+ },
+ {
+ "functionCall": {
+ "args": {
+ "cmd": "ls"
+ },
+ "name": "shell"
+ }
+ }
+ ],
+ "role": "model"
+ },
+ {
+ "parts": [
+ {
+ "functionResponse": {
+ "name": "shell",
+ "response": {
+ "result": "Chunk ID: ab12cd\nOutput:\na.go b.go"
+ }
+ }
+ }
+ ],
+ "role": "user"
+ },
+ {
+ "parts": [
+ {
+ "text": "目录里有两个 Go 文件。"
+ },
+ {
+ "text": "根据目录内容回复用户。",
+ "thought": true
+ }
+ ],
+ "role": "model"
+ },
+ {
+ "parts": [
+ {
+ "text": "open a.go"
+ }
+ ],
+ "role": "user"
+ }
+ ],
+ "generationConfig": {},
+ "safetySettings": [
+ {
+ "category": "HARM_CATEGORY_HARASSMENT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_HATE_SPEECH",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ }
+ ],
+ "systemInstruction": {
+ "parts": [
+ {
+ "text": "You are a coding agent."
+ }
+ ],
+ "role": ""
+ },
+ "tools": [
+ {
+ "functionDeclarations": [
+ {
+ "name": "shell",
+ "parameters": {
+ "properties": {
+ "cmd": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "cmd"
+ ],
+ "type": "object"
+ }
+ }
+ ]
+ }
+ ]
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/thinking.request.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/thinking.request.json
new file mode 100644
index 0000000..bc184b2
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/thinking.request.json
@@ -0,0 +1,37 @@
+{
+ "contents": [
+ {
+ "parts": [
+ {
+ "text": "Prove that sqrt(2) is irrational."
+ }
+ ],
+ "role": "user"
+ }
+ ],
+ "generationConfig": {
+ "maxOutputTokens": 4096,
+ "thinkingConfig": {
+ "includeThoughts": true,
+ "thinkingBudget": 32768
+ }
+ },
+ "safetySettings": [
+ {
+ "category": "HARM_CATEGORY_HARASSMENT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_HATE_SPEECH",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ }
+ ]
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/websearch.request.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/websearch.request.json
new file mode 100644
index 0000000..39692f3
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/websearch.request.json
@@ -0,0 +1,38 @@
+{
+ "contents": [
+ {
+ "parts": [
+ {
+ "text": "Search the latest AI news."
+ }
+ ],
+ "role": "user"
+ }
+ ],
+ "generationConfig": {
+ "maxOutputTokens": 1024
+ },
+ "safetySettings": [
+ {
+ "category": "HARM_CATEGORY_HARASSMENT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_HATE_SPEECH",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ },
+ {
+ "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+ "threshold": "BLOCK_ONLY_HIGH"
+ }
+ ],
+ "tools": [
+ {
+ "googleSearch": {}
+ }
+ ]
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/basic.request.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/basic.request.json
new file mode 100644
index 0000000..061ffeb
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/basic.request.json
@@ -0,0 +1,15 @@
+{
+ "max_tokens": 256,
+ "messages": [
+ {
+ "content": "You are helpful.",
+ "role": "system"
+ },
+ {
+ "content": "Hello, how are you?",
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model",
+ "temperature": 0.7
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/full.request.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/full.request.json
new file mode 100644
index 0000000..149ed53
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/full.request.json
@@ -0,0 +1,94 @@
+{
+ "max_tokens": 1024,
+ "messages": [
+ {
+ "content": "You are a weather and finance assistant.",
+ "role": "system"
+ },
+ {
+ "content": [
+ {
+ "text": "Describe this image and check the weather in Boston.",
+ "type": "text"
+ },
+ {
+ "image_url": {
+ "url": "data:image/jpeg;base64,/9j/4AAQSkZJRg=="
+ },
+ "type": "image_url"
+ }
+ ],
+ "role": "user"
+ },
+ {
+ "content": null,
+ "role": "assistant",
+ "tool_calls": [
+ {
+ "function": {
+ "arguments": "{\"location\": \"Boston\"}",
+ "name": "get_weather"
+ },
+ "id": "call_1",
+ "type": "function"
+ }
+ ]
+ },
+ {
+ "content": "68F, sunny",
+ "role": "tool",
+ "tool_call_id": "call_1"
+ },
+ {
+ "content": "Thanks! What about the stock price of ACME?",
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model",
+ "stream": true,
+ "stream_options": {
+ "include_usage": true
+ },
+ "temperature": 0.7,
+ "tool_choice": {
+ "function": {
+ "name": "get_weather"
+ },
+ "type": "function"
+ },
+ "tools": [
+ {
+ "function": {
+ "description": "get weather",
+ "name": "get_weather",
+ "parameters": {
+ "properties": {
+ "location": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "location"
+ ],
+ "type": "object"
+ }
+ },
+ "type": "function"
+ },
+ {
+ "function": {
+ "description": "stock quote",
+ "name": "lookup_stock",
+ "parameters": {
+ "properties": {
+ "symbol": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ },
+ "type": "function"
+ }
+ ]
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/full.response.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/full.response.json
new file mode 100644
index 0000000..4a016f5
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/full.response.json
@@ -0,0 +1,74 @@
+{
+ "completed_at": 0,
+ "conversation": null,
+ "created_at": 0,
+ "error": null,
+ "id": "\u003cscrubbed\u003e",
+ "incomplete_details": null,
+ "instructions": null,
+ "max_output_tokens": null,
+ "metadata": {},
+ "model": "origin-model",
+ "object": "response",
+ "output": [
+ {
+ "id": "\u003cscrubbed\u003e",
+ "summary": [
+ {
+ "text": "CAP_MARKER_THINKING user asks weather",
+ "type": "summary_text"
+ }
+ ],
+ "type": "reasoning"
+ },
+ {
+ "content": [
+ {
+ "text": "CAP_MARKER_ASSISTANT_TEXT the weather is 68F",
+ "type": "output_text"
+ }
+ ],
+ "id": "\u003cscrubbed\u003e",
+ "role": "assistant",
+ "status": "completed",
+ "type": "message"
+ },
+ {
+ "arguments": "{\"location\": \"Boston\"}",
+ "call_id": "call_1",
+ "id": "\u003cscrubbed\u003e",
+ "name": "get_weather",
+ "status": "completed",
+ "type": "function_call"
+ }
+ ],
+ "parallel_tool_calls": true,
+ "previous_response_id": null,
+ "prompt": null,
+ "reasoning": {
+ "effort": null,
+ "summary": null
+ },
+ "status": "completed",
+ "store": false,
+ "temperature": 1,
+ "text": {
+ "format": {
+ "type": "text"
+ }
+ },
+ "tool_choice": "auto",
+ "tools": [],
+ "top_p": 1,
+ "truncation": "disabled",
+ "usage": {
+ "input_tokens": 100,
+ "input_tokens_details": {
+ "cached_tokens": 0
+ },
+ "output_tokens": 50,
+ "output_tokens_details": {},
+ "total_tokens": 150
+ },
+ "user": null
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/reasoning-roundtrip.request.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/reasoning-roundtrip.request.json
new file mode 100644
index 0000000..8826d6f
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/reasoning-roundtrip.request.json
@@ -0,0 +1,65 @@
+{
+ "messages": [
+ {
+ "content": "You are a coding agent.",
+ "role": "system"
+ },
+ {
+ "content": "list files",
+ "role": "user"
+ },
+ {
+ "content": null,
+ "reasoning_content": "先用 shell 工具查看目录结构。",
+ "role": "assistant",
+ "tool_calls": [
+ {
+ "function": {
+ "arguments": "{\"cmd\":\"ls\"}",
+ "name": "shell"
+ },
+ "id": "call_1",
+ "type": "function"
+ }
+ ]
+ },
+ {
+ "content": "Chunk ID: ab12cd\nOutput:\na.go b.go",
+ "role": "tool",
+ "tool_call_id": "call_1"
+ },
+ {
+ "content": "目录里有两个 Go 文件。",
+ "reasoning_content": "根据目录内容回复用户。",
+ "role": "assistant"
+ },
+ {
+ "content": "open a.go",
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model",
+ "stream": true,
+ "stream_options": {
+ "include_usage": true
+ },
+ "tools": [
+ {
+ "function": {
+ "name": "shell",
+ "parameters": {
+ "properties": {
+ "cmd": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "cmd"
+ ],
+ "type": "object"
+ }
+ },
+ "type": "function"
+ }
+ ]
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/thinking.request.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/thinking.request.json
new file mode 100644
index 0000000..df25059
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/thinking.request.json
@@ -0,0 +1,11 @@
+{
+ "max_tokens": 4096,
+ "messages": [
+ {
+ "content": "Prove that sqrt(2) is irrational.",
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model",
+ "reasoning_effort": "high"
+}
diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/websearch.request.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/websearch.request.json
new file mode 100644
index 0000000..5c41e04
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/websearch.request.json
@@ -0,0 +1,11 @@
+{
+ "max_tokens": 1024,
+ "messages": [
+ {
+ "content": "Search the latest AI news.",
+ "role": "user"
+ }
+ ],
+ "model": "upstream-model",
+ "web_search_options": {}
+}
diff --git a/relaykit/relayconvert/register/golden/inputs/claude__basic.json b/relaykit/relayconvert/register/golden/inputs/claude__basic.json
new file mode 100644
index 0000000..74d10ce
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/inputs/claude__basic.json
@@ -0,0 +1,9 @@
+{
+ "model": "origin-model",
+ "max_tokens": 256,
+ "system": "You are helpful.",
+ "messages": [
+ {"role": "user", "content": "Hello, how are you?"}
+ ],
+ "temperature": 0.7
+}
diff --git a/relaykit/relayconvert/register/golden/inputs/claude__full.json b/relaykit/relayconvert/register/golden/inputs/claude__full.json
new file mode 100644
index 0000000..ff89f8f
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/inputs/claude__full.json
@@ -0,0 +1,27 @@
+{
+ "model": "origin-model",
+ "max_tokens": 1024,
+ "system": "You are a weather and finance assistant.",
+ "messages": [
+ {"role": "user", "content": [
+ {"type": "text", "text": "Describe this image and check the weather in Boston."},
+ {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "/9j/4AAQSkZJRg=="}}
+ ]},
+ {"role": "assistant", "content": [
+ {"type": "text", "text": "checking weather"},
+ {"type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": {"location": "Boston"}}
+ ]},
+ {"role": "user", "content": [
+ {"type": "tool_result", "tool_use_id": "toolu_1", "content": "68F, sunny"}
+ ]},
+ {"role": "user", "content": "Thanks! What about the stock price of ACME?"}
+ ],
+ "tools": [
+ {"name": "get_weather", "description": "get weather", "input_schema": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}},
+ {"name": "lookup_stock", "description": "stock quote", "input_schema": {"type": "object", "properties": {"symbol": {"type": "string"}}}}
+ ],
+ "tool_choice": {"type": "tool", "name": "get_weather"},
+ "stop_sequences": ["END_TOKEN"],
+ "temperature": 0.7,
+ "stream": true
+}
diff --git a/relaykit/relayconvert/register/golden/inputs/claude__thinking.json b/relaykit/relayconvert/register/golden/inputs/claude__thinking.json
new file mode 100644
index 0000000..f2b78b2
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/inputs/claude__thinking.json
@@ -0,0 +1,6 @@
+{
+ "model": "origin-model",
+ "max_tokens": 4096,
+ "messages": [{"role": "user", "content": "Prove that sqrt(2) is irrational."}],
+ "thinking": {"type": "enabled", "budget_tokens": 2048}
+}
diff --git a/relaykit/relayconvert/register/golden/inputs/claude__websearch.json b/relaykit/relayconvert/register/golden/inputs/claude__websearch.json
new file mode 100644
index 0000000..2ac538a
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/inputs/claude__websearch.json
@@ -0,0 +1,6 @@
+{
+ "model": "origin-model",
+ "max_tokens": 1024,
+ "messages": [{"role": "user", "content": "Search the latest AI news."}],
+ "tools": [{"type": "web_search_20250305", "name": "web_search", "max_uses": 5}]
+}
diff --git a/relaykit/relayconvert/register/golden/inputs/gemini__basic.json b/relaykit/relayconvert/register/golden/inputs/gemini__basic.json
new file mode 100644
index 0000000..7eafb86
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/inputs/gemini__basic.json
@@ -0,0 +1,7 @@
+{
+ "systemInstruction": {"parts": [{"text": "You are helpful."}]},
+ "contents": [
+ {"role": "user", "parts": [{"text": "Hello, how are you?"}]}
+ ],
+ "generationConfig": {"maxOutputTokens": 256, "temperature": 0.7}
+}
diff --git a/relaykit/relayconvert/register/golden/inputs/gemini__full.json b/relaykit/relayconvert/register/golden/inputs/gemini__full.json
new file mode 100644
index 0000000..38ee501
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/inputs/gemini__full.json
@@ -0,0 +1,25 @@
+{
+ "systemInstruction": {"parts": [{"text": "You are a weather and finance assistant."}]},
+ "contents": [
+ {"role": "user", "parts": [
+ {"text": "Describe this image and check the weather in Boston."},
+ {"inlineData": {"mimeType": "image/jpeg", "data": "/9j/4AAQSkZJRg=="}}
+ ]},
+ {"role": "model", "parts": [
+ {"text": "checking weather"},
+ {"functionCall": {"name": "get_weather", "args": {"location": "Boston"}}}
+ ]},
+ {"role": "user", "parts": [
+ {"functionResponse": {"name": "get_weather", "response": {"result": "68F, sunny"}}}
+ ]},
+ {"role": "user", "parts": [{"text": "Thanks! What about the stock price of ACME?"}]}
+ ],
+ "tools": [
+ {"functionDeclarations": [
+ {"name": "get_weather", "description": "get weather", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}},
+ {"name": "lookup_stock", "description": "stock quote", "parameters": {"type": "object", "properties": {"symbol": {"type": "string"}}}}
+ ]}
+ ],
+ "toolConfig": {"functionCallingConfig": {"mode": "ANY", "allowedFunctionNames": ["get_weather"]}},
+ "generationConfig": {"maxOutputTokens": 1024, "stopSequences": ["END_TOKEN"], "temperature": 0.7}
+}
diff --git a/relaykit/relayconvert/register/golden/inputs/gemini__thinking.json b/relaykit/relayconvert/register/golden/inputs/gemini__thinking.json
new file mode 100644
index 0000000..d55a945
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/inputs/gemini__thinking.json
@@ -0,0 +1,4 @@
+{
+ "contents": [{"role": "user", "parts": [{"text": "Prove that sqrt(2) is irrational."}]}],
+ "generationConfig": {"maxOutputTokens": 4096, "thinkingConfig": {"includeThoughts": true, "thinkingBudget": 2048}}
+}
diff --git a/relaykit/relayconvert/register/golden/inputs/gemini__websearch.json b/relaykit/relayconvert/register/golden/inputs/gemini__websearch.json
new file mode 100644
index 0000000..f2803b6
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/inputs/gemini__websearch.json
@@ -0,0 +1,5 @@
+{
+ "contents": [{"role": "user", "parts": [{"text": "Search the latest AI news."}]}],
+ "tools": [{"googleSearch": {}}],
+ "generationConfig": {"maxOutputTokens": 1024}
+}
diff --git a/relaykit/relayconvert/register/golden/inputs/openai__basic.json b/relaykit/relayconvert/register/golden/inputs/openai__basic.json
new file mode 100644
index 0000000..c3b3eef
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/inputs/openai__basic.json
@@ -0,0 +1,9 @@
+{
+ "model": "origin-model",
+ "messages": [
+ {"role": "system", "content": "You are helpful."},
+ {"role": "user", "content": "Hello, how are you?"}
+ ],
+ "temperature": 0.7,
+ "max_tokens": 256
+}
diff --git a/relaykit/relayconvert/register/golden/inputs/openai__full.json b/relaykit/relayconvert/register/golden/inputs/openai__full.json
new file mode 100644
index 0000000..40e1e96
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/inputs/openai__full.json
@@ -0,0 +1,24 @@
+{
+ "model": "origin-model",
+ "messages": [
+ {"role": "system", "content": "You are a weather and finance assistant."},
+ {"role": "user", "content": [
+ {"type": "text", "text": "Describe this image and check the weather in Boston."},
+ {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,/9j/4AAQSkZJRg=="}}
+ ]},
+ {"role": "assistant", "content": null, "tool_calls": [
+ {"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": "{\"location\": \"Boston\"}"}}
+ ]},
+ {"role": "tool", "tool_call_id": "call_1", "content": "68F, sunny"},
+ {"role": "user", "content": "Thanks! What about the stock price of ACME?"}
+ ],
+ "tools": [
+ {"type": "function", "function": {"name": "get_weather", "description": "get weather", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}}},
+ {"type": "function", "function": {"name": "lookup_stock", "description": "stock quote", "parameters": {"type": "object", "properties": {"symbol": {"type": "string"}}}}}
+ ],
+ "tool_choice": {"type": "function", "function": {"name": "get_weather"}},
+ "max_tokens": 1024,
+ "stop": ["END_TOKEN"],
+ "temperature": 0.7,
+ "stream": true
+}
diff --git a/relaykit/relayconvert/register/golden/inputs/openai__schema-variants.json b/relaykit/relayconvert/register/golden/inputs/openai__schema-variants.json
new file mode 100644
index 0000000..2176dc6
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/inputs/openai__schema-variants.json
@@ -0,0 +1,43 @@
+{
+ "model": "origin-model",
+ "messages": [
+ {"role": "user", "content": "Search the repo with the where filter."}
+ ],
+ "tools": [
+ {
+ "type": "function",
+ "function": {
+ "name": "grep",
+ "description": "search repo",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "object",
+ "properties": {
+ "pattern": {"type": "string"},
+ "where": {
+ "type": "array",
+ "maxItems": 10,
+ "items": {
+ "type": "array",
+ "prefixItems": [
+ {"type": "string"},
+ {"enum": ["AND", "OR"], "type": "string"},
+ {}
+ ]
+ }
+ },
+ "paths": {"type": "array"},
+ "tag": {"anyOf": [{"type": "string"}, {"type": "number"}]}
+ },
+ "required": ["pattern"]
+ }
+ },
+ "required": ["query"]
+ }
+ }
+ }
+ ],
+ "max_tokens": 512
+}
diff --git a/relaykit/relayconvert/register/golden/inputs/openai__thinking.json b/relaykit/relayconvert/register/golden/inputs/openai__thinking.json
new file mode 100644
index 0000000..7f87165
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/inputs/openai__thinking.json
@@ -0,0 +1,6 @@
+{
+ "model": "origin-model",
+ "messages": [{"role": "user", "content": "Prove that sqrt(2) is irrational."}],
+ "reasoning_effort": "high",
+ "max_tokens": 4096
+}
diff --git a/relaykit/relayconvert/register/golden/inputs/openai__websearch.json b/relaykit/relayconvert/register/golden/inputs/openai__websearch.json
new file mode 100644
index 0000000..64f89bc
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/inputs/openai__websearch.json
@@ -0,0 +1,6 @@
+{
+ "model": "origin-model",
+ "messages": [{"role": "user", "content": "Search the latest AI news."}],
+ "web_search_options": {},
+ "max_tokens": 1024
+}
diff --git a/relaykit/relayconvert/register/golden/inputs/openai_responses__basic.json b/relaykit/relayconvert/register/golden/inputs/openai_responses__basic.json
new file mode 100644
index 0000000..84abd8a
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/inputs/openai_responses__basic.json
@@ -0,0 +1,9 @@
+{
+ "model": "origin-model",
+ "instructions": "You are helpful.",
+ "input": [
+ {"role": "user", "content": [{"type": "input_text", "text": "Hello, how are you?"}]}
+ ],
+ "max_output_tokens": 256,
+ "temperature": 0.7
+}
diff --git a/relaykit/relayconvert/register/golden/inputs/openai_responses__full.json b/relaykit/relayconvert/register/golden/inputs/openai_responses__full.json
new file mode 100644
index 0000000..894875a
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/inputs/openai_responses__full.json
@@ -0,0 +1,21 @@
+{
+ "model": "origin-model",
+ "instructions": "You are a weather and finance assistant.",
+ "input": [
+ {"role": "user", "content": [
+ {"type": "input_text", "text": "Describe this image and check the weather in Boston."},
+ {"type": "input_image", "image_url": "data:image/jpeg;base64,/9j/4AAQSkZJRg=="}
+ ]},
+ {"type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": "{\"location\": \"Boston\"}"},
+ {"type": "function_call_output", "call_id": "call_1", "output": "68F, sunny"},
+ {"role": "user", "content": [{"type": "input_text", "text": "Thanks! What about the stock price of ACME?"}]}
+ ],
+ "tools": [
+ {"type": "function", "name": "get_weather", "description": "get weather", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}},
+ {"type": "function", "name": "lookup_stock", "description": "stock quote", "parameters": {"type": "object", "properties": {"symbol": {"type": "string"}}}}
+ ],
+ "tool_choice": {"type": "function", "name": "get_weather"},
+ "max_output_tokens": 1024,
+ "temperature": 0.7,
+ "stream": true
+}
diff --git a/relaykit/relayconvert/register/golden/inputs/openai_responses__reasoning-roundtrip.json b/relaykit/relayconvert/register/golden/inputs/openai_responses__reasoning-roundtrip.json
new file mode 100644
index 0000000..b1f570a
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/inputs/openai_responses__reasoning-roundtrip.json
@@ -0,0 +1,17 @@
+{
+ "model": "origin-model",
+ "instructions": "You are a coding agent.",
+ "input": [
+ {"role": "user", "content": [{"type": "input_text", "text": "list files"}]},
+ {"type": "reasoning", "content": [{"type": "reasoning_text", "text": "先用 shell 工具查看目录结构。"}]},
+ {"type": "function_call", "call_id": "call_1", "name": "shell", "arguments": "{\"cmd\":\"ls\"}"},
+ {"type": "function_call_output", "call_id": "call_1", "output": "Chunk ID: ab12cd\nOutput:\na.go b.go"},
+ {"type": "reasoning", "summary": [{"type": "summary_text", "text": "根据目录内容回复用户。"}]},
+ {"role": "assistant", "content": [{"type": "output_text", "text": "目录里有两个 Go 文件。"}]},
+ {"role": "user", "content": [{"type": "input_text", "text": "open a.go"}]}
+ ],
+ "tools": [
+ {"type": "function", "name": "shell", "parameters": {"type": "object", "properties": {"cmd": {"type": "string"}}, "required": ["cmd"]}}
+ ],
+ "stream": true
+}
diff --git a/relaykit/relayconvert/register/golden/inputs/openai_responses__thinking.json b/relaykit/relayconvert/register/golden/inputs/openai_responses__thinking.json
new file mode 100644
index 0000000..fa1d0ae
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/inputs/openai_responses__thinking.json
@@ -0,0 +1,6 @@
+{
+ "model": "origin-model",
+ "input": [{"role": "user", "content": [{"type": "input_text", "text": "Prove that sqrt(2) is irrational."}]}],
+ "reasoning": {"effort": "high"},
+ "max_output_tokens": 4096
+}
diff --git a/relaykit/relayconvert/register/golden/inputs/openai_responses__websearch.json b/relaykit/relayconvert/register/golden/inputs/openai_responses__websearch.json
new file mode 100644
index 0000000..d90cf26
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/inputs/openai_responses__websearch.json
@@ -0,0 +1,6 @@
+{
+ "model": "origin-model",
+ "input": [{"role": "user", "content": [{"type": "input_text", "text": "Search the latest AI news."}]}],
+ "tools": [{"type": "web_search"}],
+ "max_output_tokens": 1024
+}
diff --git a/relaykit/relayconvert/register/golden/stream_inputs/claude__basic.txt b/relaykit/relayconvert/register/golden/stream_inputs/claude__basic.txt
new file mode 100644
index 0000000..af29f32
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/stream_inputs/claude__basic.txt
@@ -0,0 +1,20 @@
+event: message_start
+data: {"type":"message_start","message":{"id":"msg_up","type":"message","role":"assistant","content":[],"model":"upstream-model","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":12,"output_tokens":1,"cache_read_input_tokens":4}}}
+
+event: content_block_start
+data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
+
+event: content_block_delta
+data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}
+
+event: content_block_delta
+data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":", world!"}}
+
+event: content_block_stop
+data: {"type":"content_block_stop","index":0}
+
+event: message_delta
+data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":7}}
+
+event: message_stop
+data: {"type":"message_stop"}
diff --git a/relaykit/relayconvert/register/golden/stream_inputs/claude__thinking.txt b/relaykit/relayconvert/register/golden/stream_inputs/claude__thinking.txt
new file mode 100644
index 0000000..6b4d5e9
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/stream_inputs/claude__thinking.txt
@@ -0,0 +1,29 @@
+event: message_start
+data: {"type":"message_start","message":{"id":"msg_up","type":"message","role":"assistant","content":[],"model":"upstream-model","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":1}}}
+
+event: content_block_start
+data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}
+
+event: content_block_delta
+data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Let me think..."}}
+
+event: content_block_delta
+data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig_abc123"}}
+
+event: content_block_stop
+data: {"type":"content_block_stop","index":0}
+
+event: content_block_start
+data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}
+
+event: content_block_delta
+data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"The answer is 42."}}
+
+event: content_block_stop
+data: {"type":"content_block_stop","index":1}
+
+event: message_delta
+data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":50}}
+
+event: message_stop
+data: {"type":"message_stop"}
diff --git a/relaykit/relayconvert/register/golden/stream_inputs/claude__tools.txt b/relaykit/relayconvert/register/golden/stream_inputs/claude__tools.txt
new file mode 100644
index 0000000..9471884
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/stream_inputs/claude__tools.txt
@@ -0,0 +1,29 @@
+event: message_start
+data: {"type":"message_start","message":{"id":"msg_up","type":"message","role":"assistant","content":[],"model":"upstream-model","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":20,"output_tokens":1}}}
+
+event: content_block_start
+data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
+
+event: content_block_delta
+data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Let me check the weather."}}
+
+event: content_block_stop
+data: {"type":"content_block_stop","index":0}
+
+event: content_block_start
+data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{}}}
+
+event: content_block_delta
+data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"city\":"}}
+
+event: content_block_delta
+data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\"Shanghai\"}"}}
+
+event: content_block_stop
+data: {"type":"content_block_stop","index":1}
+
+event: message_delta
+data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":9}}
+
+event: message_stop
+data: {"type":"message_stop"}
diff --git a/relaykit/relayconvert/register/golden/stream_inputs/gemini__basic.txt b/relaykit/relayconvert/register/golden/stream_inputs/gemini__basic.txt
new file mode 100644
index 0000000..a204657
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/stream_inputs/gemini__basic.txt
@@ -0,0 +1,5 @@
+data: {"candidates":[{"index":0,"content":{"role":"model","parts":[{"text":"Hello"}]}}]}
+
+data: {"candidates":[{"index":0,"content":{"role":"model","parts":[{"text":", world!"}]}}]}
+
+data: {"candidates":[{"index":0,"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":12,"candidatesTokenCount":7,"totalTokenCount":19,"cachedContentTokenCount":4}}
diff --git a/relaykit/relayconvert/register/golden/stream_inputs/gemini__thinking.txt b/relaykit/relayconvert/register/golden/stream_inputs/gemini__thinking.txt
new file mode 100644
index 0000000..520d3da
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/stream_inputs/gemini__thinking.txt
@@ -0,0 +1,5 @@
+data: {"candidates":[{"index":0,"content":{"role":"model","parts":[{"thought":true,"text":"Let me think...","thoughtSignature":"sig_abc123"}]}}]}
+
+data: {"candidates":[{"index":0,"content":{"role":"model","parts":[{"text":"The answer is 42."}]}}]}
+
+data: {"candidates":[{"index":0,"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":50,"totalTokenCount":60,"thoughtsTokenCount":30}}
diff --git a/relaykit/relayconvert/register/golden/stream_inputs/gemini__tools.txt b/relaykit/relayconvert/register/golden/stream_inputs/gemini__tools.txt
new file mode 100644
index 0000000..f32ffaa
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/stream_inputs/gemini__tools.txt
@@ -0,0 +1,5 @@
+data: {"candidates":[{"index":0,"content":{"role":"model","parts":[{"text":"Let me check the weather."}]}}]}
+
+data: {"candidates":[{"index":0,"content":{"role":"model","parts":[{"functionCall":{"name":"get_weather","args":{"city":"Shanghai"}}}]}}]}
+
+data: {"candidates":[{"index":0,"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":20,"candidatesTokenCount":9,"totalTokenCount":29}}
diff --git a/relaykit/relayconvert/register/golden/stream_inputs/ollama__basic.txt b/relaykit/relayconvert/register/golden/stream_inputs/ollama__basic.txt
new file mode 100644
index 0000000..72a6a3f
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/stream_inputs/ollama__basic.txt
@@ -0,0 +1,3 @@
+{"model":"upstream-model","created_at":"2026-01-01T00:00:00Z","message":{"role":"assistant","content":"Hello"},"done":false}
+{"model":"upstream-model","created_at":"2026-01-01T00:00:01Z","message":{"role":"assistant","content":", world!"},"done":false}
+{"model":"upstream-model","created_at":"2026-01-01T00:00:02Z","message":{"role":"assistant","content":""},"done":true,"done_reason":"stop","total_duration":5000000000,"prompt_eval_count":12,"eval_count":7}
diff --git a/relaykit/relayconvert/register/golden/stream_inputs/ollama__thinking.txt b/relaykit/relayconvert/register/golden/stream_inputs/ollama__thinking.txt
new file mode 100644
index 0000000..949e0b4
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/stream_inputs/ollama__thinking.txt
@@ -0,0 +1,3 @@
+{"model":"upstream-model","created_at":"2026-01-01T00:00:00Z","message":{"role":"assistant","content":"","thinking":"Let me think..."},"done":false}
+{"model":"upstream-model","created_at":"2026-01-01T00:00:01Z","message":{"role":"assistant","content":"The answer is 42."},"done":false}
+{"model":"upstream-model","created_at":"2026-01-01T00:00:02Z","message":{"role":"assistant","content":""},"done":true,"done_reason":"stop","total_duration":5000000000,"prompt_eval_count":10,"eval_count":50}
diff --git a/relaykit/relayconvert/register/golden/stream_inputs/ollama__tools.txt b/relaykit/relayconvert/register/golden/stream_inputs/ollama__tools.txt
new file mode 100644
index 0000000..8d5a750
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/stream_inputs/ollama__tools.txt
@@ -0,0 +1,2 @@
+{"model":"upstream-model","created_at":"2026-01-01T00:00:00Z","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"get_weather","arguments":{"city":"Shanghai"}}}]},"done":false}
+{"model":"upstream-model","created_at":"2026-01-01T00:00:01Z","message":{"role":"assistant","content":""},"done":true,"done_reason":"stop","total_duration":5000000000,"prompt_eval_count":20,"eval_count":9}
diff --git a/relaykit/relayconvert/register/golden/stream_inputs/openai__basic.txt b/relaykit/relayconvert/register/golden/stream_inputs/openai__basic.txt
new file mode 100644
index 0000000..b05c8d2
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/stream_inputs/openai__basic.txt
@@ -0,0 +1,9 @@
+data: {"id":"chatcmpl-up","object":"chat.completion.chunk","created":1700000000,"model":"upstream-model","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}
+
+data: {"id":"chatcmpl-up","object":"chat.completion.chunk","created":1700000000,"model":"upstream-model","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}
+
+data: {"id":"chatcmpl-up","object":"chat.completion.chunk","created":1700000000,"model":"upstream-model","choices":[{"index":0,"delta":{"content":", world!"},"finish_reason":null}]}
+
+data: {"id":"chatcmpl-up","object":"chat.completion.chunk","created":1700000000,"model":"upstream-model","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":12,"completion_tokens":7,"total_tokens":19,"prompt_tokens_details":{"cached_tokens":4}}}
+
+data: [DONE]
diff --git a/relaykit/relayconvert/register/golden/stream_inputs/openai__thinking.txt b/relaykit/relayconvert/register/golden/stream_inputs/openai__thinking.txt
new file mode 100644
index 0000000..f3dcb4b
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/stream_inputs/openai__thinking.txt
@@ -0,0 +1,7 @@
+data: {"id":"chatcmpl-up","object":"chat.completion.chunk","created":1700000000,"model":"upstream-model","choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"Let me think..."},"finish_reason":null}]}
+
+data: {"id":"chatcmpl-up","object":"chat.completion.chunk","created":1700000000,"model":"upstream-model","choices":[{"index":0,"delta":{"content":"The answer is 42."},"finish_reason":null}]}
+
+data: {"id":"chatcmpl-up","object":"chat.completion.chunk","created":1700000000,"model":"upstream-model","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":50,"total_tokens":60,"completion_tokens_details":{"reasoning_tokens":30}}}
+
+data: [DONE]
diff --git a/relaykit/relayconvert/register/golden/stream_inputs/openai__tools.txt b/relaykit/relayconvert/register/golden/stream_inputs/openai__tools.txt
new file mode 100644
index 0000000..16b3f1d
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/stream_inputs/openai__tools.txt
@@ -0,0 +1,9 @@
+data: {"id":"chatcmpl-up","object":"chat.completion.chunk","created":1700000000,"model":"upstream-model","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]}
+
+data: {"id":"chatcmpl-up","object":"chat.completion.chunk","created":1700000000,"model":"upstream-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":"}}]},"finish_reason":null}]}
+
+data: {"id":"chatcmpl-up","object":"chat.completion.chunk","created":1700000000,"model":"upstream-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"Shanghai\"}"}}]},"finish_reason":null}]}
+
+data: {"id":"chatcmpl-up","object":"chat.completion.chunk","created":1700000000,"model":"upstream-model","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":20,"completion_tokens":9,"total_tokens":29}}
+
+data: [DONE]
diff --git a/relaykit/relayconvert/register/golden/stream_inputs/openai_responses__basic.txt b/relaykit/relayconvert/register/golden/stream_inputs/openai_responses__basic.txt
new file mode 100644
index 0000000..2548267
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/stream_inputs/openai_responses__basic.txt
@@ -0,0 +1,16 @@
+event: response.created
+data: {"type":"response.created","response":{"id":"resp_up","object":"response","created_at":1700000000,"status":"in_progress","model":"upstream-model"}}
+
+event: response.output_item.added
+data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"msg_up","status":"in_progress","role":"assistant","content":[]}}
+
+event: response.output_text.delta
+data: {"type":"response.output_text.delta","item_id":"msg_up","output_index":0,"content_index":0,"delta":"Hello"}
+
+event: response.output_text.delta
+data: {"type":"response.output_text.delta","item_id":"msg_up","output_index":0,"content_index":0,"delta":", world!"}
+
+event: response.completed
+data: {"type":"response.completed","response":{"id":"resp_up","object":"response","created_at":1700000000,"status":"completed","model":"upstream-model","usage":{"input_tokens":12,"output_tokens":7,"total_tokens":19,"input_tokens_details":{"cached_tokens":4}}}}
+
+data: [DONE]
diff --git a/relaykit/relayconvert/register/golden/stream_inputs/openai_responses__thinking.txt b/relaykit/relayconvert/register/golden/stream_inputs/openai_responses__thinking.txt
new file mode 100644
index 0000000..55d69ab
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/stream_inputs/openai_responses__thinking.txt
@@ -0,0 +1,13 @@
+event: response.created
+data: {"type":"response.created","response":{"id":"resp_up","object":"response","created_at":1700000000,"status":"in_progress","model":"upstream-model"}}
+
+event: response.reasoning_summary_text.delta
+data: {"type":"response.reasoning_summary_text.delta","item_id":"msg_up","output_index":0,"summary_index":0,"delta":"Let me think..."}
+
+event: response.output_text.delta
+data: {"type":"response.output_text.delta","item_id":"msg_up","output_index":0,"content_index":0,"delta":"The answer is 42."}
+
+event: response.completed
+data: {"type":"response.completed","response":{"id":"resp_up","object":"response","created_at":1700000000,"status":"completed","model":"upstream-model","usage":{"input_tokens":10,"output_tokens":50,"total_tokens":60,"output_tokens_details":{"reasoning_tokens":30}}}}
+
+data: [DONE]
diff --git a/relaykit/relayconvert/register/golden/stream_inputs/openai_responses__tools.txt b/relaykit/relayconvert/register/golden/stream_inputs/openai_responses__tools.txt
new file mode 100644
index 0000000..dc886fe
--- /dev/null
+++ b/relaykit/relayconvert/register/golden/stream_inputs/openai_responses__tools.txt
@@ -0,0 +1,16 @@
+event: response.created
+data: {"type":"response.created","response":{"id":"resp_up","object":"response","created_at":1700000000,"status":"in_progress","model":"upstream-model"}}
+
+event: response.output_item.added
+data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"get_weather","arguments":""}}
+
+event: response.function_call_arguments.delta
+data: {"type":"response.function_call_arguments.delta","item_id":"call_1","output_index":0,"delta":"{\"city\":"}
+
+event: response.function_call_arguments.delta
+data: {"type":"response.function_call_arguments.delta","item_id":"call_1","output_index":0,"delta":"\"Shanghai\"}"}
+
+event: response.completed
+data: {"type":"response.completed","response":{"id":"resp_up","object":"response","created_at":1700000000,"status":"completed","model":"upstream-model","usage":{"input_tokens":20,"output_tokens":9,"total_tokens":29}}}
+
+data: [DONE]
diff --git a/relaykit/relayconvert/register/goldensuite_stream_test.go b/relaykit/relayconvert/register/goldensuite_stream_test.go
new file mode 100644
index 0000000..890bc34
--- /dev/null
+++ b/relaykit/relayconvert/register/goldensuite_stream_test.go
@@ -0,0 +1,267 @@
+package register
+
+// goldensuite_stream_test.go — 注册表级流式金样本套件(全部流式方向)。
+//
+// 与非流式套件(goldensuite_test.go)同构:一套共享上游流语料
+// (golden/stream_inputs/<上游格式>__<场景>.txt,SSE / NDJSON wire 原文)由
+// ListStreamConverterSpecs 枚举驱动全部已注册流式方向,新注册方向零成本纳入。
+// 期望输出(帧序列)按转换器落盘在 golden/expected/<转换器ID>/<场景>.stream.json。
+//
+// 帧序列表达:每帧 {event?, data, usage?}——
+// - OpenAI chat chunk(*dto.ChatCompletionStreamResponse):无 event,data 为 chunk 本体;
+// - StreamEvent:event 为事件名(Claude/Responses 风格;Gemini 风格为空省略),
+// usage 为宿主将捕获的计费用量(billing 契约的一部分,必须锁进金样本)。
+//
+// 工作流与非流式套件一致:
+// - 首次生成 / 确认新行为:go test -run TestGoldenSuite -update-golden ./relayconvert/register/
+// - 日常回归:输出变化 → 比对失败;符合预期则 -update-golden 后 git diff 审查变化面。
+//
+// 另含 TestStreamInvariants_ToolsAndUsage:不锁具体形状,只断言每个方向的语义底线
+// (工具名不丢、用量可捕获、终止语义符合客户端协议),金样本重生成也绕不过它。
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+
+ "github.com/qianfree/team-api/relaykit/dto"
+ "github.com/qianfree/team-api/relaykit/relayconvert"
+ "github.com/qianfree/team-api/relaykit/types"
+ "github.com/stretchr/testify/require"
+)
+
+// streamScenarios 流式场景清单;某上游格式缺少某场景语料时该组合自动跳过。
+var streamScenarios = []string{"basic", "tools", "thinking"}
+
+// streamFrame 一帧转换输出的金样本形态。
+type streamFrame struct {
+ Event string `json:"event,omitempty"`
+ Data any `json:"data"`
+ Usage any `json:"usage,omitempty"`
+}
+
+// streamStats 转换过程的语义统计(供不依赖金样本形状的守恒断言使用)。
+type streamStats struct {
+ usageSeen bool // 出现过宿主可捕获的用量(chat chunk Usage 或 StreamEvent.Usage)
+ finishSeen bool // chat chunk 出现过非空 finish_reason
+ eventNames []string // StreamEvent 事件名序列(Gemini 风格空名也计入,为空串)
+ chatChunkCount int
+}
+
+// runStreamConverter 以注册表查到的流式转换器消费语料,收集帧序列与语义统计。
+func runStreamConverter(t *testing.T, converterID string, corpus []byte) ([]streamFrame, *streamStats) {
+ t.Helper()
+
+ fn, ok := relayconvert.LookupStreamConverterByID(converterID)
+ require.True(t, ok, "流式转换器 %s 未注册", converterID)
+
+ var frames []streamFrame
+ stats := &streamStats{}
+ err := fn(context.Background(), capMeta(), bytes.NewReader(corpus), func(chunk any) error {
+ switch c := chunk.(type) {
+ case *dto.ChatCompletionStreamResponse:
+ stats.chatChunkCount++
+ if c.Usage != nil {
+ stats.usageSeen = true
+ }
+ for _, choice := range c.Choices {
+ if choice.FinishReason != nil {
+ stats.finishSeen = true
+ }
+ }
+ frames = append(frames, streamFrame{Data: c})
+ case *relayconvert.StreamEvent:
+ if c == nil {
+ return nil
+ }
+ stats.eventNames = append(stats.eventNames, c.Event)
+ f := streamFrame{Event: c.Event, Data: c.Data}
+ if c.Usage != nil {
+ stats.usageSeen = true
+ f.Usage = c.Usage
+ }
+ frames = append(frames, f)
+ default:
+ t.Errorf("流式转换器 %s 产出未知 chunk 类型 %T(宿主桥接层会静默丢弃)", converterID, chunk)
+ }
+ return nil
+ })
+ require.NoError(t, err, "流式转换失败")
+ return frames, stats
+}
+
+func TestGoldenSuite_Streams(t *testing.T) {
+ for _, spec := range relayconvert.ListStreamConverterSpecs() {
+ for _, scenario := range streamScenarios {
+ inPath := filepath.Join("golden", "stream_inputs", string(spec.From)+"__"+scenario+".txt")
+ corpus, err := os.ReadFile(inPath)
+ if os.IsNotExist(err) {
+ continue // 该上游格式无此场景语料
+ }
+ require.NoError(t, err)
+
+ t.Run(spec.ID+"/"+scenario, func(t *testing.T) {
+ frames, _ := runStreamConverter(t, spec.ID, corpus)
+ require.NotEmpty(t, frames, "转换器未产出任何帧")
+
+ // 帧序列 → 通用 JSON 树 → 清洗非确定字段,再走与非流式一致的比对/重生流程
+ raw, err := json.Marshal(frames)
+ require.NoError(t, err)
+ var tree any
+ require.NoError(t, json.Unmarshal(raw, &tree))
+ tree = scrubStreamNondeterministic(tree)
+
+ compareOrUpdateGolden(t,
+ filepath.Join("golden", "expected", spec.ID, scenario+".stream.json"),
+ tree, false)
+ })
+ }
+ }
+}
+
+// synthesizedStreamID 流式转换器按时间戳合成的 ID 形态(resp_/msg_/fc_/chatcmpl- 前缀
+// 接毫秒/纳秒时间戳,或 call_[_])。透传自语料的短 ID(msg_up / toolu_1 /
+// call_1 等)不匹配、不清洗,保持可断言。
+var synthesizedStreamID = regexp.MustCompile(`^(?:resp_|msg_|fc_|chatcmpl-)\d{10,}$|^call_\d{10,}(?:_\d+)?$`)
+
+// scrubStreamNondeterministic 递归清洗流式帧树中的非确定字段:
+// - 命中 nondeterministicKeys 的 key(id/created/created_at/completed_at 等)按类型置占位;
+// - 任意位置的字符串值若匹配合成 ID 模式(如 item_id 里的 msg_<纳秒>)也置占位。
+func scrubStreamNondeterministic(v any) any {
+ switch node := v.(type) {
+ case map[string]any:
+ for k, val := range node {
+ if nondeterministicKeys[k] {
+ switch val.(type) {
+ case string:
+ node[k] = ""
+ case float64:
+ node[k] = 0
+ }
+ continue
+ }
+ node[k] = scrubStreamNondeterministic(val)
+ }
+ return node
+ case []any:
+ for i := range node {
+ node[i] = scrubStreamNondeterministic(node[i])
+ }
+ return node
+ case string:
+ if synthesizedStreamID.MatchString(node) {
+ return ""
+ }
+ return node
+ default:
+ return v
+ }
+}
+
+// TestStreamInvariants_ToolsAndUsage 流式语义守恒:对每个已注册流式方向灌入 tools
+// 语料(含工具调用 + 用量 + 正常收尾的上游流),断言与金样本形状无关的语义底线:
+//
+// 1. 工具调用名不得静默丢失(转换后帧序列中必须出现 get_weather);
+// 2. 用量必须以宿主可捕获的形态产出(chat chunk 的 Usage 或 StreamEvent.Usage),
+// 否则计费按 0 token 结算;
+// 3. 终止语义符合客户端协议:OpenAI 需带 finish_reason 的 chunk、Claude 需
+// message_stop 事件、Responses 需 response.completed 事件、Gemini 需 finishReason 字段。
+//
+// 每个上游格式必须提供 tools 语料(新增上游流格式时在 stream_inputs 补齐)。
+func TestStreamInvariants_ToolsAndUsage(t *testing.T) {
+ for _, spec := range relayconvert.ListStreamConverterSpecs() {
+ t.Run(spec.ID, func(t *testing.T) {
+ corpus, err := os.ReadFile(filepath.Join("golden", "stream_inputs", string(spec.From)+"__tools.txt"))
+ require.NoError(t, err, "上游格式 %s 缺少 tools 流式语料,请在 golden/stream_inputs/ 补充", spec.From)
+
+ frames, stats := runStreamConverter(t, spec.ID, corpus)
+ require.NotEmpty(t, frames)
+
+ raw, err := json.Marshal(frames)
+ require.NoError(t, err)
+ require.Contains(t, string(raw), "get_weather", "工具调用名在流式转换后丢失(静默能力丢失)")
+
+ require.True(t, stats.usageSeen, "未产出宿主可捕获的用量(计费将按 0 token 结算)")
+
+ switch spec.To {
+ case types.RelayFormatOpenAI:
+ require.True(t, stats.finishSeen, "OpenAI 客户端方向未产出带 finish_reason 的终止 chunk")
+ case types.RelayFormatClaude:
+ require.Contains(t, stats.eventNames, "message_stop", "Claude 客户端方向未产出 message_stop 终止事件")
+ case types.RelayFormatOpenAIResponses:
+ require.Contains(t, stats.eventNames, "response.completed", "Responses 客户端方向未产出 response.completed 终止事件")
+ case types.RelayFormatGemini:
+ require.Contains(t, string(raw), "finishReason", "Gemini 客户端方向未产出带 finishReason 的终止帧")
+ default:
+ t.Errorf("未支持的客户端格式 %s,请补充终止语义断言", spec.To)
+ }
+ })
+ }
+}
+
+// TestGoldenSuite_StreamInputCorpusRegistered 流式语料目录完整性:stream_inputs 下的
+// 文件名必须能被套件识别(<已知上游格式>__<已知场景>.txt),防止拼错文件名被静默跳过。
+func TestGoldenSuite_StreamInputCorpusRegistered(t *testing.T) {
+ knownFormats := map[string]bool{
+ string(types.RelayFormatOpenAI): true,
+ string(types.RelayFormatClaude): true,
+ string(types.RelayFormatGemini): true,
+ string(types.RelayFormatOpenAIResponses): true,
+ string(types.RelayFormatOllama): true,
+ }
+ knownScenarios := map[string]bool{}
+ for _, s := range streamScenarios {
+ knownScenarios[s] = true
+ }
+
+ files, err := os.ReadDir(filepath.Join("golden", "stream_inputs"))
+ require.NoError(t, err)
+ for _, f := range files {
+ name := strings.TrimSuffix(f.Name(), ".txt")
+ parts := strings.SplitN(name, "__", 2)
+ if len(parts) != 2 || !knownFormats[parts[0]] || !knownScenarios[parts[1]] {
+ t.Errorf("流式语料文件 %s 不符合 <上游格式>__<场景>.txt 命名(格式∈%v,场景∈%v),会被套件静默跳过",
+ f.Name(), keysOf(knownFormats), streamScenarios)
+ }
+ }
+}
+
+// geminiGroundedStream 带 groundingMetadata 的 Gemini 上游流:
+// grounding 只在**末帧**到达,正文增量此前早已推给客户端。
+const geminiGroundedStream = `data: {"candidates":[{"index":0,"content":{"role":"model","parts":[{"text":"Boston is 68F"}]}}]}
+
+data: {"candidates":[{"index":0,"finishReason":"STOP","groundingMetadata":{"webSearchQueries":["boston weather"],"groundingChunks":[{"web":{"uri":"https://weather.example/boston","title":"weather.example"}}],"groundingSupports":[{"segment":{"startIndex":0,"endIndex":6,"text":"Boston"},"groundingChunkIndices":[0]}]}}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15}}
+
+`
+
+// TestStreamInvariants_GroundingRestore 流式响应侧搜索证据还原:
+// Gemini 上游在末帧给出 groundingMetadata 时,来源 URL 必须出现在客户端收到的流里。
+//
+// 这是非流式 grounding 还原(TestCapabilityInvariants_GroundingRestore)的流式对照。
+// 流式的难点在于时序:证据到得比正文晚,转换器必须缓冲并在收尾补发,
+// 漏做就会出现「非流式有引用、流式没有」的不一致——而客户端通常只用流式。
+func TestStreamInvariants_GroundingRestore(t *testing.T) {
+ for _, spec := range relayconvert.ListStreamConverterSpecs() {
+ if spec.From != types.RelayFormatGemini {
+ continue // grounding 是 Gemini 侧构件
+ }
+
+ t.Run(spec.ID, func(t *testing.T) {
+ frames, _ := runStreamConverter(t, spec.ID, []byte(geminiGroundedStream))
+ require.NotEmpty(t, frames)
+
+ raw, err := json.Marshal(frames)
+ require.NoError(t, err)
+ body := string(raw)
+
+ require.Contains(t, body, "https://weather.example/boston",
+ "流式转换后搜索来源丢失(非流式已还原、流式漏做会导致两者行为不一致)\n实际帧序列: %s", body)
+ require.Contains(t, body, "Boston is 68F", "回答正文在 grounding 还原后丢失")
+ })
+ }
+}
diff --git a/relaykit/relayconvert/register/goldensuite_test.go b/relaykit/relayconvert/register/goldensuite_test.go
new file mode 100644
index 0000000..e53bd9f
--- /dev/null
+++ b/relaykit/relayconvert/register/goldensuite_test.go
@@ -0,0 +1,212 @@
+package register
+
+// goldensuite_test.go — 注册表级金样本套件(全方向请求 + 响应)。
+//
+// 与各转换器包内的 golden_test 不同,本套件由注册表驱动:一套共享场景语料
+// (golden/inputs/<入站格式>__<场景>.json,wire JSON)自动覆盖所有已注册方向,
+// 新注册方向零成本纳入。期望输出按转换器落盘在
+// golden/expected/<转换器ID>/<场景>.request.json(响应侧 .response.json)。
+//
+// 工作流:
+// - 首次生成 / 确认新行为:go test -run TestGoldenSuite -update-golden ./relayconvert/register/
+// - 日常回归:任何改动导致某方向输出变化,对应 expected 文件比对失败;
+// 若变化符合预期,重新 -update-golden 后 git diff 审查 expected 文件的变化面——
+// 改 A 方向却看到 B 方向的 expected 变化,即为意外的跨方向影响。
+// - 语料扩充:把真实客户端流量(脱敏后)按场景命名放进 golden/inputs/ 即自动生效。
+//
+// 响应侧非确定字段(合成的 id / created 时间戳等)在比对前于全树置为固定占位符。
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+
+ "github.com/qianfree/team-api/relaykit/relayconvert"
+ "github.com/qianfree/team-api/relaykit/relayconvert/internal/goldentest"
+ "github.com/qianfree/team-api/relaykit/types"
+ "github.com/stretchr/testify/require"
+)
+
+// goldenScenarios 请求场景清单;某入站格式缺少某场景语料时该组合自动跳过。
+// schema-variants / reasoning-roundtrip 来自线上真实 bug 形状的反哺(2026-09-12 语料战役,
+// 见 docs/codex请求问题排查记录.md):前者是 Claude Code Grep 工具的 prefixItems 元组
+// + 嵌套数组 + 空 schema 节点,后者是 codex 多轮历史的明文 reasoning 项双形态回传。
+var goldenScenarios = []string{"basic", "full", "websearch", "thinking", "schema-variants", "reasoning-roundtrip"}
+
+// nondeterministicKeys 响应侧转换器合成的非确定字段(任意层级,按 key 名匹配)。
+// 比对前统一置为占位符,保证金样本可重复。
+var nondeterministicKeys = map[string]bool{
+ "id": true,
+ "created": true,
+ "created_at": true,
+ "completed_at": true,
+ "responseId": true,
+ "response_id": true,
+ // call_id 不整体清洗:透传自输入的具确定性;仅合成形态按 synthesizedCallID 模式清洗
+}
+
+func TestGoldenSuite_Requests(t *testing.T) {
+ ctx := context.Background()
+
+ for _, id := range relayconvert.ListRequestConverterIDs() {
+ spec, ok := relayconvert.LookupRequestConverter(id)
+ require.True(t, ok)
+
+ for _, scenario := range goldenScenarios {
+ inPath := filepath.Join("golden", "inputs", string(spec.From)+"__"+scenario+".json")
+ input, err := os.ReadFile(inPath)
+ if os.IsNotExist(err) {
+ continue // 该入站格式无此场景语料
+ }
+ require.NoError(t, err)
+
+ t.Run(id+"/"+scenario, func(t *testing.T) {
+ parsed := parseWireRequest(t, spec.From, string(input))
+ converted, convErr := relayconvert.ConvertRequestByID(ctx, capMeta(), id, parsed)
+ require.NoError(t, convErr, "转换失败")
+ compareOrUpdateGolden(t,
+ filepath.Join("golden", "expected", id, scenario+".request.json"),
+ converted, false)
+ })
+ }
+ }
+}
+
+func TestGoldenSuite_Responses(t *testing.T) {
+ ctx := context.Background()
+
+ for _, id := range relayconvert.ListResponseConverterIDs() {
+ spec, ok := relayconvert.LookupResponseConverter(id)
+ require.True(t, ok)
+ if spec.Convert == nil {
+ continue // 纯流式登记不在非流式金样本范围
+ }
+
+ input, hasInput := upstreamWireResponses[spec.To]
+ if !hasInput {
+ t.Errorf("响应转换器 %s 的上游格式 %s 没有响应语料", id, spec.To)
+ continue
+ }
+
+ t.Run(id, func(t *testing.T) {
+ parsed := parseWireResponse(t, spec.To, input)
+ converted, _, convErr := spec.Convert(ctx, capMeta(), parsed)
+ require.NoError(t, convErr, "响应转换失败")
+ compareOrUpdateGolden(t,
+ filepath.Join("golden", "expected", id, "full.response.json"),
+ converted, true)
+ })
+ }
+}
+
+// compareOrUpdateGolden 将转换结果与金样本文件比对;-update-golden 时重写文件。
+// scrub=true 时先将全树非确定字段置为占位符(响应侧)。
+func compareOrUpdateGolden(t *testing.T, path string, converted any, scrub bool) {
+ t.Helper()
+
+ raw, err := json.Marshal(converted)
+ require.NoError(t, err, "marshal 转换结果")
+ var actual any
+ require.NoError(t, json.Unmarshal(raw, &actual))
+ if scrub {
+ actual = scrubNondeterministic(actual)
+ }
+
+ if *goldentest.Update {
+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
+ data, err := json.MarshalIndent(actual, "", " ")
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(path, append(data, '\n'), 0o644))
+ t.Logf("金样本已更新: %s", path)
+ return
+ }
+
+ expectedRaw, err := os.ReadFile(path)
+ if os.IsNotExist(err) {
+ t.Fatalf("金样本 %s 不存在,首次生成请运行: go test -run TestGoldenSuite -update-golden ./relayconvert/register/", path)
+ }
+ require.NoError(t, err)
+ var expected any
+ require.NoError(t, json.Unmarshal(expectedRaw, &expected))
+
+ if !goldentest.Equal(actual, expected) {
+ actualPretty, _ := json.MarshalIndent(actual, "", " ")
+ t.Errorf("转换输出与金样本 %s 不一致。\n若变更符合预期,运行 -update-golden 重生并在 git diff 中审查变化面。\n实际输出:\n%s", path, actualPretty)
+ }
+}
+
+// synthesizedCallID 匹配转换器合成的 call_id(如 Gemini functionCall 无 id 时按
+// 纳秒时间戳生成 call__);透传自输入的 call_id(call_1 / toolu_1 等)不清洗。
+var synthesizedCallID = regexp.MustCompile(`^call_\d{15,}_\d+$`)
+
+// scrubNondeterministic 递归将非确定字段置为固定占位符。
+// 只处理值为字符串/数字的命中 key,避免误伤同名的结构字段。
+func scrubNondeterministic(v any) any {
+ switch node := v.(type) {
+ case map[string]any:
+ for k, val := range node {
+ if nondeterministicKeys[k] {
+ switch val.(type) {
+ case string:
+ node[k] = ""
+ case float64:
+ node[k] = 0
+ }
+ continue
+ }
+ if k == "call_id" {
+ if s, ok := val.(string); ok && synthesizedCallID.MatchString(s) {
+ node[k] = ""
+ continue
+ }
+ }
+ node[k] = scrubNondeterministic(val)
+ }
+ return node
+ case []any:
+ for i := range node {
+ node[i] = scrubNondeterministic(node[i])
+ }
+ return node
+ default:
+ return v
+ }
+}
+
+// TestGoldenSuite_InputCorpusRegistered 语料目录完整性:inputs 下的文件名必须能被
+// 套件识别(<已知格式>__<已知场景>.json),防止拼错文件名导致语料被静默跳过。
+func TestGoldenSuite_InputCorpusRegistered(t *testing.T) {
+ knownFormats := map[string]bool{
+ string(types.RelayFormatOpenAI): true,
+ string(types.RelayFormatClaude): true,
+ string(types.RelayFormatGemini): true,
+ string(types.RelayFormatOpenAIResponses): true,
+ }
+ knownScenarios := map[string]bool{}
+ for _, s := range goldenScenarios {
+ knownScenarios[s] = true
+ }
+
+ files, err := os.ReadDir(filepath.Join("golden", "inputs"))
+ require.NoError(t, err)
+ for _, f := range files {
+ name := strings.TrimSuffix(f.Name(), ".json")
+ parts := strings.SplitN(name, "__", 2)
+ if len(parts) != 2 || !knownFormats[parts[0]] || !knownScenarios[parts[1]] {
+ t.Errorf("语料文件 %s 不符合 <格式>__<场景>.json 命名(格式∈%v,场景∈%v),会被套件静默跳过",
+ f.Name(), keysOf(knownFormats), goldenScenarios)
+ }
+ }
+}
+
+func keysOf(m map[string]bool) []string {
+ out := make([]string, 0, len(m))
+ for k := range m {
+ out = append(out, k)
+ }
+ return out
+}
diff --git a/relaykit/relayconvert/register/register.go b/relaykit/relayconvert/register/register.go
index 6d93c27..e6373d9 100644
--- a/relaykit/relayconvert/register/register.go
+++ b/relaykit/relayconvert/register/register.go
@@ -16,10 +16,11 @@ import (
"github.com/qianfree/team-api/relaykit/dto"
"github.com/qianfree/team-api/relaykit/relayconvert"
"github.com/qianfree/team-api/relaykit/relayconvert/convmeta"
- "github.com/qianfree/team-api/relaykit/relayconvert/internal/coze_chat"
- "github.com/qianfree/team-api/relaykit/relayconvert/internal/dify_chat"
+ "github.com/qianfree/team-api/relaykit/relayconvert/internal/claude_gemini"
+ "github.com/qianfree/team-api/relaykit/relayconvert/internal/native_responses"
"github.com/qianfree/team-api/relaykit/relayconvert/internal/oai_chat"
"github.com/qianfree/team-api/relaykit/relayconvert/internal/oai_gemini"
+ "github.com/qianfree/team-api/relaykit/relayconvert/internal/oai_responses"
"github.com/qianfree/team-api/relaykit/relayconvert/internal/ollama_chat"
"github.com/qianfree/team-api/relaykit/types"
)
@@ -28,9 +29,11 @@ func init() {
registerOpenAIToClaude()
registerOpenAIToGemini()
// 剩余原生格式供应商
- registerOpenAIToCoze()
- registerOpenAIToDify()
registerOpenAIToOllama()
+ // 反向方向(非 OpenAI 入站 → OpenAI 上游)与 Responses 双向
+ registerReverseAndResponsesDirections()
+ // 跨原生方向(请求经 OpenAI 中枢链式,响应直连)
+ registerCrossNativeDirections()
}
// registerOpenAIToClaude 注册 OpenAI → Claude 方向转换器。
@@ -100,70 +103,6 @@ func registerOpenAIToGemini() {
)
}
-// registerOpenAIToCoze 注册 OpenAI → Coze 方向转换器。
-// 客户端说 OpenAI,上游说 Coze v3:
-// - 请求侧 OpenAI → Coze
-// - 响应侧 Coze → OpenAI(非流式:解析缓冲 SSE;流式:SSE→SSE)
-func registerOpenAIToCoze() {
- reqConv := &coze_chat.OpenAIToCozeRequestConverter{}
- respConv := &coze_chat.CozeToOpenAIResponseConverter{}
-
- relayconvert.RegisterTextConverter(relayconvert.TextConverterSpec{
- ID: relayconvert.ConverterOpenAIChatToCoze,
- From: types.RelayFormatOpenAI,
- To: types.RelayFormatCoze,
- Quality: relayconvert.TextConverterQualityFair,
- Req: relayconvert.TextRequestSide{
- Convert: reqConv.ConvertRequest,
- },
- Resp: relayconvert.TextResponseSide{
- Convert: func(ctx context.Context, info convmeta.Meta, response any) (any, *dto.Usage, error) {
- result, err := respConv.ConvertResponse(ctx, info, response)
- return result, nil, err
- },
- },
- })
-
- // 流式响应侧:Coze SSE → OpenAI SSE(方向与请求相反)。
- relayconvert.RegisterStreamConverter(
- types.RelayFormatCoze, types.RelayFormatOpenAI,
- relayconvert.ResponseConverterCozeChatToOAIChatStream,
- (&coze_chat.CozeToOpenAIStreamConverter{}).ConvertStreamResponse,
- )
-}
-
-// registerOpenAIToDify 注册 OpenAI → Dify 方向转换器。
-// 客户端说 OpenAI,上游说 Dify chat-messages:
-// - 请求侧 OpenAI → Dify
-// - 响应侧 Dify → OpenAI(非流式 blocking JSON;流式 SSE→SSE)
-func registerOpenAIToDify() {
- reqConv := &dify_chat.OpenAIToDifyRequestConverter{}
- respConv := &dify_chat.DifyToOpenAIResponseConverter{}
-
- relayconvert.RegisterTextConverter(relayconvert.TextConverterSpec{
- ID: relayconvert.ConverterOpenAIChatToDify,
- From: types.RelayFormatOpenAI,
- To: types.RelayFormatDify,
- Quality: relayconvert.TextConverterQualityFair,
- Req: relayconvert.TextRequestSide{
- Convert: reqConv.ConvertRequest,
- },
- Resp: relayconvert.TextResponseSide{
- Convert: func(ctx context.Context, info convmeta.Meta, response any) (any, *dto.Usage, error) {
- result, err := respConv.ConvertResponse(ctx, info, response)
- return result, nil, err
- },
- },
- })
-
- // 流式响应侧:Dify SSE → OpenAI SSE(方向与请求相反)。
- relayconvert.RegisterStreamConverter(
- types.RelayFormatDify, types.RelayFormatOpenAI,
- relayconvert.ResponseConverterDifyChatToOAIChatStream,
- (&dify_chat.DifyToOpenAIStreamConverter{}).ConvertStreamResponse,
- )
-}
-
// registerOpenAIToOllama 注册 OpenAI → Ollama 方向转换器(仅 chat 路径)。
// 客户端说 OpenAI,上游说 Ollama /api/chat:
// - 请求侧 OpenAI → Ollama
@@ -197,3 +136,271 @@ func registerOpenAIToOllama() {
(&ollama_chat.OllamaToOpenAIStreamConverter{}).ConvertStreamResponse,
)
}
+
+// registerReverseAndResponsesDirections 注册反向方向(非 OpenAI 入站 → OpenAI 上游)
+// 与 Responses 双向转换器。
+//
+// 单侧注册:反向方向的请求侧与响应侧分属不同的配对 spec(如 Claude 入站 → OpenAI 上游,
+// 请求走 c2o、响应走 o2c),故按方向分别注册,注册表允许某一侧为空。
+func registerReverseAndResponsesDirections() {
+ registerClaudeToOpenAI()
+ registerGeminiToOpenAI()
+ registerResponsesToOpenAI()
+ registerOpenAIToResponses()
+}
+
+// registerClaudeToOpenAI 注册 Claude 入站 → OpenAI 上游方向。
+// 请求侧 Claude → OpenAI;响应侧 OpenAI → Claude(含流式)。
+func registerClaudeToOpenAI() {
+ reqConv := &oai_chat.ClaudeToOpenAIRequestConverter{}
+ respConv := &oai_chat.OpenAIToClaudeResponseConverter{}
+
+ relayconvert.RegisterTextConverter(relayconvert.TextConverterSpec{
+ ID: relayconvert.ConverterClaudeMessagesToOpenAIChat,
+ From: types.RelayFormatClaude,
+ To: types.RelayFormatOpenAI,
+ Quality: relayconvert.TextConverterQualityFair,
+ Req: relayconvert.TextRequestSide{
+ Convert: reqConv.ConvertRequest,
+ },
+ Resp: relayconvert.TextResponseSide{
+ Convert: func(ctx context.Context, info convmeta.Meta, response any) (any, *dto.Usage, error) {
+ result, err := respConv.ConvertResponse(ctx, info, response)
+ return result, nil, err
+ },
+ },
+ })
+
+ // 流式响应侧:OpenAI SSE → Claude 事件流(方向与请求相反)。
+ relayconvert.RegisterStreamConverter(
+ types.RelayFormatOpenAI, types.RelayFormatClaude,
+ relayconvert.ResponseConverterOAIChatToClaudeMessagesStream,
+ (&oai_chat.OpenAIToClaudeStreamConverter{}).ConvertStreamResponse,
+ )
+}
+
+// registerGeminiToOpenAI 注册 Gemini 入站 → OpenAI 上游方向。
+// 请求侧 Gemini → OpenAI;响应侧 OpenAI → Gemini(含流式)。
+func registerGeminiToOpenAI() {
+ reqConv := &oai_gemini.GeminiToOpenAIRequestConverter{}
+ respConv := &oai_gemini.OpenAIToGeminiResponseConverter{}
+
+ relayconvert.RegisterTextConverter(relayconvert.TextConverterSpec{
+ ID: relayconvert.ConverterGeminiContentToOpenAIChat,
+ From: types.RelayFormatGemini,
+ To: types.RelayFormatOpenAI,
+ Quality: relayconvert.TextConverterQualityGood,
+ Req: relayconvert.TextRequestSide{
+ Convert: reqConv.ConvertRequest,
+ },
+ Resp: relayconvert.TextResponseSide{
+ Convert: func(ctx context.Context, info convmeta.Meta, response any) (any, *dto.Usage, error) {
+ result, err := respConv.ConvertResponse(ctx, info, response)
+ return result, nil, err
+ },
+ },
+ })
+
+ // 流式响应侧:OpenAI SSE → Gemini data 帧(方向与请求相反)。
+ relayconvert.RegisterStreamConverter(
+ types.RelayFormatOpenAI, types.RelayFormatGemini,
+ relayconvert.ResponseConverterOAIChatToGeminiChatStream,
+ (&oai_gemini.OpenAIToGeminiStreamConverter{}).ConvertStreamResponse,
+ )
+}
+
+// registerResponsesToOpenAI 注册 Responses 入站 → OpenAI chat 上游方向。
+// 请求侧 Responses → chat;响应侧 chat → Responses(含流式)。
+func registerResponsesToOpenAI() {
+ reqConv := &oai_responses.ResponsesToOpenAIRequestConverter{}
+ respConv := &oai_responses.OpenAIToResponsesResponseConverter{}
+
+ relayconvert.RegisterTextConverter(relayconvert.TextConverterSpec{
+ ID: relayconvert.ConverterOpenAIResponsesToOpenAIChat,
+ From: types.RelayFormatOpenAIResponses,
+ To: types.RelayFormatOpenAI,
+ Quality: relayconvert.TextConverterQualityFair,
+ Req: relayconvert.TextRequestSide{
+ Convert: reqConv.ConvertRequest,
+ },
+ Resp: relayconvert.TextResponseSide{
+ Convert: func(ctx context.Context, info convmeta.Meta, response any) (any, *dto.Usage, error) {
+ result, err := respConv.ConvertResponse(ctx, info, response)
+ return result, nil, err
+ },
+ },
+ })
+
+ // 流式响应侧:OpenAI chat SSE → Responses 事件流(方向与请求相反)。
+ relayconvert.RegisterStreamConverter(
+ types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses,
+ relayconvert.ResponseConverterOAIChatToOAIResponsesStream,
+ (&oai_responses.OpenAIToResponsesStreamConverter{}).ConvertStreamResponse,
+ )
+}
+
+// registerOpenAIToResponses 注册 OpenAI chat 入站 → Responses 上游方向
+// (渠道 ChatViaResponses 桥接:chat 请求经 /v1/responses 发送)。
+// 请求侧 chat → Responses;响应侧 Responses → chat(含流式)。
+func registerOpenAIToResponses() {
+ reqConv := &oai_responses.OpenAIToResponsesRequestConverter{}
+ respConv := &oai_responses.ResponsesToOpenAIResponseConverter{}
+
+ relayconvert.RegisterTextConverter(relayconvert.TextConverterSpec{
+ ID: relayconvert.ConverterOpenAIChatToOpenAIResponses,
+ From: types.RelayFormatOpenAI,
+ To: types.RelayFormatOpenAIResponses,
+ Quality: relayconvert.TextConverterQualityGood,
+ Req: relayconvert.TextRequestSide{
+ Convert: reqConv.ConvertRequest,
+ },
+ Resp: relayconvert.TextResponseSide{
+ Convert: func(ctx context.Context, info convmeta.Meta, response any) (any, *dto.Usage, error) {
+ result, err := respConv.ConvertResponse(ctx, info, response)
+ return result, nil, err
+ },
+ },
+ })
+
+ // 流式响应侧:Responses 事件流 → OpenAI chat SSE(方向与请求相反)。
+ relayconvert.RegisterStreamConverter(
+ types.RelayFormatOpenAIResponses, types.RelayFormatOpenAI,
+ relayconvert.ResponseConverterOAIResponsesToOAIChatStream,
+ (&oai_responses.ResponsesToOpenAIStreamConverter{}).ConvertStreamResponse,
+ )
+}
+
+// registerCrossNativeDirections 注册跨原生方向(Claude↔Gemini、Responses→Claude/Gemini)。
+//
+// 响应侧一律为直连转换器(保真度高于两跳,thinking 签名等不经中间格式丢失);
+// 请求侧已直连的方向走 Convert,其余走「经 OpenAI 中枢」的步骤链(StepConverters):
+// 如 Gemini→Claude 实际执行 Gemini→OpenAI→Claude 两跳,各跳复用已注册的直连转换器,
+// 注册时校验 From/To 连续性。
+func registerCrossNativeDirections() {
+ registerClaudeToGemini()
+ registerGeminiToClaude()
+ registerResponsesToClaude()
+ registerResponsesToGemini()
+}
+
+// registerClaudeToGemini 注册 Claude 入站 → Gemini 上游。
+// 请求直连 Claude→Gemini;响应直连 Gemini→Claude(含流式)。
+func registerClaudeToGemini() {
+ reqConv := &claude_gemini.ClaudeToGeminiRequestConverter{}
+ respConv := &claude_gemini.GeminiToClaudeResponseConverter{}
+
+ relayconvert.RegisterTextConverter(relayconvert.TextConverterSpec{
+ ID: relayconvert.ConverterClaudeMessagesToGeminiContent,
+ From: types.RelayFormatClaude,
+ To: types.RelayFormatGemini,
+ Quality: relayconvert.TextConverterQualityFair,
+ Req: relayconvert.TextRequestSide{
+ Convert: reqConv.ConvertRequest,
+ },
+ Resp: relayconvert.TextResponseSide{
+ Convert: func(ctx context.Context, info convmeta.Meta, response any) (any, *dto.Usage, error) {
+ result, err := respConv.ConvertResponse(ctx, info, response)
+ return result, nil, err
+ },
+ },
+ })
+
+ relayconvert.RegisterStreamConverter(
+ types.RelayFormatGemini, types.RelayFormatClaude,
+ relayconvert.ResponseConverterGeminiChatToClaudeMessagesStream,
+ (&claude_gemini.GeminiToClaudeStreamConverter{}).ConvertStreamResponse,
+ )
+}
+
+// registerGeminiToClaude 注册 Gemini 入站 → Claude 上游。
+// 请求链 Gemini→OpenAI→Claude;响应直连 Claude→Gemini(含流式)。
+func registerGeminiToClaude() {
+ respConv := &claude_gemini.ClaudeToGeminiResponseConverter{}
+
+ relayconvert.RegisterTextConverter(relayconvert.TextConverterSpec{
+ ID: relayconvert.ConverterGeminiContentToClaudeMessages,
+ From: types.RelayFormatGemini,
+ To: types.RelayFormatClaude,
+ Quality: relayconvert.TextConverterQualityFair,
+ Req: relayconvert.TextRequestSide{
+ StepConverters: []string{
+ relayconvert.ConverterGeminiContentToOpenAIChat,
+ relayconvert.ConverterOpenAIChatToClaudeMessages,
+ },
+ },
+ Resp: relayconvert.TextResponseSide{
+ Convert: func(ctx context.Context, info convmeta.Meta, response any) (any, *dto.Usage, error) {
+ result, err := respConv.ConvertResponse(ctx, info, response)
+ return result, nil, err
+ },
+ },
+ })
+
+ relayconvert.RegisterStreamConverter(
+ types.RelayFormatClaude, types.RelayFormatGemini,
+ relayconvert.ResponseConverterClaudeMessagesToGeminiChatStream,
+ (&claude_gemini.ClaudeToGeminiStreamConverter{}).ConvertStreamResponse,
+ )
+}
+
+// registerResponsesToClaude 注册 Responses 入站 → Claude 上游。
+// 请求链 Responses→OpenAI→Claude;响应直连 Claude→Responses(含流式)。
+func registerResponsesToClaude() {
+ respConv := &native_responses.ClaudeToResponsesResponseConverter{}
+
+ relayconvert.RegisterTextConverter(relayconvert.TextConverterSpec{
+ ID: relayconvert.ConverterResponsesToClaudeMessages,
+ From: types.RelayFormatOpenAIResponses,
+ To: types.RelayFormatClaude,
+ Quality: relayconvert.TextConverterQualityFair,
+ Req: relayconvert.TextRequestSide{
+ StepConverters: []string{
+ relayconvert.ConverterOpenAIResponsesToOpenAIChat,
+ relayconvert.ConverterOpenAIChatToClaudeMessages,
+ },
+ },
+ Resp: relayconvert.TextResponseSide{
+ Convert: func(ctx context.Context, info convmeta.Meta, response any) (any, *dto.Usage, error) {
+ result, err := respConv.ConvertResponse(ctx, info, response)
+ return result, nil, err
+ },
+ },
+ })
+
+ relayconvert.RegisterStreamConverter(
+ types.RelayFormatClaude, types.RelayFormatOpenAIResponses,
+ relayconvert.ResponseConverterClaudeMessagesToOAIResponsesStream,
+ (&native_responses.ClaudeToResponsesStreamConverter{}).ConvertStreamResponse,
+ )
+}
+
+// registerResponsesToGemini 注册 Responses 入站 → Gemini 上游。
+// 请求链 Responses→OpenAI→Gemini;响应直连 Gemini→Responses(含流式)。
+func registerResponsesToGemini() {
+ respConv := &native_responses.GeminiToResponsesResponseConverter{}
+
+ relayconvert.RegisterTextConverter(relayconvert.TextConverterSpec{
+ ID: relayconvert.ConverterOpenAIResponsesToGemini,
+ From: types.RelayFormatOpenAIResponses,
+ To: types.RelayFormatGemini,
+ Quality: relayconvert.TextConverterQualityFair,
+ Req: relayconvert.TextRequestSide{
+ StepConverters: []string{
+ relayconvert.ConverterOpenAIResponsesToOpenAIChat,
+ relayconvert.ConverterOpenAIChatToGeminiContent,
+ },
+ },
+ Resp: relayconvert.TextResponseSide{
+ Convert: func(ctx context.Context, info convmeta.Meta, response any) (any, *dto.Usage, error) {
+ result, err := respConv.ConvertResponse(ctx, info, response)
+ return result, nil, err
+ },
+ },
+ })
+
+ relayconvert.RegisterStreamConverter(
+ types.RelayFormatGemini, types.RelayFormatOpenAIResponses,
+ relayconvert.ResponseConverterGeminiChatToOAIResponsesStream,
+ (&native_responses.GeminiToResponsesStreamConverter{}).ConvertStreamResponse,
+ )
+}
diff --git a/relaykit/relayconvert/register/register_test.go b/relaykit/relayconvert/register/register_test.go
index 8c32d7d..7ddff0a 100644
--- a/relaykit/relayconvert/register/register_test.go
+++ b/relaykit/relayconvert/register/register_test.go
@@ -27,18 +27,6 @@ func TestBuiltinConvertersRegistered(t *testing.T) {
wantReq: true,
wantResp: true,
},
- {
- name: "OpenAI→Coze",
- converterID: relayconvert.ConverterOpenAIChatToCoze,
- wantReq: true,
- wantResp: true,
- },
- {
- name: "OpenAI→Dify",
- converterID: relayconvert.ConverterOpenAIChatToDify,
- wantReq: true,
- wantResp: true,
- },
{
name: "OpenAI→Ollama",
converterID: relayconvert.ConverterOpenAIChatToOllama,
@@ -84,18 +72,6 @@ func TestBuiltinStreamConvertersRegistered(t *testing.T) {
to: types.RelayFormatOpenAI,
wantID: relayconvert.ResponseConverterGeminiChatToOAIChatStream,
},
- {
- name: "Coze→OpenAI",
- from: types.RelayFormatCoze,
- to: types.RelayFormatOpenAI,
- wantID: relayconvert.ResponseConverterCozeChatToOAIChatStream,
- },
- {
- name: "Dify→OpenAI",
- from: types.RelayFormatDify,
- to: types.RelayFormatOpenAI,
- wantID: relayconvert.ResponseConverterDifyChatToOAIChatStream,
- },
{
name: "Ollama→OpenAI",
from: types.RelayFormatOllama,
diff --git a/relaykit/relayconvert/register/roundtrip_test.go b/relaykit/relayconvert/register/roundtrip_test.go
index f3b4dec..1604cd5 100644
--- a/relaykit/relayconvert/register/roundtrip_test.go
+++ b/relaykit/relayconvert/register/roundtrip_test.go
@@ -59,19 +59,6 @@ func TestRegisteredConverters_Roundtrip(t *testing.T) {
UsageMetadata: &dto.GeminiUsageMetadata{PromptTokenCount: 5, CandidatesTokenCount: 3},
},
},
- {
- name: "Coze", converterID: relayconvert.ConverterOpenAIChatToCoze, upstream: "bot-123",
- // Coze 上游始终为 SSE,非流式客户端也由桥接缓冲整段后交给转换器解析。
- nativeResp: []byte("event: conversation.message.completed\n" +
- `data: {"role":"assistant","type":"answer","content":"hi"}` + "\n"),
- },
- {
- name: "Dify", converterID: relayconvert.ConverterOpenAIChatToDify, upstream: "dify-bot",
- nativeResp: &dto.DifyBlockingResponse{
- Answer: "hi",
- Metadata: dto.DifyMeta{Usage: dto.DifyUsage{TotalTokens: 8, PromptTokens: 5, CompletionTokens: 3}},
- },
- },
{
name: "Ollama", converterID: relayconvert.ConverterOpenAIChatToOllama, upstream: "llama3",
nativeResp: &dto.OllamaChatResponse{
diff --git a/relaykit/relayconvert/request_registry.go b/relaykit/relayconvert/request_registry.go
index fef1839..ceadbed 100644
--- a/relaykit/relayconvert/request_registry.go
+++ b/relaykit/relayconvert/request_registry.go
@@ -10,6 +10,7 @@ package relayconvert
import (
"context"
"fmt"
+ "sort"
"strings"
"sync"
@@ -63,14 +64,6 @@ var (
requestConverterDirectRoutes = make(map[requestConverterRoute]string)
)
-const (
- requestConverterClaudeToGemini = "claude_messages_to_gemini_generate_content"
- requestConverterClaudeToResponses = "claude_messages_to_openai_responses"
- requestConverterGeminiToClaude = "gemini_generate_content_to_claude_messages"
- requestConverterGeminiToResponses = "gemini_generate_content_to_openai_responses"
- requestConverterResponsesToClaude = "openai_responses_to_claude_messages"
-)
-
const (
ConverterNone = "none"
ConverterClaudeMessagesToOpenAIChat = "anthropic_messages_to_openai_chat_completions"
@@ -83,9 +76,14 @@ const (
ConverterOpenAIChatToGeminiContent = "openai_chat_completions_to_gemini_generate_content"
// OpenAI → 原生格式供应商(请求侧)
- ConverterOpenAIChatToCoze = "openai_chat_completions_to_coze_chat"
- ConverterOpenAIChatToDify = "openai_chat_completions_to_dify_chat_messages"
ConverterOpenAIChatToOllama = "openai_chat_completions_to_ollama_chat"
+
+ // 跨原生方向(请求侧为「经 OpenAI 中枢」步骤链,响应侧为直连转换器)
+ ConverterClaudeMessagesToGeminiContent = "claude_messages_to_gemini_generate_content"
+ ConverterClaudeMessagesToResponses = "claude_messages_to_openai_responses"
+ ConverterGeminiContentToClaudeMessages = "gemini_generate_content_to_claude_messages"
+ ConverterGeminiContentToResponses = "gemini_generate_content_to_openai_responses"
+ ConverterResponsesToClaudeMessages = "openai_responses_to_claude_messages"
)
// registerBuiltinRequestConverter 注册一个请求转换器 spec。
@@ -164,6 +162,21 @@ func LookupRequestConverter(converter string) (RequestConverterSpec, bool) {
return cloneRequestConverterSpec(spec), true
}
+// ListRequestConverterIDs 返回全部已注册请求转换器 ID(字典序副本)。
+// 供诊断与测试枚举使用:能力守恒等注册表级测试据此保证「每个已注册方向都有期望条目」,
+// 新注册方向若未同步补充测试期望会立即被发现。
+func ListRequestConverterIDs() []string {
+ requestConverterMu.RLock()
+ defer requestConverterMu.RUnlock()
+
+ ids := make([]string, 0, len(requestConverters))
+ for id := range requestConverters {
+ ids = append(ids, id)
+ }
+ sort.Strings(ids)
+ return ids
+}
+
func lookupRequestRoute(from types.RelayFormat, to types.RelayFormat) (RequestConverterSpec, bool) {
requestConverterMu.RLock()
defer requestConverterMu.RUnlock()
diff --git a/relaykit/relayconvert/response_registry.go b/relaykit/relayconvert/response_registry.go
index 760ba82..efb77b0 100644
--- a/relaykit/relayconvert/response_registry.go
+++ b/relaykit/relayconvert/response_registry.go
@@ -10,6 +10,7 @@ package relayconvert
import (
"context"
"fmt"
+ "sort"
"strings"
"sync"
@@ -105,19 +106,27 @@ const (
ResponseConverterGeminiChatToOAIChatStream = "gemini_chat_to_oai_chat_stream_resp"
// 原生格式供应商 → OpenAI(响应侧,含流式)
- ResponseConverterCozeChatToOAIChat = "coze_chat_to_oai_chat_resp"
- ResponseConverterCozeChatToOAIChatStream = "coze_chat_to_oai_chat_stream_resp"
- ResponseConverterDifyChatToOAIChat = "dify_chat_to_oai_chat_resp"
- ResponseConverterDifyChatToOAIChatStream = "dify_chat_to_oai_chat_stream_resp"
ResponseConverterOllamaChatToOAIChat = "ollama_chat_to_oai_chat_resp"
ResponseConverterOllamaChatToOAIChatStream = "ollama_chat_to_oai_chat_stream_resp"
- responseConverterClaudeToGemini = "claude_messages_to_gemini_chat_resp"
- responseConverterClaudeToResponses = "claude_messages_to_oai_responses_resp"
- responseConverterGeminiToClaude = "gemini_chat_to_claude_messages_resp"
- responseConverterGeminiToResponses = "gemini_chat_to_oai_responses_resp"
- responseConverterResponsesToClaude = "oai_responses_to_claude_messages_resp"
- responseConverterResponsesToGemini = "oai_responses_to_gemini_chat_resp"
+ // 反向方向(OpenAI 上游 → 非 OpenAI 客户端)与 Responses 双向的流式转换器 ID。
+ // 流式注册表按数据方向 (from=上游格式, to=客户端格式) 登记。
+ ResponseConverterOAIChatToClaudeMessagesStream = "oai_chat_to_claude_messages_stream_resp"
+ ResponseConverterOAIChatToGeminiChatStream = "oai_chat_to_gemini_chat_stream_resp"
+ ResponseConverterOAIResponsesToOAIChatStream = "oai_responses_to_oai_chat_stream_resp"
+ ResponseConverterOAIChatToOAIResponsesStream = "oai_chat_to_oai_responses_stream_resp"
+
+ // 跨原生方向(上游原生 → 另一原生客户端)的非流式转换器 ID(响应侧元数据)。
+ ResponseConverterClaudeMessagesToGeminiChat = "claude_messages_to_gemini_chat_resp"
+ ResponseConverterGeminiChatToClaudeMessages = "gemini_chat_to_claude_messages_resp"
+ ResponseConverterClaudeMessagesToOAIResponses = "claude_messages_to_oai_responses_resp"
+ ResponseConverterGeminiChatToOAIResponses = "gemini_chat_to_oai_responses_resp"
+
+ // 跨原生方向(上游原生 → 另一原生客户端)的流式转换器 ID。
+ ResponseConverterClaudeMessagesToGeminiChatStream = "claude_messages_to_gemini_chat_stream_resp"
+ ResponseConverterGeminiChatToClaudeMessagesStream = "gemini_chat_to_claude_messages_stream_resp"
+ ResponseConverterGeminiChatToOAIResponsesStream = "gemini_chat_to_oai_responses_stream_resp"
+ ResponseConverterClaudeMessagesToOAIResponsesStream = "claude_messages_to_oai_responses_stream_resp"
)
var (
@@ -127,6 +136,20 @@ var (
responseConverterRoutes = make(map[responseConverterRoute]string)
)
+// ListResponseConverterIDs 返回全部已注册响应侧转换器 ID(字典序副本,不含别名)。
+// 供诊断与测试枚举使用,与 ListRequestConverterIDs 配套。
+func ListResponseConverterIDs() []string {
+ responseConverterMu.RLock()
+ defer responseConverterMu.RUnlock()
+
+ ids := make([]string, 0, len(responseConverters))
+ for id := range responseConverters {
+ ids = append(ids, id)
+ }
+ sort.Strings(ids)
+ return ids
+}
+
// registerBuiltinResponseConverter 注册响应侧转换器到注册表。
// init() 通过 registerBuiltinTextConverter() 调用本函数。
//
diff --git a/relaykit/relayconvert/stream_event.go b/relaykit/relayconvert/stream_event.go
new file mode 100644
index 0000000..731df8e
--- /dev/null
+++ b/relaykit/relayconvert/stream_event.go
@@ -0,0 +1,25 @@
+// Package relayconvert — 通用流式事件封装。
+//
+// 早期流式转换器只服务「上游原生 → OpenAI 客户端」方向,chunkWriter 直接收
+// *dto.ChatCompletionStreamResponse。反向与跨原生方向的输出是 Claude 事件流 /
+// Gemini SSE / Responses 事件流,帧格式各异(是否带 event 名、结束语义),
+// 因此引入 StreamEvent 封装:转换器负责产出「事件名 + 负载」,宿主桥接层负责
+// SSE 帧化与 Usage 提取。
+package relayconvert
+
+import "github.com/qianfree/team-api/relaykit/dto"
+
+// StreamEvent 一帧流式输出。
+//
+// - Event 非空:写为事件帧(event: X\ndata: {json}\n\n),Claude / Responses 风格;
+// - Event 为空:写为纯数据帧(data: {json}\n\n),Gemini / OpenAI 风格;
+// - Usage 非空:宿主捕获为本次请求的用量(多次出现取最后一次)。
+// 使用带明细的 UsageWithDetails,缓存读/写 token 明细不丢失(计费按明细扣减缓存价)。
+//
+// 输出为 OpenAI chat 格式时转换器仍可直接产出 *dto.ChatCompletionStreamResponse
+// (宿主桥接层对两种 chunk 类型均兼容)。
+type StreamEvent struct {
+ Event string
+ Data any
+ Usage *dto.UsageWithDetails
+}
diff --git a/relaykit/relayconvert/stream_registry.go b/relaykit/relayconvert/stream_registry.go
index 65f2124..7f2299d 100644
--- a/relaykit/relayconvert/stream_registry.go
+++ b/relaykit/relayconvert/stream_registry.go
@@ -16,6 +16,7 @@ import (
"context"
"fmt"
"io"
+ "sort"
"strings"
"sync"
@@ -80,3 +81,35 @@ func LookupStreamConverter(from, to types.RelayFormat) (StreamConverterFunc, str
}
return fn, converterID, true
}
+
+// StreamConverterSpec 一个已注册流式转换方向的元信息(枚举用)。
+// From 是上游流格式,To 是客户端格式(流式方向与请求方向相反)。
+type StreamConverterSpec struct {
+ ID string
+ From types.RelayFormat
+ To types.RelayFormat
+}
+
+// ListStreamConverterSpecs 返回全部已注册流式转换方向(按 ID 升序,结果稳定)。
+// 供注册表级测试套件(流式金样本 / 能力守恒)枚举:新注册方向零成本自动纳入。
+func ListStreamConverterSpecs() []StreamConverterSpec {
+ streamConverterMu.RLock()
+ defer streamConverterMu.RUnlock()
+
+ out := make([]StreamConverterSpec, 0, len(streamConverterRoutes))
+ for route, id := range streamConverterRoutes {
+ out = append(out, StreamConverterSpec{ID: id, From: route.from, To: route.to})
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
+ return out
+}
+
+// LookupStreamConverterByID 按 converterID 查找流式转换函数。
+// 与 ListStreamConverterSpecs 配套使用;未注册返回 (nil, false)。
+func LookupStreamConverterByID(converterID string) (StreamConverterFunc, bool) {
+ streamConverterMu.RLock()
+ defer streamConverterMu.RUnlock()
+
+ fn, ok := streamConverterFuncs[converterID]
+ return fn, ok
+}
diff --git a/relaykit/relayconvert/text_converter_registry.go b/relaykit/relayconvert/text_converter_registry.go
index c3dae28..e0060bf 100644
--- a/relaykit/relayconvert/text_converter_registry.go
+++ b/relaykit/relayconvert/text_converter_registry.go
@@ -94,11 +94,12 @@ func registerBuiltinTextConverter(spec TextConverterSpec) {
if spec.Quality == "" {
panic(fmt.Sprintf("text converter %q must declare quality", spec.ID))
}
- if !textRequestSideConfigured(spec.Req) {
- panic(fmt.Sprintf("text converter %q must declare request conversion", spec.ID))
- }
- if !textResponseSideConfigured(spec.Resp) {
- panic(fmt.Sprintf("text converter %q must declare response conversion", spec.ID))
+ // 单侧注册:请求侧与响应侧至少配置一侧即可。
+ // 反向方向(如 Claude 入站 → OpenAI 上游)请求与响应可分属不同 spec 注册。
+ reqConfigured := textRequestSideConfigured(spec.Req)
+ respConfigured := textResponseSideConfigured(spec.Resp)
+ if !reqConfigured && !respConfigured {
+ panic(fmt.Sprintf("text converter %q must declare request or response conversion", spec.ID))
}
textConverterMu.Lock()
@@ -108,26 +109,30 @@ func registerBuiltinTextConverter(spec TextConverterSpec) {
panic(fmt.Sprintf("text converter %q is already registered", spec.ID))
}
- registerBuiltinRequestConverter(RequestConverterSpec{
- ID: spec.ID,
- From: spec.From,
- To: spec.To,
- Quality: RequestConverterQuality(spec.Quality),
- Convert: spec.Req.Convert,
- StepConverters: cloneTextConverterStrings(spec.Req.StepConverters),
- })
- registerBuiltinResponseConverter(ResponseConverterSpec{
- ID: spec.ID,
- From: spec.From,
- To: spec.To,
- Quality: ResponseConverterQuality(spec.Quality),
- Convert: spec.Resp.Convert,
- ConvertStream: spec.Resp.ConvertStream,
- NewStreamState: spec.Resp.NewStreamState,
- ConvertStreamChunk: spec.Resp.ConvertStreamChunk,
- FinalizeStream: spec.Resp.FinalizeStream,
- StepConverters: cloneTextConverterStrings(spec.Resp.StepConverters),
- })
+ if reqConfigured {
+ registerBuiltinRequestConverter(RequestConverterSpec{
+ ID: spec.ID,
+ From: spec.From,
+ To: spec.To,
+ Quality: RequestConverterQuality(spec.Quality),
+ Convert: spec.Req.Convert,
+ StepConverters: cloneTextConverterStrings(spec.Req.StepConverters),
+ })
+ }
+ if respConfigured {
+ registerBuiltinResponseConverter(ResponseConverterSpec{
+ ID: spec.ID,
+ From: spec.From,
+ To: spec.To,
+ Quality: ResponseConverterQuality(spec.Quality),
+ Convert: spec.Resp.Convert,
+ ConvertStream: spec.Resp.ConvertStream,
+ NewStreamState: spec.Resp.NewStreamState,
+ ConvertStreamChunk: spec.Resp.ConvertStreamChunk,
+ FinalizeStream: spec.Resp.FinalizeStream,
+ StepConverters: cloneTextConverterStrings(spec.Resp.StepConverters),
+ })
+ }
textConverters[spec.ID] = cloneTextConverterSpec(spec)
for _, alias := range spec.Resp.Aliases {
diff --git a/relaykit/relayconvert/text_converter_registry_test.go b/relaykit/relayconvert/text_converter_registry_test.go
index 0dd40ff..e0428f4 100644
--- a/relaykit/relayconvert/text_converter_registry_test.go
+++ b/relaykit/relayconvert/text_converter_registry_test.go
@@ -53,6 +53,30 @@ func TestRegisterAndLookupTextConverter(t *testing.T) {
}
}
+// TestRegisterTextConverter_SingleSide 单侧注册:仅请求侧或仅响应侧配置均合法,
+// 且只把已配置的一侧写入对应注册表。
+func TestRegisterTextConverter_SingleSide(t *testing.T) {
+ reqOnly := validTextSpec("text_req_only")
+ reqOnly.Resp = TextResponseSide{}
+ RegisterTextConverter(reqOnly)
+ if _, ok := LookupRequestConverter("text_req_only"); !ok {
+ t.Error("expected request-only spec to register request side")
+ }
+ if _, ok := LookupResponseConverter("text_req_only"); ok {
+ t.Error("request-only spec must not register response side")
+ }
+
+ respOnly := validTextSpec("text_resp_only")
+ respOnly.Req = TextRequestSide{}
+ RegisterTextConverter(respOnly)
+ if _, ok := LookupResponseConverter("text_resp_only"); !ok {
+ t.Error("expected response-only spec to register response side")
+ }
+ if _, ok := LookupRequestConverter("text_resp_only"); ok {
+ t.Error("response-only spec must not register request side")
+ }
+}
+
func TestRegisterTextConverter_Panics(t *testing.T) {
// 空 ID
assertPanic(t, func() {
@@ -78,15 +102,10 @@ func TestRegisterTextConverter_Panics(t *testing.T) {
s.Quality = ""
RegisterTextConverter(s)
})
- // 缺请求侧(Convert 为 nil 且无 StepConverters)
+ // 双侧均缺失才 panic(单侧注册合法)
assertPanic(t, func() {
- s := validTextSpec("text_no_req")
+ s := validTextSpec("text_no_sides")
s.Req = TextRequestSide{}
- RegisterTextConverter(s)
- })
- // 缺响应侧(全部为 nil)
- assertPanic(t, func() {
- s := validTextSpec("text_no_resp")
s.Resp = TextResponseSide{}
RegisterTextConverter(s)
})
diff --git a/relaykit/types/relay_format.go b/relaykit/types/relay_format.go
index 39cb256..3460f17 100644
--- a/relaykit/types/relay_format.go
+++ b/relaykit/types/relay_format.go
@@ -19,7 +19,5 @@ const (
RelayFormatMjProxy = "mj_proxy"
// 剩余原生格式供应商(非 OpenAI 兼容)
- RelayFormatCoze = "coze" // 字节 Coze v3
- RelayFormatDify = "dify" // Dify chat-messages
RelayFormatOllama = "ollama" // Ollama /api/chat
)
diff --git a/web/admin/src/constants/channel.ts b/web/admin/src/constants/channel.ts
index d0aeb6f..d9097c2 100644
--- a/web/admin/src/constants/channel.ts
+++ b/web/admin/src/constants/channel.ts
@@ -18,8 +18,8 @@ export const providerTypeOptions: { label: string; value: number }[] = [
{ label: 'SiliconFlow (硅基流动)', value: 25 }, { label: 'Xunfei (讯飞)', value: 26 },
{ label: 'OpenRouter', value: 27 }, { label: 'XInference', value: 28 },
{ label: 'MiniMax', value: 29 }, { label: 'Submodel', value: 30 },
- { label: 'Coze (扣子)', value: 32 },
- { label: 'Dify', value: 33 }, { label: 'Jimeng (即梦)', value: 34 },
+ // 32 = Coze、33 = Dify:渠道已移除,编号永久保留不复用
+ { label: 'Jimeng (即梦)', value: 34 },
{ label: 'Codex', value: 35 },
{ label: 'New API', value: 41 }, { label: 'Sub2API', value: 42 },
]
diff --git a/web/admin/src/views/ai/ChannelDetailPage.vue b/web/admin/src/views/ai/ChannelDetailPage.vue
index c198543..c2e266f 100644
--- a/web/admin/src/views/ai/ChannelDetailPage.vue
+++ b/web/admin/src/views/ai/ChannelDetailPage.vue
@@ -75,6 +75,7 @@ const editForm = reactive({
status: 'active',
is_vip: false,
use_proxy: false,
+ web_search_to_google_search: false,
sharing_threshold: null as number | null,
preemption_threshold: null as number | null,
borrowing_cooldown_seconds: null as number | null,
@@ -96,6 +97,7 @@ function openEditModal() {
status: detail.value.status || 'active',
is_vip: detail.value.is_vip || false,
use_proxy: detail.value.use_proxy || false,
+ web_search_to_google_search: detail.value.web_search_to_google_search || false,
sharing_threshold: detail.value.sharing_threshold ?? null,
preemption_threshold: detail.value.preemption_threshold ?? null,
borrowing_cooldown_seconds: detail.value.borrowing_cooldown_seconds ?? null,
@@ -1104,6 +1106,12 @@ function formatHeaders(headers: Record): string {
已启用
-
+
+
+ googleSearch
+ -
+
+
{{ detail.created_at }}
{{ detail.updated_at }}
{{ detail.remark }}
@@ -1522,6 +1530,11 @@ function formatHeaders(headers: Record): string {
+
+
+
+
+
diff --git a/web/admin/src/views/ai/ChannelsPage.vue b/web/admin/src/views/ai/ChannelsPage.vue
index 46c7c06..debbe72 100644
--- a/web/admin/src/views/ai/ChannelsPage.vue
+++ b/web/admin/src/views/ai/ChannelsPage.vue
@@ -264,8 +264,6 @@ const providerDefaultURLs: Record = {
28: 'http://localhost:9997',
29: 'https://api.minimax.chat',
30: '',
- 32: 'https://api.coze.cn',
- 33: 'https://api.dify.ai',
34: '',
35: 'https://chatgpt.com',
}