feat(responses): support local shell tool translation

- Map local shell tool declarations and tool choice to synthetic function calls
- Normalize shell call and output history items into chat completion tool messages
- Convert upstream tool calls back to `shell_call` output items in stream and non-stream responses
- Validate shell action arguments and handle invalid input errors

Closes: #4812
This commit is contained in:
Luis Pater
2026-10-03 20:21:15 +08:00
parent 0594a632d9
commit 91c934f68b
6 changed files with 400 additions and 6 deletions

View File

@@ -73,7 +73,7 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
// Convert input array to messages
if input := root.Get("input"); input.Exists() && input.IsArray() {
rawInputArray := input.Array()
rawInputArray := toolIndex.shellHistory(input.Array())
explicitOutputCounts := make(map[string]int)
missingIDOutputsCount := 0
for _, item := range rawInputArray {
@@ -482,6 +482,12 @@ func convertResponsesToolChoiceWithIndex(toolChoice gjson.Result, toolIndex *res
}
choiceType := toolChoice.Get("type").String()
if choiceType == "shell" {
if name := toolIndex.shellName(); name != "" {
converted, _ := sjson.SetBytes([]byte(`{"type":"function","function":{}}`), "function.name", name)
return converted
}
}
if choiceType != "function" && choiceType != "custom" {
return []byte(toolChoice.Raw)
}

View File

@@ -213,6 +213,13 @@ func buildResponsesCompletedEvent(st *oaiToResponsesState, requestRawJSON []byte
if _, isInc := incompleteByFinishReason(st.FinishReason); isInc {
toolStatus = "incomplete"
}
if st.ToolIndex.isShell(name) {
item, errShellCallItem := shellCallItem(callID, args, toolStatus)
if errShellCallItem == nil {
outputItems = append(outputItems, completedOutputItem{index: st.FuncOutputIx[key], raw: item})
}
continue
}
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))
@@ -359,7 +366,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
failToolInput := func(err error) {
if st.ToolInputError() == nil {
st.SetToolInputError(err)
out = append(out, emitRespEvent("response.failed", translatorcommon.ApplyPatchFailure(st.ResponseID, nextSeq())))
out = append(out, emitRespEvent("response.failed", responsesToolInputFailure(st.ResponseID, nextSeq(), err)))
}
}
emitToolItem := func(key string, force bool) {
@@ -390,7 +397,13 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
outputIndex := st.FuncOutputIx[key]
_, isCustomTool := st.CustomToolNames[name]
st.FuncItemCustom[key] = isCustomTool
if isCustomTool {
if st.ToolIndex.isShell(name) {
o := []byte(`{"type":"response.output_item.added"}`)
o, _ = sjson.SetBytes(o, "sequence_number", nextSeq())
o, _ = sjson.SetBytes(o, "output_index", outputIndex)
o, _ = sjson.SetRawBytes(o, "item", shellCallPlaceholder(callID))
out = append(out, emitRespEvent("response.output_item.added", o))
} else if isCustomTool {
if st.ToolIndex.isApplyPatch(name) {
d := st.ToolIndex.byChat[name]
st.ApplyPatchCalls[key] = &translatorcommon.ApplyPatchCallState{
@@ -417,7 +430,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
st.FuncItemAdded[key] = true
}
emitPendingFunctionArgs := func(key string) {
if !st.FuncItemAdded[key] || st.ToolInputError() != nil {
if !st.FuncItemAdded[key] || st.ToolInputError() != nil || st.ToolIndex.isShell(st.FuncNames[key]) {
return
}
argsBuf := st.FuncArgsBuf[key]
@@ -644,6 +657,22 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
toolStatus = "incomplete"
}
if st.ToolIndex.isShell(name) {
item, errShellCallItem := shellCallItem(callID, args, toolStatus)
if errShellCallItem != nil {
failToolInput(errShellCallItem)
return
}
event := []byte(`{"type":"response.output_item.done"}`)
event, _ = sjson.SetBytes(event, "sequence_number", nextSeq())
event, _ = sjson.SetBytes(event, "output_index", outputIndex)
event, _ = sjson.SetRawBytes(event, "item", item)
out = append(out, emitRespEvent("response.output_item.done", event))
st.FuncItemDone[key] = true
st.FuncArgsDone[key] = true
continue
}
if st.FuncItemCustom[key] {
input := ""
if patchCall := st.ApplyPatchCalls[key]; patchCall != nil {
@@ -1044,6 +1073,15 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co
if isIncomplete {
toolStatus = "incomplete"
}
if toolIndex.isShell(name) {
item, errShellCallItem := shellCallItem(callID, args, toolStatus)
if errShellCallItem != nil {
st.SetToolInputError(errShellCallItem)
return false
}
outputItems = append(outputItems, item)
return true
}
if _, isCustomTool := customToolNames[name]; isCustomTool {
item := []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`)
item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("ctc_%s", callID))
@@ -1081,7 +1119,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co
})
}
if st.ToolInputError() != nil {
return []byte(gjson.GetBytes(translatorcommon.ApplyPatchFailure(id, 0), "response").Raw)
return []byte(gjson.GetBytes(responsesToolInputFailure(id, 0, st.ToolInputError()), "response").Raw)
}
if len(outputItems) > 0 {
resp, _ = sjson.SetRawBytes(resp, "output", translatorcommon.JoinRawArray(outputItems))

View File

@@ -20,6 +20,7 @@ type responsesToolDeclaration struct {
localName string
namespace string
custom bool
shell bool
}
// walkResponsesToolDeclarations visits the tool declarations of a Responses
@@ -36,15 +37,23 @@ type responsesToolDeclaration struct {
func walkResponsesToolDeclarations(root gjson.Result, visit func(responsesToolDeclaration) bool) {
var declarations []responsesToolDeclaration
emit := func(tool gjson.Result, namespaceName string) {
var custom bool
var custom, shell bool
switch strings.TrimSpace(tool.Get("type").String()) {
case "", "function":
case "custom":
custom = true
case "shell":
if namespaceName != "" || tool.Get("environment.type").String() != "local" {
return
}
shell = true
default:
return
}
localName := responsesToolName(tool)
if shell {
localName = "__cpa_local_shell"
}
if localName == "" {
return
}
@@ -54,6 +63,7 @@ func walkResponsesToolDeclarations(root gjson.Result, visit func(responsesToolDe
localName: localName,
namespace: namespaceName,
custom: custom,
shell: shell,
})
}
scan := func(tools gjson.Result) {
@@ -86,6 +96,25 @@ func walkResponsesToolDeclarations(root gjson.Result, visit func(responsesToolDe
})
}
// Reserve user identities before assigning the synthetic shell name.
reserved := make(map[string]bool)
for _, d := range declarations {
if !d.shell {
reserved[d.localName] = true
reserved[d.chatName] = true
reserved[rawResponsesNamespaceQualifiedName(d.namespace, d.localName)] = true
}
}
shellName := "__cpa_local_shell"
for suffix := 1; reserved[shellName]; suffix++ {
shellName = "__cpa_local_shell_" + strconv.Itoa(suffix)
}
for i := range declarations {
if declarations[i].shell {
declarations[i].localName = shellName
declarations[i].chatName = shellName
}
}
disambiguateResponsesChatToolNames(declarations)
proceed := true

View File

@@ -118,6 +118,8 @@ func (idx *responsesToolIndex) chatTools() [][]byte {
convert := convertResponsesFunctionToolToOpenAIChat
if d.custom {
convert = convertResponsesCustomToolToOpenAIChat
} else if d.shell {
convert = convertResponsesShellToolToOpenAIChat
}
if tool, ok := convert(d.tool, d.chatName); ok {
merged = append(merged, tool)

View File

@@ -0,0 +1,132 @@
package responses
import (
"errors"
"math"
"strconv"
"strings"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
var errInvalidShellAction = errors.New("invalid shell action: expected nonempty commands strings and optional positive integer limits")
func convertResponsesShellToolToOpenAIChat(_ gjson.Result, name string) ([]byte, bool) {
tool := []byte(`{"type":"function","function":{"name":"","description":"Request commands to execute in the client-provided local shell environment. Each commands entry is a complete shell command, not an argv element.","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"},"minItems":1},"timeout_ms":{"type":"integer","minimum":1},"max_output_length":{"type":"integer","minimum":1}},"required":["commands"],"additionalProperties":false}}}`)
tool, _ = sjson.SetBytes(tool, "function.name", name)
return tool, true
}
func (idx *responsesToolIndex) isShell(name string) bool {
return idx.byChat[name].shell
}
func (idx *responsesToolIndex) shellName() string {
for _, d := range idx.declarations {
if d.shell {
return d.chatName
}
}
return ""
}
// Normalize shell history before the existing call grouping and output pairing.
// Keep the entire output envelope as JSON text, including truncation metadata.
func (idx *responsesToolIndex) shellHistory(items []gjson.Result) []gjson.Result {
name := idx.shellName()
if name == "" {
// Historical calls remain meaningful after the client withdraws the tool.
// Reserve a replay-only name without adding an available declaration.
reserved := make(map[string]bool)
for key := range idx.byChat {
reserved[key] = true
}
for key := range idx.byRaw {
reserved[key] = true
}
for key := range idx.byLocal {
reserved[key] = true
}
for _, item := range items {
if kind := item.Get("type").String(); kind == "function_call" || kind == "custom_tool_call" {
reserved[idx.canonicalName(item.Get("name").String())] = true
}
}
name = "__cpa_local_shell"
for suffix := 1; reserved[name]; suffix++ {
name = "__cpa_local_shell_" + strconv.Itoa(suffix)
}
}
for i, item := range items {
raw := []byte(item.Raw)
switch item.Get("type").String() {
case "shell_call":
if environment := item.Get("environment.type"); environment.Exists() && environment.String() != "local" {
continue
}
raw, _ = sjson.SetBytes(raw, "type", "function_call")
raw, _ = sjson.SetBytes(raw, "name", name)
raw, _ = sjson.SetBytes(raw, "arguments", item.Get("action").Raw)
case "shell_call_output":
raw, _ = sjson.SetBytes(raw, "type", "function_call_output")
raw, _ = sjson.SetBytes(raw, "output", item.Raw)
default:
continue
}
items[i] = gjson.ParseBytes(raw)
}
return items
}
func shellCallItem(callID, arguments, status string) ([]byte, error) {
action := gjson.Parse(arguments)
if !gjson.Valid(arguments) || !action.IsObject() {
return nil, errInvalidShellAction
}
commands := action.Get("commands")
if !commands.IsArray() || len(commands.Array()) == 0 {
return nil, errInvalidShellAction
}
for _, command := range commands.Array() {
if command.Type != gjson.String || strings.TrimSpace(command.String()) == "" {
return nil, errInvalidShellAction
}
}
valid := true
action.ForEach(func(key, value gjson.Result) bool {
switch key.String() {
case "commands":
case "timeout_ms", "max_output_length":
if value.Type != gjson.Null && (value.Type != gjson.Number || value.Float() <= 0 || math.Trunc(value.Float()) != value.Float()) {
valid = false
}
default:
valid = false
}
return valid
})
if !valid {
return nil, errInvalidShellAction
}
item := shellCallPlaceholder(callID)
item, _ = sjson.SetBytes(item, "status", status)
item, _ = sjson.SetRawBytes(item, "action", []byte(arguments))
return item, nil
}
func shellCallPlaceholder(callID string) []byte {
item := []byte(`{"id":"","type":"shell_call","status":"in_progress","call_id":"","action":{"commands":[]}}`)
item, _ = sjson.SetBytes(item, "id", "sh_"+callID)
item, _ = sjson.SetBytes(item, "call_id", callID)
return item
}
func responsesToolInputFailure(responseID string, sequence int, err error) []byte {
failure := translatorcommon.ApplyPatchFailure(responseID, sequence)
if errors.Is(err, errInvalidShellAction) {
failure, _ = sjson.SetBytes(failure, "response.error.message", errInvalidShellAction.Error())
}
return failure
}

View File

@@ -0,0 +1,187 @@
package responses
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
func TestLocalShellDeclarationSurvivesChatTranslation(t *testing.T) {
request := []byte(`{"tools":[{"type":"shell","environment":{"type":"local"}}],"tool_choice":"auto","input":"Run echo SHELL_OK"}`)
got := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("test", request, false)
if gjson.GetBytes(got, "tools.#").Int() != 1 {
t.Fatalf("local shell declaration was dropped from chat request: %s", got)
}
if gjson.GetBytes(got, "tools.0.function.parameters.properties.commands.items.type").String() != "string" {
t.Fatalf("shell action must expose commands as strings: %s", got)
}
}
const shellRequest = `{"tools":[{"type":"shell","environment":{"type":"local"}},{"type":"function","name":"__cpa_local_shell","parameters":{}},{"type":"custom","name":"__cpa_local_shell_1"},{"type":"namespace","name":"ns","tools":[{"type":"function","name":"lookup","parameters":{}}]}],"tool_choice":{"type":"shell"}}`
func shellUpstreamCall(name, args string) []byte {
call := []byte(`{"index":0,"id":"call_shell","type":"function","function":{}}`)
call, _ = sjson.SetBytes(call, "function.name", name)
call, _ = sjson.SetBytes(call, "function.arguments", args)
return call
}
func shellNonStream(request []byte, name, args string) []byte {
upstream := []byte(`{"id":"resp_test","choices":[{"index":0,"finish_reason":"tool_calls","message":{"role":"assistant","tool_calls":[]}}]}`)
upstream, _ = sjson.SetRawBytes(upstream, "choices.0.message.tool_calls.0", shellUpstreamCall(name, args))
return ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "test", request, nil, upstream, nil)
}
func shellStream(request []byte, name, args string) []gjson.Result {
var state any
var events []gjson.Result
push := func(chunk []byte) {
for _, output := range ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "test", request, nil, chunk, &state) {
for _, line := range strings.Split(string(output), "\n") {
if strings.HasPrefix(line, "data: ") {
events = append(events, gjson.Parse(strings.TrimPrefix(line, "data: ")))
}
}
}
}
for i, fragment := range []string{args[:len(args)/2], args[len(args)/2:]} {
chunk := []byte(`{"id":"resp_test","choices":[{"index":0,"delta":{"tool_calls":[]}}]}`)
call := shellUpstreamCall(name, fragment)
if i == 1 {
call, _ = sjson.DeleteBytes(call, "id")
call, _ = sjson.DeleteBytes(call, "function.name")
}
chunk, _ = sjson.SetRawBytes(chunk, "choices.0.delta.tool_calls.0", call)
push(append([]byte("data: "), chunk...))
}
push([]byte(`data: {"id":"resp_test","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`))
push([]byte("data: [DONE]"))
return events
}
func TestLocalShellRoundTrip(t *testing.T) {
request := []byte(shellRequest)
chat := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("test", request, false)
name := gjson.GetBytes(chat, "tools.0.function.name").String()
if name == "" || name == "__cpa_local_shell" || name == "__cpa_local_shell_1" || gjson.GetBytes(chat, "tools.#").Int() != 4 {
t.Fatalf("synthetic tool collided with user tools: %s", chat)
}
if gjson.GetBytes(chat, "tool_choice.function.name").String() != name {
t.Fatalf("shell tool_choice not mapped: %s", chat)
}
args := `{"commands":["echo SHELL_OK","pwd"],"timeout_ms":120000,"max_output_length":4096}`
nonstream := shellNonStream(request, name, args)
item := gjson.GetBytes(nonstream, "output.0")
if item.Get("type").String() != "shell_call" || item.Get("action.commands.0").String() != "echo SHELL_OK" || item.Get("status").String() != "completed" || item.Get("name").Exists() || item.Get("arguments").Exists() {
t.Fatalf("invalid shell item: %s", nonstream)
}
var done, completed gjson.Result
for _, event := range shellStream(request, name, args) {
switch event.Get("type").String() {
case "response.output_item.added":
if event.Get("item.type").String() != "shell_call" {
t.Fatalf("wrong added item: %s", event.Raw)
}
case "response.output_item.done":
done = event.Get("item")
case "response.completed":
completed = event.Get("response.output.0")
case "response.function_call_arguments.delta", "response.function_call_arguments.done":
t.Fatalf("shell leaked function event: %s", event.Raw)
}
}
if !done.Exists() || !reflect.DeepEqual(done.Value(), item.Value()) || !reflect.DeepEqual(done.Value(), completed.Value()) {
t.Fatalf("inconsistent final items: nonstream=%s done=%s completed=%s", item.Raw, done.Raw, completed.Raw)
}
output := `{"type":"shell_call_output","call_id":"call_shell","max_output_length":4096,"output":[{"stdout":"SHELL_OK\n","stderr":"warning","outcome":{"type":"exit","exit_code":0}},{"stdout":"partial","stderr":"","outcome":{"type":"timeout"}}]}`
replay, _ := sjson.SetRawBytes(request, "input", []byte("["+item.Raw+`,`+`{"type":"function_call","call_id":"call_user","name":"__cpa_local_shell","arguments":"{}"},`+output+`,`+`{"type":"function_call_output","call_id":"call_user","output":"ok"}]`))
replayed := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("test", replay, false)
if gjson.GetBytes(replayed, "messages.0.tool_calls.#").Int() != 2 || gjson.GetBytes(replayed, "messages.0.tool_calls.0.function.name").String() != name || gjson.GetBytes(replayed, "messages.0.tool_calls.0.function.arguments").String() != args {
t.Fatalf("shell history not grouped/replayed: %s", replayed)
}
content := gjson.GetBytes(replayed, "messages.1.content").String()
if gjson.GetBytes(replayed, "messages.1.role").String() != "tool" || !reflect.DeepEqual(gjson.Parse(content).Value(), gjson.Parse(output).Value()) {
t.Fatalf("shell output envelope lost: %s", replayed)
}
for _, tc := range []struct{ name, kind string }{{"__cpa_local_shell", "function_call"}, {"__cpa_local_shell_1", "custom_tool_call"}, {"ns__lookup", "function_call"}} {
got := shellNonStream(request, tc.name, `{"input":"hello"}`)
if gjson.GetBytes(got, "output.0.type").String() != tc.kind {
t.Fatalf("user tool misclassified: %s", got)
}
}
unknown := shellNonStream([]byte(`{}`), name, args)
if gjson.GetBytes(unknown, "output.0.type").String() != "function_call" {
t.Fatalf("synthetic name recognized without provenance: %s", unknown)
}
}
func TestLocalShellInvalidActions(t *testing.T) {
request := []byte(`{"tools":[{"type":"shell","environment":{"type":"local"}}]}`)
for _, args := range []string{`{`, `{}`, `{"commands":[]}`, `{"commands":[42]}`, `{"commands":[" "]}`, `{"command":["echo","ok"]}`, `{"commands":["pwd"],"timeout_ms":-1}`, `{"commands":["pwd"],"timeout_ms":1.5}`, `{"commands":["pwd"],"env":{}}`} {
t.Run(args, func(t *testing.T) {
got := shellNonStream(request, "__cpa_local_shell", args)
if gjson.GetBytes(got, "status").String() != "failed" || !strings.Contains(gjson.GetBytes(got, "error.message").String(), "shell") {
t.Fatalf("invalid action accepted: %s", got)
}
failed := false
for _, event := range shellStream(request, "__cpa_local_shell", args) {
if event.Get("type").String() == "response.failed" {
failed = true
}
if event.Get("type").String() == "response.completed" || event.Get("type").String() == "response.output_item.done" {
t.Fatalf("invalid action completed: %s", event.Raw)
}
}
if !failed {
t.Fatal("invalid action did not fail streaming")
}
})
}
}
func TestLocalShellOnlyExplicitLocalEnvironment(t *testing.T) {
for _, environment := range []string{`{"type":"container_auto"}`, `{"type":"container_reference","container_id":"cntr_x"}`, `{}`} {
request := []byte(`{"tools":[{"type":"shell","environment":` + environment + `}]}`)
got := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("test", request, false)
if gjson.GetBytes(got, "tools.#").Int() != 0 {
t.Fatalf("hosted shell represented as local: %s", got)
}
}
for _, choice := range []string{`"auto"`, `"required"`, `"none"`} {
request := []byte(`{"tools":[{"type":"shell","environment":{"type":"local"}}],"tool_choice":` + choice + `}`)
got := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("test", request, false)
var expected string
if errUnmarshal := json.Unmarshal([]byte(choice), &expected); errUnmarshal != nil {
t.Fatal(errUnmarshal)
}
if gjson.GetBytes(got, "tool_choice").String() != expected {
t.Fatalf("choice changed: %s", got)
}
}
}
func TestLocalShellHistoryWithoutDeclaration(t *testing.T) {
for _, tools := range []string{``, `,"tools":[]`, `,"tools":[{"type":"function","name":"__cpa_local_shell_1","parameters":{}}]`} {
request := []byte(`{"input":[{"type":"shell_call","call_id":"shell_1","action":{"commands":["pwd"]}},{"type":"shell_call_output","call_id":"shell_1","output":[{"stdout":"/workspace","stderr":"","outcome":{"type":"exit","exit_code":0}}]},{"type":"function_call","call_id":"user_1","name":"__cpa_local_shell","arguments":"{}"},{"type":"function_call_output","call_id":"user_1","output":"ok"}]` + tools + `}`)
got := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("test", request, false)
calls := gjson.GetBytes(got, "messages.0.tool_calls").Array()
if len(calls) != 1 || calls[0].Get("id").String() != "shell_1" || gjson.GetBytes(got, "messages.2.tool_calls.0.id").String() != "user_1" {
t.Fatalf("shell history dropped without declaration: %s", got)
}
name := calls[0].Get("function.name").String()
if name == "__cpa_local_shell" || name == "__cpa_local_shell_1" && tools != "" && tools != `,"tools":[]` {
t.Fatalf("history name collided: %s", got)
}
if gjson.Parse(gjson.GetBytes(got, "messages.1.content").String()).Get("output.0.stdout").String() != "/workspace" {
t.Fatalf("shell result lost: %s", got)
}
if gjson.GetBytes(got, "tools.#").Int() != gjson.GetBytes(request, "tools.#").Int() {
t.Fatalf("history re-enabled a tool: %s", got)
}
}
}