Add more data to the models catalog list

This commit is contained in:
Owen
2026-08-20 15:17:48 -04:00
parent bafbf6e096
commit 365a905e69
5 changed files with 205 additions and 43 deletions
+28 -5
View File
@@ -161,11 +161,34 @@ enumerable. Provider types with no catalog mapping (`openRouter`,
allow on those types lists nothing - **add exact allow entries to make their allow on those types lists nothing - **add exact allow entries to make their
models discoverable.** models discoverable.**
Fields the API declares nullable and an allow/block list cannot supply ### Where each field comes from
(`max_input_tokens`, `max_tokens`, `capabilities`) are returned as `null`.
`display_name` and `created_at` come from the configured model row when the id Token limits and capability flags can't be derived from an allow/block list.
matches one; otherwise the id doubles as the display name and `created_at` is They come from the model catalog (`server/lib/aiModelCatalog.ts`), which the
the epoch, which the Models API permits when the release date is unknown. Fossorial API builds from LiteLLM:
| Field | Source |
|-------|--------|
| `max_input_tokens` | catalog `limits.input` |
| `max_tokens` | catalog `limits.output` |
| `capabilities` | catalog flags, mapped to the Models API shape by `capabilitiesFromCatalog` |
| `display_name` | the configured model row's name, else the model id |
| `created_at` | the configured model row's timestamp, else the epoch |
A model the catalog doesn't know (an exact allow entry for a fine-tune, say)
reports `null` for all three metadata fields. The Models API declares them
nullable, so that is a valid answer rather than a broken one.
The catalog's flags are coarser than the Models API describes: it carries a
single `reasoning` flag with no way to distinguish adaptive from
`budget_tokens`-style thinking, and nothing at all for batch, citations, code
execution, PDF input, or context management. Anything it reports as unknown
(`null`) is surfaced as unsupported rather than invented, so `capabilities`
understates rather than overstates what a model can do.
The gateway does **not** query the provider's own `/v1/models`. Discovery is
answered entirely from local state.
Results are ordered newest-first with the id as tie-break, and paginated with Results are ordered newest-first with the id as tie-break, and paginated with
Anthropic's `limit` / `after_id` / `before_id` semantics (default 20, max Anthropic's `limit` / `after_id` / `before_id` semantics (default 20, max
1000). 1000).
+87 -23
View File
@@ -44,6 +44,20 @@ export function getCatalogProviderForType(
return PROVIDER_CATALOG_MAP[type]; return PROVIDER_CATALOG_MAP[type];
} }
/**
* Per-model feature flags as reported upstream. `null` means the catalog has
* no data for that model - deliberately distinct from `false`, so consumers
* can tell "unsupported" apart from "unknown".
*/
export type AiModelCapabilityFlags = {
functionCalling: boolean | null;
vision: boolean | null;
promptCaching: boolean | null;
reasoning: boolean | null;
responseSchema: boolean | null;
webSearch: boolean | null;
};
export type AiModelCatalogEntry = { export type AiModelCatalogEntry = {
provider: CatalogProvider; provider: CatalogProvider;
model: string; model: string;
@@ -53,8 +67,20 @@ export type AiModelCatalogEntry = {
cache: number | null; cache: number | null;
reasoning: number | null; reasoning: number | null;
}; };
limits: {
/** Context window. */
input: number | null;
/** Cap on the output/max_tokens request parameter. */
output: number | null;
};
capabilities: AiModelCapabilityFlags;
}; };
const flag = z.boolean().nullable().optional();
// limits/capabilities are optional so a catalog published before they were
// added (or an operator's own merge_file) still parses - those entries just
// report unknown metadata rather than failing the whole payload.
const catalogEntrySchema = z.object({ const catalogEntrySchema = z.object({
model: z.string(), model: z.string(),
provider: z.string(), provider: z.string(),
@@ -65,6 +91,22 @@ const catalogEntrySchema = z.object({
cache: z.number().nullable().optional(), cache: z.number().nullable().optional(),
reasoning: z.number().nullable().optional() reasoning: z.number().nullable().optional()
}) })
.optional(),
limits: z
.object({
input: z.number().nullable().optional(),
output: z.number().nullable().optional()
})
.optional(),
capabilities: z
.object({
functionCalling: flag,
vision: flag,
promptCaching: flag,
reasoning: flag,
responseSchema: flag,
webSearch: flag
})
.optional() .optional()
}); });
@@ -108,6 +150,18 @@ function normalizeEntry(raw: RawCatalogEntry): AiModelCatalogEntry | null {
out: raw.pricing?.out ?? null, out: raw.pricing?.out ?? null,
cache: raw.pricing?.cache ?? null, cache: raw.pricing?.cache ?? null,
reasoning: raw.pricing?.reasoning ?? null reasoning: raw.pricing?.reasoning ?? null
},
limits: {
input: raw.limits?.input ?? null,
output: raw.limits?.output ?? null
},
capabilities: {
functionCalling: raw.capabilities?.functionCalling ?? null,
vision: raw.capabilities?.vision ?? null,
promptCaching: raw.capabilities?.promptCaching ?? null,
reasoning: raw.capabilities?.reasoning ?? null,
responseSchema: raw.capabilities?.responseSchema ?? null,
webSearch: raw.capabilities?.webSearch ?? null
} }
}; };
} }
@@ -284,34 +338,44 @@ export class AiModelCatalog {
export const aiModelCatalog = new AiModelCatalog(); export const aiModelCatalog = new AiModelCatalog();
/**
* Full catalog entries for a provider type, deduplicated by model id and
* sorted by id. Model discovery uses these to report real token limits and
* capability flags; `listCatalogModelsForType` is the id-only view of the
* same list.
*/
export function listCatalogEntriesForType(
type: AiProviderType,
query?: string
): AiModelCatalogEntry[] {
const catalogProvider = getCatalogProviderForType(type);
let entries = catalogProvider ? aiModelCatalog.list(catalogProvider) : [];
if (query) {
const q = query.toLowerCase();
entries = entries.filter((e) => e.model.toLowerCase().includes(q));
}
const seen = new Set<string>();
entries = entries.filter((e) => {
if (seen.has(e.model)) {
return false;
}
seen.add(e.model);
return true;
});
return [...entries].sort((a, b) => a.model.localeCompare(b.model));
}
export function listCatalogModelsForType( export function listCatalogModelsForType(
type: AiProviderType, type: AiProviderType,
query?: string query?: string
): { model: string }[] { ): { model: string }[] {
const catalogProvider = getCatalogProviderForType(type); return listCatalogEntriesForType(type, query).map((entry) => ({
let models = catalogProvider
? aiModelCatalog.list(catalogProvider).map((entry) => ({
model: entry.model model: entry.model
})) }));
: [];
if (query) {
const q = query.toLowerCase();
models = models.filter((m) => m.model.toLowerCase().includes(q));
}
const seen = new Set<string>();
models = models.filter((m) => {
if (seen.has(m.model)) {
return false;
}
seen.add(m.model);
return true;
});
models.sort((a, b) => a.model.localeCompare(b.model));
return models;
} }
/** /**
+70 -10
View File
@@ -2,6 +2,7 @@ import {
isAllowedByLists, isAllowedByLists,
isModelKeyPattern isModelKeyPattern
} from "@server/lib/aiModelKeyMatch"; } from "@server/lib/aiModelKeyMatch";
import type { AiModelCapabilityFlags } from "@server/lib/aiModelCatalog";
// Anthropic's Models API pagination: 20 per page by default, 1..1000. // Anthropic's Models API pagination: 20 per page by default, 1..1000.
export const MODEL_PAGE_DEFAULT_LIMIT = 20; export const MODEL_PAGE_DEFAULT_LIMIT = 20;
@@ -25,12 +26,66 @@ export type AnthropicModelInfo = {
created_at: string; created_at: string;
max_input_tokens: number | null; max_input_tokens: number | null;
max_tokens: number | null; max_tokens: number | null;
capabilities: null; capabilities: Record<string, unknown> | null;
}; };
/** A model row an administrator configured explicitly on a provider. */ /** A model row an administrator configured explicitly on a provider. */
export type ConfiguredModel = { name: string; createdAt: number }; export type ConfiguredModel = { name: string; createdAt: number };
/** What the pricing catalog knows about a model beyond its id. */
export type CatalogModelMetadata = {
maxInputTokens: number | null;
maxOutputTokens: number | null;
capabilities: AiModelCapabilityFlags;
};
/**
* Translates the catalog's flat feature flags into the nested shape
* Anthropic's Models API uses. Best-effort by nature: the catalog carries a
* coarser set of flags than the Models API describes, so anything it reports
* as unknown (`null`) is surfaced as unsupported rather than invented.
*/
export function capabilitiesFromCatalog(
flags: AiModelCapabilityFlags
): Record<string, unknown> {
const supported = (value: boolean | null) => ({
supported: value === true
});
// The catalog has a single `reasoning` flag and no way to distinguish
// adaptive from budget_tokens-style thinking, so both variants follow it.
const reasoning = flags.reasoning === true;
return {
batch: supported(null),
citations: supported(null),
code_execution: supported(null),
context_management: {
supported: false,
clear_thinking_20251015: null,
clear_tool_uses_20250919: null,
compact_20260112: null
},
effort: {
supported: reasoning,
low: supported(flags.reasoning),
medium: supported(flags.reasoning),
high: supported(flags.reasoning),
max: supported(flags.reasoning),
xhigh: null
},
image_input: supported(flags.vision),
pdf_input: supported(null),
structured_outputs: supported(flags.responseSchema),
thinking: {
supported: reasoning,
types: {
adaptive: { supported: reasoning },
enabled: { supported: reasoning }
}
}
};
}
/** /**
* One attached provider's contribution to a resource's model listing, with the * One attached provider's contribution to a resource's model listing, with the
* allow/block lists already resolved for the attachment's access mode. * allow/block lists already resolved for the attachment's access mode.
@@ -40,12 +95,13 @@ export type ModelDiscoveryProvider = {
allows: string[]; allows: string[];
blocks: string[]; blocks: string[];
/** /**
* Concrete model ids the provider's type is known to serve. This is what * Concrete model ids the provider's type is known to serve, with whatever
* lets a wildcard allow such as `claude-*` enumerate into real ids; * the catalog knows about each. This is what lets a wildcard allow such as
* provider types with no catalog (aggregators, custom) pass an empty list * `claude-*` enumerate into real ids; provider types with no catalog
* and surface only their exact allow entries. * (aggregators, custom) pass an empty map and surface only their exact
* allow entries.
*/ */
catalogModelIds: string[]; catalog: Map<string, CatalogModelMetadata>;
/** Keyed by model key, for display names and creation times. */ /** Keyed by model key, for display names and creation times. */
configured: Map<string, ConfiguredModel>; configured: Map<string, ConfiguredModel>;
}; };
@@ -73,7 +129,7 @@ export function expandProviderModels(
candidates.add(allow); candidates.add(allow);
} }
} }
for (const modelId of provider.catalogModelIds) { for (const modelId of provider.catalog.keys()) {
candidates.add(modelId); candidates.add(modelId);
} }
@@ -83,6 +139,8 @@ export function expandProviderModels(
continue; continue;
} }
const configured = provider.configured.get(modelKey); const configured = provider.configured.get(modelKey);
const catalog = provider.catalog.get(modelKey);
models.push({ models.push({
type: "model", type: "model",
id: modelKey, id: modelKey,
@@ -90,9 +148,11 @@ export function expandProviderModels(
created_at: configured created_at: configured
? new Date(configured.createdAt).toISOString() ? new Date(configured.createdAt).toISOString()
: UNKNOWN_CREATED_AT, : UNKNOWN_CREATED_AT,
max_input_tokens: null, max_input_tokens: catalog?.maxInputTokens ?? null,
max_tokens: null, max_tokens: catalog?.maxOutputTokens ?? null,
capabilities: null capabilities: catalog
? capabilitiesFromCatalog(catalog.capabilities)
: null
}); });
} }
@@ -15,7 +15,7 @@ import { logsDb, db, orgs, aiSessionLog, type AiProvider } from "@server/db";
import type { InferInsertModel } from "drizzle-orm"; import type { InferInsertModel } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { and, eq, lt } from "drizzle-orm"; import { and, eq, lt } from "drizzle-orm";
import cache from "#dynamic/lib/cache"; import cache from "#private/lib/cache";
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs"; import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
import { sanitizeString } from "@server/lib/sanitize"; import { sanitizeString } from "@server/lib/sanitize";
import type { AiCapability } from "@server/lib/aiCapabilities"; import type { AiCapability } from "@server/lib/aiCapabilities";
+18 -3
View File
@@ -15,12 +15,13 @@ import {
isAiGatewayTrustHeaderValid isAiGatewayTrustHeaderValid
} from "@server/lib/aiGatewayTrust"; } from "@server/lib/aiGatewayTrust";
import { resolveEffectiveLists } from "@server/lib/aiInferenceResource"; import { resolveEffectiveLists } from "@server/lib/aiInferenceResource";
import { listCatalogModelsForType } from "@server/lib/aiModelCatalog"; import { listCatalogEntriesForType } from "@server/lib/aiModelCatalog";
import { import {
listPermittedModels, listPermittedModels,
paginateModels, paginateModels,
MODEL_PAGE_DEFAULT_LIMIT, MODEL_PAGE_DEFAULT_LIMIT,
MODEL_PAGE_MAX_LIMIT, MODEL_PAGE_MAX_LIMIT,
type CatalogModelMetadata,
type ConfiguredModel, type ConfiguredModel,
type ModelDiscoveryProvider type ModelDiscoveryProvider
} from "@server/lib/aiModelDiscovery"; } from "@server/lib/aiModelDiscovery";
@@ -113,6 +114,20 @@ async function loadProviderModelLists(
return lists; return lists;
} }
function catalogMetadataForType(
type: AiProviderType
): Map<string, CatalogModelMetadata> {
const metadata = new Map<string, CatalogModelMetadata>();
for (const entry of listCatalogEntriesForType(type)) {
metadata.set(entry.model, {
maxInputTokens: entry.limits.input,
maxOutputTokens: entry.limits.output,
capabilities: entry.capabilities
});
}
return metadata;
}
function buildDiscoveryProviders( function buildDiscoveryProviders(
attachments: ProviderAttachment[], attachments: ProviderAttachment[],
resourceListsByProvider: Map<number, ProviderPatternLists>, resourceListsByProvider: Map<number, ProviderPatternLists>,
@@ -133,9 +148,9 @@ function buildDiscoveryProviders(
providerId, providerId,
allows, allows,
blocks, blocks,
catalogModelIds: listCatalogModelsForType( catalog: catalogMetadataForType(
attachment.provider.type as AiProviderType attachment.provider.type as AiProviderType
).map((entry) => entry.model), ),
configured: lists.configuredByProvider.get(providerId) ?? new Map() configured: lists.configuredByProvider.get(providerId) ?? new Map()
}; };
}); });