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.
179 lines
6.4 KiB
Go
179 lines
6.4 KiB
Go
package executor
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"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"
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
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, 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 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, ¶m, 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", "response.incomplete":
|
|
if detail, ok := helps.ParseCodexUsage(eventData); ok {
|
|
reporter.Publish(ctx, detail)
|
|
}
|
|
eventData = xaiPatchCompletedOutput(eventData, outputItemsByIndex, outputItemsFallback)
|
|
eventData = xaiNormalizeReasoningSummaryData(eventData)
|
|
if normalizedEventName == "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, 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
|
|
}
|