Files
CLIProxyAPI/internal/runtime/executor/meta_executor_stream.go
Dylan 9d463e39f0 fix(meta): measure streaming TTFT on effective token frames
Meta streaming armed protocol-aware TTFT tracking but never observed token
events, so usage records kept the first-body-byte fallback even when
substantive output arrived later. Observe Responses events before failure
publication and translation using the existing shared helper.

Add deterministic virtual-clock coverage for content, terminal events,
error fallbacks, downstream translation, and non-streaming timing.

Fixes #6664
2026-10-20 16:57:14 +08:00

276 lines
9.5 KiB
Go

package executor
import (
"bufio"
"bytes"
"context"
"errors"
"io"
"net/http"
"strings"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
)
func (e *MetaExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) {
ctx = helps.EnsureSessionContext(ctx, opts, req.Payload)
if opts.Alt == "responses/compact" {
return nil, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"}
}
if errExpand := helps.ExpandResponsesCompactionCapsules(&req, &opts, "meta"); errExpand != nil {
return nil, statusErr{code: http.StatusBadRequest, msg: errExpand.Error()}
}
if helps.HasResponsesCompactionTriggerRequest(req, opts) {
return e.executeMetaCompactionStream(ctx, auth, req, opts)
}
enriched, errAuth := e.ensureAuth(ctx, auth)
if errAuth != nil {
return nil, errAuth
}
prepared, errPrepare := e.prepareResponsesRequest(ctx, enriched, req, opts, true)
if errPrepare != nil {
return nil, errPrepare
}
reporter := helps.NewExecutorUsageReporter(ctx, e, prepared.baseModel, enriched)
defer reporter.TrackFailure(ctx, &err)
reporter.SetTranslatedReasoningEffort(prepared.body, prepared.to.String())
baseURL, token := metaCreds(enriched)
if strings.TrimSpace(baseURL) == "" {
return nil, statusErr{code: http.StatusUnauthorized, msg: "meta executor: missing provider baseURL"}
}
url := strings.TrimSuffix(baseURL, "/") + "/responses"
httpReq, errRequest := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(prepared.body))
if errRequest != nil {
return nil, errRequest
}
applyMetaAPIHeaders(httpReq, enriched, token, true, opts.Headers)
e.recordMetaRequest(ctx, enriched, url, httpReq.Header.Clone(), prepared.body)
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, enriched, 0)
httpClient = reporter.TrackHTTPClientRoundTripOnly(httpClient)
httpResp, errDo := httpClient.Do(httpReq)
if errDo != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errDo)
return nil, errDo
}
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("meta 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, wrapMetaUpstreamError(httpResp.StatusCode, data)
}
out := make(chan cliproxyexecutor.StreamChunk)
go func() {
defer close(out)
defer func() {
if errClose := httpResp.Body.Close(); errClose != nil {
log.Errorf("meta 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 streamUsage helps.StreamUsageBuffer
defer streamUsage.Publish(ctx, reporter)
var param any
outputItemsByIndex := make(map[int64][]byte)
var outputItemsFallback [][]byte
var terminalDelivered bool
emitTranslatedLine := func(translatedLine []byte) bool {
lines, errBridge := prepared.applyPatch.Stream(translatedLine)
if errBridge != nil {
streamUsage.PublishFailure(ctx, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage})
}
var chunks [][]byte
for _, line := range lines {
chunks = append(chunks, helps.TranslateStreamWithClaudeInputTokens(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, line, &param, claudeInputTokens)...)
}
helps.RecordApplyPatchStreamFailureWithUsage(ctx, param, reporter, &streamUsage, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage})
for i := range chunks {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}:
case <-ctx.Done():
return false
}
}
if helps.StopApplyPatchStreamWithUsage(ctx, param, reporter, &streamUsage, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) {
return false
}
if errBridge != nil {
errBridge = statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}
streamUsage.PublishFailure(ctx, reporter, errBridge)
select {
case out <- cliproxyexecutor.StreamChunk{Err: errBridge}:
case <-ctx.Done():
}
return false
}
return true
}
var pendingEventLine []byte
var filteringSubscriptionUsage bool
var skipFilteredBlankLine bool
for scanner.Scan() {
line := scanner.Bytes()
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
trimmedLine := bytes.TrimSpace(line)
if bytes.HasPrefix(trimmedLine, metaEventTag) {
eventName := bytes.TrimSpace(trimmedLine[len(metaEventTag):])
if bytes.Equal(eventName, []byte(metaSubscriptionUsageEventType)) {
if pendingEventLine != nil {
if !emitTranslatedLine(pendingEventLine) {
return
}
pendingEventLine = nil
}
filteringSubscriptionUsage = true
skipFilteredBlankLine = true
continue
}
filteringSubscriptionUsage = false
skipFilteredBlankLine = false
if pendingEventLine != nil {
if !emitTranslatedLine(pendingEventLine) {
return
}
}
pendingEventLine = bytes.Clone(line)
continue
}
if !bytes.HasPrefix(trimmedLine, dataTag) {
if len(trimmedLine) == 0 {
filteringSubscriptionUsage = false
if skipFilteredBlankLine {
skipFilteredBlankLine = false
continue
}
}
if filteringSubscriptionUsage {
continue
}
skipFilteredBlankLine = false
if pendingEventLine != nil {
if !emitTranslatedLine(pendingEventLine) {
return
}
pendingEventLine = nil
}
if !emitTranslatedLine(bytes.Clone(line)) {
return
}
continue
}
eventData := bytes.TrimSpace(trimmedLine[len(dataTag):])
eventType := gjson.GetBytes(eventData, "type").String()
if isMetaSubscriptionUsageEvent(eventType, eventData) {
pendingEventLine = nil
filteringSubscriptionUsage = false
skipFilteredBlankLine = true
continue
}
if filteringSubscriptionUsage {
if eventType == "" {
pendingEventLine = nil
if gjson.ValidBytes(eventData) {
filteringSubscriptionUsage = false
}
skipFilteredBlankLine = true
continue
}
filteringSubscriptionUsage = false
}
skipFilteredBlankLine = false
// Observe upstream content before translation or failure publication.
helps.ObserveResponsesTokenEvent(reporter, eventData)
if errEvent := metaStreamEventError(eventData); errEvent != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errEvent)
reporter.PublishFailure(ctx, errEvent)
select {
case out <- cliproxyexecutor.StreamChunk{Err: errEvent}:
case <-ctx.Done():
}
return
}
switch eventType {
case "response.output_item.done":
xaiCollectOutputItemDone(eventData, outputItemsByIndex, &outputItemsFallback)
case "response.completed", "response.incomplete":
if detail, ok := helps.ParseCodexUsage(eventData); ok {
streamUsage.Observe(detail, true)
}
eventData = patchCodexCompletedOutput(eventData, outputItemsByIndex, outputItemsFallback)
}
if pendingEventLine != nil {
if !emitTranslatedLine(pendingEventLine) {
return
}
pendingEventLine = nil
}
if !emitTranslatedLine(append([]byte("data: "), eventData...)) {
return
}
if eventType == "response.completed" || eventType == "response.incomplete" {
terminalDelivered = true
}
}
if pendingEventLine != nil {
if !emitTranslatedLine(pendingEventLine) {
return
}
pendingEventLine = nil
}
finishEvents, errFinish := prepared.applyPatch.FinishStream()
if errFinish != nil {
streamUsage.PublishFailure(ctx, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage})
}
for _, event := range finishEvents {
for _, chunk := range helps.TranslateStreamWithClaudeInputTokens(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, event, &param, claudeInputTokens) {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: chunk}:
case <-ctx.Done():
return
}
}
}
if errFinish != nil {
errFinish = statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}
streamUsage.PublishFailure(ctx, reporter, errFinish)
select {
case out <- cliproxyexecutor.StreamChunk{Err: errFinish}:
case <-ctx.Done():
}
return
}
if errScan := scanner.Err(); errScan != nil {
if terminalDelivered && errors.Is(errScan, context.Canceled) {
return
}
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
}