Files
CLIProxyAPI/internal/runtime/executor/xai_executor.go
Luis Pater f3e36f19c0 feat(executor): add Claude input token state handling and token estimation logic
- Introduced `ClaudeInputTokenState` to track and estimate input token usage for Claude message_start events.
- Added `TranslateStreamWithClaudeInputTokens` for token-aware stream translation.
- Updated executors (XAI, Kimi, Gemini) to leverage the new logic.
- Included robust test cases for token counting, JSON validation, and concurrent tokenization scenarios.
2026-07-22 05:21:14 +08:00

2919 lines
95 KiB
Go

package executor
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/google/uuid"
xaiauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/xai"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"github.com/tiktoken-go/tokenizer"
)
var (
xaiDataTag = []byte("data:")
xaiEventTag = []byte("event:")
)
const (
xaiImageHandlerType = "openai-image"
xaiVideoHandlerType = "openai-video"
xaiCustomToolType = "custom"
xaiFunctionToolType = "function"
xaiImageGenerationToolType = "image_generation"
xaiNamespaceToolType = "namespace"
xaiToolSearchType = "tool_search"
xaiWebSearchToolType = "web_search"
xaiXSearchToolType = "x_search"
// Codex Desktop injects codex_app.automation_update with a large oneOf+$ref
// schema. xAI's free/build Responses path accepts the HTTP request but never
// emits SSE when that schema is present, so Desktop hangs on "thinking".
xaiCodexAppNamespaceName = "codex_app"
xaiAutomationUpdateToolName = "automation_update"
// Permissive placeholder schema: keeps the tool callable without the hang.
xaiSafeFunctionParameters = `{"type":"object","properties":{},"additionalProperties":true}`
xaiImagesGenerationsPath = "/images/generations"
xaiImagesEditsPath = "/images/edits"
xaiDefaultImageEndpointPath = xaiImagesGenerationsPath
xaiVideosGenerationsPath = "/videos/generations"
xaiVideosEditsPath = "/videos/edits"
xaiVideosExtensionsPath = "/videos/extensions"
xaiVideosPath = "/videos"
xaiIdempotencyKeyMetaKey = "idempotency_key"
xaiComposerModelPrefix = "grok-composer-"
xaiTokenAuthHeader = "X-XAI-Token-Auth"
xaiTokenAuthValue = "xai-grok-cli"
xaiClientVersionHeader = "x-grok-client-version"
// Keep in sync with the current Grok CLI client version that chat-proxy expects.
xaiClientVersionValue = "0.2.93"
// xaiUsingAPIAttr enables the official API path for non-media HTTP chat.
xaiUsingAPIAttr = "using_api"
)
// Always inject native x_search when the client did not declare it so Grok can
// run X Search server-side. Internal subtool traces are still filtered downstream
// when this native tool is present (see filterInternalXSearch).
var xaiXSearchToolJSON = []byte(`{"type":"x_search"}`)
// XAIExecutor is a stateless executor for xAI Grok's Responses API.
type XAIExecutor struct {
cfg *config.Config
}
// NewXAIExecutor creates a new xAI executor.
func NewXAIExecutor(cfg *config.Config) *XAIExecutor {
return &XAIExecutor{cfg: cfg}
}
// Identifier returns the provider identifier.
func (e *XAIExecutor) Identifier() string {
return "xai"
}
// PrepareRequest injects xAI credentials into the outgoing HTTP request.
func (e *XAIExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
if req == nil {
return nil
}
token, _ := xaiCreds(auth)
if strings.TrimSpace(token) != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
var attrs map[string]string
if auth != nil {
attrs = auth.Attributes
}
util.ApplyCustomHeadersFromAttrs(req, attrs)
return nil
}
// HttpRequest injects xAI credentials into the request and executes it.
func (e *XAIExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
if req == nil {
return nil, fmt.Errorf("xai executor: request is nil")
}
if ctx == nil {
ctx = req.Context()
}
httpReq := req.WithContext(ctx)
if errPrepare := e.PrepareRequest(httpReq, auth); errPrepare != nil {
return nil, errPrepare
}
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
return httpClient.Do(httpReq)
}
func (e *XAIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
if opts.Alt == "responses/compact" {
return e.executeCompact(ctx, auth, req, opts)
}
if endpointPath := xaiImageEndpointPath(opts); endpointPath != "" {
return e.executeImages(ctx, auth, req, endpointPath)
}
if xaiIsVideoRequest(opts) {
return e.executeVideos(ctx, auth, req, opts)
}
token, _ := xaiCreds(auth)
baseURL := xaiChatBaseURL(auth)
logXAIResolvedBaseURL(ctx, baseURL)
prepared, err := e.prepareResponsesRequest(ctx, req, opts, true)
if err != nil {
return resp, err
}
reporter := helps.NewExecutorUsageReporter(ctx, e, prepared.baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
reporter.SetTranslatedReasoningEffort(prepared.body, e.Identifier())
url := strings.TrimSuffix(baseURL, "/") + "/responses"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(prepared.body))
if err != nil {
return resp, err
}
applyXAIChatHeaders(httpReq, auth, token, true, prepared.sessionID)
e.recordXAIRequest(ctx, auth, url, httpReq.Header.Clone(), prepared.body)
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
return resp, err
}
defer func() {
if errClose := httpResp.Body.Close(); errClose != nil {
log.Errorf("xai executor: close response body error: %v", errClose)
}
}()
helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
data, errRead := io.ReadAll(httpResp.Body)
if errRead != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errRead)
return resp, errRead
}
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), data))
return resp, xaiStatusErr(httpResp.StatusCode, data)
}
data, err := io.ReadAll(httpResp.Body)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
return resp, err
}
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
outputItemsByIndex := make(map[int64][]byte)
var outputItemsFallback [][]byte
responseFilter := newXAIInternalXSearchResponseFilter(prepared.filterInternalXSearch, prepared.clientDeclaredTools)
for _, line := range bytes.Split(data, []byte("\n")) {
if !bytes.HasPrefix(line, xaiDataTag) {
continue
}
eventData := xaiNormalizeReasoningSummaryData(bytes.TrimSpace(line[len(xaiDataTag):]))
eventData = restoreXAINamespaceToolCalls(eventData, prepared.namespaceTools)
eventData = responseFilter.apply(eventData)
if len(eventData) == 0 {
continue
}
switch gjson.GetBytes(eventData, "type").String() {
case "response.output_item.done":
xaiCollectOutputItemDone(eventData, outputItemsByIndex, &outputItemsFallback)
case "response.completed":
if detail, ok := helps.ParseCodexUsage(eventData); ok {
reporter.Publish(ctx, detail)
}
completedData := xaiPatchCompletedOutput(eventData, outputItemsByIndex, outputItemsFallback)
completedData = xaiNormalizeReasoningSummaryData(completedData)
cacheXAIReasoningReplayFromCompleted(ctx, prepared.replayScope, completedData)
var param any
out := sdktranslator.TranslateNonStream(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, completedData, &param)
return cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}, nil
}
}
return resp, statusErr{code: http.StatusRequestTimeout, msg: "xai stream error: stream disconnected before response.completed"}
}
func (e *XAIExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
prepared, data, headers, errCompact := e.executeCompactRequest(ctx, auth, req, opts)
if errCompact != nil {
return resp, errCompact
}
var param any
out := sdktranslator.TranslateNonStream(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, data, &param)
return cliproxyexecutor.Response{Payload: out, Headers: headers}, nil
}
func (e *XAIExecutor) executeCompactRequest(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*xaiPreparedRequest, []byte, http.Header, error) {
token, _ := xaiCreds(auth)
// Compact must not use xaiChatBaseURL: CLI chat-proxy returns 404 for
// /responses/compact and a 404 cools down the whole xAI auth pool.
baseURL := xaiCompactBaseURL(auth)
logXAIResolvedBaseURL(ctx, baseURL)
prepared, err := e.prepareResponsesRequestTo(ctx, req, opts, false, sdktranslator.FormatOpenAIResponse)
if err != nil {
return nil, nil, nil, err
}
prepared.body, _ = sjson.DeleteBytes(prepared.body, "stream")
prepared.body, _ = sjson.DeleteBytes(prepared.body, "tools")
prepared.body = xaiRemoveInputItemsByType(prepared.body, "compaction_trigger")
reporter := helps.NewExecutorUsageReporter(ctx, e, prepared.baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
reporter.SetTranslatedReasoningEffort(prepared.body, e.Identifier())
requestURL := strings.TrimSuffix(baseURL, "/") + "/responses/compact"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewReader(prepared.body))
if err != nil {
return nil, nil, nil, err
}
// Official API / custom compact endpoints use standard API headers, not CLI
// chat-proxy identity headers (which applyXAIChatHeaders may still attach for OAuth chat).
applyXAIHeaders(httpReq, auth, token, false, prepared.sessionID)
e.recordXAIRequest(ctx, auth, requestURL, httpReq.Header.Clone(), prepared.body)
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
return nil, nil, nil, err
}
defer func() {
if errClose := httpResp.Body.Close(); errClose != nil {
log.Errorf("xai executor: close response body error: %v", errClose)
}
}()
helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
data, err := io.ReadAll(httpResp.Body)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
return nil, nil, nil, err
}
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), data))
err = xaiStatusErr(httpResp.StatusCode, data)
return nil, nil, nil, err
}
reporter.Publish(ctx, helps.ParseOpenAIUsage(data))
reporter.EnsurePublished(ctx)
clearXAIReasoningReplayAfterCompaction(ctx, prepared.replayScope)
return prepared, data, httpResp.Header.Clone(), nil
}
func (e *XAIExecutor) executeCompactionTriggerStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {
prepared, data, headers, err := e.executeCompactRequest(ctx, auth, req, opts)
if err != nil {
return nil, err
}
headers = headers.Clone()
if headers == nil {
headers = make(http.Header)
}
headers.Set("Content-Type", "text/event-stream")
chunks := xaiBuildCompactionTriggerStreamChunks(prepared, data)
out := make(chan cliproxyexecutor.StreamChunk, len(chunks))
for _, chunk := range chunks {
out <- cliproxyexecutor.StreamChunk{Payload: chunk}
}
close(out)
return &cliproxyexecutor.StreamResult{Headers: headers, Chunks: out}, nil
}
func xaiInputHasItemType(body []byte, itemType string) bool {
input := gjson.GetBytes(body, "input")
if !input.IsArray() {
return false
}
for _, item := range input.Array() {
if item.Get("type").String() == itemType {
return true
}
}
return false
}
func xaiRemoveInputItemsByType(body []byte, itemType string) []byte {
input := gjson.GetBytes(body, "input")
if !input.IsArray() {
return body
}
var buf bytes.Buffer
buf.WriteByte('[')
kept := 0
for _, item := range input.Array() {
if item.Get("type").String() == itemType {
continue
}
if kept > 0 {
buf.WriteByte(',')
}
buf.WriteString(item.Raw)
kept++
}
buf.WriteByte(']')
updated, err := sjson.SetRawBytes(body, "input", buf.Bytes())
if err != nil {
return body
}
return updated
}
func xaiBuildCompactionTriggerStreamChunks(prepared *xaiPreparedRequest, compactData []byte) [][]byte {
responseID := xaiCompactionResponseID(compactData)
now := time.Now().Unix()
createdAt := gjson.GetBytes(compactData, "created_at").Int()
if createdAt == 0 {
createdAt = now
}
completedAt := gjson.GetBytes(compactData, "completed_at").Int()
if completedAt == 0 {
completedAt = now
}
item := xaiCompactionOutputItem(compactData, responseID)
output := make([]byte, 0, len(item)+2)
output = append(output, '[')
output = append(output, item...)
output = append(output, ']')
createdResponse := xaiBuildCompactionBaseResponse(prepared, compactData, responseID, createdAt, "in_progress")
inProgressResponse := xaiBuildCompactionBaseResponse(prepared, compactData, responseID, createdAt, "in_progress")
completedResponse := xaiBuildCompactionBaseResponse(prepared, compactData, responseID, createdAt, "completed")
completedResponse, _ = sjson.SetBytes(completedResponse, "completed_at", completedAt)
completedResponse, _ = sjson.SetRawBytes(completedResponse, "output", output)
if usage := gjson.GetBytes(compactData, "usage"); usage.Exists() {
completedResponse, _ = sjson.SetRawBytes(completedResponse, "usage", []byte(usage.Raw))
}
createdPayload := []byte(`{"type":"response.created","sequence_number":0}`)
createdPayload, _ = sjson.SetRawBytes(createdPayload, "response", createdResponse)
inProgressPayload := []byte(`{"type":"response.in_progress","sequence_number":1}`)
inProgressPayload, _ = sjson.SetRawBytes(inProgressPayload, "response", inProgressResponse)
addedPayload := []byte(`{"type":"response.output_item.added","sequence_number":2,"output_index":0}`)
addedPayload, _ = sjson.SetRawBytes(addedPayload, "item", item)
keepalivePayload := []byte(`{"type":"keepalive","sequence_number":3}`)
donePayload := []byte(`{"type":"response.output_item.done","sequence_number":4,"output_index":0}`)
donePayload, _ = sjson.SetRawBytes(donePayload, "item", item)
completedPayload := []byte(`{"type":"response.completed","sequence_number":5}`)
completedPayload, _ = sjson.SetRawBytes(completedPayload, "response", completedResponse)
return [][]byte{
xaiBuildSSEFrame("response.created", createdPayload),
xaiBuildSSEFrame("response.in_progress", inProgressPayload),
xaiBuildSSEFrame("response.output_item.added", addedPayload),
xaiBuildSSEFrame("keepalive", keepalivePayload),
xaiBuildSSEFrame("response.output_item.done", donePayload),
xaiBuildSSEFrame("response.completed", completedPayload),
}
}
func xaiBuildCompactionBaseResponse(prepared *xaiPreparedRequest, compactData []byte, responseID string, createdAt int64, status string) []byte {
response := []byte(`{"id":"","object":"response","created_at":0,"status":"","background":false,"error":null,"incomplete_details":null,"output":[]}`)
response, _ = sjson.SetBytes(response, "id", responseID)
response, _ = sjson.SetBytes(response, "created_at", createdAt)
response, _ = sjson.SetBytes(response, "status", status)
if model := gjson.GetBytes(compactData, "model").String(); model != "" {
response, _ = sjson.SetBytes(response, "model", model)
} else if prepared != nil && prepared.baseModel != "" {
response, _ = sjson.SetBytes(response, "model", prepared.baseModel)
}
if prepared == nil {
return response
}
for _, field := range []string{
"instructions",
"max_output_tokens",
"max_tool_calls",
"parallel_tool_calls",
"previous_response_id",
"prompt_cache_key",
"reasoning",
"text",
"tool_choice",
"tools",
"top_logprobs",
"top_p",
"truncation",
"user",
"metadata",
} {
if value := gjson.GetBytes(prepared.body, field); value.Exists() {
response, _ = sjson.SetRawBytes(response, field, []byte(value.Raw))
}
}
return response
}
func xaiCompactionOutputItem(compactData []byte, responseID string) []byte {
itemResult := gjson.GetBytes(compactData, "output.0")
item := []byte(`{"type":"compaction"}`)
if itemResult.Exists() && itemResult.Type == gjson.JSON {
item = []byte(itemResult.Raw)
}
if !gjson.GetBytes(item, "type").Exists() {
item, _ = sjson.SetBytes(item, "type", "compaction")
}
if !gjson.GetBytes(item, "id").Exists() {
item, _ = sjson.SetBytes(item, "id", xaiCompactionItemID(responseID))
}
return item
}
func xaiCompactionResponseID(compactData []byte) string {
if responseID := strings.TrimSpace(gjson.GetBytes(compactData, "id").String()); responseID != "" {
if strings.HasPrefix(responseID, "resp_") {
return responseID
}
return "resp_" + strings.TrimPrefix(responseID, "cmp_")
}
return fmt.Sprintf("resp_xai_compaction_%d", time.Now().UnixNano())
}
func xaiCompactionItemID(responseID string) string {
if suffix := strings.TrimPrefix(responseID, "resp_"); suffix != "" && suffix != responseID {
return "cmp_" + suffix
}
return "cmp_" + responseID
}
func xaiBuildSSEFrame(eventName string, data []byte) []byte {
out := make([]byte, 0, len(eventName)+len(data)+16)
out = append(out, "event: "...)
out = append(out, eventName...)
out = append(out, '\n')
out = append(out, "data: "...)
out = append(out, data...)
out = append(out, '\n', '\n')
return out
}
func (e *XAIExecutor) executeImages(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, endpointPath string) (resp cliproxyexecutor.Response, err error) {
model := strings.TrimSpace(gjson.GetBytes(req.Payload, "model").String())
if model == "" {
model = strings.TrimSpace(req.Model)
}
reporter := helps.NewExecutorUsageReporter(ctx, e, model, auth)
defer reporter.TrackFailure(ctx, &err)
token, baseURL := xaiCreds(auth)
if baseURL == "" {
baseURL = xaiauth.DefaultAPIBaseURL
}
logXAIResolvedBaseURL(ctx, baseURL)
if endpointPath == "" {
endpointPath = xaiDefaultImageEndpointPath
}
payload := normalizeXAIImageRefs(req.Payload)
url := strings.TrimSuffix(baseURL, "/") + endpointPath
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return resp, err
}
applyXAIHeaders(httpReq, auth, token, false, "")
e.recordXAIRequest(ctx, auth, url, httpReq.Header.Clone(), payload)
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
return resp, err
}
defer func() {
if errClose := httpResp.Body.Close(); errClose != nil {
log.Errorf("xai executor: close response body error: %v", errClose)
}
}()
helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
data, err := io.ReadAll(httpResp.Body)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
return resp, err
}
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), data))
err = xaiStatusErr(httpResp.StatusCode, data)
return resp, err
}
reporter.EnsurePublished(ctx)
return cliproxyexecutor.Response{Payload: data, Headers: httpResp.Header.Clone()}, nil
}
func (e *XAIExecutor) executeVideos(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
token, baseURL := xaiCreds(auth)
if baseURL == "" {
baseURL = xaiauth.DefaultAPIBaseURL
}
logXAIResolvedBaseURL(ctx, baseURL)
payload := normalizeXAIImageRefs(req.Payload)
method := http.MethodPost
endpointPath := xaiVideosGenerationsPath
var body io.Reader = bytes.NewReader(payload)
switch path := xaiVideoEndpointPath(opts); path {
case xaiVideosGenerationsPath, xaiVideosEditsPath, xaiVideosExtensionsPath:
endpointPath = path
default:
if requestID := strings.TrimSpace(gjson.GetBytes(payload, "request_id").String()); requestID != "" {
method = http.MethodGet
endpointPath = xaiVideosPath + "/" + url.PathEscape(requestID)
body = nil
}
}
requestURL := strings.TrimSuffix(baseURL, "/") + endpointPath
httpReq, err := http.NewRequestWithContext(ctx, method, requestURL, body)
if err != nil {
return resp, err
}
applyXAIHeaders(httpReq, auth, token, false, "")
if method == http.MethodPost {
key := xaiMetadataString(opts.Metadata, xaiIdempotencyKeyMetaKey)
if key == "" && opts.Headers != nil {
key = strings.TrimSpace(opts.Headers.Get("x-idempotency-key"))
}
if key != "" {
httpReq.Header.Set("x-idempotency-key", key)
}
}
e.recordXAIRequest(ctx, auth, requestURL, httpReq.Header.Clone(), payload)
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
return resp, err
}
defer func() {
if errClose := httpResp.Body.Close(); errClose != nil {
log.Errorf("xai executor: close response body error: %v", errClose)
}
}()
helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
data, err := io.ReadAll(httpResp.Body)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
return resp, err
}
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), data))
return resp, xaiStatusErr(httpResp.StatusCode, data)
}
return cliproxyexecutor.Response{Payload: data, Headers: httpResp.Header.Clone()}, nil
}
func (e *XAIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) {
if opts.Alt == "responses/compact" {
return nil, statusErr{code: http.StatusBadRequest, msg: "streaming not supported for /responses/compact"}
}
if xaiInputHasItemType(req.Payload, "compaction_trigger") {
return e.executeCompactionTriggerStream(ctx, auth, req, opts)
}
token, _ := xaiCreds(auth)
baseURL := xaiChatBaseURL(auth)
logXAIResolvedBaseURL(ctx, baseURL)
prepared, err := e.prepareResponsesRequest(ctx, req, opts, true)
if err != nil {
return nil, err
}
reporter := helps.NewExecutorUsageReporter(ctx, e, prepared.baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
reporter.SetTranslatedReasoningEffort(prepared.body, e.Identifier())
url := strings.TrimSuffix(baseURL, "/") + "/responses"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(prepared.body))
if err != nil {
return nil, err
}
applyXAIChatHeaders(httpReq, auth, token, true, prepared.sessionID)
e.recordXAIRequest(ctx, auth, url, httpReq.Header.Clone(), prepared.body)
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
httpClient = reporter.TrackHTTPClient(httpClient)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
return nil, err
}
helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
data, errRead := io.ReadAll(httpResp.Body)
if errClose := httpResp.Body.Close(); errClose != nil {
log.Errorf("xai executor: close response body error: %v", errClose)
}
if errRead != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errRead)
return nil, errRead
}
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), data))
return nil, xaiStatusErr(httpResp.StatusCode, data)
}
out := make(chan cliproxyexecutor.StreamChunk)
go func() {
defer close(out)
defer func() {
if errClose := httpResp.Body.Close(); errClose != nil {
log.Errorf("xai executor: close response body error: %v", errClose)
}
}()
scanner := bufio.NewScanner(httpResp.Body)
scanner.Buffer(nil, 52_428_800)
claudeInputTokens := helps.NewClaudeInputTokenState(prepared.from, prepared.to, prepared.responseFormat, prepared.originalPayload)
var param any
outputItemsByIndex := make(map[int64][]byte)
var outputItemsFallback [][]byte
responseFilter := newXAIInternalXSearchResponseFilter(prepared.filterInternalXSearch, prepared.clientDeclaredTools)
var pendingEventLine []byte
emitTranslatedLine := func(translatedLine []byte) bool {
chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, translatedLine, &param, claudeInputTokens)
for i := range chunks {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}:
case <-ctx.Done():
return false
}
}
return true
}
for scanner.Scan() {
line := scanner.Bytes()
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
if bytes.HasPrefix(line, xaiEventTag) {
if pendingEventLine != nil && !emitTranslatedLine(xaiNormalizeReasoningSummaryEventLine(pendingEventLine, "")) {
return
}
pendingEventLine = bytes.Clone(line)
continue
}
if bytes.HasPrefix(line, xaiDataTag) {
eventDataList := xaiNormalizeReasoningSummaryDataEvents(bytes.TrimSpace(line[len(xaiDataTag):]))
hasPendingEventLine := pendingEventLine != nil
for i, eventData := range eventDataList {
eventData = restoreXAINamespaceToolCalls(eventData, prepared.namespaceTools)
eventData = responseFilter.apply(eventData)
if len(eventData) == 0 {
if hasPendingEventLine && i == 0 {
pendingEventLine = nil
}
continue
}
normalizedEventName := gjson.GetBytes(eventData, "type").String()
switch normalizedEventName {
case "response.output_item.done":
xaiCollectOutputItemDone(eventData, outputItemsByIndex, &outputItemsFallback)
case "response.completed":
if detail, ok := helps.ParseCodexUsage(eventData); ok {
reporter.Publish(ctx, detail)
}
eventData = xaiPatchCompletedOutput(eventData, outputItemsByIndex, outputItemsFallback)
eventData = xaiNormalizeReasoningSummaryData(eventData)
cacheXAIReasoningReplayFromCompleted(ctx, prepared.replayScope, eventData)
normalizedEventName = gjson.GetBytes(eventData, "type").String()
}
if hasPendingEventLine {
eventLine := []byte("event: " + normalizedEventName)
if i == 0 {
eventLine = xaiNormalizeReasoningSummaryEventLine(pendingEventLine, normalizedEventName)
pendingEventLine = nil
}
if !emitTranslatedLine(eventLine) {
return
}
}
if !emitTranslatedLine(append([]byte("data: "), eventData...)) {
return
}
}
continue
}
if pendingEventLine != nil {
if !emitTranslatedLine(xaiNormalizeReasoningSummaryEventLine(pendingEventLine, "")) {
return
}
pendingEventLine = nil
}
if !emitTranslatedLine(bytes.Clone(line)) {
return
}
}
if pendingEventLine != nil {
emitTranslatedLine(xaiNormalizeReasoningSummaryEventLine(pendingEventLine, ""))
}
if errScan := scanner.Err(); errScan != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errScan)
reporter.PublishFailure(ctx, errScan)
select {
case out <- cliproxyexecutor.StreamChunk{Err: errScan}:
case <-ctx.Done():
}
}
}()
return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil
}
// CountTokens estimates token count for xAI Responses requests.
func (e *XAIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
prepared, err := e.prepareResponsesRequest(ctx, req, opts, false)
if err != nil {
return cliproxyexecutor.Response{}, err
}
enc, err := tokenizer.Get(tokenizer.O200kBase)
if err != nil {
return cliproxyexecutor.Response{}, fmt.Errorf("xai executor: tokenizer init failed: %w", err)
}
count, err := countXAIInputTokens(enc, prepared.body)
if err != nil {
return cliproxyexecutor.Response{}, fmt.Errorf("xai executor: token counting failed: %w", err)
}
usageJSON := fmt.Sprintf(`{"response":{"usage":{"input_tokens":%d,"output_tokens":0,"total_tokens":%d}}}`, count, count)
translated := sdktranslator.TranslateTokenCount(ctx, prepared.to, prepared.responseFormat, count, []byte(usageJSON))
return cliproxyexecutor.Response{Payload: translated}, nil
}
func countXAIInputTokens(enc tokenizer.Codec, body []byte) (int64, error) {
if enc == nil {
return 0, fmt.Errorf("encoder is nil")
}
if len(body) == 0 {
return 0, nil
}
root := gjson.ParseBytes(body)
segments := make([]string, 0, 32)
xaiAppendTokenString(&segments, root.Get("instructions"))
xaiCollectInputTokenSegments(root.Get("input"), &segments)
xaiCollectToolTokenSegments(root.Get("tools"), &segments)
textFormat := root.Get("text.format")
if textFormat.Exists() {
xaiAppendTokenString(&segments, textFormat.Get("name"))
xaiAppendTokenJSON(&segments, textFormat.Get("schema"))
}
if len(segments) == 0 {
return 0, nil
}
count, err := enc.Count(strings.Join(segments, "\n"))
if err != nil {
return 0, err
}
return int64(count), nil
}
func xaiCollectInputTokenSegments(input gjson.Result, segments *[]string) {
if input.Type == gjson.String {
xaiAppendTokenString(segments, input)
return
}
if !input.IsArray() {
return
}
for _, item := range input.Array() {
switch item.Get("type").String() {
case "message":
xaiCollectContentTokenSegments(item.Get("content"), segments)
case "function_call":
xaiAppendTokenString(segments, item.Get("name"))
xaiAppendTokenJSON(segments, item.Get("arguments"))
case "function_call_output":
xaiAppendTokenJSON(segments, item.Get("output"))
case "reasoning":
for _, part := range item.Get("summary").Array() {
xaiAppendTokenString(segments, part.Get("text"))
}
}
}
}
func xaiCollectContentTokenSegments(content gjson.Result, segments *[]string) {
if content.Type == gjson.String {
xaiAppendTokenString(segments, content)
return
}
if !content.IsArray() {
return
}
for _, part := range content.Array() {
switch part.Get("type").String() {
case "text", "input_text", "output_text":
xaiAppendTokenString(segments, part.Get("text"))
case "refusal":
xaiAppendTokenString(segments, part.Get("refusal"))
case "input_image":
xaiAppendTokenString(segments, part.Get("image_url"))
xaiAppendTokenString(segments, part.Get("file_id"))
case "input_file":
xaiAppendTokenString(segments, part.Get("file_data"))
xaiAppendTokenString(segments, part.Get("file_url"))
xaiAppendTokenString(segments, part.Get("file_id"))
xaiAppendTokenString(segments, part.Get("filename"))
case "input_audio":
xaiAppendTokenString(segments, part.Get("data"))
xaiAppendTokenString(segments, part.Get("input_audio.data"))
}
}
}
func xaiCollectToolTokenSegments(tools gjson.Result, segments *[]string) {
if !tools.IsArray() {
return
}
for _, tool := range tools.Array() {
if tool.Get("type").String() != xaiFunctionToolType {
continue
}
xaiAppendTokenString(segments, tool.Get("name"))
xaiAppendTokenString(segments, tool.Get("description"))
xaiAppendTokenJSON(segments, tool.Get("parameters"))
}
}
func xaiAppendTokenString(segments *[]string, value gjson.Result) {
if text := strings.TrimSpace(value.String()); text != "" {
*segments = append(*segments, text)
}
}
func xaiAppendTokenJSON(segments *[]string, value gjson.Result) {
if !value.Exists() {
return
}
if value.Type == gjson.String {
xaiAppendTokenString(segments, value)
return
}
if text := strings.TrimSpace(value.Raw); text != "" {
*segments = append(*segments, text)
}
}
// Refresh refreshes xAI OAuth credentials using the stored refresh token.
func (e *XAIExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
log.Debugf("xai executor: refresh called")
if refreshed, handled, err := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled {
return refreshed, err
}
if auth == nil {
return nil, statusErr{code: http.StatusInternalServerError, msg: "xai executor: auth is nil"}
}
refreshToken := xaiMetadataString(auth.Metadata, "refresh_token")
if refreshToken == "" {
return auth, nil
}
tokenEndpoint := xaiMetadataString(auth.Metadata, "token_endpoint")
svc := xaiauth.NewXAIAuthWithProxyURL(e.cfg, auth.ProxyURL)
td, err := svc.RefreshTokens(ctx, refreshToken, tokenEndpoint)
if err != nil {
return nil, err
}
if auth.Metadata == nil {
auth.Metadata = make(map[string]any)
}
auth.Metadata["type"] = "xai"
auth.Metadata["auth_kind"] = "oauth"
auth.Metadata["access_token"] = td.AccessToken
if td.RefreshToken != "" {
auth.Metadata["refresh_token"] = td.RefreshToken
}
if td.IDToken != "" {
auth.Metadata["id_token"] = td.IDToken
}
if td.TokenType != "" {
auth.Metadata["token_type"] = td.TokenType
}
if td.ExpiresIn > 0 {
auth.Metadata["expires_in"] = td.ExpiresIn
}
if td.Expire != "" {
auth.Metadata["expired"] = td.Expire
}
if td.Email != "" {
auth.Metadata["email"] = td.Email
}
if td.Subject != "" {
auth.Metadata["sub"] = td.Subject
}
if tokenEndpoint != "" {
auth.Metadata["token_endpoint"] = tokenEndpoint
}
if xaiMetadataString(auth.Metadata, "base_url") == "" {
auth.Metadata["base_url"] = xaiauth.DefaultAPIBaseURL
}
auth.Metadata["last_refresh"] = time.Now().UTC().Format(time.RFC3339)
if auth.Attributes == nil {
auth.Attributes = make(map[string]string)
}
auth.Attributes["auth_kind"] = "oauth"
if strings.TrimSpace(auth.Attributes["base_url"]) == "" {
auth.Attributes["base_url"] = xaiauth.DefaultAPIBaseURL
}
return auth, nil
}
type xaiPreparedRequest struct {
baseModel string
from sdktranslator.Format
responseFormat sdktranslator.Format
to sdktranslator.Format
originalPayload []byte
body []byte
namespaceTools map[string]xaiNamespaceToolRef
clientDeclaredTools map[xaiClientToolKey]struct{}
sessionID string
replayScope xaiReasoningReplayScope
filterInternalXSearch bool
}
type xaiNamespaceToolRef struct {
namespace string
name string
}
// xaiClientToolKey identifies a client-declared callable tool using the
// post-restore Responses shape (short name + optional namespace) and the
// effective upstream tool type after normalizeXAITool (client custom tools are
// sent as function). Response call types are matched against this effective
// kind so internal custom_tool_call traces are not exempted merely because a
// client declared an ordinary function/custom tool with the same short name,
// while legitimate function_call responses for normalized custom tools are kept.
type xaiClientToolKey struct {
namespace string
name string
toolType string
}
func (e *XAIExecutor) prepareResponsesRequest(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, stream bool) (*xaiPreparedRequest, error) {
return e.prepareResponsesRequestTo(ctx, req, opts, stream, sdktranslator.FormatCodex)
}
func (e *XAIExecutor) prepareResponsesRequestTo(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, stream bool, to sdktranslator.Format) (*xaiPreparedRequest, error) {
baseModel := thinking.ParseSuffix(req.Model).ModelName
from := opts.SourceFormat
responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts)
originalPayloadSource := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayloadSource = opts.OriginalRequest
}
originalPayload := bytes.Clone(originalPayloadSource)
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, stream)
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), stream)
var err error
body, err = thinking.ApplyThinking(body, req.Model, from.String(), e.Identifier(), e.Identifier())
if err != nil {
return nil, err
}
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers)
body = helps.SetStringIfDifferent(body, "model", baseModel)
body = helps.SetBoolIfDifferent(body, "stream", stream)
body, _ = sjson.DeleteBytes(body, "previous_response_id")
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
body, _ = sjson.DeleteBytes(body, "safety_identifier")
body, _ = sjson.DeleteBytes(body, "stream_options")
namespaceTools := collectXAINamespaceToolRefs(body)
// Collect before normalizeXAITools flattens namespace wrappers so keys match
// the post-restore (namespace, short-name) shape used by the response filter.
clientDeclaredTools := collectXAIClientDeclaredToolKeys(body)
body = normalizeXAITools(body)
body = promoteXAIAdditionalTools(body)
// Drop choices that point at tools removed by normalizeXAITools before we
// inject native x_search, so a surviving allowed_tools / forced choice is not
// left pointing at a deleted tool once only x_search remains.
body = normalizeXAINamespaceToolChoice(body)
body = pruneXAIOrphanedToolChoice(body)
body = normalizeXAIToolChoiceForTools(body)
body = ensureXAINativeXSearchTool(body)
var replayScope xaiReasoningReplayScope
body, replayScope, err = applyXAIReasoningReplayCacheRequired(ctx, from, req, opts, body)
if err != nil {
return nil, err
}
body = normalizeXAIInputCustomToolCalls(body)
body = normalizeXAIInputNamespaceToolCalls(body)
body = normalizeXAIInputReasoningItems(body)
body = sanitizeXAIInputEncryptedContent(body)
body = normalizeCodexInstructions(body)
body = sanitizeXAIResponsesBody(body, baseModel)
body = normalizeXAIImageRefs(body)
sessionID, errSession := xaiResolveComposerSessionID(ctx, req, opts, baseModel)
if errSession != nil {
return nil, errSession
}
if sessionID != "" {
body = helps.SetStringIfDifferent(body, "prompt_cache_key", sessionID)
}
return &xaiPreparedRequest{
baseModel: baseModel,
from: from,
responseFormat: responseFormat,
to: to,
originalPayload: originalPayload,
body: body,
namespaceTools: namespaceTools,
clientDeclaredTools: clientDeclaredTools,
sessionID: sessionID,
replayScope: replayScope,
filterInternalXSearch: xaiRequestHasNativeXSearch(body),
}, nil
}
func (e *XAIExecutor) recordXAIRequest(ctx context.Context, auth *cliproxyauth.Auth, url string, headers http.Header, body []byte) {
var authID, authLabel, authType, authValue string
if auth != nil {
authID = auth.ID
authLabel = auth.Label
authType, authValue = auth.AccountInfo()
}
helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{
URL: url,
Method: http.MethodPost,
Headers: headers,
Body: body,
Provider: e.Identifier(),
AuthID: authID,
AuthLabel: authLabel,
AuthType: authType,
AuthValue: authValue,
})
}
func xaiCreds(auth *cliproxyauth.Auth) (token, baseURL string) {
if auth == nil {
return "", ""
}
if auth.Attributes != nil {
token = strings.TrimSpace(auth.Attributes["api_key"])
baseURL = strings.TrimSpace(auth.Attributes["base_url"])
}
if auth.Metadata != nil {
if token == "" {
token = xaiMetadataString(auth.Metadata, "access_token")
}
if baseURL == "" {
baseURL = xaiMetadataString(auth.Metadata, "base_url")
}
}
return token, baseURL
}
// xaiUsingAPI reports whether this xAI auth should use the official API path
// for non-media HTTP chat. OAuth defaults to false to use Grok Build.
func xaiUsingAPI(auth *cliproxyauth.Auth) bool {
if auth == nil {
return true
}
if len(auth.Attributes) > 0 {
if raw := strings.TrimSpace(auth.Attributes[xaiUsingAPIAttr]); raw != "" {
parsed, errParse := strconv.ParseBool(raw)
if errParse == nil {
return parsed
}
}
}
if len(auth.Metadata) > 0 {
raw, ok := auth.Metadata[xaiUsingAPIAttr]
if ok && raw != nil {
switch v := raw.(type) {
case bool:
return v
case string:
parsed, errParse := strconv.ParseBool(strings.TrimSpace(v))
if errParse == nil {
return parsed
}
default:
}
}
}
if raw := strings.TrimSpace(auth.Attributes["auth_kind"]); raw != "" {
return !strings.EqualFold(raw, "oauth")
}
return !strings.EqualFold(xaiMetadataString(auth.Metadata, "auth_kind"), "oauth")
}
// xaiChatBaseURL returns the base URL for non-image/video xAI HTTP chat requests.
// When auth using_api is true, the official API base URL logic is used. When it
// is false (including its OAuth default), empty or official default base_url is
// rewritten to the CLI chat-proxy endpoint; an explicit non-default base_url is
// still honored.
// Websocket and compact transports intentionally do not use this helper:
// cli-chat-proxy only accepts HTTP POST chat and does not implement
// /responses/compact (404) or websocket upgrades (405).
func xaiChatBaseURL(auth *cliproxyauth.Auth) string {
_, baseURL := xaiCreds(auth)
if xaiUsingAPI(auth) {
if baseURL == "" {
return xaiauth.DefaultAPIBaseURL
}
return baseURL
}
if baseURL != "" && !xaiIsDefaultAPIBaseURL(baseURL) {
return baseURL
}
return xaiauth.CLIChatProxyBaseURL
}
// xaiCompactBaseURL returns the base URL for xAI /responses/compact requests.
// Compact must stay on the official API (or an explicit non-CLI-proxy base_url).
// Reusing xaiChatBaseURL would pin OAuth traffic to cli-chat-proxy, which returns
// 404 for /responses/compact and then cools down the auth pool as not_found.
func xaiCompactBaseURL(auth *cliproxyauth.Auth) string {
_, baseURL := xaiCreds(auth)
if baseURL == "" || xaiIsCLIChatProxyBaseURL(baseURL) {
return xaiauth.DefaultAPIBaseURL
}
return baseURL
}
func xaiNormalizeBaseURL(baseURL string) string {
return strings.TrimRight(strings.TrimSpace(baseURL), "/")
}
func xaiIsDefaultAPIBaseURL(baseURL string) bool {
return xaiNormalizeBaseURL(baseURL) == xaiNormalizeBaseURL(xaiauth.DefaultAPIBaseURL)
}
func xaiIsCLIChatProxyBaseURL(baseURL string) bool {
return xaiNormalizeBaseURL(baseURL) == xaiNormalizeBaseURL(xaiauth.CLIChatProxyBaseURL)
}
// xaiBaseURLSource classifies a resolved xAI base URL for logging.
func xaiBaseURLSource(baseURL string) string {
switch {
case xaiIsDefaultAPIBaseURL(baseURL):
return "DefaultAPIBaseURL"
case xaiIsCLIChatProxyBaseURL(baseURL):
return "CLIChatProxyBaseURL"
default:
return "custom"
}
}
// logXAIResolvedBaseURL emits a console log for the resolved upstream base URL.
func logXAIResolvedBaseURL(ctx context.Context, baseURL string) {
helps.LogWithRequestID(ctx).Infof("xai: using base_url=%s source=%s", baseURL, xaiBaseURLSource(baseURL))
}
func applyXAIHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, sessionID string) {
applyXAIDefaultHeaders(r, token, stream, sessionID)
applyXAICustomHeaders(r, auth)
}
func applyXAIDefaultHeaders(r *http.Request, token string, stream bool, sessionID string) {
r.Header.Set("Content-Type", "application/json")
if strings.TrimSpace(token) != "" {
r.Header.Set("Authorization", "Bearer "+token)
}
if stream {
r.Header.Set("Accept", "text/event-stream")
} else {
r.Header.Set("Accept", "application/json")
}
r.Header.Set("Connection", "Keep-Alive")
if sessionID != "" {
r.Header.Set("x-grok-conv-id", sessionID)
}
}
func applyXAICustomHeaders(r *http.Request, auth *cliproxyauth.Auth) {
var attrs map[string]string
if auth != nil {
attrs = auth.Attributes
}
util.ApplyCustomHeadersFromAttrs(r, attrs)
}
// applyXAIChatHeaders applies standard xAI headers for non-image/video chat
// requests. When using_api is true, this matches the standard
// applyXAIHeaders behavior. CLI chat-proxy identity headers are only attached
// when using_api is false and the resolved chat base URL is the official CLI
// chat-proxy endpoint.
func applyXAIChatHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, sessionID string) {
if xaiUsingAPI(auth) {
applyXAIHeaders(r, auth, token, stream, sessionID)
return
}
applyXAIDefaultHeaders(r, token, stream, sessionID)
if xaiIsCLIChatProxyBaseURL(xaiChatBaseURL(auth)) {
r.Header.Set(xaiTokenAuthHeader, xaiTokenAuthValue)
r.Header.Set(xaiClientVersionHeader, xaiClientVersionValue)
r.Header.Set("User-Agent", "xai-grok-workspace/"+xaiClientVersionValue)
}
applyXAICustomHeaders(r, auth)
}
func xaiResolveComposerSessionID(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, baseModel string) (string, error) {
if sessionID := xaiExecutionSessionID(req, opts); sessionID != "" {
return sessionID, nil
}
if !xaiRequiresIsolatedConversation(baseModel) {
return "", nil
}
cached, ok, errCache := helps.ClaudeCodePromptCache(ctx, baseModel, req.Payload, opts.Headers)
if errCache != nil {
return "", errCache
}
if ok {
return cached.ID, nil
}
return uuid.NewString(), nil
}
func xaiExecutionSessionID(req cliproxyexecutor.Request, opts cliproxyexecutor.Options) string {
if value := xaiMetadataString(opts.Metadata, cliproxyexecutor.ExecutionSessionMetadataKey); value != "" {
return value
}
if value := xaiMetadataString(req.Metadata, cliproxyexecutor.ExecutionSessionMetadataKey); value != "" {
return value
}
if promptCacheKey := gjson.GetBytes(req.Payload, "prompt_cache_key"); promptCacheKey.Exists() {
return strings.TrimSpace(promptCacheKey.String())
}
return ""
}
func xaiRequiresIsolatedConversation(model string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), xaiComposerModelPrefix)
}
func xaiImageEndpointPath(opts cliproxyexecutor.Options) string {
if opts.SourceFormat.String() != xaiImageHandlerType {
return ""
}
path := xaiMetadataString(opts.Metadata, cliproxyexecutor.RequestPathMetadataKey)
if strings.HasSuffix(path, "/images/edits") {
return xaiImagesEditsPath
}
if strings.HasSuffix(path, "/images/generations") {
return xaiImagesGenerationsPath
}
return xaiDefaultImageEndpointPath
}
// normalizeXAIImageRefs rewrites OpenAI-style image object fields to the xAI
// image API shape before the payload is sent upstream:
//
// {"image":{"image_url":"https://..."}} → {"image":{"url":"https://..."}}
//
// Applies to image / images / reference_images anywhere in the JSON tree,
// including nested objects and array items. Does not rewrite chat content
// parts shaped as {"type":"image_url","image_url":{...}}.
func normalizeXAIImageRefs(body []byte) []byte {
if !gjson.ValidBytes(body) {
return body
}
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.UseNumber()
var payload any
if errDecode := decoder.Decode(&payload); errDecode != nil {
return body
}
if !normalizeXAIImageRefsValue(payload) {
return body
}
normalized, errMarshal := json.Marshal(payload)
if errMarshal != nil {
return body
}
return normalized
}
func normalizeXAIImageRefsValue(value any) bool {
changed := false
switch node := value.(type) {
case map[string]any:
for key, child := range node {
switch key {
case "image":
changed = normalizeXAIImageRef(child) || changed
case "images", "reference_images":
if refs, ok := child.([]any); ok {
for _, ref := range refs {
changed = normalizeXAIImageRef(ref) || changed
}
}
}
changed = normalizeXAIImageRefsValue(child) || changed
}
case []any:
for _, child := range node {
changed = normalizeXAIImageRefsValue(child) || changed
}
}
return changed
}
func normalizeXAIImageRef(value any) bool {
ref, ok := value.(map[string]any)
if !ok {
return false
}
originalURL, _ := ref["url"].(string)
url := strings.TrimSpace(originalURL)
imageURL, hasImageURL := ref["image_url"]
if url == "" {
switch imageURL := imageURL.(type) {
case string:
url = strings.TrimSpace(imageURL)
case map[string]any:
url, _ = imageURL["url"].(string)
url = strings.TrimSpace(url)
}
}
if url == "" {
return false
}
if url == originalURL && !hasImageURL {
return false
}
// Always emit the xAI field name and drop the OpenAI alias.
ref["url"] = url
delete(ref, "image_url")
return true
}
func xaiIsVideoRequest(opts cliproxyexecutor.Options) bool {
return opts.SourceFormat.String() == xaiVideoHandlerType
}
func xaiVideoEndpointPath(opts cliproxyexecutor.Options) string {
if !xaiIsVideoRequest(opts) {
return ""
}
path := xaiMetadataString(opts.Metadata, cliproxyexecutor.RequestPathMetadataKey)
if strings.HasSuffix(path, "/videos/edits") {
return xaiVideosEditsPath
}
if strings.HasSuffix(path, "/videos/extensions") {
return xaiVideosExtensionsPath
}
if strings.HasSuffix(path, "/videos/generations") {
return xaiVideosGenerationsPath
}
return ""
}
func xaiMetadataString(meta map[string]any, key string) string {
if len(meta) == 0 || key == "" {
return ""
}
value, ok := meta[key]
if !ok || value == nil {
return ""
}
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
case fmt.Stringer:
return strings.TrimSpace(typed.String())
default:
return strings.TrimSpace(fmt.Sprint(typed))
}
}
func sanitizeXAIResponsesBody(body []byte, model string) []byte {
if !xaiSupportsReasoningEffort(model) {
if gjson.GetBytes(body, "reasoning.effort").Exists() {
log.Debugf("xai: stripping reasoning.effort for model %s (no thinking levels in model registry)", model)
}
body, _ = sjson.DeleteBytes(body, "reasoning.effort")
if reasoning := gjson.GetBytes(body, "reasoning"); reasoning.Exists() && reasoning.IsObject() && len(reasoning.Map()) == 0 {
body, _ = sjson.DeleteBytes(body, "reasoning")
}
}
return body
}
// ensureXAINativeXSearchTool appends {"type":"x_search"} when the final tools
// list does not already include native X Search. When tool_choice restricts the
// model to allowed_tools, x_search is also added there (without duplicates) so
// Grok can select the injected tool. HTTP and websocket executors both prepare
// payloads through prepareResponsesRequestTo, so this runs once before the body
// is submitted upstream.
func ensureXAINativeXSearchTool(body []byte) []byte {
if !gjson.ValidBytes(body) {
return body
}
if !xaiRequestHasNativeXSearch(body) {
tools := gjson.GetBytes(body, "tools")
if !tools.Exists() || !tools.IsArray() {
body, _ = sjson.SetRawBytes(body, "tools", []byte(`[{"type":"x_search"}]`))
} else {
body, _ = sjson.SetRawBytes(body, "tools.-1", xaiXSearchToolJSON)
}
}
return ensureXAINativeXSearchAllowedTools(body)
}
// ensureXAINativeXSearchAllowedTools appends x_search to tool_choice.tools when
// the choice mode is allowed_tools and x_search is not already listed.
func ensureXAINativeXSearchAllowedTools(body []byte) []byte {
choice := gjson.GetBytes(body, "tool_choice")
if !choice.IsObject() || choice.Get("type").String() != "allowed_tools" {
return body
}
allowed := choice.Get("tools")
if !allowed.Exists() || !allowed.IsArray() {
body, _ = sjson.SetRawBytes(body, "tool_choice.tools", []byte(`[{"type":"x_search"}]`))
return body
}
for _, tool := range allowed.Array() {
if strings.TrimSpace(tool.Get("type").String()) == xaiXSearchToolType {
return body
}
}
body, _ = sjson.SetRawBytes(body, "tool_choice.tools.-1", xaiXSearchToolJSON)
return body
}
// pruneXAIOrphanedToolChoice removes tool_choice entries that no longer match
// any remaining tool after normalizeXAITools filtering. Forced choices that
// reference a deleted tool are dropped entirely; allowed_tools lists keep only
// choices that still resolve against the post-normalization tools set.
func pruneXAIOrphanedToolChoice(body []byte) []byte {
if !gjson.ValidBytes(body) {
return body
}
choice := gjson.GetBytes(body, "tool_choice")
if !choice.Exists() {
return body
}
available := collectXAIAvailableToolChoiceKeys(body)
if choice.Type == gjson.String {
// auto / none / required are not tool references.
return body
}
if !choice.IsObject() {
return body
}
choiceType := strings.TrimSpace(choice.Get("type").String())
switch choiceType {
case "allowed_tools":
return pruneXAIAllowedToolsChoice(body, available)
default:
if choiceType == "" {
return body
}
if xaiToolChoiceMatchesAvailable(choice, available) {
return body
}
body, _ = sjson.DeleteBytes(body, "tool_choice")
return body
}
}
func pruneXAIAllowedToolsChoice(body []byte, available map[xaiToolChoiceKey]struct{}) []byte {
allowed := gjson.GetBytes(body, "tool_choice.tools")
if !allowed.Exists() || !allowed.IsArray() {
body, _ = sjson.DeleteBytes(body, "tool_choice")
return body
}
allowedItems := allowed.Array()
filtered := make([][]byte, 0, len(allowedItems))
changed := false
for _, tool := range allowedItems {
if !xaiToolChoiceMatchesAvailable(tool, available) {
changed = true
continue
}
filtered = append(filtered, []byte(tool.Raw))
}
if !changed {
return body
}
if len(filtered) == 0 {
body, _ = sjson.DeleteBytes(body, "tool_choice")
return body
}
body, _ = sjson.SetRawBytes(body, "tool_choice.tools", helps.JoinRawJSONArray(filtered))
return body
}
// xaiToolChoiceKey identifies a selectable tool the way xAI tool_choice entries
// reference it after namespace qualification: type alone for host tools, or
// type+name for function tools.
type xaiToolChoiceKey struct {
toolType string
name string
}
func collectXAIAvailableToolChoiceKeys(body []byte) map[xaiToolChoiceKey]struct{} {
keys := make(map[xaiToolChoiceKey]struct{})
collect := func(tools gjson.Result) {
if !tools.IsArray() {
return
}
for _, tool := range tools.Array() {
toolType := strings.TrimSpace(tool.Get("type").String())
if toolType == "" {
continue
}
key := xaiToolChoiceKey{toolType: toolType}
if toolType == xaiFunctionToolType || toolType == xaiCustomToolType {
key.name = strings.TrimSpace(tool.Get("name").String())
if key.name == "" {
continue
}
}
keys[key] = struct{}{}
}
}
collect(gjson.GetBytes(body, "tools"))
input := gjson.GetBytes(body, "input")
if input.IsArray() {
for _, item := range input.Array() {
if item.Get("type").String() == "additional_tools" {
collect(item.Get("tools"))
}
}
}
return keys
}
func xaiToolChoiceMatchesAvailable(choice gjson.Result, available map[xaiToolChoiceKey]struct{}) bool {
toolType := strings.TrimSpace(choice.Get("type").String())
if toolType == "" {
return false
}
key := xaiToolChoiceKey{toolType: toolType}
if toolType == xaiFunctionToolType || toolType == xaiCustomToolType {
key.name = strings.TrimSpace(choice.Get("name").String())
if key.name == "" {
return false
}
}
_, ok := available[key]
return ok
}
func normalizeXAITools(body []byte) []byte {
if !gjson.ValidBytes(body) {
return body
}
original := body
normalizeAtPath := func(path string) bool {
tools := gjson.GetBytes(body, path)
if !tools.Exists() || !tools.IsArray() {
return true
}
filtered, changed, ok := normalizeXAIToolArray(tools)
if !ok {
return false
}
if !changed {
return true
}
updated, errSet := sjson.SetRawBytes(body, path, filtered)
if errSet != nil {
return false
}
body = updated
return true
}
if !normalizeAtPath("tools") {
return original
}
input := gjson.GetBytes(body, "input")
if input.Exists() && input.IsArray() {
for index, item := range input.Array() {
if item.Get("type").String() != "additional_tools" {
continue
}
if !normalizeAtPath(fmt.Sprintf("input.%d.tools", index)) {
return original
}
}
}
return body
}
// promoteXAIAdditionalTools moves Responses Lite tool declarations to the
// top-level tools array because xAI does not accept additional_tools input items.
func promoteXAIAdditionalTools(body []byte) []byte {
if !gjson.ValidBytes(body) {
return body
}
input := gjson.GetBytes(body, "input")
if !input.IsArray() {
return body
}
inputItems := input.Array()
remainingInput := make([]json.RawMessage, 0, len(inputItems))
promotedTools := make([]json.RawMessage, 0)
for _, item := range inputItems {
if item.Get("type").String() != "additional_tools" {
remainingInput = append(remainingInput, json.RawMessage(item.Raw))
continue
}
for _, tool := range item.Get("tools").Array() {
promotedTools = append(promotedTools, json.RawMessage(tool.Raw))
}
}
if len(remainingInput) == len(inputItems) {
return body
}
rawInput, errMarshalInput := json.Marshal(remainingInput)
if errMarshalInput != nil {
return body
}
updated, errSetInput := sjson.SetRawBytes(body, "input", rawInput)
if errSetInput != nil {
return body
}
if len(promotedTools) == 0 {
return updated
}
topLevelTools := gjson.GetBytes(updated, "tools")
tools := make([]json.RawMessage, 0, len(topLevelTools.Array())+len(promotedTools))
if topLevelTools.IsArray() {
for _, tool := range topLevelTools.Array() {
tools = append(tools, json.RawMessage(tool.Raw))
}
}
tools = append(tools, promotedTools...)
rawTools, errMarshalTools := json.Marshal(tools)
if errMarshalTools != nil {
return body
}
updated, errSetTools := sjson.SetRawBytes(updated, "tools", rawTools)
if errSetTools != nil {
return body
}
return updated
}
func normalizeXAIToolArray(tools gjson.Result) ([]byte, bool, bool) {
toolItems := tools.Array()
filtered := make([][]byte, 0, len(toolItems))
changed := false
for _, tool := range toolItems {
toolType := tool.Get("type").String()
if toolType == xaiNamespaceToolType {
changed = true
namespaceName := tool.Get("name").String()
if namespaceTools := tool.Get("tools"); namespaceTools.IsArray() {
for _, nestedTool := range namespaceTools.Array() {
nestedRaw, nestedChanged, ok := normalizeXAITool(nestedTool, namespaceName)
if !ok {
return nil, false, false
}
changed = changed || nestedChanged
if len(nestedRaw) > 0 {
filtered = append(filtered, nestedRaw)
}
}
}
continue
}
raw, toolChanged, ok := normalizeXAITool(tool, "")
if !ok {
return nil, false, false
}
changed = changed || toolChanged
if len(raw) > 0 {
filtered = append(filtered, raw)
}
}
if !changed {
return nil, false, true
}
return helps.JoinRawJSONArray(filtered), true, true
}
// normalizeXAIToolChoiceForTools drops tool_choice and parallel_tool_calls
// when tools are absent or empty (including after normalizeXAITools filtering).
// xAI rejects payloads that include tool_choice without any tools defined.
// Existence checks avoid unnecessary sjson parse/copy passes.
func normalizeXAIToolChoiceForTools(body []byte) []byte {
tools := gjson.GetBytes(body, "tools")
hasTools := tools.Exists() && tools.IsArray() && len(tools.Array()) > 0
if !hasTools {
input := gjson.GetBytes(body, "input")
if input.Exists() && input.IsArray() {
for _, item := range input.Array() {
additionalTools := item.Get("tools")
if item.Get("type").String() == "additional_tools" && additionalTools.IsArray() && len(additionalTools.Array()) > 0 {
hasTools = true
break
}
}
}
}
if hasTools {
return body
}
if tools.Exists() {
body, _ = sjson.DeleteBytes(body, "tools")
}
if gjson.GetBytes(body, "tool_choice").Exists() {
body, _ = sjson.DeleteBytes(body, "tool_choice")
}
if gjson.GetBytes(body, "parallel_tool_calls").Exists() {
body, _ = sjson.DeleteBytes(body, "parallel_tool_calls")
}
return body
}
// normalizeXAINamespaceToolChoice qualifies namespaced function choices using
// the same names sent in the flattened tools list. xAI does not accept the
// Responses namespace field on tool choices.
func normalizeXAINamespaceToolChoice(body []byte) []byte {
if !gjson.ValidBytes(body) {
return body
}
original := body
normalizeAtPath := func(path string) bool {
toolChoice := gjson.GetBytes(body, path)
if !toolChoice.IsObject() || toolChoice.Get("type").String() != xaiFunctionToolType {
return true
}
namespaceName := strings.TrimSpace(toolChoice.Get("namespace").String())
toolName := strings.TrimSpace(toolChoice.Get("name").String())
qualifiedName := qualifyXAINamespaceToolName(namespaceName, toolName)
if namespaceName == "" || qualifiedName == "" {
return true
}
updated, errSet := sjson.SetBytes(body, path+".name", qualifiedName)
if errSet != nil {
return false
}
updated, errDelete := sjson.DeleteBytes(updated, path+".namespace")
if errDelete != nil {
return false
}
body = updated
return true
}
if !normalizeAtPath("tool_choice") {
return original
}
tools := gjson.GetBytes(body, "tool_choice.tools")
if tools.IsArray() {
for index := range tools.Array() {
if !normalizeAtPath(fmt.Sprintf("tool_choice.tools.%d", index)) {
return original
}
}
}
return body
}
func normalizeXAITool(tool gjson.Result, namespaceName string) ([]byte, bool, bool) {
toolType := tool.Get("type").String()
changed := false
if toolType == xaiToolSearchType || toolType == xaiImageGenerationToolType {
return nil, true, true
}
if toolType == xaiCustomToolType && tool.Get("name").String() == "apply_patch" {
return nil, true, true
}
raw := []byte(tool.Raw)
schemaTool := tool
if toolType == xaiFunctionToolType || toolType == xaiCustomToolType {
updatedTool, schemaChanged, ok := normalizeXAIObjectRootUnionBranchTypes(raw)
if !ok {
return nil, false, false
}
raw = updatedTool
if schemaChanged {
schemaTool = gjson.ParseBytes(raw)
changed = true
log.Debugf("xai: added object types to root union branches for tool %s.%s", namespaceName, tool.Get("name").String())
}
}
if toolType == xaiCustomToolType {
updatedTool, errSet := sjson.SetBytes(raw, "type", xaiFunctionToolType)
if errSet != nil {
return nil, false, false
}
raw = updatedTool
toolType = xaiFunctionToolType
changed = true
}
if toolType == xaiWebSearchToolType && tool.Get("external_web_access").Exists() {
updatedTool, errDel := sjson.DeleteBytes(raw, "external_web_access")
if errDel != nil {
return nil, false, false
}
raw = updatedTool
changed = true
}
if toolType == xaiFunctionToolType && !schemaTool.Get("parameters").Exists() {
updatedTool, errSet := sjson.SetRawBytes(raw, "parameters", []byte(`{"type":"object","properties":{}}`))
if errSet != nil {
return nil, false, false
}
raw = updatedTool
changed = true
}
// Simplify the Codex Desktop automation schema and root unions that xAI
// rejects because function parameters must resolve exclusively to objects.
if toolType == xaiFunctionToolType && xaiFunctionParametersNeedSimplification(schemaTool, namespaceName) {
updatedTool, errSet := sjson.SetRawBytes(raw, "parameters", []byte(xaiSafeFunctionParameters))
if errSet != nil {
return nil, false, false
}
raw = updatedTool
if strict := tool.Get("strict"); strict.Exists() && strict.Bool() {
updatedTool, errSet = sjson.SetBytes(raw, "strict", false)
if errSet != nil {
return nil, false, false
}
raw = updatedTool
}
changed = true
log.Debugf("xai: simplified parameters for tool %s.%s to avoid upstream schema rejection or hang", namespaceName, tool.Get("name").String())
}
if toolType == xaiFunctionToolType && strings.TrimSpace(namespaceName) != "" {
qualifiedName := qualifyXAINamespaceToolName(namespaceName, tool.Get("name").String())
if qualifiedName == "" {
return nil, false, false
}
updatedTool, errSet := sjson.SetBytes(raw, "name", qualifiedName)
if errSet != nil {
return nil, false, false
}
raw = updatedTool
changed = true
}
return raw, changed, true
}
func qualifyXAINamespaceToolName(namespaceName, toolName string) string {
namespaceName = strings.TrimSpace(namespaceName)
toolName = strings.TrimSpace(toolName)
if namespaceName == "" || toolName == "" || strings.HasPrefix(toolName, "mcp__") {
return toolName
}
prefix := namespaceName
if !strings.HasSuffix(prefix, "__") {
prefix += "__"
}
if strings.HasPrefix(toolName, prefix) {
return toolName
}
return prefix + toolName
}
func collectXAINamespaceToolRefs(body []byte) map[string]xaiNamespaceToolRef {
refs := make(map[string]xaiNamespaceToolRef)
collect := func(tools gjson.Result) {
if !tools.Exists() || !tools.IsArray() {
return
}
for _, tool := range tools.Array() {
if tool.Get("type").String() != xaiNamespaceToolType {
continue
}
namespaceName := strings.TrimSpace(tool.Get("name").String())
if namespaceName == "" {
continue
}
for _, nestedTool := range tool.Get("tools").Array() {
toolName := strings.TrimSpace(nestedTool.Get("name").String())
qualifiedName := qualifyXAINamespaceToolName(namespaceName, toolName)
if qualifiedName == "" {
continue
}
refs[qualifiedName] = xaiNamespaceToolRef{namespace: namespaceName, name: toolName}
}
}
}
collect(gjson.GetBytes(body, "tools"))
input := gjson.GetBytes(body, "input")
if input.Exists() && input.IsArray() {
for _, item := range input.Array() {
if item.Get("type").String() == "additional_tools" {
collect(item.Get("tools"))
}
}
}
return refs
}
func normalizeXAIInputCustomToolCalls(body []byte) []byte {
input := gjson.GetBytes(body, "input")
if !input.Exists() || !input.IsArray() {
return body
}
changed := false
inputArray := input.Array()
items := make([]json.RawMessage, 0, len(inputArray))
for _, item := range inputArray {
var normalized []byte
switch item.Get("type").String() {
case "custom_tool_call":
callID := strings.TrimSpace(item.Get("call_id").String())
name := strings.TrimSpace(item.Get("name").String())
if callID == "" || name == "" {
changed = true
continue
}
normalized = []byte(`{"type":"function_call"}`)
normalized, _ = sjson.SetBytes(normalized, "call_id", callID)
normalized, _ = sjson.SetBytes(normalized, "name", name)
normalized, _ = sjson.SetBytes(normalized, "arguments", xaiCustomToolCallArguments(item.Get("input")))
case "custom_tool_call_output":
callID := strings.TrimSpace(item.Get("call_id").String())
if callID == "" {
changed = true
continue
}
normalized = []byte(`{"type":"function_call_output"}`)
normalized, _ = sjson.SetBytes(normalized, "call_id", callID)
normalized, _ = sjson.SetBytes(normalized, "output", xaiCustomToolCallOutput(item.Get("output")))
default:
items = append(items, json.RawMessage(item.Raw))
continue
}
items = append(items, json.RawMessage(normalized))
changed = true
}
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 xaiCustomToolCallArguments(input gjson.Result) string {
if !input.Exists() {
return "{}"
}
if input.Type == gjson.String {
text := input.String()
trimmed := strings.TrimSpace(text)
if gjson.Valid(trimmed) {
parsed := gjson.Parse(trimmed)
if parsed.IsObject() {
return parsed.Raw
}
}
encoded, errMarshal := json.Marshal(text)
if errMarshal != nil {
return "{}"
}
return `{"input":` + string(encoded) + `}`
}
if input.IsObject() {
return input.Raw
}
if input.Raw != "" {
return `{"input":` + input.Raw + `}`
}
return "{}"
}
func xaiCustomToolCallOutput(output gjson.Result) string {
if !output.Exists() {
return ""
}
if output.Type == gjson.String {
return output.String()
}
return output.Raw
}
// xAI executes these x_search subtools server-side but exposes their trace as
// client-style tool calls. Hide the trace so Responses clients do not execute it again.
type xaiInternalXSearchResponseFilter struct {
enabled bool
clientDeclaredTools map[xaiClientToolKey]struct{}
droppedOutputIndexes map[int64]struct{}
droppedItemIDs map[string]struct{}
}
func newXAIInternalXSearchResponseFilter(enabled bool, clientDeclaredTools map[xaiClientToolKey]struct{}) *xaiInternalXSearchResponseFilter {
filter := &xaiInternalXSearchResponseFilter{
enabled: enabled,
clientDeclaredTools: clientDeclaredTools,
}
if enabled {
filter.droppedOutputIndexes = make(map[int64]struct{})
filter.droppedItemIDs = make(map[string]struct{})
}
return filter
}
func xaiRequestHasNativeXSearch(body []byte) bool {
if gjson.GetBytes(body, `tools.#(type=="x_search")`).Exists() {
return true
}
// Multipath queries return an array of matches; an empty array still Exists().
// Check the match count instead of Exists() for additional_tools injection.
return len(gjson.GetBytes(body, `input.#(type=="additional_tools")#.tools.#(type=="x_search")`).Array()) > 0
}
// collectXAIClientDeclaredToolKeys records client-declared function/custom tools
// using the Responses post-restore identity (short name + optional namespace) and
// the effective upstream tool type after normalizeXAITool. Client custom tools
// are normalized to function before being sent to xAI, so keys use function for
// both declaration kinds. Must run before normalizeXAITools flattens namespace wrappers.
func collectXAIClientDeclaredToolKeys(body []byte) map[xaiClientToolKey]struct{} {
keys := make(map[xaiClientToolKey]struct{})
collect := func(tools gjson.Result) {
if !tools.Exists() || !tools.IsArray() {
return
}
for _, tool := range tools.Array() {
switch toolType := strings.TrimSpace(tool.Get("type").String()); toolType {
case xaiNamespaceToolType:
namespaceName := strings.TrimSpace(tool.Get("name").String())
if namespaceName == "" {
continue
}
for _, nestedTool := range tool.Get("tools").Array() {
nestedType := strings.TrimSpace(nestedTool.Get("type").String())
if nestedType != xaiFunctionToolType && nestedType != xaiCustomToolType {
continue
}
toolName := strings.TrimSpace(nestedTool.Get("name").String())
if toolName == "" {
continue
}
// normalizeXAITool converts custom → function before upstream send.
keys[xaiClientToolKey{namespace: namespaceName, name: toolName, toolType: xaiEffectiveDeclaredToolType(nestedType)}] = struct{}{}
}
case xaiFunctionToolType, xaiCustomToolType:
toolName := strings.TrimSpace(tool.Get("name").String())
if toolName == "" {
continue
}
// normalizeXAITool converts custom → function before upstream send.
keys[xaiClientToolKey{namespace: "", name: toolName, toolType: xaiEffectiveDeclaredToolType(toolType)}] = struct{}{}
}
}
}
collect(gjson.GetBytes(body, "tools"))
input := gjson.GetBytes(body, "input")
if input.Exists() && input.IsArray() {
for _, item := range input.Array() {
if item.Get("type").String() == "additional_tools" {
collect(item.Get("tools"))
}
}
}
return keys
}
// xaiEffectiveDeclaredToolType returns the tool type actually sent upstream
// after normalizeXAITool. Client custom tools are rewritten to function.
func xaiEffectiveDeclaredToolType(toolType string) string {
if strings.TrimSpace(toolType) == xaiCustomToolType {
return xaiFunctionToolType
}
return strings.TrimSpace(toolType)
}
func xaiIsInternalXSearchToolName(name string) bool {
switch strings.TrimSpace(name) {
case "x_user_search", "x_semantic_search", "x_keyword_search", "x_thread_fetch":
return true
default:
return false
}
}
// xaiResponseCallDeclaredType maps a Responses output call type to the effective
// upstream tool declaration kind used when matching client-declared tools.
// Client custom tools are normalized to function before upstream send, so only
// function_call can match a client-declared same-name tool; custom_tool_call
// remains the internal X Search trace shape.
func xaiResponseCallDeclaredType(itemType string) string {
switch strings.TrimSpace(itemType) {
case "function_call":
return xaiFunctionToolType
case "custom_tool_call":
return xaiCustomToolType
default:
return ""
}
}
// xaiIsInternalXSearchCallID reports whether call_id matches the evidenced xAI
// X Search server-side trace prefix (xs_call...), as observed in Responses traffic
// for native x_search subtools (see issue #4282 / PR #4284 fixtures).
func xaiIsInternalXSearchCallID(callID string) bool {
return strings.HasPrefix(strings.TrimSpace(callID), "xs_call")
}
// xaiIsInternalXSearchCall reports whether an output item is an xAI server-side
// X Search subtool trace that should be hidden from Responses clients.
//
// Evidence from xAI Responses traffic (issue #4282 / PR #4284):
// - native x_search subtools are emitted as custom_tool_call items named
// x_user_search / x_semantic_search / x_keyword_search / x_thread_fetch
// - those traces commonly use call_id values prefixed with "xs_call"
//
// Client tools that share a short name are preserved only when the response call
// kind matches the effective upstream declaration type. Because normalizeXAITool
// rewrites client custom → function, a client custom x_keyword_search is keyed as
// function and therefore preserves function_call while still filtering genuine
// internal custom_tool_call / xs_call* traces. Namespaced restored client tools
// are never treated as internal.
func xaiIsInternalXSearchCall(item gjson.Result, clientDeclaredTools map[xaiClientToolKey]struct{}) bool {
itemType := strings.TrimSpace(item.Get("type").String())
declaredType := xaiResponseCallDeclaredType(itemType)
if declaredType == "" {
return false
}
name := strings.TrimSpace(item.Get("name").String())
if !xaiIsInternalXSearchToolName(name) {
return false
}
namespace := strings.TrimSpace(item.Get("namespace").String())
// Namespaced calls are restored client tools, never xAI internal X Search traces.
if namespace != "" {
return false
}
// Evidenced internal call_id prefix always identifies server-side X Search traces,
// even when a client tool reuses the same short name.
if xaiIsInternalXSearchCallID(item.Get("call_id").String()) {
return true
}
// Preserve only client tools whose effective upstream declaration kind matches
// this call type (function_call ↔ function after custom normalization).
if _, declared := clientDeclaredTools[xaiClientToolKey{namespace: namespace, name: name, toolType: declaredType}]; declared {
return false
}
return true
}
func (f *xaiInternalXSearchResponseFilter) apply(eventData []byte) []byte {
if f == nil || !f.enabled || len(eventData) == 0 || !gjson.ValidBytes(eventData) {
return eventData
}
if item := gjson.GetBytes(eventData, "item"); xaiIsInternalXSearchCall(item, f.clientDeclaredTools) {
f.recordDroppedItem(eventData, item)
return nil
}
eventData = f.filterCompletedOutput(eventData)
if f.referencesDroppedItem(eventData) {
return nil
}
return f.compactOutputIndex(eventData)
}
func (f *xaiInternalXSearchResponseFilter) recordDroppedItem(eventData []byte, item gjson.Result) {
if outputIndex := gjson.GetBytes(eventData, "output_index"); outputIndex.Exists() {
f.droppedOutputIndexes[outputIndex.Int()] = struct{}{}
}
for _, path := range []string{"id", "call_id"} {
if id := strings.TrimSpace(item.Get(path).String()); id != "" {
f.droppedItemIDs[id] = struct{}{}
}
}
}
func (f *xaiInternalXSearchResponseFilter) referencesDroppedItem(eventData []byte) bool {
if outputIndex := gjson.GetBytes(eventData, "output_index"); outputIndex.Exists() {
if _, dropped := f.droppedOutputIndexes[outputIndex.Int()]; dropped {
return true
}
}
for _, path := range []string{"item_id", "call_id"} {
id := strings.TrimSpace(gjson.GetBytes(eventData, path).String())
if _, dropped := f.droppedItemIDs[id]; id != "" && dropped {
return true
}
}
return false
}
func (f *xaiInternalXSearchResponseFilter) compactOutputIndex(eventData []byte) []byte {
outputIndex := gjson.GetBytes(eventData, "output_index")
if !outputIndex.Exists() {
return eventData
}
original := outputIndex.Int()
removedBefore := int64(0)
for dropped := range f.droppedOutputIndexes {
if dropped < original {
removedBefore++
}
}
if removedBefore == 0 {
return eventData
}
updated, errSet := sjson.SetBytes(eventData, "output_index", original-removedBefore)
if errSet != nil {
return eventData
}
return updated
}
func (f *xaiInternalXSearchResponseFilter) filterCompletedOutput(eventData []byte) []byte {
output := gjson.GetBytes(eventData, "response.output")
if !output.IsArray() {
return eventData
}
var clientDeclaredTools map[xaiClientToolKey]struct{}
if f != nil {
clientDeclaredTools = f.clientDeclaredTools
}
items := make([]json.RawMessage, 0, len(output.Array()))
changed := false
for _, item := range output.Array() {
if xaiIsInternalXSearchCall(item, clientDeclaredTools) {
changed = true
continue
}
items = append(items, json.RawMessage(item.Raw))
}
if !changed {
return eventData
}
rawOutput, errMarshal := json.Marshal(items)
if errMarshal != nil {
return eventData
}
updated, errSet := sjson.SetRawBytes(eventData, "response.output", rawOutput)
if errSet != nil {
return eventData
}
return updated
}
func normalizeXAIInputNamespaceToolCalls(body []byte) []byte {
if !gjson.ValidBytes(body) {
return body
}
input := gjson.GetBytes(body, "input")
if !input.Exists() || !input.IsArray() {
return body
}
for index, item := range input.Array() {
if item.Get("type").String() != "function_call" {
continue
}
namespaceName := strings.TrimSpace(item.Get("namespace").String())
toolName := strings.TrimSpace(item.Get("name").String())
qualifiedName := qualifyXAINamespaceToolName(namespaceName, toolName)
if namespaceName == "" || qualifiedName == "" {
continue
}
namePath := fmt.Sprintf("input.%d.name", index)
namespacePath := fmt.Sprintf("input.%d.namespace", index)
updated, errSet := sjson.SetBytes(body, namePath, qualifiedName)
if errSet != nil {
continue
}
updated, errDelete := sjson.DeleteBytes(updated, namespacePath)
if errDelete != nil {
continue
}
body = updated
}
return body
}
func restoreXAINamespaceToolCalls(data []byte, refs map[string]xaiNamespaceToolRef) []byte {
if len(refs) == 0 || len(data) == 0 || !gjson.ValidBytes(data) {
return data
}
data = restoreXAINamespaceToolCallAtPath(data, "item", refs)
output := gjson.GetBytes(data, "response.output")
if output.Exists() && output.IsArray() {
for index := range output.Array() {
data = restoreXAINamespaceToolCallAtPath(data, fmt.Sprintf("response.output.%d", index), refs)
}
}
return data
}
func restoreXAINamespaceToolCallAtPath(data []byte, path string, refs map[string]xaiNamespaceToolRef) []byte {
if gjson.GetBytes(data, path+".type").String() != "function_call" {
return data
}
qualifiedName := strings.TrimSpace(gjson.GetBytes(data, path+".name").String())
ref, ok := refs[qualifiedName]
if !ok {
return data
}
updated, errSet := sjson.SetBytes(data, path+".name", ref.name)
if errSet != nil {
return data
}
updated, errSet = sjson.SetBytes(updated, path+".namespace", ref.namespace)
if errSet != nil {
return data
}
return updated
}
// normalizeXAIObjectRootUnionBranchTypes makes untyped root union branches
// explicitly object-only when the parameter root already permits only objects.
// This preserves the original schema semantics while satisfying xAI validation.
func normalizeXAIObjectRootUnionBranchTypes(tool []byte) ([]byte, bool, bool) {
parameters := gjson.GetBytes(tool, "parameters")
rootType := parameters.Get("type")
if rootType.Type != gjson.String || rootType.String() != "object" {
return tool, false, true
}
original := tool
changed := false
for _, unionName := range []string{"anyOf", "oneOf"} {
union := parameters.Get(unionName)
if !union.IsArray() {
continue
}
for index, branch := range union.Array() {
if !branch.IsObject() || branch.Get("type").Exists() {
continue
}
updated, errSet := sjson.SetBytes(tool, fmt.Sprintf("parameters.%s.%d.type", unionName, index), "object")
if errSet != nil {
return original, false, false
}
tool = updated
changed = true
}
}
return tool, changed, true
}
func xaiSchemaTypeIsObjectOnly(schemaType gjson.Result) bool {
if schemaType.Type == gjson.String {
return strings.EqualFold(strings.TrimSpace(schemaType.String()), "object")
}
if !schemaType.IsArray() {
return false
}
types := schemaType.Array()
if len(types) == 0 {
return false
}
for _, schemaTypeItem := range types {
if schemaTypeItem.Type != gjson.String || !strings.EqualFold(strings.TrimSpace(schemaTypeItem.String()), "object") {
return false
}
}
return true
}
// xaiFunctionParametersNeedSimplification reports whether a function tool, or
// a custom tool normalized to a function, has a schema that xAI cannot accept.
func xaiFunctionParametersNeedSimplification(tool gjson.Result, namespaceName string) bool {
toolType := strings.TrimSpace(tool.Get("type").String())
isFunction := strings.EqualFold(toolType, xaiFunctionToolType)
isNormalizedCustom := strings.EqualFold(toolType, xaiCustomToolType)
if !isFunction && !isNormalizedCustom {
return false
}
toolName := strings.TrimSpace(tool.Get("name").String())
qualifiedAutomationName := xaiCodexAppNamespaceName + "__" + xaiAutomationUpdateToolName
if isFunction && (strings.EqualFold(toolName, qualifiedAutomationName) ||
(strings.EqualFold(strings.TrimSpace(namespaceName), xaiCodexAppNamespaceName) &&
strings.EqualFold(toolName, xaiAutomationUpdateToolName))) {
return true
}
parameters := tool.Get("parameters")
for _, unionName := range []string{"anyOf", "oneOf"} {
union := parameters.Get(unionName)
if !union.IsArray() {
continue
}
for _, branch := range union.Array() {
if !xaiSchemaTypeIsObjectOnly(branch.Get("type")) {
return true
}
}
}
return false
}
func sanitizeXAIInputEncryptedContent(body []byte) []byte {
input := gjson.GetBytes(body, "input")
if !input.Exists() || !input.IsArray() {
return body
}
items := make([]json.RawMessage, 0, len(input.Array()))
changed := false
dropCount := 0
firstReason := ""
firstItemType := ""
for _, item := range input.Array() {
itemType := strings.TrimSpace(item.Get("type").String())
if itemType != "reasoning" && itemType != "compaction" {
items = append(items, json.RawMessage(item.Raw))
continue
}
encryptedContent := item.Get("encrypted_content")
if !encryptedContent.Exists() {
items = append(items, json.RawMessage(item.Raw))
continue
}
reason := ""
switch encryptedContent.Type {
case gjson.String:
if _, err := signature.InspectGrokEncryptedContent(encryptedContent.String()); err != nil {
reason = err.Error()
}
case gjson.Null:
reason = "encrypted_content is null"
default:
reason = fmt.Sprintf("encrypted_content must be a string, got %s", encryptedContent.Type.String())
}
if reason == "" {
items = append(items, json.RawMessage(item.Raw))
continue
}
if itemType == "compaction" {
changed = true
dropCount++
if firstReason == "" {
firstReason = reason
firstItemType = itemType
}
continue
}
next, err := sjson.DeleteBytes([]byte(item.Raw), "encrypted_content")
if err != nil {
items = append(items, json.RawMessage(item.Raw))
continue
}
items = append(items, json.RawMessage(next))
changed = true
dropCount++
if firstReason == "" {
firstReason = reason
firstItemType = itemType
}
}
if !changed {
return body
}
rawInput, err := json.Marshal(items)
if err != nil {
return body
}
updated, err := sjson.SetRawBytes(body, "input", rawInput)
if err != nil {
return body
}
if dropCount > 0 {
log.WithFields(log.Fields{
"component": "xai_encrypted_content_sanitizer",
"dropped": dropCount,
"first_item_type": firstItemType,
"first_reason": firstReason,
}).Debug("xai executor: removed invalid encrypted_content before upstream")
}
return mergeAdjacentXAIInputReasoningSummaries(updated)
}
func normalizeXAIInputReasoningItems(body []byte) []byte {
input := gjson.GetBytes(body, "input")
if !input.Exists() || !input.IsArray() {
return body
}
updated := body
for i, item := range input.Array() {
if item.Get("type").String() != "reasoning" {
continue
}
contentPath := fmt.Sprintf("input.%d.content", i)
if content := gjson.GetBytes(updated, contentPath); content.Exists() && content.Type == gjson.Null {
updatedBody, errDel := sjson.DeleteBytes(updated, contentPath)
if errDel != nil {
return body
}
updated = updatedBody
}
encryptedContentPath := fmt.Sprintf("input.%d.encrypted_content", i)
if encryptedContent := gjson.GetBytes(updated, encryptedContentPath); encryptedContent.Exists() && encryptedContent.Type == gjson.Null {
updatedBody, errDel := sjson.DeleteBytes(updated, encryptedContentPath)
if errDel != nil {
return body
}
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
}
// xaiSupportsReasoningEffort reports whether the model accepts Responses API
// reasoning.effort. Capability comes from model registry thinking metadata
// (static models.json and dynamic registrations), not a hard-coded name allowlist.
func xaiSupportsReasoningEffort(model string) bool {
name := strings.ToLower(strings.TrimSpace(thinking.ParseSuffix(model).ModelName))
if idx := strings.LastIndex(name, "/"); idx >= 0 {
name = name[idx+1:]
}
if name == "" {
return false
}
info := registry.LookupModelInfo(name, "xai")
if info == nil || info.Thinking == nil {
return false
}
return len(info.Thinking.Levels) > 0
}
func xaiNormalizeReasoningSummaryEventLine(line []byte, eventName string) []byte {
if eventName == "" && bytes.HasPrefix(line, xaiEventTag) {
eventName = strings.TrimSpace(string(line[len(xaiEventTag):]))
}
eventName = xaiNormalizeReasoningSummaryEventName(eventName)
if eventName == "" {
return bytes.Clone(line)
}
return []byte("event: " + eventName)
}
func xaiNormalizeReasoningSummaryEventName(eventName string) string {
switch eventName {
case "response.reasoning_text.delta":
return "response.reasoning_summary_text.delta"
case "response.reasoning_text.done":
return "response.reasoning_summary_part.done"
default:
return eventName
}
}
func xaiNormalizeReasoningSummaryData(eventData []byte) []byte {
if len(eventData) == 0 || !gjson.ValidBytes(eventData) {
return eventData
}
normalized := eventData
switch gjson.GetBytes(normalized, "type").String() {
case "response.reasoning_text.delta":
normalized, _ = sjson.SetBytes(normalized, "type", "response.reasoning_summary_text.delta")
normalized = xaiNormalizeReasoningSummaryIndex(normalized)
case "response.reasoning_text.done":
normalized, _ = sjson.SetBytes(normalized, "type", "response.reasoning_summary_part.done")
normalized, _ = sjson.SetBytes(normalized, "part.type", "summary_text")
if text := gjson.GetBytes(normalized, "text"); text.Exists() {
normalized, _ = sjson.SetBytes(normalized, "part.text", text.String())
}
normalized, _ = sjson.DeleteBytes(normalized, "text")
normalized = xaiNormalizeReasoningSummaryIndex(normalized)
case "response.content_part.added":
if gjson.GetBytes(normalized, "part.type").String() == "reasoning_text" {
normalized, _ = sjson.SetBytes(normalized, "type", "response.reasoning_summary_part.added")
normalized, _ = sjson.SetBytes(normalized, "part.type", "summary_text")
normalized = xaiNormalizeReasoningSummaryIndex(normalized)
}
case "response.content_part.done":
if gjson.GetBytes(normalized, "part.type").String() == "reasoning_text" {
normalized, _ = sjson.SetBytes(normalized, "type", "response.reasoning_summary_part.done")
normalized, _ = sjson.SetBytes(normalized, "part.type", "summary_text")
normalized = xaiNormalizeReasoningSummaryIndex(normalized)
}
}
if item := gjson.GetBytes(normalized, "item"); item.Exists() && item.Type == gjson.JSON {
updatedItem := xaiNormalizeReasoningOutputItem([]byte(item.Raw))
if !bytes.Equal(updatedItem, []byte(item.Raw)) {
normalized, _ = sjson.SetRawBytes(normalized, "item", updatedItem)
}
}
if output := gjson.GetBytes(normalized, "response.output"); output.IsArray() {
updatedOutput, changed := xaiNormalizeReasoningOutputItems(output.Array())
if changed {
normalized, _ = sjson.SetRawBytes(normalized, "response.output", updatedOutput)
}
}
return normalized
}
func xaiNormalizeReasoningSummaryDataEvents(eventData []byte) [][]byte {
if len(eventData) == 0 || !gjson.ValidBytes(eventData) {
return [][]byte{eventData}
}
if gjson.GetBytes(eventData, "type").String() != "response.reasoning_text.done" {
return [][]byte{xaiNormalizeReasoningSummaryData(eventData)}
}
textDone, _ := sjson.SetBytes(eventData, "type", "response.reasoning_summary_text.done")
textDone = xaiNormalizeReasoningSummaryIndex(textDone)
partDone := xaiNormalizeReasoningSummaryData(eventData)
return [][]byte{textDone, partDone}
}
func xaiNormalizeReasoningSummaryIndex(eventData []byte) []byte {
contentIndex := gjson.GetBytes(eventData, "content_index")
if contentIndex.Exists() && contentIndex.Raw != "" && !gjson.GetBytes(eventData, "summary_index").Exists() {
eventData, _ = sjson.SetRawBytes(eventData, "summary_index", []byte(contentIndex.Raw))
}
eventData, _ = sjson.DeleteBytes(eventData, "content_index")
return eventData
}
func xaiNormalizeReasoningOutputItems(items []gjson.Result) ([]byte, bool) {
var buf bytes.Buffer
buf.WriteByte('[')
changed := false
for i, item := range items {
if i > 0 {
buf.WriteByte(',')
}
updatedItem := xaiNormalizeReasoningOutputItem([]byte(item.Raw))
if !bytes.Equal(updatedItem, []byte(item.Raw)) {
changed = true
}
buf.Write(updatedItem)
}
buf.WriteByte(']')
return buf.Bytes(), changed
}
func xaiNormalizeReasoningOutputItem(item []byte) []byte {
if !gjson.ValidBytes(item) || gjson.GetBytes(item, "type").String() != "reasoning" {
return item
}
normalized := item
if summary := gjson.GetBytes(normalized, "summary"); summary.IsArray() {
updatedSummary, changed := xaiNormalizeReasoningSummaryItems(summary.Array())
if changed {
normalized, _ = sjson.SetRawBytes(normalized, "summary", updatedSummary)
}
}
content := gjson.GetBytes(normalized, "content")
if !content.IsArray() {
return normalized
}
summaryItems := make([]gjson.Result, 0, len(content.Array()))
for _, part := range content.Array() {
if part.Get("type").String() == "reasoning_text" {
summaryItems = append(summaryItems, part)
}
}
if len(summaryItems) == 0 {
return normalized
}
updatedSummary, _ := xaiNormalizeReasoningSummaryItems(summaryItems)
normalized, _ = sjson.SetRawBytes(normalized, "summary", updatedSummary)
normalized, _ = sjson.DeleteBytes(normalized, "content")
return normalized
}
func xaiNormalizeReasoningSummaryItems(items []gjson.Result) ([]byte, bool) {
var buf bytes.Buffer
buf.WriteByte('[')
changed := false
for i, item := range items {
if i > 0 {
buf.WriteByte(',')
}
itemRaw := []byte(item.Raw)
if item.Get("type").String() == "reasoning_text" {
var errSet error
itemRaw, errSet = sjson.SetBytes(itemRaw, "type", "summary_text")
if errSet == nil {
changed = true
}
}
buf.Write(itemRaw)
}
buf.WriteByte(']')
return buf.Bytes(), changed
}
func xaiCollectOutputItemDone(eventData []byte, outputItemsByIndex map[int64][]byte, outputItemsFallback *[][]byte) {
itemResult := gjson.GetBytes(eventData, "item")
if !itemResult.Exists() || itemResult.Type != gjson.JSON {
return
}
outputIndexResult := gjson.GetBytes(eventData, "output_index")
if outputIndexResult.Exists() {
outputItemsByIndex[outputIndexResult.Int()] = []byte(itemResult.Raw)
return
}
*outputItemsFallback = append(*outputItemsFallback, []byte(itemResult.Raw))
}
func xaiPatchCompletedOutput(eventData []byte, outputItemsByIndex map[int64][]byte, outputItemsFallback [][]byte) []byte {
outputResult := gjson.GetBytes(eventData, "response.output")
shouldPatchOutput := (!outputResult.Exists() || !outputResult.IsArray() || len(outputResult.Array()) == 0) && (len(outputItemsByIndex) > 0 || len(outputItemsFallback) > 0)
if !shouldPatchOutput {
return eventData
}
indexes := make([]int64, 0, len(outputItemsByIndex))
for idx := range outputItemsByIndex {
indexes = append(indexes, idx)
}
sort.Slice(indexes, func(i, j int) bool {
return indexes[i] < indexes[j]
})
outputArray := []byte("[]")
var buf bytes.Buffer
buf.WriteByte('[')
wrote := false
for _, idx := range indexes {
if wrote {
buf.WriteByte(',')
}
buf.Write(outputItemsByIndex[idx])
wrote = true
}
for _, item := range outputItemsFallback {
if wrote {
buf.WriteByte(',')
}
buf.Write(item)
wrote = true
}
buf.WriteByte(']')
if wrote {
outputArray = buf.Bytes()
}
patched, _ := sjson.SetRawBytes(eventData, "response.output", outputArray)
return patched
}
// xaiFreeUsageExhaustedCooldown is the free-tier rolling window advertised by
// cli-chat-proxy ("Usage resets over a rolling 24-hour window").
const xaiFreeUsageExhaustedCooldown = 24 * time.Hour
// xaiStatusErr wraps upstream error bodies so free-tier exhaustion
// (subscription:free-usage-exhausted) carries a 24h RetryAfter hint for
// auth cooldown / account rotation. Generic 429s stay without an explicit
// retry hint so conductor backoff still applies.
func xaiStatusErr(code int, body []byte) statusErr {
err := statusErr{code: code, msg: string(body)}
if code != http.StatusTooManyRequests || len(body) == 0 {
return err
}
codeStr := strings.ToLower(gjson.GetBytes(body, "code").String())
msg := strings.ToLower(gjson.GetBytes(body, "error").String())
if msg == "" {
msg = strings.ToLower(string(body))
}
if strings.Contains(codeStr, "free-usage-exhausted") ||
strings.Contains(msg, "free-usage-exhausted") ||
strings.Contains(msg, "included free usage") {
d := xaiFreeUsageExhaustedCooldown
err.retryAfter = &d
}
return err
}