Add anthropic_models capability

This commit is contained in:
Owen
2026-08-20 14:28:19 -04:00
parent df7e26a444
commit bafbf6e096
14 changed files with 584 additions and 18 deletions
+16 -1
View File
@@ -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",
+3
View File
@@ -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,
+175
View File
@@ -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
};
}
+2
View File
@@ -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