mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-26 01:25:10 +08:00
- Handle compaction trigger requests by generating summaries and sealing antigravity compaction capsules - Expand replayed compaction capsules into message history before translation and filter foreign compaction items - Preserve tool definitions with `tool_choice: none` or flatten tool blocks when tool definitions are missing - Support streaming compaction execution and map cache creation and read token usage Closes: #6466
301 lines
11 KiB
Go
301 lines
11 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"
|
|
log "github.com/sirupsen/logrus"
|
|
"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 {
|
|
if req != nil {
|
|
req.Payload = dropForeignClaudeCompactionItems(req.Payload)
|
|
if helps.HasResponsesCompactionItem(req.Payload) {
|
|
expanded, errExpand := helps.ExpandAntigravityCompactionCapsules(req.Payload)
|
|
if errExpand != nil {
|
|
return errExpand
|
|
}
|
|
req.Payload = expanded
|
|
}
|
|
}
|
|
if opts != nil {
|
|
opts.OriginalRequest = dropForeignClaudeCompactionItems(opts.OriginalRequest)
|
|
if helps.HasResponsesCompactionItem(opts.OriginalRequest) {
|
|
expanded, errExpand := helps.ExpandAntigravityCompactionCapsules(opts.OriginalRequest)
|
|
if errExpand != nil {
|
|
return errExpand
|
|
}
|
|
opts.OriginalRequest = expanded
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func dropForeignClaudeCompactionItems(payload []byte) []byte {
|
|
input := gjson.GetBytes(payload, "input")
|
|
if !input.IsArray() {
|
|
return payload
|
|
}
|
|
dropped := 0
|
|
items := make([]string, 0, len(input.Array()))
|
|
for _, item := range input.Array() {
|
|
if item.Get("type").String() == "compaction" && !helps.RecognizedAntigravityCompactionCapsule(item.Get("encrypted_content").String()) {
|
|
dropped++
|
|
continue
|
|
}
|
|
items = append(items, item.Raw)
|
|
}
|
|
if dropped == 0 {
|
|
return payload
|
|
}
|
|
log.Warnf("claude compaction: dropped %d non-CPA compaction item(s)", dropped)
|
|
out, _ := sjson.SetRawBytes(payload, "input", []byte("["+strings.Join(items, ",")+"]"))
|
|
return out
|
|
}
|
|
|
|
func claudeResponsesCompactionRequested(req cliproxyexecutor.Request, opts cliproxyexecutor.Options) bool {
|
|
return opts.Alt == "responses/compact" ||
|
|
helps.HasResponsesCompactionTrigger(req.Payload) ||
|
|
helps.HasResponsesCompactionTrigger(opts.OriginalRequest)
|
|
}
|
|
|
|
func claudeCompactionSourcePayload(req cliproxyexecutor.Request, opts cliproxyexecutor.Options) []byte {
|
|
payload := req.Payload
|
|
if !helps.HasResponsesCompactionTrigger(payload) && helps.HasResponsesCompactionTrigger(opts.OriginalRequest) {
|
|
payload = opts.OriginalRequest
|
|
}
|
|
if len(payload) == 0 && len(opts.OriginalRequest) > 0 {
|
|
payload = opts.OriginalRequest
|
|
}
|
|
return payload
|
|
}
|
|
|
|
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()
|
|
}
|