Add native Google AI Studio transformation, measured token accounting, dashboard support, and a production 312×728 profile for Gemini 3.6 Flash.
Gemini uses 26% fewer vision tokens than Fable 5 per full page and matches or exceeds Fable across the production-profile quality suite.
* fix(render): glyph surgery so Spleen 5x8 K no longer reads as H
The stock Spleen K was H with one crossbar pixel removed (Hamming 1), the
worst confusable pair in the atlas. Because the API downscales images and the
model infers rather than scans, that difference vanishes and K is silently
misread as H - with no signal of uncertainty.
gen-atlas.ts now repaints K with a diagonal-legged bitmap: Hamming 8 from H,
and >=6 from every other ASCII glyph. Scoped to the narrow Spleen 5x8 primary
cell, so the JetBrains-Mono and CJK-fallback atlases are untouched. Zero
token-cost change - the glyph occupies the same 5x8 box.
Guarded by a regression test asserting no alphanumeric pair sits below
Hamming 2. Reproduce with: npx tsx eval/glyph-matrix/demo-glyph.mts
Closes the "optional glyph surgery" item in the 2026-07-01 legibility audit.
When a Claude Code /v1/messages request is bridged to a GPT model on the
Responses path (bridgedGptMessages), prior assistant text turns were lowered
to content parts of type input_text. The OpenAI Responses API requires
assistant-role message content to be output_text; input_text under
role:assistant is rejected with 400, breaking every multi-turn session to a
Responses-routed GPT model after the first assistant reply. The bridge's read
side already maps assistant text to/from output_text — only the write side was
wrong. Thread the role into inputParts and pick the text type accordingly;
user/system stay input_text.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(transform): pass tool-search managed tools through the tool rewrite
Tools carrying defer_loading: true belong to Anthropic's tool-search beta:
the server keeps them out of context (billed ~zero) until the model searches
for them. Rewriting them — stub description, annotation-stripped schema, full
docs rendered into the imaged Tool Reference — materializes documentation the
API was deliberately keeping free, inflating every request. A Claude Code
session with a large MCP surface ships hundreds of tools (~490k chars
observed), so an unpatched proxy can image the entire set into every request.
The tool_search_tool_regex/_bm25 server tools themselves must also pass
through untouched: they are schema-less and server-defined, and stubbing
their description breaks the beta's contract.
Deferred tools now pass through byte-identical, are excluded from the imaged
Tool Reference, and a deferred_tools_skipped counter lands in events.jsonl
for observability.
* test(defer-loading): cover below-min-chars early return with all-deferred tools
Self-review nit: no test exercised the below_min_chars early-return path
(all tools deferred, tiny system prompt) where transformRequest returns
the original body before req.tools = toolsRewritten ever runs. Locks in
that this path stays byte-identical.
Auto-fix-by: babysit-prs
* test(defer-loading): focus coverage on passthrough behavior
Align Chat and Responses framing with annotation-only tool imaging, preserving native tool definitions as authoritative. Includes output-level regressions adapted from #19.
Co-authored-by: Danil Silantyev <danilsilantyevwork@gmail.com>
The dashboard labeled the average cost of small passthrough requests as what the larger imaged requests would have cost without pxpipe. Compare each paid imaged request with its own cache-aware text counterfactual instead, and explain the same-request calculation in the math drawer.
Fixes#93
FINDINGS.md documents the main failure mode of imaged text: the reader (Opus
especially) confabulates a plausible exact value instead of abstaining. Add
abstention guidance to the rendered framing so the model defers exact identifiers
to the factsheet / source rather than guess.
- the imaged slab banner (imageInstructionHeader) and the history-transcript
intro (HISTORY_SYNTHETIC_INTRO) now say: for exact identifiers, paths, hashes,
version strings, and numbers, use the adjacent exact-value factsheet; if a value
was only in an image and not in the factsheet, do not guess — say it is not safe
to quote and re-read the source.
- no geometry, model-allowlist, or gate change; the exact factsheet still rides
after the image blocks unchanged.
- test asserts the phrasing appears in info.imageSourceText for an applied
transform and in the history intro, and NOT in a non-applied (compress=false)
transform.
Full suite green (633).
Three full-width rows of model chips were always visible, but only
Fable 5 is enabled by default. Now it's one collapsed line; expanding
shows chips + PXPIPE_MODELS CSV, a Fable-5-only warning, and a routing
help modal (/model claude-<model> to switch).
Fixes#116
Keep non-cache-controlled appended system text in the Anthropic system field when the cacheable system slab is rendered to images.
Generated by Ora Studio
Vibe coded by ousamabenyounes
- docs-integrity: normalize walker paths to forward slashes so keys match on Windows
- export-git-e2e: run tsx's JS entry via node instead of the .bin/.cmd shim (same pattern as #63)
- vitest: 30s testTimeout for CPU-bound PNG-render tests on slower machines
(build.mjs portion of the original branch dropped — superseded by #63)
The model id is interpolated into the single-quoted hx-vals attribute. A
crafted id (reachable as an `active` model from any /v1/messages request on
the unauthenticated localhost dashboard) could break out of the JSON or the
attribute itself. Build valid JSON via JSON.stringify, then escapeHtml the
whole attribute value; the browser decodes the entities before htmx reads it,
so well-formed ids are unaffected. Closes#58.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Request bodies hold full prompts and any secrets in context. On 4xx (429s are
routine) they were gzipped to ~/.pxpipe/events.jsonl / 4xx-bodies/ with no
retention limit. Gate the capture behind captureErrorReqBody (env
PXPIPE_DEBUG_CAPTURE_4XX=1), default off, with a one-time startup warning. The
upstream errorBody (no user content) and the sha8 hash still land. Closes#69.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Server-side tools (e.g. advisor_20260301, web_search_20250305) were
getting a description injected and a heading in the imaged Tool
Reference, causing Anthropic to 400 with "Extra inputs are not
permitted". Guard the rewrite map so any tool with a non-"custom"
type passes through byte-identical, mirroring the OpenAI-path
isFunctionTool guard.
Fixes#43
Claude Code marks its system prompt with cache_control
{type:"ephemeral", ttl:"1h", scope:"global"}. When pxpipe relocates
that marker onto a rendered image, the new position is never a valid
global prefix (slab pages 1..N-1 and other injected blocks carry no
marker), so Anthropic rejects the whole request with 400
invalid_request_error — making pxpipe unusable in front of Claude Code.
Demote relocated markers to plain ephemeral by stripping the scope key
(type/ttl preserved) at all four relocation sites: the slab->history
anchor move, the static-system marker, and the reminder / tool_result
image renders. Markers without scope pass through untouched, so
byte-stability of existing traffic is unaffected. pxpipe still never
adds markers of its own.
Closes#95
The relocated '# Environment' block put model-identity/catalog lines in
the LAST user message every turn, steering subagent picks to fable
instead of haiku (LinkedIn report). splitEnvByVolatility now learns per
project (claudeMdSha) which env entries are byte-stable and promotes
them into the imaged slab; churned/new entries keep the live-tail
relocation, with the identity regex retained as a second layer.
Invariants (pinned by tests):
- first-ever sightings stay volatile: git state never bakes into a
fresh session's image (48.8% cold-create fix intact)
- the slab never re-renders mid-session: static side frozen byte-exact;
a churned promoted entry re-emits fresh text on the tail (supersedes
its stale slab copy) and demotes next session via sticky churn history
- no user message -> env stays in system, unchanged
Telemetry: envStaticChars / envVolatileKeys.
Ship Grok 5×8 white/IDS packing and universal IDS/factsheet plumbing while
keeping DEFAULT_MODEL_BASES Fable-only: live pure-image Grok is not
Fable-level; image+factsheet is the working exact path.
Chart shows Grok as a text-window series only (no Grok+pxpipe overlay) and
Fable as the measured pxpipe density point. Add Codex-path quality harnesses
and receipts (multi-seed, fix-matrix, shipped smoke).
The GPT tests drive gpt-5.6-sol, which is intentionally outside the
built-in default scope (Fable 5 only). Without pinning, the file's
result depended on the developer's ambient PXPIPE_MODELS — passing
locally but failing in CI's clean env. Snapshot/pin/restore, same
convention as proxy-usage.test.ts.
Several families share /v1/responses. Cost, geometry, cache rates, and
whether imaging is even allowed must follow the model that serves the
request. Otherwise Claude and Grok inherit GPT defaults, the gate lies,
and the dashboard reports nonsense.
Claude on Responses:
- Bill images by Anthropic pixel area; cache 0.1x, output 5x.
- Use Anthropic page geometry (312 cols x 728 px). GPT height overstated
image cost ~2.6x and flipped every Opus slab to not_profitable, so
enabled Claude stayed text-only with blank As text / Saved.
History profitability gate:
- Reflow packs hard newlines with an inline ↵ glyph. countVisualRows
still treated ↵ as a row break, so estimateImageCount overstated pages
~6x on reflowed history and collapsed Claude/Grok history never fired.
Match the renderer: only hard newlines start a visual row.
Grok:
- Keep production 5x8 packing and a verbatim fact-sheet for OCR-hard
tokens (paths/hex/ports/camelCase). Measured ~1000 image tok/MPix;
cache 0.25x, output 3x.
- Leave Grok out of DEFAULT_MODEL_BASES (opt-in only, same bar as Opus).
Pure-image exact OCR fails at 5x8; do not image it silently.
Also thread optional per-model render style through the Responses path
and allow eval-only atlas overrides in gen-atlas.
Broaden the guard from README + top-level docs/*.md inline links to the full
tracked doc set and more link forms:
- scan README.md, CHANGELOG.md, FINDINGS.md, docs/**/*.md, and eval/**/README.md
(eval run logs are skipped — only their READMEs are prose)
- parse inline links, image links, reference-style definitions, and simple HTML
href=/src=
- validate the target file exists AND, when a link carries a #fragment into a
markdown file, that a matching GitHub-style heading anchor exists
Verified: passes on the current tree and fails on an injected broken link or
broken #anchor. (A README public-API-example typecheck lands on the integration
branch, where the README import fix is present.)
A static audit can only catch broken doc links once; this keeps them honest on
every run. A dependency-light vitest test (pure fs + regex, no new deps, already
picked up by `pnpm test` and CI) walks README.md and docs/*.md, extracts each
relative link/image target (skipping http/mailto/data/# and stripping
fragments/queries), and asserts the target exists.
Verified: passes on the current tree (all 7 relative links resolve, including
docs/LEGIBILITY-AUDIT-2026-07-01.md) and fails with a clear per-link message
when a dead link is introduced.
Strengthen the >0 assertion into a structural one: with a rendered image, a
pointer, an input_text prompt, and a rewritten tool in the outgoing body, assert
outgoingTextChars (a) counts the instructions pointer + input_text parts + tool
name/description/parameters (>= their summed length, within the '\n\n' part
separators), and (b) stays well below the input_image base64 length — i.e. the
base64 is excluded from the regression denominator, as intended.
transformOpenAIChatCompletions set info.outgoingTextChars = countOutgoingTextChars(req)
before returning; transformOpenAIResponses set info.compressed = true and returned
without it. That field is the regression denominator (tokens ≈ α·outgoingTextChars +
β·imagePixels), so compressed Responses rows silently carried no denominator.
- add countResponsesOutgoingTextChars(): instructions + message-item text
(string or input_text parts, via responsesContentText so input_image base64 is
excluded) + flat tool name/description/parameters
- set it on the Responses success path, mirroring the Chat path
- test asserts outgoingTextChars > 0 for a compressed Responses request
shouldTransformAnthropicMessages() gated the path with
!path.endsWith('/v1/messages'), but createProxy() routes three exact paths:
/v1/messages, /anthropic/v1/messages, and /anthropic/messages. So an SDK/host
using the public helper to pre-gate got unsupported_path on /anthropic/messages
— a request the proxy actually transforms — while endsWith would also have
wrongly accepted an unrelated /foo/v1/messages.
- lift isAnthropicMessagesPath() into applicability.ts (proxy.ts already imports
from it; no cycle) as the single source of truth
- proxy.ts imports it instead of its own copy
- shouldTransformAnthropicMessages() uses it, so helper and router agree
- public-api tests cover /anthropic/v1/messages and /anthropic/messages
(eligible) plus /v1/messages/count_tokens (still unsupported_path)
The Responses API allows a message item's content to be a string OR an array of
input_text parts. transformOpenAIResponses only read the string form
(`typeof content === 'string' ? content : ''`), so a developer/system item sent
as [{ type: 'input_text', text: ... }] was neither imaged nor stubbed — its
verbose text rode uncompressed as native input, and the pointer replacement
(also string-only) left it in place.
- collect static context via the existing responsesContentText() helper, which
reads both shapes
- pointer replacement now handles arrays too, preserving the array shape
([{ type: 'input_text', text: RESPONSES_POINTER }]) so a parts-form request is
not reshaped into a string
- test: a developer item with array content is imaged (staticChars counts it)
and its outgoing content is a single pointer part, no original text
The original bug lived in collectSource's --git untracked branch, not in the
readExportTextFile helper the unit tests cover. Add an E2E test that runs the
actual CLI (tsx src/node.ts export --git --include '*.ts') against a throwaway
git repo with a tracked baseline plus untracked keep.ts, a 5000-char skip.md, an
oversized huge.ts, and a binary bin.ts. Asserts oversized + binary untracked
files are skipped (with warnings) and that only keep.ts's content reaches the
source (sourceChars < 500 — skip.md would have added thousands).
In --git mode, untracked files (git ls-files --others) were read straight to
memory with only a binary check — unlike directory and single-file modes, which
also apply --include/--exclude globs and the 1 MiB MAX_FILE_BYTES cap. So
`pxpipe export --git --include '*.ts'` still slurped every untracked file
regardless of the filter, and an untracked multi-MB file was read whole (a
resource-safety hazard).
- extract the shared gate into src/export-collect.ts: readExportTextFile()
applies include/exclude → size → binary, in that order, and is import-safe
(src/core/export.ts is contractually fs-free, so it can't live there)
- route all three collection paths (walkDir, single-file target, --git
untracked) through it; untracked skips now warn like explicit targets do
- tests/export-collect.test.ts covers include, exclude, oversized, the size
boundary, binary, and inaccessible
Verified end-to-end: `export --git --include '*.ts'` in a temp repo excludes an
untracked .md, skips an oversized .ts (with a warning), and keeps the .ts.
- staleFreshnessHints() rewrites the upstream "(file state is current in
your context - no need to Read it back)" hint into a stale-state warning
at history-serialization time, covering both collapsed same-session
turns and slabs inherited by continuation sessions
- Edit/Write/NotebookEdit stubs gain a live-text same-session Read
precondition (READ_FIRST_TOOLS), riding un-imaged in the tools array
- telemetry: churning_static_tags canary - FNV-1a fingerprint per
session/tag flags slab tags whose content churns and busts the image
cache (TAG_OBSERVATIONS_MAX-capped)
- 5 regression tests in tests/history.test.ts; baseline to beat: 97
read-gate errors across 18 sessions
Opening-turn task prompts were reduced to a 300-char compactPreview
tombstone by demoteProtectedHeadText, losing questions and trailing
output-format instructions that no later turn restated. The tombstone
keeps its cheap cache-stable preview, but latestCollapsedUserPointer
now scans INCLUDING the protected head: when the opening turn is the
only typed user text in the collapsed range (single-task sessions),
the pointer carries it verbatim via verbatimTaskText — up to 4000
chars, head 2600 / tail 1400 elision beyond that.
Volatile env/context text (git status, cwd, date) rode in req.system —
BEFORE the slab anchor in Anthropic's prefix order (tools → system →
messages) — so any git-state change cold-restarted the entire anchored
prefix. Telemetry attribution (events.jsonl 2026-06-26..07-02): 48.8%
of cold-create waste, ~2.6k tokens/session vs ~200 saved by imaging it.
Now appended as a trailing text block on the LAST user message — the
per-turn live tail that re-caches incrementally anyway — placed AFTER
history collapse (never baked into frozen chunks) and after tool_result
compression. Session-stable billingLine/sysRemainder stay in system;
fallback keeps env in system when no user message exists to carry it.
New telemetry field: envRelocatedChars.