openai_gateway_cc_tool_call_identity.go 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. package service
  2. import (
  3. "bytes"
  4. "strconv"
  5. "strings"
  6. "github.com/tidwall/gjson"
  7. "github.com/tidwall/sjson"
  8. )
  9. // stripEmptyChatToolCallIdentityFromSSELine 从 CC 流式 SSE 行中剔除
  10. // choices[*].delta.tool_calls[*] 上的空 id / 空 function.name 字段。
  11. //
  12. // DashScope/DeepSeek 等 OpenAI 兼容上游会把同一个 tool_calls[index]
  13. // 拆成多个 delta:首个 delta 带合法 id + function.name(arguments 可能
  14. // 为空),后续参数 delta 带 id:"" 与 function.name:"" 只追加 arguments
  15. // 碎片。dsh 等客户端按 `!== undefined` 合并字段,空串会被当成有效值
  16. // 覆盖首包合法 id/name,最终得到 {"id":"","name":"",...} 导致
  17. // ToolNotFoundError: unknown tool ""。这里无状态剔除空串字段(缺失
  18. // 即不覆盖),不补写、不记忆首包 id/name,适用于所有走 raw CC 直转
  19. // 路径的账号(不限定 DeepSeek)。
  20. //
  21. // 只处理流式 chunk 的 delta.tool_calls;非流式 message.tool_calls 不属于
  22. // 本 helper 范围。
  23. func stripEmptyChatToolCallIdentityFromSSELine(line string) string {
  24. payload, ok := extractOpenAISSEDataLine(line)
  25. if !ok {
  26. return line
  27. }
  28. trimmed := strings.TrimSpace(payload)
  29. if trimmed == "" || trimmed == "[DONE]" {
  30. return line
  31. }
  32. rewritten, changed := stripEmptyChatToolCallIdentity([]byte(payload))
  33. if !changed {
  34. return line
  35. }
  36. prefixLen := len(line) - len(payload)
  37. if prefixLen < 0 {
  38. return line
  39. }
  40. return line[:prefixLen] + string(rewritten)
  41. }
  42. // stripEmptyChatToolCallIdentity 从单个 CC 流式 chunk payload 中删除
  43. // choices[*].delta.tool_calls[*] 上存在但为空字符串的 id 与
  44. // function.name 字段;arguments(即使是空串)、index、type 与其它字段
  45. // 一律保留,非空 id/name 不动。多 choice、多 index 都会处理。
  46. //
  47. // 返回 (原始 payload, false) 当:payload 为空、不含 "tool_calls"、
  48. // 非法 JSON、无 choices / delta / tool_calls 数组、或没有需要删除的
  49. // 字段。sjson 删除失败时 fail-closed 返回原始 payload。
  50. func stripEmptyChatToolCallIdentity(payload []byte) ([]byte, bool) {
  51. if len(payload) == 0 {
  52. return payload, false
  53. }
  54. // 热路径快速失败:绝大多数 chunk 没有 tool_calls。
  55. if !bytes.Contains(payload, []byte("tool_calls")) {
  56. return payload, false
  57. }
  58. if !gjson.ValidBytes(payload) {
  59. return payload, false
  60. }
  61. choices := gjson.GetBytes(payload, "choices")
  62. if !choices.Exists() || !choices.IsArray() {
  63. return payload, false
  64. }
  65. updated := payload
  66. changed := false
  67. for ci, choice := range choices.Array() {
  68. delta := choice.Get("delta")
  69. if !delta.Exists() || !delta.IsObject() {
  70. continue
  71. }
  72. toolCalls := delta.Get("tool_calls")
  73. if !toolCalls.Exists() || !toolCalls.IsArray() {
  74. continue
  75. }
  76. for ti, tc := range toolCalls.Array() {
  77. if id := tc.Get("id"); id.Exists() && id.Type == gjson.String && id.Str == "" {
  78. next, err := sjson.DeleteBytes(updated, "choices."+strconv.Itoa(ci)+".delta.tool_calls."+strconv.Itoa(ti)+".id")
  79. if err != nil {
  80. return payload, false
  81. }
  82. updated = next
  83. changed = true
  84. }
  85. if name := tc.Get("function.name"); name.Exists() && name.Type == gjson.String && name.Str == "" {
  86. next, err := sjson.DeleteBytes(updated, "choices."+strconv.Itoa(ci)+".delta.tool_calls."+strconv.Itoa(ti)+".function.name")
  87. if err != nil {
  88. return payload, false
  89. }
  90. updated = next
  91. changed = true
  92. }
  93. }
  94. }
  95. if !changed {
  96. return payload, false
  97. }
  98. return updated, true
  99. }