diff --git a/src/dashboard.ts b/src/dashboard.ts index 909f90f..d64aab8 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -1516,6 +1516,18 @@ export class DashboardState { else next.delete(model); setAllowedModelBases([...next]); } + + /** POST /fragments/models with {list} — replace the WHOLE runtime compress + * scope from the PXPIPE_MODELS textbox. Same CSV shape as the env var; + * empty or off/false/0/no/none = compress nothing. In-memory only. */ + handleModelsSet(csv: string): void { + const trimmed = csv.trim(); + const bases = + !trimmed || /^(0|false|no|off|none)$/i.test(trimmed) + ? [] + : trimmed.split(',').map((s) => s.trim()).filter(Boolean); + setAllowedModelBases(bases); + } } function jsonResponse(body: unknown, status = 200): Response { diff --git a/src/dashboard/fragments.ts b/src/dashboard/fragments.ts index 1888752..3a18968 100644 --- a/src/dashboard/fragments.ts +++ b/src/dashboard/fragments.ts @@ -103,7 +103,8 @@ export function renderModelsFragment( [...MODEL_CATALOG, ...GPT_MODEL_CATALOG, ...GROK_MODEL_CATALOG].map((m) => [m.id, m.label]), ); // Union the catalog with env-configured + active ids so PXPIPE_MODELS-enabled - // families always show as toggles, then split by family for the two sections. + // families always show as toggles, then split into two chip rows (Claude / + // OpenAI Responses) plus the PXPIPE_MODELS CSV textbox that mirrors the scope. const ids: string[] = []; const seen = new Set(); for (const id of [ @@ -134,23 +135,29 @@ export function renderModelsFragment( .filter((id) => !id.startsWith('claude') && !id.startsWith('gpt') && !id.startsWith('grok')) .map(chipFor) .join(''); - const moot = enabled ? '' : ` compression is off, so this has no effect right now`; + const moot = enabled + ? '' + : `
compression is off — these settings have no effect right now
`; return ( + moot + `
` + `Image Claude models` + claudeChips + - `everything else is sent as normal text · runtime only · persist with PXPIPE_MODELS${moot}` + + `unlisted models get plain text` + `
` + `
` + - `Image Grok models` + + `Image OpenAI Responses models` + + gptChips + grokChips + otherChips + - `opt-in only · OpenAI Responses path · set PXPIPE_MODELS to persist${moot}` + + `opt-in · no Anthropic cache_control` + `
` + `
` + - `Image GPT models` + - gptChips + - `imaging only, no Anthropic cache_control · one scope for all families · set PXPIPE_MODELS (CSV of bases, or off) to persist${moot}` + + `PXPIPE_MODELS` + + `` + + `CSV of bases, or off · applies on enter/blur · export to persist` + `
` ); } @@ -839,12 +846,41 @@ const CSS = ` /* model chips */ .models { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 0 18px; } .models-label { color: var(--ink-2); font-size: 12px; font-weight: 600; } + .models-csv { flex: 1 1 260px; min-width: 220px; color: var(--ink); background: var(--surface); + border: 1px solid var(--border-strong); border-radius: 6px; padding: 4px 8px; + font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; } + .models-csv:focus { outline: none; border-color: var(--flame-ink); } + .models-routing { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 0 18px; } + #routing-help { border: 1px solid var(--border-strong); border-radius: 10px; background: var(--surface); + color: var(--ink); max-width: 600px; padding: 16px 20px; } + #routing-help::backdrop { background: rgba(20, 12, 6, .4); } + #routing-help h3 { margin: 0 0 8px; font-size: 14px; color: var(--ink); } + #routing-help p, #routing-help li { font-size: 12px; line-height: 1.55; color: var(--ink-2); margin: 6px 0; } + #routing-help ul { margin: 6px 0; padding-left: 18px; } + #routing-help code { font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--ink); } + #routing-help pre { background: var(--surface-2); border: 1px solid var(--border); border-radius: 6px; + padding: 8px 10px; margin: 8px 0; overflow-x: auto; + font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--ink); } .chip { background: var(--surface); color: var(--ink-2); border: 1px solid var(--border-strong); border-radius: 999px; padding: 4px 12px; cursor: pointer; font: inherit; font-size: 12px; } .chip:hover { border-color: var(--flame); color: var(--flame-ink); } .chip.on { background: var(--flame-tint); color: var(--flame-ink); border-color: var(--flame); font-weight: 600; } + /* collapsed model-scope section (#116): the default compress scope is Fable 5 + only, so the three family rows stay hidden until the user opts in. The +
wrapper lives in the static shell — NOT inside #frag-models — + because the every-2s innerHTML poll would otherwise reset its open state. */ + .models-collapse { margin: 0 0 18px; } + .models-collapse .models { margin: 0 0 10px; } + .models-collapse .models:last-child { margin-bottom: 0; } + .models-summary { cursor: pointer; color: var(--ink-2); font-size: 12px; font-weight: 600; + margin: 0 0 8px; user-select: none; } + .models-summary:hover { color: var(--flame-ink); } + .models-warning { color: var(--ink-2); background: var(--surface); border: 1px solid var(--border-strong); + border-left: 3px solid var(--bad); border-radius: 8px; padding: 8px 12px; font-size: 12px; + margin: 0 0 12px; } + /* session hero */ #frag-session { display: block; margin-bottom: 16px; } .hero { background: linear-gradient(135deg, var(--flame-tint), var(--surface) 60%); border: 1px solid var(--border); @@ -1145,7 +1181,32 @@ export function renderPage(port: number): string { -
+
+ Image model scope Fable 5 only by default · expand to experiment with other families +
⚠ Image compression is tuned for Fable 5 only — other families can use more tokens, not less. Opt in only for deliberate experiments (custom system prompt, subagent model setup, …).
+
+
imaging scope ≠ provider routing — non-Anthropic IDs also need routing env on the proxy
+
+ + +

Routing Claude Code to OpenAI / Cloudflare models

+

Claude models use Anthropic by default. Two optional routes can run together — set on the pxpipe process (keep provider credentials out of Claude Code):

+
    +
  • OPENAI_MODELS — exact model IDs routed to OpenAI Responses (OPENAI_UPSTREAM + OPENAI_API_KEY)
  • +
  • CLOUDFLARE_MODELS — exact model IDs routed to Cloudflare's OpenAI-compatible endpoint (CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN)
  • +
+

If a model appears in both lists: CLOUDFLARE_MODELS > OPENAI_MODELS > default routing.

+
OPENAI_UPSTREAM=https://api.openai.com \\
+OPENAI_API_KEY=your-openai-key \\
+OPENAI_MODELS=gpt-5.6-sol \\
+CLOUDFLARE_ACCOUNT_ID=your-account-id \\
+CLOUDFLARE_API_TOKEN=your-cloudflare-token \\
+CLOUDFLARE_MODELS=moonshotai/kimi-k3 \\
+npx pxpipe-proxy
+

Non-Anthropic IDs are advertised with a claude- prefix because Claude Code needs a Claude-shaped ID; pxpipe strips it before forwarding. Switch to one inside Claude Code with /model claude-<model> — e.g. /model claude-moonshotai/kimi-k3 — or launch with claude --model claude-moonshotai/kimi-k3. Verify discovery with curl …/v1/models.

+

PXPIPE_MODELS above is separate: it controls image compression, not routing. Kimi K3 on Cloudflare is the only non-Anthropic model tested end to end — see docs/CLAUDE_CODE_PROVIDER_ROUTING.md.

+ +
Connecting…
diff --git a/src/node.ts b/src/node.ts index e0839b5..ff200cd 100644 --- a/src/node.ts +++ b/src/node.ts @@ -441,26 +441,31 @@ async function dispatchDashboard( dashboard.handleCompressionToggle({ enabled }); return dashboard.serveFragment('toggle', url, port); } - // /fragments/models POSTs one chip flip: {model, on}. Server mutates the - // runtime compress scope and returns the re-rendered chip row. + // /fragments/models POSTs one chip flip {model, on}, or a whole-scope + // rewrite {list: "csv"} from the PXPIPE_MODELS textbox. Server mutates + // the runtime compress scope and returns the re-rendered rows. if (route.name === 'models' && method === 'POST') { let model = ''; let on = false; + let list: string | null = null; try { const raw = await readRequestBody(req); try { - const j = JSON.parse(raw) as { model?: unknown; on?: unknown }; + const j = JSON.parse(raw) as { model?: unknown; on?: unknown; list?: unknown }; model = typeof j.model === 'string' ? j.model : ''; on = j.on === true; + if (typeof j.list === 'string') list = j.list; } catch { const p = new URLSearchParams(raw); model = p.get('model') ?? ''; on = p.get('on') === 'true'; + list = p.get('list'); } } catch { return new Response('bad request body', { status: 400 }); } - if (model) dashboard.handleModelsToggle(model, on); + if (list !== null) dashboard.handleModelsSet(list); + else if (model) dashboard.handleModelsToggle(model, on); return dashboard.serveFragment('models', url, port); } if (method !== 'GET') return undefined; diff --git a/tests/dashboard-api.test.ts b/tests/dashboard-api.test.ts index 4e88df1..10258a7 100644 --- a/tests/dashboard-api.test.ts +++ b/tests/dashboard-api.test.ts @@ -184,8 +184,11 @@ describe('serveFragment', () => { delete process.env.PXPIPE_MODELS; 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).toContain('Image OpenAI Responses models'); expect(off).not.toContain('