mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-21 03:32:33 +02:00
Add anthropic_models capability
This commit is contained in:
@@ -7,6 +7,8 @@ inference resource has more than one AI provider.
|
||||
|
||||
- Route → capability binding: `server/routers/aiGateway/createAiGatewayRouter.ts`
|
||||
- Request pipeline: `server/routers/aiGateway/pipeline.ts` (`selectProvider`)
|
||||
- Model discovery: `server/routers/aiGateway/anthropicModels.ts` and
|
||||
`server/lib/aiModelDiscovery.ts`
|
||||
- Tie-break scoring: `server/lib/aiProviderSelection.ts`
|
||||
- Allow/block matching: `server/lib/aiModelKeyMatch.ts`
|
||||
- Model catalog: `server/lib/aiModelCatalog.ts`
|
||||
@@ -39,6 +41,7 @@ The incoming path selects a capability before any provider logic runs.
|
||||
| `POST /v1/chat/completions` | `openai_chat` |
|
||||
| `POST /v1/responses` | `openai_responses` |
|
||||
| `POST /v1/messages` | `anthropic_messages` |
|
||||
| `GET /v1/models`, `GET /v1/models/{id}` | `anthropic_models` |
|
||||
| Gemini / Vertex / Bedrock routes | their respective capability ids |
|
||||
|
||||
Only attached providers that advertise that capability stay in the candidate
|
||||
@@ -47,10 +50,10 @@ set. Default capabilities do not overlap for native OpenAI vs Anthropic:
|
||||
| Provider type | Default capabilities |
|
||||
|---------------|----------------------|
|
||||
| `openai` | `openai_chat`, `openai_responses` |
|
||||
| `anthropic` | `anthropic_messages` |
|
||||
| `anthropic` | `anthropic_messages`, `anthropic_models` |
|
||||
| `openRouter` | `openai_chat` |
|
||||
| `vercelAiGateway` | `openai_chat`, `openai_responses` |
|
||||
| `microsoftFoundry` | `openai_chat`, `openai_responses`, `anthropic_messages` |
|
||||
| `microsoftFoundry` | `openai_chat`, `openai_responses`, `anthropic_messages`, `anthropic_models` |
|
||||
| `custom` | whatever was configured |
|
||||
|
||||
### 2. Allow / Block Lists
|
||||
@@ -128,6 +131,45 @@ Model "<id>" is ambiguous across multiple AI providers on this resource
|
||||
Typical remaining ties: two OpenAI-type providers both with `*`, or two
|
||||
customs advertising the same capability for an unknown model.
|
||||
|
||||
## Model Discovery Is Not Selection
|
||||
|
||||
`GET /v1/models` and `GET /v1/models/{id}` (`anthropic_models`) skip steps 3-6
|
||||
entirely. There is no requested model to disambiguate on, so the gateway does
|
||||
not pick one provider - it returns the **union** of what every attached
|
||||
provider advertising `anthropic_models` would accept, deduplicated by model id
|
||||
(lowest `providerId` wins a collision).
|
||||
|
||||
Discovery is answered from the gateway's own view of the allow/block lists,
|
||||
never proxied upstream. Providers that expose no `/v1/models` endpoint of their
|
||||
own still get a working listing, and a model an allow/block list forbids is
|
||||
never advertised.
|
||||
|
||||
Each provider's candidate ids come from two places:
|
||||
|
||||
| Source | Contributes |
|
||||
|--------|-------------|
|
||||
| Exact (non-wildcard) allow entries | the model key itself |
|
||||
| The model catalog for the provider's type | every catalog id matching an allow pattern |
|
||||
|
||||
Both sources are then filtered through the same
|
||||
`isAllowedByLists(id, allows, blocks)` check step 2 applies, so a block pattern
|
||||
hides a model from discovery exactly as it would reject it at request time.
|
||||
|
||||
The catalog source is what makes a wildcard allow such as `claude-*`
|
||||
enumerable. Provider types with no catalog mapping (`openRouter`,
|
||||
`vercelAiGateway`, `custom`) have nothing to expand against, so a wildcard
|
||||
allow on those types lists nothing - **add exact allow entries to make their
|
||||
models discoverable.**
|
||||
|
||||
Fields the API declares nullable and an allow/block list cannot supply
|
||||
(`max_input_tokens`, `max_tokens`, `capabilities`) are returned as `null`.
|
||||
`display_name` and `created_at` come from the configured model row when the id
|
||||
matches one; otherwise the id doubles as the display name and `created_at` is
|
||||
the epoch, which the Models API permits when the release date is unknown.
|
||||
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
|
||||
1000).
|
||||
|
||||
## Examples
|
||||
|
||||
Assume each provider below is attached and enabled on the same inference
|
||||
|
||||
@@ -1923,6 +1923,8 @@
|
||||
"aiCapabilityOpenaiResponsesDescription": "Supports /v1/responses",
|
||||
"aiCapabilityAnthropicMessages": "Anthropic Messages",
|
||||
"aiCapabilityAnthropicMessagesDescription": "Supports /v1/messages",
|
||||
"aiCapabilityAnthropicModels": "Anthropic Models",
|
||||
"aiCapabilityAnthropicModelsDescription": "Supports /v1/models model discovery",
|
||||
"aiCapabilityGeminiGenerateContent": "Gemini Generate Content",
|
||||
"aiCapabilityGeminiGenerateContentDescription": "Supports the direct Gemini API",
|
||||
"aiCapabilityBedrockModelInvoke": "Bedrock Model Invoke",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import { Request, Response } from "express";
|
||||
import { inArray } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { aiModels, db } from "@server/db";
|
||||
import {
|
||||
providerHasCapability,
|
||||
type AiCapability
|
||||
} from "@server/lib/aiCapabilities";
|
||||
import {
|
||||
buildAiCapabilityErrorBody,
|
||||
type AiCapabilityErrorKind
|
||||
} from "@server/lib/aiGatewayAuthError";
|
||||
import {
|
||||
getAiGatewayResourceType,
|
||||
isAiGatewayTrustHeaderValid
|
||||
} from "@server/lib/aiGatewayTrust";
|
||||
import { resolveEffectiveLists } from "@server/lib/aiInferenceResource";
|
||||
import { listCatalogModelsForType } from "@server/lib/aiModelCatalog";
|
||||
import {
|
||||
listPermittedModels,
|
||||
paginateModels,
|
||||
MODEL_PAGE_DEFAULT_LIMIT,
|
||||
MODEL_PAGE_MAX_LIMIT,
|
||||
type ConfiguredModel,
|
||||
type ModelDiscoveryProvider
|
||||
} from "@server/lib/aiModelDiscovery";
|
||||
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
|
||||
import {
|
||||
resolveGatewayHost,
|
||||
resolveTarget,
|
||||
type ProviderAttachment,
|
||||
type ProviderPatternLists
|
||||
} from "@server/routers/aiGateway/pipeline";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
const CAPABILITY: AiCapability = "anthropic_models";
|
||||
|
||||
const querySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(MODEL_PAGE_MAX_LIMIT).optional(),
|
||||
after_id: z.string().min(1).optional(),
|
||||
before_id: z.string().min(1).optional()
|
||||
});
|
||||
|
||||
type ProviderModelLists = {
|
||||
allowsByProvider: Map<number, string[]>;
|
||||
blocksByProvider: Map<number, string[]>;
|
||||
configuredByProvider: Map<number, Map<string, ConfiguredModel>>;
|
||||
};
|
||||
|
||||
function errorResponse(
|
||||
res: Response,
|
||||
status: number,
|
||||
kind: AiCapabilityErrorKind,
|
||||
message: string
|
||||
) {
|
||||
return res
|
||||
.status(status)
|
||||
.json(buildAiCapabilityErrorBody(CAPABILITY, kind, message, status));
|
||||
}
|
||||
|
||||
// Provider-level allow/block lists, plus the display name and creation time of
|
||||
// every catalog row, so explicitly configured models are reported with the name
|
||||
// the administrator gave them rather than a bare model id.
|
||||
async function loadProviderModelLists(
|
||||
providerIds: number[]
|
||||
): Promise<ProviderModelLists> {
|
||||
const lists: ProviderModelLists = {
|
||||
allowsByProvider: new Map(),
|
||||
blocksByProvider: new Map(),
|
||||
configuredByProvider: new Map()
|
||||
};
|
||||
|
||||
if (providerIds.length === 0) {
|
||||
return lists;
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
providerId: aiModels.providerId,
|
||||
modelKey: aiModels.modelKey,
|
||||
name: aiModels.name,
|
||||
listType: aiModels.listType,
|
||||
enabled: aiModels.enabled,
|
||||
createdAt: aiModels.createdAt
|
||||
})
|
||||
.from(aiModels)
|
||||
.where(inArray(aiModels.providerId, providerIds));
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.enabled) {
|
||||
continue;
|
||||
}
|
||||
const targetMap =
|
||||
row.listType === "allow"
|
||||
? lists.allowsByProvider
|
||||
: lists.blocksByProvider;
|
||||
const existing = targetMap.get(row.providerId) ?? [];
|
||||
existing.push(row.modelKey);
|
||||
targetMap.set(row.providerId, existing);
|
||||
|
||||
let configured = lists.configuredByProvider.get(row.providerId);
|
||||
if (!configured) {
|
||||
configured = new Map();
|
||||
lists.configuredByProvider.set(row.providerId, configured);
|
||||
}
|
||||
configured.set(row.modelKey, {
|
||||
name: row.name,
|
||||
createdAt: row.createdAt
|
||||
});
|
||||
}
|
||||
|
||||
return lists;
|
||||
}
|
||||
|
||||
function buildDiscoveryProviders(
|
||||
attachments: ProviderAttachment[],
|
||||
resourceListsByProvider: Map<number, ProviderPatternLists>,
|
||||
lists: ProviderModelLists
|
||||
): ModelDiscoveryProvider[] {
|
||||
return attachments.map((attachment) => {
|
||||
const providerId = attachment.provider.providerId;
|
||||
const resourceLists = resourceListsByProvider.get(providerId);
|
||||
const { allows, blocks } = resolveEffectiveLists({
|
||||
accessMode: attachment.accessMode,
|
||||
providerAllows: lists.allowsByProvider.get(providerId) ?? [],
|
||||
providerBlocks: lists.blocksByProvider.get(providerId) ?? [],
|
||||
resourceAllows: resourceLists?.allows ?? [],
|
||||
resourceBlocks: resourceLists?.blocks ?? []
|
||||
});
|
||||
|
||||
return {
|
||||
providerId,
|
||||
allows,
|
||||
blocks,
|
||||
catalogModelIds: listCatalogModelsForType(
|
||||
attachment.provider.type as AiProviderType
|
||||
).map((entry) => entry.model),
|
||||
configured: lists.configuredByProvider.get(providerId) ?? new Map()
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves Anthropic's model-discovery endpoints (`GET /v1/models` and
|
||||
* `GET /v1/models/{id}`) for an inference resource. The gateway answers these
|
||||
* itself rather than proxying: upstream providers either don't expose a model
|
||||
* list at all or would expose models the resource's allow/block lists forbid,
|
||||
* so the response is built from the same effective lists that gate inference.
|
||||
*/
|
||||
export async function handleAnthropicModels(
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<any> {
|
||||
try {
|
||||
const host = resolveGatewayHost(req);
|
||||
if (!host) {
|
||||
return errorResponse(
|
||||
res,
|
||||
HttpCode.BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Missing Host header"
|
||||
);
|
||||
}
|
||||
|
||||
const resourceType = getAiGatewayResourceType(
|
||||
req.headers as Record<string, string>
|
||||
);
|
||||
const target = await resolveTarget(host, resourceType);
|
||||
if (!target) {
|
||||
return errorResponse(
|
||||
res,
|
||||
HttpCode.NOT_FOUND,
|
||||
"not_found",
|
||||
"No inference resource found for this host"
|
||||
);
|
||||
}
|
||||
|
||||
// Same gate as the inference pipeline: public inference must pass
|
||||
// Badger verify-session first, which is what stamps the trust header.
|
||||
if (
|
||||
target.resourceId != null &&
|
||||
!isAiGatewayTrustHeaderValid(req.headers as Record<string, string>)
|
||||
) {
|
||||
return errorResponse(
|
||||
res,
|
||||
HttpCode.UNAUTHORIZED,
|
||||
"authentication",
|
||||
"Request must be authenticated via the inference resource"
|
||||
);
|
||||
}
|
||||
|
||||
if (target.attachments.length === 0) {
|
||||
return errorResponse(
|
||||
res,
|
||||
HttpCode.FORBIDDEN,
|
||||
"permission",
|
||||
"No AI providers configured for this resource"
|
||||
);
|
||||
}
|
||||
|
||||
const capableAttachments = target.attachments.filter((a) =>
|
||||
providerHasCapability(a.provider.capabilities, CAPABILITY)
|
||||
);
|
||||
if (capableAttachments.length === 0) {
|
||||
return errorResponse(
|
||||
res,
|
||||
HttpCode.FORBIDDEN,
|
||||
"permission",
|
||||
`No AI provider on this resource supports ${CAPABILITY}`
|
||||
);
|
||||
}
|
||||
|
||||
const lists = await loadProviderModelLists(
|
||||
capableAttachments.map((a) => a.provider.providerId)
|
||||
);
|
||||
const models = listPermittedModels(
|
||||
buildDiscoveryProviders(
|
||||
capableAttachments,
|
||||
target.resourceListsByProvider,
|
||||
lists
|
||||
)
|
||||
);
|
||||
|
||||
// `GET /v1/models/{id}` - a single model, 404 when this resource
|
||||
// doesn't permit it.
|
||||
const requestedModel = req.params?.model;
|
||||
if (typeof requestedModel === "string" && requestedModel.length > 0) {
|
||||
const model = models.find((m) => m.id === requestedModel);
|
||||
if (!model) {
|
||||
return errorResponse(
|
||||
res,
|
||||
HttpCode.NOT_FOUND,
|
||||
"not_found",
|
||||
`Model "${requestedModel}" is not available on this resource`
|
||||
);
|
||||
}
|
||||
return res.status(HttpCode.OK).json(model);
|
||||
}
|
||||
|
||||
const parsedQuery = querySchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return errorResponse(
|
||||
res,
|
||||
HttpCode.BAD_REQUEST,
|
||||
"invalid_request",
|
||||
parsedQuery.error.issues[0]?.message ??
|
||||
"Invalid pagination parameters"
|
||||
);
|
||||
}
|
||||
|
||||
const page = paginateModels(
|
||||
models,
|
||||
parsedQuery.data.limit ?? MODEL_PAGE_DEFAULT_LIMIT,
|
||||
{
|
||||
afterId: parsedQuery.data.after_id,
|
||||
beforeId: parsedQuery.data.before_id
|
||||
}
|
||||
);
|
||||
if ("error" in page) {
|
||||
return errorResponse(
|
||||
res,
|
||||
HttpCode.BAD_REQUEST,
|
||||
"invalid_request",
|
||||
page.error
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug("AI gateway model discovery", {
|
||||
host,
|
||||
resourceId: target.resourceId,
|
||||
siteResourceId: target.siteResourceId,
|
||||
providers: capableAttachments.length,
|
||||
total: models.length,
|
||||
returned: page.data.length
|
||||
});
|
||||
|
||||
return res.status(HttpCode.OK).json({
|
||||
data: page.data,
|
||||
has_more: page.has_more,
|
||||
first_id: page.data[0]?.id ?? null,
|
||||
last_id: page.data[page.data.length - 1]?.id ?? null
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return errorResponse(
|
||||
res,
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"internal",
|
||||
"Failed to list models"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,37 @@
|
||||
import { Router } from "express";
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import {
|
||||
AI_CAPABILITY_DEFS,
|
||||
type AiCapability
|
||||
} from "@server/lib/aiCapabilities";
|
||||
import { handleAiGatewayProxy } from "@server/routers/aiGateway/pipeline";
|
||||
import { handleAnthropicModels } from "@server/routers/aiGateway/anthropicModels";
|
||||
|
||||
type CapabilityHandler = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
capability: AiCapability
|
||||
) => Promise<any>;
|
||||
|
||||
// Capabilities the gateway answers itself instead of proxying upstream.
|
||||
// Everything else goes through the inference pipeline.
|
||||
const LOCAL_HANDLERS: Partial<Record<AiCapability, CapabilityHandler>> = {
|
||||
anthropic_models: handleAnthropicModels
|
||||
};
|
||||
|
||||
export function createAiGatewayRouter() {
|
||||
const router = Router();
|
||||
|
||||
for (const def of Object.values(AI_CAPABILITY_DEFS)) {
|
||||
const capability = def.id as AiCapability;
|
||||
const handler = LOCAL_HANDLERS[capability] ?? handleAiGatewayProxy;
|
||||
for (const route of def.routes) {
|
||||
router.post(route.path, (req, res) =>
|
||||
handleAiGatewayProxy(req, res, capability)
|
||||
);
|
||||
const bind = (req: Request, res: Response) =>
|
||||
handler(req, res, capability);
|
||||
if (route.method === "GET") {
|
||||
router.get(route.path, bind);
|
||||
} else {
|
||||
router.post(route.path, bind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { handleAiGatewayProxy } from "./pipeline";
|
||||
export { handleAnthropicModels } from "./anthropicModels";
|
||||
export { createAiGatewayRouter } from "./createAiGatewayRouter";
|
||||
|
||||
@@ -137,7 +137,7 @@ async function findClientByIp(ip: string): Promise<CachedClient> {
|
||||
return result;
|
||||
}
|
||||
|
||||
type ProviderAttachment = {
|
||||
export type ProviderAttachment = {
|
||||
provider: AiProvider;
|
||||
accessMode: AccessMode;
|
||||
};
|
||||
@@ -149,12 +149,12 @@ type ResourceModelPattern = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type ProviderPatternLists = {
|
||||
export type ProviderPatternLists = {
|
||||
allows: string[];
|
||||
blocks: string[];
|
||||
};
|
||||
|
||||
type ResolvedTarget = {
|
||||
export type ResolvedTarget = {
|
||||
resourceId: number | null;
|
||||
siteResourceId: number | null;
|
||||
orgId: string | null;
|
||||
@@ -362,7 +362,7 @@ function getRequestHeader(req: Request, name: string): string | undefined {
|
||||
// request came through, per the trust middleware's resource-type header -
|
||||
// falls back to checking both (public preferred on overlap) only when that
|
||||
// header is absent, e.g. a request that reached the gateway outside Traefik.
|
||||
async function resolveTarget(
|
||||
export async function resolveTarget(
|
||||
host: string,
|
||||
resourceType: AiGatewayResourceType | null
|
||||
): Promise<ResolvedTarget | null> {
|
||||
@@ -812,6 +812,17 @@ export function recordAiGatewayCompletion(args: {
|
||||
});
|
||||
}
|
||||
|
||||
// p-host is only used sometimes when overriding the host header for some
|
||||
// middleware proxy. Shared with the model-discovery endpoint so both resolve
|
||||
// the inference resource off the same hostname.
|
||||
export function resolveGatewayHost(req: Request): string {
|
||||
return (
|
||||
(req.headers["p-host"] as string | undefined) ||
|
||||
req.headers.host ||
|
||||
""
|
||||
).split(":")[0];
|
||||
}
|
||||
|
||||
export async function handleAiGatewayProxy(
|
||||
req: Request,
|
||||
res: Response,
|
||||
@@ -820,11 +831,7 @@ export async function handleAiGatewayProxy(
|
||||
try {
|
||||
const def = AI_CAPABILITY_DEFS[capability];
|
||||
|
||||
const host = (
|
||||
(req.headers["p-host"] as string | undefined) || // p-host is only used sometimes when overriding the host header for some middleware proxy
|
||||
req.headers.host ||
|
||||
""
|
||||
).split(":")[0];
|
||||
const host = resolveGatewayHost(req);
|
||||
if (!host) {
|
||||
return res
|
||||
.status(HttpCode.BAD_REQUEST)
|
||||
|
||||
@@ -33,6 +33,7 @@ const capabilityLabels: Record<string, string> = {
|
||||
openai_chat: "OpenAI Chat Completions",
|
||||
openai_responses: "OpenAI Responses",
|
||||
anthropic_messages: "Anthropic Messages",
|
||||
anthropic_models: "Anthropic Models",
|
||||
gemini_generate_content: "Gemini",
|
||||
google_generate_content: "Vertex AI (Generate Content)",
|
||||
google_raw_predict: "Vertex AI (Raw Predict)",
|
||||
|
||||
@@ -20,6 +20,7 @@ const CAPABILITY_LABEL_KEYS: Record<AiCapability, string> = {
|
||||
openai_chat: "aiCapabilityOpenaiChat",
|
||||
openai_responses: "aiCapabilityOpenaiResponses",
|
||||
anthropic_messages: "aiCapabilityAnthropicMessages",
|
||||
anthropic_models: "aiCapabilityAnthropicModels",
|
||||
gemini_generate_content: "aiCapabilityGeminiGenerateContent",
|
||||
bedrock_model_invoke: "aiCapabilityBedrockModelInvoke",
|
||||
google_generate_content: "aiCapabilityGoogleGenerateContent",
|
||||
|
||||
@@ -2,6 +2,7 @@ export const AI_CAPABILITIES = [
|
||||
"openai_chat",
|
||||
"openai_responses",
|
||||
"anthropic_messages",
|
||||
"anthropic_models",
|
||||
"gemini_generate_content",
|
||||
"bedrock_model_invoke",
|
||||
"google_generate_content",
|
||||
|
||||
@@ -43,7 +43,7 @@ export const AI_PROVIDER_DEFAULTS: Record<
|
||||
anthropic: {
|
||||
upstreamUrl: "https://api.anthropic.com",
|
||||
authType: "x-api-key",
|
||||
capabilities: ["anthropic_messages"]
|
||||
capabilities: ["anthropic_messages", "anthropic_models"]
|
||||
},
|
||||
googleGemini: {
|
||||
upstreamUrl: "https://generativelanguage.googleapis.com",
|
||||
@@ -63,7 +63,12 @@ export const AI_PROVIDER_DEFAULTS: Record<
|
||||
microsoftFoundry: {
|
||||
upstreamUrl: null,
|
||||
authType: "bearer",
|
||||
capabilities: ["openai_chat", "openai_responses", "anthropic_messages"]
|
||||
capabilities: [
|
||||
"openai_chat",
|
||||
"openai_responses",
|
||||
"anthropic_messages",
|
||||
"anthropic_models"
|
||||
]
|
||||
},
|
||||
openRouter: {
|
||||
upstreamUrl: "https://openrouter.ai/api/v1",
|
||||
|
||||
Reference in New Issue
Block a user