Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/claude-compaction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Claude compaction bridge

Claude requests compaction through its normal messages endpoint. The bridge
translates the transcript and custom summary instructions into Codex input,
appends a compaction_trigger item, and dispatches through CPA's ordinary Responses
execution. CPA handles upstream routing, credentials, transport and response
assembly.

The bridge returns only the compaction item's encrypted_content as Claude text,
including for SSE clients. It adds no capsule wrapper or cache reference. On
replay, a complete Fernet-shaped ciphertext line is removed from the summary
message and restored as a Responses compaction input item. The newest raw block
replaces the preceding conversation window; messages after that boundary remain.
Quoted or inline ciphertext examples are left as ordinary text.

This recognition checks transport shape, not decryptability or origin. Raw
ciphertext carries no explicit type tag, so a standalone valid-looking ciphertext
line in ordinary prose is ambiguous and will be interpreted as compaction state.
The upstream validates the encrypted state.

Credential selection uses CPA's normal router. No auth ID is stored or pinned.
There is no synthetic 200k rejection; Claude uses its configured window and the
upstream enforces the model's actual context limit.

Compaction requires no server cache or persistent volume. Only raw ciphertext is
supported; legacy inline capsules and KV cache references are not decoded.

Three manual compact/resume cycles with real Luna OAuth output and unmodified
Claude Code 2.1.211 established that ciphertext text is replayed unchanged and each
replacement supersedes the previous block. This does not establish automatic
compaction or Claude Desktop behavior.
15 changes: 15 additions & 0 deletions internal/constant/constant.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
// ensuring consistent naming across the application.
package constant

// ClaudeBridgeUsageContextKey scopes bridge-specific context accounting to its requests.
type ClaudeBridgeUsageContextKey struct{}

const (
// Gemini represents the Google Gemini provider identifier.
Gemini = "gemini"
Expand All @@ -27,4 +30,16 @@ const (

// Interactions represents the Google Interactions API format identifier.
Interactions = "interactions"

// ClaudeResponsesBridgeAlt identifies Claude /messages requests that must use
// the Codex Responses API while preserving a Claude-compatible response.
ClaudeResponsesBridgeAlt = "claude/responses"

// ClaudeResponsesCompactBridgeAlt identifies Claude compaction requests that
// must use the Codex /responses/compact endpoint.
ClaudeResponsesCompactBridgeAlt = "claude/responses/compact"

// ClaudeResponsesCompactionField carries validated compacted Responses items
// from the Claude handler to the Codex executor. It is never sent upstream.
ClaudeResponsesCompactionField = "cpa_responses_compaction"
)
63 changes: 63 additions & 0 deletions internal/runtime/executor/codex_claude_bootstrap_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package executor

import (
"context"
"net/http/httptest"
"strings"
"testing"

cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
)

func TestClaudeBridgeBootstrapPreservesUpstreamFailureDelivery(t *testing.T) {
for _, transport := range []string{"http", "websocket"} {
for _, scenario := range []string{"overload", "frame_budget", "empty_incomplete"} {
t.Run(transport+"/"+scenario, func(t *testing.T) {
events := []string{codexCreatedEvent, codexInProgressEvent}
if scenario == "frame_budget" {
for i := 0; i <= codexBootstrapMaxBufferedFrames; i++ {
events = append(events, codexInProgressEvent)
}
}
if scenario == "empty_incomplete" {
events = append(events, `{"type":"response.incomplete","response":{"id":"resp_1","output":[],"usage":{"input_tokens":1,"output_tokens":0,"total_tokens":1}}}`)
} else {
events = append(events, codexOverloadEvent)
}
var server *httptest.Server
if transport == "http" {
server = codexSSEServer(events...)
} else {
server = codexWebsocketServer(t, events...)
}
defer server.Close()
body := []byte(`{"model":"gpt-5.6-sol","stream":true,"max_tokens":64,"messages":[{"role":"user","content":"hello"}]}`)
req := cliproxyexecutor.Request{Model: "gpt-5.6-sol", Payload: body}
opts := claudeResponsesBridgeOptions(body, true)
var result *cliproxyexecutor.StreamResult
var err error
if transport == "http" {
result, err = NewCodexExecutor(codexBufferingConfig(true)).ExecuteStream(context.Background(), codexTestAuth(server.URL), req, opts)
} else {
result, err = NewCodexWebsocketsExecutor(codexBufferingConfig(true)).ExecuteStream(context.Background(), codexTestAuth(server.URL), req, opts)
}
if scenario == "overload" {
if err == nil || result != nil {
t.Fatalf("overload must fail before exposing usage: result=%v err=%v", result, err)
}
return
}
if err != nil || result == nil {
t.Fatalf("expected in-stream failure: result=%v err=%v", result, err)
}
payload, streamErr := drainChunks(result)
if streamErr == nil {
t.Fatal("expected terminal stream error")
}
if !strings.Contains(payload, "message_start") || !strings.Contains(payload, "input_tokens") {
t.Fatalf("buffered Claude start and usage were lost: %s", payload)
}
})
}
}
}
30 changes: 23 additions & 7 deletions internal/runtime/executor/codex_executor_execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"

"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
"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"
Expand All @@ -20,12 +21,15 @@ import (

func (e *CodexExecutor) 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" {
if opts.Alt == "responses/compact" || opts.Alt == constant.ClaudeResponsesCompactBridgeAlt {
return e.executeCompact(ctx, auth, req, opts)
}
if isCodexOpenAIImageRequest(opts) {
return e.executeOpenAIImage(ctx, auth, req, opts)
}
if opts.Alt == constant.ClaudeResponsesBridgeAlt {
ctx = context.WithValue(ctx, constant.ClaudeBridgeUsageContextKey{}, true)
}
baseModel := thinking.ParseSuffix(req.Model).ModelName

apiKey, baseURL := codexCreds(auth)
Expand All @@ -45,6 +49,8 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
}
originalPayload := originalPayloadSource
originalTranslated, body := translateCodexRequestPair(from, to, baseModel, originalPayload, req.Payload, false, helps.APIKeyModelIsCompat(req))
originalTranslated = applyClaudeResponsesCompactionReplay(originalTranslated, originalPayload, opts)
body = applyClaudeResponsesCompactionReplay(body, req.Payload, opts)

body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier())
if err != nil {
Expand Down Expand Up @@ -73,6 +79,9 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
if errReplay != nil {
return resp, errReplay
}
if _, errContext := validateClaudeBridgeContextWindow(baseModel, body, opts); errContext != nil {
return resp, errContext
}
reporter.SetTranslatedReasoningEffort(body, to.String())

url := strings.TrimSuffix(baseURL, "/") + "/responses"
Expand Down Expand Up @@ -221,30 +230,37 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A

from := opts.SourceFormat
responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts)
to := sdktranslator.FromString("openai-response")
requestFormat := sdktranslator.FromString("openai-response")
if opts.Alt == constant.ClaudeResponsesCompactBridgeAlt {
requestFormat = sdktranslator.FromString("codex")
}
responseSourceFormat := sdktranslator.FromString("openai-response")
originalPayloadSource := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
originalTranslated, body := translateCodexRequestPair(from, to, baseModel, originalPayload, req.Payload, false, helps.APIKeyModelIsCompat(req))
originalTranslated, body := translateCodexRequestPair(from, requestFormat, baseModel, originalPayload, req.Payload, false, helps.APIKeyModelIsCompat(req))
originalTranslated = applyClaudeResponsesCompactionReplay(originalTranslated, originalPayload, opts)
body = applyClaudeResponsesCompactionReplay(body, req.Payload, opts)

