Files
CLIProxyAPI/internal/runtime/executor/claude_executor_execute.go
이대희 3b69068e6c fix(translator): stop dropping attachments and empty turns
An attachment a translator could not represent was dropped without a word, and
a turn whose only content was that attachment went upstream as an empty
messages, parts, or input array. The model then answered as if nothing was
attached, or the upstream rejected a request that never named the attachment.

A base64 document now reaches OpenAI as a file part. A file the target cannot
receive, such as container_upload, an OpenAI file_id, or input_audio sent to
Claude, is reported by name so the route answers 400 instead of forwarding an
empty turn. Text beside it still travels.

One rule, ErrIfNothingLeft, decides when to refuse, and RegisterCheckedRequest
carries the refusal on the request envelope. Every executor that translates
checks it before calling upstream. The exported converters keep their []byte
signature, so no caller or sibling test had to change.
2026-10-07 19:21:32 +09:00

415 lines
18 KiB
Go

package executor
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
)
func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
ctx = helps.EnsureSessionContext(ctx, opts, req.Payload)
if opts.Alt == "responses/compact" {
return resp, 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)
fp := resolveClaudeFingerprintPolicy(e.cfg, auth, apiKey)
// Real Claude OAuth always signs CCH. An opted-in API key signs only where
// native does, so a third-party gateway keeps a cache-stable billing header.
// Default API-key and delegated-provider requests preserve the caller body.
cchSigning := claudeCCHSigningEnabled(apiKey, claudeCCHUpstreamAnthropic, fp.ProfileClaudeCodeCLI, url)
reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
if upstreamModel != baseModel {
reporter.SetUpstreamModel(upstreamModel)
}
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)
}
}()
// Use an upstream stream whenever the downstream response needs translation
// from Claude events. Native Claude responses use the JSON response path.
upstreamStream := responseFormat != to
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 fp.ProfileClaudeCodeCLI {
claudeSessionID = helps.ClaudeAgentSessionUUIDForRequest(incomingHeaders, originalPayload, req.Payload, confirmedClaudeCode, opts.Metadata, req.Metadata)
}
continuityCtx := &helps.ClaudeContinuityContext{}
ctx = helps.WithClaudeContinuityContext(ctx, continuityCtx)
ctx = helps.WithIncomingHeaders(ctx, incomingHeaders)
ctx = helps.WithClaudeExecutionMetadata(ctx, helps.ClaudeRequestHasExecutionMetadata(opts.Metadata, req.Metadata))
if claudeSessionID != "" {
ctx = helps.WithClaudeSessionID(ctx, claudeSessionID)
}
isCompat := helps.APIKeyModelIsCompat(req)
originalTranslated, body, err := helps.TranslateRequestPairReturningError(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, req.Payload, upstreamStream, isCompat)
if err != nil {
return resp, err
}
body = helps.SetStringIfDifferent(body, "model", upstreamModel)
body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier())
if err != nil {
return resp, 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.
_, wireSettings := resolveClaudeWirePolicy(e.cfg, auth, apiKey, confirmedClaudeCode)
isProbeOrHelper := helps.IsClaudeProbeOrHelperRequest(body)
explicitCacheMode := isExplicitPromptCacheMode(originalPayload, req.Payload, body)
if explicitCacheMode {
ctx = withExplicitPromptCacheMode(ctx, true)
}
var cloaked bool
body, cloaked, err = applyCloakingInternal(
ctx,
e.cfg,
auth,
body,
apiKey,
confirmedClaudeCode,
cchSigning,
false,
explicitCacheMode,
)
if err != nil {
return resp, err
}
// Only the Messages endpoint on Anthropic itself was captured; count_tokens
// keeps its own shape and other gateways never see this field.
diagnosticsState := claudeDiagnosticsRequestState{}
if !isProbeOrHelper {
isProbeOrHelper = helps.IsClaudeProbeOrHelperRequest(body)
}
if continuityCtx.Initialized {
diagnosticsState = claudeDiagnosticsRequestState{
key: continuityCtx.Key,
sequence: continuityCtx.Sequence,
promptID: continuityCtx.PromptID,
}
}
contextManagementState := claudeCodeContextManagementState{
eligible: cloaked && isAnthropicUpstreamBase(baseURL),
callerOwned: gjson.GetBytes(body, "context_management").Exists(),
}
if contextManagementState.eligible {
body, contextManagementState.automaticallyInjected = injectClaudeCodeContextManagement(body)
if fp.InjectDiagnostics && !isProbeOrHelper {
if continuityCtx.Initialized {
body, diagnosticsState = injectClaudeDiagnosticsWithState(body, continuityCtx.Key, continuityCtx.Sequence, continuityCtx.PreviousMessageID, continuityCtx.PromptID)
} else {
body, diagnosticsState = injectClaudeDiagnostics(body, auth, claudeSessionID)
}
}
}
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, originalPayload, req.Payload)
if cpaOwnsCacheControl {
body = ensureCacheControl(body)
}
// Enforce Anthropic's cache_control block limit (max 4 breakpoints per request).
// Cloaking and ensureCacheControl may push the total over 4 when the client
// already sends multiple cache_control blocks.
body = enforceCacheControlLimit(body, 4)
body = stripPromptCacheOptions(body)
// 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.
// claude-code-cli fingerprint profiles emit extended-cache-ttl and must use the same 1h pool.
// In native Claude Code, subagents default to 5m unless 1h is explicitly configured;
// probes omit both 1h cache and extended-cache-ttl.
isSubagent := helps.IsClaudeSubagentRequest(incomingHeaders, body)
subagent1h := isSubagent && helps.ClaudeSubagentRequests1h(incomingHeaders, body)
if cpaOwnsCacheControl && fp.ProfileClaudeCodeCLI && (!isSubagent || subagent1h) && !isProbeOrHelper {
body = upgradeClaudeCacheControlTTL(body, claudeCacheControlTTL1h)
} else if (isProbeOrHelper || (isSubagent && !subagent1h)) && !explicitCacheMode {
body = stripClaudeCacheControlTTL(body)
}
// Normalize TTL values to prevent ordering violations under prompt-caching-scope-2026-01-05.
// A 1h-TTL block must not appear after a 5m-TTL block in evaluation order (tools→system→messages).
if !explicitCacheMode {
body = normalizeCacheControlTTL(body)
}
// Set the built-in transport default before the explicit payload barrier.
// Native non-stream Haiku helper requests omit stream rather than sending
// false, so preserve that measured wire shape when the transport agrees.
streamField := gjson.GetBytes(body, "stream")
if !claudeCodeDetection.HelperProfile || streamField.Exists() || upstreamStream {
body = helps.SetBoolIfDifferent(body, "stream", upstreamStream)
}
// Extract betas from body and convert to header
var extraBetas []string
bodyForTranslation := body
bodyForUpstream := body
var oauthToolNamesReverseMap map[string]string
if fp.MCPAlias && cloaked {
mcpAliases := resolveClaudeMCPAliasOptions(ctx)
bodyForUpstream, oauthToolNamesReverseMap, err = e.prepareClaudeOAuthToolNamesForRequest(bodyForUpstream, mcpAliases)
if err != nil {
return resp, err
}
}
bodyForUpstream = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, bodyForUpstream, baseModel, helps.APIKeyModelIsCompat(req))
if fp.ApplyCLIIdentity {
bodyForUpstream, err = applyClaudeCLIIdentity(bodyForUpstream, auth, apiKey, url, claudeSessionID, fp.SynthesizeIdentity)
if err != nil {
return resp, err
}
}
if cloaked && len(wireSettings.sensitiveWords) > 0 {
matcher := helps.BuildSensitiveWordMatcher(wireSettings.sensitiveWords)
bodyForUpstream = helps.ObfuscateSensitiveWords(bodyForUpstream, matcher)
}
cchBilling := ""
if cchSigning {
if !claudeCodeDetection.HelperProfile || claudeBodyNeedsBillingFallback(bodyForUpstream) {
cchBilling = claudeCCHFallbackBillingHeader(ctx, e.cfg, bodyForUpstream, claudeCodeDetection.Entrypoint)
}
bodyForUpstream, err = ensureClaudeBillingHeaderCCHPlaceholder(bodyForUpstream, cchBilling)
if err != nil {
return resp, fmt.Errorf("finalize Claude CCH: %w", err)
}
}
bodyForUpstream = stripDefaultKimiClaudeCodeAttribution(auth, url, fp.ProfileClaudeCodeCLI, bodyForUpstream)
// User rules match the fully prepared business body and are applied only once.
var touchedPayloadPaths map[string]bool
bodyForUpstream, touchedPayloadPaths = helps.ApplyPayloadConfigWithTrackedPaths(
e.cfg, baseModel, to.String(), from.String(), "", bodyForUpstream, originalTranslated,
helps.PayloadRequestedModel(opts, req.Model), helps.PayloadRequestPath(opts), opts.Headers,
"diagnostics",
)
if touchedPayloadPaths["diagnostics"] {
diagnosticsState = claudeDiagnosticsRequestState{}
}
extraBetas, bodyForUpstream = extractAndRemoveBetas(bodyForUpstream)
bodyForUpstream = stripPromptCacheOptions(bodyForUpstream)
if cchSigning {
bodyForUpstream, err = signAnthropicMessagesBody(bodyForUpstream)
if err != nil {
return resp, fmt.Errorf("sign Claude CCH: %w", err)
}
}
// Read-only validation must observe the final configured model and messages.
if errMidSystem := validateClaudeMidSystemMessageModel(bodyForUpstream, confirmedClaudeCode, isAnthropicUpstreamBase(baseURL)); errMidSystem != nil {
return resp, errMidSystem
}
reporter.SetTranslatedReasoningEffort(bodyForUpstream, to.String())
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyForUpstream))
if err != nil {
return resp, err
}
if errHeaders := applyClaudeHeadersWithNativeProfile(
httpReq,
auth,
apiKey,
upstreamStream,
extraBetas,
bodyForUpstream,
e.cfg,
incomingHeaders,
confirmedClaudeCode && !cloaked,
claudeCodeDetection.HelperProfile,
claudeSessionID,
); errHeaders != nil {
return resp, 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 resp, 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)
errClassified := classifyClaudeUpstreamErrorWithCooling(httpResp.StatusCode, httpResp.Header, []byte(msg), e.modelLevelCooling())
if fastRequest {
return resp, wrapClaudeFastRequestError(fastRequest, httpResp.StatusCode, errClassified)
}
return resp, errClassified
}
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 resp, newClaudeFastDirectResponseError(httpResp, b)
}
return resp, classifyClaudeUpstreamErrorWithCooling(httpResp.StatusCode, httpResp.Header, b, e.modelLevelCooling())
}
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 resp, wrapClaudeFastRequestError(fastRequest, httpResp.StatusCode, err)
}
defer func() {
if errClose := decodedBody.Close(); errClose != nil {
log.Errorf("response body close error: %v", errClose)
}
}()
data, err := io.ReadAll(decodedBody)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
return resp, wrapClaudeFastRequestError(fastRequest, httpResp.StatusCode, err)
}
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
var streamUsage helps.StreamUsageBuffer
if upstreamStream {
if errValidate := validateClaudeStreamingResponse(data); errValidate != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errValidate)
return resp, wrapClaudeFastRequestError(fastRequest, httpResp.StatusCode, errValidate)
}
if msgID := claudeMessageIDFromSSE(data); msgID != "" {
commitClaudeContinuity(diagnosticsState, msgID, helps.HeaderValueCaseInsensitive(httpResp.Header, "request-id"))
e.rememberClaudeOAuthToolAliases(body, oauthToolNamesReverseMap, msgID)
}
lines := bytes.Split(data, []byte("\n"))
for i, line := range lines {
reporter.ObserveResponseModel(line)
streamUsage.ObserveClaudeStream(line)
restoredLine, errRestore := restoreClaudeOAuthToolNamesFromStreamLine(line, oauthToolNamesReverseMap)
if errRestore != nil {
errRestore = fmt.Errorf("restore Claude OAuth tool name from streaming response: %w", errRestore)
helps.RecordAPIResponseError(ctx, e.cfg, errRestore)
streamUsage.PublishFailure(ctx, reporter, errRestore)
return resp, wrapClaudeFastRequestError(fastRequest, httpResp.StatusCode, errRestore)
}
lines[i] = restoredLine
}
data = bytes.Join(lines, []byte("\n"))
} else {
messageID := claudeMessageIDFromResponse(data)
commitClaudeContinuity(diagnosticsState, messageID, helps.HeaderValueCaseInsensitive(httpResp.Header, "request-id"))
e.rememberClaudeOAuthToolAliases(body, oauthToolNamesReverseMap, messageID)
reporter.ObserveResponseModel(data)
var errRestore error
data, errRestore = restoreClaudeOAuthToolNamesFromResponse(data, oauthToolNamesReverseMap)
if errRestore != nil {
errRestore = fmt.Errorf("restore Claude OAuth tool name from response: %w", errRestore)
helps.RecordAPIResponseError(ctx, e.cfg, errRestore)
return resp, wrapClaudeFastRequestError(fastRequest, httpResp.StatusCode, errRestore)
}
}
data = e.restoreResponseModel(data, req.Model)
cacheClaudeThinkingReplayResponse(ctx, replayScope, data)
var param any
out := sdktranslator.TranslateNonStream(
ctx,
to,
responseFormat,
req.Model,
helps.ApplyPatchOriginalRequest(req, opts),
bodyForTranslation,
data,
&param,
)
if helps.ApplyPatchTranslationError(param) != nil || len(out) == 0 {
return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}
}
if upstreamStream {
streamUsage.Publish(ctx, reporter)
} else {
reporter.Publish(ctx, helps.ParseClaudeUsage(data))
}
if responseFormat == sdktranslator.FormatOpenAIResponse {
out = helps.EnsureResponsesUsageDetails(out)
}
resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}
return resp, nil
}