Skip to content

Commit ed6b188

Browse files
Claude Codeclaude
andcommitted
merge: integrate OpenAI-compatible provider management
Merge feat/openai-compatible-provider-management into main with DB schema, API routes, gateway routing, and dedicated dashboard management UI. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2 parents 75233c0 + bc9e08b commit ed6b188

File tree

20 files changed

+28335
-103
lines changed

20 files changed

+28335
-103
lines changed

apps/api/src/routes/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { guardrails } from "./guardrails.js";
1212
import keysApi from "./keys-api.js";
1313
import keysProvider from "./keys-provider.js";
1414
import { logs } from "./logs.js";
15+
import openaiCompatibleProviders from "./openai-compatible-providers.js";
1516
import organization from "./organization.js";
1617
import { payments } from "./payments.js";
1718
import playground from "./playground.js";
@@ -60,3 +61,4 @@ routes.route("/subscriptions", subscriptions);
6061
routes.route("/dev-plans", devPlans);
6162
routes.route("/audit-logs", auditLogs);
6263
routes.route("/guardrails", guardrails);
64+
routes.route("/", openaiCompatibleProviders);

apps/api/src/routes/keys-provider.spec.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,34 @@ describe("provider keys route", () => {
160160
expect(res.status).toBe(400);
161161
});
162162

