Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 142 additions & 1 deletion apps/admin/src/lib/api/v1.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,46 @@ export interface paths {
patch?: never;
trace?: never;
};
"/internal/models": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get all models
* @description Returns all models with their provider mappings, sorted by createdAt descending
*/
get: operations["internal_get_models"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/internal/providers": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get all providers
* @description Returns all providers, sorted by createdAt descending
*/
get: operations["internal_get_providers"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/user/me": {
parameters: {
query?: never;
Expand Down Expand Up @@ -3542,4 +3582,105 @@ export interface components {
pathItems: never;
}
export type $defs = Record<string, never>;
export type operations = Record<string, never>;
export interface operations {
internal_get_models: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description List of all models with their provider mappings */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
models: {
id: string;
createdAt: string | null;
releasedAt: string | null;
name: string | null;
aliases: string[] | null;
description: string | null;
family: string;
free: boolean | null;
output: string[] | null;
/** @enum {string|null} */
stability: "stable" | "beta" | "unstable" | "experimental" | null;
/** @enum {string} */
status: "active" | "inactive";
mappings: {
id: string;
createdAt: string | null;
modelId: string;
providerId: string;
modelName: string;
inputPrice: string | null;
outputPrice: string | null;
cachedInputPrice: string | null;
imageInputPrice: string | null;
requestPrice: string | null;
contextSize: number | null;
maxOutput: number | null;
streaming: boolean;
vision: boolean | null;
reasoning: boolean | null;
reasoningOutput: string | null;
tools: boolean | null;
jsonOutput: boolean | null;
jsonOutputSchema: boolean | null;
webSearch: boolean | null;
discount: string | null;
/** @enum {string|null} */
stability: "stable" | "beta" | "unstable" | "experimental" | null;
supportedParameters: string[] | null;
deprecatedAt: string | null;
deactivatedAt: string | null;
/** @enum {string} */
status: "active" | "inactive";
}[];
}[];
};
};
};
};
};
internal_get_providers: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description List of all providers */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
providers: {
id: string;
createdAt: string | null;
name: string | null;
description: string | null;
streaming: boolean | null;
cancellation: boolean | null;
color: string | null;
website: string | null;
announcement: string | null;
/** @enum {string} */
status: "active" | "inactive";
}[];
};
};
};
};
};
}
3 changes: 3 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { authHandler } from "./auth/handler.js";
import { tracingMiddleware } from "./middleware/tracing.js";
import { beacon } from "./routes/beacon.js";
import { routes } from "./routes/index.js";
import { internalModels } from "./routes/internal-models.js";
import { referral } from "./routes/referral.js";
import { stripeRoutes } from "./stripe.js";

Comment on lines +23 to 26

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's get an overview of the API index file structure
wc -l apps/api/src/index.ts

Repository: theopenco/llmgateway

Length of output: 89


🏁 Script executed:

# Read the relevant sections of the index file, including lines around 23-26 and 185-186
head -50 apps/api/src/index.ts | cat -n

Repository: theopenco/llmgateway

Length of output: 1879


🏁 Script executed:

# Check the internal-models route file to see if it has auth protection
cat -n apps/api/src/routes/internal-models.ts

Repository: theopenco/llmgateway

Length of output: 5162


🏁 Script executed:

# Read the rest of index.ts to see the full app routing configuration
tail -150 apps/api/src/index.ts | cat -n

Repository: theopenco/llmgateway

Length of output: 4330


🏁 Script executed:

# Also search for all app.route and app.use calls to understand the complete routing order
grep -n "app\.\(route\|use\)" apps/api/src/index.ts | head -30

Repository: theopenco/llmgateway

Length of output: 395


🏁 Script executed:

# Check the authHandler implementation to see if it applies middleware or routes
cat -n apps/api/src/auth/handler.ts

Repository: theopenco/llmgateway

Length of output: 879


🏁 Script executed:

# Check if there are any authentication checks or middleware in the routes/index.ts
# to see if other protected routes have explicit auth checks
head -100 apps/api/src/routes/index.ts | cat -n

Repository: theopenco/llmgateway

Length of output: 2021


