Skip to content

fix: decompress gzip responses for Anthropic token extraction#1550

Merged
lpcox merged 5 commits intomainfrom
fix/websocket-token-tracking
Apr 1, 2026
Merged

fix: decompress gzip responses for Anthropic token extraction#1550
lpcox merged 5 commits intomainfrom
fix/websocket-token-tracking

Conversation

@lpcox
Copy link
Copy Markdown
Collaborator

@lpcox lpcox commented Apr 1, 2026

Problem

Token tracking was not extracting usage data from Anthropic (Claude) API responses, despite working correctly for OpenAI/Copilot.

Root Cause

The Anthropic API returns gzip-compressed SSE responses (content-encoding: gzip). The token tracker was trying to parse the raw compressed binary as SSE text, finding 0x1F 0x8B 0x08 (gzip magic bytes) instead of data: lines.

This was discovered through diagnostic logging showing has_usage: false and model: null for all Claude requests, with raw_sample containing gzip binary data instead of SSE text.

Solution

  • Add gzip/deflate/brotli decompression support in trackTokenUsage()
  • When content-encoding header indicates compression, create a decompression pipeline
  • Feed raw chunks into the decompressor; parse the decompressed output for SSE/JSON usage extraction
  • Raw compressed bytes still flow to the client unchanged via pipe() — zero impact on proxy latency
  • Gate diagnostic file logging behind AWF_DEBUG_TOKENS=1 env var (off by default)
  • Also adds WebSocket token tracking infrastructure (for future use)

Tests

8 new tests for compressed response handling:

  • isCompressedResponse — gzip, deflate, brotli, identity
  • trackTokenUsage (compressed) — gzip SSE streaming, gzip JSON, multi-chunk gzip, backward compat

All 187 api-proxy tests pass.

How to verify

Run the Claude smoke test workflow — token-usage.jsonl should now contain entries with provider: anthropic.

Claude Code CLI uses WebSocket streaming to the Anthropic API, which
routes through proxyWebSocket() instead of proxyRequest(). The
proxyWebSocket function did not call trackTokenUsage(), so all
Anthropic/Claude token usage went unrecorded.

This adds:
- parseWebSocketFrames(): lightweight server→client frame parser
- trackWebSocketTokenUsage(): sniffs upstream TLS socket data events,
  skips HTTP 101 header, parses WebSocket text frames, and extracts
  token usage using existing extractUsageFromSseLine()
- 12 new tests for frame parsing and WebSocket token extraction

The fix is non-blocking: it adds a data listener alongside the existing
bidirectional pipe relay, with no impact on latency or throughput.

Closes #1536

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@lpcox lpcox requested a review from Mossaka as a code owner April 1, 2026 17:01
Copilot AI review requested due to automatic review settings April 1, 2026 17:01
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 1, 2026

✅ Coverage Check Passed

Overall Coverage

Metric Base PR Delta
Lines 82.67% 82.77% 📈 +0.10%
Statements 82.34% 82.43% 📈 +0.09%
Functions 81.22% 81.22% ➡️ +0.00%
Branches 75.94% 76.00% 📈 +0.06%
📁 Per-file Coverage Changes (1 files)
File Lines (Before → After) Statements (Before → After)
src/docker-manager.ts 85.8% → 86.2% (+0.41%) 85.3% → 85.7% (+0.40%)

Coverage comparison generated by scripts/ci/compare-coverage.ts

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds token usage tracking for Anthropic/Claude traffic that streams over WebSockets (which previously bypassed the existing HTTP/SSE token tracker), ensuring token-usage records are produced for Claude smoke runs.

Changes:

  • Added a lightweight WebSocket frame parser and a WebSocket token-usage tracker that sniffs upstream TLS socket data and extracts usage from JSON text frames.
  • Wired WebSocket token tracking into proxyWebSocket() alongside the existing bidirectional socket piping.
  • Added unit tests covering WebSocket frame parsing and WebSocket token usage extraction/finalization behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
containers/api-proxy/token-tracker.js Adds parseWebSocketFrames() and trackWebSocketTokenUsage() and logs token usage for WebSocket-based Anthropic streaming.
containers/api-proxy/server.js Calls trackWebSocketTokenUsage() from the WebSocket proxy path and adds safe fallback when token-tracker is unavailable.
containers/api-proxy/token-tracker.test.js Adds test coverage for WebSocket frame parsing and token extraction/finalization.
Comments suppressed due to low confidence (1)

containers/api-proxy/token-tracker.test.js:25

  • The test sets process.env.AWF_TOKEN_LOG_DIR in beforeAll, but require('./token-tracker') happens at module top-level before that. Since TOKEN_LOG_DIR/TOKEN_LOG_FILE are computed at require-time, the env override won’t take effect and tests may attempt to write under /var/log/api-proxy. Set the env var before requiring the module (or refactor token-tracker to read the env var lazily).
const {
  extractUsageFromJson,
  extractUsageFromSseLine,
  parseSseDataLines,
  parseWebSocketFrames,
  normalizeUsage,
  isStreamingResponse,
  trackTokenUsage,
  trackWebSocketTokenUsage,
} = require('./token-tracker');
const { EventEmitter } = require('events');
const os = require('os');
const path = require('path');
const fs = require('fs');

// Redirect token log output to a temp dir to avoid /var/log permission errors
let tmpLogDir;
beforeAll(() => {
  tmpLogDir = fs.mkdtempSync(path.join(os.tmpdir(), 'token-tracker-test-'));
  process.env.AWF_TOKEN_LOG_DIR = tmpLogDir;
});

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

provider,
model: streamingModel || 'unknown',
path: reqPath,
status: 200,
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In WebSocket token usage records, status is hard-coded to 200. For a WebSocket upgrade the actual HTTP status is 101 (and the surrounding WebSocket request metrics/logging treat it as 1xx). Please set this to 101 (or capture the real upgrade status) so downstream analysis doesn’t misclassify these entries as normal 200 HTTP responses.

Suggested change
status: 200,
status: 101,

