feat(xai): support namespace tools and enhance tool normalization logic
- Added `namespace` tool type support, enabling nested tools to be normalized and moved to the top level. - Refactored tool normalization logic into `normalizeXAITool` for reusability and clarity. - Updated `xai_executor` test cases to validate namespace tool handling and nested tool normalization.
This commit is contained in:
@@ -49,6 +49,42 @@ func TestWithXAIBuiltinsAddsVideoModel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateModelsCatalogAllowsMissingSections(t *testing.T) {
|
||||||
|
data := validTestModelsCatalog()
|
||||||
|
data.XAI = nil
|
||||||
|
|
||||||
|
if err := validateModelsCatalog(data); err != nil {
|
||||||
|
t.Fatalf("validateModelsCatalog() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateModelsCatalogRejectsInvalidDefinitions(t *testing.T) {
|
||||||
|
data := validTestModelsCatalog()
|
||||||
|
data.Claude = []*ModelInfo{{ID: ""}}
|
||||||
|
|
||||||
|
if err := validateModelsCatalog(data); err == nil {
|
||||||
|
t.Fatal("expected invalid model definition error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validTestModelsCatalog() *staticModelsJSON {
|
||||||
|
models := []*ModelInfo{{ID: "test-model"}}
|
||||||
|
return &staticModelsJSON{
|
||||||
|
Claude: models,
|
||||||
|
Gemini: models,
|
||||||
|
Vertex: models,
|
||||||
|
GeminiCLI: models,
|
||||||
|
AIStudio: models,
|
||||||
|
CodexFree: models,
|
||||||
|
CodexTeam: models,
|
||||||
|
CodexPlus: models,
|
||||||
|
CodexPro: models,
|
||||||
|
Kimi: models,
|
||||||
|
Antigravity: models,
|
||||||
|
XAI: models,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func findModelInfo(models []*ModelInfo, id string) *ModelInfo {
|
func findModelInfo(models []*ModelInfo, id string) *ModelInfo {
|
||||||
for _, model := range models {
|
for _, model := range models {
|
||||||
if model != nil && model.ID == id {
|
if model != nil && model.ID == id {
|
||||||
|
|||||||
@@ -349,7 +349,8 @@ func validateModelsCatalog(data *staticModelsJSON) error {
|
|||||||
|
|
||||||
func validateModelSection(section string, models []*ModelInfo) error {
|
func validateModelSection(section string, models []*ModelInfo) error {
|
||||||
if len(models) == 0 {
|
if len(models) == 0 {
|
||||||
return fmt.Errorf("%s section is empty", section)
|
log.Warnf("models catalog: %s section is empty, continuing without those model definitions", section)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
seen := make(map[string]struct{}, len(models))
|
seen := make(map[string]struct{}, len(models))
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -767,9 +768,79 @@ func normalizeXAIInputReasoningItems(body []byte) []byte {
|
|||||||
updated = updatedBody
|
updated = updatedBody
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return mergeAdjacentXAIInputReasoningSummaries(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeAdjacentXAIInputReasoningSummaries(body []byte) []byte {
|
||||||
|
input := gjson.GetBytes(body, "input")
|
||||||
|
if !input.Exists() || !input.IsArray() {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := false
|
||||||
|
items := make([]json.RawMessage, 0, len(input.Array()))
|
||||||
|
for _, item := range input.Array() {
|
||||||
|
if len(items) > 0 && canMergeXAIReasoningSummary(items[len(items)-1], item) {
|
||||||
|
merged, ok := appendXAIReasoningSummary(items[len(items)-1], item.Get("summary").Array())
|
||||||
|
if ok {
|
||||||
|
items[len(items)-1] = json.RawMessage(merged)
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items = append(items, json.RawMessage(item.Raw))
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
rawInput, errMarshal := json.Marshal(items)
|
||||||
|
if errMarshal != nil {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
updated, errSet := sjson.SetRawBytes(body, "input", rawInput)
|
||||||
|
if errSet != nil {
|
||||||
|
return body
|
||||||
|
}
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func canMergeXAIReasoningSummary(previous json.RawMessage, current gjson.Result) bool {
|
||||||
|
previousItem := gjson.ParseBytes(previous)
|
||||||
|
if previousItem.Get("type").String() != "reasoning" || current.Get("type").String() != "reasoning" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !previousItem.Get("summary").IsArray() || !current.Get("summary").IsArray() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(current.Get("summary").Array()) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for name := range current.Map() {
|
||||||
|
if name != "type" && name != "summary" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendXAIReasoningSummary(previous json.RawMessage, currentSummary []gjson.Result) ([]byte, bool) {
|
||||||
|
updated := []byte(previous)
|
||||||
|
summary := gjson.GetBytes(updated, "summary")
|
||||||
|
if !summary.IsArray() {
|
||||||
|
return previous, false
|
||||||
|
}
|
||||||
|
nextIndex := len(summary.Array())
|
||||||
|
for i, item := range currentSummary {
|
||||||
|
updatedItem, errSet := sjson.SetRawBytes(updated, fmt.Sprintf("summary.%d", nextIndex+i), []byte(item.Raw))
|
||||||
|
if errSet != nil {
|
||||||
|
return previous, false
|
||||||
|
}
|
||||||
|
updated = updatedItem
|
||||||
|
}
|
||||||
|
return updated, true
|
||||||
|
}
|
||||||
|
|
||||||
func removeXAIEncryptedReasoningInclude(body []byte) []byte {
|
func removeXAIEncryptedReasoningInclude(body []byte) []byte {
|
||||||
include := gjson.GetBytes(body, "include")
|
include := gjson.GetBytes(body, "include")
|
||||||
if !include.Exists() || !include.IsArray() {
|
if !include.Exists() || !include.IsArray() {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func TestXAIExecutorExecuteShapesResponsesRequest(t *testing.T) {
|
|||||||
|
|
||||||
_, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{
|
_, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{
|
||||||
Model: "grok-4.3",
|
Model: "grok-4.3",
|
||||||
Payload: []byte(`{"model":"grok-4.3","input":[{"type":"reasoning","summary":[{"type":"summary_text","text":"test"}],"content":null,"encrypted_content":null},{"role":"user","content":"hello"}],"include":["reasoning.encrypted_content"],"reasoning":{"effort":"high"},"tools":[{"type":"tool_search"},{"type":"image_generation"},{"type":"custom","name":"apply_patch"},{"type":"custom","name":"custom_lookup"},{"type":"function","name":"lookup"},{"type":"web_search","external_web_access":true,"search_content_types":["text","image"]},{"type":"namespace","name":"codex_app","description":"Tools in the codex_app namespace.","tools":[{"type":"function","name":"automation_update"},{"type":"custom","name":"namespace_custom"},{"type":"tool_search"}]}]}`),
|
Payload: []byte(`{"model":"grok-4.3","input":[{"type":"reasoning","summary":[{"type":"summary_text","text":"test"}],"content":null,"encrypted_content":null},{"type":"reasoning","summary":[{"type":"summary_text","text":"second"}]},{"role":"user","content":"hello"}],"include":["reasoning.encrypted_content"],"reasoning":{"effort":"high"},"tools":[{"type":"tool_search"},{"type":"image_generation"},{"type":"custom","name":"apply_patch"},{"type":"custom","name":"custom_lookup"},{"type":"function","name":"lookup"},{"type":"web_search","external_web_access":true,"search_content_types":["text","image"]},{"type":"namespace","name":"codex_app","description":"Tools in the codex_app namespace.","tools":[{"type":"function","name":"automation_update"},{"type":"custom","name":"namespace_custom"},{"type":"tool_search"}]}]}`),
|
||||||
}, cliproxyexecutor.Options{
|
}, cliproxyexecutor.Options{
|
||||||
SourceFormat: sdktranslator.FormatOpenAIResponse,
|
SourceFormat: sdktranslator.FormatOpenAIResponse,
|
||||||
Stream: false,
|
Stream: false,
|
||||||
@@ -100,6 +100,15 @@ func TestXAIExecutorExecuteShapesResponsesRequest(t *testing.T) {
|
|||||||
if got := gjson.GetBytes(gotBody, "input.0.summary.0.text").String(); got != "test" {
|
if got := gjson.GetBytes(gotBody, "input.0.summary.0.text").String(); got != "test" {
|
||||||
t.Fatalf("input.0.summary.0.text = %q, want test; body=%s", got, string(gotBody))
|
t.Fatalf("input.0.summary.0.text = %q, want test; body=%s", got, string(gotBody))
|
||||||
}
|
}
|
||||||
|
if got := gjson.GetBytes(gotBody, "input.0.summary.1.text").String(); got != "second" {
|
||||||
|
t.Fatalf("input.0.summary.1.text = %q, want second; body=%s", got, string(gotBody))
|
||||||
|
}
|
||||||
|
if got := gjson.GetBytes(gotBody, "input.1.role").String(); got != "user" {
|
||||||
|
t.Fatalf("input.1.role = %q, want user; body=%s", got, string(gotBody))
|
||||||
|
}
|
||||||
|
if gjson.GetBytes(gotBody, "input.2").Exists() {
|
||||||
|
t.Fatalf("input.2 exists, want consecutive reasoning item merged; body=%s", string(gotBody))
|
||||||
|
}
|
||||||
tools := gjson.GetBytes(gotBody, "tools").Array()
|
tools := gjson.GetBytes(gotBody, "tools").Array()
|
||||||
if len(tools) != 5 {
|
if len(tools) != 5 {
|
||||||
t.Fatalf("tools length = %d, want 5; body=%s", len(tools), string(gotBody))
|
t.Fatalf("tools length = %d, want 5; body=%s", len(tools), string(gotBody))
|
||||||
@@ -206,7 +215,7 @@ func TestXAIExecutorExecuteStreamFiltersToolSearchTool(t *testing.T) {
|
|||||||
|
|
||||||
result, err := exec.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
|
result, err := exec.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
|
||||||
Model: "grok-4.3",
|
Model: "grok-4.3",
|
||||||
Payload: []byte(`{"model":"grok-4.3","input":[{"type":"reasoning","summary":[{"type":"summary_text","text":"test"}],"content":null,"encrypted_content":null},{"role":"user","content":"hello"}],"tools":[{"type":"tool_search"},{"type":"image_generation"},{"type":"custom","name":"apply_patch"},{"type":"custom","name":"custom_lookup"},{"type":"function","name":"lookup"},{"type":"web_search","external_web_access":true,"search_content_types":["text","image"]},{"type":"namespace","name":"codex_app","description":"Tools in the codex_app namespace.","tools":[{"type":"function","name":"automation_update"},{"type":"custom","name":"namespace_custom"},{"type":"tool_search"}]}]}`),
|
Payload: []byte(`{"model":"grok-4.3","input":[{"type":"reasoning","summary":[{"type":"summary_text","text":"test"}],"content":null,"encrypted_content":null},{"type":"reasoning","summary":[{"type":"summary_text","text":"second"}]},{"role":"user","content":"hello"},{"type":"reasoning","summary":[{"type":"summary_text","text":"separate"}]}],"tools":[{"type":"tool_search"},{"type":"image_generation"},{"type":"custom","name":"apply_patch"},{"type":"custom","name":"custom_lookup"},{"type":"function","name":"lookup"},{"type":"web_search","external_web_access":true,"search_content_types":["text","image"]},{"type":"namespace","name":"codex_app","description":"Tools in the codex_app namespace.","tools":[{"type":"function","name":"automation_update"},{"type":"custom","name":"namespace_custom"},{"type":"tool_search"}]}]}`),
|
||||||
}, cliproxyexecutor.Options{
|
}, cliproxyexecutor.Options{
|
||||||
SourceFormat: sdktranslator.FormatOpenAIResponse,
|
SourceFormat: sdktranslator.FormatOpenAIResponse,
|
||||||
Stream: true,
|
Stream: true,
|
||||||
@@ -233,6 +242,15 @@ func TestXAIExecutorExecuteStreamFiltersToolSearchTool(t *testing.T) {
|
|||||||
if got := gjson.GetBytes(gotBody, "input.0.summary.0.text").String(); got != "test" {
|
if got := gjson.GetBytes(gotBody, "input.0.summary.0.text").String(); got != "test" {
|
||||||
t.Fatalf("input.0.summary.0.text = %q, want test; body=%s", got, string(gotBody))
|
t.Fatalf("input.0.summary.0.text = %q, want test; body=%s", got, string(gotBody))
|
||||||
}
|
}
|
||||||
|
if got := gjson.GetBytes(gotBody, "input.0.summary.1.text").String(); got != "second" {
|
||||||
|
t.Fatalf("input.0.summary.1.text = %q, want second; body=%s", got, string(gotBody))
|
||||||
|
}
|
||||||
|
if got := gjson.GetBytes(gotBody, "input.1.role").String(); got != "user" {
|
||||||
|
t.Fatalf("input.1.role = %q, want user; body=%s", got, string(gotBody))
|
||||||
|
}
|
||||||
|
if got := gjson.GetBytes(gotBody, "input.2.summary.0.text").String(); got != "separate" {
|
||||||
|
t.Fatalf("input.2.summary.0.text = %q, want separate; body=%s", got, string(gotBody))
|
||||||
|
}
|
||||||
foundAutomationUpdate := false
|
foundAutomationUpdate := false
|
||||||
foundNamespaceCustom := false
|
foundNamespaceCustom := false
|
||||||
for i, tool := range tools {
|
for i, tool := range tools {
|
||||||
|
|||||||
Reference in New Issue
Block a user