body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier())
body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), requestFormat.String(), e.Identifier())
if err != nil {
return resp, err
}

requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers)
body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, requestFormat.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers)
body = helps.SetStringIfDifferent(body, "model", baseModel)
body, _ = sjson.DeleteBytes(body, "stream")
body = normalizeCodexInstructions(body, helps.IsNativeCodexRequest(req.Payload, opts))
body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex executor", body)
body = normalizeCodexParallelToolCalls(body, opts.Headers)
body = helps.NormalizeCodexToolSchemas(body)
body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuth(ctx, opts.Headers, body, e.cfg, auth, baseModel)
reporter.SetTranslatedReasoningEffort(body, to.String())
body = codexCompactRequestPayload(body)
reporter.SetTranslatedReasoningEffort(body, requestFormat.String())

url := strings.TrimSuffix(baseURL, "/") + "/responses/compact"
var identityState codexIdentityConfuseState
Expand Down Expand Up @@ -305,7 +321,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
reporter.EnsurePublished(ctx)
var param any
clientData := applyCodexIdentityExposeResponsePayload(upstreamData, identityState)
out := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, originalPayload, body, clientData, &param)
out := sdktranslator.TranslateNonStream(ctx, responseSourceFormat, responseFormat, req.Model, originalPayload, body, clientData, &param)
if responseFormat == sdktranslator.FormatOpenAIResponse {
out = helps.EnsureResponsesUsageDetails(out)
}
Expand Down
50 changes: 50 additions & 0 deletions internal/runtime/executor/codex_executor_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
Expand Down Expand Up @@ -48,6 +49,55 @@ func translateCodexRequestPair(from, to sdktranslator.Format, model string, orig
return originalTranslated, body
}

func applyClaudeResponsesCompactionReplay(translated, source []byte, opts cliproxyexecutor.Options) []byte {
if opts.Alt != constant.ClaudeResponsesBridgeAlt && opts.Alt != constant.ClaudeResponsesCompactBridgeAlt {
return translated
}
replay := gjson.GetBytes(source, constant.ClaudeResponsesCompactionField+".output")
if !replay.IsArray() || len(replay.Array()) == 0 {
return translated
}

input := gjson.GetBytes(translated, "input")
var combined bytes.Buffer
combined.WriteByte('[')
needsComma := false
for _, item := range replay.Array() {
if needsComma {
combined.WriteByte(',')
}
combined.WriteString(item.Raw)
needsComma = true
}
if input.IsArray() {
for _, item := range input.Array() {
if needsComma {
combined.WriteByte(',')
}
combined.WriteString(item.Raw)
needsComma = true
}
}
combined.WriteByte(']')
updated, errSet := sjson.SetRawBytes(translated, "input", combined.Bytes())
if errSet != nil {
return translated
}
return updated
}

func codexCompactRequestPayload(body []byte) []byte {
out := []byte(`{"model":"","instructions":"","input":[]}`)
out, _ = sjson.SetBytes(out, "model", gjson.GetBytes(body, "model").String())
if instructions := gjson.GetBytes(body, "instructions"); instructions.Type == gjson.String {
out, _ = sjson.SetBytes(out, "instructions", instructions.String())
}
if input := gjson.GetBytes(body, "input"); input.IsArray() {
out, _ = sjson.SetRawBytes(out, "input", []byte(input.Raw))
}
return out
}

// PrepareRequest injects Codex credentials into the outgoing HTTP request.
func (e *CodexExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
if req == nil {
Expand Down
Loading
Loading