Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
69 changes: 41 additions & 28 deletions src/app/api/proxy/v1/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2660,6 +2660,47 @@ async function handleProxy(request: NextRequest, context: RouteContext): Promise
: activeUpstreams.map((upstream) => upstream.id);
const allowedUpstreamIdSet = new Set(allowedUpstreamIds);

// Serve the OpenAI-compatible model list locally for keys that declare allowed
// models. Model listing is a discovery endpoint and must not be gated on a
// specific route capability (openai_chat_compatible): a key whose authorized
// upstreams only expose openai_responses / codex_cli_responses can still
// enumerate its allowed models, mirroring what those upstreams actually serve.
const apiKeyAllowedModels = normalizeApiKeyAllowedModels(validApiKey.allowedModels);
if (isOpenAIModelListRequest(request.method, path) && apiKeyAllowedModels) {
const authorizedActiveUpstreams = activeUpstreams.filter((upstream) =>
allowedUpstreamIdSet.has(upstream.id)
);
if (authorizedActiveUpstreams.length > 0) {
const visibleModels = getApiKeyVisibleModelList(
apiKeyAllowedModels,
authorizedActiveUpstreams
);

try {
await logLocalApiKeyModelListRequest({
apiKeyId: validApiKey.id,
apiKeyName: apiKeySnapshot.apiKeyName,
apiKeyPrefix: apiKeySnapshot.apiKeyPrefix,
request,
path,
requestId,
startTime,
matchedRouteCapability,
routeMatchSource: matchedRouteMatchSource,
});
} catch (error) {
log.error({ err: error, requestId }, "failed to log local API key model list request");
}

return new Response(Buffer.from(createApiKeyModelListResponseBody(visibleModels)), {
status: 200,
headers: {
"content-type": "application/json",
},
});
}
}

const primaryCandidatePool = resolveRouteCapabilityCandidatePool(
activeUpstreams,
allowedUpstreamIdSet,
Expand Down Expand Up @@ -2821,34 +2862,6 @@ async function handleProxy(request: NextRequest, context: RouteContext): Promise
return rejectedResponse;
}

const apiKeyAllowedModels = normalizeApiKeyAllowedModels(validApiKey.allowedModels);
if (isOpenAIModelListRequest(request.method, path) && apiKeyAllowedModels) {
const visibleModels = getApiKeyVisibleModelList(apiKeyAllowedModels, finalCapabilityCandidates);

try {
await logLocalApiKeyModelListRequest({
apiKeyId: validApiKey.id,
apiKeyName: apiKeySnapshot.apiKeyName,
apiKeyPrefix: apiKeySnapshot.apiKeyPrefix,
request,
path,
requestId,
startTime,
matchedRouteCapability,
routeMatchSource: matchedRouteMatchSource,
});
} catch (error) {
log.error({ err: error, requestId }, "failed to log local API key model list request");
}

return new Response(Buffer.from(createApiKeyModelListResponseBody(visibleModels)), {
status: 200,
headers: {
"content-type": "application/json",
},
});
}

let selectedCandidate = finalCapabilityCandidates[0];
({ resolvedModel, redirectApplied: modelRedirectApplied } = resolvePathRoutingModelForUpstream(
model,
Expand Down
74 changes: 74 additions & 0 deletions tests/unit/api/proxy/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6318,6 +6318,80 @@ describe("proxy route upstream selection", () => {
expect(forwardRequest).not.toHaveBeenCalled();
});

it("should return model list for keys bound only to non-chat-compatible upstreams", async () => {
const { db } = await import("@/lib/db");
const { forwardRequest } = await import("@/lib/services/proxy-client");
const { selectFromProviderType } = await import("@/lib/services/load-balancer");
const { logRequest } = await import("@/lib/services/request-logger");

vi.mocked(db.query.apiKeys.findMany).mockResolvedValueOnce([
{
id: "key-1",
keyHash: "hash-1",
keyPrefix: "sk-test",
name: "Codex-only Model List Key",
expiresAt: null,
isActive: true,
allowedModels: ["gpt-5.5", "gpt-5.4-high"],
},
]);
vi.mocked(db.query.apiKeyUpstreams.findMany).mockResolvedValueOnce([
{ upstreamId: "up-codex-only" },
]);
// The only authorized upstream exposes Codex/Responses capabilities, NOT
// openai_chat_compatible. Listing models must still succeed locally instead
// of returning NO_AUTHORIZED_UPSTREAMS.
vi.mocked(db.query.upstreams.findMany).mockResolvedValueOnce([
{
id: "up-codex-only",
name: "codex-only",
providerType: "openai",
routeCapabilities: ["openai_responses", "codex_cli_responses"],
baseUrl: "https://codex.example.com",
isDefault: false,
isActive: true,
timeout: 60,
priority: 0,
weight: 1,
modelRules: null,
allowedModels: null,
modelRedirects: null,
},
]);

const request = new NextRequest("http://localhost/api/proxy/v1/models", {
method: "GET",
headers: {
authorization: "Bearer sk-test",
},
});

const response = await GET(request, {
params: Promise.resolve({ path: ["models"] }),
});
const data = await response.json();

expect(response.status).toBe(200);
expect(data.data.map((item: { id: string }) => item.id)).toEqual(["gpt-5.5", "gpt-5.4-high"]);
expect(selectFromProviderType).not.toHaveBeenCalled();
expect(forwardRequest).not.toHaveBeenCalled();
expect(logRequest).toHaveBeenCalledWith(
expect.objectContaining({
apiKeyId: "key-1",
upstreamId: null,
method: "GET",
path: "models",
model: "(model-list)",
statusCode: 200,
routingDecision: expect.objectContaining({
matched_route_capability: "openai_chat_compatible",
did_send_upstream: false,
actual_upstream_id: null,
}),
})
);
});

it("should reject when API key not authorized for selected upstream", async () => {
const { db } = await import("@/lib/db");
const { forwardRequest } = await import("@/lib/services/proxy-client");
Expand Down
Loading