Copilot uses AI. Check for mistakes.
Comment on lines +465 to +481
upstreamSocket.on('data', (chunk) => {
totalBytes += chunk.length;
buffer = Buffer.concat([buffer, chunk]);

// Safety: drop buffer if it grows too large (malformed frames)
if (buffer.length > MAX_WS_BUFFER) {
buffer = Buffer.alloc(0);
httpHeaderParsed = true; // skip header parsing
return;
}

// Skip the HTTP 101 Switching Protocols response header
if (!httpHeaderParsed) {
const headerEnd = buffer.indexOf('\r\n\r\n');
if (headerEnd === -1) return; // need more data for full header
buffer = buffer.slice(headerEnd + 4);
httpHeaderParsed = true;
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

response_bytes in trackWebSocketTokenUsage currently counts the initial HTTP 101 upgrade header bytes as well as WebSocket frame bytes. In trackTokenUsage (HTTP) response_bytes counts only the response body, so this makes the field inconsistent across transports. Consider tracking header bytes separately and reporting only post-header WebSocket payload bytes as response_bytes (or renaming/adding a field to clarify semantics).

Copilot uses AI. Check for mistakes.
Comment on lines +417 to +424
if (masked) headerSize += 4; // skip masking key

const frameEnd = pos + headerSize + payloadLength;
if (frameEnd > buf.length) break;

// Extract text frames (opcode 1) with FIN set
if (opcode === 1 && fin) {
messages.push(buf.slice(pos + headerSize, frameEnd).toString('utf8'));
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseWebSocketFrames advances past the masking key when masked is set, but it does not unmask the payload before decoding it as UTF-8. That means masked text frames would produce corrupted messages. Since this parser is exported and already branches on masked, either properly unmask the payload or explicitly treat masked frames as unsupported and skip returning them.

Suggested change
if (masked) headerSize += 4; // skip masking key
const frameEnd = pos + headerSize + payloadLength;
if (frameEnd > buf.length) break;
// Extract text frames (opcode 1) with FIN set
if (opcode === 1 && fin) {
messages.push(buf.slice(pos + headerSize, frameEnd).toString('utf8'));
let maskingKey = null;
if (masked) {
// Ensure we have enough bytes for the masking key
if (pos + headerSize + 4 > buf.length) break;
maskingKey = buf.slice(pos + headerSize, pos + headerSize + 4);
headerSize += 4;
}
const payloadStart = pos + headerSize;
const frameEnd = payloadStart + payloadLength;
if (frameEnd > buf.length) break;
// Extract text frames (opcode 1) with FIN set
if (opcode === 1 && fin) {
if (masked && maskingKey) {
const maskedPayload = buf.slice(payloadStart, frameEnd);
const unmaskedPayload = Buffer.allocUnsafe(payloadLength);
for (let i = 0; i < payloadLength; i++) {
unmaskedPayload[i] = maskedPayload[i] ^ maskingKey[i % 4];
}
messages.push(unmaskedPayload.toString('utf8'));
} else {
messages.push(buf.slice(payloadStart, frameEnd).toString('utf8'));
}

Copilot uses AI. Check for mistakes.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 1, 2026

Smoke Test Results — PASS

💥 [THE END] — Illustrated by Smoke Claude for issue #1550

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 1, 2026

Chroot Version Comparison Results

Runtime Host Version Chroot Version Match?
Python Python 3.12.13 Python 3.12.3 ❌ NO
Node.js v24.14.0 v20.20.1 ❌ NO
Go go1.22.12 go1.22.12 ✅ YES

Overall: ❌ Not all tests passed — Python and Node.js versions differ between host and chroot environments.

Tested by Smoke Chroot for issue #1550

@github-actions github-actions bot mentioned this pull request Apr 1, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 1, 2026

Smoke Test Results — Run 23860683072

✅ GitHub MCP — Last 2 merged PRs: #1549 "feat: include api-proxy token logs in firewall audit artifact", #1544 "fix: disable IPv6 in agent container to prevent squid proxy bypass" (author: @lpcox, no assignees)
✅ Playwright — github.com title contains "GitHub"
✅ File write — /tmp/gh-aw/agent/smoke-test-copilot-23860683072.txt created and verified
✅ Bash — cat confirmed file contents

Overall: PASS

📰 BREAKING: Report filed by Smoke Copilot for issue #1550

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

Writes token-diag.log alongside token-usage.jsonl in the mounted log
volume. Since api-proxy container stdout is not captured in workflow
logs, this file provides visibility into:
- Whether trackTokenUsage (HTTP) or trackWebSocketTokenUsage (WS) is called
- Content-type, status code, streaming flag for each request
- Whether usage data was found and which fields were extracted
- Frame counts and message counts for WebSocket tracking

This will help diagnose why Claude/Anthropic produces no token records.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
}
const line = `${new Date().toISOString()} ${msg}` +
(data ? ' ' + JSON.stringify(data) : '') + '\n';
diagStream.write(line);
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 1, 2026

Smoke Test Results — Claude (run 23861249879)

✅ GitHub MCP: #1549 feat: include api-proxy token logs in firewall audit artifact / #1544 fix: disable IPv6 in agent container to prevent squid proxy bypass
✅ Playwright: github.com title contains "GitHub"
✅ File write: smoke-test-claude-23861249879.txt created and verified

Overall: PASS

💥 [THE END] — Illustrated by Smoke Claude for issue #1550

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 1, 2026

🤖 Smoke test results for @lpcox:

✅ GitHub MCP — Last 2 merged PRs: #1549 "feat: include api-proxy token logs in firewall audit artifact", #1544 "fix: disable IPv6 in agent container to prevent squid proxy bypass"
✅ Playwright — https://github.com title contains "GitHub"
✅ File write — /tmp/gh-aw/agent/smoke-test-copilot-23861249913.txt created and verified

Overall: PASS

📰 BREAKING: Report filed by Smoke Copilot for issue #1550

@github-actions

This comment has been minimized.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 1, 2026

Chroot Version Comparison Results

Runtime Host Version Chroot Version Match?
Python Python 3.12.13 Python 3.12.3
Node.js v24.14.0 v20.20.1
Go go1.22.12 go1.22.12

Overall: FAILED — Python and Node.js versions differ between host and chroot.

Tested by Smoke Chroot for issue #1550

@github-actions

This comment has been minimized.

Add first 500 bytes of raw response data to token-diag.log entries.
This will reveal the actual SSE format from the Anthropic beta API
that the parser is failing to extract usage from.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 1, 2026

Smoke Test Results

GitHub MCP — Last 2 merged PRs:

Playwright — github.com title contains "GitHub"
File Write/tmp/gh-aw/agent/smoke-test-claude-23861597401.txt created and verified
Bash — File contents confirmed

Overall: PASS

💥 [THE END] — Illustrated by Smoke Claude for issue #1550

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 1, 2026

Smoke Test Results — Copilot Engine ✅ PASS

Test Result
GitHub MCP (last 2 merged PRs) #1549 "feat: include api-proxy token logs in firewall audit artifact", #1544 "fix: disable IPv6 in agent container to prevent squid proxy bypass"
Playwright (github.com title) ✅ Title contains "GitHub"
File write /tmp/gh-aw/agent/smoke-test-copilot-23861597321.txt created
Bash verification ✅ File contents confirmed

Author: @lpcox | No assignees

📰 BREAKING: Report filed by Smoke Copilot for issue #1550

@github-actions

This comment has been minimized.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 1, 2026

Chroot Version Comparison Results

Runtime Host Version Chroot Version Match?
Python Python 3.12.13 Python 3.12.3
Node.js v24.14.0 v20.20.1
Go go1.22.12 go1.22.12

Overall: ❌ Not all tests passed — Python and Node.js versions differ between host and chroot.

Tested by Smoke Chroot for issue #1550

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

The Anthropic API returns gzip-compressed SSE responses (content-encoding:
gzip). The token tracker was trying to parse compressed binary data as SSE
text, which silently failed to extract any usage information.

Changes:
- Add gzip/deflate/brotli decompression support in trackTokenUsage()
- Create decompression pipeline when content-encoding header is present
- Raw compressed bytes still flow to client unchanged via pipe()
- Gate diagnostic logging behind AWF_DEBUG_TOKENS=1 env var
- Add isCompressedResponse() and createDecompressor() helpers
- Add 8 new tests for compressed response handling (gzip SSE, gzip JSON,
  multi-chunk gzip, backward compat with uncompressed)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@lpcox lpcox changed the title fix: add token tracking for WebSocket streaming (Claude) fix: decompress gzip responses for Anthropic token extraction Apr 1, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 1, 2026

Smoke Test Results — Run 23862098126

✅ GitHub MCP — Last 2 merged PRs: #1549 "feat: include api-proxy token logs in firewall audit artifact" (@lpcox), #1544 "fix: disable IPv6 in agent container to prevent squid proxy bypass" (@lpcox)
✅ Playwright — github.com title contains "GitHub"
✅ File Write — /tmp/gh-aw/agent/smoke-test-copilot-23862098126.txt created
✅ Bash — File verified via cat

Overall: PASS | Author: @lpcox | Assignees: none

📰 BREAKING: Report filed by Smoke Copilot for issue #1550

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 1, 2026

🏗️ Build Test Suite Results

Ecosystem Project Build/Install Tests Status
Bun elysia N/A ❌ CLONE_FAILED
Bun hono N/A ❌ CLONE_FAILED
C++ fmt N/A ❌ CLONE_FAILED
C++ json N/A ❌ CLONE_FAILED
Deno oak N/A ❌ CLONE_FAILED
Deno std N/A ❌ CLONE_FAILED
.NET hello-world N/A ❌ CLONE_FAILED
.NET json-parse N/A ❌ CLONE_FAILED
Go color N/A ❌ CLONE_FAILED
Go env N/A ❌ CLONE_FAILED
Go uuid N/A ❌ CLONE_FAILED
Java gson N/A ❌ CLONE_FAILED
Java caffeine N/A ❌ CLONE_FAILED
Node.js clsx N/A ❌ CLONE_FAILED
Node.js execa N/A ❌ CLONE_FAILED
Node.js p-limit N/A ❌ CLONE_FAILED
Rust fd N/A ❌ CLONE_FAILED
Rust zoxide N/A ❌ CLONE_FAILED

Overall: 0/8 ecosystems passed — ❌ FAIL

Error Details

All repository clones failed. The gh CLI is not authenticated — GH_TOKEN is not set in the workflow environment.

gh: To use GitHub CLI in a GitHub Actions workflow, set the GH_TOKEN environment variable. Example:
  env:
    GH_TOKEN: ${{ github.token }}

Root cause: gh repo clone requires an authenticated gh CLI. The workflow must provide GH_TOKEN (or equivalent) so that test repositories can be cloned from Mossaka/gh-aw-firewall-test-*.

Generated by Build Test Suite for issue #1550 ·

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 1, 2026

Smoke Test: PASS

💥 [THE END] — Illustrated by Smoke Claude for issue #1550

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 1, 2026

Chroot Version Comparison Results

Runtime Host Version Chroot Version Match?
Python Python 3.12.13 Python 3.12.3 ❌ NO
Node.js v24.14.0 v20.20.1 ❌ NO
Go go1.22.12 go1.22.12 ✅ YES

Overall: ❌ FAILED — Python and Node.js versions differ between host and chroot environments.

Tested by Smoke Chroot for issue #1550

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

- Set WebSocket record status to 101 instead of 200
- Track header bytes separately; report only WS payload in response_bytes
- Properly unmask masked WebSocket frames with XOR key
- Sanitize diag() to strip raw_sample before writing to disk (CodeQL)
- Add test for masked frame unmasking

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 1, 2026

🤖 Smoke test results for @lpcox (no assignees):

Test Status
GitHub MCP (last 2 merged PRs) #1549 feat: include api-proxy token logs in firewall audit artifact, #1544 fix: disable IPv6 in agent container to prevent squid proxy bypass
Playwright (github.com title) ✅ "GitHub · Change is constant..."
File write /tmp/gh-aw/agent/smoke-test-copilot-23862883302.txt created
Bash verify ✅ File contents confirmed

Overall: PASS

📰 BREAKING: Report filed by Smoke Copilot for issue #1550

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 1, 2026

Smoke Test Results — PASS

💥 [THE END] — Illustrated by Smoke Claude for issue #1550

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 1, 2026

Smoke Test: GitHub Actions Services Connectivity ✅

All checks passed:

Service Check Result
Redis (host.docker.internal:6379) PING PONG
PostgreSQL (host.docker.internal:5432) pg_isready ✅ accepting connections
PostgreSQL (smoketest db) SELECT 1 ✅ returned 1

Note: redis-cli was not available; Redis was tested via raw TCP (/dev/tcp), receiving +PONG.

🔌 Service connectivity validated by Smoke Services

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 1, 2026

Chroot Version Comparison Results

Runtime Host Version Chroot Version Match?
Python Python 3.12.13 Python 3.12.3 ❌ NO
Node.js v24.14.0 v20.20.1 ❌ NO
Go go1.22.12 go1.22.12 ✅ YES

Result: ❌ FAILED — Python and Node.js versions differ between host and chroot environment.

Tested by Smoke Chroot for issue #1550

@github-actions

This comment has been minimized.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 1, 2026

🔮 The ancient spirits stir at the network boundary; this smoke-test oracle has passed through and marked the run. (Discussion target unavailable in current toolset, so this omen is left on the PR.)

🔮 The oracle has spoken through Smoke Codex

@lpcox lpcox merged commit 43870f7 into main Apr 1, 2026
64 of 65 checks passed
@lpcox lpcox deleted the fix/websocket-token-tracking branch April 1, 2026 18:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants