mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-09 17:42:08 +08:00
fix(translator): stabilize Responses Lite tool events
This commit is contained in:
@@ -37,15 +37,18 @@ type oaiToResponsesState struct {
|
||||
FuncNames map[string]string
|
||||
FuncCallIDs map[string]string
|
||||
FuncOutputIx map[string]int
|
||||
FuncArgsSent map[string]int
|
||||
MsgOutputIx map[int]int
|
||||
NextOutputIx int
|
||||
// message item state per output index
|
||||
MsgItemAdded map[int]bool // whether response.output_item.added emitted for message
|
||||
MsgContentAdded map[int]bool // whether response.content_part.added emitted for message
|
||||
MsgItemDone map[int]bool // whether message done events were emitted
|
||||
// function item done state
|
||||
FuncArgsDone map[string]bool
|
||||
FuncItemDone map[string]bool
|
||||
// function item state
|
||||
FuncItemAdded map[string]bool
|
||||
FuncItemCustom map[string]bool
|
||||
FuncArgsDone map[string]bool
|
||||
FuncItemDone map[string]bool
|
||||
// names of freeform ("custom") tools from the original request; calls to
|
||||
// these are emitted as custom_tool_call items instead of function_call
|
||||
CustomToolNames map[string]struct{}
|
||||
@@ -169,7 +172,7 @@ func buildResponsesCompletedEvent(st *oaiToResponsesState, requestRawJSON []byte
|
||||
}
|
||||
callID := st.FuncCallIDs[key]
|
||||
name := st.FuncNames[key]
|
||||
if _, isCustomTool := st.CustomToolNames[name]; isCustomTool {
|
||||
if st.FuncItemCustom[key] {
|
||||
item := []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`)
|
||||
item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("ctc_%s", callID))
|
||||
item, _ = sjson.SetBytes(item, "input", unwrapCustomToolInput(args))
|
||||
@@ -218,11 +221,14 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
|
||||
FuncNames: make(map[string]string),
|
||||
FuncCallIDs: make(map[string]string),
|
||||
FuncOutputIx: make(map[string]int),
|
||||
FuncArgsSent: make(map[string]int),
|
||||
MsgOutputIx: make(map[int]int),
|
||||
MsgTextBuf: make(map[int]*strings.Builder),
|
||||
MsgItemAdded: make(map[int]bool),
|
||||
MsgContentAdded: make(map[int]bool),
|
||||
MsgItemDone: make(map[int]bool),
|
||||
FuncItemAdded: make(map[string]bool),
|
||||
FuncItemCustom: make(map[string]bool),
|
||||
FuncArgsDone: make(map[string]bool),
|
||||
FuncItemDone: make(map[string]bool),
|
||||
Reasonings: make([]oaiToResponsesStateReasoning, 0),
|
||||
@@ -293,6 +299,67 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
|
||||
}
|
||||
toolStateKey := func(outputIndex, toolIndex int) string { return fmt.Sprintf("%d:%d", outputIndex, toolIndex) }
|
||||
var out [][]byte
|
||||
emitToolItem := func(key string, force bool) {
|
||||
if st.FuncItemAdded[key] {
|
||||
return
|
||||
}
|
||||
callID := st.FuncCallIDs[key]
|
||||
name := st.FuncNames[key]
|
||||
if !force && (callID == "" || name == "") {
|
||||
return
|
||||
}
|
||||
if name == "" {
|
||||
if customToolName, ok := responsesSingleCustomToolName(requestForNamespace); ok {
|
||||
name = customToolName
|
||||
st.FuncNames[key] = customToolName
|
||||
}
|
||||
}
|
||||
if callID == "" {
|
||||
callID = fmt.Sprintf("call_%s_%s", st.ResponseID, strings.ReplaceAll(key, ":", "_"))
|
||||
st.FuncCallIDs[key] = callID
|
||||
}
|
||||
|
||||
outputIndex := st.FuncOutputIx[key]
|
||||
_, isCustomTool := st.CustomToolNames[name]
|
||||
st.FuncItemCustom[key] = isCustomTool
|
||||
if isCustomTool {
|
||||
o := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"in_progress","input":"","call_id":"","name":""}}`)
|
||||
o, _ = sjson.SetBytes(o, "sequence_number", nextSeq())
|
||||
o, _ = sjson.SetBytes(o, "output_index", outputIndex)
|
||||
o, _ = sjson.SetBytes(o, "item.id", fmt.Sprintf("ctc_%s", callID))
|
||||
o, _ = sjson.SetBytes(o, "item.call_id", callID)
|
||||
o, _ = sjson.SetBytes(o, "item.name", name)
|
||||
out = append(out, emitRespEvent("response.output_item.added", o))
|
||||
} else {
|
||||
o := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"in_progress","arguments":"","call_id":"","name":""}}`)
|
||||
o, _ = sjson.SetBytes(o, "sequence_number", nextSeq())
|
||||
o, _ = sjson.SetBytes(o, "output_index", outputIndex)
|
||||
o, _ = sjson.SetBytes(o, "item.id", fmt.Sprintf("fc_%s", callID))
|
||||
o, _ = sjson.SetBytes(o, "item.call_id", callID)
|
||||
o = applyResponsesFunctionCallNamespaceFields(o, requestForNamespace, name, "item")
|
||||
out = append(out, emitRespEvent("response.output_item.added", o))
|
||||
}
|
||||
st.FuncItemAdded[key] = true
|
||||
}
|
||||
emitPendingFunctionArgs := func(key string) {
|
||||
if !st.FuncItemAdded[key] || st.FuncItemCustom[key] {
|
||||
return
|
||||
}
|
||||
argsBuf := st.FuncArgsBuf[key]
|
||||
if argsBuf == nil || argsBuf.Len() <= st.FuncArgsSent[key] {
|
||||
return
|
||||
}
|
||||
args := argsBuf.String()
|
||||
delta := args[st.FuncArgsSent[key]:]
|
||||
callID := st.FuncCallIDs[key]
|
||||
ad := []byte(`{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`)
|
||||
ad, _ = sjson.SetBytes(ad, "sequence_number", nextSeq())
|
||||
ad, _ = sjson.SetBytes(ad, "item_id", fmt.Sprintf("fc_%s", callID))
|
||||
ad, _ = sjson.SetBytes(ad, "output_index", st.FuncOutputIx[key])
|
||||
ad, _ = sjson.SetBytes(ad, "delta", delta)
|
||||
out = append(out, emitRespEvent("response.function_call_arguments.delta", ad))
|
||||
st.FuncArgsSent[key] = len(args)
|
||||
}
|
||||
|
||||
if !st.Started {
|
||||
st.ResponseID = root.Get("id").String()
|
||||
@@ -306,11 +373,14 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
|
||||
st.FuncNames = make(map[string]string)
|
||||
st.FuncCallIDs = make(map[string]string)
|
||||
st.FuncOutputIx = make(map[string]int)
|
||||
st.FuncArgsSent = make(map[string]int)
|
||||
st.MsgOutputIx = make(map[int]int)
|
||||
st.NextOutputIx = 0
|
||||
st.MsgItemAdded = make(map[int]bool)
|
||||
st.MsgContentAdded = make(map[int]bool)
|
||||
st.MsgItemDone = make(map[int]bool)
|
||||
st.FuncItemAdded = make(map[string]bool)
|
||||
st.FuncItemCustom = make(map[string]bool)
|
||||
st.FuncArgsDone = make(map[string]bool)
|
||||
st.FuncItemDone = make(map[string]bool)
|
||||
st.CustomToolNames = responsesCustomToolNames(requestForNamespace)
|
||||
@@ -483,74 +553,23 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
|
||||
tcs.ForEach(func(_, tc gjson.Result) bool {
|
||||
toolIndex := int(tc.Get("index").Int())
|
||||
key := toolStateKey(idx, toolIndex)
|
||||
newCallID := tc.Get("id").String()
|
||||
if st.FuncArgsBuf[key] == nil {
|
||||
st.FuncArgsBuf[key] = &strings.Builder{}
|
||||
st.FuncOutputIx[key] = allocOutputIndex()
|
||||
}
|
||||
if newCallID := tc.Get("id").String(); newCallID != "" && st.FuncCallIDs[key] == "" {
|
||||
st.FuncCallIDs[key] = newCallID
|
||||
}
|
||||
nameChunk := tc.Get("function.name").String()
|
||||
if nameChunk != "" {
|
||||
if nameChunk != "" && !st.FuncItemAdded[key] {
|
||||
st.FuncNames[key] = nameChunk
|
||||
}
|
||||
|
||||
existingCallID := st.FuncCallIDs[key]
|
||||
effectiveCallID := existingCallID
|
||||
shouldEmitItem := false
|
||||
if existingCallID == "" {
|
||||
if newCallID == "" {
|
||||
// Some OpenAI-compatible providers omit tool_call ids in
|
||||
// streaming deltas; synthesize one so the Responses event
|
||||
// chain (output_item.added/done) still fires for Codex.
|
||||
newCallID = fmt.Sprintf("call_%s_%d_%d", st.ResponseID, idx, toolIndex)
|
||||
}
|
||||
effectiveCallID = newCallID
|
||||
st.FuncCallIDs[key] = newCallID
|
||||
st.FuncOutputIx[key] = allocOutputIndex()
|
||||
shouldEmitItem = true
|
||||
}
|
||||
|
||||
_, isCustomTool := st.CustomToolNames[st.FuncNames[key]]
|
||||
|
||||
if shouldEmitItem && effectiveCallID != "" {
|
||||
outputIndex := st.FuncOutputIx[key]
|
||||
if isCustomTool {
|
||||
o := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"in_progress","input":"","call_id":"","name":""}}`)
|
||||
o, _ = sjson.SetBytes(o, "sequence_number", nextSeq())
|
||||
o, _ = sjson.SetBytes(o, "output_index", outputIndex)
|
||||
o, _ = sjson.SetBytes(o, "item.id", fmt.Sprintf("ctc_%s", effectiveCallID))
|
||||
o, _ = sjson.SetBytes(o, "item.call_id", effectiveCallID)
|
||||
o, _ = sjson.SetBytes(o, "item.name", st.FuncNames[key])
|
||||
out = append(out, emitRespEvent("response.output_item.added", o))
|
||||
} else {
|
||||
o := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"in_progress","arguments":"","call_id":"","name":""}}`)
|
||||
o, _ = sjson.SetBytes(o, "sequence_number", nextSeq())
|
||||
o, _ = sjson.SetBytes(o, "output_index", outputIndex)
|
||||
o, _ = sjson.SetBytes(o, "item.id", fmt.Sprintf("fc_%s", effectiveCallID))
|
||||
o, _ = sjson.SetBytes(o, "item.call_id", effectiveCallID)
|
||||
o = applyResponsesFunctionCallNamespaceFields(o, requestForNamespace, st.FuncNames[key], "item")
|
||||
out = append(out, emitRespEvent("response.output_item.added", o))
|
||||
}
|
||||
}
|
||||
|
||||
if st.FuncArgsBuf[key] == nil {
|
||||
st.FuncArgsBuf[key] = &strings.Builder{}
|
||||
}
|
||||
|
||||
if args := tc.Get("function.arguments"); args.Exists() && args.String() != "" {
|
||||
refCallID := st.FuncCallIDs[key]
|
||||
if refCallID == "" {
|
||||
refCallID = newCallID
|
||||
}
|
||||
// Custom tool calls buffer arguments only: JSON argument
|
||||
// fragments cannot be mapped onto freeform input deltas, so
|
||||
// the full input is delivered with the done events instead.
|
||||
if refCallID != "" && !isCustomTool {
|
||||
outputIndex := st.FuncOutputIx[key]
|
||||
ad := []byte(`{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`)
|
||||
ad, _ = sjson.SetBytes(ad, "sequence_number", nextSeq())
|
||||
ad, _ = sjson.SetBytes(ad, "item_id", fmt.Sprintf("fc_%s", refCallID))
|
||||
ad, _ = sjson.SetBytes(ad, "output_index", outputIndex)
|
||||
ad, _ = sjson.SetBytes(ad, "delta", args.String())
|
||||
out = append(out, emitRespEvent("response.function_call_arguments.delta", ad))
|
||||
}
|
||||
st.FuncArgsBuf[key].WriteString(args.String())
|
||||
}
|
||||
emitToolItem(key, false)
|
||||
emitPendingFunctionArgs(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -608,9 +627,9 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
|
||||
}
|
||||
|
||||
// Emit function call done events for any active function calls
|
||||
if len(st.FuncCallIDs) > 0 {
|
||||
keys := make([]string, 0, len(st.FuncCallIDs))
|
||||
for key := range st.FuncCallIDs {
|
||||
if len(st.FuncArgsBuf) > 0 {
|
||||
keys := make([]string, 0, len(st.FuncArgsBuf))
|
||||
for key := range st.FuncArgsBuf {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
@@ -619,6 +638,8 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
|
||||
return left < right || (left == right && keys[i] < keys[j])
|
||||
})
|
||||
for _, key := range keys {
|
||||
emitToolItem(key, true)
|
||||
emitPendingFunctionArgs(key)
|
||||
callID := st.FuncCallIDs[key]
|
||||
if callID == "" || st.FuncItemDone[key] {
|
||||
continue
|
||||
@@ -628,7 +649,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
|
||||
if b := st.FuncArgsBuf[key]; b != nil && b.Len() > 0 {
|
||||
args = b.String()
|
||||
}
|
||||
if _, isCustomTool := st.CustomToolNames[st.FuncNames[key]]; isCustomTool {
|
||||
if st.FuncItemCustom[key] {
|
||||
input := unwrapCustomToolInput(args)
|
||||
inputDone := []byte(`{"type":"response.custom_tool_call_input.done","sequence_number":0,"item_id":"","output_index":0,"input":""}`)
|
||||
inputDone, _ = sjson.SetBytes(inputDone, "sequence_number", nextSeq())
|
||||
|
||||
@@ -593,3 +593,271 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream_Restores
|
||||
t.Fatalf("non-stream output namespace = %q, want mcp__test_mcp__; response=%s", got, resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_CustomToolNameArrivesLate(t *testing.T) {
|
||||
originalRequest := []byte(`{
|
||||
"model":"gpt-5.4",
|
||||
"tools":[{"type":"custom","name":"exec"}]
|
||||
}`)
|
||||
chunks := []string{
|
||||
`data: {"id":"chatcmpl_custom_late_name","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_exec","type":"function","function":{"arguments":""}}]},"finish_reason":null}]}`,
|
||||
`data: {"id":"chatcmpl_custom_late_name","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"name":"exec","arguments":""}}]},"finish_reason":null}]}`,
|
||||
`data: {"id":"chatcmpl_custom_late_name","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"input\":\"pwd\"}"}}]},"finish_reason":"tool_calls"}]}`,
|
||||
`data: [DONE]`,
|
||||
}
|
||||
|
||||
var param any
|
||||
var added gjson.Result
|
||||
var inputDone gjson.Result
|
||||
var itemDone gjson.Result
|
||||
var completed gjson.Result
|
||||
for _, line := range chunks {
|
||||
for _, chunk := range ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "model", originalRequest, nil, []byte(line), ¶m) {
|
||||
event, data := parseOpenAIResponsesSSEEvent(t, chunk)
|
||||
switch event {
|
||||
case "response.output_item.added":
|
||||
if data.Get("item.call_id").String() == "call_exec" {
|
||||
added = data
|
||||
}
|
||||
case "response.custom_tool_call_input.done":
|
||||
inputDone = data
|
||||
case "response.output_item.done":
|
||||
if data.Get("item.call_id").String() == "call_exec" {
|
||||
itemDone = data
|
||||
}
|
||||
case "response.completed":
|
||||
completed = data
|
||||
case "response.function_call_arguments.delta", "response.function_call_arguments.done":
|
||||
t.Fatalf("unexpected function call event %q: %s", event, chunk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
label string
|
||||
got gjson.Result
|
||||
path string
|
||||
}{
|
||||
{"added", added, "item"},
|
||||
{"done", itemDone, "item"},
|
||||
{"completed", completed, "response.output.0"},
|
||||
} {
|
||||
if !tc.got.Exists() {
|
||||
t.Fatalf("expected %s event", tc.label)
|
||||
}
|
||||
if got := tc.got.Get(tc.path + ".type").String(); got != "custom_tool_call" {
|
||||
t.Fatalf("%s type = %q, want custom_tool_call", tc.label, got)
|
||||
}
|
||||
if got := tc.got.Get(tc.path + ".id").String(); got != "ctc_call_exec" {
|
||||
t.Fatalf("%s id = %q, want ctc_call_exec", tc.label, got)
|
||||
}
|
||||
if got := tc.got.Get(tc.path + ".name").String(); got != "exec" {
|
||||
t.Fatalf("%s name = %q, want exec", tc.label, got)
|
||||
}
|
||||
}
|
||||
if got := inputDone.Get("item_id").String(); got != "ctc_call_exec" {
|
||||
t.Fatalf("custom input done item_id = %q, want ctc_call_exec", got)
|
||||
}
|
||||
if got := inputDone.Get("input").String(); got != "pwd" {
|
||||
t.Fatalf("custom input done input = %q, want pwd", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_CustomToolNameAndIDAreMissing(t *testing.T) {
|
||||
originalRequest := []byte(`{"model":"gpt-5.4","tools":[{"type":"custom","name":"exec"}]}`)
|
||||
chunks := []string{
|
||||
`data: {"id":"chatcmpl_custom_missing_fields","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"type":"function","function":{"arguments":"{\"input\":\"pwd\"}"}}]},"finish_reason":"tool_calls"}]}`,
|
||||
`data: [DONE]`,
|
||||
}
|
||||
|
||||
var param any
|
||||
var added gjson.Result
|
||||
var done gjson.Result
|
||||
var completed gjson.Result
|
||||
for _, line := range chunks {
|
||||
for _, chunk := range ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "model", originalRequest, nil, []byte(line), ¶m) {
|
||||
event, data := parseOpenAIResponsesSSEEvent(t, chunk)
|
||||
switch event {
|
||||
case "response.output_item.added":
|
||||
added = data
|
||||
case "response.output_item.done":
|
||||
done = data
|
||||
case "response.completed":
|
||||
completed = data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wantCallID := "call_chatcmpl_custom_missing_fields_0_0"
|
||||
for _, tc := range []struct {
|
||||
label string
|
||||
got gjson.Result
|
||||
path string
|
||||
}{
|
||||
{"added", added, "item"},
|
||||
{"done", done, "item"},
|
||||
{"completed", completed, "response.output.0"},
|
||||
} {
|
||||
if got := tc.got.Get(tc.path + ".type").String(); got != "custom_tool_call" {
|
||||
t.Fatalf("%s type = %q, want custom_tool_call", tc.label, got)
|
||||
}
|
||||
if got := tc.got.Get(tc.path + ".id").String(); got != "ctc_"+wantCallID {
|
||||
t.Fatalf("%s id = %q, want %q", tc.label, got, "ctc_"+wantCallID)
|
||||
}
|
||||
if got := tc.got.Get(tc.path + ".call_id").String(); got != wantCallID {
|
||||
t.Fatalf("%s call_id = %q, want %q", tc.label, got, wantCallID)
|
||||
}
|
||||
if got := tc.got.Get(tc.path + ".name").String(); got != "exec" {
|
||||
t.Fatalf("%s name = %q, want exec", tc.label, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_ToolCallIDMayArriveLateOrBeMissing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
chunks []string
|
||||
wantCallID string
|
||||
}{
|
||||
{
|
||||
name: "late id",
|
||||
chunks: []string{
|
||||
`data: {"id":"chatcmpl_late_id","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"type":"function","function":{"name":"read","arguments":"{\"file"}}]},"finish_reason":null}]}`,
|
||||
`data: {"id":"chatcmpl_late_id","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_late","function":{"arguments":"Path\":\"README.md\"}"}}]},"finish_reason":"tool_calls"}]}`,
|
||||
},
|
||||
wantCallID: "call_late",
|
||||
},
|
||||
{
|
||||
name: "missing id",
|
||||
chunks: []string{
|
||||
`data: {"id":"chatcmpl_missing_id","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"type":"function","function":{"name":"read","arguments":"{\"filePath\":\"README.md\"}"}}]},"finish_reason":"tool_calls"}]}`,
|
||||
},
|
||||
wantCallID: "call_chatcmpl_missing_id_0_0",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var param any
|
||||
var events []string
|
||||
var added gjson.Result
|
||||
var argsDelta gjson.Result
|
||||
var argsDone gjson.Result
|
||||
var itemDone gjson.Result
|
||||
for _, line := range append(tt.chunks, `data: [DONE]`) {
|
||||
for _, chunk := range ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "model", nil, nil, []byte(line), ¶m) {
|
||||
event, data := parseOpenAIResponsesSSEEvent(t, chunk)
|
||||
events = append(events, event)
|
||||
switch event {
|
||||
case "response.output_item.added":
|
||||
added = data
|
||||
case "response.function_call_arguments.delta":
|
||||
argsDelta = data
|
||||
case "response.function_call_arguments.done":
|
||||
argsDone = data
|
||||
case "response.output_item.done":
|
||||
itemDone = data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wantItemID := "fc_" + tt.wantCallID
|
||||
if got := added.Get("item.id").String(); got != wantItemID {
|
||||
t.Fatalf("added item id = %q, want %q; events=%v", got, wantItemID, events)
|
||||
}
|
||||
if got := added.Get("item.call_id").String(); got != tt.wantCallID {
|
||||
t.Fatalf("added call id = %q, want %q", got, tt.wantCallID)
|
||||
}
|
||||
if got := argsDelta.Get("item_id").String(); got != wantItemID {
|
||||
t.Fatalf("arguments delta item id = %q, want %q", got, wantItemID)
|
||||
}
|
||||
if got := argsDelta.Get("delta").String(); got != `{"filePath":"README.md"}` {
|
||||
t.Fatalf("arguments delta = %q, want full buffered arguments", got)
|
||||
}
|
||||
if got := argsDone.Get("item_id").String(); got != wantItemID {
|
||||
t.Fatalf("arguments done item id = %q, want %q", got, wantItemID)
|
||||
}
|
||||
if got := itemDone.Get("item.id").String(); got != wantItemID {
|
||||
t.Fatalf("item done id = %q, want %q", got, wantItemID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_RestoresAdditionalNamespaceFunctionCall(t *testing.T) {
|
||||
originalRequest := []byte(`{
|
||||
"model":"gpt-5.4",
|
||||
"input":[{
|
||||
"type":"additional_tools",
|
||||
"tools":[{
|
||||
"type":"namespace",
|
||||
"name":"collaboration",
|
||||
"tools":[{"type":"function","name":"send_message","parameters":{"type":"object","properties":{}}}]
|
||||
}]
|
||||
}]
|
||||
}`)
|
||||
chunks := []string{
|
||||
`data: {"id":"chatcmpl_additional_namespace_stream","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_send","type":"function","function":{"name":"collaboration__send_message","arguments":""}}]},"finish_reason":null}]}`,
|
||||
`data: {"id":"chatcmpl_additional_namespace_stream","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"target\":\"worker\",\"message\":\"ping\"}"}}]},"finish_reason":"tool_calls"}]}`,
|
||||
`data: [DONE]`,
|
||||
}
|
||||
|
||||
var param any
|
||||
var added gjson.Result
|
||||
var done gjson.Result
|
||||
var completed gjson.Result
|
||||
for _, line := range chunks {
|
||||
for _, chunk := range ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "model", originalRequest, nil, []byte(line), ¶m) {
|
||||
event, data := parseOpenAIResponsesSSEEvent(t, chunk)
|
||||
switch event {
|
||||
case "response.output_item.added":
|
||||
added = data
|
||||
case "response.output_item.done":
|
||||
done = data
|
||||
case "response.completed":
|
||||
completed = data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
label string
|
||||
got gjson.Result
|
||||
path string
|
||||
}{
|
||||
{"added", added, "item"},
|
||||
{"done", done, "item"},
|
||||
{"completed", completed, "response.output.0"},
|
||||
} {
|
||||
if got := tc.got.Get(tc.path + ".name").String(); got != "send_message" {
|
||||
t.Fatalf("%s name = %q, want send_message", tc.label, got)
|
||||
}
|
||||
if got := tc.got.Get(tc.path + ".namespace").String(); got != "collaboration" {
|
||||
t.Fatalf("%s namespace = %q, want collaboration", tc.label, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream_RestoresAdditionalNamespaceFunctionCall(t *testing.T) {
|
||||
originalRequest := []byte(`{
|
||||
"model":"gpt-5.4",
|
||||
"input":[{
|
||||
"type":"additional_tools",
|
||||
"tools":[{
|
||||
"type":"namespace",
|
||||
"name":"collaboration",
|
||||
"tools":[{"type":"function","name":"send_message","parameters":{"type":"object","properties":{}}}]
|
||||
}]
|
||||
}]
|
||||
}`)
|
||||
raw := []byte(`{"id":"chatcmpl_additional_namespace_nonstream","object":"chat.completion","created":1773896263,"model":"model","choices":[{"index":0,"message":{"role":"assistant","tool_calls":[{"id":"call_send","type":"function","function":{"name":"collaboration__send_message","arguments":"{\"target\":\"worker\",\"message\":\"ping\"}"}}]},"finish_reason":"tool_calls"}]}`)
|
||||
|
||||
resp := ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "model", originalRequest, nil, raw, nil)
|
||||
data := gjson.ParseBytes(resp)
|
||||
if got := data.Get("output.0.name").String(); got != "send_message" {
|
||||
t.Fatalf("non-stream output name = %q, want send_message; response=%s", got, resp)
|
||||
}
|
||||
if got := data.Get("output.0.namespace").String(); got != "collaboration" {
|
||||
t.Fatalf("non-stream output namespace = %q, want collaboration; response=%s", got, resp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,36 @@ func responsesCustomToolNames(requestRawJSON []byte) map[string]struct{} {
|
||||
return names
|
||||
}
|
||||
|
||||
func responsesSingleCustomToolName(requestRawJSON []byte) (string, bool) {
|
||||
toolCount := 0
|
||||
customToolName := ""
|
||||
collect := func(tools gjson.Result) {
|
||||
if !tools.Exists() || !tools.IsArray() {
|
||||
return
|
||||
}
|
||||
tools.ForEach(func(_, tool gjson.Result) bool {
|
||||
convertedTools := convertResponsesToolToOpenAIChatTools(tool)
|
||||
toolCount += len(convertedTools)
|
||||
if len(convertedTools) == 1 && strings.TrimSpace(tool.Get("type").String()) == "custom" {
|
||||
customToolName = responsesToolName(tool)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
root := gjson.ParseBytes(requestRawJSON)
|
||||
collect(root.Get("tools"))
|
||||
if input := root.Get("input"); input.Exists() && input.IsArray() {
|
||||
input.ForEach(func(_, item gjson.Result) bool {
|
||||
if item.Get("type").String() == "additional_tools" {
|
||||
collect(item.Get("tools"))
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
return customToolName, toolCount == 1 && customToolName != ""
|
||||
}
|
||||
|
||||
// unwrapCustomToolInput extracts the freeform input from the {"input": "..."}
|
||||
// function-call arguments produced for a converted custom tool; it falls back
|
||||
// to the raw arguments when the wrapper is absent.
|
||||
@@ -201,38 +231,49 @@ func splitResponsesQualifiedFunctionCallFromRequest(requestRawJSON []byte, quali
|
||||
return "", ""
|
||||
}
|
||||
|
||||
tools := gjson.GetBytes(requestRawJSON, "tools")
|
||||
if !tools.Exists() || !tools.IsArray() {
|
||||
return qualifiedName, ""
|
||||
}
|
||||
|
||||
var bestNamespace string
|
||||
var bestChild string
|
||||
tools.ForEach(func(_, tool gjson.Result) bool {
|
||||
if strings.TrimSpace(tool.Get("type").String()) != "namespace" {
|
||||
return true
|
||||
collect := func(tools gjson.Result) {
|
||||
if !tools.Exists() || !tools.IsArray() {
|
||||
return
|
||||
}
|
||||
namespaceName := strings.TrimSpace(tool.Get("name").String())
|
||||
if namespaceName == "" {
|
||||
return true
|
||||
}
|
||||
children := tool.Get("tools")
|
||||
if !children.Exists() || !children.IsArray() {
|
||||
return true
|
||||
}
|
||||
children.ForEach(func(_, child gjson.Result) bool {
|
||||
childName := responsesToolName(child)
|
||||
if childName == "" {
|
||||
tools.ForEach(func(_, tool gjson.Result) bool {
|
||||
if strings.TrimSpace(tool.Get("type").String()) != "namespace" {
|
||||
return true
|
||||
}
|
||||
if qualifyResponsesNamespaceToolName(namespaceName, childName) == qualifiedName {
|
||||
bestNamespace = namespaceName
|
||||
bestChild = childName
|
||||
namespaceName := strings.TrimSpace(tool.Get("name").String())
|
||||
if namespaceName == "" {
|
||||
return true
|
||||
}
|
||||
children := tool.Get("tools")
|
||||
if !children.Exists() || !children.IsArray() {
|
||||
return true
|
||||
}
|
||||
children.ForEach(func(_, child gjson.Result) bool {
|
||||
childName := responsesToolName(child)
|
||||
if childName == "" {
|
||||
return true
|
||||
}
|
||||
if qualifyResponsesNamespaceToolName(namespaceName, childName) == qualifiedName {
|
||||
bestNamespace = namespaceName
|
||||
bestChild = childName
|
||||
}
|
||||
return true
|
||||
})
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
root := gjson.ParseBytes(requestRawJSON)
|
||||
collect(root.Get("tools"))
|
||||
if input := root.Get("input"); input.Exists() && input.IsArray() {
|
||||
input.ForEach(func(_, item gjson.Result) bool {
|
||||
if item.Get("type").String() == "additional_tools" {
|
||||
collect(item.Get("tools"))
|
||||
}
|
||||
return true
|
||||
})
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
if bestNamespace == "" || bestChild == "" {
|
||||
return qualifiedName, ""
|
||||
|
||||
Reference in New Issue
Block a user