mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-21 19:52:47 +02:00
Add anthropic_models capability
This commit is contained in:
@@ -4,7 +4,7 @@ import { AI_CAPABILITIES, type AiCapability } from "@app/lib/aiCapabilities";
|
||||
export { AI_CAPABILITIES, type AiCapability };
|
||||
|
||||
export type AiCapabilityRoute = {
|
||||
method: "POST";
|
||||
method: "GET" | "POST";
|
||||
path: string;
|
||||
};
|
||||
|
||||
@@ -135,6 +135,21 @@ export const AI_CAPABILITY_DEFS: Record<AiCapability, AiCapabilityDefinition> =
|
||||
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||
isStreaming: isBodyOrSseStreaming
|
||||
},
|
||||
anthropic_models: {
|
||||
id: "anthropic_models",
|
||||
protocolFamily: "anthropic",
|
||||
routes: [
|
||||
{ method: "GET", path: "/v1/models" },
|
||||
{ method: "GET", path: "/v1/models/:model" }
|
||||
],
|
||||
extractModel: paramModel,
|
||||
resolveUpstreamUrl: (base, req) =>
|
||||
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||
// Model listings are answered from the gateway's own view of the
|
||||
// provider allow/block lists rather than proxied upstream, so
|
||||
// there is never a stream to detect.
|
||||
isStreaming: () => false
|
||||
},
|
||||
gemini_generate_content: {
|
||||
id: "gemini_generate_content",
|
||||
protocolFamily: "google",
|
||||
|
||||
@@ -471,6 +471,8 @@ const REQUEST_NORMALIZERS: Record<
|
||||
openai_chat: normalizeOpenAiChatRequest,
|
||||
openai_responses: normalizeOpenAiResponsesRequest,
|
||||
anthropic_messages: normalizeAnthropicRequest,
|
||||
// Model discovery carries no transcript to normalize.
|
||||
anthropic_models: () => null,
|
||||
gemini_generate_content: normalizeGeminiRequest,
|
||||
google_generate_content: normalizeGeminiRequest,
|
||||
google_raw_predict: normalizeBestEffortRequest,
|
||||
@@ -485,6 +487,7 @@ const RESPONSE_NORMALIZERS: Record<
|
||||
openai_chat: normalizeOpenAiChatResponse,
|
||||
openai_responses: normalizeOpenAiResponsesResponse,
|
||||
anthropic_messages: normalizeAnthropicResponse,
|
||||
anthropic_models: () => null,
|
||||
gemini_generate_content: normalizeGeminiResponse,
|
||||
google_generate_content: normalizeGeminiResponse,
|
||||
google_raw_predict: normalizeGoogleRawPredictResponse,
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
isAllowedByLists,
|
||||
isModelKeyPattern
|
||||
} from "@server/lib/aiModelKeyMatch";
|
||||
|
||||
// Anthropic's Models API pagination: 20 per page by default, 1..1000.
|
||||
export const MODEL_PAGE_DEFAULT_LIMIT = 20;
|
||||
export const MODEL_PAGE_MAX_LIMIT = 1000;
|
||||
|
||||
// Release dates aren't something we can know for a wildcard allow pattern or a
|
||||
// catalog entry. The Models API explicitly permits an epoch value when the
|
||||
// release date is unknown.
|
||||
const UNKNOWN_CREATED_AT = new Date(0).toISOString();
|
||||
|
||||
/**
|
||||
* One entry of Anthropic's `GET /v1/models` response. Only the identity fields
|
||||
* can be filled in from a provider's model lists - token limits and
|
||||
* per-model capability flags aren't derivable from an allow/block list, and the
|
||||
* API schema declares all three nullable.
|
||||
*/
|
||||
export type AnthropicModelInfo = {
|
||||
type: "model";
|
||||
id: string;
|
||||
display_name: string;
|
||||
created_at: string;
|
||||
max_input_tokens: number | null;
|
||||
max_tokens: number | null;
|
||||
capabilities: null;
|
||||
};
|
||||
|
||||
/** A model row an administrator configured explicitly on a provider. */
|
||||
export type ConfiguredModel = { name: string; createdAt: number };
|
||||
|
||||
/**
|
||||
* One attached provider's contribution to a resource's model listing, with the
|
||||
* allow/block lists already resolved for the attachment's access mode.
|
||||
*/
|
||||
export type ModelDiscoveryProvider = {
|
||||
providerId: number;
|
||||
allows: string[];
|
||||
blocks: string[];
|
||||
/**
|
||||
* Concrete model ids the provider's type is known to serve. This is what
|
||||
* lets a wildcard allow such as `claude-*` enumerate into real ids;
|
||||
* provider types with no catalog (aggregators, custom) pass an empty list
|
||||
* and surface only their exact allow entries.
|
||||
*/
|
||||
catalogModelIds: string[];
|
||||
/** Keyed by model key, for display names and creation times. */
|
||||
configured: Map<string, ConfiguredModel>;
|
||||
};
|
||||
|
||||
export type ModelPage = {
|
||||
data: AnthropicModelInfo[];
|
||||
has_more: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Expands one provider's effective allow/block lists into concrete model ids.
|
||||
* Two sources feed the candidate set: exact (non-wildcard) allow entries, which
|
||||
* are already concrete ids, and the catalog for the provider's type, which is
|
||||
* what makes wildcard allows enumerable. Every candidate is then run back
|
||||
* through the same allow/block check the inference pipeline applies, so a block
|
||||
* pattern hides a model here exactly as it would reject it at request time.
|
||||
*/
|
||||
export function expandProviderModels(
|
||||
provider: ModelDiscoveryProvider
|
||||
): AnthropicModelInfo[] {
|
||||
const candidates = new Set<string>();
|
||||
|
||||
for (const allow of provider.allows) {
|
||||
if (!isModelKeyPattern(allow)) {
|
||||
candidates.add(allow);
|
||||
}
|
||||
}
|
||||
for (const modelId of provider.catalogModelIds) {
|
||||
candidates.add(modelId);
|
||||
}
|
||||
|
||||
const models: AnthropicModelInfo[] = [];
|
||||
for (const modelKey of candidates) {
|
||||
if (!isAllowedByLists(modelKey, provider.allows, provider.blocks)) {
|
||||
continue;
|
||||
}
|
||||
const configured = provider.configured.get(modelKey);
|
||||
models.push({
|
||||
type: "model",
|
||||
id: modelKey,
|
||||
display_name: configured?.name || modelKey,
|
||||
created_at: configured
|
||||
? new Date(configured.createdAt).toISOString()
|
||||
: UNKNOWN_CREATED_AT,
|
||||
max_input_tokens: null,
|
||||
max_tokens: null,
|
||||
capabilities: null
|
||||
});
|
||||
}
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates the permitted models across every provider attached to a
|
||||
* resource. Unlike an inference request there is no requested model to
|
||||
* disambiguate on, so no provider selection happens - the listing is the union
|
||||
* of what each provider would accept, deduplicated by model id.
|
||||
*/
|
||||
export function listPermittedModels(
|
||||
providers: ModelDiscoveryProvider[]
|
||||
): AnthropicModelInfo[] {
|
||||
const byModelId = new Map<string, AnthropicModelInfo>();
|
||||
|
||||
// Sorted so a model offered by two providers always resolves to the same
|
||||
// entry, which keeps the cursor ordering stable across requests.
|
||||
const ordered = [...providers].sort((a, b) => a.providerId - b.providerId);
|
||||
|
||||
for (const provider of ordered) {
|
||||
for (const model of expandProviderModels(provider)) {
|
||||
if (!byModelId.has(model.id)) {
|
||||
byModelId.set(model.id, model);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "More recently released models are listed first" per the Models API,
|
||||
// with the id as a tie-break so the ordering is total - cursor pagination
|
||||
// needs it to be stable between calls.
|
||||
return [...byModelId.values()].sort((a, b) => {
|
||||
const byCreated = b.created_at.localeCompare(a.created_at);
|
||||
return byCreated !== 0 ? byCreated : a.id.localeCompare(b.id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies Anthropic's cursor pagination to an ordered model list. `after_id`
|
||||
* returns the page immediately after that model, `before_id` the page
|
||||
* immediately before it. Returns an error message for a caller mistake
|
||||
* (both cursors, or a cursor naming a model that isn't in the list).
|
||||
*/
|
||||
export function paginateModels(
|
||||
models: AnthropicModelInfo[],
|
||||
limit: number,
|
||||
cursor: { afterId?: string; beforeId?: string }
|
||||
): ModelPage | { error: string } {
|
||||
if (cursor.afterId && cursor.beforeId) {
|
||||
return { error: "Only one of after_id and before_id may be provided" };
|
||||
}
|
||||
|
||||
const cursorId = cursor.afterId ?? cursor.beforeId;
|
||||
if (!cursorId) {
|
||||
return {
|
||||
data: models.slice(0, limit),
|
||||
has_more: models.length > limit
|
||||
};
|
||||
}
|
||||
|
||||
const index = models.findIndex((model) => model.id === cursorId);
|
||||
if (index === -1) {
|
||||
return { error: `Unknown cursor id "${cursorId}"` };
|
||||
}
|
||||
|
||||
if (cursor.afterId) {
|
||||
const start = index + 1;
|
||||
return {
|
||||
data: models.slice(start, start + limit),
|
||||
has_more: models.length > start + limit
|
||||
};
|
||||
}
|
||||
|
||||
const start = Math.max(0, index - limit);
|
||||
return {
|
||||
data: models.slice(start, index),
|
||||
has_more: start > 0
|
||||
};
|
||||
}
|
||||
@@ -335,6 +335,8 @@ const EXTRACTORS: Record<
|
||||
openai_chat: extractOpenAiChat,
|
||||
openai_responses: extractOpenAiResponses,
|
||||
anthropic_messages: extractAnthropicMessages,
|
||||
// Model discovery never runs a model, so there are no tokens to bill.
|
||||
anthropic_models: () => null,
|
||||
gemini_generate_content: extractGoogleGenerateContent,
|
||||
google_generate_content: extractGoogleGenerateContent,
|
||||
// rawPredict is a passthrough to whatever the underlying publisher
|
||||
|
||||
Reference in New Issue
Block a user