278 lines
9.1 KiB
TypeScript
Executable File
278 lines
9.1 KiB
TypeScript
Executable File
import { assert, assertEquals } from "@std/assert";
|
|
import { createHandler } from "../main.ts";
|
|
import { type AppContext, NullToolExecutor, VERSION } from "../context.ts";
|
|
import { ProviderManager } from "../../../packages/providers/src/mod.ts";
|
|
import { Metrics } from "../../../packages/telemetry/src/metrics.ts";
|
|
import { LogBus } from "../../../packages/telemetry/src/logbus.ts";
|
|
import { VirtualKeyManager } from "../../../packages/governance/src/virtual_keys.ts";
|
|
import { MCPRegistry } from "../../../packages/mcp/src/registry.ts";
|
|
import { PluginManager } from "../../../packages/plugins/src/lifecycle.ts";
|
|
|
|
function makeContext(): AppContext {
|
|
return {
|
|
providers: new ProviderManager([]),
|
|
metrics: new Metrics(),
|
|
logBus: new LogBus(),
|
|
virtualKeys: new VirtualKeyManager(),
|
|
mcp: new MCPRegistry(),
|
|
plugins: new PluginManager(),
|
|
toolExecutor: new NullToolExecutor(),
|
|
version: VERSION,
|
|
};
|
|
}
|
|
|
|
const base = "http://gateway.test";
|
|
const JSON_HEADERS = { "Content-Type": "application/json" };
|
|
|
|
Deno.test("POST /api/providers round-trips 6-tab fields and redacts secrets", async () => {
|
|
const handler = createHandler(makeContext());
|
|
const res = await handler(
|
|
new Request(`${base}/api/providers`, {
|
|
method: "POST",
|
|
headers: JSON_HEADERS,
|
|
body: JSON.stringify({
|
|
id: "anthropic",
|
|
type: "anthropic",
|
|
apiKey: "sk-ant-secret",
|
|
models: ["claude-3-5-sonnet"],
|
|
proxyUrl: "http://user:pw@proxy.local",
|
|
network: {
|
|
maxRetries: 4,
|
|
extraHeaders: [{ name: "X-Org", value: "acme" }],
|
|
caCertPem: "-----BEGIN CERTIFICATE-----secret",
|
|
},
|
|
proxy: {
|
|
proxyType: "http",
|
|
proxyUsername: "u",
|
|
proxyPassword: "shh",
|
|
noProxy: [".private"],
|
|
},
|
|
governance: { budgetUsd: 50, budgetResetPeriod: "monthly" },
|
|
betaHeaders: { overrides: { "prompt-caching-2024-07-31": "enabled" } },
|
|
debugging: { sendBackRawResponse: true },
|
|
}),
|
|
}),
|
|
);
|
|
assertEquals(res.status, 201);
|
|
const body = await res.json();
|
|
|
|
// Non-secret 6-tab fields survive the round-trip.
|
|
assertEquals(body.network.maxRetries, 4);
|
|
assertEquals(body.network.extraHeaders[0], { name: "X-Org", hasValue: true });
|
|
assertEquals(body.proxy.proxyUsername, "u");
|
|
assertEquals(body.proxy.noProxyCount, 1);
|
|
assertEquals(body.governance.budgetUsd, 50);
|
|
assertEquals(
|
|
body.betaHeaders.overrides["prompt-caching-2024-07-31"],
|
|
"enabled",
|
|
);
|
|
assertEquals(body.debugging.sendBackRawResponse, true);
|
|
|
|
// Presence flags replace secrets.
|
|
assertEquals(body.hasApiKey, true);
|
|
assertEquals(body.hasProxy, true);
|
|
assertEquals(body.hasProxyPassword, true);
|
|
assertEquals(body.hasCaCert, true);
|
|
|
|
// No secret material is echoed anywhere in the response.
|
|
const serialized = JSON.stringify(body);
|
|
for (
|
|
const secret of [
|
|
"sk-ant-secret",
|
|
"pw@proxy.local",
|
|
"shh",
|
|
"BEGIN CERTIFICATE",
|
|
"acme",
|
|
".private",
|
|
]
|
|
) {
|
|
assert(!serialized.includes(secret), `leaked secret: ${secret}`);
|
|
}
|
|
});
|
|
|
|
Deno.test("PUT /api/providers/:id merges new-group fields", async () => {
|
|
const ctx = makeContext();
|
|
const handler = createHandler(ctx);
|
|
await handler(
|
|
new Request(`${base}/api/providers`, {
|
|
method: "POST",
|
|
headers: JSON_HEADERS,
|
|
body: JSON.stringify({
|
|
id: "openai",
|
|
type: "openai",
|
|
apiKey: "sk",
|
|
models: ["gpt-4o"],
|
|
}),
|
|
}),
|
|
);
|
|
const res = await handler(
|
|
new Request(`${base}/api/providers/openai`, {
|
|
method: "PUT",
|
|
headers: JSON_HEADERS,
|
|
body: JSON.stringify({
|
|
performance: { maxConcurrentRequests: 8 },
|
|
network: { timeoutSec: 30 },
|
|
}),
|
|
}),
|
|
);
|
|
assertEquals(res.status, 200);
|
|
const body = await res.json();
|
|
assertEquals(body.performance.maxConcurrentRequests, 8);
|
|
assertEquals(body.network.timeoutSec, 30);
|
|
// Existing fields are preserved through the merge.
|
|
assertEquals(body.hasApiKey, true);
|
|
assertEquals(body.models, ["gpt-4o"]);
|
|
|
|
// The stored config carries the secret internally but the listing hides it.
|
|
const stored = ctx.providers.get("openai");
|
|
assertEquals(stored?.apiKey, "sk");
|
|
const listRes = await handler(new Request(`${base}/api/providers`));
|
|
const list = await listRes.json();
|
|
assertEquals(list.providers[0].network.timeoutSec, 30);
|
|
assert(!("apiKey" in list.providers[0]));
|
|
});
|
|
|
|
Deno.test("GET /api/config exposes the EUR display rate", async () => {
|
|
const handler = createHandler(makeContext());
|
|
const res = await handler(new Request(`${base}/api/config`));
|
|
assertEquals(res.status, 200);
|
|
const body = await res.json();
|
|
assertEquals(typeof body.eurRate, "number");
|
|
assert(body.eurRate > 0);
|
|
});
|
|
|
|
Deno.test("GET /api/providers/health reports per-provider status", async () => {
|
|
const handler = createHandler(makeContext());
|
|
// Disabled account: reported without a probe.
|
|
await handler(
|
|
new Request(`${base}/api/providers`, {
|
|
method: "POST",
|
|
headers: JSON_HEADERS,
|
|
body: JSON.stringify({ id: "off", type: "openai", enabled: false }),
|
|
}),
|
|
);
|
|
// Enabled but no credentials: cannot be probed -> "unknown", never "ok".
|
|
await handler(
|
|
new Request(`${base}/api/providers`, {
|
|
method: "POST",
|
|
headers: JSON_HEADERS,
|
|
body: JSON.stringify({ id: "nokey", type: "openai", enabled: true }),
|
|
}),
|
|
);
|
|
|
|
const res = await handler(new Request(`${base}/api/providers/health`));
|
|
assertEquals(res.status, 200);
|
|
const body = await res.json();
|
|
const byId = Object.fromEntries(
|
|
body.health.map((h: { id: string }) => [h.id, h]),
|
|
);
|
|
assertEquals(byId["off"].status, "disabled");
|
|
assertEquals(byId["nokey"].status, "unknown");
|
|
// Un-probed accounts carry no error text.
|
|
assertEquals(byId["off"].lastError, undefined);
|
|
assertEquals(byId["nokey"].lastError, undefined);
|
|
});
|
|
|
|
Deno.test("GET /api/providers/health reports 'ok' for a reachable enabled provider", async () => {
|
|
// Regression: the probe must invoke listModels AS A METHOD (bound `this`).
|
|
// A detached call throws a TypeError before any I/O, which the probe would
|
|
// mislabel as "error" for every enabled provider (the vLLM online-badge bug).
|
|
const originalFetch = globalThis.fetch;
|
|
let hitModels = false;
|
|
globalThis.fetch = ((input: RequestInfo | URL) => {
|
|
if (String(input).endsWith("/models")) {
|
|
hitModels = true;
|
|
return Promise.resolve(
|
|
new Response(JSON.stringify({ data: [{ id: "gpt-4o-mini" }] }), {
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
}
|
|
return Promise.resolve(new Response("not found", { status: 404 }));
|
|
}) as typeof fetch;
|
|
|
|
try {
|
|
const handler = createHandler(makeContext());
|
|
await handler(
|
|
new Request(`${base}/api/providers`, {
|
|
method: "POST",
|
|
headers: JSON_HEADERS,
|
|
body: JSON.stringify({
|
|
id: "live",
|
|
type: "openai",
|
|
apiKey: "sk-test",
|
|
enabled: true,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
const res = await handler(new Request(`${base}/api/providers/health`));
|
|
assertEquals(res.status, 200);
|
|
const body = await res.json();
|
|
const live = body.health.find((h: { id: string }) => h.id === "live");
|
|
assertEquals(live.status, "ok");
|
|
assertEquals(live.lastError, undefined);
|
|
assert(hitModels, "probe should have hit the live /models endpoint");
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
Deno.test("GET /api/providers/:id/available-models lists live models without persisting", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = ((input: RequestInfo | URL) => {
|
|
if (String(input).endsWith("/models")) {
|
|
return Promise.resolve(
|
|
new Response(
|
|
JSON.stringify({
|
|
data: [{ id: "gpt-4o" }, { id: "gpt-4o-mini" }, { id: "o1" }],
|
|
}),
|
|
{ headers: { "Content-Type": "application/json" } },
|
|
),
|
|
);
|
|
}
|
|
return Promise.resolve(new Response("not found", { status: 404 }));
|
|
}) as typeof fetch;
|
|
|
|
try {
|
|
const ctx = makeContext();
|
|
const handler = createHandler(ctx);
|
|
// Enabled subset is a single model; the live provider advertises three.
|
|
await handler(
|
|
new Request(`${base}/api/providers`, {
|
|
method: "POST",
|
|
headers: JSON_HEADERS,
|
|
body: JSON.stringify({
|
|
id: "oai",
|
|
type: "openai",
|
|
apiKey: "sk-test",
|
|
models: ["gpt-4o"],
|
|
enabled: true,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
const res = await handler(
|
|
new Request(`${base}/api/providers/oai/available-models`),
|
|
);
|
|
assertEquals(res.status, 200);
|
|
const body = await res.json();
|
|
assertEquals(body.id, "oai");
|
|
assertEquals(body.models, ["gpt-4o", "gpt-4o-mini", "o1"]);
|
|
|
|
// Read-only: the enabled set the gateway routes on is left untouched.
|
|
assertEquals(ctx.providers.get("oai")?.models, ["gpt-4o"]);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
Deno.test("GET /api/providers/:id/available-models 404s an unknown provider", async () => {
|
|
const handler = createHandler(makeContext());
|
|
const res = await handler(
|
|
new Request(`${base}/api/providers/nope/available-models`),
|
|
);
|
|
assertEquals(res.status, 404);
|
|
await res.body?.cancel();
|
|
});
|