mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-19 22:19:12 +08:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
132
internal/translator/openai/openai/responses/shell_tool.go
Normal file
132
internal/translator/openai/openai/responses/shell_tool.go
Normal 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
|
||||
}
|
||||
187
internal/translator/openai/openai/responses/shell_tool_test.go
Normal file
187
internal/translator/openai/openai/responses/shell_tool_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user