diff --git a/src/App.svelte b/src/App.svelte index 3b1c2f4..ed5a273 100644 --- 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 | 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; + const spec = + parsed.sample_spec != null && + typeof parsed.sample_spec === "object" && + !Array.isArray(parsed.sample_spec) + ? (parsed.sample_spec as Record) + : 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 @@ We also need to update [${RESOURCE_BACKUP_NAME}](https://github.com/${REPO_FULL_ } 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,11 +315,49 @@ We also need to update [${RESOURCE_BACKUP_NAME}](https://github.com/${REPO_FULL_ }); } - 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 { @@ -233,18 +367,6 @@ We also need to update [${RESOURCE_BACKUP_NAME}](https://github.com/${REPO_FULL_ 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 = { @@ -252,6 +374,7 @@ We also need to update [${RESOURCE_BACKUP_NAME}](https://github.com/${REPO_FULL_ "completion": "Completion", "embedding": "Embedding", "image_generation": "Image Gen", + "image_edit": "Image edit", "audio_transcription": "Transcription", "audio_speech": "TTS", "moderation": "Moderation", @@ -271,16 +394,22 @@ We also need to update [${RESOURCE_BACKUP_NAME}](https://github.com/${REPO_FULL_ 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 || 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 @@ We also need to update [${RESOURCE_BACKUP_NAME}](https://github.com/${REPO_FULL_ handleSort("input")}> - Input $/M + Input cost handleSort("output")}> - Output $/M + Output cost - handleSort("cache_read")}> - Cache Read + handleSort("cache_read")} title="Prompt cache read (chat models)"> + Cache read - handleSort("cache_write")}> - Cache Write + handleSort("cache_write")} title="Prompt cache write (chat models)"> + Cache write - {#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)} - 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} + toggleRow(name)} + >
@@ -526,7 +665,7 @@ We also need to update [${RESOURCE_BACKUP_NAME}](https://github.com/${REPO_FULL_
{getDisplayModelName(name, litellm_provider)} {#if mode} - {getModeLabel(mode)} + {getModeLabel(mode)} {/if}
- {formatContext(max_input_tokens)} - {formatCost(input_cost_per_token)} - {formatCost(output_cost_per_token)} - {formatCost(cache_read_input_token_cost)} - {formatCost(cache_creation_input_token_cost)} + {contextCellForRow(item, max_input_tokens)} + {tableInputCell(item)} + {tableOutputCell(item)} + {tableCacheReadCell(item)} + {tableCacheWriteCell(item)} {#if expandedRows.has(name)}
-
-

Pricing per 1M tokens

+

+ {#if isSampleSpecCatalogRow(item)} + Field reference example values and types from the catalog JSON + {:else if isImagePricingMode(mode)} + Image pricing per image where applicable + {:else if mode === "audio_speech"} + Audio pricing per character where applicable + {:else if mode === "audio_transcription"} + Audio pricing per second where applicable + {:else if isAudioPricingMode(mode)} + Audio pricing per second · per character where applicable + {:else} + Token pricing per 1M tokens where applicable + {/if} +

Input - {formatCost(input_cost_per_token)} + {tableInputCell(item)}
Output - {formatCost(output_cost_per_token)} + {tableOutputCell(item)}
- Cache Read - {formatCost(cache_read_input_token_cost)} + Cache read + {tableCacheReadCell(item)}
- Cache Write - {formatCost(cache_creation_input_token_cost)} + Cache write + {tableCacheWriteCell(item)}
+ {#if isImagePricingMode(mode)} + {@const imageExtras = getImagePricingExtraRows(item)} + {#if imageExtras.length > 0} +
    + {#each imageExtras as row} +
  • {row.label} {row.value}
  • + {/each} +
+ {/if} + {/if}
@@ -594,27 +760,28 @@ We also need to update [${RESOURCE_BACKUP_NAME}](https://github.com/${REPO_FULL_
Max Input - {max_input_tokens ? max_input_tokens.toLocaleString() + " tokens" : "—"} + {formatDetailTokenField(max_input_tokens)}
Max Output - {max_output_tokens ? max_output_tokens.toLocaleString() + " tokens" : "—"} + {formatDetailTokenField(max_output_tokens)}
- + + {#if !isImagePricingMode(mode) && !isAudioPricingMode(mode)}

Features

{#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}
{#if feature.key} @@ -627,6 +794,7 @@ We also need to update [${RESOURCE_BACKUP_NAME}](https://github.com/${REPO_FULL_ {/each}
+ {/if}
@@ -645,42 +813,31 @@ We also need to update [${RESOURCE_BACKUP_NAME}](https://github.com/${REPO_FULL_ >AI Gateway (Proxy) {#if !codeTabStates[name] || codeTabStates[name] === "sdk"} - {:else} - {/if} {#if !codeTabStates[name] || codeTabStates[name] === "sdk"} -
from litellm import completion
-
-response = completion(
-    model="{getDisplayModelName(name, litellm_provider)}",
-    messages=[{`{`}"role": "user", "content": "Hello!"{`}`}]
-)
+
{@html getLiteLLmSdkSnippetHtml(mode, getDisplayModelName(name, litellm_provider))}
{:else} -
# Start proxy: litellm --model {getDisplayModelName(name, litellm_provider)}
-
-curl http://0.0.0.0:4000/v1/chat/completions \
-  -H "Content-Type: application/json" \
-  -H "Authorization: Bearer sk-1234" \
-  -d '{`{`}
-    "model": "{getDisplayModelName(name, litellm_provider)}",
-    "messages": [{`{`}"role": "user", "content": "Hello!"{`}`}]
-  {`}`}'
+
{@html getLiteLLmProxyCurlSnippetHtml(mode, getDisplayModelName(name, litellm_provider))}
{/if} + {#if name !== SAMPLE_SPEC_ROW_NAME} + {/if} @@ -1072,11 +1229,14 @@ curl http://0.0.0.0:4000/v1/chat/completions \ table { width: 100%; - border-collapse: collapse; + /* separate avoids sticky 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 and clips header vs body paint. */ + overflow: visible; } thead { @@ -1097,7 +1257,8 @@ curl http://0.0.0.0:4000/v1/chat/completions \ 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,6 +1286,12 @@ curl http://0.0.0.0:4000/v1/chat/completions \ border-bottom: 1px solid var(--border-color); transition: background-color 0.1s ease; cursor: pointer; + position: relative; + z-index: 0; + } + + tbody tr.model-row-schema td { + vertical-align: top; } tbody tr.model-row:hover { @@ -1248,12 +1415,31 @@ curl http://0.0.0.0:4000/v1/chat/completions \ 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 +1508,29 @@ curl http://0.0.0.0:4000/v1/chat/completions \ 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 +1557,8 @@ curl http://0.0.0.0:4000/v1/chat/completions \ font-weight: 600; color: var(--text-color); font-family: 'JetBrains Mono', monospace; + overflow-wrap: anywhere; + word-break: break-word; } .feature-list { @@ -1451,13 +1662,26 @@ curl http://0.0.0.0:4000/v1/chat/completions \ 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 index 0000000..2248bf8 --- /dev/null +++ b/src/catalogApi.ts @@ -0,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 { + 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 { + 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[]; + /** Present when the API returns it (page 1 of list); schema reference from source JSON. */ + sample_spec: Record | null; +}; + +/** + * Fetch every page from GET /model_catalog (max page_size 500). + * `baseUrl` should have no trailing slash, e.g. "" for same-origin proxy or "http://127.0.0.1:8000". + */ +export async function fetchAllCatalogModels(baseUrl: string): Promise { + const root = baseUrl.replace(/\/$/, ""); + const all: Record[] = []; + let sample_spec: Record | null = null; + let page = 1; + let hasMore = true; + while (hasMore) { + const path = `/model_catalog?page=${page}&page_size=500`; + const url = root ? `${root}${path}` : path; + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Catalog API ${res.status}: ${await res.text()}`); + } + const json = (await res.json()) as { + data?: CatalogApiEntry[]; + has_more?: boolean; + sample_spec?: Record | null; + }; + if (page === 1 && json.sample_spec != null && typeof json.sample_spec === "object" && !Array.isArray(json.sample_spec)) { + sample_spec = json.sample_spec; + } + const batch = json.data ?? []; + for (const row of batch) { + all.push(mapCatalogEntryToItem(row)); + } + hasMore = Boolean(json.has_more); + page += 1; + if (page > 200) break; // safety + } + return { models: all, sample_spec }; +} diff --git a/src/modelPresentation.ts b/src/modelPresentation.ts new file mode 100644 index 0000000..e853330 --- /dev/null +++ b/src/modelPresentation.ts @@ -0,0 +1,548 @@ +/** + * Mode-specific pricing, SDK/proxy snippets, and capability rows for the catalog UI. + */ + +import { formatExactUsd, formatPricingSlot, formatTokenCostPerMillion, slotSortValue, type PricingSlots } from "./catalogApi"; + +export function isImagePricingMode(mode: string | undefined): boolean { + const m = (mode || "").toLowerCase(); + return m === "image_generation" || m === "image_edit"; +} + +export function isAudioPricingMode(mode: string | undefined): boolean { + const m = (mode || "").toLowerCase(); + return m === "audio_transcription" || m === "audio_speech"; +} + +type PriceFormat = "usd_per_image" | "usd_per_million" | "usd_per_pixel"; + +/** Catalog keys used for image_generation / image_edit pricing (order = display priority). */ +const IMAGE_PRICE_FIELDS: { key: string; label: string; format: PriceFormat }[] = [ + { key: "output_cost_per_image", label: "Output (per image)", format: "usd_per_image" }, + { key: "input_cost_per_image", label: "Input (per image)", format: "usd_per_image" }, + { key: "output_cost_per_image_token", label: "Output image tokens", format: "usd_per_million" }, + { key: "input_cost_per_image_token", label: "Input image tokens", format: "usd_per_million" }, + { key: "output_cost_per_pixel", label: "Output (per pixel)", format: "usd_per_pixel" }, + { key: "input_cost_per_pixel", label: "Input (per pixel)", format: "usd_per_pixel" }, + { + key: "output_cost_per_image_above_1024_and_1024_pixels", + label: "Output (>1024×1024 px)", + format: "usd_per_image", + }, + { + key: "output_cost_per_image_above_512_and_512_pixels", + label: "Output (>512×512 px)", + format: "usd_per_image", + }, + { key: "output_cost_per_image_premium_image", label: "Output (premium image)", format: "usd_per_image" }, + { + key: "output_cost_per_image_above_1024_and_1024_pixels_and_premium_image", + label: "Output (>1024², premium)", + format: "usd_per_image", + }, + { + key: "output_cost_per_image_above_512_and_512_pixels_and_premium_image", + label: "Output (>512², premium)", + format: "usd_per_image", + }, + { key: "input_cost_per_token", label: "Input (text tokens)", format: "usd_per_million" }, + { key: "output_cost_per_token", label: "Output (text tokens)", format: "usd_per_million" }, +]; + +function num(v: unknown): number | null { + if (v === null || v === undefined) return null; + if (typeof v === "number" && !Number.isNaN(v)) return v; + if (typeof v === "string" && v.trim() !== "") { + const n = Number(v); + return Number.isNaN(n) ? null : n; + } + return null; +} + +function formatUsdFlat(n: number): string { + return formatExactUsd(n); +} + +function formatUsdPerMillion(perToken: number): string { + return formatTokenCostPerMillion(perToken).replace(/\/M$/, "/M tok"); +} + +/** e.g. `256-x-256/dall-e-2` → { width: 256, height: 256 } */ +export function parseResolutionFromModelName( + name: string, +): { width: number; height: number } | null { + const m = name.match(/(\d+)-x-(\d+)/i); + if (!m) return null; + const width = Number(m[1]); + const height = Number(m[2]); + if (!width || !height) return null; + return { width, height }; +} + +function formatUsdPerPixel(perPixel: number, modelName?: string): string { + const res = modelName ? parseResolutionFromModelName(modelName) : null; + if (res) { + const perImage = perPixel * res.width * res.height; + return formatExactUsd(perImage, "/img"); + } + return formatExactUsd(perPixel * 1e6, "/Mpx"); +} + +function formatImageCatalogValue( + format: PriceFormat, + raw: number, + modelName?: string, +): string { + switch (format) { + case "usd_per_image": + return formatExactUsd(raw, "/img"); + case "usd_per_million": + return formatTokenCostPerMillion(raw); + case "usd_per_pixel": + return formatUsdPerPixel(raw, modelName); + default: + return String(raw); + } +} + +const IMAGE_EXTRA_PRICE_KEYS = new Set([ + "output_cost_per_image_above_1024_and_1024_pixels", + "output_cost_per_image_above_512_and_512_pixels", + "output_cost_per_image_premium_image", + "output_cost_per_image_above_1024_and_1024_pixels_and_premium_image", + "output_cost_per_image_above_512_and_512_pixels_and_premium_image", + "input_cost_per_image_token", + "output_cost_per_image_token", + "input_cost_per_token", + "output_cost_per_token", +]); + +function imageSlotCost( + item: Record, + slotName: "input" | "output", +): string { + const slots = item.pricing_slots as PricingSlots | undefined; + const slot = slots?.[slotName]; + if (!slot || slot.amount_usd == null) return "—"; + const modelName = String(item.name ?? ""); + if (slot.unit === "per_pixel") { + return formatUsdPerPixel(slot.amount_usd, modelName); + } + return formatPricingSlot(slot); +} + +function formatImageSlotForDisplay( + item: Record, + slotName: "input" | "output", +): string { + const fromSlot = imageSlotCost(item, slotName); + if (fromSlot !== "—") return fromSlot; + const modelName = String(item.name ?? ""); + const prefix = slotName === "input" ? "input_" : "output_"; + let sawZero = false; + for (const { key, format } of IMAGE_PRICE_FIELDS) { + if (!key.startsWith(prefix)) continue; + const raw = num(item[key]); + if (raw === null) continue; + if (raw === 0) { + sawZero = true; + continue; + } + return formatImageCatalogValue(format, raw, modelName); + } + if (sawZero) return formatExactUsd(0, "/img"); + return "—"; +} + +/** Primary value for table “input” column (image modes). */ +export function tableImageInputCost(item: Record): string { + return formatImageSlotForDisplay(item, "input"); +} + +/** Primary value for table “output” column (image modes). */ +export function tableImageOutputCost(item: Record): string { + return formatImageSlotForDisplay(item, "output"); +} + +/** Tier / token extras shown below the main 2×2 grid (image modes only). */ +export function getImagePricingExtraRows( + item: Record, +): { label: string; value: string }[] { + const modelName = String(item.name ?? ""); + const rows: { label: string; value: string }[] = []; + for (const { key, label, format } of IMAGE_PRICE_FIELDS) { + if (!IMAGE_EXTRA_PRICE_KEYS.has(key)) continue; + const raw = num(item[key]); + if (raw === null || raw === 0) continue; + rows.push({ label, value: formatImageCatalogValue(format, raw, modelName) }); + } + return rows; +} + +export function imageModeInputSortValue(item: Record): number { + const slots = item.pricing_slots as PricingSlots | undefined; + if (slots?.input && (slots.input.amount_usd != null || slots.input.unit)) { + return slotSortValue(slots.input); + } + const modelName = String(item.name ?? ""); + for (const { key, format } of IMAGE_PRICE_FIELDS) { + if (!key.startsWith("input_")) continue; + const raw = num(item[key]); + if (raw === null || raw === 0) continue; + if (format === "usd_per_million") return raw * 1e6; + if (format === "usd_per_pixel") { + const res = parseResolutionFromModelName(modelName); + return res ? raw * res.width * res.height : raw * 1e6; + } + return raw; + } + return 0; +} + +export function imageModeOutputSortValue(item: Record): number { + const slots = item.pricing_slots as PricingSlots | undefined; + if (slots?.output && (slots.output.amount_usd != null || slots.output.unit)) { + const v = slotSortValue(slots.output); + if (v > 0) return v; + } + const modelName = String(item.name ?? ""); + for (const { key, format } of IMAGE_PRICE_FIELDS) { + if (!key.startsWith("output_")) continue; + const raw = num(item[key]); + if (raw === null || raw === 0) continue; + if (format === "usd_per_million") return raw * 1e6; + if (format === "usd_per_pixel") { + const res = parseResolutionFromModelName(modelName); + return res ? raw * res.width * res.height : raw * 1e6; + } + return raw; + } + return 0; +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function pyStr(value: string): string { + return `"${escapeHtml(value)}"`; +} + +function pyKw(text: string): string { + return `${text}`; +} + +/** Syntax-highlighted HTML for the Python SDK tab (copy still uses plain `getLiteLLmSdkSnippet`). */ +export function getLiteLLmSdkSnippetHtml(mode: string | undefined, model: string): string { + const m = (mode || "").toLowerCase(); + const modelStr = pyStr(model); + + if (m === "image_generation") { + return `${pyKw("from")} litellm ${pyKw("import")} image_generation + +response = image_generation( + model=${modelStr}, + prompt=${pyStr("A cute baby sea otter")}, +)`; + } + if (m === "image_edit") { + return `${pyKw("from")} litellm ${pyKw("import")} image_edit + +response = image_edit( + model=${modelStr}, + image=open(${pyStr("image.png")}, ${pyStr("rb")}), + prompt=${pyStr("Describe your edit")}, +)`; + } + if (m === "embedding") { + return `${pyKw("from")} litellm ${pyKw("import")} embedding + +response = embedding( + model=${modelStr}, + input=[${pyStr("hello world")}], +)`; + } + if (m === "audio_transcription") { + return `${pyKw("from")} litellm ${pyKw("import")} transcription + +response = transcription( + model=${modelStr}, + file=open(${pyStr("audio.mp3")}, ${pyStr("rb")}), +)`; + } + if (m === "audio_speech") { + return `${pyKw("from")} litellm ${pyKw("import")} speech + +response = speech( + model=${modelStr}, + input=${pyStr("Hello from LiteLLM")}, + voice=${pyStr("alloy")}, +)`; + } + return `${pyKw("from")} litellm ${pyKw("import")} completion + +response = completion( + model=${modelStr}, + messages=[{${pyStr("role")}: ${pyStr("user")}, ${pyStr("content")}: ${pyStr("Hello!")}}], +)`; +} + +export function getLiteLLmSdkSnippet(mode: string | undefined, model: string): string { + const m = (mode || "").toLowerCase(); + if (m === "image_generation") { + return `from litellm import image_generation + +response = image_generation( + model="${model}", + prompt="A cute baby sea otter", +)`; + } + if (m === "image_edit") { + return `from litellm import image_edit + +response = image_edit( + model="${model}", + image=open("image.png", "rb"), + prompt="Describe your edit", +)`; + } + if (m === "embedding") { + return `from litellm import embedding + +response = embedding( + model="${model}", + input=["hello world"], +)`; + } + if (m === "audio_transcription") { + return `from litellm import transcription + +response = transcription( + model="${model}", + file=open("audio.mp3", "rb"), +)`; + } + if (m === "audio_speech") { + return `from litellm import speech + +response = speech( + model="${model}", + input="Hello from LiteLLM", + voice="alloy", +)`; + } + return `from litellm import completion + +response = completion( + model="${model}", + messages=[{"role": "user", "content": "Hello!"}], +)`; +} + +function curlStr(value: string): string { + return `${escapeHtml(value)}`; +} + +/** Syntax-highlighted HTML for the proxy curl tab. */ +export function getLiteLLmProxyCurlSnippetHtml(mode: string | undefined, model: string): string { + const m = (mode || "").toLowerCase(); + const modelStr = escapeHtml(model); + + if (m === "image_generation") { + return `curl http://0.0.0.0:4000/v1/images/generations \\ + -H ${curlStr("Content-Type: application/json")} \\ + -H ${curlStr("Authorization: Bearer sk-1234")} \\ + -d ${curlStr(`{ + "model": "${modelStr}", + "prompt": "A cute baby sea otter", + "n": 1, + "size": "1024x1024" + }`)}`; + } + if (m === "image_edit") { + return `curl http://0.0.0.0:4000/v1/images/edits \\ + -H ${curlStr("Authorization: Bearer sk-1234")} \\ + -F model=${curlStr(model)} \\ + -F image=@image.png \\ + -F prompt=${curlStr("Describe your edit")}`; + } + if (m === "embedding") { + return `curl http://0.0.0.0:4000/v1/embeddings \\ + -H ${curlStr("Content-Type: application/json")} \\ + -H ${curlStr("Authorization: Bearer sk-1234")} \\ + -d ${curlStr(`{ + "model": "${modelStr}", + "input": "hello world" + }`)}`; + } + if (m === "audio_transcription") { + return `curl http://0.0.0.0:4000/v1/audio/transcriptions \\ + -H ${curlStr("Authorization: Bearer sk-1234")} \\ + -F model=${curlStr(model)} \\ + -F file=@audio.mp3`; + } + if (m === "audio_speech") { + return `curl http://0.0.0.0:4000/v1/audio/speech \\ + -H ${curlStr("Content-Type: application/json")} \\ + -H ${curlStr("Authorization: Bearer sk-1234")} \\ + -d ${curlStr(`{ + "model": "${modelStr}", + "input": "Hello from LiteLLM", + "voice": "alloy" + }`)}`; + } + return `# Start proxy: litellm --model ${modelStr} + +curl http://0.0.0.0:4000/v1/chat/completions \\ + -H ${curlStr("Content-Type: application/json")} \\ + -H ${curlStr("Authorization: Bearer sk-1234")} \\ + -d ${curlStr(`{ + "model": "${modelStr}", + "messages": [{"role": "user", "content": "Hello!"}] + }`)}`; +} + +export function getLiteLLmProxyCurlSnippet(mode: string | undefined, model: string): string { + const m = (mode || "").toLowerCase(); + if (m === "image_generation") { + return `curl http://0.0.0.0:4000/v1/images/generations \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer sk-1234" \\ + -d '{ + "model": "${model}", + "prompt": "A cute baby sea otter", + "n": 1, + "size": "1024x1024" + }'`; + } + if (m === "image_edit") { + return `curl http://0.0.0.0:4000/v1/images/edits \\ + -H "Authorization: Bearer sk-1234" \\ + -F model="${model}" \\ + -F image=@image.png \\ + -F prompt="Describe your edit"`; + } + if (m === "embedding") { + return `curl http://0.0.0.0:4000/v1/embeddings \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer sk-1234" \\ + -d '{ + "model": "${model}", + "input": "hello world" + }'`; + } + if (m === "audio_transcription") { + return `curl http://0.0.0.0:4000/v1/audio/transcriptions \\ + -H "Authorization: Bearer sk-1234" \\ + -F model="${model}" \\ + -F file=@audio.mp3`; + } + if (m === "audio_speech") { + return `curl http://0.0.0.0:4000/v1/audio/speech \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer sk-1234" \\ + -d '{ + "model": "${model}", + "input": "Hello from LiteLLM", + "voice": "alloy" + }'`; + } + return `curl http://0.0.0.0:4000/v1/chat/completions \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer sk-1234" \\ + -d '{ + "model": "${model}", + "messages": [{"role": "user", "content": "Hello!"}] + }'`; +} + +/** Per-second catalog field → exact display. */ +export function formatSecondCost(perSecond: number | null | undefined): string { + if (perSecond === null || perSecond === undefined) return "—"; + return formatExactUsd(perSecond, "/s"); +} + +/** Per-character catalog field → exact display. */ +export function formatCharacterCost(perChar: number | null | undefined): string { + if (perChar === null || perChar === undefined) return "—"; + return formatExactUsd(perChar, "/char"); +} + +function displayInputCost(item: Record): string { + const s = (item.pricing_slots as PricingSlots | undefined)?.input; + if (s && s.amount_usd != null) return formatPricingSlot(s); + const perChar = num(item.input_cost_per_character); + if (perChar !== null) return formatCharacterCost(perChar); + const perSec = num(item.input_cost_per_second); + if (perSec !== null) return formatSecondCost(perSec); + return formatTokenCostPerMillion(num(item.input_cost_per_token)); +} + +function displayOutputCost(item: Record): string { + const s = (item.pricing_slots as PricingSlots | undefined)?.output; + if (s && s.amount_usd != null) return formatPricingSlot(s); + const perChar = num(item.output_cost_per_character); + if (perChar !== null) return formatCharacterCost(perChar); + const perSec = num(item.output_cost_per_second); + if (perSec !== null) return formatSecondCost(perSec); + return formatTokenCostPerMillion(num(item.output_cost_per_token)); +} + +/** Table + sort: non-image modes use token / second slots / raw catalog costs. */ +export function displayChatInputCost(item: Record): string { + return displayInputCost(item); +} + +export function displayChatOutputCost(item: Record): string { + return displayOutputCost(item); +} + +export function displayChatCacheRead(item: Record): string { + const s = (item.pricing_slots as PricingSlots | undefined)?.cache_read; + if (s && s.amount_usd != null) return formatPricingSlot(s); + return formatTokenCostPerMillion(num(item.cache_read_input_token_cost)); +} + +export function displayChatCacheWrite(item: Record): string { + const s = (item.pricing_slots as PricingSlots | undefined)?.cache_write; + if (s && s.amount_usd != null) return formatPricingSlot(s); + return formatTokenCostPerMillion(num(item.cache_creation_input_token_cost)); +} + +export function chatSortInput(item: Record): number { + const slots = item.pricing_slots as PricingSlots | undefined; + if (slots?.input && slots.input.amount_usd != null) return slotSortValue(slots.input); + const perChar = num(item.input_cost_per_character); + if (perChar !== null) return perChar; + const perSec = num(item.input_cost_per_second); + if (perSec !== null) return perSec; + return num(item.input_cost_per_token) ?? 0; +} + +export function chatSortOutput(item: Record): number { + const slots = item.pricing_slots as PricingSlots | undefined; + if (slots?.output && slots.output.amount_usd != null) return slotSortValue(slots.output); + const perChar = num(item.output_cost_per_character); + if (perChar !== null) return perChar; + const perSec = num(item.output_cost_per_second); + if (perSec !== null) return perSec; + return num(item.output_cost_per_token) ?? 0; +} + +export function chatSortCacheRead(item: Record): number { + const slots = item.pricing_slots as PricingSlots | undefined; + if (slots?.cache_read && (slots.cache_read.amount_usd != null || slots.cache_read.unit)) + return slotSortValue(slots.cache_read); + return num(item.cache_read_input_token_cost) ?? 0; +} + +export function chatSortCacheWrite(item: Record): number { + const slots = item.pricing_slots as PricingSlots | undefined; + if (slots?.cache_write && (slots.cache_write.amount_usd != null || slots.cache_write.unit)) + return slotSortValue(slots.cache_write); + return num(item.cache_creation_input_token_cost) ?? 0; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 4078e74..0966c7f 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,2 +1,12 @@ /// /// + +interface ImportMetaEnv { + readonly VITE_USE_LOCAL_CATALOG_API?: string; + readonly VITE_CATALOG_API_BASE?: string; + readonly VITE_CATALOG_API_PROXY_TARGET?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/vite.config.ts b/vite.config.ts index 91164ec..b26f723 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,11 +1,33 @@ -import { defineConfig } from "vite"; +import { defineConfig, loadEnv } from "vite"; import { svelte } from "@sveltejs/vite-plugin-svelte"; // https://vitejs.dev/config/ -export default defineConfig({ - plugins: [svelte()], - server: { - // Enable SPA fallback for client-side routing - historyApiFallback: true, - }, +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ""); + const catalogProxyTarget = + env.VITE_CATALOG_API_PROXY_TARGET || "http://127.0.0.1:8000"; + + return { + plugins: [svelte()], + server: { + // Enable SPA fallback for client-side routing + historyApiFallback: true, + // When VITE_USE_LOCAL_CATALOG_API=true, the app fetches /model_catalog (same origin); + // proxy to litellm-model-catalog-api so the browser avoids CORS. + proxy: { + "/model_catalog": { + target: catalogProxyTarget, + changeOrigin: true, + }, + }, + }, + preview: { + proxy: { + "/model_catalog": { + target: catalogProxyTarget, + changeOrigin: true, + }, + }, + }, + }; });