16 Commits

Author SHA1 Message Date
Steven 7e48713b4e feat: make Gemini 3.6 Flash a default pxpipe reader (#134)
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.
2026-07-21 16:41:40 -04:00
Steven Chong 64b62851da refactor(proxy): simplify provider routing 2026-07-18 10:21:33 -04:00
teamchong 4c78363757 feat(proxy): add scoped Cloudflare model routing 2026-07-17 13:01:43 -04:00
Shubham Srivastava 50ca4725df fix(proxy): stop persisting 4xx request bodies by default (privacy) (#74)
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>
2026-07-16 13:30:09 -04:00
Steven Chong 76f4fd2282 feat: add Sol Messages bridge and history compression 2026-07-15 15:15:58 -04:00
Steven Chong 05cee28c18 fix(proxy): gate Messages transforms by model family 2026-07-11 18:16:30 -04:00
Steven Chong cd4c9efddd feat: add model-specific render profiles and recall evidence 2026-07-09 22:25:06 -04:00
teamchong e49bb11878 feat(telemetry): log stop_reason + safety-flag per request 2026-07-02 23:07:37 -04:00
Steven Chong 47b5e412b3 feat: cache-stable history-collapse imaging for GPT (OpenAI) + Anthropic
Collapse old conversation history into rendered PNG sections so the model
reads a compact image instead of re-billed text, while preserving prompt
caching and tool-call behavior. Measures real vs compressed token/cost.

Core:
- GPT history collapse (openai-history.ts): append-only, o200k token-length
  sectioning. Sections seal only at a tool-closed boundary (open call-id set
  empty), so a function_call and its function_call_output never split across
  the collapse cut. Fixes the OpenAI 400 "No tool call found for function
  call output with call_id ..." that hit long Responses-API sessions.
- Anthropic cache contract (history.ts): append-only per-chunk rendering;
  cache_control markers are preserved/moved, never added; chunk boundaries
  align with caller marker seams for byte-stable prefix caching.
- GPT image budget (openai.ts): detail:'original' for gpt-5.x, flagship
  vision-multiplier fix, patch cap; schema-strip preserves real descriptions.
- Savings accounting (openai-savings.ts): cached_tokens + vision-token basis.

Model scope (applicability.ts):
- Default imaged scope = claude-fable-5 + gpt-5.6.
- gpt-5.5 and claude-opus-4-8 stay opt-in: same pipeline, but they degrade
  reading dense imaged history (gist drift), so silently imaging them by
  default is wrong. Promotion is gated on an OCR/recall threshold.

Dashboard: GPT + Anthropic rendering, per-family model toggles, persisted
metrics, thumbnail-expired session UI, reflow/newline handling.

Tests: cache-alignment (GPT + Anthropic), history sectioning + tool-boundary
invariants, savings, dashboard, sessions/restart-restore. 452 passing.
2026-06-20 21:11:24 -04:00
Steven Chong 613ca3bc71 feat(proxy): support gpt 5.5 chat completions 2026-06-05 00:35:26 -04:00
teamchong 6cbdea584b feat(proxy): SSE+JSON output measurement scanner
Adds a third tee branch in createProxy that walks the response body and
counts Unicode code units across text_delta / thinking_delta /
input_json_delta / tool_use blocks, plus a redacted_thinking block count.
Same shape on streaming (SSE) and non-stream (content[]) responses.

Numbers are independent of Anthropic's usage.output_tokens - they give a
real ruler against the redacted_thinking-inflated bill that surfaced in
the May-2026 weekly-meter audit (#22).

Wiring:
  OutputMeasurement type in proxy.ts (textChars, thinkingChars,
    toolUseChars, redactedBlockCount)
  teeForUsage now returns measurementPromise alongside usagePromise +
    errorBodyPromise; Promise.all in handle() awaits all three
  ProxyEvent gains optional measurement: OutputMeasurement
  TrackEvent gains text_chars_measured / thinking_chars_measured /
    tool_use_chars_measured / redacted_block_count_measured (optional,
    non-breaking JSONL shape)
  toTrackEvent() copies them through

Tests (6 new in proxy-usage.test.ts):
  text_delta count across multi-event SSE
  message_delta output_tokens overrides message_start placeholder
  thinking_delta + redacted_thinking block count
  tool_use input_json_delta reassembly
  non-stream content[] walks same shape
  5xx no-body path leaves measurement undefined

288 tests pass, tsc --noEmit clean, dist/node.js builds.

Dashboard surfacing lands in a follow-up so the wire-up stays reviewable.

Refs #22
2026-05-20 15:53:58 -04:00
teamchong dfd3bf514f feat(proxy): cache-aware baseline via second count_tokens probe
The baseline_tokens probe alone reports the COLD cost of the unproxied
body — but the user's request actually carries `cache_control` markers
that would have cached on the unproxied path too. Comparing cold-
baseline against warm-actual was an apples-to-oranges inflation of
saved%.

Fix: fire a SECOND count_tokens probe on the body TRUNCATED at the last
`cache_control` marker. Difference between the two is (cacheable_prefix,
cold_tail). Dashboard weights the prefix by the SAME cache class the
actual response landed in:

    cacheable = baseline_cacheable_tokens or 0
    cold_tail = baseline_tokens − cacheable
    weight    = cr>0 ? 0.10 (warm hit — both paths cache)
              : cc>0 ? 1.25 (cold create — both paths build cache)
              :        1.0  (no caching — marker rejected or absent)
    baseline_input_eff = cacheable·weight + cold_tail·1.0
    actual_input_eff   = input + cache_create·1.25 + cache_read·0.10
    saved              = baseline_input_eff − actual_input_eff

Now the headline number is the exact unproxied counterfactual, not a
cold-every-time approximation. No estimation, no α, no assumptions —
every input is a measurement.

Wiring:
- src/core/proxy.ts: buildCountTokensCacheablePrefix() walks messages
  backward → system backward → tools backward (reverse cache order) for
  the last cache_control marker, truncates, attaches a sentinel user
  message when needed. Second probe fires in parallel; null/missing
  leaves the field absent.
- src/core/transform.ts + tracker.ts: new baselineCacheableTokens /
  baseline_cacheable_tokens fields, emitted only when > 0.
- src/dashboard.ts: per-event cache-aware math in update() and
  replay(); Totals renamed to baselineInputWeighted; /proxy-stats
  payload field is `baseline_input_weighted`.
- src/sessions.ts: per-session tokensSavedEst uses the same formula so
  session and global rollups agree.

Tests: 2 new in proxy-usage covering (a) body with cache_control →
two probes fire with different bodies and both numbers land on info,
(b) cacheable-prefix probe failure doesn't poison the full probe.
260→262 tests, all green; typecheck and build clean.
2026-05-19 22:29:07 -04:00
teamchong f8902732bb feat(dashboard): real saved% from count_tokens, rip α-regression dead code
The headline saved%/saved$ numbers are now MEASURED, not estimated:

  baseline = sum of /v1/messages/count_tokens(originalBody).input_tokens
  actual   = sum of (input + cache_create×1.25 + cache_read×0.10)
  saved    = baseline − actual

Output is excluded from both sides — it's identical with or without
compression (same prompt → same response), so it cancels in the
numerator and would only drag the percentage toward zero. The proxy
now makes one parallel side call to /v1/messages/count_tokens (free,
no billing) before forwarding /v1/messages, and the result lands on
TransformInfo.baselineTokens → TrackEvent.baseline_tokens → the
DashboardState rollup.

Removed (~280 lines of dead code):
  - FALLBACK_ALPHA, FALLBACK_ALPHA_LOW, FALLBACK_ALPHA_HIGH constants
  - estImageTokens(), baselineCost(), effectiveCost() helpers
  - FitSample interface + FIT_SAMPLE_CAP + fitSamples ring buffer
  - DashboardState.fitCosts() (~218 lines of OLS regression)
  - Three-mode ladder (joint / constrained / stale) and all UI for it
  - saved_pct_low/high, saved_effective_tokens_low/high range fields
  - renderSavingsMath() range/regime branches
  - m_pct_regime DOM node + its color/text wiring
  - cost_fit payload field + all frontend consumers

/proxy-stats payload is now: requests, compressed_requests,
baseline_input_tokens, actual_input_weighted, saved_input_tokens,
saved_pct, saved_usd, pricing_assumptions, uptime_sec,
compression_enabled. Nothing else.

The recent-requests table now shows baseline / actual / saved per
row (input-side, matching the cards) instead of the old img-tok
estimate + effective_actual (which mixed output × 5 in inconsistently).

Sessions panel: per-session tokensSavedEst now uses the same real
baseline−actual formula. Events without both a count_tokens probe
AND an upstream usage block don't contribute to the rollup — no
synthetic 4-char/tok fallback.

Tests:
  - dashboard-fit.test.ts: deleted (fitCosts is gone)
  - proxy-usage.test.ts: inverted the privacy regression — count_tokens
    is now explicitly expected as the one whitelisted side path; added
    baselineTokens-attachment and count_tokens-failure-survives tests
  - sessions.test.ts: rewrote the two savings tests against the new
    baseline_tokens + usage formula
2026-05-19 22:00:14 -04:00
teamchong 1d46b72221 rip(proxy): remove count_tokens probe path — leaks original text
Sending the pre-compression body to /v1/messages/count_tokens means
Anthropic sees the original text, which is exactly what pixelpipe is
built to prevent. Probe results were 'free' in dollars but not in
privacy; the whole point of imaging the slab is that Anthropic does
NOT receive the slab text.

Removed:
  - countTokensUpstream + buildCountTokensBody in src/core/proxy.ts
  - measurePromise wiring + finalize await
  - baselineTokensMeasured / actualTokensMeasured on TransformInfo
  - baseline_tokens_measured / actual_tokens_measured on TrackEvent
  - measuredEvents / effectiveCostActualMeasured / *Baseline*
    fields on DashboardState
  - measured_events / saved_pct_measured / effective_cost_*_measured
    / saved_effective_tokens_measured on /proxy-stats payload
  - HTML headline branch + 'show calculation' rows for measured path
  - 2 dashboard-fit tests asserting the measurement-path behavior

Added regression test: proxy must NOT hit any upstream path other than
/v1/messages.

Real-savings path now: self-derive α from billed usage + own char
walker. Coming next.
2026-05-19 21:28:26 -04:00
teamchong f199640bdc ship Unifont @ 10px multilingual rendering + 400 diagnosis fixes
- Switch font: JetBrains Mono @ 15px → Unifont 16.0.04 @ 10px. Sparse
  atlas with double-width EAW dispatch for CJK. Full BMP profile
  (35,501 glyphs) covers Latin/Cyrillic/Greek/Hebrew/Arabic/CJK/Hangul
  plus Letterlike, Misc Technical, Block Elements, Geometric Shapes,
  Misc Symbols, Dingbats, Enclosed Alphanumerics, all box-drawing.
- Fix cell-height bug (was hardcoded 9; now probed from font metrics
  via Math.ceil(maxAscent+maxDescent)). Previously clipped the top
  2 rows off every CJK glyph since the original Unifont ship.
- Expand tabs to spaces with 4-column tab stops, EAW-aware. Was the
  silent-drop source for 99.6% of dropped chars in production.
- Add dropped_codepoints_top telemetry (top 20 per event, only emitted
  when drops > 0) so future coverage gaps surface in events.jsonl.
- Raise minToolResultChars 2000→5000 and minReminderChars 1000→2000
  to stop net-losing tokens on small compressions, per ~2,500 tok/img
  empirical measurement (N=33 cold-miss events).
- Fix dashboard per-image-cost formula: was pngBytes/375 (~190 tok),
  now imageCount * 2500 (the measured empirical rate). Reduces the
  inflated "tokens saved" headline to honest numbers.
- Schema-stub fix in compressSchemas path: preserveSchemaShell()
  retains type / properties / required / enum / oneOf / anyOf / $ref /
  numeric constraints recursively (depth 20), strips only descriptions
  and metadata. Prevents 400 errors on first-tool-call requests.
- Capture full gzipped 4xx body + per-event req_body_sha8 for upstream
  error diagnosis. Sidecar to ~/.pixelpipe/4xx-bodies/ when over 32 KB
  inline cap. Workers-safe via CompressionStream + Web Crypto.
- scripts/restart.sh: pnpm-aware one-command kill+rebuild+start with
  graceful SIGTERM→SIGKILL escalation, build-failure abort, port-in-use
  precheck, --no-build flag for fast iteration. Shell-tested.
- Bundle 1.58 MB gzipped on Node. 91/91 vitest + 4/4 shell tests.
2026-05-18 21:28:25 -04:00
teamchong cb8377a6e4 proxy: tee upstream body and extract Anthropic usage tokens
ProxyEvent gains two new fields:
- usage: Anthropic's input/output/cache_creation/cache_read tokens.
- firstByteMs: ms to upstream response headers (durationMs now measures
  end-to-end since the event fires after usage is extracted).

Implementation (teeForUsage):
- Tee the response body. Client gets one side immediately; we read the
  other in the background.
- SSE: scan up to 64 KiB for 'event: message_start' + the data: payload.
  Usage is always in the first event so this terminates within ms of
  upstream first byte.
- JSON: buffer up to 4 MiB and parse.
- Error responses (status >= 400) and bodyless responses skip extraction.
- Drains the tee fully in the background to avoid backpressure.

Without this, the proxy had no way to validate its own value
proposition. Now we can compute cache hit rate, real input-token
savings, etc. directly from log events.

Tests: +3 (JSON usage, SSE message_start usage, error skips extraction).
2026-05-18 16:02:52 -04:00