From 75645a189faceb8faa3f3fdbb6fd4267ca3a6fd9 Mon Sep 17 00:00:00 2001 From: Chesars Date: Sun, 3 May 2026 20:03:49 -0300 Subject: [PATCH 1/2] Mobile UX polish, data sanitization, and broken-link override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Filter the upstream `sample_spec` pseudo-entry: it documents the JSON schema (each field holds a description string, not data) and was rendering as a real model with garbage values that overflowed cells and the column header. - Override the A2A provider docs URL: upstream JSON ships `docs/providers/a2a` which 404s; the working page is `docs/a2a`. - Drop the sticky `` — its `top: 63px` overlapped the first row when the table scrolled, especially on mobile after expanding a sample-spec panel. - Mobile breakpoint pass: shrink detail-panel headings, pricing card values, info-rows (now stack label/value), and code snippets; wrap long lines (`pre-wrap` + `word-break`) so model names like `bedrock/...nova-canvas-v1:0` no longer make the block visually huge. - Tablet breakpoint for code snippets between desktop and phone. - Lock model-row height (52px desktop / 48px mobile) so Bedrock and the first entries don't render taller than others. - Unify trust-logo hover: image and text logos now share the same `grayscale → color + opacity` treatment with one transition rule, so Netflix/Twilio/etc. fade in and out of color the same way as Stripe /Adobe/etc. `transform: translateZ(0)` on text logos prevents the inline-SVG + text combos (Zurich, Weather Co) from jittering on the filter transition. - Defensive validation in `formatCost` / `formatContext` and an `isKnownMode` guard so future malformed entries can't leak schema strings into numeric/badge cells. - `vite.config.ts`: `server.host: true` so `npm run dev` exposes the app on the LAN for phone testing without flags. --- src/App.svelte | 193 ++++++++++++++++++++++++++++++++++--------- src/Providers.svelte | 12 ++- vite.config.ts | 1 + 3 files changed, 162 insertions(+), 44 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index 3b1c2f4..6783c47 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -75,11 +75,14 @@ .then((res) => res.text()) .then((text) => { lines = text.split("\n"); - const items: Item[] = Object.entries(JSON.parse(text)).map( - ([k, v]: any) => ({ name: k, ...v }), - ); - - providers = [...new Set(items.map((i) => i.litellm_provider))]; + // Upstream JSON ships a "sample_spec" pseudo-entry that documents + // the schema (each field's value is a description string, not data). + // It is not a real model — drop it from the table. + const items: Item[] = Object.entries(JSON.parse(text)) + .filter(([k]) => k !== "sample_spec") + .map(([k, v]: any) => ({ name: k, ...v })); + + providers = [...new Set(items.map((i) => i.litellm_provider).filter(Boolean))]; providers.sort(); index = new Fuse(items, { @@ -220,14 +223,14 @@ We also need to update [${RESOURCE_BACKUP_NAME}](https://github.com/${REPO_FULL_ } function formatCost(costPerToken: number | undefined): string { - if (!costPerToken) return "—"; + if (typeof costPerToken !== "number" || !isFinite(costPerToken) || costPerToken <= 0) return "—"; const perMillion = costPerToken * 1000000; if (perMillion < 0.01) return "<$0.01"; return "$" + perMillion.toFixed(2); } function formatContext(tokens: number | undefined): string { - if (!tokens || tokens <= 0) return "—"; + if (typeof tokens !== "number" || !isFinite(tokens) || tokens <= 0) return "—"; if (tokens >= 1000000) return (tokens / 1000000).toFixed(0) + "M"; if (tokens >= 1000) return (tokens / 1000).toFixed(0) + "K"; return tokens.toString(); @@ -245,19 +248,24 @@ We also need to update [${RESOURCE_BACKUP_NAME}](https://github.com/${REPO_FULL_ return badges; } + const KNOWN_MODES: Record = { + "chat": "Chat", + "completion": "Completion", + "embedding": "Embedding", + "image_generation": "Image Gen", + "audio_transcription": "Transcription", + "audio_speech": "TTS", + "moderation": "Moderation", + "rerank": "Rerank", + }; + function getModeLabel(mode: string | undefined): string { if (!mode) return ""; - const labels: Record = { - "chat": "Chat", - "completion": "Completion", - "embedding": "Embedding", - "image_generation": "Image Gen", - "audio_transcription": "Transcription", - "audio_speech": "TTS", - "moderation": "Moderation", - "rerank": "Rerank", - }; - return labels[mode] || mode; + return KNOWN_MODES[mode] || mode; + } + + function isKnownMode(mode: string | undefined): boolean { + return typeof mode === "string" && mode in KNOWN_MODES; } function filterResults( @@ -354,25 +362,25 @@ We also need to update [${RESOURCE_BACKUP_NAME}](https://github.com/${REPO_FULL_ Google ADK Greptile OpenHands - Netflix + Netflix OpenAI Agents SDK Adobe - + Twilio - + Z Zurich Zapier - Rocket Money - Lemonade - + Rocket Money + Lemonade + The Weather Company - samsara + samsara @@ -525,7 +533,7 @@ We also need to update [${RESOURCE_BACKUP_NAME}](https://github.com/${REPO_FULL_
{getDisplayModelName(name, litellm_provider)} - {#if mode} + {#if isKnownMode(mode)} {getModeLabel(mode)} {/if}
@@ -586,19 +594,19 @@ We also need to update [${RESOURCE_BACKUP_NAME}](https://github.com/${REPO_FULL_
Provider - {litellm_provider || "—"} + {litellm_provider && !litellm_provider.includes(" ") && !litellm_provider.includes("/") ? litellm_provider : "—"}
Mode - {mode ? getModeLabel(mode) : "—"} + {isKnownMode(mode) ? getModeLabel(mode) : "—"}
Max Input - {max_input_tokens ? max_input_tokens.toLocaleString() + " tokens" : "—"} + {typeof max_input_tokens === "number" && max_input_tokens > 0 ? max_input_tokens.toLocaleString() + " tokens" : "—"}
Max Output - {max_output_tokens ? max_output_tokens.toLocaleString() + " tokens" : "—"} + {typeof max_output_tokens === "number" && max_output_tokens > 0 ? max_output_tokens.toLocaleString() + " tokens" : "—"}
@@ -817,12 +825,24 @@ curl http://0.0.0.0:4000/v1/chat/completions \ flex-wrap: wrap; } + /* Unified hover transition for all trust logos (img + text + svg-icon). + Same duration/easing across the board so each one fades smoothly + into its colored state. */ + .trust-logo-img, + .trust-logo-text, + .trust-logo-icon svg, + .trust-logo-svg { + transition: color 0.3s ease-out, + fill 0.3s ease-out, + opacity 0.3s ease-out, + filter 0.3s ease-out; + } + .trust-logo-img { height: 28px; object-fit: contain; opacity: 0.55; filter: grayscale(100%); - transition: all 0.2s ease; } .trust-logo-img:hover { @@ -841,11 +861,47 @@ curl http://0.0.0.0:4000/v1/chat/completions \ } } + /* Text logos use the same grayscale->color treatment as image logos. + The brand color is the BASE color; the grayscale filter desaturates + it by default, and removing the filter on hover reveals it. */ .trust-logo-text { font-size: 1.125rem; font-weight: 700; - color: var(--border-color-strong); + color: var(--text-color); letter-spacing: 0.04em; + opacity: 0.55; + filter: grayscale(100%); + cursor: default; + /* Promote to its own compositing layer so filter transitions don't + cause subpixel jitter in the inline SVG + text combo (Zurich, + Weather Company, Twilio). */ + transform: translateZ(0); + will-change: filter, opacity; + backface-visibility: hidden; + } + + .trust-logo-text:hover { + opacity: 0.9; + filter: grayscale(0%); + } + + .trust-logo-text[data-brand="netflix"] { color: #E50914; } + .trust-logo-text[data-brand="lemonade"] { color: #FF0083; } + .trust-logo-text[data-brand="rocketmoney"] { color: #00D4AA; } + .trust-logo-text[data-brand="twilio"] { color: #F22F46; } + .trust-logo-text[data-brand="zurich"] { color: #2167AE; } + .trust-logo-text[data-brand="weather"] { color: #00ABEB; } + .trust-logo-text[data-brand="samsara"] { color: #1F6FB2; } + + @media (prefers-color-scheme: dark) { + .trust-logo-text { + filter: grayscale(100%) brightness(2); + opacity: 0.5; + } + .trust-logo-text:hover { + filter: grayscale(0%) brightness(1.2); + opacity: 0.9; + } } .trust-logo-icon { @@ -856,26 +912,27 @@ curl http://0.0.0.0:4000/v1/chat/completions \ .trust-logo-icon svg { flex-shrink: 0; + fill: currentColor; } .trust-logo-svg { filter: brightness(0); - opacity: 0.45; + opacity: 0.55; } .trust-logo-svg:hover { filter: brightness(0); - opacity: 0.8; + opacity: 1; } @media (prefers-color-scheme: dark) { .trust-logo-svg { filter: brightness(0) invert(1); - opacity: 0.5; + opacity: 0.6; } .trust-logo-svg:hover { filter: brightness(0) invert(1); - opacity: 0.9; + opacity: 1; } } @@ -1095,9 +1152,6 @@ curl http://0.0.0.0:4000/v1/chat/completions \ background-color: var(--bg-secondary); white-space: nowrap; user-select: none; - position: sticky; - top: 63px; - z-index: 10; } .th-model { padding-left: 1rem; } @@ -1125,6 +1179,7 @@ curl http://0.0.0.0:4000/v1/chat/completions \ border-bottom: 1px solid var(--border-color); transition: background-color 0.1s ease; cursor: pointer; + height: 52px; } tbody tr.model-row:hover { @@ -1153,6 +1208,9 @@ curl http://0.0.0.0:4000/v1/chat/completions \ display: flex; align-items: center; gap: 0.625rem; + flex-wrap: nowrap; + min-width: 0; + min-height: 32px; } .expand-icon { @@ -1224,6 +1282,8 @@ curl http://0.0.0.0:4000/v1/chat/completions \ align-items: center; gap: 0.5rem; min-width: 0; + flex: 1 1 auto; + flex-wrap: nowrap; } .model-title { @@ -1233,6 +1293,8 @@ curl http://0.0.0.0:4000/v1/chat/completions \ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + flex: 1 1 auto; + min-width: 0; } .mode-badge { @@ -1246,6 +1308,9 @@ curl http://0.0.0.0:4000/v1/chat/completions \ text-transform: uppercase; letter-spacing: 0.03em; flex-shrink: 0; + max-width: 110px; + overflow: hidden; + text-overflow: ellipsis; } .context-cell { @@ -1334,6 +1399,8 @@ curl http://0.0.0.0:4000/v1/chat/completions \ align-items: center; padding: 0.375rem 0; border-bottom: 1px solid var(--border-color); + gap: 0.75rem; + min-width: 0; } .info-row:last-child { border-bottom: none; } @@ -1341,6 +1408,7 @@ curl http://0.0.0.0:4000/v1/chat/completions \ .info-label { font-size: 0.8125rem; color: var(--muted-color); + flex-shrink: 0; } .info-value { @@ -1348,6 +1416,9 @@ curl http://0.0.0.0:4000/v1/chat/completions \ font-weight: 600; color: var(--text-color); font-family: 'JetBrains Mono', monospace; + text-align: right; + overflow-wrap: anywhere; + min-width: 0; } .feature-list { @@ -1449,9 +1520,13 @@ curl http://0.0.0.0:4000/v1/chat/completions \ overflow-x: auto; background: var(--code-bg); color: var(--code-text); + max-width: 100%; + box-sizing: border-box; + -webkit-overflow-scrolling: touch; } - .code-snippet code { display: block; } + .code-snippet code { display: block; white-space: pre; } + .detail-code-section { max-width: 100%; min-width: 0; } .code-kw { color: #8b5cf6; } .code-str { color: #10b981; } @@ -1518,6 +1593,12 @@ curl http://0.0.0.0:4000/v1/chat/completions \ .th-hide-mobile, .td-hide-mobile { display: none; } } + /* Tablet — intermediate code-snippet size between desktop and phone */ + @media (max-width: 1024px) { + .code-snippet { font-size: 0.6875rem; line-height: 1.5; padding: 0.75rem; } + .code-snippet code { white-space: pre-wrap; word-break: break-word; } + } + @media (max-width: 768px) { .hero { padding: 2.5rem 1rem 1.5rem; } .hero-title { font-size: 2rem; } @@ -1532,6 +1613,36 @@ curl http://0.0.0.0:4000/v1/chat/completions \ .model-name { min-width: 180px; } .trust-logos { gap: 1.25rem; } .trust-logo-img { height: 22px; } - .detail-grid { grid-template-columns: 1fr; } + .trust-logo-text { font-size: 0.9375rem; } + .detail-grid { grid-template-columns: 1fr; gap: 1rem; } + + /* Detail panel: shrink section headings and pricing values so they + no longer dominate the body text on phones. Section headings + (Pricing, Model Info, Features) sit one tier above pricing-card + labels (Input, Cache Read, ...) for clear hierarchy. */ + .detail-panel { padding: 1rem; } + .detail-heading { font-size: 0.5625rem; letter-spacing: 0.06em; margin-bottom: 0.5rem; } + .pricing-cards { gap: 0.3125rem; } + .pricing-card { padding: 0.375rem 0.4375rem; gap: 0.125rem; } + .pricing-label { font-size: 0.375rem; letter-spacing: 0.04em; font-weight: 500; } + .pricing-value { font-size: 0.5625rem; font-weight: 600; } + + /* Stack info-rows so "Max Input" label can't collide with long values */ + .info-row { flex-direction: column; align-items: flex-start; gap: 0.125rem; padding: 0.375rem 0; } + .info-label { font-size: 0.75rem; } + .info-value { text-align: left; font-size: 0.75rem; word-break: break-word; } + + /* Code blocks: smaller font and tighter padding for mobile readability. + Wrap lines (pre-wrap) so very long model names break instead of + forcing the block to be visually huge. */ + .code-snippet { font-size: 0.4375rem; line-height: 1.4; padding: 0.5rem; } + .code-snippet code { white-space: pre-wrap; word-break: break-word; } + .code-header-row { padding: 0.5rem 0.75rem; flex-wrap: wrap; gap: 0.5rem; } + .code-tab { padding: 0.3125rem 0.625rem; font-size: 0.6875rem; } + .copy-code-btn { font-size: 0.6875rem; padding: 0.25rem 0.5rem; } + + /* Lock row height so Bedrock / first entries don't render taller than others */ + .model-row td { height: 48px; } + .provider-avatar { width: 24px; height: 24px; } } diff --git a/src/Providers.svelte b/src/Providers.svelte index fb90b93..75d604d 100644 --- a/src/Providers.svelte +++ b/src/Providers.svelte @@ -32,20 +32,26 @@ const PROVIDERS_URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/provider_endpoints_support.json"; const DOCS_URL = "https://docs.litellm.ai/docs/providers"; + // Upstream JSON ships a few broken /docs/providers/* URLs that 404. + // Map provider key -> correct doc URL. + const PROVIDER_URL_OVERRIDES: Record = { + a2a: "https://docs.litellm.ai/docs/a2a", + }; + onMount(async () => { try { const response = await fetch(PROVIDERS_URL); const data = await response.json(); - + if (data.endpoints) { endpointsMetadata = data.endpoints; } - + if (data.providers) { providers = Object.entries(data.providers).map(([provider, info]: [string, any]) => ({ provider, display_name: info.display_name || provider, - url: info.url || DOCS_URL, + url: PROVIDER_URL_OVERRIDES[provider] || info.url || DOCS_URL, endpoints: info.endpoints || {} })); } diff --git a/vite.config.ts b/vite.config.ts index 91164ec..1306ca2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,5 +7,6 @@ export default defineConfig({ server: { // Enable SPA fallback for client-side routing historyApiFallback: true, + host: true, }, }); From 83664ea389099e12c0ea6191f04c5b4a67aafc81 Mon Sep 17 00:00:00 2001 From: Chesars Date: Sun, 3 May 2026 20:08:30 -0300 Subject: [PATCH 2/2] Drop speculative defensive validation and host:true default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback: - Revert `server.host: true` in vite.config.ts. Exposing the dev server to the LAN by default is not the right default for upstream; contributors who want LAN access can run `npm run dev -- --host`. - Revert defensive type checks in `formatCost` / `formatContext` and the `isKnownMode` helper. With `sample_spec` filtered at load, no malformed entry reaches these formatters — the extra guards were speculative validation for scenarios that can't currently happen. --- src/App.svelte | 41 ++++++++++++++++++----------------------- vite.config.ts | 1 - 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index 6783c47..a1dc4a3 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -223,14 +223,14 @@ We also need to update [${RESOURCE_BACKUP_NAME}](https://github.com/${REPO_FULL_ } function formatCost(costPerToken: number | undefined): string { - if (typeof costPerToken !== "number" || !isFinite(costPerToken) || costPerToken <= 0) return "—"; + if (!costPerToken) return "—"; const perMillion = costPerToken * 1000000; if (perMillion < 0.01) return "<$0.01"; return "$" + perMillion.toFixed(2); } function formatContext(tokens: number | undefined): string { - if (typeof tokens !== "number" || !isFinite(tokens) || tokens <= 0) return "—"; + if (!tokens || tokens <= 0) return "—"; if (tokens >= 1000000) return (tokens / 1000000).toFixed(0) + "M"; if (tokens >= 1000) return (tokens / 1000).toFixed(0) + "K"; return tokens.toString(); @@ -248,24 +248,19 @@ We also need to update [${RESOURCE_BACKUP_NAME}](https://github.com/${REPO_FULL_ return badges; } - const KNOWN_MODES: Record = { - "chat": "Chat", - "completion": "Completion", - "embedding": "Embedding", - "image_generation": "Image Gen", - "audio_transcription": "Transcription", - "audio_speech": "TTS", - "moderation": "Moderation", - "rerank": "Rerank", - }; - function getModeLabel(mode: string | undefined): string { if (!mode) return ""; - return KNOWN_MODES[mode] || mode; - } - - function isKnownMode(mode: string | undefined): boolean { - return typeof mode === "string" && mode in KNOWN_MODES; + const labels: Record = { + "chat": "Chat", + "completion": "Completion", + "embedding": "Embedding", + "image_generation": "Image Gen", + "audio_transcription": "Transcription", + "audio_speech": "TTS", + "moderation": "Moderation", + "rerank": "Rerank", + }; + return labels[mode] || mode; } function filterResults( @@ -533,7 +528,7 @@ We also need to update [${RESOURCE_BACKUP_NAME}](https://github.com/${REPO_FULL_
{getDisplayModelName(name, litellm_provider)} - {#if isKnownMode(mode)} + {#if mode} {getModeLabel(mode)} {/if}
@@ -594,19 +589,19 @@ We also need to update [${RESOURCE_BACKUP_NAME}](https://github.com/${REPO_FULL_
Provider - {litellm_provider && !litellm_provider.includes(" ") && !litellm_provider.includes("/") ? litellm_provider : "—"} + {litellm_provider || "—"}
Mode - {isKnownMode(mode) ? getModeLabel(mode) : "—"} + {mode ? getModeLabel(mode) : "—"}
Max Input - {typeof max_input_tokens === "number" && max_input_tokens > 0 ? max_input_tokens.toLocaleString() + " tokens" : "—"} + {max_input_tokens ? max_input_tokens.toLocaleString() + " tokens" : "—"}
Max Output - {typeof max_output_tokens === "number" && max_output_tokens > 0 ? max_output_tokens.toLocaleString() + " tokens" : "—"} + {max_output_tokens ? max_output_tokens.toLocaleString() + " tokens" : "—"}
diff --git a/vite.config.ts b/vite.config.ts index 1306ca2..91164ec 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,6 +7,5 @@ export default defineConfig({ server: { // Enable SPA fallback for client-side routing historyApiFallback: true, - host: true, }, });