mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-06 16:15:50 +08:00
A caller can put a {"role":"system"} turn inside messages. Models older than
the role=system turn reject it outright, verified against api.anthropic.com on
both /v1/messages and /v1/messages/count_tokens:
400 role 'system' is not supported on this model
claude-haiku-4-5, claude-sonnet-4-5, claude-sonnet-4-6 and claude-opus-4-6
answer that way, while claude-sonnet-5 and claude-opus-5 accept the turn.
claudeLegacySystemReminderModels already enumerates that boundary, which is why
claudeCodeCLIBetas withholds mid-conversation-system-2026-04-07 for those
models, but caller-provided turns were forwarded unchanged and always spent an
upstream call on a guaranteed rejection.
The native client does not produce the pairing either: it gates the turn on the
model. In 314 captured native requests the turn appears only on
claude-opus-5 and claude-sonnet-5, and on none of the 43 requests addressed to
a model in that set. That is an observation about the captures rather than a
proof about the upstream, so it only corroborates the measured rejection.
Validate the finished body, and only inside the evidence that produced the
rule:
- The check runs after the body is finalized and before
http.NewRequestWithContext. Payload rules can rewrite model and messages long
after translation, so an earlier check would not describe what is sent.
- Only Anthropic's first-party origin is covered, matching the reasoning
shouldUseClaudeUpstreamTokenCount already applies to count_tokens. A
third-party gateway may map these model IDs onto something that accepts the
turn and therefore decides for itself.
- A confirmed native caller keeps the passthrough. It gates the turn itself, so
its body is forwarded untouched and the upstream error reaches it unchanged.
- Unknown and future model IDs stay optimistic and are forwarded, matching how
checkSystemInstructions treats them.
- rebuild_mid_system_message still folds caller turns into the system slot; the
final check therefore preserves the explicit escape hatch.
Cloaking adds one extra ordering case: it can place a caller's top-level system
prompt into a role=system turn for a modern model before a payload rule changes
the model to legacy. Track only the exact contiguous turns that CPA inserted,
using both their position and the corresponding message-count increase as
provenance. After payload rules settle the model, replay those turns through the
existing legacy <system-reminder> path. Pre-existing caller turns, even if they
have identical content, remain caller-owned and are still rejected. If payload
rules also rewrite the tracked messages, reconciliation fails closed and final
validation returns 400 rather than guessing provenance.
The 400 is request-scoped like claudeCallerSystemBlockError: the invalid
body/model pairing is independent of first-party credential health, so no
credential is cooled or retried.
Translated requests never carry the pairing, because every non-Claude source
format hoists system content into the top-level system field. Tests pin that for
OpenAI, Gemini, Responses and Interactions, and separately drive Execute,
ExecuteStream and CountTokens through an injected first-party transport.
475 lines
18 KiB
Go
475 lines
18 KiB
Go
package executor
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
|
|
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"
|
|
)
|
|
|
|
func (e *ClaudeExecutor) 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.StatusNotImplemented, msg: "/responses/compact not supported"}
|
|
}
|
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
|
upstreamModel := e.upstreamModel(baseModel)
|
|
|
|
apiKey, baseURL := claudeCreds(auth)
|
|
if baseURL == "" {
|
|
baseURL = "https://api.anthropic.com"
|
|
}
|
|
url := fmt.Sprintf("%s/v1/messages?beta=true", baseURL)
|
|
oauthToken := isClaudeOAuthToken(apiKey)
|
|
defer func() {
|
|
if cancelErr := newClaudeOAuthCancellationError(ctx, oauthToken, err); cancelErr != nil {
|
|
err = cancelErr
|
|
}
|
|
}()
|
|
cchSigning := claudeCCHSigningEnabled(apiKey, claudeCCHUpstreamAnthropic, url)
|
|
|
|
reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
|
|
defer reporter.TrackFailure(ctx, &err)
|
|
from := opts.SourceFormat
|
|
responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts)
|
|
to := sdktranslator.FromString("claude")
|
|
var replayScope claudeThinkingReplayScope
|
|
if claudeThinkingReplayEnabled(auth, req, opts) {
|
|
req, replayScope = prepareClaudeThinkingReplayRequest(ctx, auth, req, opts)
|
|
}
|
|
defer func() {
|
|
if err != nil && replayScope.replayApplied && shouldClearKimiThinkingReplayAfterError(err) {
|
|
clearClaudeThinkingReplayContent(ctx, replayScope)
|
|
}
|
|
}()
|
|
originalPayloadSource := req.Payload
|
|
if len(opts.OriginalRequest) > 0 {
|
|
originalPayloadSource = opts.OriginalRequest
|
|
}
|
|
originalPayload := originalPayloadSource
|
|
incomingHeaders, claudeCodeDetection := detectIncomingClaudeCodeRequest(ctx, opts.Headers, originalPayload, false, e.cfg)
|
|
confirmedClaudeCode := claudeCodeDetection.Confirmed
|
|
claudeSessionID := ""
|
|
if oauthToken {
|
|
claudeSessionID = helps.ClaudeAgentSessionUUIDForRequest(incomingHeaders, originalPayload, req.Payload, confirmedClaudeCode, opts.Metadata, req.Metadata)
|
|
}
|
|
originalTranslated := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true, helps.APIKeyModelIsCompat(req))
|
|
body := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true, helps.APIKeyModelIsCompat(req))
|
|
body = helps.SetStringIfDifferent(body, "model", upstreamModel)
|
|
|
|
body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if rebuildMidSystemMessageEnabled(e.cfg, auth) {
|
|
body = rebuildMidSystemMessagesToTopLevel(body)
|
|
}
|
|
|
|
// Apply cloaking (system prompt injection, fake user ID, sensitive word obfuscation)
|
|
// based on client type and configuration.
|
|
bodyBeforeCloaking := body
|
|
var cloaked bool
|
|
body, cloaked, err = applyCloaking(
|
|
ctx,
|
|
e.cfg,
|
|
auth,
|
|
body,
|
|
apiKey,
|
|
confirmedClaudeCode,
|
|
cchSigning,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
systemPlacementState := captureClaudeCodeSystemPlacement(bodyBeforeCloaking, body, cloaked)
|
|
// Only the Messages endpoint on Anthropic itself was captured; count_tokens
|
|
// keeps its own shape and other gateways never see this field.
|
|
diagnosticsState := claudeDiagnosticsRequestState{}
|
|
contextManagementState := claudeCodeContextManagementState{
|
|
eligible: cloaked && isAnthropicUpstreamBase(baseURL),
|
|
callerOwned: gjson.GetBytes(body, "context_management").Exists(),
|
|
}
|
|
if contextManagementState.eligible {
|
|
body, contextManagementState.automaticallyInjected = injectClaudeCodeContextManagement(body)
|
|
if oauthToken {
|
|
body, diagnosticsState = injectClaudeDiagnostics(body, auth, claudeSessionID)
|
|
}
|
|
}
|
|
|
|
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
|
|
requestPath := helps.PayloadRequestPath(opts)
|
|
body, contextManagementState.payloadRuleTouched = helps.ApplyPayloadConfigWithRequestTracked(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers, "context_management")
|
|
body = reconcileClaudeCodeSystemPlacementAfterPayload(body, systemPlacementState)
|
|
body = ensureModelMaxTokens(body, baseModel)
|
|
|
|
// Disable thinking if tool_choice forces tool use (Anthropic API constraint)
|
|
body = disableThinkingIfToolChoiceForced(body)
|
|
body = reconcileClaudeCodeContextManagement(body, contextManagementState)
|
|
body = normalizeClaudeSamplingForUpstream(body, confirmedClaudeCode)
|
|
|
|
// Default cache_control for translated entrypoints (Responses/Chat/Gemini) and other
|
|
// non-native callers. Confirmed native Claude Code owns its marker placement and must
|
|
// not be rewritten. Cloaked requests always run section-independent ensure so cloaking's
|
|
// first-user marker cannot suppress system/latest-user breakpoints.
|
|
// cloaked and confirmedClaudeCode are mutually exclusive: resolveClaudeWirePolicy
|
|
// forces Cloak off for a confirmed native client.
|
|
cpaOwnsCacheControl := shouldEnsureCacheControl(body, cloaked, confirmedClaudeCode)
|
|
if cpaOwnsCacheControl {
|
|
body = ensureCacheControl(body)
|
|
}
|
|
|
|
// Enforce Anthropic's cache_control block limit (max 4 breakpoints per request).
|
|
body = enforceCacheControlLimit(body, 4)
|
|
|
|
// Native selects the 1h cache pool only for OAuth credentials and pairs it with
|
|
// extended-cache-ttl-2025-04-11, which claudeCodeCLIBetas emits on exactly the
|
|
// same credential condition. Upgrading after placement is settled mirrors the
|
|
// native ttl helper.
|
|
//
|
|
// This runs only while CPA owns placement, and it then owns the ttl of every
|
|
// breakpoint it can reach: a marker carrying no ttl is the wire default, not an
|
|
// opt-in to 5m, so a cloaked caller's bare {"type":"ephemeral"} is upgraded too.
|
|
// Only a ttl the caller wrote out explicitly survives, because
|
|
// upgradeClaudeCacheControlTTL skips any block that already has one.
|
|
if cpaOwnsCacheControl && claudeCredentialUsesOAuth(auth, apiKey) {
|
|
body = upgradeClaudeCacheControlTTL(body, claudeCacheControlTTL1h)
|
|
}
|
|
|
|
// Normalize TTL values to prevent ordering violations under prompt-caching-scope-2026-01-05.
|
|
body = normalizeCacheControlTTL(body)
|
|
|
|
// Extract betas from body and convert to header
|
|
var extraBetas []string
|
|
extraBetas, body = extractAndRemoveBetas(body)
|
|
bodyForTranslation := body
|
|
bodyForUpstream := body
|
|
var oauthToolNamesReverseMap map[string]string
|
|
if oauthToken && cloaked {
|
|
mcpAliases := resolveClaudeMCPAliasOptions(ctx)
|
|
bodyForUpstream, oauthToolNamesReverseMap = prepareClaudeOAuthToolNamesForUpstream(bodyForUpstream, mcpAliases)
|
|
}
|
|
bodyForUpstream = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, bodyForUpstream, baseModel, helps.APIKeyModelIsCompat(req))
|
|
if oauthToken {
|
|
bodyForUpstream, _, err = helps.ApplyClaudeCredentialMetadata(bodyForUpstream, auth, claudeSessionID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("apply Claude credential metadata: %w", err)
|
|
}
|
|
}
|
|
cchBilling := ""
|
|
if cchSigning {
|
|
if !claudeCodeDetection.HelperProfile || claudeBodyNeedsBillingFallback(bodyForUpstream) {
|
|
cchBilling = claudeCCHFallbackBillingHeader(ctx, e.cfg, bodyForUpstream, claudeCodeDetection.Entrypoint)
|
|
}
|
|
bodyForUpstream, err = finalizeAnthropicMessagesBodyCCH(bodyForUpstream, cchBilling)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("finalize Claude CCH: %w", err)
|
|
}
|
|
}
|
|
// Runs on the finished body: payload rules can rewrite model and messages
|
|
// long after translation, so an earlier check would not describe the request
|
|
// that is about to be sent.
|
|
if errMidSystem := validateClaudeMidSystemMessageModel(bodyForUpstream, confirmedClaudeCode, isAnthropicUpstreamBase(baseURL)); errMidSystem != nil {
|
|
return nil, errMidSystem
|
|
}
|
|
reporter.SetTranslatedReasoningEffort(bodyForUpstream, to.String())
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyForUpstream))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if errHeaders := applyClaudeHeadersWithNativeProfile(
|
|
httpReq,
|
|
auth,
|
|
apiKey,
|
|
true,
|
|
extraBetas,
|
|
bodyForUpstream,
|
|
e.cfg,
|
|
incomingHeaders,
|
|
confirmedClaudeCode && !cloaked,
|
|
claudeCodeDetection.HelperProfile,
|
|
claudeSessionID,
|
|
); errHeaders != nil {
|
|
return nil, errHeaders
|
|
}
|
|
fastRequest := isAnthropicUpstreamBase(baseURL) && claudeRequestIsFast(httpReq, bodyForUpstream)
|
|
authID, authLabel, authType, authValue := claudeAuthLogIdentity(auth)
|
|
helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{
|
|
URL: url,
|
|
Method: http.MethodPost,
|
|
Headers: httpReq.Header.Clone(),
|
|
Body: bodyForUpstream,
|
|
Provider: e.upstreamRequestLogProvider(),
|
|
AuthID: authID,
|
|
AuthLabel: authLabel,
|
|
AuthType: authType,
|
|
AuthValue: authValue,
|
|
})
|
|
|
|
httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0)
|
|
httpClient = reporter.TrackHTTPClient(httpClient)
|
|
httpResp, err := doClaudeUpstreamRequest(httpClient, httpReq)
|
|
if err != nil {
|
|
helps.RecordAPIResponseError(ctx, e.cfg, err)
|
|
return nil, wrapClaudeFastRequestError(fastRequest, 0, err)
|
|
}
|
|
helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
// Decompress error responses — pass the Content-Encoding value (may be empty)
|
|
// and let decodeResponseBody handle both header-declared and magic-byte-detected
|
|
// compression. This keeps error-path behaviour consistent with the success path.
|
|
errBody, decErr := decodeResponseBody(httpResp.Body, claudeResponseContentEncoding(httpResp.Header))
|
|
if decErr != nil {
|
|
helps.RecordAPIResponseError(ctx, e.cfg, decErr)
|
|
msg := fmt.Sprintf("failed to decode error response body: %v", decErr)
|
|
helps.LogWithRequestID(ctx).Warn(msg)
|
|
return nil, wrapClaudeFastRequestError(fastRequest, httpResp.StatusCode, statusErr{code: httpResp.StatusCode, msg: msg})
|
|
}
|
|
b, readErr := io.ReadAll(errBody)
|
|
if readErr != nil {
|
|
helps.RecordAPIResponseError(ctx, e.cfg, readErr)
|
|
msg := fmt.Sprintf("failed to read error response body: %v", readErr)
|
|
helps.LogWithRequestID(ctx).Warn(msg)
|
|
b = []byte(msg)
|
|
}
|
|
helps.AppendAPIResponseChunk(ctx, e.cfg, b)
|
|
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
|
|
if errClose := errBody.Close(); errClose != nil {
|
|
log.Errorf("response body close error: %v", errClose)
|
|
}
|
|
if fastRequest {
|
|
return nil, newClaudeFastDirectResponseError(httpResp, b)
|
|
}
|
|
return nil, classifyClaudeUpstreamError(httpResp.StatusCode, b)
|
|
}
|
|
decodedBody, err := decodeResponseBody(httpResp.Body, claudeResponseContentEncoding(httpResp.Header))
|
|
if err != nil {
|
|
helps.RecordAPIResponseError(ctx, e.cfg, err)
|
|
if errClose := httpResp.Body.Close(); errClose != nil {
|
|
log.Errorf("response body close error: %v", errClose)
|
|
}
|
|
return nil, wrapClaudeFastRequestError(fastRequest, httpResp.StatusCode, err)
|
|
}
|
|
out := make(chan cliproxyexecutor.StreamChunk, 1)
|
|
go func() {
|
|
defer close(out)
|
|
defer func() {
|
|
if errClose := decodedBody.Close(); errClose != nil {
|
|
log.Errorf("response body close error: %v", errClose)
|
|
}
|
|
}()
|
|
emitCancellation := func(cause error) bool {
|
|
cancelErr := newClaudeOAuthCancellationError(ctx, oauthToken, cause)
|
|
if cancelErr == nil {
|
|
return false
|
|
}
|
|
helps.RecordAPIResponseError(ctx, e.cfg, cancelErr)
|
|
reporter.PublishFailure(ctx, cancelErr)
|
|
select {
|
|
case out <- cliproxyexecutor.StreamChunk{Err: cancelErr}:
|
|
default:
|
|
}
|
|
return true
|
|
}
|
|
emitResponseError := func(errResponse error) {
|
|
errResponse = wrapClaudeFastRequestError(fastRequest, httpResp.StatusCode, errResponse)
|
|
helps.RecordAPIResponseError(ctx, e.cfg, errResponse)
|
|
reporter.PublishFailure(ctx, errResponse)
|
|
select {
|
|
case out <- cliproxyexecutor.StreamChunk{Err: errResponse}:
|
|
case <-ctx.Done():
|
|
}
|
|
}
|
|
|
|
// If the response target is Claude, directly forward complete SSE events without translation.
|
|
if responseFormat == to {
|
|
scanner := bufio.NewScanner(decodedBody)
|
|
scanner.Buffer(nil, 52_428_800) // 50MB
|
|
var event bytes.Buffer
|
|
var upstreamMessageID string
|
|
upstreamCompleted := false
|
|
flushEvent := func() bool {
|
|
if event.Len() == 0 {
|
|
return true
|
|
}
|
|
cloned := bytes.Clone(event.Bytes())
|
|
event.Reset()
|
|
select {
|
|
case out <- cliproxyexecutor.StreamChunk{Payload: cloned}:
|
|
return true
|
|
case <-ctx.Done():
|
|
return false
|
|
}
|
|
}
|
|
for scanner.Scan() {
|
|
line := scanner.Bytes()
|
|
observeClaudeStreamLine(line, &upstreamMessageID, &upstreamCompleted)
|
|
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
|
|
if detail, ok := helps.ParseClaudeStreamUsage(line); ok {
|
|
reporter.Publish(ctx, detail)
|
|
}
|
|
restoredLine, errRestore := restoreClaudeOAuthToolNamesFromStreamLine(line, oauthToolNamesReverseMap)
|
|
if errRestore != nil {
|
|
emitResponseError(fmt.Errorf("restore Claude OAuth tool name from streaming response: %w", errRestore))
|
|
return
|
|
}
|
|
line = e.restoreResponseModel(restoredLine, req.Model)
|
|
event.Write(line)
|
|
event.WriteByte('\n')
|
|
if len(bytes.TrimSpace(line)) == 0 && !flushEvent() {
|
|
emitCancellation(ctx.Err())
|
|
return
|
|
}
|
|
}
|
|
if !flushEvent() {
|
|
emitCancellation(ctx.Err())
|
|
return
|
|
}
|
|
if emitCancellation(scanner.Err()) {
|
|
return
|
|
}
|
|
if errScan := scanner.Err(); errScan != nil {
|
|
errScan = wrapClaudeFastRequestError(fastRequest, httpResp.StatusCode, errScan)
|
|
helps.RecordAPIResponseError(ctx, e.cfg, errScan)
|
|
reporter.PublishFailure(ctx, errScan)
|
|
select {
|
|
case out <- cliproxyexecutor.StreamChunk{Err: errScan}:
|
|
case <-ctx.Done():
|
|
}
|
|
return
|
|
}
|
|
if upstreamCompleted {
|
|
commitClaudeDiagnostics(diagnosticsState, upstreamMessageID)
|
|
}
|
|
return
|
|
}
|
|
|
|
// For other formats, use translation
|
|
scanner := bufio.NewScanner(decodedBody)
|
|
scanner.Buffer(nil, 52_428_800) // 50MB
|
|
var param any
|
|
var upstreamMessageID string
|
|
upstreamCompleted := false
|
|
for scanner.Scan() {
|
|
line := scanner.Bytes()
|
|
observeClaudeStreamLine(line, &upstreamMessageID, &upstreamCompleted)
|
|
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
|
|
if detail, ok := helps.ParseClaudeStreamUsage(line); ok {
|
|
reporter.Publish(ctx, detail)
|
|
}
|
|
restoredLine, errRestore := restoreClaudeOAuthToolNamesFromStreamLine(line, oauthToolNamesReverseMap)
|
|
if errRestore != nil {
|
|
emitResponseError(fmt.Errorf("restore Claude OAuth tool name from streaming response: %w", errRestore))
|
|
return
|
|
}
|
|
line = e.restoreResponseModel(restoredLine, req.Model)
|
|
chunks := sdktranslator.TranslateStream(
|
|
ctx,
|
|
to,
|
|
responseFormat,
|
|
req.Model,
|
|
opts.OriginalRequest,
|
|
bodyForTranslation,
|
|
bytes.Clone(line),
|
|
¶m,
|
|
)
|
|
for i := range chunks {
|
|
select {
|
|
case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}:
|
|
case <-ctx.Done():
|
|
emitCancellation(ctx.Err())
|
|
return
|
|
}
|
|
}
|
|
}
|
|
if emitCancellation(scanner.Err()) {
|
|
return
|
|
}
|
|
if errScan := scanner.Err(); errScan != nil {
|
|
errScan = wrapClaudeFastRequestError(fastRequest, httpResp.StatusCode, errScan)
|
|
helps.RecordAPIResponseError(ctx, e.cfg, errScan)
|
|
reporter.PublishFailure(ctx, errScan)
|
|
select {
|
|
case out <- cliproxyexecutor.StreamChunk{Err: errScan}:
|
|
case <-ctx.Done():
|
|
}
|
|
return
|
|
}
|
|
if upstreamCompleted {
|
|
commitClaudeDiagnostics(diagnosticsState, upstreamMessageID)
|
|
}
|
|
}()
|
|
result := &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}
|
|
if replayScope.valid() {
|
|
result = wrapClaudeThinkingReplayStream(ctx, result, replayScope)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func validateClaudeStreamingResponse(data []byte) error {
|
|
scanner := bufio.NewScanner(bytes.NewReader(data))
|
|
scanner.Buffer(nil, 52_428_800)
|
|
|
|
hasData := false
|
|
hasMessageStart := false
|
|
hasMessageDelta := false
|
|
|
|
for scanner.Scan() {
|
|
line := bytes.TrimSpace(scanner.Bytes())
|
|
if len(line) == 0 || !bytes.HasPrefix(line, []byte("data:")) {
|
|
continue
|
|
}
|
|
payload := bytes.TrimSpace(line[len("data:"):])
|
|
if len(payload) == 0 || bytes.Equal(payload, []byte("[DONE]")) {
|
|
continue
|
|
}
|
|
hasData = true
|
|
if !gjson.ValidBytes(payload) {
|
|
return statusErr{code: http.StatusBadGateway, msg: "claude executor: upstream returned malformed stream data"}
|
|
}
|
|
|
|
root := gjson.ParseBytes(payload)
|
|
switch root.Get("type").String() {
|
|
case "error":
|
|
message := strings.TrimSpace(root.Get("error.message").String())
|
|
if message == "" {
|
|
message = strings.TrimSpace(root.Get("error.type").String())
|
|
}
|
|
if message == "" {
|
|
message = "unknown upstream error"
|
|
}
|
|
return statusErr{code: http.StatusBadGateway, msg: "claude executor: upstream returned error event: " + message}
|
|
case "message_start":
|
|
message := root.Get("message")
|
|
if strings.TrimSpace(message.Get("id").String()) == "" || strings.TrimSpace(message.Get("model").String()) == "" {
|
|
return statusErr{code: http.StatusBadGateway, msg: "claude executor: upstream stream message_start is missing id or model"}
|
|
}
|
|
hasMessageStart = true
|
|
case "message_delta":
|
|
hasMessageDelta = true
|
|
}
|
|
}
|
|
if errScan := scanner.Err(); errScan != nil {
|
|
return errScan
|
|
}
|
|
if !hasData {
|
|
return statusErr{code: http.StatusBadGateway, msg: "claude executor: upstream returned empty stream response"}
|
|
}
|
|
if !hasMessageStart {
|
|
return statusErr{code: http.StatusBadGateway, msg: "claude executor: upstream stream response is missing message_start"}
|
|
}
|
|
if !hasMessageDelta {
|
|
return statusErr{code: http.StatusBadGateway, msg: "claude executor: upstream stream response ended before message completion"}
|
|
}
|
|
return nil
|
|
}
|