Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions docs/MODEL_RENDER_PROFILES.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ The endpoint is a wire protocol, not a rendering profile. Claude, GPT, and Grok
can all arrive on `/v1/responses`; pxpipe resolves geometry and vision billing
from the model id.

A gateway may prefix that wire path with one routing segment and may qualify the
model id with vendor segments. Both are recognized: `/compat/chat/completions`
and `/grok/v1/responses` are read as the OpenAI shapes they are (one leading
path segment is stripped), and the profile lookup tries the full id then its
bare final segment, so `moonshotai/kimi-k3` and
`workers-ai/@cf/moonshotai/kimi-k3` both resolve the same as `kimi-k3` — vendor
segments pick an upstream, not a
geometry. Prefix recognition does not move routing — a provider-prefixed path
still forwards to the passthrough upstream.

| model rule | default | cell | columns | max height | evidence |
|---|:---:|---|---:|---:|---|
| `claude-*` / `anthropic-*` | yes | Spleen 5×8 | 312 | 728 px | established Claude suites |
Expand Down Expand Up @@ -53,3 +63,43 @@ PXPIPE_GPT_PROFILES='{"gpt-5.6-sol":{"stripCols":120}}'

The profitability gate uses the same resolved profile as the renderer, so a
style or geometry override cannot leave cost prediction on stale dimensions.

## Unmeasured families

An id that names a family pxpipe HAS measured, but does not match any of that
family's profiles — an unmeasured Gemini or Grok sibling — is refused by
`isMisresolvedModelId`, and the request passes through as text. Such an id would
otherwise fall through to `DEFAULT_GPT_PROFILE` and be gated with OpenAI's tile
math, i.e. priced with the wrong provider's formula.

An id that names no known family at all (anything reached through a gateway's
OpenAI-compatible route — Kimi K3 on Cloudflare is the example this repo has
run) is NOT refused. It resolves to
`DEFAULT_GPT_PROFILE` and is gated with OpenAI's tile math as an approximation.
`PXPIPE_MODELS` is the only gate: listing such a model is the operator asserting
it is worth imaging. Two consequences worth stating plainly:

- The savings figure is an approximation, not a measurement. If tile math
overstates the model's real image cost, the gate declines transforms that
would have paid off; if it understates, you take a small loss.
- Nothing checks that the model can accept images at all. A text-only model
will reject an imaged request outright — a 400 on the first compressed call,
not a silent degradation.

To replace the approximation with real numbers, declare the geometry and vision
cost yourself:

```bash
PXPIPE_MODELS=claude-fable-5,kimi-k3
PXPIPE_GPT_PROFILES='{"kimi-k3":{"vision":{"regime":"mpix","tokensPerMegapixel":1000},"stripCols":152,"maxHeightPx":1932}}'
```

The `tokensPerMegapixel` above is a **placeholder, not a measurement** — read
the real image-token rule off your provider's billing docs, or measure it by
sending one known-size image and reading the reported input tokens. A wrong
number here does not corrupt output; it corrupts the profitability gate and the
dashboard's savings figure, which is worse, because both will look confident.

Declaring a cost says nothing about whether the model can *read* dense imaged
text. Verify that separately before trusting a compressed session — see
[eval/](../eval) for the harnesses used on the shipped profiles.
17 changes: 16 additions & 1 deletion src/core/applicability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,19 @@ export function setAllowedModelBases(list: readonly string[] | null): void {
runtimeModelBases = list === null ? null : list.map((s) => s.trim()).filter(Boolean);
}

/** Gateways qualify ids with routing segments:
*
* google/gemini-3.6-flash
* workers-ai/@cf/moonshotai/kimi-k3
*
* The vendor picks the upstream, not the reader, so scope matching also
* compares the segment after the last slash. Otherwise PXPIPE_MODELS entries
* never match behind a gateway. */
function unqualifiedModelId(base: string): string | null {
const slash = base.lastIndexOf('/');
return slash >= 0 ? base.slice(slash + 1) : null;
}

/** Membership test against the single allowed scope. Matches exact base or `-suffix`
* alias; [variant] tags stripped first. */
function isAllowed(model: string | null | undefined): boolean {
Expand All @@ -87,9 +100,11 @@ function isAllowed(model: string | null | undefined): boolean {
// (e.g. an unmeasured Gemini sibling falling through to the OpenAI fallback).
// Which ids those are is profile-table knowledge, not a rule maintained here.
if (isMisresolvedModelId(base)) return false;
const unqualified = unqualifiedModelId(base);
return allowedModelBases().some((b) => {
const target = b.toLowerCase();
return base === target || base.startsWith(`${target}-`) || base === `google/${target}`;
const hit = (id: string): boolean => id === target || id.startsWith(`${target}-`);
return hit(base) || (unqualified !== null && hit(unqualified));
});
}

Expand Down
46 changes: 40 additions & 6 deletions src/core/gpt-model-profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,17 @@ const FAMILY_ID_GUARDS: ReadonlyArray<{ mentions: RegExp; matches: (m: string) =
{ mentions: /grok/, matches: isGrokModel },
];

/** True when the operator declared this id in PXPIPE_GPT_PROFILES. The guards
* above catch implicit fallback to another provider's formula; an explicit
* declaration supplies geometry and vision cost, so it clears them. */
function hasDeclaredProfile(m: string): boolean {
const env = envProfiles();
if (env.size === 0) return false;
const ids = candidateIds(m);
for (const k of env.keys()) if (ids.some((id) => id.startsWith(k))) return true;
return false;
}

/**
* True when an id NAMES a known provider family but does not match that
* family's profile test — for example `gemini-3.6-pro`, when 3.6 Flash is the
Expand All @@ -332,7 +343,14 @@ const FAMILY_ID_GUARDS: ReadonlyArray<{ mentions: RegExp; matches: (m: string) =
*/
export function isMisresolvedModelId(model: string | null | undefined): boolean {
const m = (model ?? '').toLowerCase();
return FAMILY_ID_GUARDS.some((g) => g.mentions.test(m) && !g.matches(m));
if (hasDeclaredProfile(m)) return false;
// Must cover every spelling resolveGptProfile() tries, or guard and resolver
// disagree: `openrouter/gemini-3.6-flash` resolves to the measured Gemini
// profile via its unqualified id, so it must not be refused here.
const ids = candidateIds(m);
return FAMILY_ID_GUARDS.some(
(g) => ids.some((id) => g.mentions.test(id)) && !ids.some((id) => g.matches(id)),
);
}

function resolveBuiltin(m: string): GptModelProfile {
Expand Down Expand Up @@ -492,22 +510,38 @@ function envProfiles(): Map<string, GptModelProfile> {
return envMap;
}

/** Vendor segments select an upstream, not a geometry, so both spellings of an
* id must resolve to one profile — otherwise `moonshotai/kimi-k3` skips its measured
* entry and lands on DEFAULT_GPT_PROFILE's OpenAI tile math. Mirrors
* unqualifiedModelId() in applicability.ts. */
function candidateIds(m: string): string[] {
const slash = m.lastIndexOf('/');
return slash >= 0 ? [m, m.slice(slash + 1)] : [m];
}

export function resolveGptProfile(model: string | null | undefined): GptModelProfile {
// Match applicability.ts: bracketed transport variants (for example [1m])
// do not define a different visual reader profile.
const m = (model ?? '').toLowerCase().replace(/\[[^\]]*\]/g, '');
if (isGeminiModel(m)) return resolveGeminiProfile();
const ids = candidateIds(m);
if (ids.some(isGeminiModel)) return resolveGeminiProfile();
const env = envProfiles();
if (env.size > 0) {
let best: GptModelProfile | undefined;
let bestLen = -1;
for (const [k, p] of env) {
if (m.startsWith(k) && k.length > bestLen) {
best = p;
bestLen = k.length;
// Longest matching key wins. Equal-length keys fall to env insertion
// order, not candidate order — the outer loop is over keys.
for (const id of ids) {
if (id.startsWith(k) && k.length > bestLen) {
best = p;
bestLen = k.length;
}
}
}
if (best) return best;
}
return resolveBuiltin(m);
const qualified = ids.length > 1 ? resolveBuiltin(ids[0]!) : undefined;
if (qualified && qualified !== DEFAULT_GPT_PROFILE) return qualified;
return resolveBuiltin(ids[ids.length - 1]!);
}
51 changes: 47 additions & 4 deletions src/core/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,11 @@ function withClientDisconnect(
});
}

