Skip to content

test(cache): replay-alias provenance doctrines - #223

Closed
warelik wants to merge 56 commits into
kaitranntt:mainfrom
warelik:ao/airouters-21-replay-alias-doctrine
Closed

test(cache): replay-alias provenance doctrines#223
warelik wants to merge 56 commits into
kaitranntt:mainfrom
warelik:ao/airouters-21-replay-alias-doctrine

Conversation

@warelik

@warelik warelik commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Test-only PR pinning airouters-11's replay-alias rules in the alias registry:

  • (a) Compaction-stable scopes: a compacted follow-up (first user message removed) still resolves to the original conversation scope.
  • (b) Home-KV alias cap per credential: flooding one credential does not evict another credential's aliases; the per-credential index is isolated across model names.
  • (c) Atomic alias eviction with index update: alias values are rolled back when the per-credential index CAS fails, and no value is deleted before the index update and re-read succeed.
  • (d) Identical vs distinct conversation scopes: identical conversations resolve to the same scope; conversations with the same visible messages but different first-user context never collapse.

Each doctrine test calls requireReplayAliasSupport() and t.Skips with Plus #209 / stock #5150 references when the replay-alias registry is not active.

This branch was rebuilt to contain only the focused test delta on top of Plus #209 (ao/airouters-11-fix-signature-boundary-plus), removing the 42-commit/33-file history baggage. Once Plus #209 lands in main, the PR diff will show only internal/cache/replay_alias_doctrine_test.go.

Cross-links

Test plan

  • go test -run TestReplayAliasDoctrine ./internal/cache (6 passed)

warelik added 30 commits August 21, 2026 18:54
A Gemini part carrying only a thoughtSignature is not a usable
completion; the client still needs visible text, a tool call, or
positive token usage. This prevents empty responses from being
rotated into the next request and masking provider failures.
Route Claude thinking signatures through signature compatibility
logic before copying them into Gemini thoughtSignature fields.
Compatible signatures are normalized; foreign or opaque ones use
the documented Gemini bypass sentinel instead of crashing the
Gemini upstream.
Emit Claude signature_delta events as Gemini thoughtSignature
carriers and preserve them in the non-stream path, replaying
through Gemini compatibility or the bypass sentinel. Prevents
signature loss when Claude responses are translated for Gemini.
…tput

