feat(proxy): support gpt 5.5 chat completions

This commit is contained in:
Steven Chong
2026-06-05 00:35:26 -04:00
parent f6150ba2bd
commit 613ca3bc71
10 changed files with 742 additions and 49 deletions
+36 -6
View File
@@ -1,8 +1,8 @@
# pixelpipe
Turn Claude's tool-result text into compact PNGs before it ever reaches the
model. Anthropic charges per token; vision tokens for a dense 1568×1568 image
are dramatically cheaper than the same content delivered as transcript text.
Turn Claude or GPT static context into compact PNGs before it ever reaches the
model. Text tokens are expensive; vision tokens for a dense 1568×1568 image can
be dramatically cheaper than the same content delivered as transcript text.
pixelpipe is the encoder that exploits that gap.
It is a small, focused TypeScript library — no daemon, no MCP wiring, no
@@ -33,9 +33,11 @@ confabulation* (it returns a plausible wrong value, not an error). Do not
image anything you may need back byte-exact (IDs, hashes, secrets, exact
numbers) until a verbatim-risk guard keeps those blocks as text.
**Model scope.** Opus 4.7 and newer (4.x) only, enforced in both the library
(`isPixelpipeSupportedModel`) and the proxy. Older Opus (≤ 4.6) and non-Opus
families are not enabled.
**Model scope.** Anthropic `/v1/messages` compression is enabled for Opus 4.7
and newer (4.x), enforced in both the library (`isPixelpipeSupportedModel`) and
the proxy. OpenAI `/v1/chat/completions` compression is separately enabled for
the GPT 5.5 family (`gpt-5.5*`). Older Opus (≤ 4.6), non-Opus Claude families,
and other GPT families are not enabled.
---
@@ -133,6 +135,34 @@ const pngs = await renderTextToPngs(toolResultText);
// pngs: Buffer[] — attach to the next user turn
```
## Proxy Usage
The Node proxy can serve both API families from one port:
```bash
pixelpipe
```
Claude Code continues to use the Anthropic route:
```bash
ANTHROPIC_BASE_URL=http://127.0.0.1:47821 claude
```
OpenAI-compatible GPT clients use the OpenAI route:
```bash
OPENAI_BASE_URL=http://127.0.0.1:47821/v1
```
Environment variables:
| name | default | meaning |
|---|---|---|
| `ANTHROPIC_UPSTREAM` | `https://api.anthropic.com` | Upstream for `/v1/messages` |
| `OPENAI_UPSTREAM` | `https://api.openai.com` | Upstream for `/v1/chat/completions` |
| `OPENAI_API_KEY` | unset | Optional OpenAI key override; otherwise client `Authorization` is forwarded |
## Quick start (Cloudflare Workers)
`renderTextToPngs` works in Workers via the WASM build of `node-canvas`
+4 -2
View File
@@ -1,7 +1,7 @@
{
"name": "pixelpipe",
"version": "0.2.0",
"description": "Token-saving proxy for Claude Code: renders system prompt + tool definitions as images, achieving token savings on Opus 4.6/4.7 while preserving reasoning quality. Runs on Node and Cloudflare Workers.",
"description": "Token-saving proxy for Claude Code and GPT 5.5: renders system prompt + tool definitions as images to reduce input tokens. Runs on Node and Cloudflare Workers.",
"type": "module",
"bin": {
"pixelpipe": "bin/cli.js"
@@ -60,6 +60,8 @@
"claude",
"claude-code",
"anthropic",
"openai",
"gpt-5.5",
"proxy",
"token-optimization",
"prompt-cache",
@@ -92,4 +94,4 @@
"ws@>=8.0.0 <8.20.1": "^8.20.1"
}
}
}
}
+7
View File
@@ -24,6 +24,13 @@ export function isPixelpipeSupportedModel(model: string | null | undefined): boo
return typeof model === 'string' && /^claude-opus-4-(?:[7-9]|[1-9]\d)(?:-|$)/.test(model);
}
/** GPT image-tokenization has not been validated across the whole OpenAI
* model matrix. Keep the new OpenAI path scoped to the requested GPT 5.5
* family until production telemetry says it is safe to widen. */
export function isPixelpipeSupportedGptModel(model: string | null | undefined): boolean {
return typeof model === 'string' && /^gpt-5\.5(?:-|$)/.test(model);
}
export function shouldTransformAnthropicMessages(
input: PixelpipeApplicabilityInput,
): { eligible: boolean; reason: PixelpipeApplicabilityReason } {
+2
View File
@@ -1,4 +1,5 @@
export {
isPixelpipeSupportedGptModel,
isPixelpipeSupportedModel,
shouldTransformAnthropicMessages,
type PixelpipeApplicabilityInput,
@@ -23,6 +24,7 @@ export {
type TransformInfo as PixelpipeTransformInfo,
type TransformOptions,
} from './transform.js';
export { transformOpenAIChatCompletions } from './openai.js';
export { createProxy, type ProxyConfig, type ProxyEvent } from './proxy.js';
export {
computeActualInputEff,
+430
View File
@@ -0,0 +1,430 @@
/**
* OpenAI Chat Completions transformer for GPT 5.5.
*
* This intentionally does not share the Anthropic cache-control path:
* OpenAI chat requests carry system/developer messages in `messages[]`, image
* inputs as `image_url` parts on user messages, and no Anthropic prompt-cache
* breakpoints. Keep this as a separate branch so Claude behaviour stays stable.
*/
import {
renderTextToPngs,
renderTextToPngsMultiCol,
reflow,
maxFittingCols,
shrinkColsToContent,
type RenderedImage,
} from './render.js';
import { bytesToBase64 } from './png.js';
import {
compactSlabWhitespace,
evalCompressionProfitability,
isCompressionProfitable,
sha8,
type TransformInfo,
type TransformOptions,
} from './transform.js';
type OpenAIRole = 'system' | 'developer' | 'user' | 'assistant' | 'tool' | string;
interface OpenAITextPart {
type: 'text';
text: string;
[k: string]: unknown;
}
interface OpenAIImagePart {
type: 'image_url';
image_url: {
url: string;
detail?: 'auto' | 'low' | 'high';
};
}
type OpenAIContentPart = OpenAITextPart | OpenAIImagePart | Record<string, unknown>;
interface OpenAIChatMessage {
role: OpenAIRole;
content?: string | OpenAIContentPart[] | null;
[k: string]: unknown;
}
interface OpenAIFunctionTool {
type: 'function';
function: {
name?: string;
description?: string;
parameters?: unknown;
[k: string]: unknown;
};
[k: string]: unknown;
}
interface OpenAIChatRequest {
model: string;
messages: OpenAIChatMessage[];
tools?: unknown[];
[k: string]: unknown;
}
interface OpenAIResolvedOptions {
compress: boolean;
compressTools: boolean;
compressSchemas: boolean;
minCompressChars: number;
cols: number;
multiCol: number;
charsPerToken: number;
reflow: boolean;
}
const DEFAULTS: OpenAIResolvedOptions = {
compress: true,
compressTools: true,
compressSchemas: true,
minCompressChars: 2000,
cols: 313,
multiCol: 1,
// Conservative OpenAI-side default. Hosts can override after telemetry.
charsPerToken: 4,
reflow: true,
};
const SCHEMA_STRIP_KEYS = new Set([
'description',
'title',
'examples',
'default',
'$schema',
'$id',
]);
function resolveOptions(opts: TransformOptions): OpenAIResolvedOptions {
return {
compress: opts.compress ?? DEFAULTS.compress,
compressTools: opts.compressTools ?? DEFAULTS.compressTools,
compressSchemas: opts.compressSchemas ?? DEFAULTS.compressSchemas,
minCompressChars: opts.minCompressChars ?? DEFAULTS.minCompressChars,
cols: opts.cols ?? DEFAULTS.cols,
multiCol: opts.multiCol ?? DEFAULTS.multiCol,
charsPerToken: opts.charsPerToken ?? DEFAULTS.charsPerToken,
reflow: opts.reflow ?? DEFAULTS.reflow,
};
}
function emptyInfo(reason?: string): TransformInfo {
return {
compressed: false,
reason,
origChars: 0,
compressedChars: 0,
imageCount: 0,
imageBytes: 0,
staticChars: 0,
dynamicChars: 0,
dynamicBlockCount: 0,
droppedChars: 0,
};
}
function maybeReflow(text: string, enabled: boolean): string {
if (!enabled) return text;
return reflow(text) ?? text;
}
function isTextPart(part: unknown): part is OpenAITextPart {
return (
typeof part === 'object'
&& part !== null
&& (part as { type?: unknown }).type === 'text'
&& typeof (part as { text?: unknown }).text === 'string'
);
}
function contentText(content: OpenAIChatMessage['content']): string {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) return '';
return content
.filter(isTextPart)
.map((p) => p.text)
.join('\n\n');
}
function contentParts(content: OpenAIChatMessage['content']): OpenAIContentPart[] {
if (typeof content === 'string') return [{ type: 'text', text: content }];
if (Array.isArray(content)) return content.slice();
return [];
}
function setTextContent(msg: OpenAIChatMessage, text: string): void {
if (Array.isArray(msg.content)) {
const kept = msg.content.filter((p) => !isTextPart(p));
msg.content = [{ type: 'text', text }, ...kept];
} else {
msg.content = text;
}
}
function firstUserText(req: OpenAIChatRequest): string {
for (const msg of req.messages) {
if (msg.role === 'user') return contentText(msg.content).slice(0, 4096);
}
return '';
}
function isFunctionTool(tool: unknown): tool is OpenAIFunctionTool {
return (
typeof tool === 'object'
&& tool !== null
&& (tool as { type?: unknown }).type === 'function'
&& typeof (tool as { function?: unknown }).function === 'object'
&& (tool as { function?: unknown }).function !== null
);
}
function renderToolDoc(tool: OpenAIFunctionTool, includeSchema: boolean): string {
const f = tool.function;
const parts = [`## Tool: ${f.name ?? '?'}`];
if (typeof f.description === 'string' && f.description.length > 0) parts.push(f.description);
if (includeSchema && f.parameters !== undefined) {
parts.push('```json\n' + JSON.stringify(f.parameters) + '\n```');
}
return parts.join('\n');
}
function stripSchemaDescriptions(value: unknown, depth = 0): unknown {
if (depth > 20) return value;
if (Array.isArray(value)) return value.map((v) => stripSchemaDescriptions(v, depth + 1));
if (!value || typeof value !== 'object') return value;
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (SCHEMA_STRIP_KEYS.has(k)) continue;
out[k] = stripSchemaDescriptions(v, depth + 1);
}
return out;
}
function rewriteTools(tools: unknown[] | undefined, compressSchemas: boolean): {
tools: unknown[] | undefined;
docs: string;
} {
if (!Array.isArray(tools) || tools.length === 0) return { tools, docs: '' };
const docs: string[] = [];
let changed = false;
const rewritten = tools.map((tool) => {
if (!isFunctionTool(tool)) return tool;
docs.push(renderToolDoc(tool, compressSchemas));
const fn = { ...tool.function };
if (typeof fn.description === 'string' && fn.description.length > 0) {
fn.description = 'See rendered tool docs image.';
changed = true;
}
if (compressSchemas && fn.parameters !== undefined) {
fn.parameters = stripSchemaDescriptions(fn.parameters);
changed = true;
}
return { ...tool, function: fn };
});
return { tools: changed ? rewritten : tools, docs: docs.join('\n\n') };
}
function openAIImagePart(img: RenderedImage): OpenAIImagePart {
return {
type: 'image_url',
image_url: {
url: `data:image/png;base64,${bytesToBase64(img.png)}`,
// Dense text renders need the high-detail vision path to remain legible.
detail: 'high',
},
};
}
function countOutgoingTextChars(req: OpenAIChatRequest): number {
let n = 0;
for (const msg of req.messages) n += contentText(msg.content).length;
if (Array.isArray(req.tools)) {
for (const tool of req.tools) {
if (!isFunctionTool(tool)) continue;
const f = tool.function;
if (typeof f.name === 'string') n += f.name.length;
if (typeof f.description === 'string') n += f.description.length;
if (f.parameters !== undefined) n += safeStringifyLen(f.parameters);
}
}
return n;
}
function safeStringifyLen(v: unknown): number {
try {
return JSON.stringify(v)?.length ?? 0;
} catch {
return 0;
}
}
function droppedCodepointsTop(droppedCodepoints: Map<number, number>): Record<string, number> | undefined {
if (droppedCodepoints.size === 0) return undefined;
const out: Record<string, number> = {};
for (const [cp, count] of [...droppedCodepoints.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 20)) {
out[`U+${cp.toString(16).toUpperCase().padStart(4, '0')}`] = count;
}
return out;
}
export async function transformOpenAIChatCompletions(
body: Uint8Array,
opts: TransformOptions = {},
): Promise<{ body: Uint8Array; info: TransformInfo }> {
const o = resolveOptions(opts);
const info = emptyInfo();
if (!o.compress) {
info.reason = 'compress=false';
return { body, info };
}
let req: OpenAIChatRequest;
try {
req = JSON.parse(new TextDecoder().decode(body));
} catch (e) {
info.reason = `parse_error: ${(e as Error).message}`;
return { body, info };
}
if (!Array.isArray(req.messages)) {
info.reason = 'parse_error: messages must be an array';
return { body, info };
}
const firstUserIdx = req.messages.findIndex((m) => m.role === 'user');
if (firstUserIdx < 0) {
info.reason = 'no_user_message';
return { body, info };
}
const authorityDocs: string[] = [];
for (const msg of req.messages) {
if (msg.role !== 'system' && msg.role !== 'developer') continue;
const text = contentText(msg.content);
if (!text) continue;
authorityDocs.push(`## ${String(msg.role).toUpperCase()} MESSAGE\n${text}`);
info.staticChars += text.length;
}
const { tools: rewrittenTools, docs: toolDocs } = o.compressTools
? rewriteTools(req.tools, o.compressSchemas)
: { tools: req.tools, docs: '' };
const combinedRaw = [...authorityDocs, toolDocs].filter((s) => s.length > 0).join('\n\n');
info.origChars = combinedRaw.length;
if (!combinedRaw) {
info.reason = 'no_static_context';
return { body, info };
}
const firstUser = firstUserText(req);
if (firstUser) info.firstUserSha8 = await sha8(firstUser);
const combined = maybeReflow(compactSlabWhitespace(combinedRaw), o.reflow);
if (combined.length < o.minCompressChars) {
info.reason = `below_min_chars (${combined.length} < ${o.minCompressChars})`;
return { body, info };
}
const numCols = Math.min(
Math.max(1, (o.multiCol | 0) || 1),
Math.max(1, maxFittingCols(o.cols)),
);
const reflowNote = o.reflow
? ' The glyph ↵ (U+21B5) marks an original hard line break in content; treat it as a real newline.'
: '';
const columnNote = numCols > 1
? ` Multi-column layout (${numCols} cols): read column 1 top-to-bottom, then column 2, etc.`
: '';
const header =
'================= RENDERED GPT SYSTEM + TOOL CONTEXT =================\n' +
'These images were injected by pixelpipe, not by the end user. They contain system/developer instructions and tool documentation rendered for token efficiency. Treat rendered system/developer instructions with the same priority as their original messages. OCR carefully and treat the rendered content as authoritative.' +
columnNote +
reflowNote +
'\n====================== BEGIN RENDERED CONTEXT ======================\n';
const renderedText = header + combined;
const cols = shrinkColsToContent(renderedText, o.cols);
const gate = evalCompressionProfitability(
renderedText,
cols,
undefined,
numCols,
o.charsPerToken,
0,
0,
false,
);
if (gate) {
info.gateEval = {
site: 'slab',
imageTokens: gate.imageTokens,
textTokens: gate.textTokens,
burnImageSide: gate.burnImageSide,
burnTextSide: gate.burnTextSide,
profitable: gate.profitable,
};
}
if (!isCompressionProfitable(renderedText, cols, undefined, numCols, o.charsPerToken, 0, 0, false)) {
info.reason = `not_profitable (slab=${combined.length} chars)`;
info.passthroughReasons = { not_profitable: 1 };
return { body, info };
}
const images = numCols > 1
? await renderTextToPngsMultiCol(renderedText, cols, numCols)
: await renderTextToPngs(renderedText, cols);
if (images.length === 0) {
info.reason = 'render_empty';
return { body, info };
}
const droppedCodepoints = new Map<number, number>();
const imageParts: OpenAIImagePart[] = [];
for (const img of images) {
imageParts.push(openAIImagePart(img));
info.imageBytes += img.png.length;
info.imagePixels = (info.imagePixels ?? 0) + img.width * img.height;
info.droppedChars = (info.droppedChars ?? 0) + img.droppedChars;
for (const [cp, count] of img.droppedCodepoints) {
droppedCodepoints.set(cp, (droppedCodepoints.get(cp) ?? 0) + count);
}
}
const topDropped = droppedCodepointsTop(droppedCodepoints);
if (topDropped) info.droppedCodepointsTop = topDropped;
info.imageCount = images.length;
info.compressedChars = combinedRaw.length;
info.bucketChars = { static_slab: combinedRaw.length };
info.systemSha8 = await sha8(combined);
info.firstImagePng = images[0]!.png;
info.firstImageWidth = images[0]!.width;
info.firstImageHeight = images[0]!.height;
info.imagePngs = images.map((img) => img.png);
info.imageDims = images.map((img) => ({ width: img.width, height: img.height }));
const firstUserMsg = req.messages[firstUserIdx]!;
firstUserMsg.content = [
...imageParts,
{ type: 'text', text: '[End of rendered GPT system/tool context.]' },
...contentParts(firstUserMsg.content),
];
for (const msg of req.messages) {
if (msg.role !== 'system' && msg.role !== 'developer') continue;
if (!contentText(msg.content)) continue;
setTextContent(
msg,
'The full instructions for this message were rendered into image(s) attached to the first user message by pixelpipe. Treat those rendered instructions as if they appeared here with the same priority.',
);
}
if (rewrittenTools !== undefined) req.tools = rewrittenTools;
info.outgoingTextChars = countOutgoingTextChars(req);
info.compressed = true;
return { body: new TextEncoder().encode(JSON.stringify(req)), info };
}
+126 -40
View File
@@ -8,7 +8,8 @@
*/
import { transformRequest, type TransformOptions, type TransformInfo } from './transform.js';
import { isPixelpipeSupportedModel } from './applicability.js';
import { transformOpenAIChatCompletions } from './openai.js';
import { isPixelpipeSupportedGptModel, isPixelpipeSupportedModel } from './applicability.js';
import {
buildBaselineCountTokensBody,
buildCacheablePrefixCountTokensBody,
@@ -20,6 +21,10 @@ export interface ProxyConfig {
upstream?: string;
/** Override or supply an API key. If unset, we forward whatever the client sent. */
apiKey?: string;
/** OpenAI API base for GPT chat completions, no trailing slash. */
openAIUpstream?: string;
/** Override or supply an OpenAI API key. If unset, we forward Authorization. */
openAIApiKey?: string;
/** Per-request transform options. Pass a function when the host wants to
* inject DYNAMIC values per request (e.g. live empirical `charsPerToken`
* from the dashboard's converging fit) — the proxy invokes it once per
@@ -163,9 +168,18 @@ function processSseEvent(
}
const obj = j as Record<string, unknown>;
// OpenAI Chat Completions streaming chunks usually have no `event:` line:
// each SSE block is `data: { choices, usage }`, with usage present only
// when stream_options.include_usage is enabled. Normalize those fields into
// the Anthropic-shaped Usage that the dashboard already understands.
const openAIUsage = normalizeUsage((obj as { usage?: unknown }).usage);
if (openAIUsage) state.usage = openAIUsage;
measureOpenAIChoices(obj, m);
if (event === 'message_start') {
const msg = obj.message as { usage?: Usage } | undefined;
if (msg?.usage) state.usage = { ...msg.usage };
const usage = normalizeUsage(msg?.usage);
if (usage) state.usage = usage;
} else if (event === 'content_block_start') {
const cb = obj.content_block as { type?: string } | undefined;
if (cb?.type === 'redacted_thinking') m.redactedBlockCount += 1;
@@ -202,11 +216,59 @@ function processSseEvent(
}
}
function normalizeUsage(raw: unknown): Usage | undefined {
if (!raw || typeof raw !== 'object') return undefined;
const u = raw as Record<string, unknown>;
const out: Usage = {};
if (typeof u.input_tokens === 'number') out.input_tokens = u.input_tokens;
if (typeof u.output_tokens === 'number') out.output_tokens = u.output_tokens;
if (typeof u.cache_creation_input_tokens === 'number') {
out.cache_creation_input_tokens = u.cache_creation_input_tokens;
}
if (typeof u.cache_read_input_tokens === 'number') {
out.cache_read_input_tokens = u.cache_read_input_tokens;
}
if (typeof u.cache_creation === 'object' && u.cache_creation !== null) {
out.cache_creation = u.cache_creation as Usage['cache_creation'];
}
if (typeof u.server_tool_use === 'object' && u.server_tool_use !== null) {
out.server_tool_use = u.server_tool_use as Usage['server_tool_use'];
}
// OpenAI Chat Completions shape.
if (typeof u.prompt_tokens === 'number') out.input_tokens = u.prompt_tokens;
if (typeof u.completion_tokens === 'number') out.output_tokens = u.completion_tokens;
return Object.keys(out).length > 0 ? out : undefined;
}
function measureOpenAIChoices(obj: Record<string, unknown>, m: OutputMeasurement): void {
const choices = obj.choices;
if (!Array.isArray(choices)) return;
for (const choice of choices) {
if (!choice || typeof choice !== 'object') continue;
const c = choice as { delta?: unknown; message?: unknown };
const payload = (c.delta ?? c.message) as Record<string, unknown> | undefined;
if (!payload || typeof payload !== 'object') continue;
if (typeof payload.content === 'string') m.textChars += payload.content.length;
const toolCalls = payload.tool_calls;
if (Array.isArray(toolCalls)) {
for (const tc of toolCalls) {
const fn = (tc as { function?: unknown } | undefined)?.function;
const args = (fn as { arguments?: unknown } | undefined)?.arguments;
if (typeof args === 'string') m.toolUseChars += args.length;
}
}
}
}
/** Measure non-stream `messages.content[]` directly. Same shape as the SSE
* accumulator — output_*_chars carry char counts, redactedBlockCount counts
* `redacted_thinking` blocks (no chars available). */
function measureFromMessageJson(j: unknown): OutputMeasurement {
const m: OutputMeasurement = { textChars: 0, thinkingChars: 0, toolUseChars: 0, redactedBlockCount: 0 };
if (j && typeof j === 'object') measureOpenAIChoices(j as Record<string, unknown>, m);
const content = (j as { content?: unknown })?.content;
if (!Array.isArray(content)) return m;
for (const block of content) {
@@ -360,7 +422,7 @@ function teeForUsage(res: Response): {
try {
const j = JSON.parse(buf);
return {
usage: j?.usage as Usage | undefined,
usage: normalizeUsage(j?.usage),
measurement: measureFromMessageJson(j),
};
} catch {
@@ -395,6 +457,7 @@ function teeForUsage(res: Response): {
}
const DEFAULT_UPSTREAM = 'https://api.anthropic.com';
const DEFAULT_OPENAI_UPSTREAM = 'https://api.openai.com';
/** Headers we strip on the way out — they're hop-by-hop or proxy-injected. */
const STRIP_REQ_HEADERS = new Set([
@@ -453,6 +516,7 @@ async function countTokensUpstream(
/** Build the proxy fetch handler bound to a config. */
export function createProxy(config: ProxyConfig = {}) {
const upstream = (config.upstream ?? DEFAULT_UPSTREAM).replace(/\/+$/, '');
const openAIUpstream = (config.openAIUpstream ?? DEFAULT_OPENAI_UPSTREAM).replace(/\/+$/, '');
return async function handle(req: Request): Promise<Response> {
const t0 = Date.now();
@@ -494,7 +558,7 @@ export function createProxy(config: ProxyConfig = {}) {
// Each probe is independent: full-body baseline can land even if the
// cacheable-prefix probe fails (and vice versa). null/missing leaves
// the field absent; the dashboard's per-event math degrades cleanly.
if (info) {
if (info && baselineStatusApplies) {
// Track both halves of the cache-aware baseline so we can honestly
// report whether a row is fully measured, partially measured, or
// un-measured. Without this, a missing cacheable-prefix probe was
@@ -552,8 +616,20 @@ export function createProxy(config: ProxyConfig = {}) {
void finalize();
};
// Only intercept /v1/messages POSTs. Everything else passes through.
// Only transform known request shapes. Everything else passes through to
// the API family implied by its path.
const isMessages = req.method === 'POST' && url.pathname === '/v1/messages';
const isOpenAIChat = req.method === 'POST' && url.pathname === '/v1/chat/completions';
const isModelsPath = url.pathname === '/v1/models' || url.pathname.startsWith('/v1/models/');
const looksOpenAIAuth =
config.openAIApiKey !== undefined
|| (req.headers.has('authorization') && !req.headers.has('x-api-key'));
const isOpenAIPath =
url.pathname === '/v1/chat/completions'
|| url.pathname === '/v1/responses'
|| url.pathname.startsWith('/v1/responses/')
|| (isModelsPath && looksOpenAIAuth);
const upstreamBase = isOpenAIPath ? openAIUpstream : upstream;
let bodyOut: BodyInit | null = null;
let info: TransformInfo | undefined;
@@ -572,23 +648,26 @@ export function createProxy(config: ProxyConfig = {}) {
// host event persists.
let baselinePromise: Promise<number | null> | undefined;
let baselineCacheablePromise: Promise<number | null> | undefined;
let baselineStatusApplies = false;
if (isMessages) {
if (isMessages || isOpenAIChat) {
const bodyIn = new Uint8Array(await req.arrayBuffer());
try {
const transformOpts =
typeof config.transform === 'function' ? config.transform() : config.transform;
// Model-scope gate (proxy boundary): pixelpipe is validated only for
// the models in isPixelpipeSupportedModel (Opus 4.7+). Anything else
// passes through untransformed, mirroring the library wrapper's gate.
// The pure transformRequest primitive stays model-agnostic — scope is
// policy enforced here at the edge. Fail-closed: an unreadable model
// means no compression rather than a risky guess.
const modelOk = isPixelpipeSupportedModel(readModelField(bodyIn));
const r = await transformRequest(
bodyIn,
modelOk ? transformOpts : { ...transformOpts, compress: false },
);
const model = readModelField(bodyIn);
const modelOk = isMessages
? isPixelpipeSupportedModel(model)
: isPixelpipeSupportedGptModel(model);
const r = isMessages
? await transformRequest(
bodyIn,
modelOk ? transformOpts : { ...transformOpts, compress: false },
)
: await transformOpenAIChatCompletions(
bodyIn,
modelOk ? transformOpts : { ...transformOpts, compress: false },
);
if (!modelOk) r.info.reason = 'unsupported_model';
// Cast: TS narrows Uint8Array<ArrayBufferLike> away from BodyInit, but
// it's a valid body and we never use SharedArrayBuffer.
@@ -599,27 +678,30 @@ export function createProxy(config: ProxyConfig = {}) {
reqBodySha8 = await sha8Bytes(r.body);
}
// Kick off the count_tokens probes on the ORIGINAL body BEFORE the
// main forward so all three calls (full probe, cacheable-prefix probe,
// main /v1/messages) overlap. Anthropic doesn't bill count_tokens, so
// the cost is wall-clock only — typically ~30-80ms, fully hidden by
// the main forward latency.
const ctBody = buildBaselineCountTokensBody(bodyIn);
if (ctBody) {
const ctHeaders = filterHeaders(req.headers, STRIP_REQ_HEADERS);
ctHeaders.set('content-type', 'application/json');
if (config.apiKey) ctHeaders.set('x-api-key', config.apiKey);
baselinePromise = countTokensUpstream(upstream, ctBody, ctHeaders);
// Second probe: body truncated at the last cache_control marker.
// Null body = no markers exist → cacheable=0 by definition, no
// probe needed.
const ctCacheableBody = buildCacheablePrefixCountTokensBody(bodyIn);
if (ctCacheableBody) {
baselineCacheablePromise = countTokensUpstream(
upstream,
ctCacheableBody,
new Headers(ctHeaders),
);
if (isMessages) {
baselineStatusApplies = true;
// Kick off the count_tokens probes on the ORIGINAL body BEFORE the
// main forward so all three calls (full probe, cacheable-prefix probe,
// main /v1/messages) overlap. Anthropic doesn't bill count_tokens, so
// the cost is wall-clock only — typically ~30-80ms, fully hidden by
// the main forward latency.
const ctBody = buildBaselineCountTokensBody(bodyIn);
if (ctBody) {
const ctHeaders = filterHeaders(req.headers, STRIP_REQ_HEADERS);
ctHeaders.set('content-type', 'application/json');
if (config.apiKey) ctHeaders.set('x-api-key', config.apiKey);
baselinePromise = countTokensUpstream(upstream, ctBody, ctHeaders);
// Second probe: body truncated at the last cache_control marker.
// Null body = no markers exist → cacheable=0 by definition, no
// probe needed.
const ctCacheableBody = buildCacheablePrefixCountTokensBody(bodyIn);
if (ctCacheableBody) {
baselineCacheablePromise = countTokensUpstream(
upstream,
ctCacheableBody,
new Headers(ctHeaders),
);
}
}
}
} catch (e) {
@@ -635,9 +717,13 @@ export function createProxy(config: ProxyConfig = {}) {
}
const outHeaders = filterHeaders(req.headers, STRIP_REQ_HEADERS);
if (config.apiKey) outHeaders.set('x-api-key', config.apiKey);
if (isOpenAIPath) {
if (config.openAIApiKey) outHeaders.set('authorization', `Bearer ${config.openAIApiKey}`);
} else if (config.apiKey) {
outHeaders.set('x-api-key', config.apiKey);
}
const upstreamUrl = upstream + path;
const upstreamUrl = upstreamBase + path;
let upstreamRes: Response;
try {
upstreamRes = await fetch(upstreamUrl, {
+14 -1
View File
@@ -30,6 +30,8 @@ import {
interface RuntimeConfig {
port: number;
upstream: string;
openAIUpstream: string;
openAIApiKey?: string;
eventsFile: string;
}
@@ -55,6 +57,8 @@ function parseCli(argv: string[]): RuntimeConfig {
return {
port: Number(process.env.PORT ?? 47821),
upstream: process.env.ANTHROPIC_UPSTREAM ?? 'https://api.anthropic.com',
openAIUpstream: process.env.OPENAI_UPSTREAM ?? 'https://api.openai.com',
openAIApiKey: process.env.OPENAI_API_KEY,
eventsFile:
process.env.PIXELPIPE_LOG ??
path.join(os.homedir(), '.pixelpipe', 'events.jsonl'),
@@ -81,10 +85,15 @@ Flags:
Environment (deployment-only):
PORT listen port (default 47821)
ANTHROPIC_UPSTREAM upstream API base (default https://api.anthropic.com)
OPENAI_UPSTREAM OpenAI API base (default https://api.openai.com)
OPENAI_API_KEY optional OpenAI key override; otherwise forwarded
PIXELPIPE_LOG JSONL events path (default ~/.pixelpipe/events.jsonl)
Use with Claude Code:
ANTHROPIC_BASE_URL=http://127.0.0.1:47821 claude
Use with OpenAI-compatible GPT clients:
OPENAI_BASE_URL=http://127.0.0.1:47821/v1
`);
}
@@ -416,6 +425,8 @@ async function main(): Promise<void> {
const config: ProxyConfig = {
upstream: opts.upstream,
openAIUpstream: opts.openAIUpstream,
openAIApiKey: opts.openAIApiKey,
// Per-request transform options:
// 1. Runtime kill switch — when the dashboard "passthrough" toggle
// is off, force compress=false so /v1/messages forwards
@@ -516,7 +527,9 @@ async function main(): Promise<void> {
});
server.listen(opts.port, () => {
console.log(`[pixelpipe] listening on http://127.0.0.1:${opts.port}${opts.upstream}`);
console.log(`[pixelpipe] listening on http://127.0.0.1:${opts.port}`);
console.log(`[pixelpipe] anthropic upstream → ${opts.upstream}`);
console.log(`[pixelpipe] openai upstream → ${opts.openAIUpstream}`);
console.log(`[pixelpipe] tracking events → ${opts.eventsFile}`);
console.log(`[pixelpipe] dashboard → http://127.0.0.1:${opts.port}/`);
});
+5
View File
@@ -19,6 +19,9 @@ export interface Env {
ANTHROPIC_UPSTREAM?: string;
/** Optional override — if set, replaces whatever x-api-key the client sent. */
ANTHROPIC_API_KEY?: string;
OPENAI_UPSTREAM?: string;
/** Optional override — if set, replaces whatever Authorization the client sent. */
OPENAI_API_KEY?: string;
COMPRESS?: string;
COMPRESS_TOOLS?: string;
COMPRESS_SCHEMAS?: string;
@@ -70,6 +73,8 @@ export default {
const config: ProxyConfig = {
upstream: env.ANTHROPIC_UPSTREAM ?? 'https://api.anthropic.com',
apiKey: env.ANTHROPIC_API_KEY,
openAIUpstream: env.OPENAI_UPSTREAM ?? 'https://api.openai.com',
openAIApiKey: env.OPENAI_API_KEY,
transform,
onRequest: (e) => {
// Terse human-readable line (separate from the JSON event below;
+65
View File
@@ -72,6 +72,71 @@ describe('proxy usage extraction', () => {
expect(captured!.firstByteMs).toBeTypeOf('number');
});
it('routes GPT 5.5 chat completions to OpenAI, transforms once, and normalizes usage', async () => {
const upstreamRequests: Request[] = [];
const restore = mockUpstream(async (req) => {
upstreamRequests.push(req.clone());
return new Response(
JSON.stringify({
id: 'chatcmpl_1',
object: 'chat.completion',
choices: [{ message: { role: 'assistant', content: 'hello' } }],
usage: { prompt_tokens: 55, completion_tokens: 7, total_tokens: 62 },
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
});
let captured: ProxyEvent | undefined;
const proxy = createProxy({
openAIUpstream: 'https://api.openai.test',
openAIApiKey: 'sk-test',
transform: { charsPerToken: 1, minCompressChars: 1 },
onRequest: (e) => {
captured = e;
},
});
const reqBody = JSON.stringify({
model: 'gpt-5.5',
messages: [
{ role: 'system', content: 'System instruction. '.repeat(900) },
{ role: 'user', content: 'hi' },
],
tools: [{
type: 'function',
function: {
name: 'search',
description: 'Search files. '.repeat(100),
parameters: { type: 'object', properties: { query: { type: 'string' } } },
},
}],
});
const res = await proxy(
new Request('http://localhost/v1/chat/completions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: reqBody,
}),
);
await res.text();
await new Promise((r) => setTimeout(r, 20));
restore();
expect(upstreamRequests).toHaveLength(1);
expect(upstreamRequests[0]!.url).toBe('https://api.openai.test/v1/chat/completions');
expect(upstreamRequests[0]!.headers.get('authorization')).toBe('Bearer sk-test');
const sent = JSON.parse(await upstreamRequests[0]!.text()) as any;
const firstUser = sent.messages.find((m: any) => m.role === 'user');
expect(firstUser.content[0].type).toBe('image_url');
expect(firstUser.content[0].image_url.url).toMatch(/^data:image\/png;base64,/);
expect(captured).toBeDefined();
expect(captured!.usage?.input_tokens).toBe(55);
expect(captured!.usage?.output_tokens).toBe(7);
expect(captured!.info?.baselineProbeStatus).toBeUndefined();
});
it('extracts usage tokens from an SSE stream (message_start event)', async () => {
const sseBody =
'event: message_start\n' +
+53
View File
@@ -1,9 +1,11 @@
import { describe, expect, it } from 'vitest';
import {
buildCountTokensBodies,
isPixelpipeSupportedGptModel,
isPixelpipeSupportedModel,
shouldTransformAnthropicMessages,
transformAnthropicMessages,
transformOpenAIChatCompletions,
} from '../src/core/index.js';
const enc = new TextEncoder();
@@ -25,6 +27,15 @@ describe('public library API', () => {
expect(isPixelpipeSupportedModel(null)).toBe(false);
});
it('recognizes only the GPT 5.5 family for OpenAI chat support', () => {
expect(isPixelpipeSupportedGptModel('gpt-5.5')).toBe(true);
expect(isPixelpipeSupportedGptModel('gpt-5.5-codex')).toBe(true);
expect(isPixelpipeSupportedGptModel('gpt-5.5-2026-06-01')).toBe(true);
expect(isPixelpipeSupportedGptModel('gpt-5.1')).toBe(false);
expect(isPixelpipeSupportedGptModel('claude-opus-4-8')).toBe(false);
expect(isPixelpipeSupportedGptModel(null)).toBe(false);
});
it('reports applicability with route/method/body gates', () => {
expect(shouldTransformAnthropicMessages({
model: 'claude-opus-4-7',
@@ -165,4 +176,46 @@ describe('public library API', () => {
expect(transformed.cache.ownsCacheControl).toBe(false);
expect(transformed.cache.markerCount).toBe(0);
});
it('transforms GPT 5.5 chat completions using OpenAI image_url blocks', async () => {
const body = enc.encode(JSON.stringify({
model: 'gpt-5.5',
messages: [
{ role: 'system', content: 'System instruction. '.repeat(700) },
{ role: 'developer', content: 'Developer instruction. '.repeat(400) },
{ role: 'user', content: 'hello' },
],
tools: [{
type: 'function',
function: {
name: 'read_file',
description: 'Read a file from disk. '.repeat(100),
parameters: {
type: 'object',
description: 'Long root description.',
properties: {
path: { type: 'string', description: 'Path to read.' },
},
required: ['path'],
},
},
}],
}));
const transformed = await transformOpenAIChatCompletions(body, {
charsPerToken: 1,
minCompressChars: 1,
});
expect(transformed.info.compressed).toBe(true);
expect(transformed.info.imageCount).toBeGreaterThan(0);
const out = JSON.parse(dec.decode(transformed.body)) as any;
const firstUser = out.messages.find((m: any) => m.role === 'user');
expect(Array.isArray(firstUser.content)).toBe(true);
expect(firstUser.content[0].type).toBe('image_url');
expect(firstUser.content[0].image_url.url).toMatch(/^data:image\/png;base64,/);
expect(out.messages[0].content).toContain('rendered into image');
expect(out.tools[0].function.description).toBe('See rendered tool docs image.');
expect(out.tools[0].function.parameters.description).toBeUndefined();
expect(out.tools[0].function.parameters.properties.path.description).toBeUndefined();
});
});