/** Cap on bodies buffered only to sniff a model name, on paths pxpipe does not
* transform. Chat requests naming a model are far below this; uploads that blow
* past it stream through unbuffered. */
const MODEL_SNIFF_MAX_BYTES = 1 << 20;

/** Read the actual top-level `model` field. The body is already buffered for
* transformation, so parsing it is both safer and simpler than a prefix regex
* (which could mistake `metadata.model` for the routing model). */
Expand Down Expand Up @@ -804,14 +809,27 @@ function isProviderPrefixedPath(pathname: string): boolean {
return PASSTHROUGH_PREFIXES.some((prefix) => pathname.startsWith(prefix));
}

/** One optional gateway/provider segment, then an optional `/v1`, before the
* wire-shape suffix:
*
* /v1/chat/completions
* /openai/v1/chat/completions
* /compat/chat/completions AI Gateway's OpenAI-compatible route
* /grok/chat/completions
*
* Wire shape only: whether the body is OpenAI-shaped enough to parse a model
* out of. Routing stays with isProviderPrefixedPath/isCanonicalOpenAIPath
* above, so a prefixed path still goes to passthroughUpstream. */
const PROVIDER_SEGMENT = String.raw`(?:\/[a-z0-9][a-z0-9._-]*)?(?:\/v1)?`;
const OPENAI_CHAT_PATH = new RegExp(`^${PROVIDER_SEGMENT}\\/chat\\/completions$`);
const OPENAI_RESPONSES_PATH = new RegExp(`^${PROVIDER_SEGMENT}\\/responses$`);

function isOpenAIChatPath(pathname: string): boolean {
return pathname === '/v1/chat/completions' || pathname === '/openai/v1/chat/completions';
return OPENAI_CHAT_PATH.test(pathname);
}

function isOpenAIResponsesPath(pathname: string): boolean {
return pathname === '/v1/responses'
|| pathname === '/openai/v1/responses'
|| pathname === '/openai/responses';
return OPENAI_RESPONSES_PATH.test(pathname);
}

function isCanonicalOpenAIPath(pathname: string, headers: Headers, hasOpenAIKey: boolean): boolean {
Expand Down Expand Up @@ -1298,6 +1316,31 @@ export function createProxy(config: ProxyConfig = {}) {
headers: { 'content-type': 'application/json' },
});
}
} else if (req.method === 'POST') {
// The model lives in the body, not the path, so read it here too or the
// dashboard row has a blank Model and token columns. Label only: the body
// is forwarded byte-for-byte, no transform, no baseline probe, no
// accounting change. Never overwrite a model already recovered from the
// path — Google routes name it there and carry no body `model`.
//
// This route also carries uploads and audio, so buffer only what can
// plausibly be a JSON request naming a model: declared JSON, declared
// length under the cap. Anything else streams and shows a blank Model, as
// before. A missing content-length is not evidence of a big body —
// chunked clients omit it — so only an explicit over-cap declaration
// disqualifies.
const declaredType = req.headers.get('content-type') ?? '';
const declaredLength = Number(req.headers.get('content-length') ?? NaN);
const worthBuffering =
declaredType.toLowerCase().includes('json') &&
!(Number.isFinite(declaredLength) && declaredLength > MODEL_SNIFF_MAX_BYTES);
if (worthBuffering) {
const bodyIn = new Uint8Array(await req.arrayBuffer());
requestModel ??= readModelField(bodyIn) ?? undefined;
bodyOut = bodyIn;
} else {
bodyOut = req.body; // pass through unchanged, model stays unknown
}
} else {
bodyOut = req.body; // pass through unchanged
}
Expand Down
Loading
Loading