mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-08 01:16:47 +08:00
feat(translator): ensure preservation of tool and call IDs in Gemini request and response translations
- Updated `ConvertGeminiRequestToClaude`, `ConvertGeminiRequestToCodex`, and their respective response counterparts to include logic for retaining and using tool/call IDs when present from gateway-provided inputs. - Enhanced pairing logic between function calls and responses to handle custom and auto-generated IDs consistently. - Introduced tests validating ID preservation and proper behavior in both streaming and non-streaming flows. Closes: #3878
This commit is contained in:
@@ -80,6 +80,25 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
|
||||
return "toolu_" + b.String()
|
||||
}
|
||||
|
||||
getGeminiToolID := func(value gjson.Result) string {
|
||||
if toolID := strings.TrimSpace(value.Get("id").String()); toolID != "" {
|
||||
return toolID
|
||||
}
|
||||
return strings.TrimSpace(value.Get("call_id").String())
|
||||
}
|
||||
|
||||
removePendingToolID := func(ids []string, toolID string) []string {
|
||||
if toolID == "" {
|
||||
return ids
|
||||
}
|
||||
for idx, pendingID := range ids {
|
||||
if pendingID == toolID {
|
||||
return append(ids[:idx], ids[idx+1:]...)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// FIFO queue to store tool call IDs for matching with tool results
|
||||
// Gemini uses sequential pairing across possibly multiple in-flight
|
||||
// functionCalls, so we keep a FIFO queue of generated tool IDs and
|
||||
@@ -262,9 +281,11 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
|
||||
if fc := part.Get("functionCall"); fc.Exists() && role == "assistant" {
|
||||
toolUse := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
|
||||
|
||||
// Generate a unique tool ID and enqueue it for later matching
|
||||
// with the corresponding functionResponse
|
||||
toolID := genToolCallID()
|
||||
// Reuse gateway-provided IDs when present, otherwise generate one for pairing.
|
||||
toolID := getGeminiToolID(fc)
|
||||
if toolID == "" {
|
||||
toolID = genToolCallID()
|
||||
}
|
||||
pendingToolIDs = append(pendingToolIDs, toolID)
|
||||
toolUse, _ = sjson.SetBytes(toolUse, "id", toolID)
|
||||
|
||||
@@ -285,7 +306,10 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
|
||||
// Attach the oldest queued tool_id to pair the response
|
||||
// with its call. If the queue is empty, generate a new id.
|
||||
var toolID string
|
||||
if len(pendingToolIDs) > 0 {
|
||||
if customID := getGeminiToolID(fr); customID != "" {
|
||||
toolID = customID
|
||||
pendingToolIDs = removePendingToolID(pendingToolIDs, toolID)
|
||||
} else if len(pendingToolIDs) > 0 {
|
||||
toolID = pendingToolIDs[0]
|
||||
// Pop the first element from the queue
|
||||
pendingToolIDs = pendingToolIDs[1:]
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertGeminiRequestToClaude_PreservesCustomToolIDs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
callField string
|
||||
responseField string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "id",
|
||||
callField: `"id":"call_gateway_id"`,
|
||||
responseField: `"id":"call_gateway_id"`,
|
||||
want: "call_gateway_id",
|
||||
},
|
||||
{
|
||||
name: "call_id",
|
||||
callField: `"call_id":"call_gateway_call_id"`,
|
||||
responseField: `"call_id":"call_gateway_call_id"`,
|
||||
want: "call_gateway_call_id",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
raw := []byte(fmt.Sprintf(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "lookup", %s, "args": {"query": "status"}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "lookup", %s, "response": {"result": "ok"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`, tt.callField, tt.responseField))
|
||||
|
||||
out := ConvertGeminiRequestToClaude("claude-sonnet-4", raw, false)
|
||||
|
||||
gotCallID := gjson.GetBytes(out, "messages.0.content.0.id").String()
|
||||
if gotCallID != tt.want {
|
||||
t.Fatalf("expected tool_use id %q, got %q; output=%s", tt.want, gotCallID, string(out))
|
||||
}
|
||||
|
||||
gotResultID := gjson.GetBytes(out, "messages.1.content.0.tool_use_id").String()
|
||||
if gotResultID != tt.want {
|
||||
t.Fatalf("expected tool_result tool_use_id %q, got %q; output=%s", tt.want, gotResultID, string(out))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ type ConvertAnthropicResponseToGeminiParams struct {
|
||||
// Keyed by content_block index from Claude SSE events
|
||||
ToolUseNames map[int]string // function/tool name per block index
|
||||
ToolUseArgs map[int]*strings.Builder // accumulates partial_json across deltas
|
||||
ToolUseIDs map[int]string // tool use ID per block index
|
||||
}
|
||||
|
||||
// ConvertClaudeResponseToGemini converts Claude Code streaming response format to Gemini format.
|
||||
@@ -110,6 +111,12 @@ func ConvertClaudeResponseToGemini(_ context.Context, modelName string, original
|
||||
if name := cb.Get("name"); name.Exists() {
|
||||
(*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames[idx] = name.String()
|
||||
}
|
||||
if toolID := cb.Get("id").String(); toolID != "" {
|
||||
if (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs == nil {
|
||||
(*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs = map[int]string{}
|
||||
}
|
||||
(*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs[idx] = toolID
|
||||
}
|
||||
}
|
||||
}
|
||||
return [][]byte{}
|
||||
@@ -169,6 +176,10 @@ func ConvertClaudeResponseToGemini(_ context.Context, modelName string, original
|
||||
argsTrim = strings.TrimSpace(b.String())
|
||||
}
|
||||
}
|
||||
toolID := ""
|
||||
if (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs != nil {
|
||||
toolID = (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs[idx]
|
||||
}
|
||||
if name != "" || argsTrim != "" {
|
||||
functionCall := []byte(`{"functionCall":{"name":"","args":{}}}`)
|
||||
if name != "" {
|
||||
@@ -177,6 +188,9 @@ func ConvertClaudeResponseToGemini(_ context.Context, modelName string, original
|
||||
if argsTrim != "" {
|
||||
functionCall, _ = sjson.SetRawBytes(functionCall, "functionCall.args", []byte(argsTrim))
|
||||
}
|
||||
if toolID != "" {
|
||||
functionCall, _ = sjson.SetBytes(functionCall, "functionCall.id", toolID)
|
||||
}
|
||||
template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", functionCall)
|
||||
template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP")
|
||||
(*param).(*ConvertAnthropicResponseToGeminiParams).LastStorageOutput = append([]byte(nil), template...)
|
||||
@@ -187,6 +201,9 @@ func ConvertClaudeResponseToGemini(_ context.Context, modelName string, original
|
||||
if (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames != nil {
|
||||
delete((*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames, idx)
|
||||
}
|
||||
if (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs != nil {
|
||||
delete((*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs, idx)
|
||||
}
|
||||
return [][]byte{template}
|
||||
}
|
||||
return [][]byte{}
|
||||
@@ -308,6 +325,7 @@ func ConvertClaudeResponseToGeminiNonStream(_ context.Context, modelName string,
|
||||
IsStreaming: false,
|
||||
ToolUseNames: nil,
|
||||
ToolUseArgs: nil,
|
||||
ToolUseIDs: nil,
|
||||
}
|
||||
|
||||
// Process each streaming event and collect parts
|
||||
@@ -348,6 +366,12 @@ func ConvertClaudeResponseToGeminiNonStream(_ context.Context, modelName string,
|
||||
if name := cb.Get("name"); name.Exists() {
|
||||
newParam.ToolUseNames[idx] = name.String()
|
||||
}
|
||||
if toolID := cb.Get("id").String(); toolID != "" {
|
||||
if newParam.ToolUseIDs == nil {
|
||||
newParam.ToolUseIDs = map[int]string{}
|
||||
}
|
||||
newParam.ToolUseIDs[idx] = toolID
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
@@ -401,6 +425,10 @@ func ConvertClaudeResponseToGeminiNonStream(_ context.Context, modelName string,
|
||||
argsTrim = strings.TrimSpace(b.String())
|
||||
}
|
||||
}
|
||||
toolID := ""
|
||||
if newParam.ToolUseIDs != nil {
|
||||
toolID = newParam.ToolUseIDs[idx]
|
||||
}
|
||||
if name != "" || argsTrim != "" {
|
||||
functionCallJSON := []byte(`{"functionCall":{"name":"","args":{}}}`)
|
||||
if name != "" {
|
||||
@@ -409,6 +437,9 @@ func ConvertClaudeResponseToGeminiNonStream(_ context.Context, modelName string,
|
||||
if argsTrim != "" {
|
||||
functionCallJSON, _ = sjson.SetRawBytes(functionCallJSON, "functionCall.args", []byte(argsTrim))
|
||||
}
|
||||
if toolID != "" {
|
||||
functionCallJSON, _ = sjson.SetBytes(functionCallJSON, "functionCall.id", toolID)
|
||||
}
|
||||
allParts = append(allParts, functionCallJSON)
|
||||
// cleanup used state for this index
|
||||
if newParam.ToolUseArgs != nil {
|
||||
@@ -417,6 +448,9 @@ func ConvertClaudeResponseToGeminiNonStream(_ context.Context, modelName string,
|
||||
if newParam.ToolUseNames != nil {
|
||||
delete(newParam.ToolUseNames, idx)
|
||||
}
|
||||
if newParam.ToolUseIDs != nil {
|
||||
delete(newParam.ToolUseIDs, idx)
|
||||
}
|
||||
}
|
||||
|
||||
case "message_delta":
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertClaudeResponseToGemini_StreamPreservesToolUseID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var param any
|
||||
|
||||
start := []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_gateway","name":"lookup"}}`)
|
||||
out := ConvertClaudeResponseToGemini(ctx, "gemini-2.5-pro", nil, nil, start, ¶m)
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("expected content_block_start to be buffered, got %d chunks", len(out))
|
||||
}
|
||||
|
||||
delta := []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\":\"status\"}"}}`)
|
||||
out = ConvertClaudeResponseToGemini(ctx, "gemini-2.5-pro", nil, nil, delta, ¶m)
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("expected input_json_delta to be buffered, got %d chunks", len(out))
|
||||
}
|
||||
|
||||
stop := []byte(`data: {"type":"content_block_stop","index":0}`)
|
||||
out = ConvertClaudeResponseToGemini(ctx, "gemini-2.5-pro", nil, nil, stop, ¶m)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected content_block_stop to emit 1 chunk, got %d", len(out))
|
||||
}
|
||||
|
||||
got := gjson.GetBytes(out[0], "candidates.0.content.parts.0.functionCall.id").String()
|
||||
if got != "toolu_gateway" {
|
||||
t.Fatalf("expected functionCall.id %q, got %q; chunk=%s", "toolu_gateway", got, string(out[0]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeResponseToGeminiNonStreamPreservesToolUseID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
raw := []byte(strings.Join([]string{
|
||||
`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_gateway","name":"lookup"}}`,
|
||||
`data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\":\"status\"}"}}`,
|
||||
`data: {"type":"content_block_stop","index":0}`,
|
||||
}, "\n"))
|
||||
|
||||
out := ConvertClaudeResponseToGeminiNonStream(ctx, "gemini-2.5-pro", nil, nil, raw, nil)
|
||||
|
||||
got := gjson.GetBytes(out, "candidates.0.content.parts.0.functionCall.id").String()
|
||||
if got != "toolu_gateway" {
|
||||
t.Fatalf("expected functionCall.id %q, got %q; chunk=%s", "toolu_gateway", got, string(out))
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,25 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
|
||||
return "call_" + b.String()
|
||||
}
|
||||
|
||||
getGeminiCallID := func(value gjson.Result) string {
|
||||
if callID := strings.TrimSpace(value.Get("id").String()); callID != "" {
|
||||
return callID
|
||||
}
|
||||
return strings.TrimSpace(value.Get("call_id").String())
|
||||
}
|
||||
|
||||
removePendingCallID := func(ids []string, callID string) []string {
|
||||
if callID == "" {
|
||||
return ids
|
||||
}
|
||||
for idx, pendingID := range ids {
|
||||
if pendingID == callID {
|
||||
return append(ids[:idx], ids[idx+1:]...)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// Model
|
||||
out, _ = sjson.SetBytes(out, "model", modelName)
|
||||
|
||||
@@ -155,10 +174,11 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
|
||||
if args := fc.Get("args"); args.Exists() {
|
||||
fn, _ = sjson.SetBytes(fn, "arguments", args.Raw)
|
||||
}
|
||||
// generate a paired random call_id and enqueue it so the
|
||||
// corresponding functionResponse can pop the earliest id
|
||||
// to preserve ordering when multiple calls are present.
|
||||
id := genCallID()
|
||||
// Reuse gateway-provided IDs when present, otherwise generate one for pairing.
|
||||
id := getGeminiCallID(fc)
|
||||
if id == "" {
|
||||
id = genCallID()
|
||||
}
|
||||
fn, _ = sjson.SetBytes(fn, "call_id", id)
|
||||
pendingCallIDs = append(pendingCallIDs, id)
|
||||
out, _ = sjson.SetRawBytes(out, "input.-1", fn)
|
||||
@@ -178,7 +198,10 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
|
||||
// attach the oldest queued call_id to pair the response
|
||||
// with its call. If the queue is empty, generate a new id.
|
||||
var id string
|
||||
if len(pendingCallIDs) > 0 {
|
||||
if customID := getGeminiCallID(fr); customID != "" {
|
||||
id = customID
|
||||
pendingCallIDs = removePendingCallID(pendingCallIDs, id)
|
||||
} else if len(pendingCallIDs) > 0 {
|
||||
id = pendingCallIDs[0]
|
||||
// pop the first element
|
||||
pendingCallIDs = pendingCallIDs[1:]
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertGeminiRequestToCodex_PreservesCustomCallIDs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
callField string
|
||||
responseField string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "id",
|
||||
callField: `"id":"call_gateway_id"`,
|
||||
responseField: `"id":"call_gateway_id"`,
|
||||
want: "call_gateway_id",
|
||||
},
|
||||
{
|
||||
name: "call_id",
|
||||
callField: `"call_id":"call_gateway_call_id"`,
|
||||
responseField: `"call_id":"call_gateway_call_id"`,
|
||||
want: "call_gateway_call_id",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
raw := []byte(fmt.Sprintf(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "lookup", %s, "args": {"query": "status"}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "lookup", %s, "response": {"result": "ok"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`, tt.callField, tt.responseField))
|
||||
|
||||
out := ConvertGeminiRequestToCodex("gpt-5.1-codex", raw, false)
|
||||
|
||||
gotCallID := gjson.GetBytes(out, "input.0.call_id").String()
|
||||
if gotCallID != tt.want {
|
||||
t.Fatalf("expected function_call call_id %q, got %q; output=%s", tt.want, gotCallID, string(out))
|
||||
}
|
||||
|
||||
gotOutputID := gjson.GetBytes(out, "input.1.call_id").String()
|
||||
if gotOutputID != tt.want {
|
||||
t.Fatalf("expected function_call_output call_id %q, got %q; output=%s", tt.want, gotOutputID, string(out))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,7 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
|
||||
functionCall, _ = sjson.SetRawBytes(functionCall, "functionCall.args", []byte(argsStr))
|
||||
}
|
||||
}
|
||||
functionCall = setGeminiFunctionCallID(functionCall, itemResult)
|
||||
|
||||
template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", functionCall)
|
||||
template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP")
|
||||
@@ -361,6 +362,7 @@ func ConvertCodexResponseToGeminiNonStream(_ context.Context, modelName string,
|
||||
functionCall, _ = sjson.SetRawBytes(functionCall, "functionCall.args", []byte(argsStr))
|
||||
}
|
||||
}
|
||||
functionCall = setGeminiFunctionCallID(functionCall, value)
|
||||
|
||||
pendingFunctionCalls = append(pendingFunctionCalls, functionCall)
|
||||
}
|
||||
@@ -410,6 +412,17 @@ func buildReverseMapFromGeminiOriginal(original []byte) map[string]string {
|
||||
return rev
|
||||
}
|
||||
|
||||
func setGeminiFunctionCallID(functionCall []byte, item gjson.Result) []byte {
|
||||
if callID := strings.TrimSpace(item.Get("call_id").String()); callID != "" {
|
||||
functionCall, _ = sjson.SetBytes(functionCall, "functionCall.id", callID)
|
||||
return functionCall
|
||||
}
|
||||
if id := strings.TrimSpace(item.Get("id").String()); id != "" {
|
||||
functionCall, _ = sjson.SetBytes(functionCall, "functionCall.id", id)
|
||||
}
|
||||
return functionCall
|
||||
}
|
||||
|
||||
func GeminiTokenCount(ctx context.Context, count int64) []byte {
|
||||
return translatorcommon.GeminiTokenCountJSON(count)
|
||||
}
|
||||
|
||||
@@ -109,3 +109,43 @@ func TestConvertCodexResponseToGemini_NonStreamImageGenerationCallAddsInlineData
|
||||
t.Fatalf("expected inlineData.mimeType %q, got %q; chunk=%s", "image/png", gotMime, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToGemini_StreamPreservesFunctionCallID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
originalRequest := []byte(`{"tools":[]}`)
|
||||
var param any
|
||||
|
||||
out := ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, []byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_gateway","name":"lookup","arguments":"{\"query\":\"status\"}"}}`), ¶m)
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("expected function call output to be buffered, got %d chunks", len(out))
|
||||
}
|
||||
|
||||
out = ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, []byte(`data: {"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1}}}`), ¶m)
|
||||
if len(out) == 0 {
|
||||
t.Fatal("expected buffered function call to be emitted on completion")
|
||||
}
|
||||
|
||||
got := ""
|
||||
for _, chunk := range out {
|
||||
if value := gjson.GetBytes(chunk, "candidates.0.content.parts.0.functionCall.id").String(); value != "" {
|
||||
got = value
|
||||
break
|
||||
}
|
||||
}
|
||||
if got != "call_gateway" {
|
||||
t.Fatalf("expected functionCall.id %q, got %q; chunks=%q", "call_gateway", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToGeminiNonStreamPreservesFunctionCallID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
originalRequest := []byte(`{"tools":[]}`)
|
||||
|
||||
raw := []byte(`{"type":"response.completed","response":{"id":"resp_123","created_at":1700000000,"usage":{"input_tokens":1,"output_tokens":1},"output":[{"type":"function_call","call_id":"call_gateway","name":"lookup","arguments":"{\"query\":\"status\"}"}]}}`)
|
||||
out := ConvertCodexResponseToGeminiNonStream(ctx, "gemini-2.5-pro", originalRequest, nil, raw, nil)
|
||||
|
||||
got := gjson.GetBytes(out, "candidates.0.content.parts.0.functionCall.id").String()
|
||||
if got != "call_gateway" {
|
||||
t.Fatalf("expected functionCall.id %q, got %q; chunk=%s", "call_gateway", got, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user