af65908cb0
- Added functions to handle tool conversion, including namespace-based tools and web search tools. - Improved parameter normalization and tool input schema standardization. - Integrated logic to handle qualified tool names and map override functionality. - Refactored existing tool processing for better extensibility and maintainability. Fixed: #3199
564 lines
18 KiB
Go
564 lines
18 KiB
Go
package responses
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"math/big"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
)
|
|
|
|
var (
|
|
user = ""
|
|
account = ""
|
|
session = ""
|
|
)
|
|
|
|
// ConvertOpenAIResponsesRequestToClaude transforms an OpenAI Responses API request
|
|
// into a Claude Messages API request using only gjson/sjson for JSON handling.
|
|
// It supports:
|
|
// - instructions -> system message
|
|
// - input[].type==message with input_text/output_text -> user/assistant messages
|
|
// - function_call -> assistant tool_use
|
|
// - function_call_output -> user tool_result
|
|
// - tools[].parameters -> tools[].input_schema
|
|
// - max_output_tokens -> max_tokens
|
|
// - stream passthrough via parameter
|
|
func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte, stream bool) []byte {
|
|
rawJSON := inputRawJSON
|
|
|
|
if account == "" {
|
|
u, _ := uuid.NewRandom()
|
|
account = u.String()
|
|
}
|
|
if session == "" {
|
|
u, _ := uuid.NewRandom()
|
|
session = u.String()
|
|
}
|
|
if user == "" {
|
|
sum := sha256.Sum256([]byte(account + session))
|
|
user = hex.EncodeToString(sum[:])
|
|
}
|
|
userID := fmt.Sprintf("user_%s_account_%s_session_%s", user, account, session)
|
|
|
|
// Base Claude message payload
|
|
out := []byte(fmt.Sprintf(`{"model":"","max_tokens":32000,"messages":[],"metadata":{"user_id":"%s"}}`, userID))
|
|
|
|
root := gjson.ParseBytes(rawJSON)
|
|
|
|
// Convert OpenAI Responses reasoning.effort to Claude thinking config.
|
|
if v := root.Get("reasoning.effort"); v.Exists() {
|
|
effort := strings.ToLower(strings.TrimSpace(v.String()))
|
|
if effort != "" {
|
|
mi := registry.LookupModelInfo(modelName, "claude")
|
|
supportsAdaptive := mi != nil && mi.Thinking != nil && len(mi.Thinking.Levels) > 0
|
|
supportsMax := supportsAdaptive && thinking.HasLevel(mi.Thinking.Levels, string(thinking.LevelMax))
|
|
|
|
// Claude 4.6 supports adaptive thinking with output_config.effort.
|
|
// MapToClaudeEffort normalizes levels (e.g. minimal→low, xhigh→high) to avoid
|
|
// validation errors since validate treats same-provider unsupported levels as errors.
|
|
if supportsAdaptive {
|
|
switch effort {
|
|
case "none":
|
|
out, _ = sjson.SetBytes(out, "thinking.type", "disabled")
|
|
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
|
|
out, _ = sjson.DeleteBytes(out, "output_config.effort")
|
|
case "auto":
|
|
out, _ = sjson.SetBytes(out, "thinking.type", "adaptive")
|
|
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
|
|
out, _ = sjson.DeleteBytes(out, "output_config.effort")
|
|
default:
|
|
if mapped, ok := thinking.MapToClaudeEffort(effort, supportsMax); ok {
|
|
effort = mapped
|
|
}
|
|
out, _ = sjson.SetBytes(out, "thinking.type", "adaptive")
|
|
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
|
|
out, _ = sjson.SetBytes(out, "output_config.effort", effort)
|
|
}
|
|
} else {
|
|
// Legacy/manual thinking (budget_tokens).
|
|
budget, ok := thinking.ConvertLevelToBudget(effort)
|
|
if ok {
|
|
switch budget {
|
|
case 0:
|
|
out, _ = sjson.SetBytes(out, "thinking.type", "disabled")
|
|
case -1:
|
|
out, _ = sjson.SetBytes(out, "thinking.type", "enabled")
|
|
default:
|
|
if budget > 0 {
|
|
out, _ = sjson.SetBytes(out, "thinking.type", "enabled")
|
|
out, _ = sjson.SetBytes(out, "thinking.budget_tokens", budget)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper for generating tool call IDs when missing
|
|
genToolCallID := func() string {
|
|
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
var b strings.Builder
|
|
for i := 0; i < 24; i++ {
|
|
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
|
|
b.WriteByte(letters[n.Int64()])
|
|
}
|
|
return "toolu_" + b.String()
|
|
}
|
|
|
|
// Model
|
|
out, _ = sjson.SetBytes(out, "model", modelName)
|
|
|
|
// Max tokens
|
|
if mot := root.Get("max_output_tokens"); mot.Exists() {
|
|
out, _ = sjson.SetBytes(out, "max_tokens", mot.Int())
|
|
}
|
|
|
|
// Stream
|
|
out, _ = sjson.SetBytes(out, "stream", stream)
|
|
|
|
// instructions -> as a leading message (use role user for Claude API compatibility)
|
|
instructionsText := ""
|
|
extractedFromSystem := false
|
|
if instr := root.Get("instructions"); instr.Exists() && instr.Type == gjson.String {
|
|
instructionsText = instr.String()
|
|
if instructionsText != "" {
|
|
sysMsg := []byte(`{"role":"user","content":""}`)
|
|
sysMsg, _ = sjson.SetBytes(sysMsg, "content", instructionsText)
|
|
out, _ = sjson.SetRawBytes(out, "messages.-1", sysMsg)
|
|
}
|
|
}
|
|
|
|
if instructionsText == "" {
|
|
if input := root.Get("input"); input.Exists() && input.IsArray() {
|
|
input.ForEach(func(_, item gjson.Result) bool {
|
|
if strings.EqualFold(item.Get("role").String(), "system") {
|
|
var builder strings.Builder
|
|
if parts := item.Get("content"); parts.Exists() && parts.IsArray() {
|
|
parts.ForEach(func(_, part gjson.Result) bool {
|
|
textResult := part.Get("text")
|
|
text := textResult.String()
|
|
if builder.Len() > 0 && text != "" {
|
|
builder.WriteByte('\n')
|
|
}
|
|
builder.WriteString(text)
|
|
return true
|
|
})
|
|
} else if parts.Type == gjson.String {
|
|
builder.WriteString(parts.String())
|
|
}
|
|
instructionsText = builder.String()
|
|
if instructionsText != "" {
|
|
sysMsg := []byte(`{"role":"user","content":""}`)
|
|
sysMsg, _ = sjson.SetBytes(sysMsg, "content", instructionsText)
|
|
out, _ = sjson.SetRawBytes(out, "messages.-1", sysMsg)
|
|
extractedFromSystem = true
|
|
}
|
|
}
|
|
return instructionsText == ""
|
|
})
|
|
}
|
|
}
|
|
|
|
// input array processing
|
|
if input := root.Get("input"); input.Exists() && input.IsArray() {
|
|
input.ForEach(func(_, item gjson.Result) bool {
|
|
if extractedFromSystem && strings.EqualFold(item.Get("role").String(), "system") {
|
|
return true
|
|
}
|
|
typ := item.Get("type").String()
|
|
if typ == "" && item.Get("role").String() != "" {
|
|
typ = "message"
|
|
}
|
|
switch typ {
|
|
case "message":
|
|
// Determine role and construct Claude-compatible content parts.
|
|
var role string
|
|
var textAggregate strings.Builder
|
|
var partsJSON []string
|
|
hasImage := false
|
|
hasFile := false
|
|
if parts := item.Get("content"); parts.Exists() && parts.IsArray() {
|
|
parts.ForEach(func(_, part gjson.Result) bool {
|
|
ptype := part.Get("type").String()
|
|
switch ptype {
|
|
case "input_text", "output_text":
|
|
if t := part.Get("text"); t.Exists() {
|
|
txt := t.String()
|
|
textAggregate.WriteString(txt)
|
|
contentPart := []byte(`{"type":"text","text":""}`)
|
|
contentPart, _ = sjson.SetBytes(contentPart, "text", txt)
|
|
partsJSON = append(partsJSON, string(contentPart))
|
|
}
|
|
if ptype == "input_text" {
|
|
role = "user"
|
|
} else {
|
|
role = "assistant"
|
|
}
|
|
case "input_image":
|
|
url := part.Get("image_url").String()
|
|
if url == "" {
|
|
url = part.Get("url").String()
|
|
}
|
|
if url != "" {
|
|
var contentPart []byte
|
|
if strings.HasPrefix(url, "data:") {
|
|
trimmed := strings.TrimPrefix(url, "data:")
|
|
mediaAndData := strings.SplitN(trimmed, ";base64,", 2)
|
|
mediaType := "application/octet-stream"
|
|
data := ""
|
|
if len(mediaAndData) == 2 {
|
|
if mediaAndData[0] != "" {
|
|
mediaType = mediaAndData[0]
|
|
}
|
|
data = mediaAndData[1]
|
|
}
|
|
if data != "" {
|
|
contentPart = []byte(`{"type":"image","source":{"type":"base64","media_type":"","data":""}}`)
|
|
contentPart, _ = sjson.SetBytes(contentPart, "source.media_type", mediaType)
|
|
contentPart, _ = sjson.SetBytes(contentPart, "source.data", data)
|
|
}
|
|
} else {
|
|
contentPart = []byte(`{"type":"image","source":{"type":"url","url":""}}`)
|
|
contentPart, _ = sjson.SetBytes(contentPart, "source.url", url)
|
|
}
|
|
if len(contentPart) > 0 {
|
|
partsJSON = append(partsJSON, string(contentPart))
|
|
if role == "" {
|
|
role = "user"
|
|
}
|
|
hasImage = true
|
|
}
|
|
}
|
|
case "input_file":
|
|
fileData := part.Get("file_data").String()
|
|
if fileData != "" {
|
|
mediaType := "application/octet-stream"
|
|
data := fileData
|
|
if strings.HasPrefix(fileData, "data:") {
|
|
trimmed := strings.TrimPrefix(fileData, "data:")
|
|
mediaAndData := strings.SplitN(trimmed, ";base64,", 2)
|
|
if len(mediaAndData) == 2 {
|
|
if mediaAndData[0] != "" {
|
|
mediaType = mediaAndData[0]
|
|
}
|
|
data = mediaAndData[1]
|
|
}
|
|
}
|
|
contentPart := []byte(`{"type":"document","source":{"type":"base64","media_type":"","data":""}}`)
|
|
contentPart, _ = sjson.SetBytes(contentPart, "source.media_type", mediaType)
|
|
contentPart, _ = sjson.SetBytes(contentPart, "source.data", data)
|
|
partsJSON = append(partsJSON, string(contentPart))
|
|
if role == "" {
|
|
role = "user"
|
|
}
|
|
hasFile = true
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
} else if parts.Type == gjson.String {
|
|
textAggregate.WriteString(parts.String())
|
|
}
|
|
|
|
// Fallback to given role if content types not decisive
|
|
if role == "" {
|
|
r := item.Get("role").String()
|
|
switch r {
|
|
case "user", "assistant", "system":
|
|
role = r
|
|
default:
|
|
role = "user"
|
|
}
|
|
}
|
|
|
|
if len(partsJSON) > 0 {
|
|
msg := []byte(`{"role":"","content":[]}`)
|
|
msg, _ = sjson.SetBytes(msg, "role", role)
|
|
if len(partsJSON) == 1 && !hasImage && !hasFile {
|
|
// Preserve legacy behavior for single text content
|
|
msg, _ = sjson.DeleteBytes(msg, "content")
|
|
textPart := gjson.Parse(partsJSON[0])
|
|
msg, _ = sjson.SetBytes(msg, "content", textPart.Get("text").String())
|
|
} else {
|
|
for _, partJSON := range partsJSON {
|
|
msg, _ = sjson.SetRawBytes(msg, "content.-1", []byte(partJSON))
|
|
}
|
|
}
|
|
out, _ = sjson.SetRawBytes(out, "messages.-1", msg)
|
|
} else if textAggregate.Len() > 0 || role == "system" {
|
|
msg := []byte(`{"role":"","content":""}`)
|
|
msg, _ = sjson.SetBytes(msg, "role", role)
|
|
msg, _ = sjson.SetBytes(msg, "content", textAggregate.String())
|
|
out, _ = sjson.SetRawBytes(out, "messages.-1", msg)
|
|
}
|
|
|
|
case "function_call":
|
|
// Map to assistant tool_use
|
|
callID := item.Get("call_id").String()
|
|
if callID == "" {
|
|
callID = genToolCallID()
|
|
}
|
|
name := item.Get("name").String()
|
|
argsStr := item.Get("arguments").String()
|
|
|
|
toolUse := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
|
|
toolUse, _ = sjson.SetBytes(toolUse, "id", callID)
|
|
toolUse, _ = sjson.SetBytes(toolUse, "name", name)
|
|
if argsStr != "" && gjson.Valid(argsStr) {
|
|
argsJSON := gjson.Parse(argsStr)
|
|
if argsJSON.IsObject() {
|
|
toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte(argsJSON.Raw))
|
|
}
|
|
}
|
|
|
|
asst := []byte(`{"role":"assistant","content":[]}`)
|
|
asst, _ = sjson.SetRawBytes(asst, "content.-1", toolUse)
|
|
out, _ = sjson.SetRawBytes(out, "messages.-1", asst)
|
|
|
|
case "function_call_output":
|
|
// Map to user tool_result
|
|
callID := item.Get("call_id").String()
|
|
outputStr := item.Get("output").String()
|
|
toolResult := []byte(`{"type":"tool_result","tool_use_id":"","content":""}`)
|
|
toolResult, _ = sjson.SetBytes(toolResult, "tool_use_id", callID)
|
|
toolResult, _ = sjson.SetBytes(toolResult, "content", outputStr)
|
|
|
|
usr := []byte(`{"role":"user","content":[]}`)
|
|
usr, _ = sjson.SetRawBytes(usr, "content.-1", toolResult)
|
|
out, _ = sjson.SetRawBytes(out, "messages.-1", usr)
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
|
|
includedToolNames := map[string]struct{}{}
|
|
toolNameMap := map[string]string{}
|
|
|
|
// tools mapping: parameters -> input_schema
|
|
if tools := root.Get("tools"); tools.Exists() && tools.IsArray() {
|
|
toolsJSON := []byte("[]")
|
|
tools.ForEach(func(_, tool gjson.Result) bool {
|
|
convertedTools := convertResponsesToolToClaudeTools(tool, toolNameMap)
|
|
for _, tJSON := range convertedTools {
|
|
toolName := gjson.GetBytes(tJSON, "name").String()
|
|
if toolName != "" {
|
|
includedToolNames[toolName] = struct{}{}
|
|
}
|
|
toolsJSON, _ = sjson.SetRawBytes(toolsJSON, "-1", tJSON)
|
|
}
|
|
return true
|
|
})
|
|
if parsedTools := gjson.ParseBytes(toolsJSON); parsedTools.IsArray() && len(parsedTools.Array()) > 0 {
|
|
out, _ = sjson.SetRawBytes(out, "tools", toolsJSON)
|
|
}
|
|
}
|
|
|
|
// Map tool_choice similar to Chat Completions translator (optional in docs, safe to handle)
|
|
if toolChoice := root.Get("tool_choice"); toolChoice.Exists() {
|
|
switch toolChoice.Type {
|
|
case gjson.String:
|
|
switch toolChoice.String() {
|
|
case "auto":
|
|
out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"auto"}`))
|
|
case "none":
|
|
// Leave unset; implies no tools
|
|
case "required":
|
|
if len(includedToolNames) > 0 {
|
|
out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"any"}`))
|
|
}
|
|
}
|
|
case gjson.JSON:
|
|
if toolChoice.Get("type").String() == "function" {
|
|
fn := toolChoice.Get("function.name").String()
|
|
if fn == "" {
|
|
fn = toolChoice.Get("name").String()
|
|
}
|
|
if mappedName := toolNameMap[fn]; mappedName != "" {
|
|
fn = mappedName
|
|
}
|
|
if _, ok := includedToolNames[fn]; ok {
|
|
toolChoiceJSON := []byte(`{"name":"","type":"tool"}`)
|
|
toolChoiceJSON, _ = sjson.SetBytes(toolChoiceJSON, "name", fn)
|
|
out, _ = sjson.SetRawBytes(out, "tool_choice", toolChoiceJSON)
|
|
}
|
|
}
|
|
default:
|
|
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func convertResponsesToolToClaudeTools(tool gjson.Result, toolNameMap map[string]string) [][]byte {
|
|
toolType := strings.TrimSpace(tool.Get("type").String())
|
|
switch toolType {
|
|
case "", "function":
|
|
if tJSON, ok := convertResponsesFunctionToolToClaude(tool, ""); ok {
|
|
return [][]byte{tJSON}
|
|
}
|
|
case "namespace":
|
|
return convertResponsesNamespaceToolToClaude(tool, toolNameMap)
|
|
case "web_search":
|
|
if tJSON, ok := convertResponsesWebSearchToolToClaude(tool); ok {
|
|
if name := gjson.GetBytes(tJSON, "name").String(); name != "" {
|
|
toolNameMap[name] = name
|
|
}
|
|
return [][]byte{tJSON}
|
|
}
|
|
default:
|
|
if isUnsupportedOpenAIBuiltinToolType(toolType) {
|
|
return nil
|
|
}
|
|
if tool.Get("name").String() != "" {
|
|
return [][]byte{[]byte(tool.Raw)}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func convertResponsesNamespaceToolToClaude(tool gjson.Result, toolNameMap map[string]string) [][]byte {
|
|
namespaceName := strings.TrimSpace(tool.Get("name").String())
|
|
children := tool.Get("tools")
|
|
if !children.Exists() || !children.IsArray() {
|
|
return nil
|
|
}
|
|
|
|
var out [][]byte
|
|
children.ForEach(func(_, child gjson.Result) bool {
|
|
childName := responsesToolName(child)
|
|
qualifiedName := qualifyResponsesNamespaceToolName(namespaceName, childName)
|
|
if tJSON, ok := convertResponsesFunctionToolToClaude(child, qualifiedName); ok {
|
|
out = append(out, tJSON)
|
|
toolNameMap[qualifiedName] = qualifiedName
|
|
if childName != "" {
|
|
toolNameMap[childName] = qualifiedName
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
return out
|
|
}
|
|
|
|
func convertResponsesFunctionToolToClaude(tool gjson.Result, overrideName string) ([]byte, bool) {
|
|
name := strings.TrimSpace(overrideName)
|
|
if name == "" {
|
|
name = responsesToolName(tool)
|
|
}
|
|
if name == "" {
|
|
return nil, false
|
|
}
|
|
|
|
tJSON := []byte(`{"name":"","description":"","input_schema":{}}`)
|
|
tJSON, _ = sjson.SetBytes(tJSON, "name", name)
|
|
if d := responsesToolDescription(tool); d != "" {
|
|
tJSON, _ = sjson.SetBytes(tJSON, "description", d)
|
|
}
|
|
tJSON, _ = sjson.SetRawBytes(tJSON, "input_schema", normalizeClaudeToolInputSchema(responsesToolParameters(tool)))
|
|
return tJSON, true
|
|
}
|
|
|
|
func convertResponsesWebSearchToolToClaude(tool gjson.Result) ([]byte, bool) {
|
|
if externalWebAccess := tool.Get("external_web_access"); externalWebAccess.Exists() && !externalWebAccess.Bool() {
|
|
return nil, false
|
|
}
|
|
|
|
name := strings.TrimSpace(tool.Get("name").String())
|
|
if name == "" {
|
|
name = "web_search"
|
|
}
|
|
tJSON := []byte(`{"type":"web_search_20250305","name":""}`)
|
|
tJSON, _ = sjson.SetBytes(tJSON, "name", name)
|
|
if maxUses := tool.Get("max_uses"); maxUses.Exists() {
|
|
tJSON, _ = sjson.SetBytes(tJSON, "max_uses", maxUses.Int())
|
|
}
|
|
if allowedDomains := tool.Get("filters.allowed_domains"); allowedDomains.Exists() && allowedDomains.IsArray() {
|
|
tJSON, _ = sjson.SetRawBytes(tJSON, "allowed_domains", []byte(allowedDomains.Raw))
|
|
}
|
|
if userLocation := tool.Get("user_location"); userLocation.Exists() && userLocation.IsObject() {
|
|
tJSON, _ = sjson.SetRawBytes(tJSON, "user_location", []byte(userLocation.Raw))
|
|
}
|
|
return tJSON, true
|
|
}
|
|
|
|
func responsesToolName(tool gjson.Result) string {
|
|
if name := strings.TrimSpace(tool.Get("name").String()); name != "" {
|
|
return name
|
|
}
|
|
return strings.TrimSpace(tool.Get("function.name").String())
|
|
}
|
|
|
|
func responsesToolDescription(tool gjson.Result) string {
|
|
if description := tool.Get("description").String(); description != "" {
|
|
return description
|
|
}
|
|
return tool.Get("function.description").String()
|
|
}
|
|
|
|
func responsesToolParameters(tool gjson.Result) gjson.Result {
|
|
for _, path := range []string{
|
|
"parameters",
|
|
"parametersJsonSchema",
|
|
"input_schema",
|
|
"function.parameters",
|
|
"function.parametersJsonSchema",
|
|
} {
|
|
if parameters := tool.Get(path); parameters.Exists() {
|
|
return parameters
|
|
}
|
|
}
|
|
return gjson.Result{}
|
|
}
|
|
|
|
func normalizeClaudeToolInputSchema(parameters gjson.Result) []byte {
|
|
raw := strings.TrimSpace(parameters.Raw)
|
|
if raw == "" || raw == "null" || !gjson.Valid(raw) {
|
|
return []byte(`{"type":"object","properties":{}}`)
|
|
}
|
|
result := gjson.Parse(raw)
|
|
if !result.IsObject() {
|
|
return []byte(`{"type":"object","properties":{}}`)
|
|
}
|
|
schema := []byte(raw)
|
|
schemaType := result.Get("type").String()
|
|
if schemaType == "" {
|
|
schema, _ = sjson.SetBytes(schema, "type", "object")
|
|
schemaType = "object"
|
|
}
|
|
if schemaType == "object" && !result.Get("properties").Exists() {
|
|
schema, _ = sjson.SetRawBytes(schema, "properties", []byte(`{}`))
|
|
}
|
|
return schema
|
|
}
|
|
|
|
func qualifyResponsesNamespaceToolName(namespaceName, childName string) string {
|
|
childName = strings.TrimSpace(childName)
|
|
if childName == "" || namespaceName == "" || strings.HasPrefix(childName, "mcp__") {
|
|
return childName
|
|
}
|
|
if strings.HasPrefix(childName, namespaceName) {
|
|
return childName
|
|
}
|
|
if strings.HasSuffix(namespaceName, "__") {
|
|
return namespaceName + childName
|
|
}
|
|
return namespaceName + "__" + childName
|
|
}
|
|
|
|
func isUnsupportedOpenAIBuiltinToolType(toolType string) bool {
|
|
switch toolType {
|
|
case "image_generation", "file_search", "code_interpreter", "computer_use_preview":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|