diff --git a/internal/runtime/executor/helps/thinking_providers.go b/internal/runtime/executor/helps/thinking_providers.go index a776136fd..013f93e34 100644 --- a/internal/runtime/executor/helps/thinking_providers.go +++ b/internal/runtime/executor/helps/thinking_providers.go @@ -8,4 +8,5 @@ import ( _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/geminicli" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/kimi" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/openai" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/xai" ) diff --git a/internal/runtime/executor/xai_executor.go b/internal/runtime/executor/xai_executor.go index 5661328d2..ef46a1314 100644 --- a/internal/runtime/executor/xai_executor.go +++ b/internal/runtime/executor/xai_executor.go @@ -487,7 +487,7 @@ func (e *XAIExecutor) prepareResponsesRequest(ctx context.Context, req cliproxye body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), stream) var err error - body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier()) + body, err = thinking.ApplyThinking(body, req.Model, from.String(), e.Identifier(), e.Identifier()) if err != nil { return nil, err } diff --git a/internal/runtime/executor/xai_executor_test.go b/internal/runtime/executor/xai_executor_test.go index a75f13474..5579cd904 100644 --- a/internal/runtime/executor/xai_executor_test.go +++ b/internal/runtime/executor/xai_executor_test.go @@ -196,6 +196,48 @@ func TestXAIExecutorOmitsUnsupportedReasoningEffort(t *testing.T) { } } +func TestXAIExecutorAppliesThinkingSuffix(t *testing.T) { + var gotBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var errRead error + gotBody, errRead = io.ReadAll(r.Body) + if errRead != nil { + t.Fatalf("read body: %v", errRead) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"created_at\":0,\"status\":\"completed\",\"model\":\"grok-4.3\",\"output\":[{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"ok\"}]}]}}\n\n")) + })) + defer server.Close() + + exec := NewXAIExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ + Provider: "xai", + Attributes: map[string]string{ + "base_url": server.URL, + "auth_kind": "oauth", + }, + Metadata: map[string]any{"access_token": "xai-token"}, + } + + _, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "grok-4.3(low)", + Payload: []byte(`{"model":"grok-4.3","input":"hello"}`), + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatOpenAIResponse, + Stream: false, + }) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + + if got := gjson.GetBytes(gotBody, "model").String(); got != "grok-4.3" { + t.Fatalf("model = %q, want grok-4.3; body=%s", got, string(gotBody)) + } + if got := gjson.GetBytes(gotBody, "reasoning.effort").String(); got != "low" { + t.Fatalf("reasoning.effort = %q, want low; body=%s", got, string(gotBody)) + } +} + func TestXAIExecutorExecuteStreamFiltersToolSearchTool(t *testing.T) { var gotBody []byte server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/thinking/apply.go b/internal/thinking/apply.go index d422a8d8b..e8a078319 100644 --- a/internal/thinking/apply.go +++ b/internal/thinking/apply.go @@ -18,6 +18,7 @@ var providerAppliers = map[string]ProviderApplier{ "codex": nil, "antigravity": nil, "kimi": nil, + "xai": nil, } // GetProviderApplier returns the ProviderApplier for the given provider name. @@ -62,7 +63,7 @@ func IsUserDefinedModel(modelInfo *registry.ModelInfo) bool { // - body: Original request body JSON // - model: Model name, optionally with thinking suffix (e.g., "claude-sonnet-4-5(16384)") // - fromFormat: Source request format (e.g., openai, codex, gemini) -// - toFormat: Target provider format for the request body (gemini, gemini-cli, antigravity, claude, openai, codex, kimi) +// - toFormat: Target provider format for the request body (gemini, gemini-cli, antigravity, claude, openai, codex, kimi, xai) // - providerKey: Provider identifier used for registry model lookups (may differ from toFormat, e.g., openrouter -> openai) // // Returns: @@ -324,7 +325,7 @@ func extractThinkingConfig(body []byte, provider string) ThinkingConfig { return extractGeminiConfig(body, provider) case "openai": return extractOpenAIConfig(body) - case "codex": + case "codex", "xai": return extractCodexConfig(body) case "kimi": // Kimi uses OpenAI-compatible reasoning_effort format diff --git a/internal/thinking/provider/xai/apply.go b/internal/thinking/provider/xai/apply.go new file mode 100644 index 000000000..3938a4325 --- /dev/null +++ b/internal/thinking/provider/xai/apply.go @@ -0,0 +1,26 @@ +// Package xai implements thinking configuration for xAI Grok Responses API models. +// +// xAI models use the OpenAI Responses API compatible reasoning.effort format +// with discrete levels. +package xai + +import ( + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/codex" +) + +// Applier implements thinking.ProviderApplier for xAI models. +type Applier struct { + codex.Applier +} + +var _ thinking.ProviderApplier = (*Applier)(nil) + +// NewApplier creates a new xAI thinking applier. +func NewApplier() *Applier { + return &Applier{} +} + +func init() { + thinking.RegisterProvider("xai", NewApplier()) +} diff --git a/internal/thinking/provider/xai/apply_test.go b/internal/thinking/provider/xai/apply_test.go new file mode 100644 index 000000000..17f99f563 --- /dev/null +++ b/internal/thinking/provider/xai/apply_test.go @@ -0,0 +1,51 @@ +package xai + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + "github.com/tidwall/gjson" +) + +func TestApplySetsReasoningEffort(t *testing.T) { + applier := NewApplier() + modelInfo := ®istry.ModelInfo{ + ID: "grok-4.3", + Thinking: ®istry.ThinkingSupport{ + ZeroAllowed: true, + Levels: []string{"none", "low", "medium", "high"}, + }, + } + + out, err := applier.Apply([]byte(`{"input":"hello"}`), thinking.ThinkingConfig{ + Mode: thinking.ModeLevel, + Level: thinking.LevelHigh, + }, modelInfo) + if err != nil { + t.Fatalf("Apply() error = %v", err) + } + if got := gjson.GetBytes(out, "reasoning.effort").String(); got != "high" { + t.Fatalf("reasoning.effort = %q, want high; body=%s", got, string(out)) + } +} + +func TestApplyNoneFallsBackToLowestLevelWhenDisableUnsupported(t *testing.T) { + applier := NewApplier() + modelInfo := ®istry.ModelInfo{ + ID: "grok-3-mini", + Thinking: ®istry.ThinkingSupport{ + Levels: []string{"low", "medium", "high"}, + }, + } + + out, err := applier.Apply([]byte(`{"input":"hello"}`), thinking.ThinkingConfig{ + Mode: thinking.ModeNone, + }, modelInfo) + if err != nil { + t.Fatalf("Apply() error = %v", err) + } + if got := gjson.GetBytes(out, "reasoning.effort").String(); got != "low" { + t.Fatalf("reasoning.effort = %q, want low; body=%s", got, string(out)) + } +} diff --git a/internal/thinking/strip.go b/internal/thinking/strip.go index 1e1712d19..75755b31f 100644 --- a/internal/thinking/strip.go +++ b/internal/thinking/strip.go @@ -42,7 +42,7 @@ func StripThinkingConfig(body []byte, provider string) []byte { "reasoning_effort", "thinking", } - case "codex": + case "codex", "xai": paths = []string{"reasoning.effort"} default: return body diff --git a/internal/thinking/types.go b/internal/thinking/types.go index 39868a02f..987ababc6 100644 --- a/internal/thinking/types.go +++ b/internal/thinking/types.go @@ -1,7 +1,7 @@ // Package thinking provides unified thinking configuration processing. // // This package offers a unified interface for parsing, validating, and applying -// thinking configurations across various AI providers (Claude, Gemini, OpenAI, Codex, Antigravity, Kimi). +// thinking configurations across various AI providers (Claude, Gemini, OpenAI, Codex, Antigravity, Kimi, xAI). package thinking import "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" diff --git a/internal/thinking/validate.go b/internal/thinking/validate.go index 2baa93f1d..909a2eeaa 100644 --- a/internal/thinking/validate.go +++ b/internal/thinking/validate.go @@ -357,7 +357,7 @@ func isGeminiFamily(provider string) bool { func isOpenAIFamily(provider string) bool { switch provider { - case "openai", "openai-response", "codex": + case "openai", "openai-response", "codex", "xai": return true default: return false