Merge pull request #6080 from alfadb/fix/cc-stream-empty-tool-call-identity
fix(openai): 剔除流式 tool_call 后续 delta 中的空 id/name
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// stripEmptyChatToolCallIdentityFromSSELine 从 CC 流式 SSE 行中剔除
|
||||
// choices[*].delta.tool_calls[*] 上的空 id / 空 function.name 字段。
|
||||
//
|
||||
// DashScope/DeepSeek 等 OpenAI 兼容上游会把同一个 tool_calls[index]
|
||||
// 拆成多个 delta:首个 delta 带合法 id + function.name(arguments 可能
|
||||
// 为空),后续参数 delta 带 id:"" 与 function.name:"" 只追加 arguments
|
||||
// 碎片。dsh 等客户端按 `!== undefined` 合并字段,空串会被当成有效值
|
||||
// 覆盖首包合法 id/name,最终得到 {"id":"","name":"",...} 导致
|
||||
// ToolNotFoundError: unknown tool ""。这里无状态剔除空串字段(缺失
|
||||
// 即不覆盖),不补写、不记忆首包 id/name,适用于所有走 raw CC 直转
|
||||
// 路径的账号(不限定 DeepSeek)。
|
||||
//
|
||||
// 只处理流式 chunk 的 delta.tool_calls;非流式 message.tool_calls 不属于
|
||||
// 本 helper 范围。
|
||||
func stripEmptyChatToolCallIdentityFromSSELine(line string) string {
|
||||
payload, ok := extractOpenAISSEDataLine(line)
|
||||
if !ok {
|
||||
return line
|
||||
}
|
||||
trimmed := strings.TrimSpace(payload)
|
||||
if trimmed == "" || trimmed == "[DONE]" {
|
||||
return line
|
||||
}
|
||||
rewritten, changed := stripEmptyChatToolCallIdentity([]byte(payload))
|
||||
if !changed {
|
||||
return line
|
||||
}
|
||||
prefixLen := len(line) - len(payload)
|
||||
if prefixLen < 0 {
|
||||
return line
|
||||
}
|
||||
return line[:prefixLen] + string(rewritten)
|
||||
}
|
||||
|
||||
// stripEmptyChatToolCallIdentity 从单个 CC 流式 chunk payload 中删除
|
||||
// choices[*].delta.tool_calls[*] 上存在但为空字符串的 id 与
|
||||
// function.name 字段;arguments(即使是空串)、index、type 与其它字段
|
||||
// 一律保留,非空 id/name 不动。多 choice、多 index 都会处理。
|
||||
//
|
||||
// 返回 (原始 payload, false) 当:payload 为空、不含 "tool_calls"、
|
||||
// 非法 JSON、无 choices / delta / tool_calls 数组、或没有需要删除的
|
||||
// 字段。sjson 删除失败时 fail-closed 返回原始 payload。
|
||||
func stripEmptyChatToolCallIdentity(payload []byte) ([]byte, bool) {
|
||||
if len(payload) == 0 {
|
||||
return payload, false
|
||||
}
|
||||
// 热路径快速失败:绝大多数 chunk 没有 tool_calls。
|
||||
if !bytes.Contains(payload, []byte("tool_calls")) {
|
||||
return payload, false
|
||||
}
|
||||
if !gjson.ValidBytes(payload) {
|
||||
return payload, false
|
||||
}
|
||||
choices := gjson.GetBytes(payload, "choices")
|
||||
if !choices.Exists() || !choices.IsArray() {
|
||||
return payload, false
|
||||
}
|
||||
updated := payload
|
||||
changed := false
|
||||
for ci, choice := range choices.Array() {
|
||||
delta := choice.Get("delta")
|
||||
if !delta.Exists() || !delta.IsObject() {
|
||||
continue
|
||||
}
|
||||
toolCalls := delta.Get("tool_calls")
|
||||
if !toolCalls.Exists() || !toolCalls.IsArray() {
|
||||
continue
|
||||
}
|
||||
for ti, tc := range toolCalls.Array() {
|
||||
if id := tc.Get("id"); id.Exists() && id.Type == gjson.String && id.Str == "" {
|
||||
next, err := sjson.DeleteBytes(updated, "choices."+strconv.Itoa(ci)+".delta.tool_calls."+strconv.Itoa(ti)+".id")
|
||||
if err != nil {
|
||||
return payload, false
|
||||
}
|
||||
updated = next
|
||||
changed = true
|
||||
}
|
||||
if name := tc.Get("function.name"); name.Exists() && name.Type == gjson.String && name.Str == "" {
|
||||
next, err := sjson.DeleteBytes(updated, "choices."+strconv.Itoa(ci)+".delta.tool_calls."+strconv.Itoa(ti)+".function.name")
|
||||
if err != nil {
|
||||
return payload, false
|
||||
}
|
||||
updated = next
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return payload, false
|
||||
}
|
||||
return updated, true
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestStripEmptyChatToolCallIdentity_FirstChunkIdentityUntouched 首包带合法
|
||||
// id/name 的 delta 必须原样保留(changed=false)。
|
||||
func TestStripEmptyChatToolCallIdentity_FirstChunkIdentityUntouched(t *testing.T) {
|
||||
payload := []byte(`{"id":"chatcmpl_tool","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_example","type":"function","function":{"name":"web_search","arguments":""}}]}}]}`)
|
||||
rewritten, changed := stripEmptyChatToolCallIdentity(payload)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, string(payload), string(rewritten))
|
||||
require.Equal(t, "call_example", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.id").String())
|
||||
require.Equal(t, "web_search", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.name").String())
|
||||
// 首包 arguments 为空串也不应被删除。
|
||||
require.True(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").Exists())
|
||||
require.Equal(t, "", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").String())
|
||||
}
|
||||
|
||||
// TestStripEmptyChatToolCallIdentity_FollowUpDelta 后续参数 delta 的
|
||||
// `"id":""` 与 `"function":{"name":""}` 应被删除;arguments 碎片、
|
||||
// index、type 保留。
|
||||
func TestStripEmptyChatToolCallIdentity_FollowingDelta(t *testing.T) {
|
||||
payload := []byte(`{"id":"chatcmpl_tool","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"{\"query\":"}}]}}]}`)
|
||||
rewritten, changed := stripEmptyChatToolCallIdentity(payload)
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.id").Exists())
|
||||
require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.name").Exists())
|
||||
require.Equal(t, `{"query":`, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").String())
|
||||
require.Equal(t, int64(0), gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.index").Int())
|
||||
require.Equal(t, "function", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.type").String())
|
||||
// 空串字段不得再出现在 payload 里。
|
||||
require.NotContains(t, string(rewritten), `"id":""`)
|
||||
require.NotContains(t, string(rewritten), `"name":""`)
|
||||
}
|
||||
|
||||
// TestStripEmptyChatToolCallIdentity_OnlyEmptyName / _OnlyEmptyID 只删
|
||||
// 空的那一个,非空字段必须保留。
|
||||
func TestStripEmptyChatToolCallIdentity_OnlyEmptyName(t *testing.T) {
|
||||
payload := []byte(`{"id":"chatcmpl_tool","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"","arguments":"{}"}}]}}]}`)
|
||||
rewritten, changed := stripEmptyChatToolCallIdentity(payload)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "call_1", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.id").String())
|
||||
require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.name").Exists())
|
||||
require.Equal(t, "{}", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").String())
|
||||
}
|
||||
|
||||
// TestStripEmptyChatToolCallIdentity_OnlyEmptyID 覆盖 `"id": ""` 带空格的
|
||||
// JSON 形式,确认 gjson/sjson 均能识别。
|
||||
func TestStripEmptyChatToolCallIdentity_OnlyEmptyID(t *testing.T) {
|
||||
payload := []byte(`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id" : "" , "type" : "function","function":{"name":"get_weather","arguments":"{}"}}]}}]}`)
|
||||
rewritten, changed := stripEmptyChatToolCallIdentity(payload)
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.id").Exists())
|
||||
require.Equal(t, "get_weather", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.name").String())
|
||||
require.Equal(t, "{}", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").String())
|
||||
}
|
||||
|
||||
// TestStripEmptyChatToolCallIdentity_EmptyArgumentsKept 空 arguments 不删,
|
||||
// 只有 id/name 空串被剔除。
|
||||
func TestStripEmptyChatToolCallIdentity_EmptyArgumentsKept(t *testing.T) {
|
||||
payload := []byte(`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":""}}]}}]}`)
|
||||
rewritten, changed := stripEmptyChatToolCallIdentity(payload)
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.id").Exists())
|
||||
require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.name").Exists())
|
||||
require.True(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").Exists())
|
||||
require.Equal(t, "", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").String())
|
||||
}
|
||||
|
||||
// TestStripEmptyChatToolCallIdentity_TwoParallelToolCalls 两个并行 index 都要
|
||||
// 处理:合法的 index 0 保留,后续参数 delta 的 index 1 剔除空 id/name。
|
||||
func TestStripEmptyChatToolCallIdentity_TwoParallelToolCalls(t *testing.T) {
|
||||
payload := []byte(`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"tool_a","arguments":"{\"x\":"}},{"index":1,"id":"","type":"function","function":{"name":"","arguments":"{\"y\":"}}]}}]}`)
|
||||
rewritten, changed := stripEmptyChatToolCallIdentity(payload)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "call_a", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.id").String())
|
||||
require.Equal(t, "tool_a", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.name").String())
|
||||
require.Equal(t, `{"x":`, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").String())
|
||||
require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.1.id").Exists())
|
||||
require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.1.function.name").Exists())
|
||||
require.Equal(t, `{"y":`, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.1.function.arguments").String())
|
||||
}
|
||||
|
||||
// TestStripEmptyChatToolCallIdentity_Passthrough 无 tool_calls、无 choices、
|
||||
// 非数组 tool_calls、非法 JSON 一律原样返回。
|
||||
func TestStripEmptyChatToolCallIdentity_Passthrough(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payload string
|
||||
}{
|
||||
{"no tool_calls", `{"choices":[{"index":0,"delta":{"content":"hi"}}]}`},
|
||||
{"no choices", `{"id":"chatcmpl_x"}`},
|
||||
{"empty choices", `{"choices":[]}`},
|
||||
{"tool_calls not array", `{"choices":[{"index":0,"delta":{"tool_calls":{"foo":1}}}]}`},
|
||||
{"invalid JSON", `{"choices":[{`},
|
||||
{"empty string", ``},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rewritten, changed := stripEmptyChatToolCallIdentity([]byte(tt.payload))
|
||||
require.False(t, changed)
|
||||
require.Equal(t, tt.payload, string(rewritten))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStripEmptyChatToolCallIdentityFromSSELine_Passthrough SSE 行级:非
|
||||
// data 行、[DONE]、空行原样;data 行保留 `data: ` 前缀。
|
||||
func TestStripEmptyChatToolCallIdentityFromSSELine_Passthrough(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
line string
|
||||
}{
|
||||
{"done", "data: [DONE]"},
|
||||
{"non-data line", ": keep-alive"},
|
||||
{"empty line", ""},
|
||||
{"comment line", ":"},
|
||||
{"content chunk", `data: {"choices":[{"index":0,"delta":{"content":"hi"}}]}`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.line, stripEmptyChatToolCallIdentityFromSSELine(tt.line))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStripEmptyChatToolCallIdentityFromSSELine_KeepsDataPrefix 改写后的
|
||||
// SSE 行必须保留 `data: ` 前缀。
|
||||
func TestStripEmptyChatToolCallIdentityFromSSELine_KeepsDataPrefix(t *testing.T) {
|
||||
line := `data: {"id":"chatcmpl_tool","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"{}"}}]}}]}`
|
||||
got := stripEmptyChatToolCallIdentityFromSSELine(line)
|
||||
require.True(t, strings.HasPrefix(got, "data: "))
|
||||
payload, ok := extractOpenAISSEDataLine(got)
|
||||
require.True(t, ok)
|
||||
require.False(t, gjson.Get(payload, "choices.0.delta.tool_calls.0.id").Exists())
|
||||
require.False(t, gjson.Get(payload, "choices.0.delta.tool_calls.0.function.name").Exists())
|
||||
require.Equal(t, "{}", gjson.Get(payload, "choices.0.delta.tool_calls.0.function.arguments").String())
|
||||
}
|
||||
|
||||
// TestStripEmptyChatToolCallIdentity_DshClientMerge 模拟 dsh rc.2 adapter 的
|
||||
// 合并逻辑(字段存在——含空串——才覆盖):sanitize 后合并,最终 id/name 必须
|
||||
// 仍是首包合法值,arguments 为各碎片拼接。
|
||||
func TestStripEmptyChatToolCallIdentity_DshClientMerge(t *testing.T) {
|
||||
lines := []string{
|
||||
`data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_example","type":"function","function":{"name":"web_search","arguments":""}}]}}]}`,
|
||||
`data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"{\"query\":"}}]}}]}`,
|
||||
`data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"\"example\"}"}}]}}]}`,
|
||||
}
|
||||
|
||||
var mergedID, mergedName, mergedArgs string
|
||||
for _, line := range lines {
|
||||
sanitized := stripEmptyChatToolCallIdentityFromSSELine(line)
|
||||
payload, ok := extractOpenAISSEDataLine(sanitized)
|
||||
require.True(t, ok)
|
||||
for _, tc := range gjson.Get(payload, "choices.0.delta.tool_calls").Array() {
|
||||
if v := tc.Get("id"); v.Exists() {
|
||||
mergedID = v.String()
|
||||
}
|
||||
if v := tc.Get("function.name"); v.Exists() {
|
||||
mergedName = v.String()
|
||||
}
|
||||
if v := tc.Get("function.arguments"); v.Exists() {
|
||||
mergedArgs += v.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
require.Equal(t, "call_example", mergedID)
|
||||
require.Equal(t, "web_search", mergedName)
|
||||
require.Equal(t, `{"query":"example"}`, mergedArgs)
|
||||
}
|
||||
@@ -336,6 +336,7 @@ func (s *OpenAIGatewayService) streamRawChatCompletions(
|
||||
}
|
||||
}
|
||||
line = applyOllamaCloudRawChatCompletionsSSELine(account, line)
|
||||
line = stripEmptyChatToolCallIdentityFromSSELine(line)
|
||||
|
||||
writeLine(line)
|
||||
if line == "" {
|
||||
|
||||
@@ -607,6 +607,86 @@ func TestForwardAsRawChatCompletions_SilentRefusalNormalContentExempt(t *testing
|
||||
require.Contains(t, rec.Body.String(), "data: [DONE]")
|
||||
}
|
||||
|
||||
// TestForwardAsRawChatCompletions_StripsEmptyToolCallIdentity 端到端验证 raw
|
||||
// CC 流式直转路径剔除 DashScope/DeepSeek 后续参数 delta 的空 id/name:
|
||||
// 下游仍保留首包合法 id/name 与 arguments 碎片,但后续 delta 不再带
|
||||
// `"id":""` / `"name":""`,避免 dsh 等客户端用 `!== undefined` 合并时把
|
||||
// 首包合法值覆盖掉(ToolNotFoundError: unknown tool "")。
|
||||
func TestForwardAsRawChatCompletions_StripsEmptyToolCallIdentity(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
body := []byte(`{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"weather"}],"stream":true}`)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
upstreamBody := strings.Join([]string{
|
||||
`data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_example","type":"function","function":{"name":"web_search","arguments":""}}]}}]}`,
|
||||
"",
|
||||
`data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"{\"query\":"}}]}}]}`,
|
||||
"",
|
||||
`data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"\"example\"}"}}]}}]}`,
|
||||
"",
|
||||
`data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","model":"deepseek-v4-flash","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
|
||||
"",
|
||||
"data: [DONE]",
|
||||
"",
|
||||
}, "\n")
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_tool_identity"}},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamBody)),
|
||||
}}
|
||||
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: rawChatCompletionsTestConfig(),
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
account := rawChatCompletionsTestAccount()
|
||||
|
||||
result, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
downstream := rec.Body.String()
|
||||
require.Contains(t, downstream, `"id":"call_example"`)
|
||||
require.Contains(t, downstream, `"name":"web_search"`)
|
||||
require.Contains(t, downstream, `{\"query\":`)
|
||||
require.Contains(t, downstream, `\"example\"}`)
|
||||
require.Contains(t, downstream, "data: [DONE]")
|
||||
require.NotContains(t, downstream, `"id":""`)
|
||||
require.NotContains(t, downstream, `"name":""`)
|
||||
|
||||
// 逐条扫下游 data payload:后续参数 delta 的 tool_calls.0.id /
|
||||
// function.name 必须已剔除(Exists() == false),首包合法值保留。
|
||||
followUpSeen := false
|
||||
for _, line := range strings.Split(downstream, "\n") {
|
||||
payload, ok := extractOpenAISSEDataLine(line)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
trimmed := strings.TrimSpace(payload)
|
||||
if trimmed == "" || trimmed == "[DONE]" {
|
||||
continue
|
||||
}
|
||||
delta := gjson.Get(payload, "choices.0.delta")
|
||||
if !delta.Exists() || !delta.Get("tool_calls").Exists() {
|
||||
continue
|
||||
}
|
||||
id := delta.Get("tool_calls.0.id")
|
||||
if id.String() == "call_example" {
|
||||
require.Equal(t, "web_search", delta.Get("tool_calls.0.function.name").String())
|
||||
continue
|
||||
}
|
||||
require.False(t, id.Exists(), "empty id must be stripped: %s", payload)
|
||||
require.False(t, delta.Get("tool_calls.0.function.name").Exists(), "empty name must be stripped: %s", payload)
|
||||
require.NotEmpty(t, delta.Get("tool_calls.0.function.arguments").String())
|
||||
followUpSeen = true
|
||||
}
|
||||
require.True(t, followUpSeen)
|
||||
}
|
||||
|
||||
func TestForwardAsRawChatCompletions_ClientDisconnectDrainsUsage(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user