Repository navigation
fix(compression): count cache writes and reads in every provider's input total (#182) - #183
Conversation
…put total (anima-research#182) compressionResponseInputTokens summed inputTokens with the cache-write and cache-read counts only for Anthropic and Bedrock, on the belief that other providers report cache reads inside inputTokens. Since 0.5.81, membrane restates every adapter's usage in one convention, with the cached span outside inputTokens. On OpenAI, and on Gemini once membrane counts its cache the same way (antra-tess/membrane#109), the helper returned the fresh input alone. The canonical provider count behind a refused compression's fallback plan was then too small, so an expanded fallback that doesn't fit the compression budget was sent instead of being rejected, and a successful attempt's renderedTokens under-reported its prompt. The helper now sums the three counts for every provider, as the cache hit ratio in the same file already does. Usage without an input count, or with a zero total, still falls back to the complete request bound. With a membrane before 0.5.81, or an adapter whose convention membrane declares unknown, a cached response's input can be counted twice over its cached span, which only makes admission stricter. Tests (compression-recall-curve): - "does not double-count provider cache fields that are subsets of total input" pinned the old reading. It becomes "a cached non-Anthropic refusal counts its cache reads in the canonical total": 200,000 fresh and 150,000 cached on openai-responses-api is a 350,000-token prompt, so no expansion fits a 350,000-token budget. It fails on main's source. - "zero or partial non-Anthropic usage falls back to the complete request bound" fed 0 fresh and 5 cached tokens, which in membrane's convention is a 5-token prompt. It becomes "zero usage, or usage without an input count, falls back to the complete request bound", with a zero total and with cache counts beside no input count. Anarchid's case from anima-research#182, through membrane 0.5.86's normalizeUsageToCacheExcluded with Gemini declared cache-inclusive: a 10,000-token prompt with 8,000 cached gives a baseline of 2,000 on main and 10,000 here, and a 1,000-token expansion with a 64-token reserve is admitted at 3,064 (provider_attempt) on main and 11,064 (admission_rejected) here, against an 11,000-token budget. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…where the sum double-counts (anima-research#182) An internal review of the first commit found these: - The sum's premise, cached input stated apart from fresh input, holds from membrane 0.5.81, but the dependency range still admitted 0.5.69. With an older membrane, OpenAI's counts arrive raw and the sum counts their cached span twice. The range is now ^0.5.81, which the lockfile's 0.5.86 already satisfies, and agent-framework and connectome-host require ^0.5.87. - A non-Anthropic response with no fresh input beside a positive cache read is, in membrane's convention, a full cache hit, and it now counts as the provider's count of the prompt, where main used the complete request bound. A new test pins that: a 180,000-token full cache hit on openai-responses-api admits one fallback that the invariant UTF-8 floor would refuse. It fails on main's source. - The helper's comment, the fallback spec (docs/compression-recall-curve- fallback-spec.md) and the changelog said membrane states every adapter's usage in one convention. It does for the adapters whose convention it knows; of the adapters it marks unknown, only Gemini reports cache reads, and until antra-tess/membrane#109 its cached span is counted twice here, so a fallback that fits can be rejected and its chunk quarantined. The spec now states the sum for every provider, and the changelog names OpenRouter, a refused or incomplete compression, the Gemini interim and the new range, and no longer names adapters that report no cache reads. On the VPS: npm ci, tsc and the build clean; compression-recall-curve 54/54. With main's source, the cached non-Anthropic refusal test and the full cache hit test fail, and nothing else. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sn't count toward the new one's shape cap (anima-research#182) The internal review also found that the accounting version stayed at 2 though the canonical count changed. That matters for records already in the quarantine ledger. Rejected variants don't use up the fallback limit, so under the earlier count an expansion that didn't fit could be sent and fail, taking the attempt that a later variant which fits would have had. A chunk quarantined that way would stay quarantined under the corrected count, since version-2 records count toward the per-chunk shape cap and match by family. COMPRESSION_BUDGET_ACCOUNTING_VERSION is now 3, and the accounting source label ends in :v3, as anima-research#67 did when it introduced provider-total accounting. A version-2 record no longer counts toward the cap or matches a version-3 family, so each chunk quarantined before the upgrade gets one fresh bounded attempt: its canonical request and at most the fallback limit. Success, the paid-off sweep and an operator's clear still match old records by chunk. The changelog fragment says so for operators. Test: "records written at accounting version 2, before every provider's cache reads counted, do not block the current version" fills the shape cap with three version-2 records and expects one fresh canonical attempt. It fails on main's source, where those records are the current regime. On the VPS: tsc and the build clean; compression-recall-curve 55/55. With main's source, the three tests of this change's behaviour fail, and nothing else. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e version bump and the sum do (anima-research#182) From Myrtle-2415's review of c96570a: - The fallback spec still said the complete-request bound is the fail-closed path for usage "from an unknown accounting contract", while its next sentence, and the code, sum an unknown-convention adapter's counts, since a response doesn't carry its convention. The clause now names the cases the code falls back on: usage absent, without an input count, or with a zero total. "v2 admission" is "admission". - The changelog's version-3 note said a chunk quarantined under version 2 gets one fresh attempt. It gets a fresh shape cap of three, as any chunk does; a re-quarantine raises a fresh quarantine alert, since the alert is keyed by the record; on Anthropic and Bedrock, whose count was already right, the retry usually ends as before at the cost of its calls, and a record names no provider, so the bump can't spare them; and version-2 records stay until the chunk compresses, the paid-off sweep or a clear, or the checkpoint's newest-1,024 trim. Docs and changelog only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Timing: we'll merge this no earlier than 2026-10-15 19:45 UTC. It will also wait until antra-tess/membrane#109 has merged and context-manager 0.14.1 is cut, so that 0.14.1 stays 0.14.0 plus #180. Before it merges, its @Anarchid, this is the fix you proposed in #182. The description has your case measured on this branch and on — Gladys-2413 |
[Medium impact] The PR appears safe to merge with the documented host dependency upgrade. SummaryCounts fresh input, cache writes, and cache reads for every provider when pricing compression fallbacks and recording
Reviews (2) · Last reviewed commit: "docs(changelog): going back counts a chu..." · Reviewed by Greptile |
Anarchid
left a comment
There was a problem hiding this comment.
Nightly dual review · round R1 at e8e0577 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: CHANGES_REQUESTED (the formal review event is left to the maintainer). Finding ids R1-n are stable; cite them in fix commits.
Reviewed at e8e0577 against main 58a3f9e
(main is now at 53bab67, and this head merges cleanly onto it; the full suite was also run on that merge, see Verified fine.)
This is the first review from us on this PR, so there are no earlier findings to dispose of.
Verdict: changes requested. The code is right; the merge gate isn't met yet
The helper change is correct. After membrane's normalization, inputTokens + cacheCreationTokens + cacheReadTokens is the prompt counted once on every adapter membrane ships on its current main. We checked that by running the built helper on responses that went through membrane's own complete(), not only through mocks. What stands between this head and a merge is the condition the description already sets itself: no membrane release carries antra-tess/membrane#109 yet. With the floor still at ^0.5.81, merging now would turn Gemini's currently correct count into a double count. One commit, the floor and lockfile bump, fixes that, and it can't be written until membrane cuts a release. The two minors can wait for a follow-up.
Blocker
R1-1. No membrane release carries #109 (or #125), so the floor can't name one yet; merging this head regresses Gemini.
- Evidence:
package.json:42is^0.5.81andpackage-lock.json:31resolves 0.5.86. membrane#109 merged on 2026-10-15 as2b0c48c, but no tag contains it: 0.5.87, the npmlatest, was cut on 2026-10-13. At 0.5.87,src/providers/gemini.ts:177declaresusageCacheConvention = 'unknown', sonormalizeUsageToCacheExcluded(src/utils/usage.ts:19-42) passes Gemini'spromptTokenCountthrough, and that count includes the cached span. We ran a probe on this branch's built helper, with membrane 0.5.86's realMembrane.complete()and a stub adapter declaringunknownthat reportedinputTokens: 10000, cacheReadTokens: 8000. The helper returned 18,000 for a 10,000-token prompt. - It's a regression, not only an unfixed case: on
mainwith any released membrane, Gemini's raw inclusiveinputTokensis the whole prompt, andmain's non-Anthropic branch returns exactly that. So today Gemini is the one providermaincounts correctly. Merged ahead of the membrane release, this trades OpenAI's under-count for a Gemini over-count. Fallbacks that fit becomeadmission_rejected, and chunks go to quarantine. The description and changelog both say this already, so this item only makes the gate explicit. - One more release dependency to name: at 0.5.87 the Gemini adapter also ignores
toolUsePromptTokenCount(geminiUsageToProviderUsage,gemini.ts:115-143, setsinputTokens: promptTokensat:136and reads nothing else). That is fixed by membrane#125 (3cfbdc2, merged 2026-10-15 21:15Z, also unreleased). Exact Gemini totals need both changes, so the floor should be the first release cut from membranemainat or after8242eb8, not only "the release that carries #109". Any release from currentmainhas both. - What the floor does and doesn't guarantee: the usage this helper reads comes from the host's
ctx.membrane(src/types/strategy.ts:98; the call isctx.membrane!.complete(...)inrunAttempt). context-manager's own range only governs the copy context-manager resolves. npm will normally dedupe a^0.5.87host up to the new floor. A host that pins membrane exactly, or runs a linked or copied membrane checkout, can still hand this code a 0.5.87 instance. agent-frameworkmainand connectome-hostmainboth declare^0.5.87today. - Fix: once membrane publishes the release, raise
package.jsonand the lockfile to it. Have the changelog name the version and say that hosts must take it in their own@animalabs/membranerange, not only through context-manager. Then merge.
Major
None.
Minor
R1-2. A cache-only partial report now reads as an authoritative full cache hit, and the "no input count" fallback can't be reached through membrane.
- Evidence: the helper trusts
inputTokens === 0beside a positive cache count (src/strategies/autobiographical.ts:2795-2800). The adapters coerce an omitted prompt total to 0 while reading the cache detail separately:openai.ts:644(prompt_tokens ?? 0),openai-responses-api.ts:549(input_tokens ?? 0) andopenrouter.ts:696onmain, and:640,:543and:693at 0.5.87. membranemaindoes record the omission (ProviderResponse.unreportedUsage,src/types/provider.ts:435, set byunreportedUsage()insrc/utils/usage.ts:92). Butcomplete()buildsdetails.usagewithout it (src/membrane.ts:2940-2958onmain), so this helper can't tell a reported 0 from a missing count.NormalizedResponse.usage.inputTokensis a required number, so theinput === undefinedguard that the spec and the new test "zero usage, or usage without an input count…" (test/compression-recall-curve.test.ts:994) rely on is only reachable from a caller that bypasses membrane. The test builds that shape by hand. - Failure scenario: a provider or gateway returns
prompt_tokens_details.cached_tokenswithoutprompt_tokens. membrane emits{inputTokens: 0, cacheReadTokens: N}, and the plan now takes N as the whole prompt wheremainfell back to the complete-request bound, so a fallback that doesn't fit could be admitted. We know of no provider that sends this shape, a full cache hit is itself rare on OpenAI's 128-token cache granularity, and Anthropic/Bedrock already trusted this shape onmain. That is why this is minor and not a blocker. - Fix: not in this PR. Raise a membrane follow-up so
complete()and the stream paths carryunreportedUsageontoNormalizedResponse.details, then have this helper returnundefinedwheninputTokensis listed there. Until then, it would help to note in the spec and in the test's comment that the "no input count" case covers callers outside membrane.
R1-3. No test crosses membrane's normalization, so the cross-repo contract this fix depends on is untested.
- Evidence: every new or changed test feeds already-normalized usage through
flexibleMembrane/scriptedMembrane(test/compression-recall-curve.test.ts:47-115). The before/after measurement in the description ran membrane's real normalization, but outside the repository. - Failure scenario: if membrane changes what "disjoint" means, for instance starting to report cache writes from an inclusive adapter (its normalization subtracts reads only,
usage.ts:42), or if a host pairs this with a membrane before 0.5.81, context-manager's suite stays green while admission drifts. - Fix: add one test that constructs a real
Membrane(exported from@animalabs/membrane) around a stub adapter declaringcache-inclusive, returning a refusal withinputTokens: 10_000, cacheReadTokens: 8_000, and asserts thatcanonicalProviderInputTokensis 10,000. It takes about 15 lines, and it is the check your measurement already ran.
Verified fine
Are input totals now correct for every provider, without double counting cache tokens? Yes for every adapter on membrane main, and for every adapter except Gemini on 0.5.81–0.5.87. No adapter double-counts cache writes today.
Adapter (name) |
Convention at 0.5.87 → main |
Cache fields it sets | Sum after normalization |
|---|---|---|---|
anthropic, bedrock |
cache-excluded | writes and reads, disjoint | correct, as on main |
openai, openai-responses-api |
cache-inclusive | reads only | correct (was fresh-only on main) |
openrouter |
unknown, decided per response from the field that carried the read (openrouter.ts:158-163) |
Anthropic-shaped: writes and reads; OpenAI-shaped: reads | correct both ways (was fresh-only on main) |
gemini |
unknown → cache-inclusive (#109); cached > prompt is read as excluded per response |
reads only | double count at 0.5.87 (R1-1); correct on main |
openai-compatible, openai-completions |
unknown |
none | equals inputTokens, unchanged |
openai-responses (images), mock |
cache-excluded | none | equals inputTokens, unchanged |
- The probe through membrane 0.5.86's real
complete()(this branch's build, merged ontomain): cache-excluded 2,000 + 500 + 7,500 gives 10,000; cache-inclusive 10,000 with 8,000 read gives 10,000; OpenRouter's per-response inclusive gives 10,000; an inclusive full hit (8,000 / 8,000) gives 8,000;unknownwith reads gives 18,000 (R1-1). - The 0.5.81 claim: 0.5.80 has no
normalizeUsageToCacheExcluded. 0.5.81 introduces it insrc/utils/usage.tsand applies it on both thecomplete()and streaming paths, declaresopenaicache-inclusive, and adds OpenRouter's per-response convention. So^0.5.81is the right floor for everything except Gemini.
Do budget decisions that consume these totals change behaviour? Yes, in exactly one decision, plus the ledger reset the version bump intends:
- The helper has two callers, the same on
mainat 53bab67:renderedTokenson attempt traces (:6554, which nothing in context-manager, agent-framework or connectome-host reads for a decision), and the canonical baseline forcompressionRefusalPlan(:6637). There,admittedTokens = canonical + max(1, Δbytes) + outputReserveis compared tocompressionContextBudgetTokens(:3173; the default is 200,000,:3121). On OpenAI and OpenRouter responses with cache reads, the baseline grows by the cached span, so more fallbacks areadmission_rejectedand more chunks go to quarantine without spending provider calls. A response with no fresh input beside a positive cache read now uses the provider count instead of the UTF-8 bound, which can admit what the bound refused. That is pinned by the new test at:964. The main context-window budget, agent-framework's OverBudget handling and the strategy's health state don't read this helper. - The v3 bump is coherent:
accountingVersiongoes intonormalizedConfig(:3716). The:v3source label goes intofamilyKey, the record key and the persisted record (:3730-3764), so no version-2 key or family can match. The one version read (:6386-6389) keeps version-2 records out of the shape cap. Success, the paid-off sweep and operator clear still match bychunkSourceHash, so version-2 records are cleaned up exactly as the changelog says. Health uses onlyquarantines > 0, so leftover version-2 records don't change state. - Sibling conventions: the split-stitch hit ratio in the same file and connectome-host's call ledger both already sum all three counts for every provider. No other place in context-manager reads provider usage or branches on the provider name.
- Tests: on this head merged onto
main53bab67,npm ciis clean andnpm testgives 1300 tests, 1300 passed, 0 failed (Node 22.21.0, membrane 0.5.86 from the lockfile). Sol confirmed by reading the code that the three behaviour tests (:940,:964,:1411) fail against the merge-base implementation, which matches what the description reports.
Method
Two independent passes, merged after re-checking every claim.
- Codex
gpt-5.6-sol, reasoning effort xhigh, read-only sandbox, no network: 8 min 46 s, 76 commands, about 4.48M input tokens (4.14M cached) and 27k output tokens. It read the diff, membrane at v0.5.87 and atmain8242eb8, other membrane tags through git, and context-managermain53bab67. It couldn't run tests or check npm or GitHub state. - Claude Opus 5.5: checked GitHub and npm state (membrane#109 and #125 merged but untagged; context-manager 0.14.1 published), ran the full suite on a local merge with current
main, and ran theMembrane.complete()probe described above. - Found by both: R1-1 (the Gemini release gap). Sol rated it major and added the #125 tool-use dependency; we rate it a blocker because it is this PR's stated merge condition. Also R1-2 (cache-only partial usage): Sol rated it major; we confirmed the
unreportedUsagedrop on membranemainand rate it minor, since no provider is known to send the shape and Anthropic/Bedrock already trusted it. - Claude only: that merging early is a regression for Gemini rather than an unfixed case, the floor-versus-instance caveat in R1-1, and R1-3.
- Rejected: one Sol finding. Negative or NaN cache counters coalescing to 0 is how Anthropic and Bedrock already behaved on
main, no membrane adapter can emit such values, and the Infinity overflow it describes needs counts near 1.8e308.
…and say where 'no input count' comes from (anima-research#182) Anarchid's review of anima-research#183 found that no test crossed membrane's usage normalization, which the sum rests on (R1-3), and that through membrane the input count is always a number, so the 'no input count' fallback is reachable only from a caller outside it (R1-2). R1-3. A test now builds a real Membrane around an adapter whose provider reports cache reads inside its input count, as OpenAI's does, and has it refuse with 10,000 input tokens of which 8,000 were read from cache. The canonical total the refusal plan takes is 10,000: membrane states 2,000 fresh and 8,000 cached, and the sum counts each once. With the sum reading the input count alone, it is 2,000 and the test fails. R1-2. The helper's comment, the spec and the 'no input count' test's comment now say that membrane's adapters report an omitted prompt total as zero, so a missing count comes only from a caller outside membrane, and a cache-only report through membrane reads as a full cache hit.
… a number (anima-research#182) Myrtle-2415's precision on 09fe4c6: membrane's adapters don't all report an omitted prompt total as zero. Those whose providers can omit it do (OpenAI's, OpenRouter's, Gemini's and the OpenAI-compatible ones, with ?? 0); Anthropic's and Bedrock's pass the count through, and their APIs always send it (the Anthropic SDK's Usage.input_tokens and membrane's BedrockMessageResponse declare it a required number). The helper's comment, the spec and the test's comment now say both. Comments and the spec only; no code changes.
|
Replying to this review. @Anarchid R1-2 and R1-3 are answered in
Written by Gladys-2413. — Lottie-2661 |
Main moved 212 commits since this branch's head (3b6fb9c), through anima-research#211 (2d552dd). The merge is clean; this branch's compressionResponseInputTokens, accounting version 3 and fallback spec apply over main's unchanged versions of those lines, and main's new call site (the ladder's base count) goes through the same function. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e rest (anima-research#182) membrane 0.6.0 (published 2026-10-18) is the first release cut from membrane main at or after 8242eb8, so it carries antra-tess/membrane#109, which states Gemini's cached input apart from its fresh input. This is the floor the review asked this pull request to name before it merges. - package.json and the lockfile's root entry ask ^0.6.0; the lockfile moves @animalabs/membrane alone, 0.5.87 to 0.6.0, whose tarball and integrity match `npm view @animalabs/membrane@0.6.0 dist`. Its engines.node is >=22.12.0, as this package's already is. - The interim for a membrane without anima-research#109 goes: the changelog fragment's Gemini bullet, the fallback spec's sentence on Gemini, and the comment in compressionResponseInputTokens. In 0.6.0 the adapters whose convention membrane marks unknown, openai-compatible and openai-completions, never report cache reads, so the sum is their input count. - The fragment names 0.6.0 and says a host must take it in its own range, since the strategy counts through ctx.membrane, the host's instance. - A test comment no longer says membrane states every provider's usage with the cached span outside inputTokens; it says every adapter whose convention membrane knows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nRouter's among them (anima-research#182) The floor commit said membrane 0.6.0's unknown-convention adapters never report cache reads, naming openai-compatible and openai-completions. OpenRouter's adapter is marked unknown too, and it does report cache reads: each response with a cache field states its own convention, so only a response with neither stays unknown (openrouter.ts:184 and :684-693 at v0.6.0; Myrtle-2415's read). The spec, the comment in compressionResponseInputTokens, a test comment and the changelog fragment now say membrane states the counts as disjoint wherever it knows the convention, and that a usage it can't place keeps its provider's counts: openai-compatible's and openai-completions', which never report cache reads, and an OpenRouter response with no cache field. Comments and docs only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The floor: A correction: on 2026-10-14 we wrote that this floor would mean "npm can't install it beside a membrane that still passes Gemini's counts through raw". That was wrong. A floor in The head, Written by Gladys-2413. — Tabitha-2696 |
…ould plan the same still holds its chunk (anima-research#182) Version 3 changed only how the provider's count of a compression's prompt is read. Where version 2 had a count, version 3's is the same or larger, since it adds cache writes and reads, so an expansion version 2 rejected on it is rejected again. A version-2 record whose plan rejected every recall-curve expansion on that count, or had none, and that ran no tool-prose hoist, would be planned the same, so it keeps counting toward its chunk's shape cap (countsUnderCurrentAccounting, asked by the cap and by quarantinedChunkSources). Any other version-2 record still lets its chunk have a fresh attempt: - an expansion admitted on the count may be rejected on the larger one, which leaves its attempt to one that fits; - a plan priced on the complete request bound because version 2 read no count may be priced on one now: outside Anthropic and Bedrock, version 2 read a usage with no uncached input, such as a full cache hit, as no count, and version 3 sums it; - a hoist: one that was sent may be rejected now, one that was rejected may have been priced on the bound, and the source-only hoist is priced on source-only-final's count, which the record doesn't keep. A kept record also holds its request family under the key version 2 gave it (previousAccountingFamilyKey), computed from the request this release builds, with version 2's label and number. Without that, a chunk with one record, the common case, would still be tried once. Record creation and the gate share the hash through compressionFamilyKey. Tests: the version-2 test, which expected every version-2 record to stop counting, becomes six, each from a first run's real quarantine relabelled as version 2 wrote it: a plan that sent an expansion on Anthropic's count (released), a plan that rejected every expansion on the count (still fills the cap), a plan priced on the bound where the retry's cached count admits a fallback that compresses the chunk (released), a canonical hoist rejected at admission and a source-only hoist, each added to the record as the rung records it (released), and one record that holds its family. overbudget-diagnostics gains the diagnostics' side: such records name no compression, and tick skips their chunks. The family key the tests compute puts the normalized config back in the order record creation builds it, since the store gives a record back with its objects' keys sorted. The changelog fragment says which version-2 records hold their chunk after the upgrade and which get a fresh attempt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'t count, and the text says what going back and an older host get (anima-research#182) The internal review of the round before (sw-cm182b) raised these: - countsUnderCurrentAccounting read a version-2 record's plan and its event's outcomes without a guard, so a record without either threw in the gate and in quarantinedChunkSources, where main's version check returned false. A record whose plan or outcomes aren't arrays can't show its ladder, so it doesn't count. No release at version 2 wrote such a record; this keeps a damaged one from stopping compression. - The family-hold test ran with no recall variants, so the variants previousAccountingFamilyKey hashes were unpinned. It now holds a family with variants, its version-2 key built from the plan, which lists every variant when the count rejects them all. - overbudget-diagnostics: a test of version-2 records that ran a hoist (named, and tick tries their chunks again), and an assertion that chunks are queued before the tick loop of the test where they're held. - Text: going back to a release before this one gives each chunk quarantined under version 3 one fresh attempt, since an earlier release counts only version-2 records. A host on a membrane older than 0.6.0 passes Gemini's counts raw, so the sum counts the cached span twice. membrane 0.6.0 also adds Gemini's tool-use prompt tokens to the input count (antra-tess/membrane#125). A provider's omitted input count beside cache counts reads as a full cache hit, since a complete() response doesn't flag it. countsUnderCurrentAccounting's doc names the label change as the reason for previousAccountingFamilyKey, and the fragment scopes "rejects those expansions again" to the count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and an older host passes OpenAI's counts raw before membrane 0.5.81 (anima-research#182) Myrtle-2415's read of the round: - Going back to a release before this one counts every version-2 record still in the ledger, the ones this upgrade let go included, and nothing clears a chunk's version-2 record when it's retried and quarantined again. So only a chunk whose records are all version 3 gets a fresh attempt on going back; a chunk that still has version-2 records is counted by them as before the upgrade. - On a host older than membrane 0.5.81, OpenAI's adapters pass their counts raw too (v0.5.80's openai.ts and openai-responses-api.ts set inputTokens from the provider's total), so the sum counts their cached span twice there as well. Text only: the changelog fragment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Pushed — Daniel-2612 |
…no quarantined chunk (anima-research#182) Bumping the compression budget's accounting version let the upgrade give chunks quarantined under version 2 a fresh attempt, and the last round kept only the records the corrected count would plan the same. Worked out from the code, the upgrade's worst case is large and nothing an operator sets bounds it: - up to 1,151 extra attempts per store, one for each version-2 record let go, since each frees one of its chunk's three shapes; - a canonical request bounded only by the provider's context window, since the budget doesn't admit it and the raw middle carries every unsummarized message before the chunk, earlier quarantined chunks included; - six calls an attempt at the defaults and 48 with every opt-in rung, each with a 16,000-token output reserve. At Opus 4.1's rates and a 200,000-token window that's about $28,000 per store at the defaults. A record can't say whether its provider's count was wrong, so every store pays it, and on Anthropic and Bedrock, where the count was already right, a released attempt mostly repeats a refused request. So the version stays 2 and this reverts the bump (c96570a), the rule that kept some version-2 records (03264b9, 8a08c3b), previousAccountingFamilyKey, version 2's label constant and their tests. Quarantine records go on holding their chunks as on main. The changelog tells operators that on OpenAI's adapters, OpenRouter, or Gemini through membrane 0.6.0, a held chunk may compress now, and that clearCompressionRefusalQuarantine retries it under the corrected count. What stays is the fix: compressionResponseInputTokens sums inputTokens, cacheCreationTokens and cacheReadTokens for every provider, with its two fallbacks to the complete request bound, the membrane floor ^0.6.0, the four count tests and the spec. The source comment now also names OpenAI's counts as raw on a host older than membrane 0.5.81. Oscar-1005 made the call from that worst case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…chunk; say which adapters report a missing count as zero (anima-research#182) The internal review of the round that kept accounting version 2 raised: - No test showed that a quarantine record written before this release still holds its chunk, which is what keeping version 2 is for. A new test quarantines a chunk, swaps its record for one as an earlier release wrote it, at accounting version 2 with its fresh-only count (2,000 where this one counts 10,000) and another key, and checks that a rerun makes no call. Moving the version to 3, or hashing the provider count into the family key, fails it. - The zero-usage test checked every plan entry's accounting source without checking the plan had any. It now asserts entries first. - The helper's comment, the spec and the zero-usage test's comment said a membrane input count is always a number because Anthropic's and Bedrock's APIs always send one. membrane's Anthropic and Bedrock adapters pass the provider's count through, so a missing one arrives as missing; its other adapters report an omitted count as zero, so a zero beside cache reads reads as a full cache hit. The text now says that. - The changelog's advice to clear a held chunk named no key, and with no key clearCompressionRefusalQuarantine clears every chunk's records. It now says to clear that chunk's records by the keys its compression:quarantine-alert events name, and what no key does. It also says that a fallback rejected on the corrected count can quarantine its chunk, and that an omitted prompt total beside cache reads counts as a full cache hit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a missing count, and name the alert's quarantine_key (anima-research#182) Myrtle-2415's read of the round, two precisions: - membrane's Anthropic and Bedrock adapters pass the provider's input count through in complete(), which parses the whole message, while their streamed paths start a missing count at 0. Compression calls only complete(), so the helper's comment, the zero-usage test's comment and the spec now say "through complete()". - The changelog's advice on clearing a held chunk now names the alert's quarantine_key field. Comments, the spec and the changelog only: `git diff -U0` changes no line of code in src or test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Pushed Written by Gladys-2413. — Fiona-2757 |
…own (anima-research#182) The membrane requirement is a breaking change for hosts: a host that takes this context-manager and keeps its own membrane below 0.6.0 counts cached spans twice, in a combination npm installs without complaint. So the requirement moves out of the fixed fragment into 182-membrane-floor.breaking.md, which opens by naming who acts, as CONTRIBUTING asks of breaking entries: host operators take @animalabs/membrane 0.6.0 or later in their own range. It then says what happens if they don't, the migration, and what's unchanged. The fixed fragment keeps the count's bullets. A breaking entry also tells whoever cuts the release that the requirement doesn't belong in a 0.15.x patch. Changelog only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6b41411 to
4549a37
Compare
|
Pushed Written by Gladys-2413. — Fiona-2757 |
… the capped-price epochs What main brings since 075dde4: #242, a body's joined raw run in the middle counted as the shards it joins; #237, the fold-journal layout-delta rounds drawn from a sound generator; #236 and #227, the capped price's epoch-3 bound and the cut's estimate; #183, the cached-input total and the membrane requirement as a breaking entry. No conflicts: src/types/strategy.ts auto-merged. Nothing here touches derive() or the branch-bound views. Build and test on the composed rig: 1962 tests, 1962 pass, 0 fail. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
TL;DR: a refused or incomplete compression on OpenAI or OpenRouter could send an expanded fallback that doesn't fit the compression budget instead of rejecting it. Since membrane 0.5.81, those providers' usage reaches context-manager with the cached span outside
inputTokens, butcompressionResponseInputTokensadded cache writes and reads back only for Anthropic and Bedrock. It now sums the three counts for every provider, as Anarchid proposed in #182. Gemini joins that convention with antra-tess/membrane#109, which membrane 0.6.0 carries, so this asks for 0.6.0, and hosts need it too. Fixes #182.What happens
Anarchid's case from #182: a Gemini refusal whose prompt was 10,000 tokens, 8,000 of them from cache, followed by a 1,000-token recall-curve expansion with a 64-token output reserve. The usage below is what membrane 0.5.86's
normalizeUsageToCacheExcludedmakes of Gemini's counts once its adapter declares the cache-inclusive convention, as membrane#109 does; OpenAI's adapters get the same treatment today. The compression budget is 11,000 tokens; any budget from 3,064 to 11,063 shows the same flip.The same count is a successful attempt's
renderedTokens, which under-reported the prompt by the cached span in the same way.Why
normalizeUsageToCacheExcluded(membranesrc/utils/usage.ts, since 0.5.81) restates the usage of each adapter whose convention membrane knows so thatinputTokens,cacheCreationTokensandcacheReadTokensare disjoint: an adapter that declarescache-inclusive, such as OpenAI's, has the cached span subtracted frominputTokens, and OpenRouter states its convention per response.compressionResponseInputTokensstill read the counts the way providers report them raw: it summed the three only foranthropicandbedrock, and for any other provider returnedinputTokensalone, "so summing there would double-count". After normalization that is only the fresh input.The fallback plan's accounting already names its baseline
provider_total_input, and the cache hit ratio in the same file sums all three counts for every provider. So this makes the helper compute what the plan says it uses.Changes
src/strategies/autobiographical.ts:compressionResponseInputTokenssumsinputTokens,cacheCreationTokensandcacheReadTokensfor every provider, and no longer reads the provider. Usage without an input count, or with a zero total, still falls back to the complete normalized request bound, as it did for Anthropic and Bedrock.package.jsonand the lockfile:@animalabs/membraneis^0.6.0, the first release cut from membranemainat or after8242eb8, so it carries membrane#109 and membrane#125 (Gemini's tool-use prompt tokens). The lockfile moves@animalabs/membranealone, from 0.5.87 to 0.6.0. That range binds context-manager's own copy of membrane only. The usage this code reads comes from the host's instance,ctx.membrane, and npm installs this beside a host's older membrane by nesting its own 0.6.0 under it. So the changelog's breaking entry asks each host to take 0.6.0 in its own@animalabs/membranerange.docs/compression-recall-curve-fallback-spec.mdstates the provider total as the sum for every provider, with membrane 0.6.0 or later in the host. It also says that throughcomplete(), which compression calls, membrane's Anthropic and Bedrock adapters pass a missing input count through and its other adapters report one as zero, so a cache-only report reads as a full cache hit.test/compression-recall-curve.test.ts:openai-responses-apiis a 350,000-token prompt, so no expansion fits a 350,000-token budget.Membranearound a stub adapter declaringcache-inclusiverefuses withinputTokens: 10_000, cacheReadTokens: 8_000, and the refusal plan's canonical total is 10,000.fixedfragment for the count, and abreakingone for the membrane requirement, which opens by naming host operators as the ones to act.What operators will notice
@animalabs/membrane0.6.0 or later in your own range with this release. On an older host membrane, this release counts Gemini's cached span twice. Below 0.5.81 it does the same for OpenAI's, and for OpenRouter's on routes that report OpenAI'scached_tokens, as the companion section below says.admission_rejectedand not sent, as it already is on Anthropic and Bedrock. A chunk can then go to quarantine where before it spent a provider call on a request that didn't fit.renderedTokensin the compression log includes the cached span.main, and the upgrade releases none. On OpenAI's adapters or OpenRouter, or on Gemini through membrane 0.6.0, the old count may have planned a held chunk's fallbacks wrongly, so the chunk may compress now. Clearing each of that chunk's records withclearCompressionRefusalQuarantine(key), by thequarantine_keyeach of itscompression:quarantine-alertevents names beside the chunk'schunk_hash, retries it under the corrected count, at the cost of one fresh attempt. Called with no key, it clears every chunk's records.What each part is for
What it's for: a compression's provider count of its prompt should be the prompt's whole size on every provider, now that membrane states fresh input, cache writes and cache reads apart. Counting only the fresh part let a refused compression's fallback plan send expansions that didn't fit the budget.
compressionResponseInputTokens: three lines in place of the provider check and its two branches). It's the fix. Wherever membrane knows the convention, the three counts are disjoint, so their sum is the prompt's size. The check it replaces read the counts the way providers report them raw, which membrane's normalization had already undone.maindoes for Anthropic and Bedrock, since neither shape observes the prompt. Throughcomplete(), membrane's Anthropic and Bedrock adapters pass a missing count through, so the first covers them as well as a caller outside membrane.^0.6.0(package.jsonand the lockfile's membrane entry). It keeps context-manager's own copy on the first release that states Gemini's cached input apart from its fresh input. It doesn't bind the host's copy, which the changelog's breaking entry asks hosts to raise.Membrane, and that a record an earlier release wrote still holds its chunk.Not taken:
maindoes. membrane already states the counts disjoint, so the check only brought back the undercount.clearCompressionRefusalQuarantine(key).@animalabs/membrane, which npm would check against the host's copy. It changes how every consumer installs, so it's a question of its own.The source change is +26/−19 in
src/strategies/autobiographical.ts: 4 lines of code in place of 13, and 22 comment lines in place of 6, counting a line that starts with//,/*or*as a comment.Companion PR and merge order
Companion: antra-tess/membrane#109, which declares Gemini's cache convention, and membrane#125, which counts Gemini's tool-use prompt tokens, shipped in membrane 0.6.0, and this branch's floor names it. Hosts take their own membrane:
maintoday, and the case above applies to it.inputTokens. Before membrane 0.5.81 OpenAI's do too, and so do OpenRouter's on routes that report OpenAI'scached_tokens. So a cached prompt's cached span is counted twice here on those providers. A fallback that fits can beadmission_rejected, and its chunk can go to quarantine. The changelog's breaking entry asks hosts to take 0.6.0.A usage whose convention membrane can't establish keeps its provider's counts: in 0.6.0 that's the OpenAI-compatible and OpenAI completions adapters', which never report cache reads, and an OpenRouter response with no cache field. So the sum equals its
inputTokens, as before.Tests
The head is
4549a375, on the merge ofmainat2d552dd1. It differs from9b835ec1, where these ran, only in comments, the spec and the changelog. Runs on Linux, each from a checkout withnpm ci, as CI runs them, with membrane 0.6.0 from the lockfile, on Node v24.22.0:9b835ec1(the run's source and test files hash to its own):npx tsc --noEmitandnpm run buildclean.compression-recall-curve: 95 tests, all passing.overbudget-diagnostics: 136, all passing, as onmain.main'ssrc/strategies/autobiographical.tsat2d552dd1and this branch's tests,compression-recall-curvefails exactly the three tests of the sum (the cached refusal, the full cache hit and the refusal through membrane) and the held-record test. The held-record test fails at its premise there, sincemaincounts its refusal at 2,000, not 10,000. The zero-usage test passes, sincemainalready falls back to the complete request bound on both its shapes.inputTokensalone, fails the three and the held-record test;4549a375(run 39178167195) ran 1850 tests in each of its five jobs, all passing, against 1847 atmain075dde40(run 39157675151). The three more are this change's new tests.main's builds against membrane 0.5.86, the version the lockfile resolved then.Out of scope
compressionToolProseFallbackset, the shape cap didn't hold a quarantined chunk, since the store gives a record'stoolProseFallbackback with its keys sorted. fix(compression): compare the tool-prose rung's options whatever their key order #232, now merged, fixed that. It's outside this change, and the two merge cleanly.Not verified
Membrane, and the measurement above, which runs this branch's andmain's builds on a response passed through membrane's own normalization function.Written by Gladys-2413 (Claude Opus 5.5), using the Rookery's tooling. Anarchid found it in his review of antra-tess/membrane#109, measured the case above and proposed the fix. An internal review read the first commit, and its findings are in the second and third. Myrtle-2415 (Claude Opus 5.5) reviewed it, and her read is in the fourth. Anarchid's review is in the fifth, and Myrtle-2415's read of it in the sixth. The seventh merges
main, the eighth raises the membrane floor to 0.6.0, and the ninth narrows a sentence on Myrtle-2415's read; she approved both. The tenth keeps the version-2 records the corrected count would plan the same, as Oscar-1005 decided. An internal review read it, and its findings are in the eleventh. Myrtle-2415 reviewed both and approved their code, and the twelfth takes her read of the changelog. The thirteenth reverts the version bump and the tenth and eleventh's rule, keeping accounting version 2, on Oscar-1005's call from the upgrade's worst case. An internal review read the whole change at the thirteenth, and its findings are in the fourteenth. Myrtle-2415 reviewed both and approved them, and the fifteenth takes her two precisions. The sixteenth moves the membrane requirement into a breaking changelog entry of its own, on Oscar-1005's call.Description checked and posted by Fiona-2757 (Claude Opus 5.5).