163+
test("POST /keys/provider with duplicate custom provider name", async () => {
164+
await db.insert(tables.providerKey).values({
165+
id: "test-custom-provider-key-id",
166+
token: "test-custom-provider-token",
167+
provider: "custom",
168+
name: "mycustomprovider",
169+
baseUrl: "https://custom.example.com",
170+
organizationId: "test-org-id",
171+
});
172+
173+
const res = await app.request("/keys/provider", {
174+
method: "POST",
175+
headers: {
176+
"Content-Type": "application/json",
177+
Cookie: token,
178+
},
179+
body: JSON.stringify({
180+
provider: "custom",
181+
token: "test-token",
182+
name: "mycustomprovider",
183+
baseUrl: "https://another-custom.example.com",
184+
organizationId: "test-org-id",
185+
}),
186+
});
187+
188+
expect(res.status).toBe(400);
189+
});
190+
163191
test("PATCH /keys/provider/{id}", async () => {
164192
const res = await app.request("/keys/provider/test-provider-key-id", {
165193
method: "PATCH",
Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2+
3+
import { app } from "@/index.js";
4+
import { createTestUser, deleteAll } from "@/testing.js";
5+
6+
import { db, tables } from "@llmgateway/db";
7+
8+
describe("openai-compatible providers route", () => {
9+
let token = "";
10+
11+
beforeEach(async () => {
12+
token = await createTestUser();
13+
14+
await db.insert(tables.organization).values({
15+
id: "test-org-id",
16+
name: "Test Organization",
17+
billingEmail: "test@example.com",
18+
});
19+
20+
await db.insert(tables.userOrganization).values({
21+
userId: "test-user-id",
22+
organizationId: "test-org-id",
23+
role: "owner",
24+
});
25+
26+
await db.insert(tables.openaiCompatibleProvider).values({
27+
id: "test-openai-compatible-provider-id",
28+
organizationId: "test-org-id",
29+
name: "custom-openai",
30+
baseUrl: "https://custom-openai.example.com",
31+
status: "active",
32+
});
33+
34+
await db.insert(tables.openaiCompatibleProviderKey).values({
35+
id: "test-openai-compatible-provider-key-id",
36+
providerId: "test-openai-compatible-provider-id",
37+
token: "test-provider-token",
38+
label: "primary",
39+
status: "active",
40+
});
41+
42+
await db.insert(tables.openaiCompatibleModelAlias).values({
43+
id: "test-openai-compatible-model-alias-id",
44+
providerId: "test-openai-compatible-provider-id",
45+
alias: "cheap",
46+
modelId: "gpt-4o-mini",
47+
status: "active",
48+
});
49+
});
50+
51+
afterEach(async () => {
52+
await deleteAll();
53+
});
54+
55+
test("GET /openai-compatible-providers unauthorized", async () => {
56+
const res = await app.request("/openai-compatible-providers");
57+
expect(res.status).toBe(401);
58+
});
59+
60+
test("POST /openai-compatible-providers", async () => {
61+
const res = await app.request("/openai-compatible-providers", {
62+
method: "POST",
63+
headers: {
64+
"Content-Type": "application/json",
65+
Cookie: token,
66+
},
67+
body: JSON.stringify({
68+
organizationId: "test-org-id",
69+
name: "new-provider",
70+
baseUrl: "https://new-provider.example.com",
71+
}),
72+
});
73+
74+
expect(res.status).toBe(200);
75+
const json = await res.json();
76+
expect(json.provider.name).toBe("new-provider");
77+
expect(json.provider.baseUrl).toBe("https://new-provider.example.com");
78+
79+
const provider = await db.query.openaiCompatibleProvider.findFirst({
80+
where: {
81+
name: {
82+
eq: "new-provider",
83+
},
84+
},
85+
});
86+
expect(provider).not.toBeNull();
87+
});
88+
89+
test("GET /openai-compatible-providers", async () => {
90+
const res = await app.request("/openai-compatible-providers", {
91+
headers: {
92+
Cookie: token,
93+
},
94+
});
95+
96+
expect(res.status).toBe(200);
97+
const json = await res.json();
98+
expect(json.providers).toHaveLength(1);
99+
expect(json.providers[0].name).toBe("custom-openai");
100+
});
101+
102+
test("PATCH /openai-compatible-providers/{id}", async () => {
103+
const res = await app.request(
104+
"/openai-compatible-providers/test-openai-compatible-provider-id",
105+
{
106+
method: "PATCH",
107+
headers: {
108+
"Content-Type": "application/json",
109+
Cookie: token,
110+
},
111+
body: JSON.stringify({
112+
name: "updated-provider",
113+
status: "inactive",
114+
}),
115+
},
116+
);
117+
118+
expect(res.status).toBe(200);
119+
const json = await res.json();
120+
expect(json.provider.name).toBe("updated-provider");
121+
expect(json.provider.status).toBe("inactive");
122+
});
123+
124+
test("DELETE /openai-compatible-providers/{id}", async () => {
125+
const res = await app.request(
126+
"/openai-compatible-providers/test-openai-compatible-provider-id",
127+
{
128+
method: "DELETE",
129+
headers: {
130+
Cookie: token,
131+
},
132+
},
133+
);
134+
135+
expect(res.status).toBe(200);
136+
const json = await res.json();
137+
expect(json.message).toBe(
138+
"OpenAI-compatible provider deleted successfully",
139+
);
140+
});
141+
142+
test("POST /openai-compatible-providers/{id}/keys", async () => {
143+
const res = await app.request(
144+
"/openai-compatible-providers/test-openai-compatible-provider-id/keys",
145+
{
146+
method: "POST",
147+
headers: {
148+
"Content-Type": "application/json",
149+
Cookie: token,
150+
},
151+
body: JSON.stringify({
152+
token: "another-provider-token",
153+
label: "backup",
154+
}),
155+
},
156+
);
157+
158+
expect(res.status).toBe(200);
159+
const json = await res.json();
160+
expect(json.providerKey.label).toBe("backup");
161+
expect(json.providerKey.maskedToken).toContain("another-prov");
162+
});
163+
164+
test("GET /openai-compatible-providers/{id}/keys", async () => {
165+
const res = await app.request(
166+
"/openai-compatible-providers/test-openai-compatible-provider-id/keys",
167+
{
168+
headers: {
169+
Cookie: token,
170+
},
171+
},
172+
);
173+
174+
expect(res.status).toBe(200);
175+
const json = await res.json();
176+
expect(json.providerKeys).toHaveLength(1);
177+
expect(json.providerKeys[0].id).toBe(
178+
"test-openai-compatible-provider-key-id",
179+
);
180+
expect(json.providerKeys[0].maskedToken).toContain("test-provide");
181+
});
182+
183+
test("PATCH /openai-compatible-providers/keys/{keyId}", async () => {
184+
const res = await app.request(
185+
"/openai-compatible-providers/keys/test-openai-compatible-provider-key-id",
186+
{
187+
method: "PATCH",
188+
headers: {
189+
"Content-Type": "application/json",
190+
Cookie: token,
191+
},
192+
body: JSON.stringify({
193+
status: "inactive",
194+
label: "updated",
195+
}),
196+
},
197+
);
198+
199+
expect(res.status).toBe(200);
200+
const json = await res.json();
201+
expect(json.providerKey.status).toBe("inactive");
202+
expect(json.providerKey.label).toBe("updated");
203+
});
204+
205+
test("DELETE /openai-compatible-providers/keys/{keyId}", async () => {
206+
const res = await app.request(
207+
"/openai-compatible-providers/keys/test-openai-compatible-provider-key-id",
208+
{
209+
method: "DELETE",
210+
headers: {
211+
Cookie: token,
212+
},
213+
},
214+
);
215+
216+
expect(res.status).toBe(200);
217+
const json = await res.json();
218+
expect(json.message).toBe(
219+
"OpenAI-compatible provider key deleted successfully",
220+
);
221+
});
222+
223+
test("POST /openai-compatible-providers/{id}/aliases", async () => {
224+
const res = await app.request(
225+
"/openai-compatible-providers/test-openai-compatible-provider-id/aliases",
226+
{
227+
method: "POST",
228+
headers: {
229+
"Content-Type": "application/json",
230+
Cookie: token,
231+
},
232+
body: JSON.stringify({
233+
alias: "reasoning",
234+
modelId: "gpt-4.1",
235+
}),
236+
},
237+
);
238+
239+
expect(res.status).toBe(200);
240+
const json = await res.json();
241+
expect(json.alias.alias).toBe("reasoning");
242+
expect(json.alias.modelId).toBe("gpt-4.1");
243+
});
244+
245+
test("GET /openai-compatible-providers/{id}/aliases", async () => {
246+
const res = await app.request(
247+
"/openai-compatible-providers/test-openai-compatible-provider-id/aliases",
248+
{
249+
headers: {
250+
Cookie: token,
251+
},
252+
},
253+
);
254+
255+
expect(res.status).toBe(200);
256+
const json = await res.json();
257+
expect(json.aliases).toHaveLength(1);
258+
expect(json.aliases[0].alias).toBe("cheap");
259+
});
260+
261+
test("PATCH /openai-compatible-providers/aliases/{aliasId}", async () => {
262+
const res = await app.request(
263+
"/openai-compatible-providers/aliases/test-openai-compatible-model-alias-id",
264+
{
265+
method: "PATCH",
266+
headers: {
267+
"Content-Type": "application/json",
268+
Cookie: token,
269+
},
270+
body: JSON.stringify({
271+
alias: "cheapest",
272+
modelId: "gpt-4.1-mini",
273+
}),
274+
},
275+
);
276+
277+
expect(res.status).toBe(200);
278+
const json = await res.json();
279+
expect(json.alias.alias).toBe("cheapest");
280+
expect(json.alias.modelId).toBe("gpt-4.1-mini");
281+
});
282+
283+
test("DELETE /openai-compatible-providers/aliases/{aliasId}", async () => {
284+
const res = await app.request(
285+
"/openai-compatible-providers/aliases/test-openai-compatible-model-alias-id",
286+
{
287+
method: "DELETE",
288+
headers: {
289+
Cookie: token,
290+
},
291+
},
292+
);
293+
294+
expect(res.status).toBe(200);
295+
const json = await res.json();
296+
expect(json.message).toBe(
297+
"OpenAI-compatible model alias deleted successfully",
298+
);
299+
});
300+
301+
test("GET /openai-compatible-providers/{id}/models with search", async () => {
302+
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
303+
new Response(
304+
JSON.stringify({
305+
data: [
306+
{ id: "gpt-4o" },
307+
{ id: "gpt-4o-mini" },
308+
{ id: "claude-sonnet-4.5" },
309+
],
310+
}),
311+
{
312+
status: 200,
313+
headers: {
314+
"Content-Type": "application/json",
315+
},
316+
},
317+
),
318+
);
319+
320+
const res = await app.request(
321+
"/openai-compatible-providers/test-openai-compatible-provider-id/models?search=4o",
322+
{
323+
headers: {
324+
Cookie: token,
325+
},
326+
},
327+
);
328+
329+
expect(res.status).toBe(200);
330+
const json = await res.json();
331+
expect(json.models).toEqual([{ id: "gpt-4o" }, { id: "gpt-4o-mini" }]);
332+
333+
fetchMock.mockRestore();
334+
});
335+
});

0 commit comments

Comments
 (0)