mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 06:35:00 +08:00
The xAI executor only switched on response.completed, so any turn that
ended with the spec-correct response.incomplete terminal event fell out
of the loop. Non-streaming requests were reported to the client as a 408
("stream disconnected before response.completed") even though the
upstream request succeeded, and because 408 is retryable it burned
credential rotations against a healthy pool. Streaming requests forwarded
the terminal event without patching the collected output items or
publishing usage.
Accept response.incomplete alongside response.completed in both paths,
mirroring the Codex executor, and keep the reasoning replay cache gated
on response.completed since a truncated turn has no replayable state.
409 lines
15 KiB
Go
409 lines
15 KiB
Go
package executor
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
|
|
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"
|
|
)
|
|
|
|
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, opts, 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, opts.Headers)
|
|
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
|
|
}
|
|
eventType := gjson.GetBytes(eventData, "type").String()
|
|
switch eventType {
|
|
case "response.output_item.done":
|
|
xaiCollectOutputItemDone(eventData, outputItemsByIndex, &outputItemsFallback)
|
|
case "response.completed", "response.incomplete":
|
|
if detail, ok := helps.ParseCodexUsage(eventData); ok {
|
|
reporter.Publish(ctx, detail)
|
|
}
|
|
completedData := xaiPatchCompletedOutput(eventData, outputItemsByIndex, outputItemsFallback)
|
|
completedData = xaiNormalizeReasoningSummaryData(completedData)
|
|
if eventType == "response.completed" {
|
|
// A truncated turn carries no replayable terminal state, so only a
|
|
// completed response may refresh the reasoning replay cache.
|
|
cacheXAIReasoningReplayFromCompleted(ctx, prepared.replayScope, completedData)
|
|
}
|
|
var param any
|
|
out := sdktranslator.TranslateNonStream(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, completedData, ¶m)
|
|
if prepared.responseFormat == sdktranslator.FormatOpenAIResponse {
|
|
out = helps.EnsureResponsesUsageDetails(out)
|
|
}
|
|
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 or response.incomplete"}
|
|
}
|
|
|
|
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, ¶m)
|
|
if prepared.responseFormat == sdktranslator.FormatOpenAIResponse {
|
|
out = helps.EnsureResponsesUsageDetails(out)
|
|
}
|
|
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")
|
|
for _, field := range []string{"max_output_tokens", "temperature", "top_p", "top_k", "stop"} {
|
|
prepared.body, _ = sjson.DeleteBytes(prepared.body, field)
|
|
}
|
|
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, opts.Headers)
|
|
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")
|
|
requestModelName := ""
|
|
if prepared != nil {
|
|
requestModelName = gjson.GetBytes(prepared.originalPayload, "model").String()
|
|
if requestModelName == "" {
|
|
requestModelName = prepared.baseModel
|
|
}
|
|
}
|
|
if requestModelName == "" {
|
|
requestModelName = gjson.GetBytes(compactData, "model").String()
|
|
}
|
|
if requestModelName != "" {
|
|
createdResponse, _ = sjson.SetBytes(createdResponse, "model", requestModelName)
|
|
inProgressResponse, _ = sjson.SetBytes(inProgressResponse, "model", requestModelName)
|
|
}
|
|
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)
|
|
completedPayload = helps.EnsureResponsesUsageDetails(completedPayload)
|
|
|
|
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
|
|
}
|