diff --git a/docs/ai-gateway-provider-selection.md b/docs/ai-gateway-provider-selection.md index f955e25aa..d35e8afe7 100644 --- a/docs/ai-gateway-provider-selection.md +++ b/docs/ai-gateway-provider-selection.md @@ -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 "" 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 diff --git a/messages/en-US.json b/messages/en-US.json index 46f255c39..8952bbcbc 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -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", diff --git a/server/lib/aiCapabilities.ts b/server/lib/aiCapabilities.ts index b43cf3c1f..15e2595e2 100644 --- a/server/lib/aiCapabilities.ts +++ b/server/lib/aiCapabilities.ts @@ -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 = 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", diff --git a/server/lib/aiMessageNormalization.ts b/server/lib/aiMessageNormalization.ts index 1153142e1..4dd8cef8c 100644 --- a/server/lib/aiMessageNormalization.ts +++ b/server/lib/aiMessageNormalization.ts @@ -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, diff --git a/server/lib/aiModelDiscovery.ts b/server/lib/aiModelDiscovery.ts new file mode 100644 index 000000000..873abdb98 --- /dev/null +++ b/server/lib/aiModelDiscovery.ts @@ -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; +}; + +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(); + + 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(); + + // 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 + }; +} diff --git a/server/lib/aiUsageExtraction.ts b/server/lib/aiUsageExtraction.ts index fcec807e1..55c562e3b 100644 --- a/server/lib/aiUsageExtraction.ts +++ b/server/lib/aiUsageExtraction.ts @@ -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 diff --git a/server/routers/aiGateway/anthropicModels.ts b/server/routers/aiGateway/anthropicModels.ts new file mode 100644 index 000000000..b89868510 --- /dev/null +++ b/server/routers/aiGateway/anthropicModels.ts @@ -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; + blocksByProvider: Map; + configuredByProvider: Map>; +}; + +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 { + 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, + 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 { + 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 + ); + 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) + ) { + 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" + ); + } +} diff --git a/server/routers/aiGateway/createAiGatewayRouter.ts b/server/routers/aiGateway/createAiGatewayRouter.ts index cc62ade28..3e30dd6fb 100644 --- a/server/routers/aiGateway/createAiGatewayRouter.ts +++ b/server/routers/aiGateway/createAiGatewayRouter.ts @@ -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; + +// Capabilities the gateway answers itself instead of proxying upstream. +// Everything else goes through the inference pipeline. +const LOCAL_HANDLERS: Partial> = { + 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); + } } } diff --git a/server/routers/aiGateway/index.ts b/server/routers/aiGateway/index.ts index 6eea36d60..a10a7aed4 100644 --- a/server/routers/aiGateway/index.ts +++ b/server/routers/aiGateway/index.ts @@ -1,2 +1,3 @@ export { handleAiGatewayProxy } from "./pipeline"; +export { handleAnthropicModels } from "./anthropicModels"; export { createAiGatewayRouter } from "./createAiGatewayRouter"; diff --git a/server/routers/aiGateway/pipeline.ts b/server/routers/aiGateway/pipeline.ts index 76889cdc8..6375aa704 100644 --- a/server/routers/aiGateway/pipeline.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -137,7 +137,7 @@ async function findClientByIp(ip: string): Promise { 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 { @@ -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) diff --git a/src/app/[orgId]/settings/logs/ai/page.tsx b/src/app/[orgId]/settings/logs/ai/page.tsx index 136d186c3..be043dd30 100644 --- a/src/app/[orgId]/settings/logs/ai/page.tsx +++ b/src/app/[orgId]/settings/logs/ai/page.tsx @@ -33,6 +33,7 @@ const capabilityLabels: Record = { 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)", diff --git a/src/components/AiProviderCapabilitiesSelect.tsx b/src/components/AiProviderCapabilitiesSelect.tsx index ecb636472..7383998b4 100644 --- a/src/components/AiProviderCapabilitiesSelect.tsx +++ b/src/components/AiProviderCapabilitiesSelect.tsx @@ -20,6 +20,7 @@ const CAPABILITY_LABEL_KEYS: Record = { openai_chat: "aiCapabilityOpenaiChat", openai_responses: "aiCapabilityOpenaiResponses", anthropic_messages: "aiCapabilityAnthropicMessages", + anthropic_models: "aiCapabilityAnthropicModels", gemini_generate_content: "aiCapabilityGeminiGenerateContent", bedrock_model_invoke: "aiCapabilityBedrockModelInvoke", google_generate_content: "aiCapabilityGoogleGenerateContent", diff --git a/src/lib/aiCapabilities.ts b/src/lib/aiCapabilities.ts index faa1f7af6..885cf4a9f 100644 --- a/src/lib/aiCapabilities.ts +++ b/src/lib/aiCapabilities.ts @@ -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", diff --git a/src/lib/aiProviderDefaults.ts b/src/lib/aiProviderDefaults.ts index 31499a19d..f30f6dbd1 100644 --- a/src/lib/aiProviderDefaults.ts +++ b/src/lib/aiProviderDefaults.ts @@ -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",