fix(openai): native tool 인자를 schema에 맞춘다
This commit is contained in:
parent
7dc0788f88
commit
a32264e908
5 changed files with 253 additions and 5 deletions
|
|
@ -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에 `<tool_call>`, `{{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이 지원하는 텍스트 블록의 최소 형태는 `<tool_call><function=<name>><parameter=<key>>JSON-or-text</parameter></function></tool_call>` 또는 `{{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로 만든다.
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue