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
+44 -2
View File
@@ -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
+2
View File
@@ -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",
+16 -1
View File
@@ -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",
+3
View File
@@ -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,
+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_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
+293
View File
@@ -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
View File
@@ -1,2 +1,3 @@
export { handleAiGatewayProxy } from "./pipeline"; export { handleAiGatewayProxy } from "./pipeline";
export { handleAnthropicModels } from "./anthropicModels";
export { createAiGatewayRouter } from "./createAiGatewayRouter"; export { createAiGatewayRouter } from "./createAiGatewayRouter";
+16 -9
View File
@@ -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",
+1
View File
@@ -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",
+7 -2
View File
@@ -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",