mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
feat(api): model catalog enrichment + MCP model-catalog tools (#3306)
Integrated into release/v3.8.13 — model catalog enrichment + MCP model-catalog tools, reconciled with #3309 URL-token hardening
This commit is contained in:
@@ -10,7 +10,7 @@ lastUpdated: 2026-05-30
|
||||
>
|
||||
> Source of truth: `open-sse/mcp-server/schemas/tools.ts` (30 tools) + `open-sse/mcp-server/tools/memoryTools.ts` (3 tools) + `open-sse/mcp-server/tools/skillTools.ts` (4 tools) + `open-sse/mcp-server/tools/notionTools.ts` (6 tools). Tool registration and scope wiring lives in `open-sse/mcp-server/server.ts`.
|
||||
|
||||

|
||||

|
||||
|
||||
> Source: [diagrams/mcp-tools-43.mmd](../diagrams/mcp-tools-43.mmd) (regenerate via `npm run docs:render-diagrams`).
|
||||
|
||||
|
||||
@@ -207,6 +207,16 @@ const GPT_5_5_CODEX_CAPABILITIES = {
|
||||
contextLength: GPT_5_5_CONTEXT_LENGTH,
|
||||
} as const;
|
||||
|
||||
const GPT_5_4_CODEX_CAPABILITIES = {
|
||||
targetFormat: "openai-responses",
|
||||
toolCalling: true,
|
||||
supportsReasoning: true,
|
||||
supportsVision: true,
|
||||
supportsXHighEffort: true,
|
||||
contextLength: 200000,
|
||||
maxOutputTokens: 128000,
|
||||
} as const;
|
||||
|
||||
const CHAT_OPENAI_COMPAT_MODELS: Record<string, RegistryModel[]> = {
|
||||
deepinfra: buildModels([
|
||||
"anthropic/claude-4-opus",
|
||||
@@ -866,9 +876,27 @@ const _REGISTRY_EAGER: Record<string, RegistryEntry> = {
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
name: "GPT 5.4",
|
||||
targetFormat: "openai-responses",
|
||||
supportsReasoning: true,
|
||||
supportsXHighEffort: true,
|
||||
...GPT_5_4_CODEX_CAPABILITIES,
|
||||
},
|
||||
{
|
||||
id: "gpt-5.4-xhigh",
|
||||
name: "GPT 5.4 (xHigh)",
|
||||
...GPT_5_4_CODEX_CAPABILITIES,
|
||||
},
|
||||
{
|
||||
id: "gpt-5.4-high",
|
||||
name: "GPT 5.4 (High)",
|
||||
...GPT_5_4_CODEX_CAPABILITIES,
|
||||
},
|
||||
{
|
||||
id: "gpt-5.4-medium",
|
||||
name: "GPT 5.4 (Medium)",
|
||||
...GPT_5_4_CODEX_CAPABILITIES,
|
||||
},
|
||||
{
|
||||
id: "gpt-5.4-low",
|
||||
name: "GPT 5.4 (Low)",
|
||||
...GPT_5_4_CODEX_CAPABILITIES,
|
||||
},
|
||||
{ id: "gpt-5.4-mini", name: "GPT 5.4 Mini", targetFormat: "openai-responses" },
|
||||
{ id: "gpt-5.3-codex-spark", name: "GPT 5.3 Codex Spark" },
|
||||
@@ -1007,7 +1035,7 @@ const _REGISTRY_EAGER: Record<string, RegistryEntry> = {
|
||||
{ id: "gpt-5-mini", name: "GPT-5 Mini", targetFormat: "openai-responses" },
|
||||
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex", targetFormat: "openai-responses" },
|
||||
{ id: "gpt-5.4-mini", name: "GPT-5.4 Mini", targetFormat: "openai-responses" },
|
||||
{ id: "gpt-5.4", name: "GPT-5.4", targetFormat: "openai-responses" },
|
||||
{ id: "gpt-5.4", name: "GPT-5.4", targetFormat: "openai-responses", supportsXHighEffort: true },
|
||||
{ id: "gpt-5.5", name: "GPT-5.5", ...GPT_5_5_CODEX_CAPABILITIES },
|
||||
{
|
||||
id: "claude-haiku-4.5",
|
||||
|
||||
@@ -370,6 +370,7 @@ export const listModelsCatalogOutput = z.object({
|
||||
provider: z.string(),
|
||||
capabilities: z.array(z.string()),
|
||||
status: z.enum(["available", "degraded", "unavailable"]),
|
||||
thinkingEffort: z.string().optional(),
|
||||
pricing: z
|
||||
.object({
|
||||
inputPerMillion: z.number().nullable(),
|
||||
|
||||
@@ -91,7 +91,10 @@ import {
|
||||
type McpAccessibilityConfig,
|
||||
} from "../services/compression/engines/mcpAccessibility/constants.ts";
|
||||
import { getDbInstance } from "../../src/lib/db/core.ts";
|
||||
import { getProviderConnections } from "../../src/lib/db/providers.ts";
|
||||
import { getCodexRequestDefaults } from "../../src/lib/providers/requestDefaults.ts";
|
||||
import { normalizeQuotaResponse } from "../../src/shared/contracts/quota.ts";
|
||||
import { AI_PROVIDERS, NOAUTH_PROVIDERS } from "../../src/shared/constants/providers.ts";
|
||||
import { resolveOmniRouteBaseUrl } from "../../src/shared/utils/resolveOmniRouteBaseUrl.ts";
|
||||
|
||||
// ============ Configuration ============
|
||||
@@ -147,6 +150,28 @@ type TextToolResult = {
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
type McpCatalogStatus = "available" | "degraded" | "unavailable";
|
||||
|
||||
type McpCatalogResponse = {
|
||||
models: Array<{
|
||||
id: string;
|
||||
provider: string;
|
||||
capabilities: string[];
|
||||
status: McpCatalogStatus;
|
||||
thinkingEffort?: string;
|
||||
pricing?: unknown;
|
||||
}>;
|
||||
source: string;
|
||||
warning?: string;
|
||||
};
|
||||
|
||||
type ProviderConnectionLike = {
|
||||
id?: string;
|
||||
provider?: string;
|
||||
isActive?: boolean;
|
||||
providerSpecificData?: unknown;
|
||||
};
|
||||
|
||||
function toRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
@@ -214,6 +239,188 @@ async function omniRouteFetch(path: string, options: RequestInit = {}): Promise<
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function buildProviderAliasMap(): Record<string, string> {
|
||||
const aliasMap: Record<string, string> = {};
|
||||
|
||||
for (const provider of Object.values(AI_PROVIDERS)) {
|
||||
if (!provider?.id) continue;
|
||||
aliasMap[provider.id] = provider.id;
|
||||
if (typeof provider.alias === "string" && provider.alias.length > 0) {
|
||||
aliasMap[provider.alias] = provider.id;
|
||||
}
|
||||
}
|
||||
|
||||
for (const provider of Object.values(NOAUTH_PROVIDERS)) {
|
||||
if (!provider?.id) continue;
|
||||
aliasMap[provider.id] = provider.id;
|
||||
if ("alias" in provider && typeof provider.alias === "string" && provider.alias.length > 0) {
|
||||
aliasMap[provider.alias] = provider.id;
|
||||
}
|
||||
}
|
||||
|
||||
return aliasMap;
|
||||
}
|
||||
|
||||
function normalizeCapability(value: string): string {
|
||||
switch (value) {
|
||||
case "embeddings":
|
||||
return "embedding";
|
||||
case "images":
|
||||
return "image";
|
||||
case "videos":
|
||||
return "video";
|
||||
case "moderations":
|
||||
return "moderation";
|
||||
case "chat-completions":
|
||||
return "chat";
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function getCatalogModelCapabilities(model: JsonRecord): string[] {
|
||||
if (Array.isArray(model.capabilities) && model.capabilities.length > 0) {
|
||||
return toStringArray(model.capabilities, ["chat"]).map(normalizeCapability);
|
||||
}
|
||||
|
||||
if (Array.isArray(model.supportedEndpoints) && model.supportedEndpoints.length > 0) {
|
||||
return toStringArray(model.supportedEndpoints, ["chat"]).map(normalizeCapability);
|
||||
}
|
||||
|
||||
const type = toString(model.type);
|
||||
if (type) return [normalizeCapability(type)];
|
||||
|
||||
return ["chat"];
|
||||
}
|
||||
|
||||
function normalizeCatalogStatus(model: JsonRecord, source: string, warning?: string): McpCatalogStatus {
|
||||
const explicitStatus = toString(model.status);
|
||||
if (explicitStatus === "available" || explicitStatus === "degraded" || explicitStatus === "unavailable") {
|
||||
return explicitStatus;
|
||||
}
|
||||
|
||||
if (warning || source === "local_catalog") return "degraded";
|
||||
return "available";
|
||||
}
|
||||
|
||||
function getConnectionThinkingEffort(connection: ProviderConnectionLike): string | undefined {
|
||||
const provider = typeof connection.provider === "string" ? connection.provider : null;
|
||||
const providerSpecificData = toRecord(connection.providerSpecificData);
|
||||
|
||||
if (provider === "codex") {
|
||||
return getCodexRequestDefaults(providerSpecificData).reasoningEffort || "medium";
|
||||
}
|
||||
|
||||
const rawThinkingEffort = toString(providerSpecificData.thinkingEffort);
|
||||
return rawThinkingEffort || undefined;
|
||||
}
|
||||
|
||||
function normalizeProviderModelRecord(
|
||||
rawModel: unknown,
|
||||
fallbackProvider: string,
|
||||
source: string,
|
||||
warning?: string,
|
||||
thinkingEffort?: string
|
||||
) {
|
||||
const model = toRecord(rawModel);
|
||||
const id = toString(model.id, "");
|
||||
|
||||
return {
|
||||
id,
|
||||
provider: toString(model.owned_by, toString(model.provider, fallbackProvider)),
|
||||
capabilities: getCatalogModelCapabilities(model),
|
||||
status: normalizeCatalogStatus(model, source, warning),
|
||||
...(thinkingEffort ? { thinkingEffort } : {}),
|
||||
pricing: model.pricing,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getMcpModelsCatalog(
|
||||
args: { provider?: string; capability?: string },
|
||||
deps: {
|
||||
fetchJson?: (path: string) => Promise<unknown>;
|
||||
listProviderConnections?: () => Promise<ProviderConnectionLike[]>;
|
||||
} = {}
|
||||
): Promise<McpCatalogResponse> {
|
||||
const fetchJson = deps.fetchJson ?? ((path: string) => omniRouteFetch(path));
|
||||
const listProviderConnections = deps.listProviderConnections ?? getProviderConnections;
|
||||
const aliasMap = buildProviderAliasMap();
|
||||
const normalizeProviderId = (value: string) => aliasMap[value] || value;
|
||||
const requestedProvider = args.provider ? normalizeProviderId(args.provider) : null;
|
||||
const requestedCapability = args.capability ? normalizeCapability(args.capability) : null;
|
||||
|
||||
let connections = await listProviderConnections();
|
||||
connections = Array.isArray(connections) ? connections : [];
|
||||
|
||||
const activeConnections = connections.filter((connection) => {
|
||||
const provider = typeof connection?.provider === "string" ? normalizeProviderId(connection.provider) : null;
|
||||
if (!provider || !connection?.id || connection.isActive === false) return false;
|
||||
if (requestedProvider && provider !== requestedProvider) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const requestSpecs = activeConnections.map((connection) => ({
|
||||
provider: normalizeProviderId(String(connection.provider)),
|
||||
path: `/api/providers/${encodeURIComponent(String(connection.id))}/models?excludeHidden=true`,
|
||||
thinkingEffort: getConnectionThinkingEffort(connection),
|
||||
}));
|
||||
|
||||
if (requestedProvider && requestSpecs.length === 0) {
|
||||
const isNoAuthProvider = Object.values(NOAUTH_PROVIDERS).some((provider) => provider.id === requestedProvider);
|
||||
if (isNoAuthProvider) {
|
||||
requestSpecs.push({
|
||||
provider: requestedProvider,
|
||||
path: `/api/v1/providers/${encodeURIComponent(requestedProvider)}/models`,
|
||||
thinkingEffort: undefined,
|
||||
});
|
||||
} else {
|
||||
return {
|
||||
models: [],
|
||||
source: "provider_connections",
|
||||
warning: `No active connections found for provider '${requestedProvider}'.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const collectedModels = new Map<string, McpCatalogResponse["models"][number]>();
|
||||
const warnings = new Set<string>();
|
||||
const sources = new Set<string>();
|
||||
|
||||
for (const spec of requestSpecs) {
|
||||
const raw = toRecord(await fetchJson(spec.path));
|
||||
const source = toString(raw.source, spec.path.startsWith("/api/providers/") ? "api" : "v1_catalog");
|
||||
const warning = raw.warning ? String(raw.warning) : undefined;
|
||||
if (warning) warnings.add(warning);
|
||||
sources.add(source);
|
||||
|
||||
const rawModels = Array.isArray(raw.models)
|
||||
? raw.models
|
||||
: Array.isArray(raw.data)
|
||||
? raw.data
|
||||
: [];
|
||||
|
||||
for (const rawModel of rawModels) {
|
||||
const normalized = normalizeProviderModelRecord(rawModel, spec.provider, source, warning);
|
||||
if (spec.thinkingEffort && !normalized.thinkingEffort) {
|
||||
normalized.thinkingEffort = spec.thinkingEffort;
|
||||
}
|
||||
if (!normalized.id) continue;
|
||||
if (requestedCapability && !normalized.capabilities.includes(requestedCapability)) continue;
|
||||
|
||||
const key = `${normalized.provider}:${normalized.id}`;
|
||||
if (!collectedModels.has(key)) {
|
||||
collectedModels.set(key, normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
models: [...collectedModels.values()],
|
||||
source: sources.size === 1 ? [...sources][0] : "aggregated_provider_models",
|
||||
...(warnings.size > 0 ? { warning: [...warnings].join(" | ") } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function withScopeEnforcement(
|
||||
toolName: string,
|
||||
handler: (args: unknown, extra?: McpToolExtraLike) => Promise<TextToolResult>,
|
||||
@@ -522,50 +729,7 @@ async function handleCostReport(args: { period?: string }) {
|
||||
async function handleListModelsCatalog(args: { provider?: string; capability?: string }) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
let path = "/v1/models";
|
||||
let isProviderSpecific = false;
|
||||
let source = "local_catalog";
|
||||
let warning: string | undefined;
|
||||
|
||||
if (args.provider && !args.capability) {
|
||||
// Use direct provider fetch to get real-time API status
|
||||
path = `/api/providers/${encodeURIComponent(args.provider)}/models?excludeHidden=true`;
|
||||
isProviderSpecific = true;
|
||||
} else {
|
||||
const params = new URLSearchParams();
|
||||
if (args.provider) params.set("provider", args.provider);
|
||||
if (args.capability) params.set("capability", args.capability);
|
||||
if (params.toString()) path += `?${params.toString()}`;
|
||||
}
|
||||
|
||||
const raw = toRecord(await omniRouteFetch(path));
|
||||
|
||||
// If we used the direct provider endpoint
|
||||
let rawModels: unknown[] = [];
|
||||
if (isProviderSpecific) {
|
||||
rawModels = Array.isArray(raw.models) ? raw.models : [];
|
||||
source = typeof raw.source === "string" ? raw.source : "api";
|
||||
if (raw.warning) warning = String(raw.warning);
|
||||
} else {
|
||||
rawModels = Array.isArray(raw.data) ? raw.data : [];
|
||||
source = "local_catalog";
|
||||
// OmniRoute's global /v1/models is always a cached/local catalog
|
||||
}
|
||||
|
||||
const result = {
|
||||
models: rawModels.map((rawModel) => {
|
||||
const model = toRecord(rawModel);
|
||||
return {
|
||||
id: toString(model.id, ""),
|
||||
provider: toString(model.owned_by, toString(model.provider, args.provider || "unknown")),
|
||||
capabilities: toStringArray(model.capabilities, ["chat"]),
|
||||
status: toString(model.status, "available"),
|
||||
pricing: model.pricing,
|
||||
};
|
||||
}),
|
||||
source,
|
||||
...(warning ? { warning } : {}),
|
||||
};
|
||||
const result = await getMcpModelsCatalog(args);
|
||||
|
||||
await logToolCall(
|
||||
"omniroute_list_models_catalog",
|
||||
|
||||
@@ -33,6 +33,7 @@ import { getModelSpec } from "@/shared/constants/modelSpecs";
|
||||
import { isAuthRequired, isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { parseModel } from "@omniroute/open-sse/services/model";
|
||||
import { getTokenLimit } from "@omniroute/open-sse/services/contextManager";
|
||||
import { extractApiKey } from "@/sse/services/auth";
|
||||
import type { ComboModelStep } from "@/lib/combos/steps";
|
||||
|
||||
interface CustomModelEntry {
|
||||
@@ -189,13 +190,7 @@ function getOpenRouterDisplayName(model: {
|
||||
: name;
|
||||
}
|
||||
|
||||
function extractBearer(headers: Headers): string | null {
|
||||
const authHeader = headers.get("authorization") || headers.get("Authorization");
|
||||
if (!authHeader?.trim().toLowerCase().startsWith("bearer ")) return null;
|
||||
return authHeader.trim().slice(7).trim() || null;
|
||||
}
|
||||
|
||||
async function validateCatalogBearer(apiKey: string): Promise<boolean> {
|
||||
async function validateCatalogApiKey(apiKey: string): Promise<boolean> {
|
||||
const { validateApiKey } = await import("@/lib/db/apiKeys");
|
||||
return validateApiKey(apiKey);
|
||||
}
|
||||
@@ -207,9 +202,9 @@ async function getModelCatalogAuthRejection(
|
||||
): Promise<Response | null> {
|
||||
if (settings.requireAuthForModels !== true || !(await isAuthRequired(request))) return null;
|
||||
|
||||
const bearer = extractBearer(request.headers);
|
||||
if (bearer) {
|
||||
if (await validateCatalogBearer(bearer)) return null;
|
||||
const apiKey = extractApiKey(request);
|
||||
if (apiKey) {
|
||||
if (await validateCatalogApiKey(apiKey)) return null;
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
@@ -1250,7 +1245,7 @@ export async function getUnifiedModelsResponse(
|
||||
}
|
||||
|
||||
// Filter by API key permissions if requested
|
||||
const apiKey = extractBearer(request.headers);
|
||||
const apiKey = extractApiKey(request);
|
||||
let finalModels = models;
|
||||
if (apiKey) {
|
||||
const { isModelAllowedForKey, getApiKeyMetadata } = await import("@/lib/db/apiKeys");
|
||||
|
||||
@@ -272,6 +272,7 @@ export function enrichCatalogModelEntry<T extends JsonRecord>(
|
||||
if (!metadata) return entry;
|
||||
|
||||
const nextEntry: JsonRecord = { ...entry };
|
||||
const existingName = asNonEmptyString(entry.name);
|
||||
const capabilityFields = {
|
||||
...(typeof metadata.capabilities.vision === "boolean"
|
||||
? { vision: metadata.capabilities.vision }
|
||||
@@ -331,6 +332,9 @@ export function enrichCatalogModelEntry<T extends JsonRecord>(
|
||||
if (typeof metadata.metadata.openWeights === "boolean") {
|
||||
nextEntry.open_weights = metadata.metadata.openWeights;
|
||||
}
|
||||
if (!existingName && metadata.displayName) {
|
||||
nextEntry.name = metadata.displayName;
|
||||
}
|
||||
|
||||
return nextEntry as T;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,15 @@ export const MODEL_SPECS: Record<string, ModelSpec> = {
|
||||
supportsVision: true,
|
||||
},
|
||||
|
||||
"gpt-5.4": {
|
||||
maxOutputTokens: 131072,
|
||||
contextWindow: 409600,
|
||||
supportsThinking: true,
|
||||
supportsTools: true,
|
||||
supportsVision: true,
|
||||
aliases: ["openai/gpt-5.4"],
|
||||
},
|
||||
|
||||
// ── GPT-4o family ──────────────────────────────────────────────
|
||||
"gpt-4o-mini": {
|
||||
maxOutputTokens: 16384,
|
||||
|
||||
147
tests/unit/mcp-model-catalog.test.ts
Normal file
147
tests/unit/mcp-model-catalog.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { getMcpModelsCatalog } from "../../open-sse/mcp-server/server.ts";
|
||||
|
||||
test("getMcpModelsCatalog aggregates only active connection model endpoints", async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const result = await getMcpModelsCatalog(
|
||||
{},
|
||||
{
|
||||
listProviderConnections: async () => [
|
||||
{ id: "conn-github", provider: "github", isActive: true },
|
||||
{ id: "conn-codex", provider: "codex", isActive: false },
|
||||
],
|
||||
fetchJson: async (path: string) => {
|
||||
calls.push(path);
|
||||
if (path === "/api/providers/conn-github/models?excludeHidden=true") {
|
||||
return {
|
||||
source: "api",
|
||||
models: [
|
||||
{ id: "gpt-4.1", owned_by: "github", supportedEndpoints: ["chat"] },
|
||||
{
|
||||
id: "text-embedding-3-small",
|
||||
owned_by: "github",
|
||||
supportedEndpoints: ["embeddings"],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected path: ${path}`);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ["/api/providers/conn-github/models?excludeHidden=true"]);
|
||||
assert.deepEqual(result, {
|
||||
models: [
|
||||
{
|
||||
id: "gpt-4.1",
|
||||
provider: "github",
|
||||
capabilities: ["chat"],
|
||||
status: "available",
|
||||
pricing: undefined,
|
||||
},
|
||||
{
|
||||
id: "text-embedding-3-small",
|
||||
provider: "github",
|
||||
capabilities: ["embedding"],
|
||||
status: "available",
|
||||
pricing: undefined,
|
||||
},
|
||||
],
|
||||
source: "api",
|
||||
});
|
||||
});
|
||||
|
||||
test("getMcpModelsCatalog exposes codex default thinking effort when no override is stored", async () => {
|
||||
const result = await getMcpModelsCatalog(
|
||||
{ provider: "codex" },
|
||||
{
|
||||
listProviderConnections: async () => [
|
||||
{
|
||||
id: "conn-codex",
|
||||
provider: "codex",
|
||||
isActive: true,
|
||||
providerSpecificData: {},
|
||||
},
|
||||
],
|
||||
fetchJson: async () => ({
|
||||
source: "api",
|
||||
models: [{ id: "gpt-5.5", owned_by: "codex", supportedEndpoints: ["chat"] }],
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.models.length, 1);
|
||||
assert.equal(result.models[0]?.thinkingEffort, "medium");
|
||||
});
|
||||
|
||||
test("getMcpModelsCatalog exposes stored thinking effort overrides", async () => {
|
||||
const result = await getMcpModelsCatalog(
|
||||
{ provider: "chatgpt-web" },
|
||||
{
|
||||
listProviderConnections: async () => [
|
||||
{
|
||||
id: "conn-chatgpt",
|
||||
provider: "chatgpt-web",
|
||||
isActive: true,
|
||||
providerSpecificData: { thinkingEffort: "extended" },
|
||||
},
|
||||
],
|
||||
fetchJson: async () => ({
|
||||
source: "api",
|
||||
models: [{ id: "gpt-5", owned_by: "chatgpt-web", supportedEndpoints: ["chat"] }],
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.models.length, 1);
|
||||
assert.equal(result.models[0]?.thinkingEffort, "extended");
|
||||
});
|
||||
|
||||
test("getMcpModelsCatalog resolves provider aliases to active connection ids", async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const result = await getMcpModelsCatalog(
|
||||
{ provider: "gh", capability: "chat" },
|
||||
{
|
||||
listProviderConnections: async () => [
|
||||
{ id: "conn-github", provider: "github", isActive: true },
|
||||
{ id: "conn-codex", provider: "codex", isActive: true },
|
||||
],
|
||||
fetchJson: async (path: string) => {
|
||||
calls.push(path);
|
||||
return {
|
||||
source: "api",
|
||||
models: [{ id: "gpt-4.1", owned_by: "github", supportedEndpoints: ["chat"] }],
|
||||
};
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ["/api/providers/conn-github/models?excludeHidden=true"]);
|
||||
assert.equal(result.models.length, 1);
|
||||
assert.equal(result.models[0]?.provider, "github");
|
||||
assert.deepEqual(result.models[0]?.capabilities, ["chat"]);
|
||||
});
|
||||
|
||||
test("getMcpModelsCatalog returns empty result when requested provider has no active connection", async () => {
|
||||
const result = await getMcpModelsCatalog(
|
||||
{ provider: "github" },
|
||||
{
|
||||
listProviderConnections: async () => [{ id: "conn-codex", provider: "codex", isActive: true }],
|
||||
fetchJson: async () => {
|
||||
throw new Error("fetchJson should not be called without a matching active provider");
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
models: [],
|
||||
source: "provider_connections",
|
||||
warning: "No active connections found for provider 'github'.",
|
||||
});
|
||||
});
|
||||
@@ -122,6 +122,48 @@ test("v1 models catalog accepts bearer API keys and filters the list by allowed
|
||||
);
|
||||
});
|
||||
|
||||
test("v1 models catalog does NOT accept API keys supplied via query string (#3300 security follow-up)", async () => {
|
||||
// Query-string token fallbacks (`?token=`/`?key=`/`?apiKey=`/`?api_key=`) were
|
||||
// intentionally removed — a credential in the query string leaks into access
|
||||
// logs / Referer headers. The VS Code integration uses the path-scoped
|
||||
// `/vscode/<token>/…` form instead (covered by the next test). So a `?token=`
|
||||
// on the catalog route is no longer a usable credential → auth fails.
|
||||
await settingsDb.updateSettings({
|
||||
requireLogin: true,
|
||||
password: "hashed-password",
|
||||
requireAuthForModels: true,
|
||||
});
|
||||
await seedConnection("openai", { name: "openai-query-auth" });
|
||||
|
||||
const key = await apiKeysDb.createApiKey("catalog-query-auth", "machine-catalog-query");
|
||||
|
||||
const response = await v1ModelsCatalog.getUnifiedModelsResponse(
|
||||
new Request(`http://localhost/api/v1/models?token=${encodeURIComponent(key.key)}`)
|
||||
);
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
});
|
||||
|
||||
test("v1 models catalog accepts API keys embedded in vscode path aliases when auth is required", async () => {
|
||||
await settingsDb.updateSettings({
|
||||
requireLogin: true,
|
||||
password: "hashed-password",
|
||||
requireAuthForModels: true,
|
||||
});
|
||||
await seedConnection("openai", { name: "openai-path-auth" });
|
||||
|
||||
const key = await apiKeysDb.createApiKey("catalog-path-auth", "machine-catalog-path");
|
||||
|
||||
const response = await v1ModelsCatalog.getUnifiedModelsResponse(
|
||||
new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/models`)
|
||||
);
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.ok(Array.isArray(body.data));
|
||||
assert.ok(body.data.length > 0);
|
||||
});
|
||||
|
||||
test("v1 models catalog hides models excluded by every active connection while keeping models served by at least one account", async () => {
|
||||
const first = await seedConnection("openai", {
|
||||
name: "openai-first",
|
||||
|
||||
@@ -469,6 +469,27 @@ test("provider models route returns the updated local catalog for GitHub Copilot
|
||||
);
|
||||
});
|
||||
|
||||
test("provider models route returns codex gpt-5.4 effort variants in the local catalog", async () => {
|
||||
const connection = await seedConnection("codex", {
|
||||
authType: "oauth",
|
||||
apiKey: null,
|
||||
accessToken: "codex-access",
|
||||
});
|
||||
|
||||
const response = await callRoute(connection.id);
|
||||
const body = (await response.json()) as any;
|
||||
const modelIds = new Set((body.models || []).map((model: any) => model.id));
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.provider, "codex");
|
||||
assert.equal(body.source, "local_catalog");
|
||||
assert.ok(modelIds.has("gpt-5.4"));
|
||||
assert.ok(modelIds.has("gpt-5.4-low"));
|
||||
assert.ok(modelIds.has("gpt-5.4-medium"));
|
||||
assert.ok(modelIds.has("gpt-5.4-high"));
|
||||
assert.ok(modelIds.has("gpt-5.4-xhigh"));
|
||||
});
|
||||
|
||||
test("provider models route returns the expanded local catalog for Kiro", async () => {
|
||||
const connection = await seedConnection("kiro", {
|
||||
authType: "oauth",
|
||||
|
||||
Reference in New Issue
Block a user