Mirror the gemini/claude non-stream signature handling for the
gemini-cli path: emit signature_delta SSE events and attach
the thoughtSignature to non-stream thinking blocks. Keeps cross-
provider thinking continuity for the CLI provider.
Preserve the block shape in compat mode while still running the
signature through compatibility logic. Foreign or opaque signatures
are stripped or emulated; decodable short Claude shapes (e.g. replay
cache synthetic signatures) are preserved to avoid breaking compat
thinking replay.
The compat-mode fallback that preserves decodable Claude E/R-shaped
signatures only applies to unprefixed, non-foreign values now. Foreign
provider prefixes (e.g. gemini#...) and signatures detected as foreign
no longer slip through the fallback and fail Claude validation.

Addresses P1 review on router-for-me/CLIProxyAPI#5150.
- Preserve the required "signature" member on empty compat thinking
  placeholders instead of deleting it.
- Reject any signature containing an unrecognized vendor prefix in the
  short-signature fallback, not only known foreign prefixes.
- Add regression tests for empty placeholder preservation and
  vendor-prefixed signature rejection.
When a "claude#" prefix is present on a short E/R-shaped signature,
strip the prefix and re-run foreign-provider detection on the payload
before allowing the fallback. This prevents a genuine Gemini E-prefixed
signature mislabeled as "claude#" from being forwarded to Claude. The
unprefixed value is emitted when the payload is safe.

Adds TestSanitizeClaudeMessagesForClaudeUpstreamStripsMislabeledClaudePrefixInCompatMode.
After stripping a "claude#" prefix, reject the payload if it still
contains a '#'. This prevents nested prefixes like
"claude#vendor#EgI=" from slipping through: HasDecodableClaudeThinkingSignature
would otherwise strip the inner "vendor#" and validate the bare
E/R shape, while provider detection returned unknown on the nested
payload. Now any residual provider delimiter is an immediate reject.

Adds TestSanitizeClaudeMessagesForClaudeUpstreamStripsNestedClaudePrefixInCompatMode.
…ng blocks

When compat mode rejects a thinking signature, it now sets the field to
an empty string instead of deleting the member. This keeps the block
shape intact while matching the placeholder-preservation semantics, and
avoids strict Claude-compatible validation rejecting the continuation
for a missing signature.

Updates compat tests to assert that rejected foreign/Gemini/opaque
signatures on non-empty thinking blocks are cleared to an empty string
rather than the field being removed.
The short-signature fallback now accepts only signatures that
DetectSignatureProviderForBlock identifies as Claude. Unknown or
unprovenanced E/R-shaped opaque payloads (e.g. Grok/xAI encrypted_content
that happens to base64-encode to 'E' or 'R') no longer pass the fallback.

Adds TestSanitizeClaudeMessagesForClaudeUpstreamRejectsGrokOpaqueERInCompatMode
and keeps the earlier vendor#/nested regression tests pinned.
…hetic

isClaudeReplayableShortSignature now requires either:
- a signature DetectSignatureProviderForBlock identifies as Claude, or
- a minimal short E-prefixed synthetic used by the thinking replay cache
  (e.g. "EgI="), where the decoded payload is <= 2 bytes.

This rejects longer opaque blobs such as Grok/xAI encrypted_content that
happens to base64-encode to 'E' or 'R', while still preserving the known
short synthetic shape.

Adds TestSanitizeClaudeMessagesForClaudeUpstreamRejectsGrokOpaqueERInCompatMode
and keeps the earlier vendor#/nested regression tests pinned.
… gate

isClaudeReplayableShortSignature is the final compat fallback and is now
only responsible for the 1-2 byte E-prefixed synthetic used by the Claude
thinking replay cache (e.g. "EgI="). It no longer calls
DetectSignatureProviderForBlock or HasDecodableClaudeThinkingSignature, which
are redundant here: longer valid Claude signatures are already preserved by
DecideSignatureCompatibilityForModel, and the dead detector branch only added
cost for large rejected blobs.

isShortClaudeSyntheticSignature rejects any base64 longer than 4 characters
before decoding, so it does not allocate when handling multi-kilobyte opaque
ciphertext.
…tion, honor cacheControlDisabled

- prepareClaudeThinkingReplayRequest now loads cached assistant contents
  without modifying req.Payload. The restore is applied to bodyForUpstream
  after sanitizeClaudeMessagesForClaudeUpstreamWithDebug, so
  cache-provenanced signatures are not cleared by the compat sanitizer.

- ClaudeExecutor.Execute and ExecuteStream now skip cache_control placement
  entirely when the embedder sets cacheControlDisabled (e.g. Kimi reusing
  the Claude path).

- Adds TestClaudeExecutorCompatThinkingReplayRestoresOpaqueOmittedBlock to
  verify that long opaque replay signatures survive the sanitizer.
…izer

Reverted the after-sanitize restore workaround. prepareClaudeThinkingReplayRequest
now restores cached assistant content into req.Payload and marks each restored
thinking part with _cliproxy_replay_provenance. The compat sanitizer detects
that marker, preserves the signature, and strips the marker before sending
upstream. Unprovenanced client signatures still pass through Decide and the
short-synthetic fallback and are rejected when not recognized.
…ient markers

prepareClaudeThinkingReplayRequest now strips client-supplied
_cliproxy_replay_provenance from the request, loads cache content, and defers
restore until after signature sanitization. The executor then restores before
prepareClaudeOAuthToolNamesForUpstream so cached caller-facing tool names match
the current body and get remapped for upstream.

The compat sanitizer no longer treats _cliproxy_replay_provenance as a trust
signal; it removes any incoming marker and subjects the signature to normal
compatibility validation.

Added TestClaudeExecutorCompatThinkingReplayRestoresBeforeMCPToolNameRemap and
TestSanitizeClaudeMessagesForClaudeUpstreamStripsClientReplayProvenanceMarkerInCompatMode.
isShortClaudeSyntheticSignature now returns the trimmed, normalized value so
whitespace-padded short synthetics like "  EgI=  " are forwarded upstream as
their canonical form. Callers use the returned normalized string instead of the
raw input.

Added TestSanitizeClaudeMessagesForClaudeUpstreamNormalizesWhitespacePaddedShortSignatureInCompatMode.
prepareClaudeThinkingReplayRequest now strips tool-use provenance fields from
cached assistant content before the replay match. This lets the canonical
comparison match an echo'd tool_use even after the upstream sanitizer removes
the same fields. The restored content still keeps the trusted thinking
signature, and the sanitizer strips any residual tool provenance from the
upstream body.

Added TestClaudeExecutorCompatThinkingReplayRestoresOmittedThinkingWithToolProvenance.
…ocks

restoreKimiThinkingReplayContent now restores cached assistant content even
when the current turn contains a thinking block. Non-thinking parts must still
match the cached non-thinking parts, and the thinking parts must match after
removing signature fields, so a sanitized echoed turn gets its trusted
signature back while a modified thinking text does not.

Added TestClaudeExecutorCompatThinkingReplayRestoresEchoedSignedThinking.
…de clients

claudeThinkingReplayScopeFromRequest now falls back to a credential-scoped
session key when the request provides no execution session, X-Claude-Code-
Session-Id, prompt cache key, or other session metadata. This preserves same-
upstream opaque signatures for standard Claude Messages clients that do not
supply session scoping, while still isolating the cache by credential identity
through the model family.

Added TestClaudeExecutorCompatThinkingReplayRestoresSessionlessSameUpstreamSignature.
…ients

claudeThinkingReplayScopeFromRequest no longer falls back to a bare
credential+model key for sessionless clients. When no execution session or
session header is present, it derives a conversation-scoped key from the first
message and the system prompt. This keeps distinct conversations through the
same credential isolated while still allowing replay within the same
conversation.

Added TestClaudeExecutorCompatThinkingReplayIsConversationScopedForSessionlessClients.
…dders

stripCacheControls previously only deleted top-level cache_control fields
in tools, system, and messages. A Claude tool_result with structured
content blocks that themselves carry cache_control would still forward the
Anthropic-only field to Kimi and cause rejection.

- Make stripCacheControls only target protocol-level cache_control markers on
  system/tool/message blocks and nested content arrays (e.g. tool_result
  content). It leaves arbitrary JSON like tool input_schema properties named
  "cache_control" untouched.
- Add stripContentCacheControls to recurse into nested content arrays without
  wandering into sibling objects such as tool input_schema or tool_use input.
- Add TestStripCacheControls and TestStripCacheControls_NestedToolResultContent.

Refs: router-for-me/CLIProxyAPI#5154
claudeThinkingReplayConversationSessionKey now mixes caller identity
signals into the conversation key: credential id, caller-scope and derived
session metadata, and selected headers (User-Agent, X-App, X-Codex-Client-Id).
It also includes tools in the content hash. This prevents two sessionless
callers sharing a credential and the same first message from colliding on the
same replay cache entry.

Added TestClaudeExecutorCompatThinkingReplayIsCallerScopedForSessionlessClients.
Moves claudeThinkingReplayConversationSessionKey to
helps.ClaudeThinkingReplayConversationSessionKey per the repo layout that
places executor support helpers under internal/runtime/executor/helps/. The
helper now lives alongside other session/identity helpers and uses a small
local canonical JSON routine to avoid an executor import cycle.
ClaudeThinkingReplayConversationSessionKey now writes every caller field
(length-prefixed with an 8-byte big-endian length) before hashing. This
prevents distinct caller tuples whose concatenated raw values look the same
(e.g. auth.ID="ab" vs auth.ID="a" + caller-scope="bc") from producing the
same replay key.

Added TestClaudeThinkingReplayConversationSessionKey_DelimitsConcatenatedFields.
When cloak_cache_user_id is false (documented default), injectFakeUserID now
uses helps.GenerateRandomFakeUserIDForSession to produce a fresh random
device_id each request while keeping the stable session_id from
CachedSessionIDRequired, preserving header/body session alignment.

cache-user-id:true continues to use CachedUserIDRequired for a deterministic,
credential-scoped user_id.

Added TestApplyCloaking_DeterministicUserID, TestApplyCloaking_NonCachedUserIDIsRandom,
TestInjectFakeUserID_CacheEnabledIsDeterministic, and
TestInjectFakeUserID_CacheDisabledIsRandomPerRequest.
…er-id contract

Reconciles the cloak_cache_user_id flag with its documented contract:

- cache-user-id:false (default) gives a fresh random device_id per request
  while keeping the stable credential session_id, preserving header/body
  session alignment and the legacy per-request random identity.
- cache-user-id:true produces a deterministic, credential-scoped user_id for
  operators who want cache warmth.

- claudeCredentialSeed falls back to auth.ID/Index/FileName/Label/Provider
  when apiKey is empty, so distinct custom-header-only credentials get
  distinct sessions and device IDs.
- CachedSessionIDRequired, CachedUserIDRequired, generateFakeUserID, and
  injectFakeUserID now take *cliproxyauth.Auth.
- injectFakeUserID uses CachedUserIDRequired when cache-user-id is true and
  GenerateRandomFakeUserIDForSession when false.
- Add TestApplyCloaking_DeterministicUserID,
  TestApplyCloaking_NonCachedUserIDIsRandom,
  TestInjectFakeUserID_CacheEnabledIsDeterministic,
  TestInjectFakeUserID_CacheDisabledIsRandomPerRequest,
  and TestCachedSessionIDRequiredDistinctEmptyAPIKeyCredentials.
…sponses, and cloaking

- ClaudeThinkingReplayConversationSessionKey no longer hashes tools, so
  changing or reordering the tools list between turns does not churn the
  conversation-scoped replay key.
- Identity headers (User-Agent, X-App, X-Codex-Client-Id) are now read
  case-insensitively to avoid missing lowercase SDK-supplied header keys.
- kimiThinkingReplayContentIsReplayable caches signed thinking responses even
  when they do not contain a tool_use block, and kimiNonThinkingContentParts
  returns a true validity flag (not hasToolUse) so non-tool assistant turns
  can be restored.
- applyCloaking-sensitive-word obfuscation is now applied to cached replay
  contents before the post-sanitizer restore, so the replay match compares
  like-for-like obfuscated bytes and restores trusted signatures after
  sensitive words are masked.

Tests added for each scenario.
warelik added 25 commits August 22, 2026 00:41
Unsigned or non-replayable assistant responses no longer clear the
Claude-compatible thinking replay cache. Only turns that carry signed
thinking are appended; earlier signed turns stay available for later
requests that echo them. Added regression test:
TestClaudeExecutorCompatThinkingReplayRetainsSignedTurnAfterUnsignedResponse.
restoreClaudeThinkingReplayContents now anchors the match window to the
first assistant message present in the incoming request. Cached turns older
than the client's oldest echoed assistant message are marked consumed before
matching, so compacted or truncated history does not mis-align replayed
thinking signatures onto later turns or leak dropped turns.

Added regression tests for truncated history and for a leading unsigned
assistant with later signed assistants.
…mpaction

Sessionless clients that compact older messages change messages.0, which
until now recomputed the Claude replay session key and orphaned the cache.
The fallback scope now registers a per-caller message hash for every user
and assistant message it sees, and resolves the original conversation key
from any remaining message in a compacted request. Cached assistant turns
are also registered by their client-visible non-thinking shape. This keeps
replay state reachable after history truncation.

Added TestClaudeExecutorCompatThinkingReplayRetainsScopeAfterHistoryCompaction.
- Home KV: RegisterClaudeThinkingReplayAlias stores a per-message KV
  entry so different instances can resolve the original conversation
  scope. ResolveClaudeThinkingReplaySessionKey looks up the same alias
  keys in home mode.
- Local map: aliases are now timestamped, TTL-enforced, and bounded by
  ClaudeThinkingReplayCacheMaxAliases; expired aliases are purged during
  the periodic cache cleanup and on registration.
- Alias caller hash uses length-prefixed field writes to prevent
  concatenation collisions (e.g. auth.ID "ab" vs "a" + caller-scope "bc").
…ed-alias scopes

- Bound Home KV replay aliases by credential with an LRU-capped per-modelFamily
  index (ClaudeThinkingReplayCacheMaxAliasesPerCredential). Evicted alias keys
  are deleted from KV alongside the index.
- Store each replay alias as a multi-session list instead of a single mapping
  so two sessionless conversations that share a visible message do not
  overwrite each other.
- Tag each alias entry with the conversation's first user hash and score alias
  resolution by message role weight plus a first-user-match bonus. This keeps
  shared tool_result/user messages from resolving the wrong conversation.
- Add internal/cache tests for alias scoring, expiry, home multi-session
  resolve, and per-credential cap.
…ate turns

- Update Home KV replay alias values with compare-and-swap retries so
  concurrent registrations for the same message hash merge instead of
  overwriting each other's session list.
- Refuse to resolve ambiguous alias ties; a session only wins when it has
  the unique highest score. This prevents a compacted request with only a
  shared message from restoring the wrong conversation's hidden thinking.
- Anchor replay restore to the latest cached suffix that matches the
  request's full assistant sequence, so duplicate assistant turns resolve
  to the correct retained signature after truncation or compaction.
- Add regression tests for atomic alias list updates, tie rejection, and
  duplicate-turn suffix anchoring.
- Key the Home KV alias index by the credential hash embedded in
  modelFamily instead of the full modelFamily, so the 256-entry
  per-credential cap is enforced across all model names a caller uses.
- Collect evicted alias keys and only delete them after the index
  KVCompareAndSwap succeeds, preventing a concurrent refresh from being
  erased before the index update wins.
- Add regression tests for cross-model per-credential cap and for
  eviction remaining atomic with the index CAS.
…iases, recheck evicted aliases

- Require an explicit conversation nonce (client_metadata.conversation_id,
  conversation_id, or X-Conversation-Id) for the sessionless replay fallback
  so identical conversation openings no longer share one cache.
- Roll back committed alias values when the per-credential index update fails,
  preventing half-registered unindexed aliases.
- Re-read the alias index before deleting evicted aliases and skip any that
  have already been re-registered by a concurrent worker.

Tests added/updated for all three behaviors.
rollBackClaudeThinkingReplayAliasHome now treats the value match as the
authoritative condition. An unreadable index no longer blocks rollback of an
orphan alias; the index is still consulted as a best-effort guard when readable.

Add TestClaudeThinkingReplayAliasHomeRollbackConditionalOnCommittedValue to
verify rollback succeeds even when the per-credential index cannot be read.
…/caller hash

headerFirstValue in both claude_thinking_replay.go and
claude_thinking_replay_session.go now trims the header value and treats
whitespace-only values as missing. This prevents whitespace-only
User-Agent/X-App/X-Codex-Client-Id from churning the replay key and stops
whitespace-only X-Conversation-Id from being accepted as a conversation nonce.

Tests added in both packages.
registerClaudeThinkingReplayAliasHome now keeps the evicted index record
(timestamp) and, before deleting an evicted alias value, re-reads the alias
value and skips deletion if it has been repopulated by a concurrent worker
(newer session timestamp than the evicted index record).

This closes the window where a worker re-registers an alias and updates its
value but has not yet updated the index when the eviction pass runs.

Add TestClaudeThinkingReplayAliasHomeRechecksEvictedAliasValue to guard the
regression.
If the alias value KVCompareAndSwap returns an error, the command may have
been applied before the error reached the client. rollBackClaudeThinkingReplayAliasHome
is now called with the attempted raw so an orphan value is removed if it
matches the last attempted write.

Add TestClaudeThinkingReplayAliasHomeRollBackOnFailedRegistration to guard the
regression.
…e clients

- ClaudeThinkingReplayConversationSessionKey now returns (key, usedNonce).
- With a conversation nonce, the key is derived from stable caller fields
  only, so it survives history compaction and gives identical openings with
  different nonces distinct scopes.
- Without a nonce, it falls back to a content-derived key (first user message
  + system), preserving replay for stateless clients.
- claudeThinkingReplayScopeFromRequest resolves content-derived keys through
  alias lookup so history compaction does not orphan the replay cache.
- Tests added for nonce-stable keys, no-nonce content keys, distinct scopes,
  and end-to-end no-nonce compaction replay.
- Observe the upstream Claude SSE in ExecuteStream before translation and
  cache the accumulated content when the message completes, so cross-format
  (e.g. OpenAI) streams can replay signed thinking turns.
- Validate Claude thinking signatures before caching using
  signature.HasDecodableClaudeThinkingSignature, so only provenanced signed
  turns are retained and malformed signatures do not evict earlier replay state.
- Add end-to-end test for cross-format stream replay and update existing
  tests to use decodable test signatures.
claudeThinkingReplayFindStartIndex now only accepts a full assistant
sequence match. Partial prefixes are rejected, so an unsigned assistant turn
that was intentionally not cached no longer causes an earlier cached turn to
be restored with the wrong hidden thinking/signature.

Add TestClaudeThinkingReplayFindStartIndex_RefusesPartialAnchor.
kimiThinkingReplayStreamAccumulator now recognizes citations_delta events
and preserves the citation objects on the cached text block instead of
abandoning the stream. This lets cross-format streams that include web-search
citations be cached and replayed without losing signed thinking.

Add TestKimiThinkingReplayStreamAccumulator_PreservesCitations.
Set fallbackKey only when the conversation session key is content-derived
(usedNonce is false). Nonce-based scopes are stable across compaction and do
not need alias resolution, so registering every message as an alias for them
only wastes the shared alias cap and can evict aliases needed by no-nonce
conversations.

Add TestClaudeExecutorCompatThinkingReplayNoAliasForNonceScope.
…tle purge scans

- Add ClaudeThinkingReplayCacheMaxAliasBytes (64 MiB) and enforce it in
  enforceClaudeThinkingReplayAliasLimitsLocked.
- Maintain claudeThinkingReplayAliasCount so enforce does not rescan the map
  just to count entries.
- Evict the oldest-by-timestamp alias when a per-key list exceeds
  ClaudeThinkingReplayCacheMaxAliasesPerKey, not the FIFO list[0].
- Throttle purgeExpiredClaudeThinkingReplayAliasesLocked to at most once per
  minute, so long histories do not scan the full alias map per message.

Add tests for byte-limit enforcement and per-key LRU eviction.
…lay streams

The replay accumulator in the cross-format stream branch now observes the
Claude SSE line after MCP tool names have been restored, so the cached replay
matches the caller-visible tool names and the restored opaque thinking/signature
is not lost on the next continuation.
claudeThinkingReplayAliasBytes now counts each map key only once and each
session/firstUser entry once, instead of adding len(key) on every alias
insertion. This prevents the byte counter from inflating on per-key churn and
correctly evicts under the 64 MiB cap.
…miss

claudeThinkingReplayFindStartIndex now finds the longest cached suffix that
matches a contiguous block of assistant turns, so leading/trailing unsigned or
new turns do not break an otherwise valid replay anchor. When no cached suffix
matches at all, restoreClaudeThinkingReplayContents refuses the fallback match
loop instead of restoring individual turns that could pair with the wrong
hidden signature.
enforceClaudeThinkingReplayAliasLimitsLocked now deletes aliases by session
key identity instead of the pre-mutation slice index, so multiple candidates
from the same key in one batch no longer shift indexes and evict the wrong
alias.
Pin airouters-11 replay-alias rules in registry tests:
(a) compaction-stable scopes,
(b) Home-KV per-credential alias cap,
(c) atomic alias eviction with index update,
(d) identical vs distinct conversation scope determinism.

Tests skip with Plus kaitranntt#209 / stock #5150 references when the alias
registry behavior is not active (e.g. on main before kaitranntt#209 lands).
@warelik
warelik force-pushed the ao/airouters-21-replay-alias-doctrine branch from e8e2182 to ad95d40 Compare August 22, 2026 05:00
warelik added a commit to warelik/CLIProxyAPIPlus that referenced this pull request Aug 22, 2026
warelik added a commit to warelik/CLIProxyAPIPlus that referenced this pull request Aug 22, 2026
@warelik

warelik commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

test-only commit ad95d40 requires APIs introduced by #209 and an identical test already exists in #209 commit e8e2182; the fork mission-integration path gets the same doctrine through #209. Closing as superseded/duplicate.

@warelik warelik closed this Aug 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant