From 243c5821593e59e6dc81903e1203c968fd9eff4c Mon Sep 17 00:00:00 2001 From: songyu Date: Thu, 30 Apr 2026 13:33:40 +0800 Subject: [PATCH 1/3] feat: add unit tests for OpenAI responses request conversion - Introduced a new test file for validating the conversion of OpenAI responses to chat completions. - Implemented tests to ensure correct merging of consecutive function calls and proper handling of interrupted function calls. - Enhanced the main conversion function to buffer consecutive function calls and emit them as a single assistant message. --- .../openai_openai-responses_request.go | 23 +++-- .../openai_openai-responses_request_test.go | 87 +++++++++++++++++++ 2 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 internal/translator/openai/openai/responses/openai_openai-responses_request_test.go diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request.go b/internal/translator/openai/openai/responses/openai_openai-responses_request.go index 2366c9c3..9164a411 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request.go @@ -57,11 +57,25 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu // Convert input array to messages if input := root.Get("input"); input.Exists() && input.IsArray() { + pendingToolCalls := make([]interface{}, 0) + flushPendingToolCalls := func() { + if len(pendingToolCalls) == 0 { + return + } + assistantMessage := []byte(`{"role":"assistant","tool_calls":[]}`) + assistantMessage, _ = sjson.SetBytes(assistantMessage, "tool_calls", pendingToolCalls) + out, _ = sjson.SetRawBytes(out, "messages.-1", assistantMessage) + pendingToolCalls = pendingToolCalls[:0] + } + input.ForEach(func(_, item gjson.Result) bool { itemType := item.Get("type").String() if itemType == "" && item.Get("role").String() != "" { itemType = "message" } + if itemType != "function_call" { + flushPendingToolCalls() + } switch itemType { case "message", "": @@ -112,9 +126,7 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu out, _ = sjson.SetRawBytes(out, "messages.-1", message) case "function_call": - // Handle function call conversion to assistant message with tool_calls - assistantMessage := []byte(`{"role":"assistant","tool_calls":[]}`) - + // Buffer consecutive function calls and emit them as one assistant message. toolCall := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`) if callId := item.Get("call_id"); callId.Exists() { @@ -128,9 +140,7 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu if arguments := item.Get("arguments"); arguments.Exists() { toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", arguments.String()) } - - assistantMessage, _ = sjson.SetRawBytes(assistantMessage, "tool_calls.0", toolCall) - out, _ = sjson.SetRawBytes(out, "messages.-1", assistantMessage) + pendingToolCalls = append(pendingToolCalls, gjson.ParseBytes(toolCall).Value()) case "function_call_output": // Handle function call output conversion to tool message @@ -149,6 +159,7 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu return true }) + flushPendingToolCalls() } else if input.Type == gjson.String { msg := []byte(`{}`) msg, _ = sjson.SetBytes(msg, "role", "user") diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go b/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go new file mode 100644 index 00000000..e9339753 --- /dev/null +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go @@ -0,0 +1,87 @@ +package responses + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/tidwall/gjson" +) + +func prettyJSONForTest(raw []byte) string { + if !gjson.ValidBytes(raw) { + return string(raw) + } + var out bytes.Buffer + if err := json.Indent(&out, raw, "", " "); err != nil { + return string(raw) + } + return out.String() +} + +func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_MergeConsecutiveFunctionCalls(t *testing.T) { + raw := []byte(`{ + "input": [ + {"type":"function_call","call_id":"exec_command:0","name":"exec_command","arguments":"{\"cmd\":\"ls\"}"}, + {"type":"function_call","call_id":"exec_command:1","name":"exec_command","arguments":"{\"cmd\":\"pwd\"}"}, + {"type":"function_call_output","call_id":"exec_command:0","output":"ok0"}, + {"type":"function_call_output","call_id":"exec_command:1","output":"ok1"} + ] + }`) + t.Logf("input json:\n%s", prettyJSONForTest(raw)) + + out := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("kimi-k2.6", raw, true) + t.Logf("output json:\n%s", prettyJSONForTest(out)) + + msgs := gjson.GetBytes(out, "messages") + if !msgs.Exists() || !msgs.IsArray() { + t.Fatalf("messages should be an array") + } + if got := len(msgs.Array()); got != 3 { + t.Fatalf("messages count = %d, want %d", got, 3) + } + + if got := gjson.GetBytes(out, "messages.0.role").String(); got != "assistant" { + t.Fatalf("messages.0.role = %q, want %q", got, "assistant") + } + if got := len(gjson.GetBytes(out, "messages.0.tool_calls").Array()); got != 2 { + t.Fatalf("messages.0.tool_calls length = %d, want %d", got, 2) + } + if got := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String(); got != "exec_command:0" { + t.Fatalf("messages.0.tool_calls.0.id = %q, want %q", got, "exec_command:0") + } + if got := gjson.GetBytes(out, "messages.0.tool_calls.1.id").String(); got != "exec_command:1" { + t.Fatalf("messages.0.tool_calls.1.id = %q, want %q", got, "exec_command:1") + } + + if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != "exec_command:0" { + t.Fatalf("messages.1.tool_call_id = %q, want %q", got, "exec_command:0") + } + if got := gjson.GetBytes(out, "messages.2.tool_call_id").String(); got != "exec_command:1" { + t.Fatalf("messages.2.tool_call_id = %q, want %q", got, "exec_command:1") + } +} + +func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_SplitFunctionCallsWhenInterrupted(t *testing.T) { + raw := []byte(`{ + "input": [ + {"type":"function_call","call_id":"call_a","name":"tool_a","arguments":"{}"}, + {"type":"message","role":"user","content":"next"}, + {"type":"function_call","call_id":"call_b","name":"tool_b","arguments":"{}"} + ] + }`) + t.Logf("input json:\n%s", prettyJSONForTest(raw)) + + out := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("kimi-k2.6", raw, false) + t.Logf("output json:\n%s", prettyJSONForTest(out)) + + if got := len(gjson.GetBytes(out, "messages").Array()); got != 3 { + t.Fatalf("messages count = %d, want %d", got, 3) + } + if got := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String(); got != "call_a" { + t.Fatalf("messages.0.tool_calls.0.id = %q, want %q", got, "call_a") + } + if got := gjson.GetBytes(out, "messages.2.tool_calls.0.id").String(); got != "call_b" { + t.Fatalf("messages.2.tool_calls.0.id = %q, want %q", got, "call_b") + } +} From 05ecfb6241f380cb67bde52123adbb43f1917021 Mon Sep 17 00:00:00 2001 From: songyu Date: Thu, 30 Apr 2026 14:01:56 +0800 Subject: [PATCH 2/3] feat: add local Docker build script and update compose configuration - Introduced a new script `docker-build-local.sh` to build a local Docker image and start services using Docker Compose. - Updated `docker-compose.yml` to allow dynamic pull policy configuration via the `CLI_PROXY_PULL_POLICY` environment variable. - Modified `Dockerfile` to support build arguments for Go module proxy settings during the `go mod download` step. --- Dockerfile | 7 +++++- docker-build-local.sh | 50 +++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 2 +- 3 files changed, 57 insertions(+), 2 deletions(-) create mode 100755 docker-build-local.sh diff --git a/Dockerfile b/Dockerfile index 3e10c4f9..1419fffd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,12 @@ WORKDIR /app COPY go.mod go.sum ./ -RUN go mod download +ARG GOPROXY=https://proxy.golang.org,direct +ARG GOSUMDB=sum.golang.org +ARG GOPRIVATE= + +RUN GOPROXY="${GOPROXY}" GOSUMDB="${GOSUMDB}" GOPRIVATE="${GOPRIVATE}" go mod download || \ + GOPROXY="https://goproxy.cn,direct" GOSUMDB="sum.golang.google.cn" GOPRIVATE="${GOPRIVATE}" go mod download COPY . . diff --git a/docker-build-local.sh b/docker-build-local.sh new file mode 100755 index 00000000..ce187a35 --- /dev/null +++ b/docker-build-local.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash + +# Build local image with docker build (no buildx required), +# then start services via docker compose. + +set -euo pipefail + +if ! command -v docker >/dev/null 2>&1; then + echo "Error: docker command not found." + exit 1 +fi + +if ! docker compose version >/dev/null 2>&1; then + echo "Error: docker compose plugin not available." + exit 1 +fi + +IMAGE_TAG="${CLI_PROXY_IMAGE:-cli-proxy-api:local}" + +if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + VERSION="$(git describe --tags --always --dirty)" + COMMIT="$(git rev-parse --short HEAD)" +else + VERSION="dev" + COMMIT="none" +fi +BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +echo "Building local image with:" +echo " Image Tag: ${IMAGE_TAG}" +echo " Version: ${VERSION}" +echo " Commit: ${COMMIT}" +echo " Build Date: ${BUILD_DATE}" +echo "----------------------------------------" + +docker build \ + -t "${IMAGE_TAG}" \ + --build-arg VERSION="${VERSION}" \ + --build-arg COMMIT="${COMMIT}" \ + --build-arg BUILD_DATE="${BUILD_DATE}" \ + --build-arg GOPROXY="${GOPROXY:-https://proxy.golang.org,direct}" \ + --build-arg GOSUMDB="${GOSUMDB:-sum.golang.org}" \ + --build-arg GOPRIVATE="${GOPRIVATE:-}" \ + . + +echo "Starting services from local image..." +CLI_PROXY_IMAGE="${IMAGE_TAG}" CLI_PROXY_PULL_POLICY="never" docker compose up -d --remove-orphans --no-build --pull never + +echo "Done." +echo "Use 'docker compose logs -f' to view logs." diff --git a/docker-compose.yml b/docker-compose.yml index ad2190c2..e2f6728f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ services: cli-proxy-api: image: ${CLI_PROXY_IMAGE:-eceasy/cli-proxy-api:latest} - pull_policy: always + pull_policy: ${CLI_PROXY_PULL_POLICY:-always} build: context: . dockerfile: Dockerfile From 17a1f53c47c2b6d846cd4cc928428c2824cb0ce5 Mon Sep 17 00:00:00 2001 From: songyu Date: Wed, 6 May 2026 14:37:18 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix=EF=BC=9Aopenai=202=20kimi=20error=20=20?= =?UTF-8?q?=20Continuous=20function=5Fcall=20=E8=BF=9E=E7=BB=AD=E7=9A=84fu?= =?UTF-8?q?nction=5Fcall=20=E8=BD=AC=E6=8D=A2=20tool=5Fcalls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openai_openai-responses_request.go | 68 +++++++++++++++++-- .../openai_openai-responses_request_test.go | 37 ++++++++++ 2 files changed, 100 insertions(+), 5 deletions(-) diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request.go b/internal/translator/openai/openai/responses/openai_openai-responses_request.go index 9164a411..15acf7cd 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request.go @@ -57,7 +57,24 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu // Convert input array to messages if input := root.Get("input"); input.Exists() && input.IsArray() { + inputItems := input.Array() + outputCallIDs := make(map[string]struct{}) + for _, item := range inputItems { + if item.Get("type").String() != "function_call_output" { + continue + } + callID := strings.TrimSpace(item.Get("call_id").String()) + if callID == "" { + continue + } + outputCallIDs[callID] = struct{}{} + } + pendingToolCalls := make([]interface{}, 0) + pendingToolCallIDs := make([]string, 0) + awaitingToolOutputs := make(map[string]struct{}) + deferredMessages := make([][]byte, 0) + flushPendingToolCalls := func() { if len(pendingToolCalls) == 0 { return @@ -65,10 +82,40 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu assistantMessage := []byte(`{"role":"assistant","tool_calls":[]}`) assistantMessage, _ = sjson.SetBytes(assistantMessage, "tool_calls", pendingToolCalls) out, _ = sjson.SetRawBytes(out, "messages.-1", assistantMessage) + for _, id := range pendingToolCallIDs { + if strings.TrimSpace(id) == "" { + continue + } + awaitingToolOutputs[id] = struct{}{} + } pendingToolCalls = pendingToolCalls[:0] + pendingToolCallIDs = pendingToolCallIDs[:0] + } + flushDeferredMessages := func() { + for _, message := range deferredMessages { + out, _ = sjson.SetRawBytes(out, "messages.-1", message) + } + deferredMessages = deferredMessages[:0] + } + hasAwaitingToolOutput := func() bool { + for id := range awaitingToolOutputs { + if _, ok := outputCallIDs[id]; ok { + return true + } + } + return false + } + appendRegularMessage := func(message []byte) { + // Keep tool-call adjacency strict for providers that require + // assistant(tool_calls) -> tool(tool_call_id) with no message in between. + if hasAwaitingToolOutput() { + deferredMessages = append(deferredMessages, message) + return + } + out, _ = sjson.SetRawBytes(out, "messages.-1", message) } - input.ForEach(func(_, item gjson.Result) bool { + for _, item := range inputItems { itemType := item.Get("type").String() if itemType == "" && item.Get("role").String() != "" { itemType = "message" @@ -123,7 +170,7 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu message, _ = sjson.SetBytes(message, "content", content.String()) } - out, _ = sjson.SetRawBytes(out, "messages.-1", message) + appendRegularMessage(message) case "function_call": // Buffer consecutive function calls and emit them as one assistant message. @@ -141,13 +188,18 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", arguments.String()) } pendingToolCalls = append(pendingToolCalls, gjson.ParseBytes(toolCall).Value()) + if callID := strings.TrimSpace(item.Get("call_id").String()); callID != "" { + pendingToolCallIDs = append(pendingToolCallIDs, callID) + } case "function_call_output": // Handle function call output conversion to tool message toolMessage := []byte(`{"role":"tool","tool_call_id":"","content":""}`) + callID := "" if callId := item.Get("call_id"); callId.Exists() { - toolMessage, _ = sjson.SetBytes(toolMessage, "tool_call_id", callId.String()) + callID = strings.TrimSpace(callId.String()) + toolMessage, _ = sjson.SetBytes(toolMessage, "tool_call_id", callID) } if output := item.Get("output"); output.Exists() { @@ -155,11 +207,17 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu } out, _ = sjson.SetRawBytes(out, "messages.-1", toolMessage) + if callID != "" { + delete(awaitingToolOutputs, callID) + } + if len(awaitingToolOutputs) == 0 && len(deferredMessages) > 0 { + flushDeferredMessages() + } } - return true - }) + } flushPendingToolCalls() + flushDeferredMessages() } else if input.Type == gjson.String { msg := []byte(`{}`) msg, _ = sjson.SetBytes(msg, "role", "user") diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go b/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go index e9339753..9dd0e288 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go @@ -85,3 +85,40 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_SplitFunctionCalls t.Fatalf("messages.2.tool_calls.0.id = %q, want %q", got, "call_b") } } + +func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_DefersMessageUntilToolOutput(t *testing.T) { + raw := []byte(`{ + "input": [ + {"type":"function_call","call_id":"call_x","name":"exec_command","arguments":"{\"cmd\":\"echo hi\"}"}, + {"type":"message","role":"user","content":"Approved command prefix saved"}, + {"type":"function_call_output","call_id":"call_x","output":"ok"}, + {"type":"message","role":"user","content":"next"} + ] + }`) + t.Logf("input json:\n%s", prettyJSONForTest(raw)) + + out := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("kimi-k2.6", raw, true) + t.Logf("output json:\n%s", prettyJSONForTest(out)) + + if got := len(gjson.GetBytes(out, "messages").Array()); got != 4 { + t.Fatalf("messages count = %d, want %d", got, 4) + } + if got := gjson.GetBytes(out, "messages.0.role").String(); got != "assistant" { + t.Fatalf("messages.0.role = %q, want %q", got, "assistant") + } + if got := gjson.GetBytes(out, "messages.1.role").String(); got != "tool" { + t.Fatalf("messages.1.role = %q, want %q", got, "tool") + } + if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != "call_x" { + t.Fatalf("messages.1.tool_call_id = %q, want %q", got, "call_x") + } + if got := gjson.GetBytes(out, "messages.2.role").String(); got != "user" { + t.Fatalf("messages.2.role = %q, want %q", got, "user") + } + if got := gjson.GetBytes(out, "messages.2.content").String(); got != "Approved command prefix saved" { + t.Fatalf("messages.2.content = %q, want %q", got, "Approved command prefix saved") + } + if got := gjson.GetBytes(out, "messages.3.content").String(); got != "next" { + t.Fatalf("messages.3.content = %q, want %q", got, "next") + } +}