🏁 Script executed:

# Search for any middleware or auth checks in the codebase that might protect routes
rg "c\.get\(.*user" apps/api/src/routes/ -A 2

Repository: theopenco/llmgateway

Length of output: 9808


Confirm whether /internal/* endpoints should be publicly accessible—they currently bypass all authentication.

Line 142 registers app.route("/internal", internalModels) before the auth-protected routes (line 148+). The internalModels route has no authentication middleware. This means /internal/models and /internal/providers are publicly accessible.

The returned data (active models and providers only) appears to be non-sensitive catalog metadata, which may be intentional for UI/public consumption. However, the naming and routing order should be explicit. Either:

  • Add a comment clarifying this is intentional public data (e.g., for frontend consumption)
  • Move the route behind authHandler if it should require authentication
  • Or document this design decision

Same issue applies at line 185-186 (the second /internal route registration mentioned).

🤖 Prompt for AI Agents
In @apps/api/src/index.ts around lines 23 - 26, The /internal routes registered
via app.route("/internal", internalModels) are currently unprotected and
therefore public; confirm intent and make it explicit by either adding a clear
inline comment above the registration stating these endpoints intentionally
expose non-sensitive catalog metadata for frontend/public consumption, or
move/wrap the registration so it is behind the existing authHandler (i.e.,
register/internalModels after the auth-protected routes or attach authHandler to
the route) to require authentication; apply the same change to the second
/internal registration and update any related tests or docs to reflect the
decision.

Expand Down Expand Up @@ -181,6 +182,8 @@ app.route("/", beacon);

app.route("/", referral);

app.route("/internal", internalModels);

app.doc("/json", config);

app.get("/docs", swaggerUI({ url: "./json" }));
Expand Down
152 changes: 152 additions & 0 deletions apps/api/src/routes/internal-models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { createRoute, OpenAPIHono } from "@hono/zod-openapi";
import { z } from "zod";

import { db } from "@llmgateway/db";

import type { ServerTypes } from "@/vars.js";

export const internalModels = new OpenAPIHono<ServerTypes>();

// Provider schema
const providerSchema = z.object({
id: z.string(),
createdAt: z.coerce.date(),
name: z.string().nullable(),
description: z.string().nullable(),
streaming: z.boolean().nullable(),
cancellation: z.boolean().nullable(),
color: z.string().nullable(),
website: z.string().nullable(),
announcement: z.string().nullable(),
status: z.enum(["active", "inactive"]),
});
Comment on lines +11 to +22

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check the file content at the specified lines
cd apps/api/src/routes && wc -l internal-models.ts

Repository: theopenco/llmgateway

Length of output: 86


🏁 Script executed:

# Read the full file to see schema definitions and usage
cat -n apps/api/src/routes/internal-models.ts

Repository: theopenco/llmgateway

Length of output: 5162


🏁 Script executed:

# Check for frontend type definitions
fd -e ts -e tsx | xargs grep -l "ApiModel\|ApiProvider" | head -20

Repository: theopenco/llmgateway

Length of output: 808


🏁 Script executed:

# Check package.json for @hono/zod-openapi version
cat package.json | grep -A 2 "@hono/zod-openapi"

Repository: theopenco/llmgateway

Length of output: 46


🏁 Script executed:

# Find package.json files
find . -name package.json -type f | head -5

Repository: theopenco/llmgateway

Length of output: 197


🏁 Script executed:

# Read the API package.json
cat apps/api/package.json | grep -A 5 -B 5 "@hono"

Repository: theopenco/llmgateway

Length of output: 573


🏁 Script executed:

# Check frontend type definitions for ApiModel and ApiProvider
rg "ApiModel|ApiProvider" --type ts --type tsx -A 5 | head -60

Repository: theopenco/llmgateway

Length of output: 91


🏁 Script executed:

# Look for type definitions file
fd -e ts -e tsx "type" | xargs grep -l "ApiModel\|ApiProvider" 2>/dev/null | head -10

Repository: theopenco/llmgateway

Length of output: 46


🏁 Script executed:

# Search for ApiModel/ApiProvider without type restrictions
rg "ApiModel|ApiProvider" -A 3 | head -80

Repository: theopenco/llmgateway

Length of output: 4517


🏁 Script executed:

# Check the models package (mentioned in dependencies)
find . -path "*/packages/models*" -o -path "*/models/*" | head -20

Repository: theopenco/llmgateway

Length of output: 907


🏁 Script executed:

# Look for generated types or interface definitions
fd "types|model" -e ts | xargs grep -l "createdAt" 2>/dev/null | head -10

Repository: theopenco/llmgateway

Length of output: 273


🏁 Script executed:

# Check if there's a shared types file
find . -name "*.ts" -path "*/types/*" -o -name "types.ts" | head -20

Repository: theopenco/llmgateway

Length of output: 516


🏁 Script executed:

# Verify the handler behavior - check if dates are returned as raw Date objects
cat -n apps/api/src/routes/internal-models.ts | sed -n '93,117p'

Repository: theopenco/llmgateway

Length of output: 776


🏁 Script executed:

# Check how Drizzle ORM returns dates from the database
rg "createdAt|releasedAt|deprecatedAt|deactivatedAt" apps/api/src --type ts -A 2 -B 2 | head -40

Repository: theopenco/llmgateway

Length of output: 2442


Use z.string().datetime() instead of z.coerce.date() for response schemas to match actual JSON serialization behavior.

Response schemas use z.coerce.date() for createdAt, releasedAt, deprecatedAt, and deactivatedAt fields, but c.json() serializes JavaScript Date objects to ISO strings. Frontend types already expect these as string, and the OpenAPI schema should match the actual wire format. Replace these with z.string().datetime() at lines 13, 27, 49–50, 57–58.

🤖 Prompt for AI Agents
In @apps/api/src/routes/internal-models.ts around lines 11 - 22, The schema uses
z.coerce.date() for response date fields but responses are serialized as ISO
strings; update the response schemas to use z.string().datetime() instead of
z.coerce.date() for the indicated fields (e.g., change providerSchema.createdAt
and the other response fields releasedAt, deprecatedAt, deactivatedAt in the
related schema objects) so the Zod/OpenAPI types match the actual JSON wire
format expected by the frontend; replace each z.coerce.date() occurrence with
z.string().datetime().


// Model provider mapping schema
const modelProviderMappingSchema = z.object({
id: z.string(),
createdAt: z.coerce.date(),
modelId: z.string(),
providerId: z.string(),
modelName: z.string(),
inputPrice: z.string().nullable(),
outputPrice: z.string().nullable(),
cachedInputPrice: z.string().nullable(),
imageInputPrice: z.string().nullable(),
requestPrice: z.string().nullable(),
contextSize: z.number().nullable(),
maxOutput: z.number().nullable(),
streaming: z.boolean(),
vision: z.boolean().nullable(),
reasoning: z.boolean().nullable(),
reasoningOutput: z.string().nullable(),
tools: z.boolean().nullable(),
jsonOutput: z.boolean().nullable(),
jsonOutputSchema: z.boolean().nullable(),
webSearch: z.boolean().nullable(),
discount: z.string().nullable(),
stability: z.enum(["stable", "beta", "unstable", "experimental"]).nullable(),
supportedParameters: z.array(z.string()).nullable(),
deprecatedAt: z.coerce.date().nullable(),
deactivatedAt: z.coerce.date().nullable(),
status: z.enum(["active", "inactive"]),
});

// Model schema with mappings
const modelSchema = z.object({
id: z.string(),
createdAt: z.coerce.date(),
releasedAt: z.coerce.date().nullable(),
name: z.string().nullable(),
aliases: z.array(z.string()).nullable(),
description: z.string().nullable(),
family: z.string(),
free: z.boolean().nullable(),
output: z.array(z.string()).nullable(),
stability: z.enum(["stable", "beta", "unstable", "experimental"]).nullable(),
status: z.enum(["active", "inactive"]),
mappings: z.array(modelProviderMappingSchema),
});

// GET /internal/models - Returns models with mappings sorted by createdAt desc
const getModelsRoute = createRoute({
operationId: "internal_get_models",
summary: "Get all models",
description:
"Returns all models with their provider mappings, sorted by createdAt descending",
method: "get",
path: "/models",
request: {},
responses: {
200: {
content: {
"application/json": {
schema: z.object({
models: z.array(modelSchema),
}),
},
},
description: "List of all models with their provider mappings",
},
},
});

internalModels.openapi(getModelsRoute, async (c) => {
const models = await db.query.model.findMany({
where: {
status: { eq: "active" },
},
with: {
modelProviderMappings: {
where: {
status: { eq: "active" },
},
},
},
orderBy: {
createdAt: "desc",
},
});

// Transform to match expected schema (rename modelProviderMappings to mappings)
const transformedModels = models.map((model) => ({
...model,
mappings: model.modelProviderMappings,
}));

return c.json({ models: transformedModels });
});
Comment on lines +93 to +117

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Response includes both mappings and modelProviderMappings; strip the internal field.

Right now you spread model and add mappings, so the payload contains modelProviderMappings too (not in the documented schema). Prefer removing it explicitly.

Proposed fix
-	// Transform to match expected schema (rename modelProviderMappings to mappings)
-	const transformedModels = models.map((model) => ({
-		...model,
-		mappings: model.modelProviderMappings,
-	}));
+	// Transform to match expected schema (rename modelProviderMappings to mappings)
+	const transformedModels = models.map(({ modelProviderMappings, ...model }) => ({
+		...model,
+		mappings: modelProviderMappings,
+	}));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
internalModels.openapi(getModelsRoute, async (c) => {
const models = await db.query.model.findMany({
where: {
status: { eq: "active" },
},
with: {
modelProviderMappings: {
where: {
status: { eq: "active" },
},
},
},
orderBy: {
createdAt: "desc",
},
});
// Transform to match expected schema (rename modelProviderMappings to mappings)
const transformedModels = models.map((model) => ({
...model,
mappings: model.modelProviderMappings,
}));
return c.json({ models: transformedModels });
});
internalModels.openapi(getModelsRoute, async (c) => {
const models = await db.query.model.findMany({
where: {
status: { eq: "active" },
},
with: {
modelProviderMappings: {
where: {
status: { eq: "active" },
},
},
},
orderBy: {
createdAt: "desc",
},
});
// Transform to match expected schema (rename modelProviderMappings to mappings)
const transformedModels = models.map(({ modelProviderMappings, ...model }) => ({
...model,
mappings: modelProviderMappings,
}));
return c.json({ models: transformedModels });
});
🤖 Prompt for AI Agents
In @apps/api/src/routes/internal-models.ts around lines 93 - 117, The response
currently includes both modelProviderMappings and mappings because
transformedModels spreads the entire model and then adds mappings; update the
mapping in internalModels.openapi (getModelsRoute) to exclude
modelProviderMappings—e.g., when mapping models, destructure {
modelProviderMappings, ...rest } = model and return { ...rest, mappings:
modelProviderMappings } or explicitly delete modelProviderMappings before
returning—so the JSON response only contains mappings and not the internal
modelProviderMappings field.


// GET /internal/providers - Returns providers sorted by createdAt desc
const getProvidersRoute = createRoute({
operationId: "internal_get_providers",
summary: "Get all providers",
description: "Returns all providers, sorted by createdAt descending",
method: "get",
path: "/providers",
request: {},
responses: {
200: {
content: {
"application/json": {
schema: z.object({
providers: z.array(providerSchema),
}),
},
},
description: "List of all providers",
},
},
});

internalModels.openapi(getProvidersRoute, async (c) => {
const providers = await db.query.provider.findMany({
where: {
status: { eq: "active" },
},
orderBy: {
createdAt: "desc",
},
});

return c.json({ providers });
});
Comment on lines +70 to +152

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Find where internalModels is registered/mounted
echo "=== Searching for internalModels router registration ==="
rg -n 'internalModels' apps/api/src --type ts --type tsx -B3 -A3

echo -e "\n=== Searching for router mount points with /internal path ==="
rg -n '"/internal"' apps/api/src --type ts --type tsx -B2 -A2

echo -e "\n=== Checking main app/router setup files ==="
fd -t f '(index|app|router|setup)' apps/api/src -e ts -e tsx | head -20

Repository: theopenco/llmgateway

Length of output: 165


🏁 Script executed:

#!/bin/bash
# Check for auth middleware on internal routes
echo "=== Searching for middleware patterns in internal-models file ==="
cat -n apps/api/src/routes/internal-models.ts | head -80

echo -e "\n=== Searching for auth-related imports in internal-models ==="
rg -n 'auth|middleware|protect|verify' apps/api/src/routes/internal-models.ts

echo -e "\n=== Checking app entry point and how routes are registered ==="
fd -t f 'index' apps/api/src -e ts | xargs cat | head -150

Repository: theopenco/llmgateway

Length of output: 7017


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Search for internalModels route registration in main app ==="
rg -n 'internalModels' apps/api/src -A5 -B2

echo -e "\n=== Check routes/index.ts for route exports and registration ==="
cat apps/api/src/routes/index.ts 2>/dev/null || echo "File not found"

echo -e "\n=== Full app initialization to see how internalModels is registered ==="
tail -n 200 apps/api/src/index.ts

Repository: theopenco/llmgateway

Length of output: 8652


Unprotected system inventory endpoints. The /internal/models and /internal/providers routes are currently publicly accessible with no authentication or authorization checks. While they are correctly mounted under the /internal path prefix, the naming implies internal-only access but they lack any protective middleware. Either add authentication middleware to these routes, restrict network access, or reconsider whether exposing all model and provider mappings (including pricing data) should be public. The main API routes in routes/index.ts include explicit session validation—the internal routes should have similar protection if they're meant to be internal-only.

🤖 Prompt for AI Agents
In @apps/api/src/routes/internal-models.ts around lines 70 - 152, The internal
endpoints getModelsRoute and getProvidersRoute are exposed publicly via
internalModels.openapi(...) with no auth; protect them by applying the same
session/auth middleware used by the main API routes (the session validation
wrapper in routes/index.ts) or by checking the session inside the handler before
querying db.query.model.findMany / db.query.provider.findMany; update
internalModels.openapi calls to require the session/authorization (or
early-return 401) for both getModelsRoute and getProvidersRoute so only
authenticated/authorized internal clients can access mappings/pricing data.

9 changes: 7 additions & 2 deletions apps/playground/src/app/group/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@ import { notFound } from "next/navigation";

import { LastUsedProjectTracker } from "@/components/last-used-project-tracker";
import GroupChatClient from "@/components/playground/group-chat-client";
import { fetchModels, fetchProviders } from "@/lib/fetch-models";
import { fetchServerData } from "@/lib/server-api";

import { models, providers } from "@llmgateway/models";

import type { Project, Organization } from "@/lib/types";

export interface GatewayModel {
Expand All @@ -24,6 +23,12 @@ export default async function GroupPage({
}) {
const { orgId, projectId } = await searchParams;

// Fetch models and providers from API
const [models, providers] = await Promise.all([
fetchModels(),
fetchProviders(),
]);

// Fetch organizations server-side
const initialOrganizationsData = await fetchServerData("GET", "/orgs");

Expand Down
9 changes: 7 additions & 2 deletions apps/playground/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@ import { notFound, redirect } from "next/navigation";

import { LastUsedProjectTracker } from "@/components/last-used-project-tracker";
import ChatPageClient from "@/components/playground/chat-page-client";
import { fetchModels, fetchProviders } from "@/lib/fetch-models";
import { fetchServerData } from "@/lib/server-api";

import { models, providers } from "@llmgateway/models";

import type { Project, Organization } from "@/lib/types";

export interface GatewayModel {
Expand Down Expand Up @@ -128,6 +127,12 @@ export default async function ChatPage({

const projects = (initialProjectsData?.projects || []) as Project[];

// Fetch models and providers from API
const [models, providers] = await Promise.all([
fetchModels(),
fetchProviders(),
]);

// Determine selected project: URL > cookie > first
let selectedProject: Project | null = null;
if (projectId) {
Expand Down
Loading
Loading