mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-21 11:42:37 +02:00
Add more data to the models catalog list
This commit is contained in:
@@ -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).
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
|
model: entry.model
|
||||||
let models = catalogProvider
|
}));
|
||||||
? aiModelCatalog.list(catalogProvider).map((entry) => ({
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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()
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user