diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index 0dca738..c5e9fc8 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -165,8 +165,9 @@ Workspace-bound route는 workspace가 없거나 상대 경로이면 OpenAI-compa - `parallel_tool_calls` - `stream_options` -`tools`가 있는 Chat Completions 요청에서 provider route(`openai_compat`, `vllm`, `ollama`, provider pool)는 `tool_choice`를 클라이언트 요청값 그대로 backend에 전달한다. 클라이언트가 `tool_choice`를 생략하면 Edge도 생략한다. +`tools`가 있는 Chat Completions 요청에서 provider route(`openai_compat`, `vllm`, `ollama`, provider pool)는 forced tool 선택 객체와 `"none"` 같은 명시적 `tool_choice`를 backend에 전달한다. 단, `"auto"`는 OpenAI-compatible 기본값과 같으므로 provider request에서는 생략한다. 일부 vLLM 계열 backend는 explicit `"auto"`를 `--enable-auto-tool-choice`/`--tool-call-parser` 없이 400으로 거부하기 때문이다. provider가 native OpenAI-compatible `tool_calls`를 반환하면 Node는 내부 `RunEvent.metadata["openai_tool_calls"]` JSON으로 보존하고, Edge는 이를 OpenAI-compatible `message.tool_calls` 또는 stream `delta.tool_calls`로 반환하며 `finish_reason: "tool_calls"`를 사용한다. +provider native `tool_calls[].function.arguments`는 OpenAI 계약에 맞는 JSON string으로 반환한다. 단, provider가 요청 `tools[].function.parameters` schema상 배열/객체여야 하는 값을 JSON 문자열로 이중 인코딩한 경우 Edge는 해당 `arguments` JSON만 schema 기준으로 복원해 다시 JSON string으로 직렬화한다. provider route에서 assistant content에 ``, `{{function(...)}}`, `[Calling tool: ...]` 같은 텍스트가 들어와도 Edge는 이를 파싱하거나 OpenAI `tool_calls`로 합성하지 않는다. 해당 텍스트는 backend가 반환한 content로 그대로 둔다. CLI route(`adapter: "cli"`)는 native backend tool calling이 없으므로, 호환 fallback으로만 assistant content의 Cline-style 텍스트 tool call 블록을 OpenAI-compatible `tool_calls`로 합성할 수 있다. 이 fallback이 지원하는 텍스트 블록의 최소 형태는 `>>JSON-or-text` 또는 `{{function_name(key=Python/JSON-like-literal)}}`이다. CLI fallback에서 텍스트 tool call을 구조화할 때만 Edge는 요청의 `tools[].function.parameters` schema를 기준으로 arguments를 정규화한다. 예를 들어 tool schema가 `commands: string[]`만 허용하면 command 객체 입력도 shell string 배열로 접고, `commands: {command,args}[]`를 허용하면 shell 문법이 없는 명령을 structured argv로 만든다. diff --git a/apps/edge/internal/openai/chat_handler.go b/apps/edge/internal/openai/chat_handler.go index 142b68d..20b717c 100644 --- a/apps/edge/internal/openai/chat_handler.go +++ b/apps/edge/internal/openai/chat_handler.go @@ -167,6 +167,7 @@ func (s *Server) completeChatCompletion(w http.ResponseWriter, r *http.Request, text, reasoning, normalized := normalizeCompletionOutput(outputPolicy, text, reasoning) message := chatMessage{Role: "assistant", Content: text, ReasoningContent: reasoning} if len(nativeToolCalls) > 0 { + nativeToolCalls = normalizeNativeToolCallsForResponse(nativeToolCalls, req.Tools) message.ToolCalls = nativeToolCalls finishReason = "tool_calls" } else if shouldSynthesizeTextToolCalls(handle.Dispatch()) { diff --git a/apps/edge/internal/openai/server_test.go b/apps/edge/internal/openai/server_test.go index 20b6582..3844ab6 100644 --- a/apps/edge/internal/openai/server_test.go +++ b/apps/edge/internal/openai/server_test.go @@ -232,7 +232,7 @@ func TestChatCompletionsPassesStandardOptions(t *testing.T) { } } -func TestChatCompletionsPassesProviderToolChoice(t *testing.T) { +func TestChatCompletionsOmitsProviderToolChoiceAuto(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"} fake.events <- &iop.RunEvent{Type: "complete"} @@ -253,8 +253,8 @@ func TestChatCompletionsPassesProviderToolChoice(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } - if fake.req.Input["tool_choice"] != "auto" { - t.Fatalf("tool_choice: got %+v, want auto", fake.req.Input["tool_choice"]) + if _, ok := fake.req.Input["tool_choice"]; ok { + t.Fatalf("tool_choice auto should be omitted for provider defaults: %+v", fake.req.Input["tool_choice"]) } if _, ok := fake.req.Input["parallel_tool_calls"]; ok { t.Fatalf("parallel_tool_calls must not be forwarded: %+v", fake.req.Input) @@ -264,6 +264,34 @@ func TestChatCompletionsPassesProviderToolChoice(t *testing.T) { } } +func TestChatCompletionsPassesProviderForcedToolChoice(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"} + fake.events <- &iop.RunEvent{Type: "complete"} + + srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ + "model":"qwen3.6:35b", + "messages":[{"role":"user","content":"hi"}], + "tools":[{"type":"function","function":{"name":"lookup"}}], + "tool_choice":{"type":"function","function":{"name":"lookup"}} + }`)) + w := httptest.NewRecorder() + + srv.handleChatCompletions(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + choice, ok := fake.req.Input["tool_choice"].(map[string]any) + if !ok { + t.Fatalf("tool_choice not forwarded as object: %+v", fake.req.Input["tool_choice"]) + } + if choice["type"] != "function" { + t.Fatalf("tool_choice type: %+v", choice) + } +} + func TestChatCompletionsDowngradesCLIToolChoiceAutoToNone(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"} @@ -477,6 +505,45 @@ func TestChatCompletionsPassesThroughNativeToolCallsFromProviderRoute(t *testing } } +func TestChatCompletionsNormalizesNativeToolCallArgumentsForProviderRoute(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 1)} + fake.events <- &iop.RunEvent{ + Type: "complete", + Metadata: map[string]string{ + "finish_reason": "tool_calls", + runtimeMetadataOpenAIToolCalls: `[{"id":"call_1","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":\"[{\\\"command\\\":\\\"cd /config/workspace/iop && git status\\\"}]\"}"}}]`, + }, + } + + srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ + "model":"qwen3.6:35b", + "messages":[{"role":"user","content":"status"}], + "tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}},"required":["commands"]}}}], + "tool_choice":"auto" + }`)) + w := httptest.NewRecorder() + + srv.handleChatCompletions(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + var resp chatCompletionResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + call := resp.Choices[0].Message.ToolCalls[0].(map[string]any) + fn := call["function"].(map[string]any) + var args map[string][]string + if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil { + t.Fatalf("arguments JSON: %v", err) + } + if len(args["commands"]) != 1 || args["commands"][0] != "cd /config/workspace/iop && git status" { + t.Fatalf("commands args: %+v", args["commands"]) + } +} + func TestSynthesizesTemplateToolCallConvertsCommandObjectsToShellStrings(t *testing.T) { content := "확인합니다.\n\n{{run_commands(commands=[{'command': 'command -v git && echo \"found\" || echo \"not found\"', 'runInTerminal': True}])}}" tools := []any{map[string]any{ diff --git a/apps/edge/internal/openai/stream.go b/apps/edge/internal/openai/stream.go index 5fb4dbe..61b1882 100644 --- a/apps/edge/internal/openai/stream.go +++ b/apps/edge/internal/openai/stream.go @@ -171,6 +171,7 @@ func (s *Server) streamBufferedChatCompletion(w http.ResponseWriter, r *http.Req zap.String("reasoning_preview", previewString(reasoning, 1000)), ) if len(nativeToolCalls) > 0 { + nativeToolCalls = normalizeNativeToolCallsForResponse(nativeToolCalls, tools) if text != "" { writeSSE(w, flusher, chatCompletionChunk{ ID: id, diff --git a/apps/edge/internal/openai/types.go b/apps/edge/internal/openai/types.go index 1ef30b2..51e4030 100644 --- a/apps/edge/internal/openai/types.go +++ b/apps/edge/internal/openai/types.go @@ -3,6 +3,7 @@ package openai import ( "encoding/json" "fmt" + "reflect" "regexp" "sort" "strconv" @@ -93,7 +94,7 @@ func (req chatCompletionRequest) runInput(prompt string, messages []chatMessage, if len(req.Tools) > 0 { input["tools"] = req.Tools if nativeToolCalls { - if req.ToolChoice != nil { + if shouldForwardProviderToolChoice(req.ToolChoice) { input["tool_choice"] = req.ToolChoice } } else { @@ -103,6 +104,16 @@ func (req chatCompletionRequest) runInput(prompt string, messages []chatMessage, return input } +func shouldForwardProviderToolChoice(toolChoice any) bool { + if toolChoice == nil { + return false + } + if s, ok := toolChoice.(string); ok { + return strings.TrimSpace(s) != "auto" + } + return true +} + func setOptionInt(options map[string]any, key string, val *int) { if val != nil { options[key] = *val @@ -433,6 +444,173 @@ func toolCallsForStreamDelta(toolCalls []any) []any { return out } +func normalizeNativeToolCallsForResponse(toolCalls []any, tools []any) []any { + specs := requestedToolSpecs(tools) + if len(toolCalls) == 0 || len(specs) == 0 { + return toolCalls + } + var out []any + for i, item := range toolCalls { + normalized, changed := normalizeNativeToolCallForResponse(item, specs) + if !changed { + if out != nil { + out[i] = item + } + continue + } + if out == nil { + out = make([]any, len(toolCalls)) + copy(out, toolCalls) + } + out[i] = normalized + } + if out == nil { + return toolCalls + } + return out +} + +func normalizeNativeToolCallForResponse(item any, specs map[string]textToolSpec) (any, bool) { + call, ok := item.(map[string]any) + if !ok { + return item, false + } + fn, ok := call["function"].(map[string]any) + if !ok { + return item, false + } + name, _ := fn["name"].(string) + spec, ok := specs[strings.TrimSpace(name)] + if !ok { + return item, false + } + arguments, ok := normalizeNativeToolCallArguments(fn["arguments"], spec) + if !ok { + return item, false + } + nextFn := make(map[string]any, len(fn)) + for key, value := range fn { + nextFn[key] = value + } + nextFn["arguments"] = arguments + nextCall := make(map[string]any, len(call)) + for key, value := range call { + nextCall[key] = value + } + nextCall["function"] = nextFn + return nextCall, true +} + +func normalizeNativeToolCallArguments(value any, spec textToolSpec) (string, bool) { + var args map[string]any + original := "" + switch v := value.(type) { + case string: + original = v + if err := json.Unmarshal([]byte(v), &args); err != nil { + return "", false + } + case map[string]any: + args = v + default: + return "", false + } + if args == nil { + return "", false + } + decoded := decodeStringifiedSchemaValues(args, spec.parameters) + normalized := normalizeTextToolCallArguments(spec.name, args, spec) + changed := decoded || !reflect.DeepEqual(normalized, args) || original == "" + if !changed { + return "", false + } + encoded, err := json.Marshal(normalized) + if err != nil { + return "", false + } + if original != "" && string(encoded) == original { + return "", false + } + return string(encoded), true +} + +func decodeStringifiedSchemaValues(args map[string]any, parameters map[string]any) bool { + props := schemaObjectProperties(parameters) + if len(props) == 0 { + return false + } + changed := false + for key, value := range args { + schema, ok := props[key] + if !ok { + continue + } + if decoded, ok := decodeStringifiedSchemaValue(value, schema); ok { + args[key] = decoded + changed = true + } + } + return changed +} + +func decodeStringifiedSchemaValue(value any, schema any) (any, bool) { + switch v := value.(type) { + case string: + trimmed := strings.TrimSpace(v) + if trimmed == "" || (!strings.HasPrefix(trimmed, "[") && !strings.HasPrefix(trimmed, "{")) { + return nil, false + } + var decoded any + if err := json.Unmarshal([]byte(trimmed), &decoded); err != nil { + return nil, false + } + if !schemaAcceptsDecodedValue(decoded, schema) { + return nil, false + } + return decoded, true + case map[string]any: + if changed := decodeStringifiedSchemaValues(v, mapValue(schema)); changed { + return v, true + } + case []any: + itemSchema := schemaArrayItemSchema(mapValue(schema)) + if itemSchema == nil { + return nil, false + } + var out []any + for i, item := range v { + decoded, ok := decodeStringifiedSchemaValue(item, itemSchema) + if !ok { + if out != nil { + out[i] = item + } + continue + } + if out == nil { + out = make([]any, len(v)) + copy(out, v) + } + out[i] = decoded + } + if out != nil { + return out, true + } + } + return nil, false +} + +func schemaAcceptsDecodedValue(value any, schema any) bool { + m := mapValue(schema) + switch value.(type) { + case []any: + return schemaAllowsType(m, "array") || schemaArrayItemSchema(m) != nil + case map[string]any: + return schemaAllowsType(m, "object") || len(schemaObjectProperties(m)) > 0 + default: + return false + } +} + func requestedToolNames(tools []any) map[string]struct{} { names := make(map[string]struct{}, len(tools)) for _, tool := range tools {