diff --git a/internal/registry/model_definitions_test.go b/internal/registry/model_definitions_test.go index f7ce02bc..03223a15 100644 --- a/internal/registry/model_definitions_test.go +++ b/internal/registry/model_definitions_test.go @@ -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 { for _, model := range models { if model != nil && model.ID == id { diff --git a/internal/registry/model_updater.go b/internal/registry/model_updater.go index ac0caffe..fbc65bbf 100644 --- a/internal/registry/model_updater.go +++ b/internal/registry/model_updater.go @@ -349,7 +349,8 @@ func validateModelsCatalog(data *staticModelsJSON) error { func validateModelSection(section string, models []*ModelInfo) error { 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)) diff --git a/internal/runtime/executor/xai_executor.go b/internal/runtime/executor/xai_executor.go index 3060eaf5..95f71805 100644 --- a/internal/runtime/executor/xai_executor.go +++ b/internal/runtime/executor/xai_executor.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "context" + "encoding/json" "fmt" "io" "net/http" @@ -767,9 +768,79 @@ func normalizeXAIInputReasoningItems(body []byte) []byte { 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 } +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 { include := gjson.GetBytes(body, "include") if !include.Exists() || !include.IsArray() { diff --git a/internal/runtime/executor/xai_executor_test.go b/internal/runtime/executor/xai_executor_test.go index 59bdbe78..8cc85070 100644 --- a/internal/runtime/executor/xai_executor_test.go +++ b/internal/runtime/executor/xai_executor_test.go @@ -55,7 +55,7 @@ func TestXAIExecutorExecuteShapesResponsesRequest(t *testing.T) { _, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{ 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{ SourceFormat: sdktranslator.FormatOpenAIResponse, Stream: false, @@ -100,6 +100,15 @@ func TestXAIExecutorExecuteShapesResponsesRequest(t *testing.T) { 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)) } + 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() if len(tools) != 5 { 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{ 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{ SourceFormat: sdktranslator.FormatOpenAIResponse, Stream: true, @@ -233,6 +242,15 @@ func TestXAIExecutorExecuteStreamFiltersToolSearchTool(t *testing.T) { 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)) } + 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 foundNamespaceCustom := false for i, tool := range tools {