Skip to content

feat(catalog): mode-aware pricing + bugbot fixes (supersedes #1)#2

Merged
mateo-berri merged 10 commits into
mainfrom
feat/catalog-api-pricing-berriai
Jun 3, 2026
Merged

feat(catalog): mode-aware pricing + bugbot fixes (supersedes #1)#2
mateo-berri merged 10 commits into
mainfrom
feat/catalog-api-pricing-berriai

Conversation

@mateo-berri

@mateo-berri mateo-berri commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

What this is

A maintainer-controlled branch carrying the full contents of #1 (mode-aware pricing + litellm-model-catalog-api integration, by @Sameerlite) plus fixes for the four Cursor Bugbot findings raised on that PR.

#1 is opened from a fork, so the Bugbot-fix commit couldn't be pushed directly onto its head ref from this environment. This draft puts the same work on a base-repo branch so the fixes are reviewable/mergeable. Intended to supersede #1 — if #1's branch later gets the fixes another way, this can be closed.

Bugbot fixes (commit on top of #1's 3c4644b)

All four findings were verified against the code and are legitimate:

  1. Double HTML escaping in proxy curl snippets (Medium) — in getLiteLLmProxyCurlSnippetHtml, the pre-escaped modelStr was embedded in JSON bodies that are then passed through curlStr() (which escapes again), double-escaping model names for image_generation, embedding, audio_speech, and chat. Now the raw model is passed into those curlStr(...) bodies (escaped exactly once). modelStr is retained for the # Start proxy comment, which is not routed through curlStr.
  2. Output sort drops valid $0.00 slot (Medium)imageModeOutputSortValue had an if (v > 0) guard absent from imageModeInputSortValue, so a genuine $0.00 output pricing slot fell back to legacy fields and sorted inconsistently with its displayed price. Now returns the slot's sort value unconditionally, matching the input twin.
  3. Unreachable audio pricing heading branch (Low){:else if isAudioPricingMode(mode)} in App.svelte was dead (both audio modes are matched by the two preceding branches). Removed; isAudioPricingMode is still used elsewhere so the import stays.
  4. Unused dead-code helpers (Low) — removed formatUsdFlat and formatUsdPerMillion (never referenced).

Verification

  • npm run build (vite) passes.
  • npm run check (svelte-check) reports the same pre-existing 9 errors / 7 warnings before and after the fix commit — none in the touched files (src/modelPresentation.ts, src/App.svelte).

Closes the Bugbot review threads on #1.

https://claude.ai/code/session_01S3t66Fj5D6fhacT7WT5LgP


Generated by Claude Code


Note

Medium Risk
Large UI/pricing presentation changes affect what users see and sort by; incorrect cost or truncation would mislead, but there is no auth or payment logic in scope.

Overview
This PR upgrades the model catalog UI to mode-aware pricing and optional loading from litellm-model-catalog-api (env: VITE_USE_LOCAL_CATALOG_API, VITE_CATALOG_API_BASE, Vite /model_catalog proxy), with a clear load error when the API fails.

Data & schema row: Catalog load is refactored through shared finishLoad; GitHub JSON and API paths both surface a pinned sample_spec reference row (excluded from provider list, always visible under search/filters, pinned when sorting). Token min filters accept numeric strings via tokenLimitValue.

Presentation layer: New catalogApi.ts (paginated fetch, pricing_slots, exact USD formatting) and modelPresentation.ts drive table/detail costs for chat, image (incl. tiers/extras), and audio; sorting aligns with displayed units. Expanded rows use mode-specific LiteLLM Python SDK and proxy curl snippets ({@html} with single-pass escaping in JSON bodies).

UI polish: Column labels (“Input/Output cost”, cache tooltips), schema-row styling, sticky header/table CSS fixes, and hiding “Report incorrect data” for sample_spec.

On top of the fork PR (#1): Fixes proxy snippet double-escaping, image $0.00 output slot sort parity with input, removes dead audio heading branch and unused format helpers.

Reviewed by Cursor Bugbot for commit 1d7ec5c. Bugbot is set up for automated code reviews on this repo. Configure here.

Sameerlite and others added 4 commits June 2, 2026 20:58
…ration

Load from the catalog API with Vite proxy support, show exact pricing for chat,
image, audio transcription, and TTS models, and render mode-specific quick start snippets.

Co-authored-by: Cursor <cursoragent@cursor.com>
Highlight SDK/proxy code via :global CSS for {@html} snippets. Add audio
transcription and TTS proxy curl examples alongside existing mode-specific SDK samples.

Co-authored-by: Cursor <cursoragent@cursor.com>
- Fix double HTML escaping of model names in JSON-body proxy curl
  snippets: pass the raw model into curlStr (which escapes once) instead
  of the pre-escaped modelStr (image_generation, embedding, audio_speech,
  chat completion). The pre-escaped modelStr is retained for the
  non-curlStr "# Start proxy" comment.
- Make imageModeOutputSortValue consistent with imageModeInputSortValue:
  return a present output pricing slot's sort value unconditionally so a
  genuine $0.00 slot sorts as $0.00 instead of falling back to other
  pricing fields.
- Remove unreachable {:else if isAudioPricingMode(mode)} pricing-heading
  branch (audio_speech / audio_transcription are already matched above).
- Remove unused formatUsdFlat and formatUsdPerMillion helpers.
@mateo-berri

Copy link
Copy Markdown
Collaborator Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Per-pixel slot sort mismatch
    • Added an imageSlotSortValue helper that, for per_pixel pricing slots, multiplies amount_usd by width*height (from the model name) or 1e6 fallback so input/output sort values match the per-image or /Mpx amounts shown by imageSlotCost.
  • ✅ Fixed: Schema row provider filter gap
    • Updated providerOk to also pass when schema is true, mirroring the existing token-filter exemption so the sample_spec reference row stays visible regardless of the selected provider.
Preview (108582b0b4)
diff --git a/src/App.svelte b/src/App.svelte
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -6,6 +6,30 @@
   import { getProviderInitial, getProviderLogo } from "./providers";
   import ProviderDropdown from "./ProviderDropdown.svelte";
   import { trackSearch } from "./analytics";
+  import {
+    fetchAllCatalogModels,
+  } from "./catalogApi";
+  import {
+    isImagePricingMode,
+    isAudioPricingMode,
+    tableImageInputCost,
+    tableImageOutputCost,
+    imageModeInputSortValue,
+    imageModeOutputSortValue,
+    getImagePricingExtraRows,
+    getLiteLLmSdkSnippet,
+    getLiteLLmSdkSnippetHtml,
+    getLiteLLmProxyCurlSnippet,
+    getLiteLLmProxyCurlSnippetHtml,
+    displayChatInputCost,
+    displayChatOutputCost,
+    displayChatCacheRead,
+    displayChatCacheWrite,
+    chatSortInput,
+    chatSortOutput,
+    chatSortCacheRead,
+    chatSortCacheWrite,
+  } from "./modelPresentation";
 
   type Item = {
     name: string;
@@ -23,6 +47,16 @@
   const RESOURCE_BACKUP_NAME = "model_prices_and_context_window_backup.json";
   const RESOURCE_PATH = `${RESOURCE_NAME}`;
   const RESOURCE_BACKUP_PATH = `litellm/${RESOURCE_BACKUP_NAME}`;
+
+  /** Schema reference row in `model_prices_and_context_window.json` — same id as the JSON key. */
+  const SAMPLE_SPEC_ROW_NAME = "sample_spec";
+
+  /** When true, load from litellm-model-catalog-api (see .env.example). */
+  const useLocalCatalogApi =
+    import.meta.env.VITE_USE_LOCAL_CATALOG_API === "true" ||
+    import.meta.env.VITE_USE_LOCAL_CATALOG_API === "1";
+  /** Optional absolute API origin, e.g. http://127.0.0.1:8000. Empty = same-origin /model_catalog (Vite proxy). */
+  const catalogApiBase = (import.meta.env.VITE_CATALOG_API_BASE as string | undefined)?.replace(/\/$/, "") ?? "";
   let providers: string[] = [];
   let selectedProvider: string = "";
   let maxInputTokens: number | null = null;
@@ -69,33 +103,71 @@
         sha = text;
       });
 
+    function prependSampleSpecRow(
+      rows: Item[],
+      spec: Record<string, unknown> | null | undefined,
+    ): Item[] {
+      if (!spec || typeof spec !== "object") return rows;
+      const refRow: Item = { name: SAMPLE_SPEC_ROW_NAME, ...spec } as Item;
+      return [refRow, ...rows];
+    }
+
+    const finishLoad = (items: Item[]) => {
+      providers = [
+        ...new Set(
+          items
+            .filter((i) => i.name !== SAMPLE_SPEC_ROW_NAME)
+            .map((i) => i.litellm_provider)
+            .filter(Boolean),
+        ),
+      ];
+      providers.sort();
+
+      index = new Fuse(items, {
+        threshold: 0.3,
+        keys: [
+          {
+            name: "name",
+            weight: 1.5,
+          },
+          "mode",
+          "litellm_provider",
+        ],
+      });
+
+      results = items.map((item, refIndex) => ({ item, refIndex }));
+      loading = false;
+    };
+
+    if (useLocalCatalogApi) {
+      fetchAllCatalogModels(catalogApiBase)
+        .then(({ models, sample_spec }) => {
+          finishLoad(prependSampleSpecRow(models as Item[], sample_spec));
+        })
+        .catch((err) => {
+          console.error(err);
+          loading = false;
+        });
+      return;
+    }
+
     fetch(
       `https://raw.githubusercontent.com/${REPO_FULL_NAME}/main/${RESOURCE_PATH}`,
     )
       .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))];
-        providers.sort();
-
-        index = new Fuse(items, {
-          threshold: 0.3,
-          keys: [
-            {
-              name: "name",
-              weight: 1.5,
-            },
-            "mode",
-            "litellm_provider",
-          ],
-        });
-
-        results = items.map((item, refIndex) => ({ item, refIndex }));
-        loading = false;
+        const parsed = JSON.parse(text) as Record<string, unknown>;
+        const spec =
+          parsed.sample_spec != null &&
+          typeof parsed.sample_spec === "object" &&
+          !Array.isArray(parsed.sample_spec)
+            ? (parsed.sample_spec as Record<string, unknown>)
+            : null;
+        const items: Item[] = Object.entries(parsed)
+          .filter(([k]) => k !== "sample_spec")
+          .map(([k, v]: any) => ({ name: k, ...v }));
+        finishLoad(prependSampleSpecRow(items, spec));
       });
   });
 
@@ -200,13 +272,37 @@
   }
 
   function getSortValue(item: any, column: string): number {
+    if (column === "context" && isSampleSpecCatalogRow(item)) {
+      return typeof item.max_input_tokens === "number" ? item.max_input_tokens : 0;
+    }
+    if (isImagePricingMode(item.mode)) {
+      switch (column) {
+        case "context":
+          return item.max_input_tokens || 0;
+        case "input":
+          return imageModeInputSortValue(item);
+        case "output":
+          return imageModeOutputSortValue(item);
+        case "cache_read":
+        case "cache_write":
+          return 0;
+        default:
+          return 0;
+      }
+    }
     switch (column) {
-      case "context": return item.max_input_tokens || 0;
-      case "input": return item.input_cost_per_token || 0;
-      case "output": return item.output_cost_per_token || 0;
-      case "cache_read": return item.cache_read_input_token_cost || 0;
-      case "cache_write": return item.cache_creation_input_token_cost || 0;
-      default: return 0;
+      case "context":
+        return item.max_input_tokens || 0;
+      case "input":
+        return chatSortInput(item);
+      case "output":
+        return chatSortOutput(item);
+      case "cache_read":
+        return chatSortCacheRead(item);
+      case "cache_write":
+        return chatSortCacheWrite(item);
+      default:
+        return 0;
     }
   }
 
@@ -219,13 +315,51 @@
     });
   }
 
