Skip to content

Commit 35ebdc7

Browse files
committed
fix: infer function type for tools missing the type field
Some clients send tool definitions without a type field (or with type:null), e.g. {"function":{...}} in Chat Completions or {"name":...,"parameters":...} in Responses. Both translation paths forwarded such tools verbatim, and the Codex upstream rejects the whole request with 400 {"detail":"Unsupported tool type: None"}. Treat function-shaped tools (a nested function object or a top-level name) as type:function per OpenAI SDK convention, and drop typeless tools with no recognizable shape instead of failing the request. Closes #219
1 parent e6889f7 commit 35ebdc7

2 files changed

Lines changed: 147 additions & 3 deletions

File tree

proxy/translator.go

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1306,11 +1306,32 @@ func normalizeResponsesFunctionTools(body map[string]any) bool {
13061306
}
13071307

13081308
modified := false
1309+
kept := make([]any, 0, len(tools))
13091310
for _, rawTool := range tools {
13101311
tool, ok := rawTool.(map[string]any)
1311-
if !ok || strings.TrimSpace(firstNonEmptyAnyString(tool["type"])) != "function" {
1312+
if !ok {
1313+
kept = append(kept, rawTool)
1314+
continue
1315+
}
1316+
toolType := strings.TrimSpace(firstNonEmptyAnyString(tool["type"]))
1317+
if toolType == "" {
1318+
// 上游对缺失或为 null 的工具 type 返回 400 "Unsupported tool
1319+
// type: None"(issue #219)。带 function 形态(function 子对象
1320+
// 或顶层 name)的工具按 OpenAI SDK 惯例视为 function;无法识别
1321+
// 形态的工具直接剔除,避免整个请求被上游拒绝。
1322+
function, _ := tool["function"].(map[string]any)
1323+
if function == nil &&
1324+
strings.TrimSpace(firstNonEmptyAnyString(tool["name"])) == "" {
1325+
modified = true
1326+
continue
1327+
}
1328+
tool["type"] = "function"
1329+
modified = true
1330+
} else if toolType != "function" {
1331+
kept = append(kept, tool)
13121332
continue
13131333
}
1334+
kept = append(kept, tool)
13141335
function, _ := tool["function"].(map[string]any)
13151336
if function == nil {
13161337
continue
@@ -1342,6 +1363,9 @@ func normalizeResponsesFunctionTools(body map[string]any) bool {
13421363
delete(tool, "function")
13431364
modified = true
13441365
}
1366+
if modified {
1367+
body["tools"] = kept
1368+
}
13451369
return modified
13461370
}
13471371

@@ -1771,12 +1795,29 @@ func convertToolsToCodexFormat(rawTools []json.RawMessage) []any {
17711795
continue
17721796
}
17731797

1774-
if parsed.Type != "function" || parsed.Function == nil {
1798+
// type 缺失或为 null 时按 OpenAI SDK 惯例视为 function(前提是带
1799+
// function 对象);上游对空 type 一律返回 400 "Unsupported tool
1800+
// type: None"(issue #219),无法识别形态的工具直接丢弃。
1801+
isFunction := parsed.Function != nil &&
1802+
(parsed.Type == "function" || parsed.Type == "")
1803+
if !isFunction {
1804+
if parsed.Type == "" {
1805+
// 无 function 对象但有顶层 name(Codex 格式缺 type)→ 补全
1806+
// type 后保留;其余直接丢弃。
1807+
var toolMap map[string]any
1808+
if json.Unmarshal(raw, &toolMap) == nil &&
1809+
strings.TrimSpace(firstNonEmptyAnyString(toolMap["name"])) != "" {
1810+
toolMap["type"] = "function"
1811+
normalizeFunctionToolParameters(toolMap)
1812+
tools = append(tools, toolMap)
1813+
}
1814+
continue
1815+
}
17751816
// 非 function 类型 → 透传原始 JSON
17761817
// 例外:把 web_search_preview 等变体归一为 web_search,
17771818
// Codex 上游只认裸 "web_search"。归一时保留白名单字段,
17781819
// 与 PrepareResponsesBody 路径行为一致。
1779-
if parsed.Type != "" && strings.HasPrefix(parsed.Type, "web_search") {
1820+
if strings.HasPrefix(parsed.Type, "web_search") {
17801821
var toolMap map[string]any
17811822
if json.Unmarshal(raw, &toolMap) == nil && toolMap != nil {
17821823
tools = append(tools, normalizeCodexWebSearchTool(toolMap))

proxy/translator_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,59 @@ func TestPrepareResponsesBody_DefaultsMissingFunctionToolParameters(t *testing.T
405405
}
406406
}
407407

408+
func TestTranslateRequest_InfersFunctionToolTypeWhenMissing(t *testing.T) {
409+
raw := []byte(`{
410+
"model":"gpt-5.4",
411+
"messages":[{"role":"user","content":"hi"}],
412+
"tools":[
413+
{"function":{"name":"get_weather","parameters":{"type":"object","properties":{}}}},
414+
{"name":"lookup","parameters":{"type":"object","properties":{}}}
415+
]
416+
}`)
417+
418+
got, err := TranslateRequest(raw)
419+
if err != nil {
420+
t.Fatalf("TranslateRequest returned error: %v", err)
421+
}
422+
423+
first := gjson.GetBytes(got, "tools.0")
424+
if toolType := first.Get("type").String(); toolType != "function" {
425+
t.Fatalf("tools.0.type = %q, want function; body=%s", toolType, got)
426+
}
427+
if name := first.Get("name").String(); name != "get_weather" {
428+
t.Fatalf("tools.0.name = %q, want get_weather; body=%s", name, got)
429+
}
430+
if first.Get("function").Exists() {
431+
t.Fatalf("tools.0 nested function object should be expanded, got %s", got)
432+
}
433+
second := gjson.GetBytes(got, "tools.1")
434+
if toolType := second.Get("type").String(); toolType != "function" {
435+
t.Fatalf("tools.1.type = %q, want function; body=%s", toolType, got)
436+
}
437+
if name := second.Get("name").String(); name != "lookup" {
438+
t.Fatalf("tools.1.name = %q, want lookup; body=%s", name, got)
439+
}
440+
}
441+
442+
func TestTranslateRequest_DropsTypelessUnrecognizedTool(t *testing.T) {
443+
raw := []byte(`{
444+
"model":"gpt-5.4",
445+
"messages":[{"role":"user","content":"hi"}],
446+
"tools":[{"foo":"bar"},{"type":null,"description":"no shape"}]
447+
}`)
448+
449+
got, err := TranslateRequest(raw)
450+
if err != nil {
451+
t.Fatalf("TranslateRequest returned error: %v", err)
452+
}
453+
454+
for _, tool := range gjson.GetBytes(got, "tools").Array() {
455+
if strings.TrimSpace(tool.Get("type").String()) == "" {
456+
t.Fatalf("typeless tool should be dropped, got %s", got)
457+
}
458+
}
459+
}
460+
408461
func TestTranslateRequest_DefaultsNullFunctionToolParameters(t *testing.T) {
409462
raw := []byte(`{
410463
"model":"gpt-5.4",
@@ -1513,6 +1566,56 @@ func TestValidateResponsesFunctionNamesAllowsValidFunctionNames(t *testing.T) {
15131566
}
15141567
}
15151568

1569+
func TestPrepareResponsesBody_InfersFunctionToolTypeWhenMissing(t *testing.T) {
1570+
raw := []byte(`{
1571+
"model":"gpt-5.4",
1572+
"input":"hi",
1573+
"tools":[
1574+
{"name":"get_weather","parameters":{"type":"object","properties":{}}},
1575+
{"type":null,"function":{"name":"lookup"}}
1576+
]
1577+
}`)
1578+
1579+
got, _ := PrepareResponsesBody(raw)
1580+
1581+
first := gjson.GetBytes(got, "tools.0")
1582+
if toolType := first.Get("type").String(); toolType != "function" {
1583+
t.Fatalf("tools.0.type = %q, want function; body=%s", toolType, got)
1584+
}
1585+
if name := first.Get("name").String(); name != "get_weather" {
1586+
t.Fatalf("tools.0.name = %q, want get_weather; body=%s", name, got)
1587+
}
1588+
second := gjson.GetBytes(got, "tools.1")
1589+
if toolType := second.Get("type").String(); toolType != "function" {
1590+
t.Fatalf("tools.1.type = %q, want function; body=%s", toolType, got)
1591+
}
1592+
if name := second.Get("name").String(); name != "lookup" {
1593+
t.Fatalf("tools.1.name = %q, want lookup; body=%s", name, got)
1594+
}
1595+
if second.Get("function").Exists() {
1596+
t.Fatalf("tools.1 nested function object should be removed, got %s", got)
1597+
}
1598+
}
1599+
1600+
func TestPrepareResponsesBody_DropsTypelessUnrecognizedTool(t *testing.T) {
1601+
raw := []byte(`{
1602+
"model":"gpt-5.4",
1603+
"input":"hi",
1604+
"tools":[{"foo":"bar"}]
1605+
}`)
1606+
1607+
got, _ := PrepareResponsesBody(raw)
1608+
1609+
for _, tool := range gjson.GetBytes(got, "tools").Array() {
1610+
if strings.TrimSpace(tool.Get("type").String()) == "" {
1611+
t.Fatalf("typeless tool should be dropped, got %s", got)
1612+
}
1613+
if tool.Get("foo").Exists() {
1614+
t.Fatalf("unrecognized tool should be dropped, got %s", got)
1615+
}
1616+
}
1617+
}
1618+
15161619
func TestPrepareResponsesBodyNormalizesChatStyleFunctionTool(t *testing.T) {
15171620
raw := []byte(`{
15181621
"model":"gpt-5.4",

0 commit comments

Comments
 (0)