Files
CLIProxyAPI/internal/runtime/executor/claude_executor_compaction.go
Luis Pater 344e0ef929 feat(executor): support Codex remote-compaction v2 on Kimi and Meta Responses routes
- Intercept compaction_trigger items on Kimi and Meta OpenAI Responses execution paths to synthesize sealed CPA compaction capsules and reject incomplete upstream summaries.
- Expand replayed CPA compaction capsules into developer context messages and drop foreign compaction items across Kimi and Meta execution and token-counting paths.

Closes: #6781
2026-10-19 22:07:14 +08:00

253 lines
9.8 KiB
Go

package executor
import (
"bytes"
"context"
"fmt"
"net/http"
"strings"
"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"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
type claudeCompactionSummaryContextKey struct{}
// expandClaudeResponsesCompaction rewrites sealed compaction items into context
// before translation. Unrecognized capsules fail the request instead of being dropped.
func expandClaudeResponsesCompaction(req *cliproxyexecutor.Request, opts *cliproxyexecutor.Options) error {
return helps.ExpandResponsesCompactionCapsules(req, opts, "claude")
}
func dropForeignClaudeCompactionItems(payload []byte) []byte {
return helps.DropForeignResponsesCompactionItems(payload, "claude")
}
func claudeResponsesCompactionRequested(req cliproxyexecutor.Request, opts cliproxyexecutor.Options) bool {
return opts.Alt == "responses/compact" || helps.HasResponsesCompactionTriggerRequest(req, opts)
}
func claudeCompactionSourcePayload(req cliproxyexecutor.Request, opts cliproxyexecutor.Options) []byte {
return helps.ResponsesCompactionSourcePayload(req, opts)
}
func (e *ClaudeExecutor) executeClaudeCompaction(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
var resp cliproxyexecutor.Response
baseModel := thinking.ParseSuffix(req.Model).ModelName
summaryPayload := prepareClaudeCompactionSummaryPayload(claudeCompactionSourcePayload(req, opts), baseModel)
summaryReq := cliproxyexecutor.Request{
Model: req.Model,
Payload: summaryPayload,
Metadata: req.Metadata,
}
summaryOpts := opts
summaryOpts.Alt = ""
summaryOpts.Stream = false
summaryOpts.OriginalRequest = nil
summaryOpts.SourceFormat = sdktranslator.FormatOpenAIResponse
summaryOpts.ResponseFormat = sdktranslator.FormatClaude
summaryResp, errSummary := e.Execute(withClaudeCompactionSummary(ctx), auth, summaryReq, summaryOpts)
if errSummary != nil {
return resp, errSummary
}
summaryText, errExtract := helps.ExtractAntigravitySummaryText(summaryResp.Payload)
if errExtract != nil {
return resp, fmt.Errorf("extract summary: %w", errExtract)
}
capsule, errSeal := helps.SealAntigravityCompaction(summaryText, baseModel)
if errSeal != nil {
return resp, fmt.Errorf("seal compaction capsule: %w", errSeal)
}
inputTokens, outputTokens, totalTokens, cachedTokens := claudeCompactionResponsesUsage(summaryResp.Payload)
respPayload := helps.BuildAntigravityCompactionResponse(baseModel, capsule, inputTokens, outputTokens, totalTokens)
respPayload, _ = sjson.SetBytes(respPayload, "usage.input_tokens_details.cached_tokens", cachedTokens)
return cliproxyexecutor.Response{
Payload: respPayload,
Headers: summaryResp.Headers,
}, nil
}
func (e *ClaudeExecutor) executeClaudeCompactionStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {
baseModel := thinking.ParseSuffix(req.Model).ModelName
summaryResp, errSummary := e.executeClaudeCompaction(ctx, auth, req, opts)
if errSummary != nil {
return nil, errSummary
}
// Reuse the capsule and usage already sealed for the non-stream response.
item := gjson.GetBytes(summaryResp.Payload, "output.0")
capsule := item.Get("encrypted_content").String()
if item.Get("type").String() != "compaction" || capsule == "" {
return nil, fmt.Errorf("extract summary: compaction item missing")
}
inputTokens := int(gjson.GetBytes(summaryResp.Payload, "usage.input_tokens").Int())
outputTokens := int(gjson.GetBytes(summaryResp.Payload, "usage.output_tokens").Int())
totalTokens := int(gjson.GetBytes(summaryResp.Payload, "usage.total_tokens").Int())
cachedTokens := int(gjson.GetBytes(summaryResp.Payload, "usage.input_tokens_details.cached_tokens").Int())
chunks := helps.BuildAntigravityCompactionStreamChunks(baseModel, capsule, inputTokens, outputTokens, totalTokens)
for i := range chunks {
chunks[i] = patchClaudeCompactionStreamUsage(chunks[i], inputTokens, outputTokens, totalTokens, cachedTokens)
}
out := make(chan cliproxyexecutor.StreamChunk, len(chunks))
for _, chunk := range chunks {
out <- cliproxyexecutor.StreamChunk{Payload: chunk}
}
close(out)
headers := summaryResp.Headers.Clone()
if headers == nil {
headers = make(http.Header)
}
headers.Set("Content-Type", "text/event-stream")
return &cliproxyexecutor.StreamResult{Headers: headers, Chunks: out}, nil
}
func withClaudeCompactionSummary(ctx context.Context) context.Context {
if ctx == nil {
ctx = context.Background()
}
return context.WithValue(ctx, claudeCompactionSummaryContextKey{}, true)
}
func claudeCompactionSummaryFromContext(ctx context.Context) bool {
if ctx == nil {
return false
}
flag, ok := ctx.Value(claudeCompactionSummaryContextKey{}).(bool)
return ok && flag
}
// prepareClaudeCompactionSummaryPayload keeps tool definitions that Claude needs
// in order to accept tool_use history, while still asking for a text summary.
func prepareClaudeCompactionSummaryPayload(payload []byte, modelName string) []byte {
tools := gjson.GetBytes(payload, "tools")
additionalTools := gjson.GetBytes(payload, "additional_tools")
out := helps.PrepareAntigravityCompactionSummaryPayload(payload, modelName)
if tools.Exists() {
out, _ = sjson.SetRawBytes(out, "tools", []byte(tools.Raw))
}
if additionalTools.Exists() {
out, _ = sjson.SetRawBytes(out, "additional_tools", []byte(additionalTools.Raw))
}
return out
}
// finalizeClaudeCompactionSummaryBody runs after built-in translation and before
// user payload rules. Tool history stays structured when definitions exist, and
// tool_choice none stops the summary turn from calling a tool. History without
// definitions is flattened so Anthropic does not reject orphan tool blocks.
func finalizeClaudeCompactionSummaryBody(body []byte) []byte {
if claudeBodyHasToolDefinitions(body) {
body, _ = sjson.SetRawBytes(body, "tool_choice", []byte(`{"type":"none"}`))
return body
}
return flattenClaudeToolBlocksForCompaction(body)
}
func claudeBodyHasToolDefinitions(body []byte) bool {
tools := gjson.GetBytes(body, "tools")
return tools.IsArray() && len(tools.Array()) > 0
}
func flattenClaudeToolBlocksForCompaction(body []byte) []byte {
messages := gjson.GetBytes(body, "messages")
if !messages.IsArray() {
return body
}
changed := false
rewritten := make([]string, 0, len(messages.Array()))
for _, msg := range messages.Array() {
content := msg.Get("content")
if !content.IsArray() {
rewritten = append(rewritten, msg.Raw)
continue
}
parts := make([]string, 0, len(content.Array()))
msgChanged := false
for _, part := range content.Array() {
switch part.Get("type").String() {
case "tool_use":
text := fmt.Sprintf("Tool call %s (%s): %s", part.Get("name").String(), part.Get("id").String(), part.Get("input").Raw)
block, _ := sjson.SetBytes([]byte(`{"type":"text","text":""}`), "text", text)
parts = append(parts, string(block))
msgChanged = true
case "tool_result":
result := part.Get("content")
resultText := result.Raw
if result.Type == gjson.String {
resultText = result.String()
}
text := fmt.Sprintf("Tool result %s: %s", part.Get("tool_use_id").String(), resultText)
block, _ := sjson.SetBytes([]byte(`{"type":"text","text":""}`), "text", text)
parts = append(parts, string(block))
msgChanged = true
default:
parts = append(parts, part.Raw)
}
}
if !msgChanged {
rewritten = append(rewritten, msg.Raw)
continue
}
changed = true
updated, _ := sjson.SetRawBytes([]byte(msg.Raw), "content", []byte("["+strings.Join(parts, ",")+"]"))
rewritten = append(rewritten, string(updated))
}
if !changed {
return body
}
body, _ = sjson.SetRawBytes(body, "messages", []byte("["+strings.Join(rewritten, ",")+"]"))
return body
}
// claudeCompactionResponsesUsage matches the Responses translator: input includes
// cache creation and cache read, cached_tokens is cache read, and total is their sum.
func claudeCompactionResponsesUsage(payload []byte) (inputTokens, outputTokens, totalTokens, cachedTokens int) {
usage := gjson.GetBytes(payload, "usage")
if !usage.Exists() {
parsed := helps.ParseOpenAIUsage(payload)
return int(parsed.InputTokens), int(parsed.OutputTokens), int(parsed.TotalTokens), 0
}
rawInput := int(usage.Get("input_tokens").Int())
outputTokens = int(usage.Get("output_tokens").Int())
cacheCreation := int(usage.Get("cache_creation_input_tokens").Int())
cachedTokens = int(usage.Get("cache_read_input_tokens").Int())
inputTokens = rawInput + cacheCreation + cachedTokens
totalTokens = inputTokens + outputTokens
return inputTokens, outputTokens, totalTokens, cachedTokens
}
func patchClaudeCompactionStreamUsage(chunk []byte, inputTokens, outputTokens, totalTokens, cachedTokens int) []byte {
const dataPrefix = "data: "
idx := bytes.Index(chunk, []byte(dataPrefix))
if idx < 0 {
return chunk
}
data := bytes.TrimSpace(chunk[idx+len(dataPrefix):])
path := ""
switch {
case gjson.GetBytes(data, "response.usage").Exists():
path = "response.usage"
case gjson.GetBytes(data, "usage").Exists():
path = "usage"
default:
return chunk
}
data, _ = sjson.SetBytes(data, path+".input_tokens", inputTokens)
data, _ = sjson.SetBytes(data, path+".output_tokens", outputTokens)
data, _ = sjson.SetBytes(data, path+".total_tokens", totalTokens)
data, _ = sjson.SetBytes(data, path+".input_tokens_details.cached_tokens", cachedTokens)
var buf bytes.Buffer
buf.Write(chunk[:idx])
buf.WriteString(dataPrefix)
buf.Write(data)
buf.WriteString("\n\n")
return buf.Bytes()
}