-  function formatCost(costPerToken: number | undefined): string {
-    if (!costPerToken) return "—";
-    const perMillion = costPerToken * 1000000;
-    if (perMillion < 0.01) return "<$0.01";
-    return "$" + perMillion.toFixed(2);
+  function isSampleSpecCatalogRow(item: { name: string }): boolean {
+    return item.name === SAMPLE_SPEC_ROW_NAME;
   }
 
+  /** Context column: `sample_spec` shows the catalog hint string (original UI), not "—". */
+  function contextCellForRow(item: Item, max_input_tokens: unknown): string {
+    if (isSampleSpecCatalogRow(item)) {
+      if (typeof max_input_tokens === "string" && max_input_tokens.trim() !== "") {
+        return max_input_tokens;
+      }
+      return "—";
+    }
+    return formatContext(max_input_tokens as number | undefined);
+  }
+
+  /** Model info max input/output: numbers get "tokens" suffix; strings (schema hints) pass through. */
+  function formatDetailTokenField(v: unknown): string {
+    if (v == null || v === "") return "—";
+    if (typeof v === "number" && !Number.isNaN(v)) return v.toLocaleString() + " tokens";
+    if (typeof v === "string") return v;
+    return "—";
+  }
+
+  function tableInputCell(item: any): string {
+    if (isSampleSpecCatalogRow(item)) return "—";
+    return isImagePricingMode(item.mode) ? tableImageInputCost(item) : displayChatInputCost(item);
+  }
+
+  function tableOutputCell(item: any): string {
+    if (isSampleSpecCatalogRow(item)) return "—";
+    return isImagePricingMode(item.mode) ? tableImageOutputCost(item) : displayChatOutputCost(item);
+  }
+
+  function tableCacheReadCell(item: any): string {
+    if (isSampleSpecCatalogRow(item)) return "—";
+    if (isImagePricingMode(item.mode)) return "—";
+    return displayChatCacheRead(item);
+  }
+
+  function tableCacheWriteCell(item: any): string {
+    if (isSampleSpecCatalogRow(item)) return "—";
+    if (isImagePricingMode(item.mode)) return "—";
+    return displayChatCacheWrite(item);
+  }
+
   function formatContext(tokens: number | undefined): string {
     if (!tokens || tokens <= 0) return "—";
     if (tokens >= 1000000) return (tokens / 1000000).toFixed(0) + "M";
@@ -233,18 +367,6 @@
     return tokens.toString();
   }
 
-  function getFeatureBadges(item: any): string[] {
-    const badges: string[] = [];
-    if (item.supports_function_calling) badges.push("Functions");
-    if (item.supports_vision) badges.push("Vision");
-    if (item.supports_response_schema) badges.push("JSON");
-    if (item.supports_tool_choice) badges.push("Tools");
-    if (item.supports_parallel_function_calling) badges.push("Parallel");
-    if (item.supports_audio_input) badges.push("Audio");
-    if (item.supports_prompt_caching) badges.push("Caching");
-    return badges;
-  }
-
   function getModeLabel(mode: string | undefined): string {
     if (!mode) return "";
     const labels: Record<string, string> = {
@@ -252,6 +374,7 @@
       "completion": "Completion",
       "embedding": "Embedding",
       "image_generation": "Image Gen",
+      "image_edit": "Image edit",
       "audio_transcription": "Transcription",
       "audio_speech": "TTS",
       "moderation": "Moderation",
@@ -271,16 +394,22 @@
 
       const allItems = index["_docs"] as Item[];
 
-      filteredResults = allItems.filter(
-        (item) =>
-          (!selectedProvider || item.litellm_provider === selectedProvider) &&
-          (maxInputTokens === null ||
-            (item.max_input_tokens &&
-              item.max_input_tokens >= maxInputTokens)) &&
-          (maxOutputTokens === null ||
-            (item.max_output_tokens &&
-              item.max_output_tokens >= maxOutputTokens)),
-      );
+      filteredResults = allItems.filter((item) => {
+        const schema = item.name === SAMPLE_SPEC_ROW_NAME;
+        const providerOk =
+          !selectedProvider || schema || item.litellm_provider === selectedProvider;
+        const inputOk =
+          maxInputTokens === null ||
+          schema ||
+          (typeof item.max_input_tokens === "number" &&
+            item.max_input_tokens >= maxInputTokens);
+        const outputOk =
+          maxOutputTokens === null ||
+          schema ||
+          (typeof item.max_output_tokens === "number" &&
+            item.max_output_tokens >= maxOutputTokens);
+        return providerOk && inputOk && outputOk;
+      });
 
       if (query) {
         const filteredIndex = new Fuse(filteredResults, {
@@ -478,26 +607,36 @@
               <span class="sort-icon" class:active={sortColumn === "context"} class:desc={sortColumn === "context" && sortDirection === "desc"}>↑</span>
             </th>
             <th class="th-sortable" on:click={() => handleSort("input")}>
-              Input $/M
+              Input cost
               <span class="sort-icon" class:active={sortColumn === "input"} class:desc={sortColumn === "input" && sortDirection === "desc"}>↑</span>
             </th>
             <th class="th-sortable" on:click={() => handleSort("output")}>
-              Output $/M
+              Output cost
               <span class="sort-icon" class:active={sortColumn === "output"} class:desc={sortColumn === "output" && sortDirection === "desc"}>↑</span>
             </th>
-            <th class="th-sortable th-hide-mobile" on:click={() => handleSort("cache_read")}>
-              Cache Read
+            <th class="th-sortable th-hide-mobile" on:click={() => handleSort("cache_read")} title="Prompt cache read (chat models)">
+              Cache read
               <span class="sort-icon" class:active={sortColumn === "cache_read"} class:desc={sortColumn === "cache_read" && sortDirection === "desc"}>↑</span>
             </th>
-            <th class="th-sortable th-hide-mobile" on:click={() => handleSort("cache_write")}>
-              Cache Write
+            <th class="th-sortable th-hide-mobile" on:click={() => handleSort("cache_write")} title="Prompt cache write (chat models)">
+              Cache write
               <span class="sort-icon" class:active={sortColumn === "cache_write"} class:desc={sortColumn === "cache_write" && sortDirection === "desc"}>↑</span>
             </th>
           </tr>
         </thead>
         <tbody>
-          {#each results as { item: { name, mode, litellm_provider, max_input_tokens, max_output_tokens, input_cost_per_token, output_cost_per_token, cache_creation_input_token_cost, cache_read_input_token_cost, supports_function_calling, supports_vision, supports_response_schema, supports_tool_choice, supports_parallel_function_calling, supports_audio_input, supports_prompt_caching, ...data } } (name)}
-            <tr class="model-row" class:expanded={expandedRows.has(name)} on:click={() => toggleRow(name)}>
+          {#each results as { item } (item.name)}
+            {@const name = item.name}
+            {@const mode = item.mode}
+            {@const litellm_provider = item.litellm_provider}
+            {@const max_input_tokens = item.max_input_tokens}
+            {@const max_output_tokens = item.max_output_tokens}
+            <tr
+              class="model-row"
+              class:model-row-schema={name === SAMPLE_SPEC_ROW_NAME}
+              class:expanded={expandedRows.has(name)}
+              on:click={() => toggleRow(name)}
+            >
               <td class="model-name">
                 <div class="model-info">
                   <svg class="expand-icon" class:expanded={expandedRows.has(name)} width="14" height="14" viewBox="0 0 16 16" fill="none">
@@ -526,7 +665,7 @@
                   <div class="model-name-group">
                     <span class="model-title" title={getDisplayModelName(name, litellm_provider)}>{getDisplayModelName(name, litellm_provider)}</span>
                     {#if mode}
-                      <span class="mode-badge">{getModeLabel(mode)}</span>
+                      <span class="mode-badge" class:mode-badge-schema={name === SAMPLE_SPEC_ROW_NAME}>{getModeLabel(mode)}</span>
                     {/if}
                   </div>
                   <button
@@ -546,38 +685,63 @@
                   </button>
                 </div>
               </td>
-              <td class="context-cell">{formatContext(max_input_tokens)}</td>
-              <td class="cost-cell">{formatCost(input_cost_per_token)}</td>
-              <td class="cost-cell">{formatCost(output_cost_per_token)}</td>
-              <td class="cost-cell td-hide-mobile">{formatCost(cache_read_input_token_cost)}</td>
-              <td class="cost-cell td-hide-mobile">{formatCost(cache_creation_input_token_cost)}</td>
+              <td
+                class="context-cell"
+                class:context-cell-schema={name === SAMPLE_SPEC_ROW_NAME}
+                title={name === SAMPLE_SPEC_ROW_NAME && typeof max_input_tokens === "string" ? max_input_tokens : undefined}
+              >{contextCellForRow(item, max_input_tokens)}</td>
+              <td class="cost-cell">{tableInputCell(item)}</td>
+              <td class="cost-cell">{tableOutputCell(item)}</td>
+              <td class="cost-cell td-hide-mobile">{tableCacheReadCell(item)}</td>
+              <td class="cost-cell td-hide-mobile">{tableCacheWriteCell(item)}</td>
             </tr>
             {#if expandedRows.has(name)}
               <tr class="expanded-content" transition:fly={{ y: -10, duration: 200 }}>
                 <td colspan="6">
                   <div class="detail-panel">
                     <div class="detail-grid">
-                      <!-- Pricing Cards -->
                       <div class="detail-section">
-                        <h4 class="detail-heading">Pricing <span class="detail-unit">per 1M tokens</span></h4>
+                        <h4 class="detail-heading">
+                          {#if isSampleSpecCatalogRow(item)}
+                            Field reference <span class="detail-unit">example values and types from the catalog JSON</span>
+                          {:else if isImagePricingMode(mode)}
+                            Image pricing <span class="detail-unit">per image where applicable</span>
+                          {:else if mode === "audio_speech"}
+                            Audio pricing <span class="detail-unit">per character where applicable</span>
+                          {:else if mode === "audio_transcription"}
+                            Audio pricing <span class="detail-unit">per second where applicable</span>
+                          {:else}
+                            Token pricing <span class="detail-unit">per 1M tokens where applicable</span>
+                          {/if}
+                        </h4>
                         <div class="pricing-cards">
                           <div class="pricing-card">
                             <span class="pricing-label">Input</span>
-                            <span class="pricing-value">{formatCost(input_cost_per_token)}</span>
+                            <span class="pricing-value">{tableInputCell(item)}</span>
                           </div>
                           <div class="pricing-card">
                             <span class="pricing-label">Output</span>
-                            <span class="pricing-value">{formatCost(output_cost_per_token)}</span>
+                            <span class="pricing-value">{tableOutputCell(item)}</span>
                           </div>
                           <div class="pricing-card">
-                            <span class="pricing-label">Cache Read</span>
-                            <span class="pricing-value">{formatCost(cache_read_input_token_cost)}</span>
+                            <span class="pricing-label">Cache read</span>
+                            <span class="pricing-value">{tableCacheReadCell(item)}</span>
                           </div>
                           <div class="pricing-card">
-                            <span class="pricing-label">Cache Write</span>
-                            <span class="pricing-value">{formatCost(cache_creation_input_token_cost)}</span>
+                            <span class="pricing-label">Cache write</span>
+                            <span class="pricing-value">{tableCacheWriteCell(item)}</span>
                           </div>
                         </div>
+                        {#if isImagePricingMode(mode)}
+                          {@const imageExtras = getImagePricingExtraRows(item)}
+                          {#if imageExtras.length > 0}
+                            <ul class="pricing-extras">
+                              {#each imageExtras as row}
+                                <li><span class="pricing-extras-label">{row.label}</span> {row.value}</li>
+                              {/each}
+                            </ul>
+                          {/if}
+                        {/if}
                       </div>
 
                       <!-- Model Info -->
@@ -594,27 +758,28 @@
                           </div>
                           <div class="info-row">
                             <span class="info-label">Max Input</span>
-                            <span class="info-value">{max_input_tokens ? max_input_tokens.toLocaleString() + " tokens" : "—"}</span>
+                            <span class="info-value">{formatDetailTokenField(max_input_tokens)}</span>
                           </div>
                           <div class="info-row">
                             <span class="info-label">Max Output</span>
-                            <span class="info-value">{max_output_tokens ? max_output_tokens.toLocaleString() + " tokens" : "—"}</span>
+                            <span class="info-value">{formatDetailTokenField(max_output_tokens)}</span>
                           </div>
                         </div>
                       </div>
 
-                      <!-- Features -->
+                      <!-- Features (chat / completion models) -->
+                      {#if !isImagePricingMode(mode) && !isAudioPricingMode(mode)}
                       <div class="detail-section">
                         <h4 class="detail-heading">Features</h4>
                         <div class="feature-list">
                           {#each [
-                            { key: supports_function_calling, label: "Function Calling" },
-                            { key: supports_vision, label: "Vision" },
-                            { key: supports_response_schema, label: "JSON Mode" },
-                            { key: supports_tool_choice, label: "Tool Choice" },
-                            { key: supports_parallel_function_calling, label: "Parallel Calls" },
-                            { key: supports_audio_input, label: "Audio Input" },
-                            { key: supports_prompt_caching, label: "Prompt Caching" },
+                            { key: item.supports_function_calling, label: "Function Calling" },
+                            { key: item.supports_vision, label: "Vision" },
+                            { key: item.supports_response_schema, label: "JSON Mode" },
+                            { key: item.supports_tool_choice, label: "Tool Choice" },
+                            { key: item.supports_parallel_function_calling, label: "Parallel Calls" },
+                            { key: item.supports_audio_input, label: "Audio Input" },
+                            { key: item.supports_prompt_caching, label: "Prompt Caching" },
                           ] as feature}
                             <div class="feature-item" class:supported={feature.key}>
                               {#if feature.key}
@@ -627,6 +792,7 @@
                           {/each}
                         </div>
                       </div>
+                      {/if}
                     </div>
 
                     <!-- Code snippet with tabs -->
@@ -645,42 +811,31 @@
                           >AI Gateway (Proxy)</button>
                         </div>
                         {#if !codeTabStates[name] || codeTabStates[name] === "sdk"}
-                          <button class="copy-code-btn" on:click|stopPropagation={() => copyToClipboard(`from litellm import completion\n\nresponse = completion(\n    model="${getDisplayModelName(name, litellm_provider)}",\n    messages=[{"role": "user", "content": "Hello!"}]\n)`)}>
+                          <button class="copy-code-btn" on:click|stopPropagation={() => copyToClipboard(getLiteLLmSdkSnippet(mode, getDisplayModelName(name, litellm_provider)))}>
                             {copiedModel.includes("from litellm") ? "Copied!" : "Copy"}
                           </button>
                         {:else}
-                          <button class="copy-code-btn" on:click|stopPropagation={() => copyToClipboard(`curl http://0.0.0.0:4000/v1/chat/completions \\\n  -H "Content-Type: application/json" \\\n  -H "Authorization: Bearer sk-1234" \\\n  -d '{\n    "model": "${getDisplayModelName(name, litellm_provider)}",\n    "messages": [{"role": "user", "content": "Hello!"}]\n  }'`)}>
+                          <button class="copy-code-btn" on:click|stopPropagation={() => copyToClipboard(getLiteLLmProxyCurlSnippet(mode, getDisplayModelName(name, litellm_provider)))}>
                             {copiedModel.includes("curl") ? "Copied!" : "Copy"}
                           </button>
                         {/if}
                       </div>
                       {#if !codeTabStates[name] || codeTabStates[name] === "sdk"}
-                        <pre class="code-snippet"><code><span class="code-kw">from</span> litellm <span class="code-kw">import</span> completion
-
-response = completion(
-    model=<span class="code-str">"{getDisplayModelName(name, litellm_provider)}"</span>,
-    messages=[{`{`}<span class="code-str">"role"</span>: <span class="code-str">"user"</span>, <span class="code-str">"content"</span>: <span class="code-str">"Hello!"</span>{`}`}]
-)</code></pre>
+                        <pre class="code-snippet"><code>{@html getLiteLLmSdkSnippetHtml(mode, getDisplayModelName(name, litellm_provider))}</code></pre>
                       {:else}
-                        <pre class="code-snippet"><code><span class="code-comment"># Start proxy: litellm --model {getDisplayModelName(name, litellm_provider)}</span>
-
-curl http://0.0.0.0:4000/v1/chat/completions \
-  -H <span class="code-str">"Content-Type: application/json"</span> \
-  -H <span class="code-str">"Authorization: Bearer sk-1234"</span> \
-  -d <span class="code-str">'{`{`}
-    "model": "{getDisplayModelName(name, litellm_provider)}",
-    "messages": [{`{`}"role": "user", "content": "Hello!"{`}`}]
-  {`}`}'</span></code></pre>
+                        <pre class="code-snippet"><code>{@html getLiteLLmProxyCurlSnippetHtml(mode, getDisplayModelName(name, litellm_provider))}</code></pre>
                       {/if}
                     </div>
 
                     <!-- Actions -->
+                    {#if name !== SAMPLE_SPEC_ROW_NAME}
                     <div class="detail-actions">
                       <a href={getIssueUrlForFix(name)} target="_blank" rel="noopener noreferrer" class="detail-action-link" on:click|stopPropagation>
                         <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
                         Report incorrect data
                       </a>
                     </div>
+                    {/if}
                   </div>
                 </td>
               </tr>
@@ -1072,11 +1227,14 @@
 
   table {
     width: 100%;
-    border-collapse: collapse;
+    /* separate avoids sticky <th> overlapping first tbody rows (collapse + sticky bug) */
+    border-collapse: separate;
+    border-spacing: 0;
     background: var(--card-bg);
     border-radius: 12px;
     border: 1px solid var(--border-color);
-    overflow: hidden;
+    /* Do not use overflow:hidden here — it breaks position:sticky on <th> and clips header vs body paint. */
+    overflow: visible;
   }
 
   thead {
@@ -1097,7 +1255,8 @@
     user-select: none;
     position: sticky;
     top: 63px;
-    z-index: 10;
+    z-index: 25;
+    box-shadow: 0 1px 0 var(--border-color);
   }
 
   .th-model { padding-left: 1rem; }
@@ -1125,8 +1284,14 @@
     border-bottom: 1px solid var(--border-color);
     transition: background-color 0.1s ease;
     cursor: pointer;
+    position: relative;
+    z-index: 0;
   }
 
+  tbody tr.model-row-schema td {
+    vertical-align: top;
+  }
+
   tbody tr.model-row:hover {
     background-color: var(--hover-bg);
     box-shadow: inset 3px 0 0 var(--litellm-primary);
@@ -1248,12 +1413,31 @@
     flex-shrink: 0;
   }
 
+  .mode-badge.mode-badge-schema {
+    white-space: normal;
+    max-width: min(40rem, 92vw);
+    line-height: 1.35;
+    text-transform: none;
+    letter-spacing: normal;
+    font-weight: 500;
+    font-size: 0.625rem;
+  }
+
   .context-cell {
     font-weight: 600;
     font-variant-numeric: tabular-nums;
     font-size: 0.8125rem;
   }
 
+  .context-cell.context-cell-schema {
+    white-space: normal;
+    font-weight: 400;
+    font-size: 0.75rem;
+    line-height: 1.4;
+    color: var(--text-secondary);
+    max-width: 18rem;
+  }
+
   .cost-cell {
     color: var(--text-secondary);
     font-variant-numeric: tabular-nums;
@@ -1322,6 +1506,29 @@
     font-variant-numeric: tabular-nums;
   }
 
+  .pricing-empty {
+    margin: 0;
+    font-size: 0.875rem;
+    color: var(--muted-color);
+  }
+
+  .pricing-extras {
+    margin: 0.625rem 0 0;
+    padding: 0;
+    list-style: none;
+    display: flex;
+    flex-direction: column;
+    gap: 0.35rem;
+    font-size: 0.8125rem;
+    color: var(--muted-color);
+  }
+
+  .pricing-extras-label {
+    font-weight: 600;
+    color: var(--text-color);
+    opacity: 0.85;
+  }
+
   .info-rows {
     display: flex;
     flex-direction: column;
@@ -1348,6 +1555,8 @@
     font-weight: 600;
     color: var(--text-color);
     font-family: 'JetBrains Mono', monospace;
+    overflow-wrap: anywhere;
+    word-break: break-word;
   }
 
   .feature-list {
@@ -1451,13 +1660,26 @@
     color: var(--code-text);
   }
 
-  .code-snippet code { display: block; }
-  .code-kw { color: #8b5cf6; }
-  .code-str { color: #10b981; }
+  .code-snippet code { display: block; white-space: pre; }
 
+  /* {@html} snippets are not scoped — use :global so .code-kw / .code-str apply */
+  .code-snippet :global(.code-kw) {
+    color: #8b5cf6;
+  }
+  .code-snippet :global(.code-str) {
+    color: #10b981;
+  }
+  .code-snippet :global(.code-comment) {
+    color: var(--muted-color);
+  }
+
   @media (prefers-color-scheme: dark) {
-    .code-kw { color: #a78bfa; }
-    .code-str { color: #34d399; }
+    .code-snippet :global(.code-kw) {
+      color: #a78bfa;
+    }
+    .code-snippet :global(.code-str) {
+      color: #34d399;
+    }
   }
 
   .detail-actions {

diff --git a/src/catalogApi.ts b/src/catalogApi.ts
new file mode 100644
--- /dev/null
+++ b/src/catalogApi.ts
@@ -1,0 +1,154 @@
+/**
+ * Load models from litellm-model-catalog-api (paginated) and map to the
+ * flat item shape the UI expects (name + litellm_provider).
+ */
+
+export type PricingSlot = {
+  amount_usd: number | null;
+  unit: string | null;
+  source_field: string | null;
+};
+
+export type PricingSlots = {
+  input: PricingSlot;
+  output: PricingSlot;
+  cache_read: PricingSlot;
+  cache_write: PricingSlot;
+};
+
+export type CatalogApiEntry = {
+  id: string;
+  provider?: string | null;
+  pricing_slots?: PricingSlots;
+  [key: string]: unknown;
+};
+
+function isRecord(v: unknown): v is Record<string, unknown> {
+  return v !== null && typeof v === "object" && !Array.isArray(v);
+}
+
+function asPricingSlot(v: unknown): PricingSlot {
+  if (!isRecord(v)) return { amount_usd: null, unit: null, source_field: null };
+  const amount = v.amount_usd;
+  return {
+    amount_usd: typeof amount === "number" ? amount : amount != null ? Number(amount) : null,
+    unit: typeof v.unit === "string" ? v.unit : null,
+    source_field: typeof v.source_field === "string" ? v.source_field : null,
+  };
+}
+
+export function normalizePricingSlots(raw: unknown): PricingSlots | undefined {
+  if (!isRecord(raw)) return undefined;
+  return {
+    input: asPricingSlot(raw.input),
+    output: asPricingSlot(raw.output),
+    cache_read: asPricingSlot(raw.cache_read),
+    cache_write: asPricingSlot(raw.cache_write),
+  };
+}
+
+/** Map one API catalog row to UI item (GitHub JSON shape + pricing_slots). */
+export function mapCatalogEntryToItem(entry: CatalogApiEntry): Record<string, unknown> {
+  const { id, provider, pricing_slots, object: _object, ...rest } = entry;
+  const slots = normalizePricingSlots(pricing_slots);
+  return {
+    ...rest,
+    name: id,
+    litellm_provider: (provider as string) ?? (rest.litellm_provider as string) ?? "",
+    pricing_slots: slots,
+  };
+}
+
+/** Format a USD amount exactly as in the catalog — no "<$0.01" floors. */
+export function formatExactUsd(n: number, suffix = ""): string {
+  if (Number.isNaN(n)) return "—";
+  if (n === 0) return "$0.00" + suffix;
+  const abs = Math.abs(n);
+  const decimals = abs >= 0.01 ? 2 : 10;
+  let s = n.toFixed(decimals);
+  if (abs < 0.01) {
+    s = s.replace(/\.?0+$/, "");
+  }
+  return "$" + s + suffix;
+}
+
+/** Per-token catalog field → exact per-1M-token display. */
+export function formatTokenCostPerMillion(perToken: number | null | undefined): string {
+  if (perToken === null || perToken === undefined) return "—";
+  return formatExactUsd(perToken * 1e6, "/M");
+}
+
+export function formatPricingSlot(slot: PricingSlot | undefined): string {
+  if (!slot || slot.amount_usd == null || Number.isNaN(slot.amount_usd)) return "—";
+  const u = slot.unit ?? "";
+  const n = slot.amount_usd;
+  if (u === "per_1m_tokens" || u === "per_1m_reasoning_tokens" || u === "per_1m_image_tokens" || u === "per_1m_audio_tokens") {
+    return formatExactUsd(n, "/M");
+  }
+  if (u === "per_image") {
+    return formatExactUsd(n, "/img");
+  }
+  if (u === "per_pixel") {
+    return formatExactUsd(n * 1e6, "/Mpx");
+  }
+  if (u === "per_character") {
+    return formatExactUsd(n, "/char");
+  }
+  if (u === "per_query") {
+    return formatExactUsd(n, "/q");
+  }
+  if (u === "per_request") {
+    return formatExactUsd(n, "/req");
+  }
+  if (u === "per_second" || u === "per_second_video") {
+    return formatExactUsd(n, "/s");
+  }
+  return formatExactUsd(n);
+}
+
+export function slotSortValue(slot: PricingSlot | undefined): number {
+  if (!slot || slot.amount_usd == null || Number.isNaN(slot.amount_usd)) return 0;
+  return slot.amount_usd;
+}
+
+export type CatalogFetchResult = {
+  models: Record<string, unknown>[];
+  /** Present when the API returns it (page 1 of list); schema reference from source JSON. */
+  sample_spec: Record<string, unknown> | null;
... diff truncated: showing 800 of 1455 lines

You can send follow-ups to the cloud agent here.

Comment thread src/modelPresentation.ts
Comment thread src/App.svelte
@mateo-berri mateo-berri marked this pull request as ready for review June 2, 2026 23:36
@mateo-berri

Copy link
Copy Markdown
Collaborator Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Sort ignores legacy slot pricing
    • Aligned the slot-branch guard in imageModeInputSortValue, imageModeOutputSortValue, chatSortCacheRead, and chatSortCacheWrite to require amount_usd != null (matching the display helpers) so partial slots with only a unit fall through to the legacy catalog fields used for rendering.
Preview (fadb39c7fb)
diff --git a/src/App.svelte b/src/App.svelte
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -6,6 +6,30 @@
   import { getProviderInitial, getProviderLogo } from "./providers";
   import ProviderDropdown from "./ProviderDropdown.svelte";
   import { trackSearch } from "./analytics";
+  import {
+    fetchAllCatalogModels,
+  } from "./catalogApi";
+  import {
+    isImagePricingMode,
+    isAudioPricingMode,
+    tableImageInputCost,
+    tableImageOutputCost,
+    imageModeInputSortValue,
+    imageModeOutputSortValue,
+    getImagePricingExtraRows,
+    getLiteLLmSdkSnippet,
+    getLiteLLmSdkSnippetHtml,
+    getLiteLLmProxyCurlSnippet,
+    getLiteLLmProxyCurlSnippetHtml,
+    displayChatInputCost,
+    displayChatOutputCost,
+    displayChatCacheRead,
+    displayChatCacheWrite,
+    chatSortInput,
+    chatSortOutput,
+    chatSortCacheRead,
+    chatSortCacheWrite,
+  } from "./modelPresentation";
 
   type Item = {
     name: string;
@@ -23,6 +47,16 @@
   const RESOURCE_BACKUP_NAME = "model_prices_and_context_window_backup.json";
   const RESOURCE_PATH = `${RESOURCE_NAME}`;
   const RESOURCE_BACKUP_PATH = `litellm/${RESOURCE_BACKUP_NAME}`;
+
+  /** Schema reference row in `model_prices_and_context_window.json` — same id as the JSON key. */
+  const SAMPLE_SPEC_ROW_NAME = "sample_spec";
+
+  /** When true, load from litellm-model-catalog-api (see .env.example). */
+  const useLocalCatalogApi =
+    import.meta.env.VITE_USE_LOCAL_CATALOG_API === "true" ||
+    import.meta.env.VITE_USE_LOCAL_CATALOG_API === "1";
+  /** Optional absolute API origin, e.g. http://127.0.0.1:8000. Empty = same-origin /model_catalog (Vite proxy). */
+  const catalogApiBase = (import.meta.env.VITE_CATALOG_API_BASE as string | undefined)?.replace(/\/$/, "") ?? "";
   let providers: string[] = [];
   let selectedProvider: string = "";
   let maxInputTokens: number | null = null;
@@ -69,33 +103,71 @@
         sha = text;
       });
 
+    function prependSampleSpecRow(
+      rows: Item[],
+      spec: Record<string, unknown> | null | undefined,
+    ): Item[] {
+      if (!spec || typeof spec !== "object") return rows;
+      const refRow: Item = { name: SAMPLE_SPEC_ROW_NAME, ...spec } as Item;
+      return [refRow, ...rows];
+    }
+
+    const finishLoad = (items: Item[]) => {
+      providers = [
+        ...new Set(
+          items
+            .filter((i) => i.name !== SAMPLE_SPEC_ROW_NAME)
+            .map((i) => i.litellm_provider)
+            .filter(Boolean),
+        ),
+      ];
+      providers.sort();
+
+      index = new Fuse(items, {
+        threshold: 0.3,
+        keys: [
+          {
+            name: "name",
+            weight: 1.5,
+          },
+          "mode",
+          "litellm_provider",
+        ],
+      });
+
+      results = items.map((item, refIndex) => ({ item, refIndex }));
+      loading = false;
+    };
+
+    if (useLocalCatalogApi) {
+      fetchAllCatalogModels(catalogApiBase)
+        .then(({ models, sample_spec }) => {
+          finishLoad(prependSampleSpecRow(models as Item[], sample_spec));
+        })
+        .catch((err) => {
+          console.error(err);
+          loading = false;
+        });
+      return;
+    }
+
     fetch(
       `https://raw.githubusercontent.com/${REPO_FULL_NAME}/main/${RESOURCE_PATH}`,
     )
       .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))];
-        providers.sort();
-
-        index = new Fuse(items, {
-          threshold: 0.3,
-          keys: [
-            {
-              name: "name",
-              weight: 1.5,
-            },
-            "mode",
-            "litellm_provider",
-          ],
-        });
-
-        results = items.map((item, refIndex) => ({ item, refIndex }));
-        loading = false;
+        const parsed = JSON.parse(text) as Record<string, unknown>;
+        const spec =
+          parsed.sample_spec != null &&
+          typeof parsed.sample_spec === "object" &&
+          !Array.isArray(parsed.sample_spec)
+            ? (parsed.sample_spec as Record<string, unknown>)
+            : null;
+        const items: Item[] = Object.entries(parsed)
+          .filter(([k]) => k !== "sample_spec")
+          .map(([k, v]: any) => ({ name: k, ...v }));
+        finishLoad(prependSampleSpecRow(items, spec));
       });
   });
 
@@ -200,13 +272,37 @@
   }
 
   function getSortValue(item: any, column: string): number {
+    if (column === "context" && isSampleSpecCatalogRow(item)) {
+      return typeof item.max_input_tokens === "number" ? item.max_input_tokens : 0;
+    }
+    if (isImagePricingMode(item.mode)) {
+      switch (column) {
+        case "context":
+          return item.max_input_tokens || 0;
+        case "input":
+          return imageModeInputSortValue(item);
+        case "output":
+          return imageModeOutputSortValue(item);
+        case "cache_read":
+        case "cache_write":
+          return 0;
+        default:
+          return 0;
+      }
+    }
     switch (column) {
-      case "context": return item.max_input_tokens || 0;
-      case "input": return item.input_cost_per_token || 0;
-      case "output": return item.output_cost_per_token || 0;
-      case "cache_read": return item.cache_read_input_token_cost || 0;
-      case "cache_write": return item.cache_creation_input_token_cost || 0;
-      default: return 0;
+      case "context":
+        return item.max_input_tokens || 0;
+      case "input":
+        return chatSortInput(item);
+      case "output":
+        return chatSortOutput(item);
+      case "cache_read":
+        return chatSortCacheRead(item);
+      case "cache_write":
+        return chatSortCacheWrite(item);
+      default:
+        return 0;
     }
   }
 
@@ -219,13 +315,51 @@
     });
   }
 
-  function formatCost(costPerToken: number | undefined): string {
-    if (!costPerToken) return "—";
-    const perMillion = costPerToken * 1000000;
-    if (perMillion < 0.01) return "<$0.01";
-    return "$" + perMillion.toFixed(2);
+  function isSampleSpecCatalogRow(item: { name: string }): boolean {
+    return item.name === SAMPLE_SPEC_ROW_NAME;
   }
 
+  /** Context column: `sample_spec` shows the catalog hint string (original UI), not "—". */
+  function contextCellForRow(item: Item, max_input_tokens: unknown): string {
+    if (isSampleSpecCatalogRow(item)) {
+      if (typeof max_input_tokens === "string" && max_input_tokens.trim() !== "") {
+        return max_input_tokens;
+      }
+      return "—";
+    }
+    return formatContext(max_input_tokens as number | undefined);
+  }
+
+  /** Model info max input/output: numbers get "tokens" suffix; strings (schema hints) pass through. */
+  function formatDetailTokenField(v: unknown): string {
+    if (v == null || v === "") return "—";
+    if (typeof v === "number" && !Number.isNaN(v)) return v.toLocaleString() + " tokens";
+    if (typeof v === "string") return v;
+    return "—";
+  }
+
+  function tableInputCell(item: any): string {
+    if (isSampleSpecCatalogRow(item)) return "—";
+    return isImagePricingMode(item.mode) ? tableImageInputCost(item) : displayChatInputCost(item);
+  }
+
+  function tableOutputCell(item: any): string {
+    if (isSampleSpecCatalogRow(item)) return "—";
+    return isImagePricingMode(item.mode) ? tableImageOutputCost(item) : displayChatOutputCost(item);
+  }
+
+  function tableCacheReadCell(item: any): string {
+    if (isSampleSpecCatalogRow(item)) return "—";
+    if (isImagePricingMode(item.mode)) return "—";
+    return displayChatCacheRead(item);
+  }
+
+  function tableCacheWriteCell(item: any): string {
+    if (isSampleSpecCatalogRow(item)) return "—";
+    if (isImagePricingMode(item.mode)) return "—";
+    return displayChatCacheWrite(item);
+  }
+
   function formatContext(tokens: number | undefined): string {
     if (!tokens || tokens <= 0) return "—";
     if (tokens >= 1000000) return (tokens / 1000000).toFixed(0) + "M";
@@ -233,18 +367,6 @@
     return tokens.toString();
   }
 
-  function getFeatureBadges(item: any): string[] {
-    const badges: string[] = [];
-    if (item.supports_function_calling) badges.push("Functions");
-    if (item.supports_vision) badges.push("Vision");
-    if (item.supports_response_schema) badges.push("JSON");
-    if (item.supports_tool_choice) badges.push("Tools");
-    if (item.supports_parallel_function_calling) badges.push("Parallel");
-    if (item.supports_audio_input) badges.push("Audio");
-    if (item.supports_prompt_caching) badges.push("Caching");
-    return badges;
-  }
-
   function getModeLabel(mode: string | undefined): string {
     if (!mode) return "";
     const labels: Record<string, string> = {
@@ -252,6 +374,7 @@
       "completion": "Completion",
       "embedding": "Embedding",
       "image_generation": "Image Gen",
+      "image_edit": "Image edit",
       "audio_transcription": "Transcription",
       "audio_speech": "TTS",
       "moderation": "Moderation",
@@ -271,16 +394,22 @@
 
       const allItems = index["_docs"] as Item[];
 
-      filteredResults = allItems.filter(
-        (item) =>
-          (!selectedProvider || item.litellm_provider === selectedProvider) &&
-          (maxInputTokens === null ||
-            (item.max_input_tokens &&
-              item.max_input_tokens >= maxInputTokens)) &&
-          (maxOutputTokens === null ||
-            (item.max_output_tokens &&
-              item.max_output_tokens >= maxOutputTokens)),
-      );
+      filteredResults = allItems.filter((item) => {
+        const schema = item.name === SAMPLE_SPEC_ROW_NAME;
+        const providerOk =
+          !selectedProvider || schema || item.litellm_provider === selectedProvider;
+        const inputOk =
+          maxInputTokens === null ||
+          schema ||
+          (typeof item.max_input_tokens === "number" &&
+            item.max_input_tokens >= maxInputTokens);
+        const outputOk =
+          maxOutputTokens === null ||
+          schema ||
+          (typeof item.max_output_tokens === "number" &&
+            item.max_output_tokens >= maxOutputTokens);
+        return providerOk && inputOk && outputOk;
+      });
 
       if (query) {
         const filteredIndex = new Fuse(filteredResults, {
@@ -478,26 +607,36 @@
               <span class="sort-icon" class:active={sortColumn === "context"} class:desc={sortColumn === "context" && sortDirection === "desc"}>↑</span>
             </th>
             <th class="th-sortable" on:click={() => handleSort("input")}>
-              Input $/M
+              Input cost
               <span class="sort-icon" class:active={sortColumn === "input"} class:desc={sortColumn === "input" && sortDirection === "desc"}>↑</span>
             </th>
             <th class="th-sortable" on:click={() => handleSort("output")}>
-              Output $/M
+              Output cost
               <span class="sort-icon" class:active={sortColumn === "output"} class:desc={sortColumn === "output" && sortDirection === "desc"}>↑</span>
             </th>
-            <th class="th-sortable th-hide-mobile" on:click={() => handleSort("cache_read")}>
-              Cache Read
+            <th class="th-sortable th-hide-mobile" on:click={() => handleSort("cache_read")} title="Prompt cache read (chat models)">
+              Cache read
               <span class="sort-icon" class:active={sortColumn === "cache_read"} class:desc={sortColumn === "cache_read" && sortDirection === "desc"}>↑</span>
             </th>
-            <th class="th-sortable th-hide-mobile" on:click={() => handleSort("cache_write")}>
-              Cache Write
+            <th class="th-sortable th-hide-mobile" on:click={() => handleSort("cache_write")} title="Prompt cache write (chat models)">
+              Cache write
               <span class="sort-icon" class:active={sortColumn === "cache_write"} class:desc={sortColumn === "cache_write" && sortDirection === "desc"}>↑</span>
             </th>
           </tr>
         </thead>
         <tbody>
-          {#each results as { item: { name, mode, litellm_provider, max_input_tokens, max_output_tokens, input_cost_per_token, output_cost_per_token, cache_creation_input_token_cost, cache_read_input_token_cost, supports_function_calling, supports_vision, supports_response_schema, supports_tool_choice, supports_parallel_function_calling, supports_audio_input, supports_prompt_caching, ...data } } (name)}
-            <tr class="model-row" class:expanded={expandedRows.has(name)} on:click={() => toggleRow(name)}>
+          {#each results as { item } (item.name)}
+            {@const name = item.name}
+            {@const mode = item.mode}
+            {@const litellm_provider = item.litellm_provider}
+            {@const max_input_tokens = item.max_input_tokens}
+            {@const max_output_tokens = item.max_output_tokens}
+            <tr
+              class="model-row"
+              class:model-row-schema={name === SAMPLE_SPEC_ROW_NAME}
+              class:expanded={expandedRows.has(name)}
+              on:click={() => toggleRow(name)}
+            >
               <td class="model-name">
                 <div class="model-info">
                   <svg class="expand-icon" class:expanded={expandedRows.has(name)} width="14" height="14" viewBox="0 0 16 16" fill="none">
@@ -526,7 +665,7 @@
                   <div class="model-name-group">
                     <span class="model-title" title={getDisplayModelName(name, litellm_provider)}>{getDisplayModelName(name, litellm_provider)}</span>
                     {#if mode}
-                      <span class="mode-badge">{getModeLabel(mode)}</span>
+                      <span class="mode-badge" class:mode-badge-schema={name === SAMPLE_SPEC_ROW_NAME}>{getModeLabel(mode)}</span>
                     {/if}
                   </div>
                   <button
@@ -546,38 +685,63 @@
                   </button>
                 </div>
               </td>
-              <td class="context-cell">{formatContext(max_input_tokens)}</td>
-              <td class="cost-cell">{formatCost(input_cost_per_token)}</td>
-              <td class="cost-cell">{formatCost(output_cost_per_token)}</td>
-              <td class="cost-cell td-hide-mobile">{formatCost(cache_read_input_token_cost)}</td>
-              <td class="cost-cell td-hide-mobile">{formatCost(cache_creation_input_token_cost)}</td>
+              <td
+                class="context-cell"
+                class:context-cell-schema={name === SAMPLE_SPEC_ROW_NAME}
+                title={name === SAMPLE_SPEC_ROW_NAME && typeof max_input_tokens === "string" ? max_input_tokens : undefined}
+              >{contextCellForRow(item, max_input_tokens)}</td>
+              <td class="cost-cell">{tableInputCell(item)}</td>
+              <td class="cost-cell">{tableOutputCell(item)}</td>
+              <td class="cost-cell td-hide-mobile">{tableCacheReadCell(item)}</td>
+              <td class="cost-cell td-hide-mobile">{tableCacheWriteCell(item)}</td>
             </tr>
             {#if expandedRows.has(name)}
               <tr class="expanded-content" transition:fly={{ y: -10, duration: 200 }}>
                 <td colspan="6">
                   <div class="detail-panel">
                     <div class="detail-grid">
-                      <!-- Pricing Cards -->
                       <div class="detail-section">
-                        <h4 class="detail-heading">Pricing <span class="detail-unit">per 1M tokens</span></h4>
+                        <h4 class="detail-heading">
+                          {#if isSampleSpecCatalogRow(item)}
+                            Field reference <span class="detail-unit">example values and types from the catalog JSON</span>
+                          {:else if isImagePricingMode(mode)}
+                            Image pricing <span class="detail-unit">per image where applicable</span>
+                          {:else if mode === "audio_speech"}
+                            Audio pricing <span class="detail-unit">per character where applicable</span>
+                          {:else if mode === "audio_transcription"}
+                            Audio pricing <span class="detail-unit">per second where applicable</span>
+                          {:else}
+                            Token pricing <span class="detail-unit">per 1M tokens where applicable</span>
+                          {/if}
+                        </h4>
                         <div class="pricing-cards">
                           <div class="pricing-card">
                             <span class="pricing-label">Input</span>
-                            <span class="pricing-value">{formatCost(input_cost_per_token)}</span>
+                            <span class="pricing-value">{tableInputCell(item)}</span>
                           </div>
                           <div class="pricing-card">
                             <span class="pricing-label">Output</span>
-                            <span class="pricing-value">{formatCost(output_cost_per_token)}</span>
+                            <span class="pricing-value">{tableOutputCell(item)}</span>
                           </div>
                           <div class="pricing-card">
-                            <span class="pricing-label">Cache Read</span>
-                            <span class="pricing-value">{formatCost(cache_read_input_token_cost)}</span>
+                            <span class="pricing-label">Cache read</span>
+                            <span class="pricing-value">{tableCacheReadCell(item)}</span>
                           </div>
                           <div class="pricing-card">
-                            <span class="pricing-label">Cache Write</span>
-                            <span class="pricing-value">{formatCost(cache_creation_input_token_cost)}</span>
+                            <span class="pricing-label">Cache write</span>
+                            <span class="pricing-value">{tableCacheWriteCell(item)}</span>
                           </div>
                         </div>
+                        {#if isImagePricingMode(mode)}
+                          {@const imageExtras = getImagePricingExtraRows(item)}
+                          {#if imageExtras.length > 0}
+                            <ul class="pricing-extras">
+                              {#each imageExtras as row}
+                                <li><span class="pricing-extras-label">{row.label}</span> {row.value}</li>
+                              {/each}
+                            </ul>
+                          {/if}
+                        {/if}
                       </div>
 
                       <!-- Model Info -->
@@ -594,27 +758,28 @@
                           </div>
                           <div class="info-row">
                             <span class="info-label">Max Input</span>
-                            <span class="info-value">{max_input_tokens ? max_input_tokens.toLocaleString() + " tokens" : "—"}</span>
+                            <span class="info-value">{formatDetailTokenField(max_input_tokens)}</span>
                           </div>
                           <div class="info-row">
                             <span class="info-label">Max Output</span>
-                            <span class="info-value">{max_output_tokens ? max_output_tokens.toLocaleString() + " tokens" : "—"}</span>
+                            <span class="info-value">{formatDetailTokenField(max_output_tokens)}</span>
                           </div>
                         </div>
                       </div>
 
-                      <!-- Features -->
+                      <!-- Features (chat / completion models) -->
+                      {#if !isImagePricingMode(mode) && !isAudioPricingMode(mode)}
                       <div class="detail-section">
                         <h4 class="detail-heading">Features</h4>
                         <div class="feature-list">
                           {#each [
-                            { key: supports_function_calling, label: "Function Calling" },
-                            { key: supports_vision, label: "Vision" },
-                            { key: supports_response_schema, label: "JSON Mode" },
-                            { key: supports_tool_choice, label: "Tool Choice" },
-                            { key: supports_parallel_function_calling, label: "Parallel Calls" },
-                            { key: supports_audio_input, label: "Audio Input" },
-                            { key: supports_prompt_caching, label: "Prompt Caching" },
+                            { key: item.supports_function_calling, label: "Function Calling" },
+                            { key: item.supports_vision, label: "Vision" },
+                            { key: item.supports_response_schema, label: "JSON Mode" },
+                            { key: item.supports_tool_choice, label: "Tool Choice" },
+                            { key: item.supports_parallel_function_calling, label: "Parallel Calls" },
+                            { key: item.supports_audio_input, label: "Audio Input" },
+                            { key: item.supports_prompt_caching, label: "Prompt Caching" },
                           ] as feature}
                             <div class="feature-item" class:supported={feature.key}>
                               {#if feature.key}
@@ -627,6 +792,7 @@
                           {/each}
                         </div>
                       </div>
+                      {/if}
                     </div>
 
                     <!-- Code snippet with tabs -->
@@ -645,42 +811,31 @@
                           >AI Gateway (Proxy)</button>
                         </div>
                         {#if !codeTabStates[name] || codeTabStates[name] === "sdk"}
-                          <button class="copy-code-btn" on:click|stopPropagation={() => copyToClipboard(`from litellm import completion\n\nresponse = completion(\n    model="${getDisplayModelName(name, litellm_provider)}",\n    messages=[{"role": "user", "content": "Hello!"}]\n)`)}>
+                          <button class="copy-code-btn" on:click|stopPropagation={() => copyToClipboard(getLiteLLmSdkSnippet(mode, getDisplayModelName(name, litellm_provider)))}>
                             {copiedModel.includes("from litellm") ? "Copied!" : "Copy"}
                           </button>
                         {:else}
-                          <button class="copy-code-btn" on:click|stopPropagation={() => copyToClipboard(`curl http://0.0.0.0:4000/v1/chat/completions \\\n  -H "Content-Type: application/json" \\\n  -H "Authorization: Bearer sk-1234" \\\n  -d '{\n    "model": "${getDisplayModelName(name, litellm_provider)}",\n    "messages": [{"role": "user", "content": "Hello!"}]\n  }'`)}>
+                          <button class="copy-code-btn" on:click|stopPropagation={() => copyToClipboard(getLiteLLmProxyCurlSnippet(mode, getDisplayModelName(name, litellm_provider)))}>
                             {copiedModel.includes("curl") ? "Copied!" : "Copy"}
                           </button>
                         {/if}
                       </div>
                       {#if !codeTabStates[name] || codeTabStates[name] === "sdk"}
-                        <pre class="code-snippet"><code><span class="code-kw">from</span> litellm <span class="code-kw">import</span> completion
-
-response = completion(
-    model=<span class="code-str">"{getDisplayModelName(name, litellm_provider)}"</span>,
-    messages=[{`{`}<span class="code-str">"role"</span>: <span class="code-str">"user"</span>, <span class="code-str">"content"</span>: <span class="code-str">"Hello!"</span>{`}`}]
-)</code></pre>
+                        <pre class="code-snippet"><code>{@html getLiteLLmSdkSnippetHtml(mode, getDisplayModelName(name, litellm_provider))}</code></pre>
                       {:else}
-                        <pre class="code-snippet"><code><span class="code-comment"># Start proxy: litellm --model {getDisplayModelName(name, litellm_provider)}</span>
-
-curl http://0.0.0.0:4000/v1/chat/completions \
-  -H <span class="code-str">"Content-Type: application/json"</span> \
-  -H <span class="code-str">"Authorization: Bearer sk-1234"</span> \
-  -d <span class="code-str">'{`{`}
-    "model": "{getDisplayModelName(name, litellm_provider)}",
-    "messages": [{`{`}"role": "user", "content": "Hello!"{`}`}]
-  {`}`}'</span></code></pre>
+                        <pre class="code-snippet"><code>{@html getLiteLLmProxyCurlSnippetHtml(mode, getDisplayModelName(name, litellm_provider))}</code></pre>
                       {/if}
                     </div>
 
                     <!-- Actions -->
+                    {#if name !== SAMPLE_SPEC_ROW_NAME}
                     <div class="detail-actions">
                       <a href={getIssueUrlForFix(name)} target="_blank" rel="noopener noreferrer" class="detail-action-link" on:click|stopPropagation>
                         <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
                         Report incorrect data
                       </a>
                     </div>
+                    {/if}
                   </div>
                 </td>
               </tr>
@@ -1072,11 +1227,14 @@
 
   table {
     width: 100%;
-    border-collapse: collapse;
+    /* separate avoids sticky <th> overlapping first tbody rows (collapse + sticky bug) */
+    border-collapse: separate;
+    border-spacing: 0;
     background: var(--card-bg);
     border-radius: 12px;
     border: 1px solid var(--border-color);
-    overflow: hidden;
+    /* Do not use overflow:hidden here — it breaks position:sticky on <th> and clips header vs body paint. */
+    overflow: visible;
   }
 
   thead {
@@ -1097,7 +1255,8 @@
     user-select: none;
     position: sticky;
     top: 63px;
-    z-index: 10;
+    z-index: 25;
+    box-shadow: 0 1px 0 var(--border-color);
   }
 
   .th-model { padding-left: 1rem; }
@@ -1125,8 +1284,14 @@
     border-bottom: 1px solid var(--border-color);
     transition: background-color 0.1s ease;
     cursor: pointer;
+    position: relative;
+    z-index: 0;
   }
 
+  tbody tr.model-row-schema td {
+    vertical-align: top;
+  }
+
   tbody tr.model-row:hover {
     background-color: var(--hover-bg);
     box-shadow: inset 3px 0 0 var(--litellm-primary);
@@ -1248,12 +1413,31 @@
     flex-shrink: 0;
   }
 
+  .mode-badge.mode-badge-schema {
+    white-space: normal;
+    max-width: min(40rem, 92vw);
+    line-height: 1.35;
+    text-transform: none;
+    letter-spacing: normal;
+    font-weight: 500;
+    font-size: 0.625rem;
+  }
+
   .context-cell {
     font-weight: 600;
     font-variant-numeric: tabular-nums;
     font-size: 0.8125rem;
   }
 
+  .context-cell.context-cell-schema {
+    white-space: normal;
+    font-weight: 400;
+    font-size: 0.75rem;
+    line-height: 1.4;
+    color: var(--text-secondary);
+    max-width: 18rem;
+  }
+
   .cost-cell {
     color: var(--text-secondary);
     font-variant-numeric: tabular-nums;
@@ -1322,6 +1506,29 @@
     font-variant-numeric: tabular-nums;
   }
 
+  .pricing-empty {
+    margin: 0;
+    font-size: 0.875rem;
+    color: var(--muted-color);
+  }
+
+  .pricing-extras {
+    margin: 0.625rem 0 0;
+    padding: 0;
+    list-style: none;
+    display: flex;
+    flex-direction: column;
+    gap: 0.35rem;
+    font-size: 0.8125rem;
+    color: var(--muted-color);
+  }
+
+  .pricing-extras-label {
+    font-weight: 600;
+    color: var(--text-color);
+    opacity: 0.85;
+  }
+
   .info-rows {
     display: flex;
     flex-direction: column;
@@ -1348,6 +1555,8 @@
     font-weight: 600;
     color: var(--text-color);
     font-family: 'JetBrains Mono', monospace;
+    overflow-wrap: anywhere;
+    word-break: break-word;
   }
 
   .feature-list {
@@ -1451,13 +1660,26 @@
     color: var(--code-text);
   }
 
-  .code-snippet code { display: block; }
-  .code-kw { color: #8b5cf6; }
-  .code-str { color: #10b981; }
+  .code-snippet code { display: block; white-space: pre; }
 
+  /* {@html} snippets are not scoped — use :global so .code-kw / .code-str apply */
+  .code-snippet :global(.code-kw) {
+    color: #8b5cf6;
+  }
+  .code-snippet :global(.code-str) {
+    color: #10b981;
+  }
+  .code-snippet :global(.code-comment) {
+    color: var(--muted-color);
+  }
+
   @media (prefers-color-scheme: dark) {
-    .code-kw { color: #a78bfa; }
-    .code-str { color: #34d399; }
+    .code-snippet :global(.code-kw) {
+      color: #a78bfa;
+    }
+    .code-snippet :global(.code-str) {
+      color: #34d399;
+    }
   }
 
   .detail-actions {

diff --git a/src/catalogApi.ts b/src/catalogApi.ts
new file mode 100644
--- /dev/null
+++ b/src/catalogApi.ts
@@ -1,0 +1,154 @@
+/**
+ * Load models from litellm-model-catalog-api (paginated) and map to the
+ * flat item shape the UI expects (name + litellm_provider).
+ */
+
+export type PricingSlot = {
+  amount_usd: number | null;
+  unit: string | null;
+  source_field: string | null;
+};
+
+export type PricingSlots = {
+  input: PricingSlot;
+  output: PricingSlot;
+  cache_read: PricingSlot;
+  cache_write: PricingSlot;
+};
+
+export type CatalogApiEntry = {
+  id: string;
+  provider?: string | null;
+  pricing_slots?: PricingSlots;
+  [key: string]: unknown;
+};
+
+function isRecord(v: unknown): v is Record<string, unknown> {
+  return v !== null && typeof v === "object" && !Array.isArray(v);
+}
+
+function asPricingSlot(v: unknown): PricingSlot {
+  if (!isRecord(v)) return { amount_usd: null, unit: null, source_field: null };
+  const amount = v.amount_usd;
+  return {
+    amount_usd: typeof amount === "number" ? amount : amount != null ? Number(amount) : null,
+    unit: typeof v.unit === "string" ? v.unit : null,
+    source_field: typeof v.source_field === "string" ? v.source_field : null,
+  };
+}
+
+export function normalizePricingSlots(raw: unknown): PricingSlots | undefined {
+  if (!isRecord(raw)) return undefined;
+  return {
+    input: asPricingSlot(raw.input),
+    output: asPricingSlot(raw.output),
+    cache_read: asPricingSlot(raw.cache_read),
+    cache_write: asPricingSlot(raw.cache_write),
+  };
+}
+
+/** Map one API catalog row to UI item (GitHub JSON shape + pricing_slots). */
+export function mapCatalogEntryToItem(entry: CatalogApiEntry): Record<string, unknown> {
+  const { id, provider, pricing_slots, object: _object, ...rest } = entry;
+  const slots = normalizePricingSlots(pricing_slots);
+  return {
+    ...rest,
+    name: id,
+    litellm_provider: (provider as string) ?? (rest.litellm_provider as string) ?? "",
+    pricing_slots: slots,
+  };
+}
+
+/** Format a USD amount exactly as in the catalog — no "<$0.01" floors. */
+export function formatExactUsd(n: number, suffix = ""): string {
+  if (Number.isNaN(n)) return "—";
+  if (n === 0) return "$0.00" + suffix;
+  const abs = Math.abs(n);
+  const decimals = abs >= 0.01 ? 2 : 10;
+  let s = n.toFixed(decimals);
+  if (abs < 0.01) {
+    s = s.replace(/\.?0+$/, "");
+  }
+  return "$" + s + suffix;
+}
+
+/** Per-token catalog field → exact per-1M-token display. */
+export function formatTokenCostPerMillion(perToken: number | null | undefined): string {
+  if (perToken === null || perToken === undefined) return "—";
+  return formatExactUsd(perToken * 1e6, "/M");
+}
+
+export function formatPricingSlot(slot: PricingSlot | undefined): string {
+  if (!slot || slot.amount_usd == null || Number.isNaN(slot.amount_usd)) return "—";
+  const u = slot.unit ?? "";
+  const n = slot.amount_usd;
+  if (u === "per_1m_tokens" || u === "per_1m_reasoning_tokens" || u === "per_1m_image_tokens" || u === "per_1m_audio_tokens") {
+    return formatExactUsd(n, "/M");
+  }
+  if (u === "per_image") {
+    return formatExactUsd(n, "/img");
+  }
+  if (u === "per_pixel") {
+    return formatExactUsd(n * 1e6, "/Mpx");
+  }
+  if (u === "per_character") {
+    return formatExactUsd(n, "/char");
+  }
+  if (u === "per_query") {
+    return formatExactUsd(n, "/q");
+  }
+  if (u === "per_request") {
+    return formatExactUsd(n, "/req");
+  }
+  if (u === "per_second" || u === "per_second_video") {
+    return formatExactUsd(n, "/s");
+  }
+  return formatExactUsd(n);
+}
+
+export function slotSortValue(slot: PricingSlot | undefined): number {
+  if (!slot || slot.amount_usd == null || Number.isNaN(slot.amount_usd)) return 0;
+  return slot.amount_usd;
+}
+
+export type CatalogFetchResult = {
+  models: Record<string, unknown>[];
+  /** Present when the API returns it (page 1 of list); schema reference from source JSON. */
+  sample_spec: Record<string, unknown> | null;
... diff truncated: showing 800 of 1455 lines

You can send follow-ups to the cloud agent here.

Comment thread src/modelPresentation.ts
Image and cache column sort helpers treated a pricing_slots entry as
authoritative whenever 'unit' was set, even when 'amount_usd' was null,
returning a sort key of 0 from slotSortValue. The corresponding display
helpers only use the slot when amount_usd is non-null and otherwise fall
back to the legacy catalog fields. Align the sort branches with the
display branches so rows are sorted by the same value that is rendered.
@mateo-berri

Copy link
Copy Markdown
Collaborator Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 5 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 5 issues found in the latest run.

  • ✅ Fixed: Chat sort mixed pricing scales
    • Multiplied legacy *_cost_per_token fallbacks by 1e6 in chatSortInput/Output/CacheRead/CacheWrite so they match the per-million scale used by per_1m_tokens slot sort values and the displayed $/M.
  • ✅ Fixed: API may duplicate sample_spec
    • Filter rows whose id is sample_spec while building the model list in fetchAllCatalogModels, mirroring the GitHub JSON path.
  • ✅ Fixed: Schema row skews price sorts
    • Extended the sample_spec early-return in getSortValue to all non-context columns so input/output/cache sorts treat the schema row as 0.
  • ✅ Fixed: Spec spread overwrites row name
    • Reordered the synthetic row to spread the spec first and place name: SAMPLE_SPEC_ROW_NAME last so it always wins.
  • ✅ Fixed: Local catalog breaks issue anchors
    • Added a parallel fetch of the raw GitHub JSON to populate lines even when VITE_USE_LOCAL_CATALOG_API is enabled, restoring blob #L anchors in getIssueUrlForFix.
Preview (2550724401)
diff --git a/src/App.svelte b/src/App.svelte
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -6,6 +6,30 @@
   import { getProviderInitial, getProviderLogo } from "./providers";
   import ProviderDropdown from "./ProviderDropdown.svelte";
   import { trackSearch } from "./analytics";
+  import {
+    fetchAllCatalogModels,
+  } from "./catalogApi";
+  import {
+    isImagePricingMode,
+    isAudioPricingMode,
+    tableImageInputCost,
+    tableImageOutputCost,
+    imageModeInputSortValue,
+    imageModeOutputSortValue,
+    getImagePricingExtraRows,
+    getLiteLLmSdkSnippet,
+    getLiteLLmSdkSnippetHtml,
+    getLiteLLmProxyCurlSnippet,
+    getLiteLLmProxyCurlSnippetHtml,
+    displayChatInputCost,
+    displayChatOutputCost,
+    displayChatCacheRead,
+    displayChatCacheWrite,
+    chatSortInput,
+    chatSortOutput,
+    chatSortCacheRead,
+    chatSortCacheWrite,
+  } from "./modelPresentation";
 
   type Item = {
     name: string;
@@ -23,6 +47,16 @@
   const RESOURCE_BACKUP_NAME = "model_prices_and_context_window_backup.json";
   const RESOURCE_PATH = `${RESOURCE_NAME}`;
   const RESOURCE_BACKUP_PATH = `litellm/${RESOURCE_BACKUP_NAME}`;
+
+  /** Schema reference row in `model_prices_and_context_window.json` — same id as the JSON key. */
+  const SAMPLE_SPEC_ROW_NAME = "sample_spec";
+
+  /** When true, load from litellm-model-catalog-api (see .env.example). */
+  const useLocalCatalogApi =
+    import.meta.env.VITE_USE_LOCAL_CATALOG_API === "true" ||
+    import.meta.env.VITE_USE_LOCAL_CATALOG_API === "1";
+  /** Optional absolute API origin, e.g. http://127.0.0.1:8000. Empty = same-origin /model_catalog (Vite proxy). */
+  const catalogApiBase = (import.meta.env.VITE_CATALOG_API_BASE as string | undefined)?.replace(/\/$/, "") ?? "";
   let providers: string[] = [];
   let selectedProvider: string = "";
   let maxInputTokens: number | null = null;
@@ -69,33 +103,81 @@
         sha = text;
       });
 
+    function prependSampleSpecRow(
+      rows: Item[],
+      spec: Record<string, unknown> | null | undefined,
+    ): Item[] {
+      if (!spec || typeof spec !== "object") return rows;
+      const refRow: Item = { ...spec, name: SAMPLE_SPEC_ROW_NAME } as Item;
+      return [refRow, ...rows];
+    }
+
+    const finishLoad = (items: Item[]) => {
+      providers = [
+        ...new Set(
+          items
+            .filter((i) => i.name !== SAMPLE_SPEC_ROW_NAME)
+            .map((i) => i.litellm_provider)
+            .filter(Boolean),
+        ),
+      ];
+      providers.sort();
+
+      index = new Fuse(items, {
+        threshold: 0.3,
+        keys: [
+          {
+            name: "name",
+            weight: 1.5,
+          },
+          "mode",
+          "litellm_provider",
+        ],
+      });
+
+      results = items.map((item, refIndex) => ({ item, refIndex }));
+      loading = false;
+    };
+
+    if (useLocalCatalogApi) {
+      fetch(
+        `https://raw.githubusercontent.com/${REPO_FULL_NAME}/main/${RESOURCE_PATH}`,
+      )
+        .then((res) => res.text())
+        .then((text) => {
+          lines = text.split("\n");
+        })
+        .catch((err) => {
+          console.error(err);
+        });
+      fetchAllCatalogModels(catalogApiBase)
+        .then(({ models, sample_spec }) => {
+          finishLoad(prependSampleSpecRow(models as Item[], sample_spec));
+        })
+        .catch((err) => {
+          console.error(err);
+          loading = false;
+        });
+      return;
+    }
+
     fetch(
       `https://raw.githubusercontent.com/${REPO_FULL_NAME}/main/${RESOURCE_PATH}`,
     )
       .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))];
-        providers.sort();
-
-        index = new Fuse(items, {
-          threshold: 0.3,
-          keys: [
-            {
-              name: "name",
-              weight: 1.5,
-            },
-            "mode",
-            "litellm_provider",
-          ],
-        });
-
-        results = items.map((item, refIndex) => ({ item, refIndex }));
-        loading = false;
+        const parsed = JSON.parse(text) as Record<string, unknown>;
+        const spec =
+          parsed.sample_spec != null &&
+          typeof parsed.sample_spec === "object" &&
+          !Array.isArray(parsed.sample_spec)
+            ? (parsed.sample_spec as Record<string, unknown>)
+            : null;
+        const items: Item[] = Object.entries(parsed)
+          .filter(([k]) => k !== "sample_spec")
+          .map(([k, v]: any) => ({ name: k, ...v }));
+        finishLoad(prependSampleSpecRow(items, spec));
       });
   });
 
@@ -200,13 +282,40 @@
   }
 
   function getSortValue(item: any, column: string): number {
+    if (isSampleSpecCatalogRow(item)) {
+      if (column === "context") {
+        return typeof item.max_input_tokens === "number" ? item.max_input_tokens : 0;
+      }
+      return 0;
+    }
+    if (isImagePricingMode(item.mode)) {
+      switch (column) {
+        case "context":
+          return item.max_input_tokens || 0;
+        case "input":
+          return imageModeInputSortValue(item);
+        case "output":
+          return imageModeOutputSortValue(item);
+        case "cache_read":
+        case "cache_write":
+          return 0;
+        default:
+          return 0;
+      }
+    }
     switch (column) {
-      case "context": return item.max_input_tokens || 0;
-      case "input": return item.input_cost_per_token || 0;
-      case "output": return item.output_cost_per_token || 0;
-      case "cache_read": return item.cache_read_input_token_cost || 0;
-      case "cache_write": return item.cache_creation_input_token_cost || 0;
-      default: return 0;
+      case "context":
+        return item.max_input_tokens || 0;
+      case "input":
+        return chatSortInput(item);
+      case "output":
+        return chatSortOutput(item);
+      case "cache_read":
+        return chatSortCacheRead(item);
+      case "cache_write":
+        return chatSortCacheWrite(item);
+      default:
+        return 0;
     }
   }
 
@@ -219,13 +328,51 @@
     });
   }
 
-  function formatCost(costPerToken: number | undefined): string {
-    if (!costPerToken) return "—";
-    const perMillion = costPerToken * 1000000;
-    if (perMillion < 0.01) return "<$0.01";
-    return "$" + perMillion.toFixed(2);
+  function isSampleSpecCatalogRow(item: { name: string }): boolean {
+    return item.name === SAMPLE_SPEC_ROW_NAME;
   }
 
+  /** Context column: `sample_spec` shows the catalog hint string (original UI), not "—". */
+  function contextCellForRow(item: Item, max_input_tokens: unknown): string {
+    if (isSampleSpecCatalogRow(item)) {
+      if (typeof max_input_tokens === "string" && max_input_tokens.trim() !== "") {
+        return max_input_tokens;
+      }
+      return "—";
+    }
+    return formatContext(max_input_tokens as number | undefined);
+  }
+
+  /** Model info max input/output: numbers get "tokens" suffix; strings (schema hints) pass through. */
+  function formatDetailTokenField(v: unknown): string {
+    if (v == null || v === "") return "—";
+    if (typeof v === "number" && !Number.isNaN(v)) return v.toLocaleString() + " tokens";
+    if (typeof v === "string") return v;
+    return "—";
+  }
+
+  function tableInputCell(item: any): string {
+    if (isSampleSpecCatalogRow(item)) return "—";
+    return isImagePricingMode(item.mode) ? tableImageInputCost(item) : displayChatInputCost(item);
+  }
+
+  function tableOutputCell(item: any): string {
+    if (isSampleSpecCatalogRow(item)) return "—";
+    return isImagePricingMode(item.mode) ? tableImageOutputCost(item) : displayChatOutputCost(item);
+  }
+
+  function tableCacheReadCell(item: any): string {
+    if (isSampleSpecCatalogRow(item)) return "—";
+    if (isImagePricingMode(item.mode)) return "—";
+    return displayChatCacheRead(item);
+  }
+
+  function tableCacheWriteCell(item: any): string {
+    if (isSampleSpecCatalogRow(item)) return "—";
+    if (isImagePricingMode(item.mode)) return "—";
+    return displayChatCacheWrite(item);
+  }
+
   function formatContext(tokens: number | undefined): string {
     if (!tokens || tokens <= 0) return "—";
     if (tokens >= 1000000) return (tokens / 1000000).toFixed(0) + "M";
@@ -233,18 +380,6 @@
     return tokens.toString();
   }
 
-  function getFeatureBadges(item: any): string[] {
-    const badges: string[] = [];
-    if (item.supports_function_calling) badges.push("Functions");
-    if (item.supports_vision) badges.push("Vision");
-    if (item.supports_response_schema) badges.push("JSON");
-    if (item.supports_tool_choice) badges.push("Tools");
-    if (item.supports_parallel_function_calling) badges.push("Parallel");
-    if (item.supports_audio_input) badges.push("Audio");
-    if (item.supports_prompt_caching) badges.push("Caching");
-    return badges;
-  }
-
   function getModeLabel(mode: string | undefined): string {
     if (!mode) return "";
     const labels: Record<string, string> = {
@@ -252,6 +387,7 @@
       "completion": "Completion",
       "embedding": "Embedding",
       "image_generation": "Image Gen",
+      "image_edit": "Image edit",
       "audio_transcription": "Transcription",
       "audio_speech": "TTS",
       "moderation": "Moderation",
@@ -271,16 +407,22 @@
 
       const allItems = index["_docs"] as Item[];
 
-      filteredResults = allItems.filter(
-        (item) =>
-          (!selectedProvider || item.litellm_provider === selectedProvider) &&
-          (maxInputTokens === null ||
-            (item.max_input_tokens &&
-              item.max_input_tokens >= maxInputTokens)) &&
-          (maxOutputTokens === null ||
-            (item.max_output_tokens &&
-              item.max_output_tokens >= maxOutputTokens)),
-      );
+      filteredResults = allItems.filter((item) => {
+        const schema = item.name === SAMPLE_SPEC_ROW_NAME;
+        const providerOk =
+          !selectedProvider || schema || item.litellm_provider === selectedProvider;
+        const inputOk =
+          maxInputTokens === null ||
+          schema ||
+          (typeof item.max_input_tokens === "number" &&
+            item.max_input_tokens >= maxInputTokens);
+        const outputOk =
+          maxOutputTokens === null ||
+          schema ||
+          (typeof item.max_output_tokens === "number" &&
+            item.max_output_tokens >= maxOutputTokens);
+        return providerOk && inputOk && outputOk;
+      });
 
       if (query) {
         const filteredIndex = new Fuse(filteredResults, {
@@ -478,26 +620,36 @@
               <span class="sort-icon" class:active={sortColumn === "context"} class:desc={sortColumn === "context" && sortDirection === "desc"}>↑</span>
             </th>
             <th class="th-sortable" on:click={() => handleSort("input")}>
-              Input $/M
+              Input cost
               <span class="sort-icon" class:active={sortColumn === "input"} class:desc={sortColumn === "input" && sortDirection === "desc"}>↑</span>
             </th>
             <th class="th-sortable" on:click={() => handleSort("output")}>
-              Output $/M
+              Output cost
               <span class="sort-icon" class:active={sortColumn === "output"} class:desc={sortColumn === "output" && sortDirection === "desc"}>↑</span>
             </th>
-            <th class="th-sortable th-hide-mobile" on:click={() => handleSort("cache_read")}>
-              Cache Read
+            <th class="th-sortable th-hide-mobile" on:click={() => handleSort("cache_read")} title="Prompt cache read (chat models)">
+              Cache read
               <span class="sort-icon" class:active={sortColumn === "cache_read"} class:desc={sortColumn === "cache_read" && sortDirection === "desc"}>↑</span>
             </th>
-            <th class="th-sortable th-hide-mobile" on:click={() => handleSort("cache_write")}>
-              Cache Write
+            <th class="th-sortable th-hide-mobile" on:click={() => handleSort("cache_write")} title="Prompt cache write (chat models)">
+              Cache write
               <span class="sort-icon" class:active={sortColumn === "cache_write"} class:desc={sortColumn === "cache_write" && sortDirection === "desc"}>↑</span>
             </th>
           </tr>
         </thead>
         <tbody>
-          {#each results as { item: { name, mode, litellm_provider, max_input_tokens, max_output_tokens, input_cost_per_token, output_cost_per_token, cache_creation_input_token_cost, cache_read_input_token_cost, supports_function_calling, supports_vision, supports_response_schema, supports_tool_choice, supports_parallel_function_calling, supports_audio_input, supports_prompt_caching, ...data } } (name)}
-            <tr class="model-row" class:expanded={expandedRows.has(name)} on:click={() => toggleRow(name)}>
+          {#each results as { item } (item.name)}
+            {@const name = item.name}
+            {@const mode = item.mode}
+            {@const litellm_provider = item.litellm_provider}
+            {@const max_input_tokens = item.max_input_tokens}
+            {@const max_output_tokens = item.max_output_tokens}
+            <tr
+              class="model-row"
+              class:model-row-schema={name === SAMPLE_SPEC_ROW_NAME}
+              class:expanded={expandedRows.has(name)}
+              on:click={() => toggleRow(name)}
+            >
               <td class="model-name">
                 <div class="model-info">
                   <svg class="expand-icon" class:expanded={expandedRows.has(name)} width="14" height="14" viewBox="0 0 16 16" fill="none">
@@ -526,7 +678,7 @@
                   <div class="model-name-group">
                     <span class="model-title" title={getDisplayModelName(name, litellm_provider)}>{getDisplayModelName(name, litellm_provider)}</span>
                     {#if mode}
-                      <span class="mode-badge">{getModeLabel(mode)}</span>
+                      <span class="mode-badge" class:mode-badge-schema={name === SAMPLE_SPEC_ROW_NAME}>{getModeLabel(mode)}</span>
                     {/if}
                   </div>
                   <button
@@ -546,38 +698,63 @@
                   </button>
                 </div>
               </td>
-              <td class="context-cell">{formatContext(max_input_tokens)}</td>
-              <td class="cost-cell">{formatCost(input_cost_per_token)}</td>
-              <td class="cost-cell">{formatCost(output_cost_per_token)}</td>
-              <td class="cost-cell td-hide-mobile">{formatCost(cache_read_input_token_cost)}</td>
-              <td class="cost-cell td-hide-mobile">{formatCost(cache_creation_input_token_cost)}</td>
+              <td
+                class="context-cell"
+                class:context-cell-schema={name === SAMPLE_SPEC_ROW_NAME}
+                title={name === SAMPLE_SPEC_ROW_NAME && typeof max_input_tokens === "string" ? max_input_tokens : undefined}
+              >{contextCellForRow(item, max_input_tokens)}</td>
+              <td class="cost-cell">{tableInputCell(item)}</td>
+              <td class="cost-cell">{tableOutputCell(item)}</td>
+              <td class="cost-cell td-hide-mobile">{tableCacheReadCell(item)}</td>
+              <td class="cost-cell td-hide-mobile">{tableCacheWriteCell(item)}</td>
             </tr>
             {#if expandedRows.has(name)}
               <tr class="expanded-content" transition:fly={{ y: -10, duration: 200 }}>
                 <td colspan="6">
                   <div class="detail-panel">
                     <div class="detail-grid">
-                      <!-- Pricing Cards -->
                       <div class="detail-section">
-                        <h4 class="detail-heading">Pricing <span class="detail-unit">per 1M tokens</span></h4>
+                        <h4 class="detail-heading">
+                          {#if isSampleSpecCatalogRow(item)}
+                            Field reference <span class="detail-unit">example values and types from the catalog JSON</span>
+                          {:else if isImagePricingMode(mode)}
+                            Image pricing <span class="detail-unit">per image where applicable</span>
+                          {:else if mode === "audio_speech"}
+                            Audio pricing <span class="detail-unit">per character where applicable</span>
+                          {:else if mode === "audio_transcription"}
+                            Audio pricing <span class="detail-unit">per second where applicable</span>
+                          {:else}
+                            Token pricing <span class="detail-unit">per 1M tokens where applicable</span>
+                          {/if}
+                        </h4>
                         <div class="pricing-cards">
                           <div class="pricing-card">
                             <span class="pricing-label">Input</span>
-                            <span class="pricing-value">{formatCost(input_cost_per_token)}</span>
+                            <span class="pricing-value">{tableInputCell(item)}</span>
                           </div>
                           <div class="pricing-card">
                             <span class="pricing-label">Output</span>
-                            <span class="pricing-value">{formatCost(output_cost_per_token)}</span>
+                            <span class="pricing-value">{tableOutputCell(item)}</span>
                           </div>
                           <div class="pricing-card">
-                            <span class="pricing-label">Cache Read</span>
-                            <span class="pricing-value">{formatCost(cache_read_input_token_cost)}</span>
+                            <span class="pricing-label">Cache read</span>
+                            <span class="pricing-value">{tableCacheReadCell(item)}</span>
                           </div>
                           <div class="pricing-card">
-                            <span class="pricing-label">Cache Write</span>
-                            <span class="pricing-value">{formatCost(cache_creation_input_token_cost)}</span>
+                            <span class="pricing-label">Cache write</span>
+                            <span class="pricing-value">{tableCacheWriteCell(item)}</span>
                           </div>
                         </div>
+                        {#if isImagePricingMode(mode)}
+                          {@const imageExtras = getImagePricingExtraRows(item)}
+                          {#if imageExtras.length > 0}
+                            <ul class="pricing-extras">
+                              {#each imageExtras as row}
+                                <li><span class="pricing-extras-label">{row.label}</span> {row.value}</li>
+                              {/each}
+                            </ul>
+                          {/if}
+                        {/if}
                       </div>
 
                       <!-- Model Info -->
@@ -594,27 +771,28 @@
                           </div>
                           <div class="info-row">
                             <span class="info-label">Max Input</span>
-                            <span class="info-value">{max_input_tokens ? max_input_tokens.toLocaleString() + " tokens" : "—"}</span>
+                            <span class="info-value">{formatDetailTokenField(max_input_tokens)}</span>
                           </div>
                           <div class="info-row">
                             <span class="info-label">Max Output</span>
-                            <span class="info-value">{max_output_tokens ? max_output_tokens.toLocaleString() + " tokens" : "—"}</span>
+                            <span class="info-value">{formatDetailTokenField(max_output_tokens)}</span>
                           </div>
                         </div>
                       </div>
 
-                      <!-- Features -->
+                      <!-- Features (chat / completion models) -->
+                      {#if !isImagePricingMode(mode) && !isAudioPricingMode(mode)}
                       <div class="detail-section">
                         <h4 class="detail-heading">Features</h4>
                         <div class="feature-list">
                           {#each [
-                            { key: supports_function_calling, label: "Function Calling" },
-                            { key: supports_vision, label: "Vision" },
-                            { key: supports_response_schema, label: "JSON Mode" },
-                            { key: supports_tool_choice, label: "Tool Choice" },
-                            { key: supports_parallel_function_calling, label: "Parallel Calls" },
-                            { key: supports_audio_input, label: "Audio Input" },
-                            { key: supports_prompt_caching, label: "Prompt Caching" },
+                            { key: item.supports_function_calling, label: "Function Calling" },
+                            { key: item.supports_vision, label: "Vision" },
+                            { key: item.supports_response_schema, label: "JSON Mode" },
+                            { key: item.supports_tool_choice, label: "Tool Choice" },
+                            { key: item.supports_parallel_function_calling, label: "Parallel Calls" },
+                            { key: item.supports_audio_input, label: "Audio Input" },
+                            { key: item.supports_prompt_caching, label: "Prompt Caching" },
                           ] as feature}
                             <div class="feature-item" class:supported={feature.key}>
                               {#if feature.key}
@@ -627,6 +805,7 @@
                           {/each}
                         </div>
                       </div>
+                      {/if}
                     </div>
 
                     <!-- Code snippet with tabs -->
@@ -645,42 +824,31 @@
                           >AI Gateway (Proxy)</button>
                         </div>
                         {#if !codeTabStates[name] || codeTabStates[name] === "sdk"}
-                          <button class="copy-code-btn" on:click|stopPropagation={() => copyToClipboard(`from litellm import completion\n\nresponse = completion(\n    model="${getDisplayModelName(name, litellm_provider)}",\n    messages=[{"role": "user", "content": "Hello!"}]\n)`)}>
+                          <button class="copy-code-btn" on:click|stopPropagation={() => copyToClipboard(getLiteLLmSdkSnippet(mode, getDisplayModelName(name, litellm_provider)))}>
                             {copiedModel.includes("from litellm") ? "Copied!" : "Copy"}
                           </button>
                         {:else}
-                          <button class="copy-code-btn" on:click|stopPropagation={() => copyToClipboard(`curl http://0.0.0.0:4000/v1/chat/completions \\\n  -H "Content-Type: application/json" \\\n  -H "Authorization: Bearer sk-1234" \\\n  -d '{\n    "model": "${getDisplayModelName(name, litellm_provider)}",\n    "messages": [{"role": "user", "content": "Hello!"}]\n  }'`)}>
+                          <button class="copy-code-btn" on:click|stopPropagation={() => copyToClipboard(getLiteLLmProxyCurlSnippet(mode, getDisplayModelName(name, litellm_provider)))}>
                             {copiedModel.includes("curl") ? "Copied!" : "Copy"}
                           </button>
                         {/if}
                       </div>
                       {#if !codeTabStates[name] || codeTabStates[name] === "sdk"}
-                        <pre class="code-snippet"><code><span class="code-kw">from</span> litellm <span class="code-kw">import</span> completion
-
-response = completion(
-    model=<span class="code-str">"{getDisplayModelName(name, litellm_provider)}"</span>,
-    messages=[{`{`}<span class="code-str">"role"</span>: <span class="code-str">"user"</span>, <span class="code-str">"content"</span>: <span class="code-str">"Hello!"</span>{`}`}]
-)</code></pre>
+                        <pre class="code-snippet"><code>{@html getLiteLLmSdkSnippetHtml(mode, getDisplayModelName(name, litellm_provider))}</code></pre>
                       {:else}
-                        <pre class="code-snippet"><code><span class="code-comment"># Start proxy: litellm --model {getDisplayModelName(name, litellm_provider)}</span>
-
-curl http://0.0.0.0:4000/v1/chat/completions \
-  -H <span class="code-str">"Content-Type: application/json"</span> \
-  -H <span class="code-str">"Authorization: Bearer sk-1234"</span> \
-  -d <span class="code-str">'{`{`}
-    "model": "{getDisplayModelName(name, litellm_provider)}",
-    "messages": [{`{`}"role": "user", "content": "Hello!"{`}`}]
-  {`}`}'</span></code></pre>
+                        <pre class="code-snippet"><code>{@html getLiteLLmProxyCurlSnippetHtml(mode, getDisplayModelName(name, litellm_provider))}</code></pre>
                       {/if}
                     </div>
 
                     <!-- Actions -->
+                    {#if name !== SAMPLE_SPEC_ROW_NAME}
                     <div class="detail-actions">
                       <a href={getIssueUrlForFix(name)} target="_blank" rel="noopener noreferrer" class="detail-action-link" on:click|stopPropagation>
                         <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
                         Report incorrect data
                       </a>
                     </div>
+                    {/if}
                   </div>
                 </td>
               </tr>
@@ -1072,11 +1240,14 @@
 
   table {
     width: 100%;
-    border-collapse: collapse;
+    /* separate avoids sticky <th> overlapping first tbody rows (collapse + sticky bug) */
+    border-collapse: separate;
+    border-spacing: 0;
     background: var(--card-bg);
     border-radius: 12px;
     border: 1px solid var(--border-color);
-    overflow: hidden;
+    /* Do not use overflow:hidden here — it breaks position:sticky on <th> and clips header vs body paint. */
+    overflow: visible;
   }
 
   thead {
@@ -1097,7 +1268,8 @@
     user-select: none;
     position: sticky;
     top: 63px;
-    z-index: 10;
+    z-index: 25;
+    box-shadow: 0 1px 0 var(--border-color);
   }
 
   .th-model { padding-left: 1rem; }
@@ -1125,8 +1297,14 @@
     border-bottom: 1px solid var(--border-color);
     transition: background-color 0.1s ease;
     cursor: pointer;
+    position: relative;
+    z-index: 0;
   }
 
+  tbody tr.model-row-schema td {
+    vertical-align: top;
+  }
+
   tbody tr.model-row:hover {
     background-color: var(--hover-bg);
     box-shadow: inset 3px 0 0 var(--litellm-primary);
@@ -1248,12 +1426,31 @@
     flex-shrink: 0;
   }
 
+  .mode-badge.mode-badge-schema {
+    white-space: normal;
+    max-width: min(40rem, 92vw);
+    line-height: 1.35;
+    text-transform: none;
+    letter-spacing: normal;
+    font-weight: 500;
+    font-size: 0.625rem;
+  }
+
   .context-cell {
     font-weight: 600;
     font-variant-numeric: tabular-nums;
     font-size: 0.8125rem;
   }
 
+  .context-cell.context-cell-schema {
+    white-space: normal;
+    font-weight: 400;
+    font-size: 0.75rem;
+    line-height: 1.4;
+    color: var(--text-secondary);
+    max-width: 18rem;
+  }
+
   .cost-cell {
     color: var(--text-secondary);
     font-variant-numeric: tabular-nums;
@@ -1322,6 +1519,29 @@
     font-variant-numeric: tabular-nums;
   }
 
+  .pricing-empty {
+    margin: 0;
+    font-size: 0.875rem;
+    color: var(--muted-color);
+  }
+
+  .pricing-extras {
+    margin: 0.625rem 0 0;
+    padding: 0;
+    list-style: none;
+    display: flex;
+    flex-direction: column;
+    gap: 0.35rem;
+    font-size: 0.8125rem;
+    color: var(--muted-color);
+  }
+
+  .pricing-extras-label {
+    font-weight: 600;
+    color: var(--text-color);
+    opacity: 0.85;
+  }
+
   .info-rows {
     display: flex;
     flex-direction: column;
@@ -1348,6 +1568,8 @@
     font-weight: 600;
     color: var(--text-color);
     font-family: 'JetBrains Mono', monospace;
+    overflow-wrap: anywhere;
+    word-break: break-word;
   }
 
   .feature-list {
@@ -1451,13 +1673,26 @@
     color: var(--code-text);
   }
 
-  .code-snippet code { display: block; }
-  .code-kw { color: #8b5cf6; }
-  .code-str { color: #10b981; }
+  .code-snippet code { display: block; white-space: pre; }
 
+  /* {@html} snippets are not scoped — use :global so .code-kw / .code-str apply */
+  .code-snippet :global(.code-kw) {
+    color: #8b5cf6;
+  }
+  .code-snippet :global(.code-str) {
+    color: #10b981;
+  }
+  .code-snippet :global(.code-comment) {
+    color: var(--muted-color);
+  }
+
   @media (prefers-color-scheme: dark) {
-    .code-kw { color: #a78bfa; }
-    .code-str { color: #34d399; }
+    .code-snippet :global(.code-kw) {
+      color: #a78bfa;
+    }
+    .code-snippet :global(.code-str) {
+      color: #34d399;
+    }
   }
 
   .detail-actions {

diff --git a/src/catalogApi.ts b/src/catalogApi.ts
new file mode 100644
--- /dev/null
+++ b/src/catalogApi.ts
@@ -1,0 +1,155 @@
+/**
+ * Load models from litellm-model-catalog-api (paginated) and map to the
+ * flat item shape the UI expects (name + litellm_provider).
+ */
+
+export type PricingSlot = {
+  amount_usd: number | null;
+  unit: string | null;
+  source_field: string | null;
+};
+
+export type PricingSlots = {
+  input: PricingSlot;
+  output: PricingSlot;
+  cache_read: PricingSlot;
+  cache_write: PricingSlot;
+};
+
+export type CatalogApiEntry = {
+  id: string;
+  provider?: string | null;
+  pricing_slots?: PricingSlots;
+  [key: string]: unknown;
+};
+
+function isRecord(v: unknown): v is Record<string, unknown> {
+  return v !== null && typeof v === "object" && !Array.isArray(v);
+}
+
+function asPricingSlot(v: unknown): PricingSlot {
+  if (!isRecord(v)) return { amount_usd: null, unit: null, source_field: null };
+  const amount = v.amount_usd;
+  return {
+    amount_usd: typeof amount === "number" ? amount : amount != null ? Number(amount) : null,
+    unit: typeof v.unit === "string" ? v.unit : null,
+    source_field: typeof v.source_field === "string" ? v.source_field : null,
+  };
+}
+
+export function normalizePricingSlots(raw: unknown): PricingSlots | undefined {
+  if (!isRecord(raw)) return undefined;
+  return {
+    input: asPricingSlot(raw.input),
+    output: asPricingSlot(raw.output),
+    cache_read: asPricingSlot(raw.cache_read),
+    cache_write: asPricingSlot(raw.cache_write),
+  };
+}
+
+/** Map one API catalog row to UI item (GitHub JSON shape + pricing_slots). */
+export function mapCatalogEntryToItem(entry: CatalogApiEntry): Record<string, unknown> {
+  const { id, provider, pricing_slots, object: _object, ...rest } = entry;
+  const slots = normalizePricingSlots(pricing_slots);
+  return {
+    ...rest,
+    name: id,
+    litellm_provider: (provider as string) ?? (rest.litellm_provider as string) ?? "",
+    pricing_slots: slots,
+  };
+}
+
+/** Format a USD amount exactly as in the catalog — no "<$0.01" floors. */
+export function formatExactUsd(n: number, suffix = ""): string {
+  if (Number.isNaN(n)) return "—";
+  if (n === 0) return "$0.00" + suffix;
+  const abs = Math.abs(n);
+  const decimals = abs >= 0.01 ? 2 : 10;
+  let s = n.toFixed(decimals);
+  if (abs < 0.01) {
+    s = s.replace(/\.?0+$/, "");
+  }
+  return "$" + s + suffix;
+}
+
+/** Per-token catalog field → exact per-1M-token display. */
+export function formatTokenCostPerMillion(perToken: number | null | undefined): string {
+  if (perToken === null || perToken === undefined) return "—";
+  return formatExactUsd(perToken * 1e6, "/M");
+}
+
+export function formatPricingSlot(slot: PricingSlot | undefined): string {
+  if (!slot || slot.amount_usd == null || Number.isNaN(slot.amount_usd)) return "—";
+  const u = slot.unit ?? "";
+  const n = slot.amount_usd;
+  if (u === "per_1m_tokens" || u === "per_1m_reasoning_tokens" || u === "per_1m_image_tokens" || u === "per_1m_audio_tokens") {
+    return formatExactUsd(n, "/M");
+  }
+  if (u === "per_image") {
+    return formatExactUsd(n, "/img");
+  }
+  if (u === "per_pixel") {
+    return formatExactUsd(n * 1e6, "/Mpx");
+  }
+  if (u === "per_character") {
+    return formatExactUsd(n, "/char");
+  }
+  if (u === "per_query") {
+    return formatExactUsd(n, "/q");
+  }
+  if (u === "per_request") {
+    return formatExactUsd(n, "/req");
+  }
+  if (u === "per_second" || u === "per_second_video") {
+    return formatExactUsd(n, "/s");
... diff truncated: showing 800 of 1473 lines

You can send follow-ups to the cloud agent here.

Comment thread src/modelPresentation.ts
Comment thread src/catalogApi.ts
Comment thread src/App.svelte
Comment thread src/App.svelte Outdated
Comment thread src/App.svelte
@mateo-berri

Copy link
Copy Markdown
Collaborator Author

bugbot run

Comment thread src/App.svelte
Comment thread src/catalogApi.ts Outdated
…slot

Address two Cursor Bugbot findings on commit 2550724:

- Search hides sample_spec row (Medium): the sample_spec reference row is
  exempt from provider/token filters but Fuse.js search still dropped it
  when the query didn't match its content. Split the schema row out before
  searching and re-pin it on top, mirroring the existing filter exemption.

- Empty slot string becomes zero (Low): asPricingSlot coerced a non-numeric
  amount_usd of "" via Number("") to 0, treating it as a valid zero price.
  Now only finite numbers and non-empty numeric strings yield a value;
  empty/whitespace/non-finite amounts stay null so legacy fields apply,
  matching num() semantics in modelPresentation.ts.

npm run build passes; npm run check reports the same pre-existing 9 errors
/ 7 warnings (none in the touched files).
@mateo-berri

Copy link
Copy Markdown
Collaborator Author

bugbot run


Generated by Claude Code

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Image zero price wrong unit suffix
    • Tracked the format of the first observed zero legacy field and used formatImageCatalogValue with that format so a zero per-token / per-pixel value renders with /M or /Mpx instead of always /img.
  • ✅ Fixed: Catalog API failure empty table
    • Added a loadError state that the local-catalog-API .catch handler sets, and rendered an alert message in place of the empty table when loading fails.
  • ✅ Fixed: Search pin undone by sorting
    • applySorting now extracts the sample_spec row before sorting and re-prepends it, so the schema reference row stays pinned at the top regardless of the active sort column or direction.
Preview (cac0ceb0da)
diff --git a/src/App.svelte b/src/App.svelte
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -6,6 +6,30 @@
   import { getProviderInitial, getProviderLogo } from "./providers";
   import ProviderDropdown from "./ProviderDropdown.svelte";
   import { trackSearch } from "./analytics";
+  import {
+    fetchAllCatalogModels,
+  } from "./catalogApi";
+  import {
+    isImagePricingMode,
+    isAudioPricingMode,
+    tableImageInputCost,
+    tableImageOutputCost,
+    imageModeInputSortValue,
+    imageModeOutputSortValue,
+    getImagePricingExtraRows,
+    getLiteLLmSdkSnippet,
+    getLiteLLmSdkSnippetHtml,
+    getLiteLLmProxyCurlSnippet,
+    getLiteLLmProxyCurlSnippetHtml,
+    displayChatInputCost,
+    displayChatOutputCost,
+    displayChatCacheRead,
+    displayChatCacheWrite,
+    chatSortInput,
+    chatSortOutput,
+    chatSortCacheRead,
+    chatSortCacheWrite,
+  } from "./modelPresentation";
 
   type Item = {
     name: string;
@@ -23,6 +47,16 @@
   const RESOURCE_BACKUP_NAME = "model_prices_and_context_window_backup.json";
   const RESOURCE_PATH = `${RESOURCE_NAME}`;
   const RESOURCE_BACKUP_PATH = `litellm/${RESOURCE_BACKUP_NAME}`;
+
+  /** Schema reference row in `model_prices_and_context_window.json` — same id as the JSON key. */
+  const SAMPLE_SPEC_ROW_NAME = "sample_spec";
+
+  /** When true, load from litellm-model-catalog-api (see .env.example). */
+  const useLocalCatalogApi =
+    import.meta.env.VITE_USE_LOCAL_CATALOG_API === "true" ||
+    import.meta.env.VITE_USE_LOCAL_CATALOG_API === "1";
+  /** Optional absolute API origin, e.g. http://127.0.0.1:8000. Empty = same-origin /model_catalog (Vite proxy). */
+  const catalogApiBase = (import.meta.env.VITE_CATALOG_API_BASE as string | undefined)?.replace(/\/$/, "") ?? "";
   let providers: string[] = [];
   let selectedProvider: string = "";
   let maxInputTokens: number | null = null;
@@ -69,33 +103,83 @@
         sha = text;
       });
 
+    function prependSampleSpecRow(
+      rows: Item[],
+      spec: Record<string, unknown> | null | undefined,
+    ): Item[] {
+      if (!spec || typeof spec !== "object") return rows;
+      const refRow: Item = { ...spec, name: SAMPLE_SPEC_ROW_NAME } as Item;
+      return [refRow, ...rows];
+    }
+
+    const finishLoad = (items: Item[]) => {
+      providers = [
+        ...new Set(
+          items
+            .filter((i) => i.name !== SAMPLE_SPEC_ROW_NAME)
+            .map((i) => i.litellm_provider)
+            .filter(Boolean),
+        ),
+      ];
+      providers.sort();
+
+      index = new Fuse(items, {
+        threshold: 0.3,
+        keys: [
+          {
+            name: "name",
+            weight: 1.5,
+          },
+          "mode",
+          "litellm_provider",
+        ],
+      });
+
+      results = items.map((item, refIndex) => ({ item, refIndex }));
+      loading = false;
+    };
+
+    if (useLocalCatalogApi) {
+      fetch(
+        `https://raw.githubusercontent.com/${REPO_FULL_NAME}/main/${RESOURCE_PATH}`,
+      )
+        .then((res) => res.text())
+        .then((text) => {
+          lines = text.split("\n");
+        })
+        .catch((err) => {
+          console.error(err);
+        });
+      fetchAllCatalogModels(catalogApiBase)
+        .then(({ models, sample_spec }) => {
+          finishLoad(prependSampleSpecRow(models as Item[], sample_spec));
+        })
+        .catch((err) => {
+          console.error(err);
+          loadError =
+            "Failed to load model catalog from the local API. Check that the litellm-model-catalog-api service is running and reachable.";
+          loading = false;
+        });
+      return;
+    }
+
     fetch(
       `https://raw.githubusercontent.com/${REPO_FULL_NAME}/main/${RESOURCE_PATH}`,
     )
       .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))];
-        providers.sort();
-
-        index = new Fuse(items, {
-          threshold: 0.3,
-          keys: [
-            {
-              name: "name",
-              weight: 1.5,
-            },
-            "mode",
-            "litellm_provider",
-          ],
-        });
-
-        results = items.map((item, refIndex) => ({ item, refIndex }));
-        loading = false;
+        const parsed = JSON.parse(text) as Record<string, unknown>;
+        const spec =
+          parsed.sample_spec != null &&
+          typeof parsed.sample_spec === "object" &&
+          !Array.isArray(parsed.sample_spec)
+            ? (parsed.sample_spec as Record<string, unknown>)
+            : null;
+        const items: Item[] = Object.entries(parsed)
+          .filter(([k]) => k !== "sample_spec")
+          .map(([k, v]: any) => ({ name: k, ...v }));
+        finishLoad(prependSampleSpecRow(items, spec));
       });
   });
 
@@ -155,6 +239,7 @@
   let index: Fuse<Item>;
   let results: ResultItem[] = [];
   let loading = true;
+  let loadError: string | null = null;
   let expandedRows = new Set<string>();
 
   $: {
@@ -200,32 +285,104 @@
   }
 
   function getSortValue(item: any, column: string): number {
+    if (isSampleSpecCatalogRow(item)) {
+      if (column === "context") {
+        return typeof item.max_input_tokens === "number" ? item.max_input_tokens : 0;
+      }
+      return 0;
+    }
+    if (isImagePricingMode(item.mode)) {
+      switch (column) {
+        case "context":
+          return item.max_input_tokens || 0;
+        case "input":
+          return imageModeInputSortValue(item);
+        case "output":
+          return imageModeOutputSortValue(item);
+        case "cache_read":
+        case "cache_write":
+          return 0;
+        default:
+          return 0;
+      }
+    }
     switch (column) {
-      case "context": return item.max_input_tokens || 0;
-      case "input": return item.input_cost_per_token || 0;
-      case "output": return item.output_cost_per_token || 0;
-      case "cache_read": return item.cache_read_input_token_cost || 0;
-      case "cache_write": return item.cache_creation_input_token_cost || 0;
-      default: return 0;
+      case "context":
+        return item.max_input_tokens || 0;
+      case "input":
+        return chatSortInput(item);
+      case "output":
+        return chatSortOutput(item);
+      case "cache_read":
+        return chatSortCacheRead(item);
+      case "cache_write":
+        return chatSortCacheWrite(item);
+      default:
+        return 0;
     }
   }
 
   function applySorting() {
     if (!sortColumn) return;
-    results = [...results].sort((a, b) => {
+    // Keep the sample_spec schema row pinned to the top, mirroring how
+    // filterResults pins it during search. Sorting reorders only the rest.
+    const schemaRow = results.find((r) => isSampleSpecCatalogRow(r.item));
+    const others = schemaRow
+      ? results.filter((r) => !isSampleSpecCatalogRow(r.item))
+      : results;
+    const sorted = [...others].sort((a, b) => {
       const aVal = getSortValue(a.item, sortColumn);
       const bVal = getSortValue(b.item, sortColumn);
       return sortDirection === "asc" ? aVal - bVal : bVal - aVal;
     });
+    results = schemaRow ? [schemaRow, ...sorted] : sorted;
   }
 
-  function formatCost(costPerToken: number | undefined): string {
-    if (!costPerToken) return "—";
-    const perMillion = costPerToken * 1000000;
-    if (perMillion < 0.01) return "<$0.01";
-    return "$" + perMillion.toFixed(2);
+  function isSampleSpecCatalogRow(item: { name: string }): boolean {
+    return item.name === SAMPLE_SPEC_ROW_NAME;
   }
 
+  /** Context column: `sample_spec` shows the catalog hint string (original UI), not "—". */
+  function contextCellForRow(item: Item, max_input_tokens: unknown): string {
+    if (isSampleSpecCatalogRow(item)) {
+      if (typeof max_input_tokens === "string" && max_input_tokens.trim() !== "") {
+        return max_input_tokens;
+      }
+      return "—";
+    }
+    return formatContext(max_input_tokens as number | undefined);
+  }
+
+  /** Model info max input/output: numbers get "tokens" suffix; strings (schema hints) pass through. */
+  function formatDetailTokenField(v: unknown): string {
+    if (v == null || v === "") return "—";
+    if (typeof v === "number" && !Number.isNaN(v)) return v.toLocaleString() + " tokens";
+    if (typeof v === "string") return v;
+    return "—";
+  }
+
+  function tableInputCell(item: any): string {
+    if (isSampleSpecCatalogRow(item)) return "—";
+    return isImagePricingMode(item.mode) ? tableImageInputCost(item) : displayChatInputCost(item);
+  }
+
+  function tableOutputCell(item: any): string {
+    if (isSampleSpecCatalogRow(item)) return "—";
+    return isImagePricingMode(item.mode) ? tableImageOutputCost(item) : displayChatOutputCost(item);
+  }
+
+  function tableCacheReadCell(item: any): string {
+    if (isSampleSpecCatalogRow(item)) return "—";
+    if (isImagePricingMode(item.mode)) return "—";
+    return displayChatCacheRead(item);
+  }
+
+  function tableCacheWriteCell(item: any): string {
+    if (isSampleSpecCatalogRow(item)) return "—";
+    if (isImagePricingMode(item.mode)) return "—";
+    return displayChatCacheWrite(item);
+  }
+
   function formatContext(tokens: number | undefined): string {
     if (!tokens || tokens <= 0) return "—";
     if (tokens >= 1000000) return (tokens / 1000000).toFixed(0) + "M";
@@ -233,18 +390,6 @@
     return tokens.toString();
   }
 
-  function getFeatureBadges(item: any): string[] {
-    const badges: string[] = [];
-    if (item.supports_function_calling) badges.push("Functions");
-    if (item.supports_vision) badges.push("Vision");
-    if (item.supports_response_schema) badges.push("JSON");
-    if (item.supports_tool_choice) badges.push("Tools");
-    if (item.supports_parallel_function_calling) badges.push("Parallel");
-    if (item.supports_audio_input) badges.push("Audio");
-    if (item.supports_prompt_caching) badges.push("Caching");
-    return badges;
-  }
-
   function getModeLabel(mode: string | undefined): string {
     if (!mode) return "";
     const labels: Record<string, string> = {
@@ -252,6 +397,7 @@
       "completion": "Completion",
       "embedding": "Embedding",
       "image_generation": "Image Gen",
+      "image_edit": "Image edit",
       "audio_transcription": "Transcription",
       "audio_speech": "TTS",
       "moderation": "Moderation",
@@ -271,19 +417,35 @@
 
       const allItems = index["_docs"] as Item[];
 
-      filteredResults = allItems.filter(
-        (item) =>
-          (!selectedProvider || item.litellm_provider === selectedProvider) &&
-          (maxInputTokens === null ||
-            (item.max_input_tokens &&
-              item.max_input_tokens >= maxInputTokens)) &&
-          (maxOutputTokens === null ||
-            (item.max_output_tokens &&
-              item.max_output_tokens >= maxOutputTokens)),
-      );
+      filteredResults = allItems.filter((item) => {
+        const schema = item.name === SAMPLE_SPEC_ROW_NAME;
+        const providerOk =
+          !selectedProvider || schema || item.litellm_provider === selectedProvider;
+        const inputOk =
+          maxInputTokens === null ||
+          schema ||
+          (typeof item.max_input_tokens === "number" &&
+            item.max_input_tokens >= maxInputTokens);
+        const outputOk =
+          maxOutputTokens === null ||
+          schema ||
+          (typeof item.max_output_tokens === "number" &&
+            item.max_output_tokens >= maxOutputTokens);
+        return providerOk && inputOk && outputOk;
+      });
 
       if (query) {
-        const filteredIndex = new Fuse(filteredResults, {
+        // The sample_spec reference row is field documentation and stays
+        // visible regardless of the search query, mirroring its exemption from
+        // the provider/token filters above. Search the rest and re-pin it on top.
+        const schemaRow = filteredResults.find(
+          (item) => item.name === SAMPLE_SPEC_ROW_NAME,
+        );
+        const searchable = schemaRow
+          ? filteredResults.filter((item) => item.name !== SAMPLE_SPEC_ROW_NAME)
+          : filteredResults;
+
+        const filteredIndex = new Fuse(searchable, {
           threshold: 0.3,
           keys: [
             {
@@ -296,7 +458,8 @@
         });
 
         const searchResults = filteredIndex.search(query);
-        filteredResults = searchResults.map((result) => result.item);
+        const matched = searchResults.map((result) => result.item);
+        filteredResults = schemaRow ? [schemaRow, ...matched] : matched;
       }
 
       results = filteredResults.map((item, refIndex) => ({ item, refIndex }));
@@ -460,6 +623,13 @@
         {/each}
       </div>
     </div>
+  {:else if loadError}
+    <div class="table-container">
+      <div class="load-error" role="alert">
+        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
+        <span>{loadError}</span>
+      </div>
+    </div>
   {:else}
     {#if query != "" && results.length < 12}
       <div class="add-model-section">
@@ -478,26 +648,36 @@
               <span class="sort-icon" class:active={sortColumn === "context"} class:desc={sortColumn === "context" && sortDirection === "desc"}>↑</span>
             </th>
             <th class="th-sortable" on:click={() => handleSort("input")}>
-              Input $/M
+              Input cost
               <span class="sort-icon" class:active={sortColumn === "input"} class:desc={sortColumn === "input" && sortDirection === "desc"}>↑</span>
             </th>
             <th class="th-sortable" on:click={() => handleSort("output")}>
-              Output $/M
+              Output cost
               <span class="sort-icon" class:active={sortColumn === "output"} class:desc={sortColumn === "output" && sortDirection === "desc"}>↑</span>
             </th>
-            <th class="th-sortable th-hide-mobile" on:click={() => handleSort("cache_read")}>
-              Cache Read
+            <th class="th-sortable th-hide-mobile" on:click={() => handleSort("cache_read")} title="Prompt cache read (chat models)">
+              Cache read
               <span class="sort-icon" class:active={sortColumn === "cache_read"} class:desc={sortColumn === "cache_read" && sortDirection === "desc"}>↑</span>
             </th>
-            <th class="th-sortable th-hide-mobile" on:click={() => handleSort("cache_write")}>
-              Cache Write
+            <th class="th-sortable th-hide-mobile" on:click={() => handleSort("cache_write")} title="Prompt cache write (chat models)">
+              Cache write
               <span class="sort-icon" class:active={sortColumn === "cache_write"} class:desc={sortColumn === "cache_write" && sortDirection === "desc"}>↑</span>
             </th>
           </tr>
         </thead>
         <tbody>
-          {#each results as { item: { name, mode, litellm_provider, max_input_tokens, max_output_tokens, input_cost_per_token, output_cost_per_token, cache_creation_input_token_cost, cache_read_input_token_cost, supports_function_calling, supports_vision, supports_response_schema, supports_tool_choice, supports_parallel_function_calling, supports_audio_input, supports_prompt_caching, ...data } } (name)}
-            <tr class="model-row" class:expanded={expandedRows.has(name)} on:click={() => toggleRow(name)}>
+          {#each results as { item } (item.name)}
+            {@const name = item.name}
+            {@const mode = item.mode}
+            {@const litellm_provider = item.litellm_provider}
+            {@const max_input_tokens = item.max_input_tokens}
+            {@const max_output_tokens = item.max_output_tokens}
+            <tr
+              class="model-row"
+              class:model-row-schema={name === SAMPLE_SPEC_ROW_NAME}
+              class:expanded={expandedRows.has(name)}
+              on:click={() => toggleRow(name)}
+            >
               <td class="model-name">
                 <div class="model-info">
                   <svg class="expand-icon" class:expanded={expandedRows.has(name)} width="14" height="14" viewBox="0 0 16 16" fill="none">
@@ -526,7 +706,7 @@
                   <div class="model-name-group">
                     <span class="model-title" title={getDisplayModelName(name, litellm_provider)}>{getDisplayModelName(name, litellm_provider)}</span>
                     {#if mode}
-                      <span class="mode-badge">{getModeLabel(mode)}</span>
+                      <span class="mode-badge" class:mode-badge-schema={name === SAMPLE_SPEC_ROW_NAME}>{getModeLabel(mode)}</span>
                     {/if}
                   </div>
                   <button
@@ -546,38 +726,63 @@
                   </button>
                 </div>
               </td>
-              <td class="context-cell">{formatContext(max_input_tokens)}</td>
-              <td class="cost-cell">{formatCost(input_cost_per_token)}</td>
-              <td class="cost-cell">{formatCost(output_cost_per_token)}</td>
-              <td class="cost-cell td-hide-mobile">{formatCost(cache_read_input_token_cost)}</td>
-              <td class="cost-cell td-hide-mobile">{formatCost(cache_creation_input_token_cost)}</td>
+              <td
+                class="context-cell"
+                class:context-cell-schema={name === SAMPLE_SPEC_ROW_NAME}
+                title={name === SAMPLE_SPEC_ROW_NAME && typeof max_input_tokens === "string" ? max_input_tokens : undefined}
+              >{contextCellForRow(item, max_input_tokens)}</td>
+              <td class="cost-cell">{tableInputCell(item)}</td>
+              <td class="cost-cell">{tableOutputCell(item)}</td>
+              <td class="cost-cell td-hide-mobile">{tableCacheReadCell(item)}</td>
+              <td class="cost-cell td-hide-mobile">{tableCacheWriteCell(item)}</td>
             </tr>
             {#if expandedRows.has(name)}
               <tr class="expanded-content" transition:fly={{ y: -10, duration: 200 }}>
                 <td colspan="6">
                   <div class="detail-panel">
                     <div class="detail-grid">
-                      <!-- Pricing Cards -->
                       <div class="detail-section">
-                        <h4 class="detail-heading">Pricing <span class="detail-unit">per 1M tokens</span></h4>
+                        <h4 class="detail-heading">
+                          {#if isSampleSpecCatalogRow(item)}
+                            Field reference <span class="detail-unit">example values and types from the catalog JSON</span>
+                          {:else if isImagePricingMode(mode)}
+                            Image pricing <span class="detail-unit">per image where applicable</span>
+                          {:else if mode === "audio_speech"}
+                            Audio pricing <span class="detail-unit">per character where applicable</span>
+                          {:else if mode === "audio_transcription"}
+                            Audio pricing <span class="detail-unit">per second where applicable</span>
+                          {:else}
+                            Token pricing <span class="detail-unit">per 1M tokens where applicable</span>
+                          {/if}
+                        </h4>
                         <div class="pricing-cards">
                           <div class="pricing-card">
                             <span class="pricing-label">Input</span>
-                            <span class="pricing-value">{formatCost(input_cost_per_token)}</span>
+                            <span class="pricing-value">{tableInputCell(item)}</span>
                           </div>
                           <div class="pricing-card">
                             <span class="pricing-label">Output</span>
-                            <span class="pricing-value">{formatCost(output_cost_per_token)}</span>
+                            <span class="pricing-value">{tableOutputCell(item)}</span>
                           </div>
                           <div class="pricing-card">
-                            <span class="pricing-label">Cache Read</span>
-                            <span class="pricing-value">{formatCost(cache_read_input_token_cost)}</span>
+                            <span class="pricing-label">Cache read</span>
+                            <span class="pricing-value">{tableCacheReadCell(item)}</span>
                           </div>
                           <div class="pricing-card">
-                            <span class="pricing-label">Cache Write</span>
-                            <span class="pricing-value">{formatCost(cache_creation_input_token_cost)}</span>
+                            <span class="pricing-label">Cache write</span>
+                            <span class="pricing-value">{tableCacheWriteCell(item)}</span>
                           </div>
                         </div>
+                        {#if isImagePricingMode(mode)}
+                          {@const imageExtras = getImagePricingExtraRows(item)}
+                          {#if imageExtras.length > 0}
+                            <ul class="pricing-extras">
+                              {#each imageExtras as row}
+                                <li><span class="pricing-extras-label">{row.label}</span> {row.value}</li>
+                              {/each}
+                            </ul>
+                          {/if}
+                        {/if}
                       </div>
 
                       <!-- Model Info -->
@@ -594,27 +799,28 @@
                           </div>
                           <div class="info-row">
                             <span class="info-label">Max Input</span>
-                            <span class="info-value">{max_input_tokens ? max_input_tokens.toLocaleString() + " tokens" : "—"}</span>
+                            <span class="info-value">{formatDetailTokenField(max_input_tokens)}</span>
                           </div>
                           <div class="info-row">
                             <span class="info-label">Max Output</span>
-                            <span class="info-value">{max_output_tokens ? max_output_tokens.toLocaleString() + " tokens" : "—"}</span>
+                            <span class="info-value">{formatDetailTokenField(max_output_tokens)}</span>
                           </div>
                         </div>
                       </div>
 
-                      <!-- Features -->
+                      <!-- Features (chat / completion models) -->
+                      {#if !isImagePricingMode(mode) && !isAudioPricingMode(mode)}
                       <div class="detail-section">
                         <h4 class="detail-heading">Features</h4>
                         <div class="feature-list">
                           {#each [
-                            { key: supports_function_calling, label: "Function Calling" },
-                            { key: supports_vision, label: "Vision" },
-                            { key: supports_response_schema, label: "JSON Mode" },
-                            { key: supports_tool_choice, label: "Tool Choice" },
-                            { key: supports_parallel_function_calling, label: "Parallel Calls" },
-                            { key: supports_audio_input, label: "Audio Input" },
-                            { key: supports_prompt_caching, label: "Prompt Caching" },
+                            { key: item.supports_function_calling, label: "Function Calling" },
+                            { key: item.supports_vision, label: "Vision" },
+                            { key: item.supports_response_schema, label: "JSON Mode" },
+                            { key: item.supports_tool_choice, label: "Tool Choice" },
+                            { key: item.supports_parallel_function_calling, label: "Parallel Calls" },
+                            { key: item.supports_audio_input, label: "Audio Input" },
+                            { key: item.supports_prompt_caching, label: "Prompt Caching" },
                           ] as feature}
                             <div class="feature-item" class:supported={feature.key}>
                               {#if feature.key}
@@ -627,6 +833,7 @@
                           {/each}
                         </div>
                       </div>
+                      {/if}
                     </div>
 
                     <!-- Code snippet with tabs -->
@@ -645,42 +852,31 @@
                           >AI Gateway (Proxy)</button>
                         </div>
                         {#if !codeTabStates[name] || codeTabStates[name] === "sdk"}
-                          <button class="copy-code-btn" on:click|stopPropagation={() => copyToClipboard(`from litellm import completion\n\nresponse = completion(\n    model="${getDisplayModelName(name, litellm_provider)}",\n    messages=[{"role": "user", "content": "Hello!"}]\n)`)}>
+                          <button class="copy-code-btn" on:click|stopPropagation={() => copyToClipboard(getLiteLLmSdkSnippet(mode, getDisplayModelName(name, litellm_provider)))}>
                             {copiedModel.includes("from litellm") ? "Copied!" : "Copy"}
                           </button>
                         {:else}
-                          <button class="copy-code-btn" on:click|stopPropagation={() => copyToClipboard(`curl http://0.0.0.0:4000/v1/chat/completions \\\n  -H "Content-Type: application/json" \\\n  -H "Authorization: Bearer sk-1234" \\\n  -d '{\n    "model": "${getDisplayModelName(name, litellm_provider)}",\n    "messages": [{"role": "user", "content": "Hello!"}]\n  }'`)}>
+                          <button class="copy-code-btn" on:click|stopPropagation={() => copyToClipboard(getLiteLLmProxyCurlSnippet(mode, getDisplayModelName(name, litellm_provider)))}>
                             {copiedModel.includes("curl") ? "Copied!" : "Copy"}
                           </button>
                         {/if}
                       </div>
                       {#if !codeTabStates[name] || codeTabStates[name] === "sdk"}
-                        <pre class="code-snippet"><code><span class="code-kw">from</span> litellm <span class="code-kw">import</span> completion
-
-response = completion(
-    model=<span class="code-str">"{getDisplayModelName(name, litellm_provider)}"</span>,
-    messages=[{`{`}<span class="code-str">"role"</span>: <span class="code-str">"user"</span>, <span class="code-str">"content"</span>: <span class="code-str">"Hello!"</span>{`}`}]
-)</code></pre>
+                        <pre class="code-snippet"><code>{@html getLiteLLmSdkSnippetHtml(mode, getDisplayModelName(name, litellm_provider))}</code></pre>
                       {:else}
-                        <pre class="code-snippet"><code><span class="code-comment"># Start proxy: litellm --model {getDisplayModelName(name, litellm_provider)}</span>
-
-curl http://0.0.0.0:4000/v1/chat/completions \
-  -H <span class="code-str">"Content-Type: application/json"</span> \
-  -H <span class="code-str">"Authorization: Bearer sk-1234"</span> \
-  -d <span class="code-str">'{`{`}
-    "model": "{getDisplayModelName(name, litellm_provider)}",
-    "messages": [{`{`}"role": "user", "content": "Hello!"{`}`}]
-  {`}`}'</span></code></pre>
+                        <pre class="code-snippet"><code>{@html getLiteLLmProxyCurlSnippetHtml(mode, getDisplayModelName(name, litellm_provider))}</code></pre>
                       {/if}
                     </div>
 
                     <!-- Actions -->
+                    {#if name !== SAMPLE_SPEC_ROW_NAME}
                     <div class="detail-actions">
                       <a href={getIssueUrlForFix(name)} target="_blank" rel="noopener noreferrer" class="detail-action-link" on:click|stopPropagation>
                         <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
                         Report incorrect data
                       </a>
                     </div>
+                    {/if}
                   </div>
                 </td>
               </tr>
@@ -1072,11 +1268,14 @@
 
   table {
     width: 100%;
-    border-collapse: collapse;
+    /* separate avoids sticky <th> overlapping first tbody rows (collapse + sticky bug) */
+    border-collapse: separate;
+    border-spacing: 0;
     background: var(--card-bg);
     border-radius: 12px;
     border: 1px solid var(--border-color);
-    overflow: hidden;
+    /* Do not use overflow:hidden here — it breaks position:sticky on <th> and clips header vs body paint. */
+    overflow: visible;
   }
 
   thead {
@@ -1097,7 +1296,8 @@
     user-select: none;
     position: sticky;
     top: 63px;
-    z-index: 10;
+    z-index: 25;
+    box-shadow: 0 1px 0 var(--border-color);
   }
 
   .th-model { padding-left: 1rem; }
@@ -1125,8 +1325,14 @@
     border-bottom: 1px solid var(--border-color);
     transition: background-color 0.1s ease;
     cursor: pointer;
+    position: relative;
+    z-index: 0;
   }
 
+  tbody tr.model-row-schema td {
+    vertical-align: top;
+  }
+
   tbody tr.model-row:hover {
     background-color: var(--hover-bg);
     box-shadow: inset 3px 0 0 var(--litellm-primary);
@@ -1248,12 +1454,31 @@
     flex-shrink: 0;
   }
 
+  .mode-badge.mode-badge-schema {
+    white-space: normal;
+    max-width: min(40rem, 92vw);
+    line-height: 1.35;
+    text-transform: none;
+    letter-spacing: normal;
+    font-weight: 500;
+    font-size: 0.625rem;
+  }
+
   .context-cell {
     font-weight: 600;
     font-variant-numeric: tabular-nums;
     font-size: 0.8125rem;
   }
 
+  .context-cell.context-cell-schema {
+    white-space: normal;
+    font-weight: 400;
+    font-size: 0.75rem;
+    line-height: 1.4;
+    color: var(--text-secondary);
+    max-width: 18rem;
+  }
+
   .cost-cell {
     color: var(--text-secondary);
     font-variant-numeric: tabular-nums;
@@ -1322,6 +1547,29 @@
     font-variant-numeric: tabular-nums;
   }
 
+  .pricing-empty {
+    margin: 0;
+    font-size: 0.875rem;
+    color: var(--muted-color);
+  }
+
+  .pricing-extras {
+    margin: 0.625rem 0 0;
+    padding: 0;
+    list-style: none;
+    display: flex;
+    flex-direction: column;
+    gap: 0.35rem;
+    font-size: 0.8125rem;
+    color: var(--muted-color);
+  }
+
+  .pricing-extras-label {
+    font-weight: 600;
+    color: var(--text-color);
+    opacity: 0.85;
+  }
+
   .info-rows {
     display: flex;
     flex-direction: column;
@@ -1348,6 +1596,8 @@
     font-weight: 600;
     color: var(--text-color);
     font-family: 'JetBrains Mono', monospace;
+    overflow-wrap: anywhere;
+    word-break: break-word;
   }
 
   .feature-list {
@@ -1451,13 +1701,26 @@
     color: var(--code-text);
   }
 
-  .code-snippet code { display: block; }
-  .code-kw { color: #8b5cf6; }
-  .code-str { color: #10b981; }
+  .code-snippet code { display: block; white-space: pre; }
 
+  /* {@html} snippets are not scoped — use :global so .code-kw / .code-str apply */
+  .code-snippet :global(.code-kw) {
+    color: #8b5cf6;
+  }
+  .code-snippet :global(.code-str) {
+    color: #10b981;
+  }
+  .code-snippet :global(.code-comment) {
+    color: var(--muted-color);
+  }
+
   @media (prefers-color-scheme: dark) {
-    .code-kw { color: #a78bfa; }
-    .code-str { color: #34d399; }
+    .code-snippet :global(.code-kw) {
+      color: #a78bfa;
+    }
+    .code-snippet :global(.code-str) {
+      color: #34d399;
+    }
   }
 
   .detail-actions {
@@ -1478,6 +1741,23 @@
 
   .detail-action-link:hover { color: var(--litellm-primary); }
 
+  .load-error {
+    display: flex;
+    align-items: center;
+    gap: 0.625rem;
+    padding: 1rem 1.25rem;
+    background: var(--bg-secondary);
+    border: 1px solid var(--border-color);
+    border-radius: 12px;
+    color: var(--text-color);
+    font-size: 0.875rem;
+  }
+
+  .load-error svg {
+    color: #ef4444;
+    flex-shrink: 0;
+  }
+
   /* Skeleton */
   .skeleton-table {
     display: flex;

diff --git a/src/catalogApi.ts b/src/catalogApi.ts
new file mode 100644
--- /dev/null
+++ b/src/catalogApi.ts
@@ -1,0 +1,168 @@
+/**
+ * Load models from litellm-model-catalog-api (paginated) and map to the
+ * flat item shape the UI expects (name + litellm_provider).
+ */
+
+export type PricingSlot = {
+  amount_usd: number | null;
+  unit: string | null;
+  source_field: string | null;
+};
+
+export type PricingSlots = {
+  input: PricingSlot;
+  output: PricingSlot;
+  cache_read: PricingSlot;
+  cache_write: PricingSlot;
+};
+
+export type CatalogApiEntry = {
... diff truncated: showing 800 of 1571 lines

You can send follow-ups to the cloud agent here.

Comment thread src/modelPresentation.ts Outdated
Comment thread src/App.svelte
Comment thread src/App.svelte
@mateo-berri

Copy link
Copy Markdown
Collaborator Author

bugbot run


Generated by Claude Code

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Token filters drop string limits
    • Added a tokenLimitValue helper that coerces both numbers and numeric strings to finite numbers before the min-context comparison, restoring the previous string-tolerant behavior consistent with formatDetailTokenField and contextCellForRow.
  • ✅ Fixed: Catalog pagination stops silently
    • fetchAllCatalogModels now throws when the 200-page safety cap is hit with has_more still true, so callers surface loadError instead of returning a silently truncated catalog.
Preview (1d7ec5c64d)
diff --git a/src/App.svelte b/src/App.svelte
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -6,6 +6,30 @@
   import { getProviderInitial, getProviderLogo } from "./providers";
   import ProviderDropdown from "./ProviderDropdown.svelte";
   import { trackSearch } from "./analytics";
+  import {
+    fetchAllCatalogModels,
+  } from "./catalogApi";
+  import {
+    isImagePricingMode,
+    isAudioPricingMode,
+    tableImageInputCost,
+    tableImageOutputCost,
+    imageModeInputSortValue,
+    imageModeOutputSortValue,
+    getImagePricingExtraRows,
+    getLiteLLmSdkSnippet,
+    getLiteLLmSdkSnippetHtml,
+    getLiteLLmProxyCurlSnippet,
+    getLiteLLmProxyCurlSnippetHtml,
+    displayChatInputCost,
+    displayChatOutputCost,
+    displayChatCacheRead,
+    displayChatCacheWrite,
+    chatSortInput,
+    chatSortOutput,
+    chatSortCacheRead,
+    chatSortCacheWrite,
+  } from "./modelPresentation";
 
   type Item = {
     name: string;
@@ -23,6 +47,16 @@
   const RESOURCE_BACKUP_NAME = "model_prices_and_context_window_backup.json";
   const RESOURCE_PATH = `${RESOURCE_NAME}`;
   const RESOURCE_BACKUP_PATH = `litellm/${RESOURCE_BACKUP_NAME}`;
+
+  /** Schema reference row in `model_prices_and_context_window.json` — same id as the JSON key. */
+  const SAMPLE_SPEC_ROW_NAME = "sample_spec";
+
+  /** When true, load from litellm-model-catalog-api (see .env.example). */
+  const useLocalCatalogApi =
+    import.meta.env.VITE_USE_LOCAL_CATALOG_API === "true" ||
+    import.meta.env.VITE_USE_LOCAL_CATALOG_API === "1";
+  /** Optional absolute API origin, e.g. http://127.0.0.1:8000. Empty = same-origin /model_catalog (Vite proxy). */
+  const catalogApiBase = (import.meta.env.VITE_CATALOG_API_BASE as string | undefined)?.replace(/\/$/, "") ?? "";
   let providers: string[] = [];
   let selectedProvider: string = "";
   let maxInputTokens: number | null = null;
@@ -69,33 +103,83 @@
         sha = text;
       });
 
+    function prependSampleSpecRow(
+      rows: Item[],
+      spec: Record<string, unknown> | null | undefined,
+    ): Item[] {
+      if (!spec || typeof spec !== "object") return rows;
+      const refRow: Item = { ...spec, name: SAMPLE_SPEC_ROW_NAME } as Item;
+      return [refRow, ...rows];
+    }
+
+    const finishLoad = (items: Item[]) => {
+      providers = [
+        ...new Set(
+          items
+            .filter((i) => i.name !== SAMPLE_SPEC_ROW_NAME)
+            .map((i) => i.litellm_provider)
+            .filter(Boolean),
+        ),
+      ];
+      providers.sort();
+
+      index = new Fuse(items, {
+        threshold: 0.3,
+        keys: [
+          {
+            name: "name",
+            weight: 1.5,
+          },
+          "mode",
+          "litellm_provider",
+        ],
+      });
+
+      results = items.map((item, refIndex) => ({ item, refIndex }));
+      loading = false;
+    };
+
+    if (useLocalCatalogApi) {
+      fetch(
+        `https://raw.githubusercontent.com/${REPO_FULL_NAME}/main/${RESOURCE_PATH}`,
+      )
+        .then((res) => res.text())
+        .then((text) => {
+          lines = text.split("\n");
+        })
+        .catch((err) => {
+          console.error(err);
+        });
+      fetchAllCatalogModels(catalogApiBase)
+        .then(({ models, sample_spec }) => {
+          finishLoad(prependSampleSpecRow(models as Item[], sample_spec));
+        })
+        .catch((err) => {
+          console.error(err);
+          loadError =
+            "Failed to load model catalog from the local API. Check that the litellm-model-catalog-api service is running and reachable.";
+          loading = false;
+        });
+      return;
+    }
+
     fetch(
       `https://raw.githubusercontent.com/${REPO_FULL_NAME}/main/${RESOURCE_PATH}`,
     )
       .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))];
-        providers.sort();
-
-        index = new Fuse(items, {
-          threshold: 0.3,
-          keys: [
-            {
-              name: "name",
-              weight: 1.5,
-            },
-            "mode",
-            "litellm_provider",
-          ],
-        });
-
-        results = items.map((item, refIndex) => ({ item, refIndex }));
-        loading = false;
+        const parsed = JSON.parse(text) as Record<string, unknown>;
+        const spec =
+          parsed.sample_spec != null &&
+          typeof parsed.sample_spec === "object" &&
+          !Array.isArray(parsed.sample_spec)
+            ? (parsed.sample_spec as Record<string, unknown>)
+            : null;
+        const items: Item[] = Object.entries(parsed)
+          .filter(([k]) => k !== "sample_spec")
+          .map(([k, v]: any) => ({ name: k, ...v }));
+        finishLoad(prependSampleSpecRow(items, spec));
       });
   });
 
@@ -155,6 +239,7 @@
   let index: Fuse<Item>;
   let results: ResultItem[] = [];
   let loading = true;
+  let loadError: string | null = null;
   let expandedRows = new Set<string>();
 
   $: {
@@ -200,32 +285,118 @@
   }
 
   function getSortValue(item: any, column: string): number {
+    if (isSampleSpecCatalogRow(item)) {
+      if (column === "context") {
+        return typeof item.max_input_tokens === "number" ? item.max_input_tokens : 0;
+      }
+      return 0;
+    }
+    if (isImagePricingMode(item.mode)) {
+      switch (column) {
+        case "context":
+          return item.max_input_tokens || 0;
+        case "input":
+          return imageModeInputSortValue(item);
+        case "output":
+          return imageModeOutputSortValue(item);
+        case "cache_read":
+        case "cache_write":
+          return 0;
+        default:
+          return 0;
+      }
+    }
     switch (column) {
-      case "context": return item.max_input_tokens || 0;
-      case "input": return item.input_cost_per_token || 0;
-      case "output": return item.output_cost_per_token || 0;
-      case "cache_read": return item.cache_read_input_token_cost || 0;
-      case "cache_write": return item.cache_creation_input_token_cost || 0;
-      default: return 0;
+      case "context":
+        return item.max_input_tokens || 0;
+      case "input":
+        return chatSortInput(item);
+      case "output":
+        return chatSortOutput(item);
+      case "cache_read":
+        return chatSortCacheRead(item);
+      case "cache_write":
+        return chatSortCacheWrite(item);
+      default:
+        return 0;
     }
   }
 
   function applySorting() {
     if (!sortColumn) return;
-    results = [...results].sort((a, b) => {
+    // Keep the sample_spec schema row pinned to the top, mirroring how
+    // filterResults pins it during search. Sorting reorders only the rest.
+    const schemaRow = results.find((r) => isSampleSpecCatalogRow(r.item));
+    const others = schemaRow
+      ? results.filter((r) => !isSampleSpecCatalogRow(r.item))
+      : results;
+    const sorted = [...others].sort((a, b) => {
       const aVal = getSortValue(a.item, sortColumn);
       const bVal = getSortValue(b.item, sortColumn);
       return sortDirection === "asc" ? aVal - bVal : bVal - aVal;
     });
+    results = schemaRow ? [schemaRow, ...sorted] : sorted;
   }
 
-  function formatCost(costPerToken: number | undefined): string {
-    if (!costPerToken) return "—";
-    const perMillion = costPerToken * 1000000;
-    if (perMillion < 0.01) return "<$0.01";
-    return "$" + perMillion.toFixed(2);
+  function isSampleSpecCatalogRow(item: { name: string }): boolean {
+    return item.name === SAMPLE_SPEC_ROW_NAME;
   }
 
+  /** Context column: `sample_spec` shows the catalog hint string (original UI), not "—". */
+  function contextCellForRow(item: Item, max_input_tokens: unknown): string {
+    if (isSampleSpecCatalogRow(item)) {
+      if (typeof max_input_tokens === "string" && max_input_tokens.trim() !== "") {
+        return max_input_tokens;
+      }
+      return "—";
+    }
+    return formatContext(max_input_tokens as number | undefined);
+  }
+
+  /** Coerce a max_input/output_tokens field to a finite number for filtering.
+   *  Accepts JS numbers and numeric strings (catalog payloads sometimes encode
+   *  limits as strings); returns null for non-numeric values like schema hints. */
+  function tokenLimitValue(v: unknown): number | null {
+    if (typeof v === "number") return Number.isFinite(v) ? v : null;
+    if (typeof v === "string") {
+      const trimmed = v.trim();
+      if (trimmed === "") return null;
+      const n = Number(trimmed);
+      return Number.isFinite(n) ? n : null;
+    }
+    return null;
+  }
+
+  /** Model info max input/output: numbers get "tokens" suffix; strings (schema hints) pass through. */
+  function formatDetailTokenField(v: unknown): string {
+    if (v == null || v === "") return "—";
+    if (typeof v === "number" && !Number.isNaN(v)) return v.toLocaleString() + " tokens";
+    if (typeof v === "string") return v;
+    return "—";
+  }
+
+  function tableInputCell(item: any): string {
+    if (isSampleSpecCatalogRow(item)) return "—";
+    return isImagePricingMode(item.mode) ? tableImageInputCost(item) : displayChatInputCost(item);
+  }
+
+  function tableOutputCell(item: any): string {
+    if (isSampleSpecCatalogRow(item)) return "—";
+    return isImagePricingMode(item.mode) ? tableImageOutputCost(item) : displayChatOutputCost(item);
+  }
+
+  function tableCacheReadCell(item: any): string {
+    if (isSampleSpecCatalogRow(item)) return "—";
+    if (isImagePricingMode(item.mode)) return "—";
+    return displayChatCacheRead(item);
+  }
+
+  function tableCacheWriteCell(item: any): string {
+    if (isSampleSpecCatalogRow(item)) return "—";
+    if (isImagePricingMode(item.mode)) return "—";
+    return displayChatCacheWrite(item);
+  }
+
   function formatContext(tokens: number | undefined): string {
     if (!tokens || tokens <= 0) return "—";
     if (tokens >= 1000000) return (tokens / 1000000).toFixed(0) + "M";
@@ -233,18 +404,6 @@
     return tokens.toString();
   }
 
-  function getFeatureBadges(item: any): string[] {
-    const badges: string[] = [];
-    if (item.supports_function_calling) badges.push("Functions");
-    if (item.supports_vision) badges.push("Vision");
-    if (item.supports_response_schema) badges.push("JSON");
-    if (item.supports_tool_choice) badges.push("Tools");
-    if (item.supports_parallel_function_calling) badges.push("Parallel");
-    if (item.supports_audio_input) badges.push("Audio");
-    if (item.supports_prompt_caching) badges.push("Caching");
-    return badges;
-  }
-
   function getModeLabel(mode: string | undefined): string {
     if (!mode) return "";
     const labels: Record<string, string> = {
@@ -252,6 +411,7 @@
       "completion": "Completion",
       "embedding": "Embedding",
       "image_generation": "Image Gen",
+      "image_edit": "Image edit",
       "audio_transcription": "Transcription",
       "audio_speech": "TTS",
       "moderation": "Moderation",
@@ -271,19 +431,35 @@
 
       const allItems = index["_docs"] as Item[];
 
-      filteredResults = allItems.filter(
-        (item) =>
-          (!selectedProvider || item.litellm_provider === selectedProvider) &&
-          (maxInputTokens === null ||
-            (item.max_input_tokens &&
-              item.max_input_tokens >= maxInputTokens)) &&
-          (maxOutputTokens === null ||
-            (item.max_output_tokens &&
-              item.max_output_tokens >= maxOutputTokens)),
-      );
+      filteredResults = allItems.filter((item) => {
+        const schema = item.name === SAMPLE_SPEC_ROW_NAME;
+        const providerOk =
+          !selectedProvider || schema || item.litellm_provider === selectedProvider;
+        const inputLimit = tokenLimitValue(item.max_input_tokens);
+        const outputLimit = tokenLimitValue(item.max_output_tokens);
+        const inputOk =
+          maxInputTokens === null ||
+          schema ||
+          (inputLimit !== null && inputLimit >= maxInputTokens);
+        const outputOk =
+          maxOutputTokens === null ||
+          schema ||
+          (outputLimit !== null && outputLimit >= maxOutputTokens);
+        return providerOk && inputOk && outputOk;
+      });
 
       if (query) {
-        const filteredIndex = new Fuse(filteredResults, {
+        // The sample_spec reference row is field documentation and stays
+        // visible regardless of the search query, mirroring its exemption from
+        // the provider/token filters above. Search the rest and re-pin it on top.
+        const schemaRow = filteredResults.find(
+          (item) => item.name === SAMPLE_SPEC_ROW_NAME,
+        );
+        const searchable = schemaRow
+          ? filteredResults.filter((item) => item.name !== SAMPLE_SPEC_ROW_NAME)
+          : filteredResults;
+
+        const filteredIndex = new Fuse(searchable, {
           threshold: 0.3,
           keys: [
             {
@@ -296,7 +472,8 @@
         });
 
         const searchResults = filteredIndex.search(query);
-        filteredResults = searchResults.map((result) => result.item);
+        const matched = searchResults.map((result) => result.item);
+        filteredResults = schemaRow ? [schemaRow, ...matched] : matched;
       }
 
       results = filteredResults.map((item, refIndex) => ({ item, refIndex }));
@@ -460,6 +637,13 @@
         {/each}
       </div>
     </div>
+  {:else if loadError}
+    <div class="table-container">
+      <div class="load-error" role="alert">
+        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
+        <span>{loadError}</span>
+      </div>
+    </div>
   {:else}
     {#if query != "" && results.length < 12}
       <div class="add-model-section">
@@ -478,26 +662,36 @@
               <span class="sort-icon" class:active={sortColumn === "context"} class:desc={sortColumn === "context" && sortDirection === "desc"}>↑</span>
             </th>
             <th class="th-sortable" on:click={() => handleSort("input")}>
-              Input $/M
+              Input cost
               <span class="sort-icon" class:active={sortColumn === "input"} class:desc={sortColumn === "input" && sortDirection === "desc"}>↑</span>
             </th>
             <th class="th-sortable" on:click={() => handleSort("output")}>
-              Output $/M
+              Output cost
               <span class="sort-icon" class:active={sortColumn === "output"} class:desc={sortColumn === "output" && sortDirection === "desc"}>↑</span>
             </th>
-            <th class="th-sortable th-hide-mobile" on:click={() => handleSort("cache_read")}>
-              Cache Read
+            <th class="th-sortable th-hide-mobile" on:click={() => handleSort("cache_read")} title="Prompt cache read (chat models)">
+              Cache read
               <span class="sort-icon" class:active={sortColumn === "cache_read"} class:desc={sortColumn === "cache_read" && sortDirection === "desc"}>↑</span>
             </th>
-            <th class="th-sortable th-hide-mobile" on:click={() => handleSort("cache_write")}>
-              Cache Write
+            <th class="th-sortable th-hide-mobile" on:click={() => handleSort("cache_write")} title="Prompt cache write (chat models)">
+              Cache write
               <span class="sort-icon" class:active={sortColumn === "cache_write"} class:desc={sortColumn === "cache_write" && sortDirection === "desc"}>↑</span>
             </th>
           </tr>
         </thead>
         <tbody>
-          {#each results as { item: { name, mode, litellm_provider, max_input_tokens, max_output_tokens, input_cost_per_token, output_cost_per_token, cache_creation_input_token_cost, cache_read_input_token_cost, supports_function_calling, supports_vision, supports_response_schema, supports_tool_choice, supports_parallel_function_calling, supports_audio_input, supports_prompt_caching, ...data } } (name)}
-            <tr class="model-row" class:expanded={expandedRows.has(name)} on:click={() => toggleRow(name)}>
+          {#each results as { item } (item.name)}
+            {@const name = item.name}
+            {@const mode = item.mode}
+            {@const litellm_provider = item.litellm_provider}
+            {@const max_input_tokens = item.max_input_tokens}
+            {@const max_output_tokens = item.max_output_tokens}
+            <tr
+              class="model-row"
+              class:model-row-schema={name === SAMPLE_SPEC_ROW_NAME}
+              class:expanded={expandedRows.has(name)}
+              on:click={() => toggleRow(name)}
+            >
               <td class="model-name">
                 <div class="model-info">
                   <svg class="expand-icon" class:expanded={expandedRows.has(name)} width="14" height="14" viewBox="0 0 16 16" fill="none">
@@ -526,7 +720,7 @@
                   <div class="model-name-group">
                     <span class="model-title" title={getDisplayModelName(name, litellm_provider)}>{getDisplayModelName(name, litellm_provider)}</span>
                     {#if mode}
-                      <span class="mode-badge">{getModeLabel(mode)}</span>
+                      <span class="mode-badge" class:mode-badge-schema={name === SAMPLE_SPEC_ROW_NAME}>{getModeLabel(mode)}</span>
                     {/if}
                   </div>
                   <button
@@ -546,38 +740,63 @@
                   </button>
                 </div>
               </td>
-              <td class="context-cell">{formatContext(max_input_tokens)}</td>
-              <td class="cost-cell">{formatCost(input_cost_per_token)}</td>
-              <td class="cost-cell">{formatCost(output_cost_per_token)}</td>
-              <td class="cost-cell td-hide-mobile">{formatCost(cache_read_input_token_cost)}</td>
-              <td class="cost-cell td-hide-mobile">{formatCost(cache_creation_input_token_cost)}</td>
+              <td
+                class="context-cell"
+                class:context-cell-schema={name === SAMPLE_SPEC_ROW_NAME}
+                title={name === SAMPLE_SPEC_ROW_NAME && typeof max_input_tokens === "string" ? max_input_tokens : undefined}
+              >{contextCellForRow(item, max_input_tokens)}</td>
+              <td class="cost-cell">{tableInputCell(item)}</td>
+              <td class="cost-cell">{tableOutputCell(item)}</td>
+              <td class="cost-cell td-hide-mobile">{tableCacheReadCell(item)}</td>
+              <td class="cost-cell td-hide-mobile">{tableCacheWriteCell(item)}</td>
             </tr>
             {#if expandedRows.has(name)}
               <tr class="expanded-content" transition:fly={{ y: -10, duration: 200 }}>
                 <td colspan="6">
                   <div class="detail-panel">
                     <div class="detail-grid">
-                      <!-- Pricing Cards -->
                       <div class="detail-section">
-                        <h4 class="detail-heading">Pricing <span class="detail-unit">per 1M tokens</span></h4>
+                        <h4 class="detail-heading">
+                          {#if isSampleSpecCatalogRow(item)}
+                            Field reference <span class="detail-unit">example values and types from the catalog JSON</span>
+                          {:else if isImagePricingMode(mode)}
+                            Image pricing <span class="detail-unit">per image where applicable</span>
+                          {:else if mode === "audio_speech"}
+                            Audio pricing <span class="detail-unit">per character where applicable</span>
+                          {:else if mode === "audio_transcription"}
+                            Audio pricing <span class="detail-unit">per second where applicable</span>
+                          {:else}
+                            Token pricing <span class="detail-unit">per 1M tokens where applicable</span>
+                          {/if}
+                        </h4>
                         <div class="pricing-cards">
                           <div class="pricing-card">
                             <span class="pricing-label">Input</span>
-                            <span class="pricing-value">{formatCost(input_cost_per_token)}</span>
+                            <span class="pricing-value">{tableInputCell(item)}</span>
                           </div>
                           <div class="pricing-card">
                             <span class="pricing-label">Output</span>
-                            <span class="pricing-value">{formatCost(output_cost_per_token)}</span>
+                            <span class="pricing-value">{tableOutputCell(item)}</span>
                           </div>
                           <div class="pricing-card">
-                            <span class="pricing-label">Cache Read</span>
-                            <span class="pricing-value">{formatCost(cache_read_input_token_cost)}</span>
+                            <span class="pricing-label">Cache read</span>
+                            <span class="pricing-value">{tableCacheReadCell(item)}</span>
                           </div>
                           <div class="pricing-card">
-                            <span class="pricing-label">Cache Write</span>
-                            <span class="pricing-value">{formatCost(cache_creation_input_token_cost)}</span>
+                            <span class="pricing-label">Cache write</span>
+                            <span class="pricing-value">{tableCacheWriteCell(item)}</span>
                           </div>
                         </div>
+                        {#if isImagePricingMode(mode)}
+                          {@const imageExtras = getImagePricingExtraRows(item)}
+                          {#if imageExtras.length > 0}
+                            <ul class="pricing-extras">
+                              {#each imageExtras as row}
+                                <li><span class="pricing-extras-label">{row.label}</span> {row.value}</li>
+                              {/each}
+                            </ul>
+                          {/if}
+                        {/if}
                       </div>
 
                       <!-- Model Info -->
@@ -594,27 +813,28 @@
                           </div>
                           <div class="info-row">
                             <span class="info-label">Max Input</span>
-                            <span class="info-value">{max_input_tokens ? max_input_tokens.toLocaleString() + " tokens" : "—"}</span>
+                            <span class="info-value">{formatDetailTokenField(max_input_tokens)}</span>
                           </div>
                           <div class="info-row">
                             <span class="info-label">Max Output</span>
-                            <span class="info-value">{max_output_tokens ? max_output_tokens.toLocaleString() + " tokens" : "—"}</span>
+                            <span class="info-value">{formatDetailTokenField(max_output_tokens)}</span>
                           </div>
                         </div>
                       </div>
 
-                      <!-- Features -->
+                      <!-- Features (chat / completion models) -->
+                      {#if !isImagePricingMode(mode) && !isAudioPricingMode(mode)}
                       <div class="detail-section">
                         <h4 class="detail-heading">Features</h4>
                         <div class="feature-list">
                           {#each [
-                            { key: supports_function_calling, label: "Function Calling" },
-                            { key: supports_vision, label: "Vision" },
-                            { key: supports_response_schema, label: "JSON Mode" },
-                            { key: supports_tool_choice, label: "Tool Choice" },
-                            { key: supports_parallel_function_calling, label: "Parallel Calls" },
-                            { key: supports_audio_input, label: "Audio Input" },
-                            { key: supports_prompt_caching, label: "Prompt Caching" },
+                            { key: item.supports_function_calling, label: "Function Calling" },
+                            { key: item.supports_vision, label: "Vision" },
+                            { key: item.supports_response_schema, label: "JSON Mode" },
+                            { key: item.supports_tool_choice, label: "Tool Choice" },
+                            { key: item.supports_parallel_function_calling, label: "Parallel Calls" },
+                            { key: item.supports_audio_input, label: "Audio Input" },
+                            { key: item.supports_prompt_caching, label: "Prompt Caching" },
                           ] as feature}
                             <div class="feature-item" class:supported={feature.key}>
                               {#if feature.key}
@@ -627,6 +847,7 @@
                           {/each}
                         </div>
                       </div>
+                      {/if}
                     </div>
 
                     <!-- Code snippet with tabs -->
@@ -645,42 +866,31 @@
                           >AI Gateway (Proxy)</button>
                         </div>
                         {#if !codeTabStates[name] || codeTabStates[name] === "sdk"}
-                          <button class="copy-code-btn" on:click|stopPropagation={() => copyToClipboard(`from litellm import completion\n\nresponse = completion(\n    model="${getDisplayModelName(name, litellm_provider)}",\n    messages=[{"role": "user", "content": "Hello!"}]\n)`)}>
+                          <button class="copy-code-btn" on:click|stopPropagation={() => copyToClipboard(getLiteLLmSdkSnippet(mode, getDisplayModelName(name, litellm_provider)))}>
                             {copiedModel.includes("from litellm") ? "Copied!" : "Copy"}
                           </button>
                         {:else}
-                          <button class="copy-code-btn" on:click|stopPropagation={() => copyToClipboard(`curl http://0.0.0.0:4000/v1/chat/completions \\\n  -H "Content-Type: application/json" \\\n  -H "Authorization: Bearer sk-1234" \\\n  -d '{\n    "model": "${getDisplayModelName(name, litellm_provider)}",\n    "messages": [{"role": "user", "content": "Hello!"}]\n  }'`)}>
+                          <button class="copy-code-btn" on:click|stopPropagation={() => copyToClipboard(getLiteLLmProxyCurlSnippet(mode, getDisplayModelName(name, litellm_provider)))}>
                             {copiedModel.includes("curl") ? "Copied!" : "Copy"}
                           </button>
                         {/if}
                       </div>
                       {#if !codeTabStates[name] || codeTabStates[name] === "sdk"}
-                        <pre class="code-snippet"><code><span class="code-kw">from</span> litellm <span class="code-kw">import</span> completion
-
-response = completion(
-    model=<span class="code-str">"{getDisplayModelName(name, litellm_provider)}"</span>,
-    messages=[{`{`}<span class="code-str">"role"</span>: <span class="code-str">"user"</span>, <span class="code-str">"content"</span>: <span class="code-str">"Hello!"</span>{`}`}]
-)</code></pre>
+                        <pre class="code-snippet"><code>{@html getLiteLLmSdkSnippetHtml(mode, getDisplayModelName(name, litellm_provider))}</code></pre>
                       {:else}
-                        <pre class="code-snippet"><code><span class="code-comment"># Start proxy: litellm --model {getDisplayModelName(name, litellm_provider)}</span>
-
-curl http://0.0.0.0:4000/v1/chat/completions \
-  -H <span class="code-str">"Content-Type: application/json"</span> \
-  -H <span class="code-str">"Authorization: Bearer sk-1234"</span> \
-  -d <span class="code-str">'{`{`}
-    "model": "{getDisplayModelName(name, litellm_provider)}",
-    "messages": [{`{`}"role": "user", "content": "Hello!"{`}`}]
-  {`}`}'</span></code></pre>
+                        <pre class="code-snippet"><code>{@html getLiteLLmProxyCurlSnippetHtml(mode, getDisplayModelName(name, litellm_provider))}</code></pre>
                       {/if}
                     </div>
 
                     <!-- Actions -->
+                    {#if name !== SAMPLE_SPEC_ROW_NAME}
                     <div class="detail-actions">
                       <a href={getIssueUrlForFix(name)} target="_blank" rel="noopener noreferrer" class="detail-action-link" on:click|stopPropagation>
                         <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
                         Report incorrect data
                       </a>
                     </div>
+                    {/if}
                   </div>
                 </td>
               </tr>
@@ -1072,11 +1282,14 @@
 
   table {
     width: 100%;
-    border-collapse: collapse;
+    /* separate avoids sticky <th> overlapping first tbody rows (collapse + sticky bug) */
+    border-collapse: separate;
+    border-spacing: 0;
     background: var(--card-bg);
     border-radius: 12px;
     border: 1px solid var(--border-color);
-    overflow: hidden;
+    /* Do not use overflow:hidden here — it breaks position:sticky on <th> and clips header vs body paint. */
+    overflow: visible;
   }
 
   thead {
@@ -1097,7 +1310,8 @@
     user-select: none;
     position: sticky;
     top: 63px;
-    z-index: 10;
+    z-index: 25;
+    box-shadow: 0 1px 0 var(--border-color);
   }
 
   .th-model { padding-left: 1rem; }
@@ -1125,8 +1339,14 @@
     border-bottom: 1px solid var(--border-color);
     transition: background-color 0.1s ease;
     cursor: pointer;
+    position: relative;
+    z-index: 0;
   }
 
+  tbody tr.model-row-schema td {
+    vertical-align: top;
+  }
+
   tbody tr.model-row:hover {
     background-color: var(--hover-bg);
     box-shadow: inset 3px 0 0 var(--litellm-primary);
@@ -1248,12 +1468,31 @@
     flex-shrink: 0;
   }
 
+  .mode-badge.mode-badge-schema {
+    white-space: normal;
+    max-width: min(40rem, 92vw);
+    line-height: 1.35;
+    text-transform: none;
+    letter-spacing: normal;
+    font-weight: 500;
+    font-size: 0.625rem;
+  }
+
   .context-cell {
     font-weight: 600;
     font-variant-numeric: tabular-nums;
     font-size: 0.8125rem;
   }
 
+  .context-cell.context-cell-schema {
+    white-space: normal;
+    font-weight: 400;
+    font-size: 0.75rem;
+    line-height: 1.4;
+    color: var(--text-secondary);
+    max-width: 18rem;
+  }
+
   .cost-cell {
     color: var(--text-secondary);
     font-variant-numeric: tabular-nums;
@@ -1322,6 +1561,29 @@
     font-variant-numeric: tabular-nums;
   }
 
+  .pricing-empty {
+    margin: 0;
+    font-size: 0.875rem;
+    color: var(--muted-color);
+  }
+
+  .pricing-extras {
+    margin: 0.625rem 0 0;
+    padding: 0;
+    list-style: none;
+    display: flex;
+    flex-direction: column;
+    gap: 0.35rem;
+    font-size: 0.8125rem;
+    color: var(--muted-color);
+  }
+
+  .pricing-extras-label {
+    font-weight: 600;
+    color: var(--text-color);
+    opacity: 0.85;
+  }
+
   .info-rows {
     display: flex;
     flex-direction: column;
@@ -1348,6 +1610,8 @@
     font-weight: 600;
     color: var(--text-color);
     font-family: 'JetBrains Mono', monospace;
+    overflow-wrap: anywhere;
+    word-break: break-word;
   }
 
   .feature-list {
@@ -1451,13 +1715,26 @@
     color: var(--code-text);
   }
 
-  .code-snippet code { display: block; }
-  .code-kw { color: #8b5cf6; }
-  .code-str { color: #10b981; }
+  .code-snippet code { display: block; white-space: pre; }
 
+  /* {@html} snippets are not scoped — use :global so .code-kw / .code-str apply */
+  .code-snippet :global(.code-kw) {
+    color: #8b5cf6;
+  }
+  .code-snippet :global(.code-str) {
+    color: #10b981;
+  }
+  .code-snippet :global(.code-comment) {
+    color: var(--muted-color);
+  }
+
   @media (prefers-color-scheme: dark) {
-    .code-kw { color: #a78bfa; }
-    .code-str { color: #34d399; }
+    .code-snippet :global(.code-kw) {
+      color: #a78bfa;
+    }
+    .code-snippet :global(.code-str) {
+      color: #34d399;
+    }
   }
 
   .detail-actions {
@@ -1478,6 +1755,23 @@
 
   .detail-action-link:hover { color: var(--litellm-primary); }
 
+  .load-error {
+    display: flex;
+    align-items: center;
+    gap: 0.625rem;
+    padding: 1rem 1.25rem;
+    background: var(--bg-secondary);
+    border: 1px solid var(--border-color);
+    border-radius: 12px;
+    color: var(--text-color);
+    font-size: 0.875rem;
+  }
+
+  .load-error svg {
+    color: #ef4444;
+    flex-shrink: 0;
+  }
+
   /* Skeleton */
   .skeleton-table {
     display: flex;

diff --git a/src/catalogApi.ts b/src/catalogApi.ts
new file mode 100644
--- /dev/null
+++ b/src/catalogApi.ts
@@ -1,0 +1,174 @@
+/**
+ * Load models from litellm-model-catalog-api (paginated) and map to the
+ * flat item shape the UI expects (name + litellm_provider).
+ */
+
... diff truncated: showing 800 of 1591 lines

You can send follow-ups to the cloud agent here.

Comment thread src/App.svelte Outdated
Comment thread src/catalogApi.ts
…ion overflow

- App.svelte: coerce max_input_tokens/max_output_tokens via a tokenLimitValue
  helper so numeric strings (valid in parsed JSON / API payloads) still pass
  the context-slider filters. Matches the string-tolerant behavior already in
  formatDetailTokenField and contextCellForRow.
- catalogApi.ts: when fetchAllCatalogModels hits the 200-page safety cap with
  has_more still true, throw instead of silently returning a partial list, so
  loadError surfaces in the UI rather than showing a truncated catalog.
@mateo-berri

Copy link
Copy Markdown
Collaborator Author

bugbot run


Generated by Claude Code

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 1d7ec5c. Configure here.

@mateo-berri mateo-berri merged commit 67ea6cf into main Jun 3, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants