` +
`
${label}${q}
` +
`
${value}
` +
`
${sub}
` +
@@ -830,7 +831,18 @@ const CSS = `
.tile-sub { font-size: 11.5px; color: var(--muted); margin-top: 6px; }
.q { display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 14px;
border-radius: 50%; background: var(--surface-2); border: 1px solid var(--border-strong);
- color: var(--muted); font-size: 9px; font-weight: 700; cursor: help; }
+ color: var(--muted); font-size: 9px; font-weight: 700; cursor: help; position: relative; outline: none; }
+ .q:hover, .q:focus-visible { color: var(--flame-ink); border-color: var(--flame); }
+ .q::after { content: attr(data-tip); position: absolute; z-index: 50; left: 50%; bottom: calc(100% + 8px);
+ width: min(280px, 75vw); transform: translate(-50%, 4px); padding: 8px 10px; border-radius: 7px;
+ background: var(--ink); color: var(--surface); box-shadow: var(--shadow); font-size: 11px; font-weight: 500;
+ line-height: 1.4; text-align: left; pointer-events: none; opacity: 0; visibility: hidden;
+ transition: opacity .12s, transform .12s, visibility .12s; }
+ .q::before { content: ''; position: absolute; z-index: 51; left: 50%; bottom: calc(100% + 3px);
+ transform: translateX(-50%); border: 5px solid transparent; border-top-color: var(--ink);
+ pointer-events: none; opacity: 0; visibility: hidden; transition: opacity .12s, visibility .12s; }
+ .q:hover::after, .q:focus-visible::after { opacity: 1; visibility: visible; transform: translate(-50%, 0); }
+ .q:hover::before, .q:focus-visible::before { opacity: 1; visibility: visible; }
/* drawer */
.drawer { margin: 0 0 14px; background: var(--surface); border: 1px solid var(--border);
diff --git a/src/node.ts b/src/node.ts
index 7f6a963..5659dbf 100644
--- a/src/node.ts
+++ b/src/node.ts
@@ -161,7 +161,7 @@ Environment:
PXPIPE_GATEWAY_BASE_URL gateway base URL (required with PXPIPE_PROVIDER)
PXPIPE_GATEWAY_HEADERS extra upstream headers: JSON object or k=v;k2=v2
PXPIPE_MODELS comma-separated model bases to image (Claude/GPT/Grok);
- default claude-fable-5,gpt-5.6 (Opus/GPT-5.5/Grok opt-in);
+ default claude-fable-5 (Sol/Opus/GPT-5.5/Grok opt-in);
off disables
PXPIPE_CONFIG JSON config path (default ~/.config/pxpipe/config.json)
supports {"models": [...]} or {"models": "off"}
diff --git a/src/worker.ts b/src/worker.ts
index 5a8c8b7..5280394 100644
--- a/src/worker.ts
+++ b/src/worker.ts
@@ -108,7 +108,9 @@ export default {
// (e.g. MIN_TOOL_RESULT_CHARS=200 to skip absurdly small dumps).
minReminderChars: env.MIN_REMINDER_CHARS ? Number(env.MIN_REMINDER_CHARS) : 0,
minToolResultChars: env.MIN_TOOL_RESULT_CHARS ? Number(env.MIN_TOOL_RESULT_CHARS) : 0,
- cols: env.COLS ? Number(env.COLS) : 100,
+ // Omit by default so OpenAI-shaped requests use their exact model profile;
+ // COLS remains an explicit operator override for every family.
+ ...(env.COLS ? { cols: Number(env.COLS) } : {}),
// R2 multi-column ON (2 cols) — single-col drops below break-even on
// real tool-doc slabs. Override via MULTI_COL=1 if OCR misreads layout.
multiCol: env.MULTI_COL ? Math.max(1, Number(env.MULTI_COL) | 0) : 2,
diff --git a/tests/cache-stability-e2e.test.ts b/tests/cache-stability-e2e.test.ts
index 44ed21e..1c58060 100644
--- a/tests/cache-stability-e2e.test.ts
+++ b/tests/cache-stability-e2e.test.ts
@@ -17,11 +17,24 @@
*
* Run just this file: pnpm vitest run tests/cache-stability-e2e.test.ts
*/
-import { describe, expect, it } from 'vitest';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createProxy } from '../src/core/proxy.js';
import { countCacheControlMarkers } from '../src/core/measurement.js';
import { HISTORY_SYNTHETIC_INTRO } from '../src/core/history.js';
+// These proxy-contract tests deliberately exercise the opt-in Sol transform.
+// Snapshot the developer shell so the suite is deterministic now that Sol is
+// intentionally absent from the built-in default scope.
+let ambientPxpipeModels: string | undefined;
+beforeAll(() => {
+ ambientPxpipeModels = process.env.PXPIPE_MODELS;
+ process.env.PXPIPE_MODELS = 'claude-fable-5,gpt-5.6-sol';
+});
+afterAll(() => {
+ if (ambientPxpipeModels === undefined) delete process.env.PXPIPE_MODELS;
+ else process.env.PXPIPE_MODELS = ambientPxpipeModels;
+});
+
// ---------------------------------------------------------------------------
// Fake upstream — records every outbound MAIN request body and answers with a
// canned, well-formed response so the proxy completes. The /count_tokens probe
@@ -487,7 +500,7 @@ describe('e2e cache alignment — GPT (OpenAI) through the real proxy', () => {
turns: { role: 'user' | 'assistant'; text: string }[];
}): string {
return JSON.stringify({
- model: opts.model ?? 'gpt-5.6',
+ model: opts.model ?? 'gpt-5.6-sol',
messages: [
{ role: 'system', content: slab(opts.systemChars) },
...opts.turns.map((t) => ({ role: t.role, content: t.text })),
@@ -500,7 +513,7 @@ describe('e2e cache alignment — GPT (OpenAI) through the real proxy', () => {
turns: { role: 'user' | 'assistant'; text: string }[];
}): string {
return JSON.stringify({
- model: 'gpt-5.6',
+ model: 'gpt-5.6-sol',
instructions: slab(opts.systemChars),
input: opts.turns.map((t) => ({ role: t.role, content: t.text })),
});
diff --git a/tests/dashboard-api.test.ts b/tests/dashboard-api.test.ts
index 04b08ac..67b48e0 100644
--- a/tests/dashboard-api.test.ts
+++ b/tests/dashboard-api.test.ts
@@ -15,6 +15,7 @@ import { getAllowedModelBases, setAllowedModelBases } from '../src/core/applicab
import type { SessionsPaths } from '../src/sessions.js';
import type { TrackEvent } from '../src/core/tracker.js';
import type { StatsPayload, RecentPayload } from '../src/dashboard/types.js';
+import { renderPage } from '../src/dashboard/fragments.js';
function makeTmp(): SessionsPaths {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pxpipe-dashapi-'));
@@ -176,27 +177,28 @@ describe('serveFragment', () => {
dash.handleCompressionToggle({ enabled: true });
});
- it('renders and mutates GPT 5.5/5.6 chips via the single model scope', async () => {
+ it('renders opt-in GPT 5.5/5.6 chips and mutates the single model scope', async () => {
const prev = process.env.PXPIPE_MODELS;
try {
delete process.env.PXPIPE_MODELS;
- setAllowedModelBases(null); // reset to built-in default (Fable 5 + GPT 5.6)
- const on = await (await dash.serveFragment('models', url, 1234)).text();
- expect(on).toContain('Image GPT models');
- // GPT 5.6 is on by default; GPT 5.5 is opt-in (off until toggled).
- expect(on).toContain('GPT 5.6 ✓');
- expect(on).toContain('GPT 5.5');
- // GPT 5.6 renders to the left of GPT 5.5.
- expect(on.indexOf('GPT 5.6')).toBeLessThan(on.indexOf('GPT 5.5'));
- expect(getAllowedModelBases()).toContain('gpt-5.6');
+ setAllowedModelBases(null); // reset to built-in Fable-only default
+ const off = await (await dash.serveFragment('models', url, 1234)).text();
+ expect(off).toContain('Image GPT models');
+ expect(off).not.toContain('
');
+ expect(off).toContain('GPT 5.6 Sol');
+ expect(off).toContain('GPT 5.5');
+ // Sol remains available and ordered first, but neither GPT is silently on.
+ expect(off.indexOf('GPT 5.6 Sol')).toBeLessThan(off.indexOf('GPT 5.5'));
+ expect(getAllowedModelBases()).not.toContain('gpt-5.6-sol');
expect(getAllowedModelBases()).not.toContain('gpt-5.5');
+ dash.handleModelsToggle('gpt-5.6-sol', true);
dash.handleModelsToggle('gpt-5.5', true);
const onBoth = await (await dash.serveFragment('models', url, 1234)).text();
expect(onBoth).toContain('GPT 5.5 ✓');
- expect(onBoth).toContain('GPT 5.6 ✓');
+ expect(onBoth).toContain('GPT 5.6 Sol ✓');
expect(getAllowedModelBases()).toContain('gpt-5.5');
- expect(getAllowedModelBases()).toContain('gpt-5.6');
+ expect(getAllowedModelBases()).toContain('gpt-5.6-sol');
} finally {
setAllowedModelBases(null);
if (prev === undefined) delete process.env.PXPIPE_MODELS;
@@ -218,6 +220,13 @@ describe('serveFragment', () => {
expect(stats).toContain('requests');
});
+ it('renders keyboard-accessible hover help for stat question marks', async () => {
+ const header = await (await dash.serveFragment('header', url, 4711)).text();
+ expect(header).toContain('class="q" tabindex="0"');
+ expect(header).toContain('data-tip=');
+ expect(header).toContain('aria-label=');
+ });
+
it('escapes HTML in latest source text', async () => {
dash.captureImage({
imagePngs: [new Uint8Array([137, 80, 78, 71])],
@@ -236,6 +245,14 @@ describe('serveFragment', () => {
});
});
+describe('dashboard page help UI', () => {
+ it('ships visible hover/focus tooltip CSS for question-mark controls', () => {
+ const html = renderPage(47821);
+ expect(html).toContain('.q:hover::after, .q:focus-visible::after');
+ expect(html).toContain('content: attr(data-tip)');
+ });
+});
+
// ---- GPT (OpenAI) savings split ------------------------------------------
// The dashboard math was built entirely around the Anthropic cache-aware
// baseline, so GPT rows used to surface all-zero columns. These lock the
diff --git a/tests/design-behavior-e2e.test.ts b/tests/design-behavior-e2e.test.ts
index e3d157f..4d9ed6f 100644
--- a/tests/design-behavior-e2e.test.ts
+++ b/tests/design-behavior-e2e.test.ts
@@ -18,9 +18,22 @@
*
* Run just this file: pnpm vitest run tests/design-behavior-e2e.test.ts
*/
-import { describe, expect, it } from 'vitest';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createProxy } from '../src/core/proxy.js';
+// These proxy-contract tests deliberately exercise the opt-in Sol transform.
+// Snapshot the developer shell so the suite is deterministic now that Sol is
+// intentionally absent from the built-in default scope.
+let ambientPxpipeModels: string | undefined;
+beforeAll(() => {
+ ambientPxpipeModels = process.env.PXPIPE_MODELS;
+ process.env.PXPIPE_MODELS = 'claude-fable-5,gpt-5.6-sol';
+});
+afterAll(() => {
+ if (ambientPxpipeModels === undefined) delete process.env.PXPIPE_MODELS;
+ else process.env.PXPIPE_MODELS = ambientPxpipeModels;
+});
+
function fakeUpstream() {
const main: string[] = [];
const real = globalThis.fetch;
@@ -190,7 +203,7 @@ describe('design: RECENT REQUEST stays legible (GPT)', () => {
const out = await drive(
'/v1/chat/completions',
JSON.stringify({
- model: 'gpt-5.6',
+ model: 'gpt-5.6-sol',
messages: [{ role: 'system', content: 'SYS ' + big(60_000) }, ...turns],
}),
);
diff --git a/tests/export.test.ts b/tests/export.test.ts
index 893d205..153001d 100644
--- a/tests/export.test.ts
+++ b/tests/export.test.ts
@@ -531,6 +531,13 @@ describe('exportImageTokens model routing', () => {
expect(gpTokens).toBeGreaterThan(0);
});
+ it('uses measured Grok pixel pricing instead of the GPT fallback', () => {
+ expect(exportImageTokens('grok-4.5', 768, 360)).toBe(Math.ceil((768 * 360) / 1000));
+ expect(exportImageTokens('grok-4.5', 768, 360)).not.toBe(
+ exportImageTokens('gpt-4o', 768, 360),
+ );
+ });
+
it('Claude image tokens are substantially higher than GPT for the same full-page image', () => {
// The issue was a ~7x underestimate when using GPT formula for Claude.
// Verify the ratio is at least 5x so the fix is clearly meaningful.
diff --git a/tests/openai-gpt5.test.ts b/tests/openai-gpt5.test.ts
index 8d012cd..39c7bf6 100644
--- a/tests/openai-gpt5.test.ts
+++ b/tests/openai-gpt5.test.ts
@@ -2,7 +2,7 @@
* Tests for GPT-5 applicability gate, OpenAI vision-token cost model,
* Chat Completions transformer, and Responses API transformer.
*/
-import { describe, expect, it } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { isPxpipeSupportedGptModel } from '../src/core/applicability.js';
import { openAIVisionTokens, visionTokensForModel, isClaudeModel, resolveVisionCost, transformOpenAIChatCompletions, transformOpenAIResponses } from '../src/core/openai.js';
import { resolveGptProfile } from '../src/core/gpt-model-profiles.js';
@@ -10,16 +10,39 @@ import { resolveGptProfile } from '../src/core/gpt-model-profiles.js';
const enc = new TextEncoder();
const dec = new TextDecoder();
+let ambientPxpipeModels: string | undefined;
+beforeEach(() => {
+ ambientPxpipeModels = process.env.PXPIPE_MODELS;
+ delete process.env.PXPIPE_MODELS;
+});
+afterEach(() => {
+ if (ambientPxpipeModels === undefined) delete process.env.PXPIPE_MODELS;
+ else process.env.PXPIPE_MODELS = ambientPxpipeModels;
+});
+
// ── Task 1: applicability gate ──────────────────────────────────────────────
describe('isPxpipeSupportedGptModel', () => {
- it('matches GPT 5.6 by default; GPT 5.5 is opt-in only', () => {
+ it('keeps GPT 5.6 Sol, sibling variants, and GPT 5.5 off by default', () => {
expect(isPxpipeSupportedGptModel('gpt-5')).toBe(false);
- expect(isPxpipeSupportedGptModel('gpt-5.5')).toBe(false); // off by default — degrades on imaged context
- expect(isPxpipeSupportedGptModel('gpt-5.6')).toBe(true);
+ expect(isPxpipeSupportedGptModel('gpt-5.5')).toBe(false);
+ expect(isPxpipeSupportedGptModel('gpt-5.6')).toBe(false);
+ expect(isPxpipeSupportedGptModel('gpt-5.6-sol')).toBe(false);
+ expect(isPxpipeSupportedGptModel('gpt-5.6-terra')).toBe(false);
expect(isPxpipeSupportedGptModel('gpt-5-mini')).toBe(false);
- expect(isPxpipeSupportedGptModel('gpt-5.6-nano')).toBe(true);
- expect(isPxpipeSupportedGptModel('gpt-5.6[1m]')).toBe(true); // variant tag stripped
+ expect(isPxpipeSupportedGptModel('gpt-5.6-nano')).toBe(false);
+ expect(isPxpipeSupportedGptModel('gpt-5.6-sol[1m]')).toBe(false);
+ expect(isPxpipeSupportedGptModel('gpt-5.6-sol-codex[1m]')).toBe(false);
+ });
+
+ it('enables only exact Sol ids and suffix aliases when explicitly opted in', () => {
+ process.env.PXPIPE_MODELS = 'gpt-5.6-sol';
+ expect(isPxpipeSupportedGptModel('gpt-5.6-sol')).toBe(true);
+ expect(isPxpipeSupportedGptModel('gpt-5.6-sol[1m]')).toBe(true);
+ expect(isPxpipeSupportedGptModel('gpt-5.6-sol-codex')).toBe(true);
+ expect(isPxpipeSupportedGptModel('gpt-5.6-sol-codex[1m]')).toBe(true);
+ expect(isPxpipeSupportedGptModel('gpt-5.6')).toBe(false);
+ expect(isPxpipeSupportedGptModel('gpt-5.6-terra')).toBe(false);
});
it('rejects non-GPT-5 models', () => {
@@ -59,7 +82,7 @@ describe('openAIVisionTokens', () => {
it('resolveVisionCost returns correct regimes', () => {
expect(resolveVisionCost('gpt-5').regime).toBe('tile');
- expect(resolveVisionCost('gpt-5.6').regime).toBe('patch');
+ expect(resolveVisionCost('gpt-5.6-sol').regime).toBe('patch');
expect(resolveVisionCost('gpt-5-mini').regime).toBe('patch');
expect(resolveVisionCost('gpt-5.6-nano').regime).toBe('patch');
expect(resolveVisionCost('gpt-4o').regime).toBe('tile');
@@ -72,7 +95,7 @@ describe('visionTokensForModel (Claude on the Responses path)', () => {
expect(isClaudeModel('claude-opus-4-8')).toBe(true);
expect(isClaudeModel('claude-sonnet-5')).toBe(true);
expect(isClaudeModel('anthropic/claude-3-5')).toBe(true);
- expect(isClaudeModel('gpt-5.6')).toBe(false);
+ expect(isClaudeModel('gpt-5.6-sol')).toBe(false);
expect(isClaudeModel(undefined)).toBe(false);
});
@@ -110,10 +133,10 @@ const TASK_LIKE_PARAMS = {
additionalProperties: false,
};
-describe('transformOpenAIChatCompletions (gpt-5.6)', () => {
+describe('transformOpenAIChatCompletions (gpt-5.6-sol)', () => {
it('compresses GPT system + tool docs while preserving native tool selection metadata', async () => {
const body = enc.encode(JSON.stringify({
- model: 'gpt-5.6',
+ model: 'gpt-5.6-sol',
messages: [
{ role: 'system', content: BIG_SYSTEM },
{ role: 'user', content: 'hello' },
@@ -145,8 +168,9 @@ describe('transformOpenAIChatCompletions (gpt-5.6)', () => {
expect(parts[0]!.type).toBe('image_url');
expect(parts[0]!.image_url!.url).toMatch(/^data:image\/png;base64,/);
- // Image width should be 768px (152 cols * 5px + 8px pad).
- expect(result.info.firstImageWidth).toBe(768);
+ // GPT 5.6 Sol has its own 6x11 JetBrains Mono profile. 126 cols keeps the
+ // physical strip below OpenAI's 768px short-side resize floor.
+ expect(result.info.firstImageWidth).toBe(764);
// System message replaced with pointer.
const sysMsg = messages.find((m) => m.role === 'system')!;
@@ -163,7 +187,7 @@ describe('transformOpenAIChatCompletions (gpt-5.6)', () => {
it('images GPT tool definitions even when there is no instruction context', async () => {
const body = enc.encode(JSON.stringify({
- model: 'gpt-5.6',
+ model: 'gpt-5.6-sol',
messages: [{ role: 'user', content: 'hello' }],
tools: [{
type: 'function',
@@ -186,7 +210,7 @@ describe('transformOpenAIChatCompletions (gpt-5.6)', () => {
it('keeps a parameter literally named "description" (task-tool regression)', async () => {
const body = enc.encode(JSON.stringify({
- model: 'gpt-5.6',
+ model: 'gpt-5.6-sol',
messages: [{ role: 'user', content: 'hello' }],
tools: [{
type: 'function',
@@ -211,7 +235,7 @@ describe('transformOpenAIChatCompletions (gpt-5.6)', () => {
it('returns compressed=false with not_profitable reason for small input', async () => {
const body = enc.encode(JSON.stringify({
- model: 'gpt-5.6',
+ model: 'gpt-5.6-sol',
messages: [
{ role: 'system', content: 'short' },
{ role: 'user', content: 'hi' },
@@ -231,10 +255,10 @@ const BIG_FLAT_TOOL_DESC = 'Flat tool description with lots of context. '.repeat
const RESPONSES_TOOL_PARAMS = { type: 'object', description: 'Param root.', properties: { x: { type: 'string', description: 'x param' } } };
const RESPONSES_TOOL_DOC = `## Tool: do_thing\n${BIG_FLAT_TOOL_DESC}\n\`\`\`json\n${JSON.stringify(RESPONSES_TOOL_PARAMS)}\n\`\`\``;
-describe('transformOpenAIResponses (gpt-5.6)', () => {
+describe('transformOpenAIResponses (gpt-5.6-sol)', () => {
it('compresses GPT Responses instructions + tool docs while preserving native tool selection metadata', async () => {
const body = enc.encode(JSON.stringify({
- model: 'gpt-5.6',
+ model: 'gpt-5.6-sol',
instructions: BIG_INSTRUCTIONS,
input: [
{ role: 'user', content: 'Please do the thing.' },
@@ -281,7 +305,7 @@ describe('transformOpenAIResponses (gpt-5.6)', () => {
// array form for a developer/system item used to be dropped: not imaged and
// not stubbed, so the verbose text rode uncompressed as native input.
const body = enc.encode(JSON.stringify({
- model: 'gpt-5.6',
+ model: 'gpt-5.6-sol',
input: [
{ role: 'developer', content: [{ type: 'input_text', text: BIG_INSTRUCTIONS }] },
{ role: 'user', content: 'Please do the thing.' },
@@ -307,7 +331,7 @@ describe('transformOpenAIResponses (gpt-5.6)', () => {
it('images GPT Responses tool definitions even when there is no instruction context', async () => {
const body = enc.encode(JSON.stringify({
- model: 'gpt-5.6',
+ model: 'gpt-5.6-sol',
input: [{ role: 'user', content: 'Please do the thing.' }],
tools: [{
type: 'function',
@@ -328,7 +352,7 @@ describe('transformOpenAIResponses (gpt-5.6)', () => {
it('keeps a parameter literally named "description" (task-tool regression)', async () => {
const body = enc.encode(JSON.stringify({
- model: 'gpt-5.6',
+ model: 'gpt-5.6-sol',
input: [{ role: 'user', content: 'Please do the thing.' }],
tools: [{ type: 'function', name: 'task', description: BIG_FLAT_TOOL_DESC, parameters: TASK_LIKE_PARAMS }],
}));
@@ -346,7 +370,7 @@ describe('transformOpenAIResponses (gpt-5.6)', () => {
it('handles bare string input (wraps into user item with images)', async () => {
const body = enc.encode(JSON.stringify({
- model: 'gpt-5.6',
+ model: 'gpt-5.6-sol',
instructions: BIG_INSTRUCTIONS,
input: 'Do the thing please.',
}));
@@ -369,7 +393,7 @@ describe('transformOpenAIResponses (gpt-5.6)', () => {
it('records outgoingTextChars for compressed Responses requests, counting text but not image base64', async () => {
const body = enc.encode(JSON.stringify({
- model: 'gpt-5.6',
+ model: 'gpt-5.6-sol',
instructions: BIG_INSTRUCTIONS,
input: [{ role: 'user', content: 'Please do the thing.' }],
tools: [{ type: 'function', name: 'do_thing', description: 'pick a thing', parameters: { type: 'object', properties: {} } }],
@@ -424,7 +448,7 @@ describe('transformOpenAIResponses (gpt-5.6)', () => {
it('returns compressed=false with not_profitable/below_min reason for small input', async () => {
const body = enc.encode(JSON.stringify({
- model: 'gpt-5.6',
+ model: 'gpt-5.6-sol',
instructions: 'Short.',
input: [{ role: 'user', content: 'hi' }],
}));
@@ -488,7 +512,7 @@ function buildChatMessages(turns: number): Array> {
describe('transformOpenAIResponses — history collapse', () => {
it('collapses the OLD transcript prefix into history images, keeps the tail as text', async () => {
const body = enc.encode(JSON.stringify({
- model: 'gpt-5.6',
+ model: 'gpt-5.6-sol',
instructions: BIG_SLAB,
input: buildResponsesInput(20),
}));
@@ -531,7 +555,7 @@ describe('transformOpenAIResponses — history collapse', () => {
it('produces a byte-stable history image sha across identical requests', async () => {
const make = () => enc.encode(JSON.stringify({
- model: 'gpt-5.6',
+ model: 'gpt-5.6-sol',
instructions: BIG_SLAB,
input: buildResponsesInput(20),
}));
@@ -543,7 +567,7 @@ describe('transformOpenAIResponses — history collapse', () => {
it('does not collapse when collapseHistory is off', async () => {
const body = enc.encode(JSON.stringify({
- model: 'gpt-5.6',
+ model: 'gpt-5.6-sol',
instructions: BIG_SLAB,
input: buildResponsesInput(20),
}));
@@ -559,7 +583,7 @@ describe('transformOpenAIResponses — history collapse', () => {
it('partially collapses GPT history up to the image cap and leaves the rest as text', async () => {
const body = enc.encode(JSON.stringify({
- model: 'gpt-5.6',
+ model: 'gpt-5.6-sol',
instructions: BIG_SLAB,
input: buildResponsesInput(30),
}));
@@ -586,7 +610,7 @@ describe('transformOpenAIResponses — history collapse', () => {
describe('transformOpenAIChatCompletions — history collapse', () => {
it('collapses the OLD transcript into a synthetic user message with image_url parts', async () => {
const body = enc.encode(JSON.stringify({
- model: 'gpt-5.6',
+ model: 'gpt-5.6-sol',
messages: buildChatMessages(20),
}));
const result = await transformOpenAIChatCompletions(body, { charsPerToken: 1, minCompressChars: 1 });
@@ -654,7 +678,7 @@ function buildAutonomousChat(turns: number): Array> {
describe('GPT history collapse — pins the live request as text (autonomous shape)', () => {
it('Responses: lone request kept as legible text + echoed in the guard, work imaged', async () => {
const body = enc.encode(JSON.stringify({
- model: 'gpt-5.6',
+ model: 'gpt-5.6-sol',
instructions: BIG_SLAB,
input: buildAutonomousResponses(24),
}));
@@ -685,7 +709,7 @@ describe('GPT history collapse — pins the live request as text (autonomous sha
it('Chat: lone request kept as legible text + echoed in the guard, work imaged', async () => {
const body = enc.encode(JSON.stringify({
- model: 'gpt-5.6',
+ model: 'gpt-5.6-sol',
messages: buildAutonomousChat(24),
}));
const result = await transformOpenAIChatCompletions(body, { charsPerToken: 1, minCompressChars: 1 });
@@ -712,7 +736,7 @@ describe('GPT history collapse — pins the live request as text (autonomous sha
it('Responses: byte-stable history image sha across identical autonomous requests', async () => {
const make = () => enc.encode(JSON.stringify({
- model: 'gpt-5.6',
+ model: 'gpt-5.6-sol',
instructions: BIG_SLAB,
input: buildAutonomousResponses(24),
}));
@@ -732,18 +756,18 @@ describe('GPT history collapse — pins the live request as text (autonomous sha
describe('openAIVisionTokens — gpt-5.x flagship patch model', () => {
it('flagship multiplier is 1.0, not the mini 1.62', () => {
// 768x1932 → patches = ceil(768/32)*ceil(1932/32) = 24*61 = 1464; ×1.0 = 1464.
- expect(openAIVisionTokens('gpt-5.6', 768, 1932)).toBe(1464);
+ expect(openAIVisionTokens('gpt-5.6-sol', 768, 1932)).toBe(1464);
expect(openAIVisionTokens('gpt-5.5', 768, 1932)).toBe(1464);
});
it('flagship patch budget is 10,000 (original detail), not 2,500', () => {
// 4000x4000 → patches = 125*125 = 15625, capped at the budget.
// Pre-fix (cap 2500, ×1.62) this returned 4050; correct is min(15625,10000)=10000.
- expect(openAIVisionTokens('gpt-5.6', 4000, 4000)).toBe(10000);
+ expect(openAIVisionTokens('gpt-5.6-sol', 4000, 4000)).toBe(10000);
});
it('resolveVisionCost flagship = patch, multiplier 1, cap 10000; mini stays 1.62/1536', () => {
- expect(resolveVisionCost('gpt-5.6')).toMatchObject({ regime: 'patch', multiplier: 1, patchCap: 10000 });
+ expect(resolveVisionCost('gpt-5.6-sol')).toMatchObject({ regime: 'patch', multiplier: 1, patchCap: 10000 });
expect(resolveVisionCost('gpt-5.5')).toMatchObject({ regime: 'patch', multiplier: 1, patchCap: 10000 });
expect(resolveVisionCost('gpt-5.6-mini')).toMatchObject({ regime: 'patch', multiplier: 1.62, patchCap: 1536 });
expect(resolveVisionCost('gpt-5.6-nano')).toMatchObject({ regime: 'patch', multiplier: 2.46, patchCap: 1536 });
@@ -755,7 +779,7 @@ describe('openAIVisionTokens — gpt-5.x flagship patch model', () => {
describe('image parts request detail = "original" (avoid downscale of dense text)', () => {
it('Chat Completions image_url parts use detail:"original"', async () => {
const body = enc.encode(JSON.stringify({
- model: 'gpt-5.6',
+ model: 'gpt-5.6-sol',
messages: [
{ role: 'system', content: BIG_SYSTEM },
{ role: 'user', content: 'hello' },
@@ -773,7 +797,7 @@ describe('image parts request detail = "original" (avoid downscale of dense text
it('Responses input_image parts use detail:"original"', async () => {
const body = enc.encode(JSON.stringify({
- model: 'gpt-5.6',
+ model: 'gpt-5.6-sol',
instructions: BIG_INSTRUCTIONS,
input: [{ role: 'user', content: [{ type: 'input_text', text: 'hello' }] }],
}));
@@ -799,21 +823,101 @@ describe('resolveGptProfile (Claude on Responses)', () => {
expect(p.stripCols).toBe(312);
expect(resolveGptProfile('claude-fable-5').maxHeightPx).toBe(728);
expect(resolveGptProfile('claude-fable-5').stripCols).toBe(312);
- expect(resolveGptProfile('gpt-5.6').maxHeightPx).toBe(1932);
- expect(resolveGptProfile('gpt-5.6').stripCols).toBe(152);
+ for (const model of [
+ 'gpt-5.6-sol',
+ 'gpt-5.6-sol[1m]',
+ 'gpt-5.6-sol-codex',
+ 'gpt-5.6-sol-codex[1m]',
+ 'gpt-5.6-sol-2026-07-09',
+ ]) {
+ const sol = resolveGptProfile(model);
+ expect(sol.maxHeightPx, model).toBe(1932);
+ expect(sol.stripCols, model).toBe(126);
+ expect(sol.style.font, model).toBe('jetbrains-mono-10');
+ }
+ for (const model of ['gpt-5.6', 'gpt-5.6-terra', 'gpt-5.6-terra[1m]']) {
+ const notSol = resolveGptProfile(model);
+ expect(notSol.stripCols, model).toBe(152);
+ expect(notSol.style.font, model).toBe('spleen-5x8');
+ }
+ expect(resolveGptProfile('claude-fable-5').style.font).toBe('spleen-5x8');
+ });
+
+ it('renders Claude Responses at Claude width instead of the GPT default cap', async () => {
+ const body = enc.encode(JSON.stringify({
+ model: 'claude-fable-5',
+ instructions: BIG_INSTRUCTIONS,
+ input: [{ role: 'user', content: 'hello' }],
+ }));
+ const result = await transformOpenAIResponses(body, { charsPerToken: 1, minCompressChars: 1 });
+ expect(result.info.compressed).toBe(true);
+ expect(result.info.firstImageWidth).toBe(1568);
});
});
describe('resolveGptProfile (Grok)', () => {
- it('uses production 5x8 packing; exact IDs ride the fact-sheet, not cell padding', () => {
- // Dense packing maximizes measured Grok savings (~1000 tok/MPix). Exact
- // tokens (paths/hex/ports/camelCase) are kept as text beside the image.
+ it('uses the measured opt-in 9x12 profile plus the fact-sheet', () => {
+ // Live climb 2026-07-09: 5x8 was 0/4 exact with four confabulations;
+ // effective 9x12 was the densest arm at 4/4 exact and zero confabulation.
const p = resolveGptProfile('grok-4.5');
- expect(p.stripCols).toBe(152);
- expect(p.style?.cellWBonus).toBe(0);
- expect(p.style?.cellHBonus).toBe(0);
- expect(p.style?.aa).toBe(true);
- expect(resolveGptProfile('grok-4').stripCols).toBe(152);
+ expect(p.stripCols).toBe(84);
+ expect(p.style.font).toBe('spleen-5x8');
+ expect(p.style.cellWBonus).toBe(4);
+ expect(p.style.cellHBonus).toBe(4);
+ expect(p.style.aa).toBe(true);
+ expect(resolveGptProfile('grok-4').stripCols).toBe(84);
+ });
+
+ it('renders the opt-in profile at 764px wide', async () => {
+ const body = enc.encode(JSON.stringify({
+ model: 'grok-4.5',
+ instructions: BIG_INSTRUCTIONS,
+ input: [{ role: 'user', content: 'hello' }],
+ }));
+ const result = await transformOpenAIResponses(body, { charsPerToken: 1, minCompressChars: 1 });
+ expect(result.info.compressed).toBe(true);
+ expect(result.info.firstImageWidth).toBe(764);
+ });
+});
+
+describe('resolveGptProfile style overrides', () => {
+ it('merges every render knob into the selected model profile', () => {
+ const prev = process.env.PXPIPE_GPT_PROFILES;
+ try {
+ process.env.PXPIPE_GPT_PROFILES = JSON.stringify({
+ 'gpt-5.6-sol': {
+ stripCols: 100,
+ style: {
+ font: 'spleen-5x8',
+ cellWBonus: 2,
+ cellHBonus: 3,
+ aa: false,
+ grid: true,
+ gridCols: 4,
+ colorCycle: true,
+ markerScale: 2,
+ markerRed: true,
+ },
+ },
+ });
+ expect(resolveGptProfile('gpt-5.6-sol-codex')).toMatchObject({
+ stripCols: 100,
+ style: {
+ font: 'spleen-5x8',
+ cellWBonus: 2,
+ cellHBonus: 3,
+ aa: false,
+ grid: true,
+ gridCols: 4,
+ colorCycle: true,
+ markerScale: 2,
+ markerRed: true,
+ },
+ });
+ } finally {
+ if (prev === undefined) delete process.env.PXPIPE_GPT_PROFILES;
+ else process.env.PXPIPE_GPT_PROFILES = prev;
+ }
});
});
@@ -828,4 +932,3 @@ describe('visionTokensForModel (Grok)', () => {
);
});
});
-
diff --git a/tests/proxy-usage.test.ts b/tests/proxy-usage.test.ts
index ad29a8d..fff7adb 100644
--- a/tests/proxy-usage.test.ts
+++ b/tests/proxy-usage.test.ts
@@ -1,6 +1,19 @@
-import { describe, it, expect } from 'vitest';
+import { afterAll, beforeAll, describe, it, expect } from 'vitest';
import { createProxy, type ProxyEvent } from '../src/core/proxy.js';
+// These proxy-contract tests deliberately exercise the opt-in Sol transform.
+// Snapshot the developer shell so the suite is deterministic now that Sol is
+// intentionally absent from the built-in default scope.
+let ambientPxpipeModels: string | undefined;
+beforeAll(() => {
+ ambientPxpipeModels = process.env.PXPIPE_MODELS;
+ process.env.PXPIPE_MODELS = 'claude-fable-5,gpt-5.6-sol';
+});
+afterAll(() => {
+ if (ambientPxpipeModels === undefined) delete process.env.PXPIPE_MODELS;
+ else process.env.PXPIPE_MODELS = ambientPxpipeModels;
+});
+
/** Tiny in-process mock upstream — accepts any request and returns whatever
* the test fixture configured. Lets us assert that the proxy correctly
* extracts Anthropic's usage block from both SSE and JSON responses without
@@ -133,7 +146,7 @@ describe('proxy usage extraction', () => {
).toBe(true);
});
- it('routes GPT 5.6 chat completions to OpenAI, transforms once, and normalizes usage', async () => {
+ it('routes GPT 5.6 Sol chat completions to OpenAI, transforms once, and normalizes usage', async () => {
const upstreamRequests: Request[] = [];
const restore = mockUpstream(async (req) => {
upstreamRequests.push(req.clone());
@@ -159,7 +172,7 @@ describe('proxy usage extraction', () => {
});
const reqBody = JSON.stringify({
- model: 'gpt-5.6',
+ model: 'gpt-5.6-sol',
messages: [
{ role: 'system', content: 'System instruction. '.repeat(900) },
{ role: 'user', content: 'hi' },
@@ -220,7 +233,7 @@ describe('proxy usage extraction', () => {
});
const reqBody = JSON.stringify({
- model: 'gpt-5.6',
+ model: 'gpt-5.6-sol',
messages: [
{ role: 'system', content: 'System instruction. '.repeat(900) },
{ role: 'user', content: 'hi' },
@@ -271,7 +284,7 @@ describe('proxy usage extraction', () => {
});
const reqBody = JSON.stringify({
- model: 'gpt-5.6',
+ model: 'gpt-5.6-sol',
instructions: 'System instruction. '.repeat(900),
input: [{ role: 'user', content: 'hi' }],
});
@@ -293,7 +306,7 @@ describe('proxy usage extraction', () => {
const sent = JSON.parse(await upstreamRequests[0]!.text()) as any;
const firstUser = sent.input.find((m: any) => m.role === 'user');
expect(firstUser.content[0].type).toBe('input_image');
- expect(captured?.model).toBe('gpt-5.6');
+ expect(captured?.model).toBe('gpt-5.6-sol');
expect(captured?.info?.compressed).toBe(true);
expect(captured?.info?.firstUserSha8).toMatch(/^[0-9a-f]{8}$/);
});
diff --git a/tests/public-api.test.ts b/tests/public-api.test.ts
index 13ff02c..c808259 100644
--- a/tests/public-api.test.ts
+++ b/tests/public-api.test.ts
@@ -89,24 +89,27 @@ describe('public library API', () => {
}
});
- it('recognizes GPT 5.6 as the default OpenAI imaging scope (5.5 opt-in)', () => {
+ it('keeps GPT 5.6 Sol off by default but preserves exact opt-in aliases', () => {
expect(isPxpipeSupportedGptModel('gpt-5')).toBe(false);
- // gpt-5.5 degrades on imaged context, so it is off by default now.
expect(isPxpipeSupportedGptModel('gpt-5.5')).toBe(false);
expect(isPxpipeSupportedGptModel('gpt-5.5-codex')).toBe(false);
- expect(isPxpipeSupportedGptModel('gpt-5.5-2026-06-01')).toBe(false);
- expect(isPxpipeSupportedGptModel('gpt-5.6')).toBe(true);
+ expect(isPxpipeSupportedGptModel('gpt-5.6')).toBe(false);
+ expect(isPxpipeSupportedGptModel('gpt-5.6-sol')).toBe(false);
+ expect(isPxpipeSupportedGptModel('gpt-5.6-sol-codex')).toBe(false);
+ expect(isPxpipeSupportedGptModel('gpt-5.6-terra')).toBe(false);
expect(isPxpipeSupportedGptModel('gpt-5-mini')).toBe(false);
- expect(isPxpipeSupportedGptModel('gpt-5.6-nano')).toBe(true);
- expect(isPxpipeSupportedGptModel('gpt-5.6[1m]')).toBe(true);
expect(isPxpipeSupportedGptModel('gpt-4o')).toBe(false);
- expect(isPxpipeSupportedGptModel('gpt-50')).toBe(false);
- expect(isPxpipeSupportedGptModel('')).toBe(false);
- expect(isPxpipeSupportedGptModel('claude-opus-4-8')).toBe(false);
- expect(isPxpipeSupportedGptModel(null)).toBe(false);
+
+ process.env.PXPIPE_MODELS = 'gpt-5.6-sol';
+ expect(isPxpipeSupportedGptModel('gpt-5.6-sol')).toBe(true);
+ expect(isPxpipeSupportedGptModel('gpt-5.6-sol-codex')).toBe(true);
+ expect(isPxpipeSupportedGptModel('gpt-5.6-sol[1m]')).toBe(true);
+ expect(isPxpipeSupportedGptModel('gpt-5.6-sol-codex[1m]')).toBe(true);
+ expect(isPxpipeSupportedGptModel('gpt-5.6')).toBe(false);
+ expect(isPxpipeSupportedGptModel('gpt-5.6-terra')).toBe(false);
});
- it('keeps Grok opt-in only (off by default, like Opus)', () => {
+ it('keeps Grok and Sol opt-in only (off by default, like Opus)', () => {
// Pure-image exact OCR fails at production 5×8; do not image Grok unless
// the operator opts in via PXPIPE_MODELS or the dashboard chip.
const prev = process.env.PXPIPE_MODELS;
@@ -116,9 +119,9 @@ describe('public library API', () => {
expect(isPxpipeSupportedGptModel('grok-4')).toBe(false);
expect(isPxpipeSupportedGptModel('grok-4.20')).toBe(false);
expect(getAllowedModelBases()).not.toContain('grok-4.5');
- expect(getAllowedModelBases()).toEqual(['claude-fable-5', 'gpt-5.6']);
+ expect(getAllowedModelBases()).toEqual(['claude-fable-5']);
- process.env.PXPIPE_MODELS = 'claude-fable-5,gpt-5.6,grok-4.5';
+ process.env.PXPIPE_MODELS = 'claude-fable-5,gpt-5.6-sol,grok-4.5';
expect(isPxpipeSupportedGptModel('grok-4.5')).toBe(true);
expect(isPxpipeSupportedGptModel('grok-4.5-fast')).toBe(true); // -suffix alias
} finally {
@@ -133,17 +136,17 @@ describe('public library API', () => {
// Explicit Claude-only scope disables GPT imaging.
process.env.PXPIPE_MODELS = 'claude-fable-5';
expect(isPxpipeSupportedGptModel('gpt-5.5')).toBe(false);
- expect(isPxpipeSupportedGptModel('gpt-5.6')).toBe(false);
+ expect(isPxpipeSupportedGptModel('gpt-5.6-sol')).toBe(false);
// Mixed CSV selects exactly those bases across families.
- process.env.PXPIPE_MODELS = 'claude-fable-5,gpt-5.6';
+ process.env.PXPIPE_MODELS = 'claude-fable-5,gpt-5.6-sol';
expect(isPxpipeSupportedGptModel('gpt-5.5')).toBe(false);
- expect(isPxpipeSupportedGptModel('gpt-5.6')).toBe(true);
+ expect(isPxpipeSupportedGptModel('gpt-5.6-sol')).toBe(true);
expect(isPxpipeSupportedModel('claude-fable-5')).toBe(true);
// `off` disables everything.
process.env.PXPIPE_MODELS = 'off';
- expect(isPxpipeSupportedGptModel('gpt-5.6')).toBe(false);
+ expect(isPxpipeSupportedGptModel('gpt-5.6-sol')).toBe(false);
expect(isPxpipeSupportedModel('claude-fable-5')).toBe(false);
} finally {
if (prev === undefined) delete process.env.PXPIPE_MODELS;
diff --git a/tests/render.test.ts b/tests/render.test.ts
index 6cc8382..e5f45af 100644
--- a/tests/render.test.ts
+++ b/tests/render.test.ts
@@ -12,6 +12,8 @@ import {
SLOT_MARK_USER,
SLOT_MARK_ASSISTANT,
ROLE_PALETTE,
+ renderCellHeight,
+ renderCellWidth,
CELL_H,
CELL_W,
} from '../src/core/render.js';
@@ -42,6 +44,31 @@ import {
synthesizeText,
} from './fixtures/real-shapes.js';
+describe('model-selectable font atlases', () => {
+ it('uses the JetBrains Mono 10 cell geometry without changing the default atlas', async () => {
+ expect(renderCellWidth({ aa: true })).toBe(5);
+ expect(renderCellHeight({ aa: true })).toBe(8);
+ expect(renderCellWidth({ font: 'jetbrains-mono-10', aa: true })).toBe(6);
+ expect(renderCellHeight({ font: 'jetbrains-mono-10', aa: true })).toBe(11);
+
+ const text = 'tokenLedgerShard a3f9c1e0b7d2';
+ const defaultImg = await renderChunkToPng(text, 40, { aa: true });
+ const solImg = await renderChunkToPng(text, 40, { font: 'jetbrains-mono-10', aa: true });
+ expect(defaultImg.width).toBe(208);
+ expect(solImg.width).toBe(248);
+ expect(solImg.height).toBeGreaterThan(defaultImg.height);
+ expect(Buffer.from(solImg.png)).not.toEqual(Buffer.from(defaultImg.png));
+ });
+
+ it('falls back to the full Spleen/Unifont atlas for Unicode outside the compact Sol atlas', async () => {
+ const img = await renderChunkToPng('한글 test', 20, {
+ font: 'jetbrains-mono-10',
+ aa: true,
+ });
+ expect(img.droppedChars).toBe(0);
+ });
+});
+
describe('compactSlabWhitespace', () => {
it('returns empty string unchanged', () => {
expect(compactSlabWhitespace('')).toBe('');
diff --git a/tests/savings-honesty.test.ts b/tests/savings-honesty.test.ts
index 477c68a..e4108be 100644
--- a/tests/savings-honesty.test.ts
+++ b/tests/savings-honesty.test.ts
@@ -27,7 +27,7 @@ import {
openAICacheReadRate,
} from '../src/core/openai-savings.js';
-const GPT = 'gpt-5.6';
+const GPT = 'gpt-5.6-sol';
// ===========================================================================
describe('GPT savings honesty (vs the real o200k cached-rate model)', () => {
@@ -160,7 +160,7 @@ describe('per-model pricing is applied correctly (Fable vs Opus vs GPT)', () =>
// Both bill cache reads at 0.1x. Pricing an unrelated GPT row at the
// aggressive 0.1x would overstate its cache savings, so the fallback stays
// 0.5x. The gate keeps families from bleeding each other's rates.
- expect(openAICacheReadRate('gpt-5.6')).toBe(0.1);
+ expect(openAICacheReadRate('gpt-5.6-sol')).toBe(0.1);
expect(openAICacheReadRate('gpt-5.5')).toBe(0.1);
// claude models arrive here through the bridge and must use Anthropic's rate.
expect(openAICacheReadRate('claude-opus-4-8')).toBe(0.1);
@@ -174,6 +174,6 @@ describe('per-model pricing is applied correctly (Fable vs Opus vs GPT)', () =>
// Guard against a refactor that unifies them: they are the same number today
// for different reasons (GPT cached-input vs Anthropic cache_read). If one
// provider changes, only its own constant should move.
- expect(openAICacheReadRate('gpt-5.6')).toBe(CACHE_READ_RATE);
+ expect(openAICacheReadRate('gpt-5.6-sol')).toBe(CACHE_READ_RATE);
});
});
diff --git a/tests/savings-math-e2e.test.ts b/tests/savings-math-e2e.test.ts
index 4503d51..556b84f 100644
--- a/tests/savings-math-e2e.test.ts
+++ b/tests/savings-math-e2e.test.ts
@@ -96,7 +96,7 @@ const slab = (n: number) =>
const gptBody = (sysChars: number) =>
JSON.stringify({
- model: 'gpt-5.6',
+ model: 'gpt-5.6-sol',
messages: [
{ role: 'system', content: slab(sysChars) },
{ role: 'user', content: 'hello' },