mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-21 11:42:37 +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`
|
- Route → capability binding: `server/routers/aiGateway/createAiGatewayRouter.ts`
|
||||||
- Request pipeline: `server/routers/aiGateway/pipeline.ts` (`selectProvider`)
|
- 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`
|
- Tie-break scoring: `server/lib/aiProviderSelection.ts`
|
||||||
- Allow/block matching: `server/lib/aiModelKeyMatch.ts`
|
- Allow/block matching: `server/lib/aiModelKeyMatch.ts`
|
||||||
- Model catalog: `server/lib/aiModelCatalog.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/chat/completions` | `openai_chat` |
|
||||||
| `POST /v1/responses` | `openai_responses` |
|
| `POST /v1/responses` | `openai_responses` |
|
||||||
| `POST /v1/messages` | `anthropic_messages` |
|
| `POST /v1/messages` | `anthropic_messages` |
|
||||||
|
| `GET /v1/models`, `GET /v1/models/{id}` | `anthropic_models` |
|
||||||
| Gemini / Vertex / Bedrock routes | their respective capability ids |
|
| Gemini / Vertex / Bedrock routes | their respective capability ids |
|
||||||
|
|
||||||
Only attached providers that advertise that capability stay in the candidate
|
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 |
|
| Provider type | Default capabilities |
|
||||||
|---------------|----------------------|
|
|---------------|----------------------|
|
||||||
| `openai` | `openai_chat`, `openai_responses` |
|
| `openai` | `openai_chat`, `openai_responses` |
|
||||||
| `anthropic` | `anthropic_messages` |
|
| `anthropic` | `anthropic_messages`, `anthropic_models` |
|
||||||
| `openRouter` | `openai_chat` |
|
| `openRouter` | `openai_chat` |
|
||||||
| `vercelAiGateway` | `openai_chat`, `openai_responses` |
|
| `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 |
|
| `custom` | whatever was configured |
|
||||||
|
|
||||||
### 2. Allow / Block Lists
|
### 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
|
Typical remaining ties: two OpenAI-type providers both with `*`, or two
|
||||||
customs advertising the same capability for an unknown model.
|
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
|
## Examples
|
||||||
|
|
||||||
Assume each provider below is attached and enabled on the same inference
|
Assume each provider below is attached and enabled on the same inference
|
||||||
|
|||||||
@@ -1923,6 +1923,8 @@
|
|||||||
"aiCapabilityOpenaiResponsesDescription": "Supports /v1/responses",
|
"aiCapabilityOpenaiResponsesDescription": "Supports /v1/responses",
|
||||||
"aiCapabilityAnthropicMessages": "Anthropic Messages",
|
"aiCapabilityAnthropicMessages": "Anthropic Messages",
|
||||||
"aiCapabilityAnthropicMessagesDescription": "Supports /v1/messages",
|
"aiCapabilityAnthropicMessagesDescription": "Supports /v1/messages",
|
||||||
|
"aiCapabilityAnthropicModels": "Anthropic Models",
|
||||||
|
"aiCapabilityAnthropicModelsDescription": "Supports /v1/models model discovery",
|
||||||
"aiCapabilityGeminiGenerateContent": "Gemini Generate Content",
|
"aiCapabilityGeminiGenerateContent": "Gemini Generate Content",
|
||||||
"aiCapabilityGeminiGenerateContentDescription": "Supports the direct Gemini API",
|
"aiCapabilityGeminiGenerateContentDescription": "Supports the direct Gemini API",
|
||||||
"aiCapabilityBedrockModelInvoke": "Bedrock Model Invoke",
|
"aiCapabilityBedrockModelInvoke": "Bedrock Model Invoke",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { AI_CAPABILITIES, type AiCapability } from "@app/lib/aiCapabilities";
|
|||||||
export { AI_CAPABILITIES, type AiCapability };
|
export { AI_CAPABILITIES, type AiCapability };
|
||||||
|
|
||||||
export type AiCapabilityRoute = {
|
export type AiCapabilityRoute = {
|
||||||
method: "POST";
|
method: "GET" | "POST";
|
||||||
path: string;
|
path: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,6 +135,21 @@ export const AI_CAPABILITY_DEFS: Record<AiCapability, AiCapabilityDefinition> =
|
|||||||
joinUpstreamUrl(base, pathFromRequest(req)),
|
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||||
isStreaming: isBodyOrSseStreaming
|
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: {
|
gemini_generate_content: {
|
||||||
id: "gemini_generate_content",
|
id: "gemini_generate_content",
|
||||||
protocolFamily: "google",
|
protocolFamily: "google",
|
||||||
|
|||||||
@@ -471,6 +471,8 @@ const REQUEST_NORMALIZERS: Record<
|
|||||||
openai_chat: normalizeOpenAiChatRequest,
|
openai_chat: normalizeOpenAiChatRequest,
|
||||||
openai_responses: normalizeOpenAiResponsesRequest,
|
openai_responses: normalizeOpenAiResponsesRequest,
|
||||||
anthropic_messages: normalizeAnthropicRequest,
|
anthropic_messages: normalizeAnthropicRequest,
|
||||||
|
// Model discovery carries no transcript to normalize.
|
||||||
|
anthropic_models: () => null,
|
||||||
gemini_generate_content: normalizeGeminiRequest,
|
gemini_generate_content: normalizeGeminiRequest,
|
||||||
google_generate_content: normalizeGeminiRequest,
|
google_generate_content: normalizeGeminiRequest,
|
||||||
google_raw_predict: normalizeBestEffortRequest,
|
google_raw_predict: normalizeBestEffortRequest,
|
||||||
@@ -485,6 +487,7 @@ const RESPONSE_NORMALIZERS: Record<
|
|||||||
openai_chat: normalizeOpenAiChatResponse,
|
openai_chat: normalizeOpenAiChatResponse,
|
||||||
openai_responses: normalizeOpenAiResponsesResponse,
|
openai_responses: normalizeOpenAiResponsesResponse,
|
||||||
anthropic_messages: normalizeAnthropicResponse,
|
anthropic_messages: normalizeAnthropicResponse,
|
||||||
|
anthropic_models: () => null,
|
||||||
gemini_generate_content: normalizeGeminiResponse,
|
gemini_generate_content: normalizeGeminiResponse,
|
||||||
google_generate_content: normalizeGeminiResponse,
|
google_generate_content: normalizeGeminiResponse,
|
||||||
google_raw_predict: normalizeGoogleRawPredictResponse,
|
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_chat: extractOpenAiChat,
|
||||||
openai_responses: extractOpenAiResponses,
|
openai_responses: extractOpenAiResponses,
|
||||||
anthropic_messages: extractAnthropicMessages,
|
anthropic_messages: extractAnthropicMessages,
|
||||||
|
// Model discovery never runs a model, so there are no tokens to bill.
|
||||||
|
anthropic_models: () => null,
|
||||||
gemini_generate_content: extractGoogleGenerateContent,
|
gemini_generate_content: extractGoogleGenerateContent,
|
||||||
google_generate_content: extractGoogleGenerateContent,
|
google_generate_content: extractGoogleGenerateContent,
|
||||||
// rawPredict is a passthrough to whatever the underlying publisher
|
// 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 {
|
import {
|
||||||
AI_CAPABILITY_DEFS,
|
AI_CAPABILITY_DEFS,
|
||||||
type AiCapability
|
type AiCapability
|
||||||
} from "@server/lib/aiCapabilities";
|
} from "@server/lib/aiCapabilities";
|
||||||
import { handleAiGatewayProxy } from "@server/routers/aiGateway/pipeline";
|
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() {
|
export function createAiGatewayRouter() {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
for (const def of Object.values(AI_CAPABILITY_DEFS)) {
|
for (const def of Object.values(AI_CAPABILITY_DEFS)) {
|
||||||
const capability = def.id as AiCapability;
|
const capability = def.id as AiCapability;
|
||||||
|
const handler = LOCAL_HANDLERS[capability] ?? handleAiGatewayProxy;
|
||||||
for (const route of def.routes) {
|
for (const route of def.routes) {
|
||||||
router.post(route.path, (req, res) =>
|
const bind = (req: Request, res: Response) =>
|
||||||
handleAiGatewayProxy(req, res, capability)
|
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 { handleAiGatewayProxy } from "./pipeline";
|
||||||
|
export { handleAnthropicModels } from "./anthropicModels";
|
||||||
export { createAiGatewayRouter } from "./createAiGatewayRouter";
|
export { createAiGatewayRouter } from "./createAiGatewayRouter";
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ async function findClientByIp(ip: string): Promise<CachedClient> {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProviderAttachment = {
|
export type ProviderAttachment = {
|
||||||
provider: AiProvider;
|
provider: AiProvider;
|
||||||
accessMode: AccessMode;
|
accessMode: AccessMode;
|
||||||
};
|
};
|
||||||
@@ -149,12 +149,12 @@ type ResourceModelPattern = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProviderPatternLists = {
|
export type ProviderPatternLists = {
|
||||||
allows: string[];
|
allows: string[];
|
||||||
blocks: string[];
|
blocks: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ResolvedTarget = {
|
export type ResolvedTarget = {
|
||||||
resourceId: number | null;
|
resourceId: number | null;
|
||||||
siteResourceId: number | null;
|
siteResourceId: number | null;
|
||||||
orgId: string | 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 -
|
// request came through, per the trust middleware's resource-type header -
|
||||||
// falls back to checking both (public preferred on overlap) only when that
|
// 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.
|
// header is absent, e.g. a request that reached the gateway outside Traefik.
|
||||||
async function resolveTarget(
|
export async function resolveTarget(
|
||||||
host: string,
|
host: string,
|
||||||
resourceType: AiGatewayResourceType | null
|
resourceType: AiGatewayResourceType | null
|
||||||
): Promise<ResolvedTarget | 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(
|
export async function handleAiGatewayProxy(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
@@ -820,11 +831,7 @@ export async function handleAiGatewayProxy(
|
|||||||
try {
|
try {
|
||||||
const def = AI_CAPABILITY_DEFS[capability];
|
const def = AI_CAPABILITY_DEFS[capability];
|
||||||
|
|
||||||
const host = (
|
const host = resolveGatewayHost(req);
|
||||||
(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];
|
|
||||||
if (!host) {
|
if (!host) {
|
||||||
return res
|
return res
|
||||||
.status(HttpCode.BAD_REQUEST)
|
.status(HttpCode.BAD_REQUEST)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const capabilityLabels: Record<string, string> = {
|
|||||||
openai_chat: "OpenAI Chat Completions",
|
openai_chat: "OpenAI Chat Completions",
|
||||||
openai_responses: "OpenAI Responses",
|
openai_responses: "OpenAI Responses",
|
||||||
anthropic_messages: "Anthropic Messages",
|
anthropic_messages: "Anthropic Messages",
|
||||||
|
anthropic_models: "Anthropic Models",
|
||||||
gemini_generate_content: "Gemini",
|
gemini_generate_content: "Gemini",
|
||||||
google_generate_content: "Vertex AI (Generate Content)",
|
google_generate_content: "Vertex AI (Generate Content)",
|
||||||
google_raw_predict: "Vertex AI (Raw Predict)",
|
google_raw_predict: "Vertex AI (Raw Predict)",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const CAPABILITY_LABEL_KEYS: Record<AiCapability, string> = {
|
|||||||
openai_chat: "aiCapabilityOpenaiChat",
|
openai_chat: "aiCapabilityOpenaiChat",
|
||||||
openai_responses: "aiCapabilityOpenaiResponses",
|
openai_responses: "aiCapabilityOpenaiResponses",
|
||||||
anthropic_messages: "aiCapabilityAnthropicMessages",
|
anthropic_messages: "aiCapabilityAnthropicMessages",
|
||||||
|
anthropic_models: "aiCapabilityAnthropicModels",
|
||||||
gemini_generate_content: "aiCapabilityGeminiGenerateContent",
|
gemini_generate_content: "aiCapabilityGeminiGenerateContent",
|
||||||
bedrock_model_invoke: "aiCapabilityBedrockModelInvoke",
|
bedrock_model_invoke: "aiCapabilityBedrockModelInvoke",
|
||||||
google_generate_content: "aiCapabilityGoogleGenerateContent",
|
google_generate_content: "aiCapabilityGoogleGenerateContent",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export const AI_CAPABILITIES = [
|
|||||||
"openai_chat",
|
"openai_chat",
|
||||||
"openai_responses",
|
"openai_responses",
|
||||||
"anthropic_messages",
|
"anthropic_messages",
|
||||||
|
"anthropic_models",
|
||||||
"gemini_generate_content",
|
"gemini_generate_content",
|
||||||
"bedrock_model_invoke",
|
"bedrock_model_invoke",
|
||||||
"google_generate_content",
|
"google_generate_content",
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const AI_PROVIDER_DEFAULTS: Record<
|
|||||||
anthropic: {
|
anthropic: {
|
||||||
upstreamUrl: "https://api.anthropic.com",
|
upstreamUrl: "https://api.anthropic.com",
|
||||||
authType: "x-api-key",
|
authType: "x-api-key",
|
||||||
capabilities: ["anthropic_messages"]
|
capabilities: ["anthropic_messages", "anthropic_models"]
|
||||||
},
|
},
|
||||||
googleGemini: {
|
googleGemini: {
|
||||||
upstreamUrl: "https://generativelanguage.googleapis.com",
|
upstreamUrl: "https://generativelanguage.googleapis.com",
|
||||||
@@ -63,7 +63,12 @@ export const AI_PROVIDER_DEFAULTS: Record<
|
|||||||
microsoftFoundry: {
|
microsoftFoundry: {
|
||||||
upstreamUrl: null,
|
upstreamUrl: null,
|
||||||
authType: "bearer",
|
authType: "bearer",
|
||||||
capabilities: ["openai_chat", "openai_responses", "anthropic_messages"]
|
capabilities: [
|
||||||
|
"openai_chat",
|
||||||
|
"openai_responses",
|
||||||
|
"anthropic_messages",
|
||||||
|
"anthropic_models"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
openRouter: {
|
openRouter: {
|
||||||
upstreamUrl: "https://openrouter.ai/api/v1",
|
upstreamUrl: "https://openrouter.ai/api/v1",
|
||||||
|
|||||||
Reference in New Issue
Block a user