SysDeck/klanker-gate/tests/integration/azure_ingress_test.ts

372 lines
13 KiB
TypeScript
Executable File

// Azure OpenAI deployment-scoped ingress: POST
// /openai/deployments/{deployment}/{chat/completions,completions,embeddings}.
// The URL deployment segment IS the dispatched model and is the ONLY model
// source, so admission (governance) and dispatch (the route) must agree on the
// exact same string - which is why azureDeploymentFromPath is exercised here as
// a unit next to the request that uses it.
import { assert, assertEquals, assertNotEquals } from "@std/assert";
import { createHandler } from "../../apps/gateway/main.ts";
import {
type AppContext,
NullToolExecutor,
VERSION,
} from "../../apps/gateway/context.ts";
import { azureDeploymentFromPath } from "../../apps/gateway/routes/azure_ingress.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";
import {
jsonResponse,
MockProvider,
openAIChatBody,
openAIStreamFrames,
sseResponse,
} from "../../packages/testing/src/mod.ts";
function makeContext(mockUrl: string): AppContext {
return {
providers: new ProviderManager([{
id: "openai",
type: "openai",
apiKey: "sk-test",
baseUrl: mockUrl,
enabled: true,
models: ["gpt-4o"],
priority: 0,
retry: { maxRetries: 0 },
}], "openai"),
metrics: new Metrics(),
logBus: new LogBus(),
virtualKeys: new VirtualKeyManager(),
mcp: new MCPRegistry(),
plugins: new PluginManager(),
toolExecutor: new NullToolExecutor(),
version: VERSION,
};
}
/** Azure clients never send a bearer; they send `api-key`. Both are supported. */
function post(
path: string,
body: unknown,
headers: Record<string, string> = {},
): Request {
return new Request(`http://gateway.test${path}`, {
method: "POST",
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify(body),
});
}
const chat = { messages: [{ role: "user", content: "hei" }] };
interface EgressBody {
model?: string;
stream?: boolean;
[key: string]: unknown;
}
interface ErrorEnvelope {
error: {
message: string;
type: string;
param: string | null;
code: string | null;
};
}
// ------------------------------------------------------- deployment as model
Deno.test("azure ingress: the deployment segment becomes the egress model", async () => {
const mock = new MockProvider(() => jsonResponse(openAIChatBody("hei")));
const ctx = makeContext(mock.url);
const handler = createHandler(ctx);
try {
const res = await handler(
post("/openai/deployments/gpt-4o-prod/chat/completions", chat),
);
assertEquals(res.status, 200);
await res.body?.cancel();
assertEquals(mock.calls.length, 1);
assertEquals(mock.calls[0].path, "/chat/completions");
assertEquals((mock.calls[0].body as EgressBody).model, "gpt-4o-prod");
assertEquals(ctx.metrics.get("requests.compat.azure"), 1);
} finally {
await mock.close();
}
});
Deno.test("azure ingress: a body model disagreeing with the URL is overridden by the URL", async () => {
// Security-relevant: governance admits against the PATH deployment, so a
// route that dispatched the body model would let a scoped key be admitted
// for one model and billed/served another.
const mock = new MockProvider(() => jsonResponse(openAIChatBody("hei")));
const handler = createHandler(makeContext(mock.url));
try {
const res = await handler(post(
"/openai/deployments/gpt-4o-prod/chat/completions",
{ ...chat, model: "openai/smuggled-model" },
));
assertEquals(res.status, 200);
await res.body?.cancel();
const sent = mock.calls[0].body as EgressBody;
assertEquals(sent.model, "gpt-4o-prod");
assertNotEquals(sent.model, "smuggled-model");
assertNotEquals(sent.model, "openai/smuggled-model");
} finally {
await mock.close();
}
});
Deno.test("azure ingress: any api-version value is tolerated and never becomes the model", async () => {
const mock = new MockProvider(() => jsonResponse(openAIChatBody("hei")));
const handler = createHandler(makeContext(mock.url));
try {
const versions = ["2024-02-15-preview", "totally-made-up"];
for (const version of versions) {
const res = await handler(post(
`/openai/deployments/gpt-4o-prod/chat/completions?api-version=${version}`,
chat,
));
assertEquals(res.status, 200, version);
await res.body?.cancel();
}
assertEquals(mock.calls.length, 2);
for (const call of mock.calls) {
const sent = call.body as EgressBody;
assertEquals(sent.model, "gpt-4o-prod");
// The query parameter is ignored outright: it never reaches egress.
assertEquals(Object.hasOwn(sent, "api-version"), false);
}
} finally {
await mock.close();
}
});
// --------------------------------------------------------- percent-encoding
Deno.test("azure ingress: a percent-encoded deployment decodes the same for admission and dispatch", async () => {
const pathname = "/openai/deployments/my%2Ddep/chat/completions";
// The unit the governance middleware calls, on the exact same pathname.
const admitted = azureDeploymentFromPath(pathname);
assertEquals(admitted, "my-dep");
const mock = new MockProvider(() => jsonResponse(openAIChatBody("hei")));
const handler = createHandler(makeContext(mock.url));
try {
const res = await handler(post(pathname, chat));
assertEquals(res.status, 200);
await res.body?.cancel();
const dispatched = (mock.calls[0].body as EgressBody).model;
assertEquals(dispatched, "my-dep");
// The whole point of the shared helper: one string, both decisions.
assertEquals(dispatched, admitted);
} finally {
await mock.close();
}
});
Deno.test("azure ingress: azureDeploymentFromPath is undefined off the azure shape", () => {
// Non-azure paths must not be mistaken for a deployment.
assertEquals(azureDeploymentFromPath("/v1/chat/completions"), undefined);
assertEquals(
azureDeploymentFromPath("/openai/v1/chat/completions"),
undefined,
);
// No trailing operation segment: not a dispatchable azure path.
assertEquals(azureDeploymentFromPath("/openai/deployments/dep"), undefined);
// Undecodable segment -> undefined, which fails admission CLOSED.
assertEquals(
azureDeploymentFromPath("/openai/deployments/my%zzdep/chat/completions"),
undefined,
);
// A slash-bearing deployment survives decoding intact.
assertEquals(
azureDeploymentFromPath("/openai/deployments/a%2Fb/chat/completions"),
"a/b",
);
});
Deno.test("azure ingress: a malformed percent-escape is a 400, never a 500", async () => {
const mock = new MockProvider(() => jsonResponse(openAIChatBody("hei")));
const handler = createHandler(makeContext(mock.url));
try {
const res = await handler(
post("/openai/deployments/my%zzdep/chat/completions", chat),
);
assertEquals(res.status, 400);
assertEquals(res.headers.get("Content-Type"), "application/json");
const body = await res.json() as ErrorEnvelope;
assertEquals(body.error.type, "invalid_request_error");
assertEquals(body.error.param, "deployment");
assert(body.error.message.length > 0);
// Nothing was dispatched: the request died at the edge.
assertEquals(mock.calls.length, 0);
} finally {
await mock.close();
}
});
// ------------------------------------------------------------------ streaming
Deno.test("azure ingress: streaming is the canonical SSE stream, byte-identical to /v1", async () => {
const frames = openAIStreamFrames(["Hei", " fra", " Azure"]);
const mock = new MockProvider((call) =>
(call.body as EgressBody).stream
? sseResponse(frames)
: jsonResponse(openAIChatBody("unused"))
);
const handler = createHandler(makeContext(mock.url));
try {
const azure = await handler(post(
"/openai/deployments/gpt-4o-prod/chat/completions",
{ ...chat, stream: true },
));
assertEquals(azure.status, 200);
assertEquals(azure.headers.get("Content-Type"), "text/event-stream");
const azureText = await azure.text();
const canonical = await handler(post("/v1/chat/completions", {
...chat,
model: "openai/gpt-4o",
stream: true,
}));
assertEquals(canonical.status, 200);
const canonicalText = await canonical.text();
// The azure surface performs NO stream translation: the bytes a client sees
// are the same bytes /v1/chat/completions produces for the same upstream.
assertEquals(azureText, canonicalText);
assert(azureText.endsWith("data: [DONE]\n\n"), azureText);
assert(azureText.includes("Hei"));
// Both upstream calls asked for a stream; the azure one carried the URL model.
assertEquals(mock.calls.length, 2);
assertEquals((mock.calls[0].body as EgressBody).stream, true);
assertEquals((mock.calls[0].body as EgressBody).model, "gpt-4o-prod");
assertEquals((mock.calls[1].body as EgressBody).stream, true);
} finally {
await mock.close();
}
});
// --------------------------------------------------- the other two operations
Deno.test("azure ingress: completions and embeddings deployments both dispatch", async () => {
const mock = new MockProvider((call) =>
call.path === "/embeddings"
? jsonResponse({
object: "list",
data: [{ object: "embedding", index: 0, embedding: [0.1, 0.2] }],
model: "embed-3-dep",
usage: { prompt_tokens: 1, total_tokens: 1 },
})
: jsonResponse({
id: "cmpl-mock",
object: "text_completion",
created: 1700000000,
model: "davinci-dep",
choices: [{ text: "hei", index: 0, finish_reason: "stop" }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
})
);
const ctx = makeContext(mock.url);
const handler = createHandler(ctx);
try {
const completions = await handler(post(
"/openai/deployments/davinci-dep/completions",
{ prompt: "hei", max_tokens: 5 },
));
assertEquals(completions.status, 200);
await completions.body?.cancel();
const embeddings = await handler(post(
"/openai/deployments/embed-3-dep/embeddings",
{ input: "hei" },
));
assertEquals(embeddings.status, 200);
await embeddings.body?.cancel();
assertEquals(mock.calls.length, 2);
assertEquals(mock.calls[0].path, "/completions");
assertEquals((mock.calls[0].body as EgressBody).model, "davinci-dep");
assertEquals(mock.calls[1].path, "/embeddings");
assertEquals((mock.calls[1].body as EgressBody).model, "embed-3-dep");
// Every azure operation is counted on the same compat counter.
assertEquals(ctx.metrics.get("requests.compat.azure"), 2);
} finally {
await mock.close();
}
});
// ----------------------------------------------------------------- governance
Deno.test("azure ingress: api-key authenticates, no credential is 401, an explicit bearer wins", async () => {
const mock = new MockProvider(() => jsonResponse(openAIChatBody("hei")));
const ctx = makeContext(mock.url);
ctx.virtualKeys.upsert({
id: "vk",
name: "azure-gate",
token: "vk-azure-gate-token",
enabled: true,
usedRequests: 0,
usedCostMicroUsd: 0,
});
const handler = createHandler(ctx);
const path = "/openai/deployments/gpt-4o-prod/chat/completions";
try {
const anonymous = await handler(post(path, chat));
assertEquals(anonymous.status, 401);
await anonymous.body?.cancel();
// A stock Azure SDK cannot send Authorization; api-key is promoted for it.
const apiKey = await handler(
post(path, chat, { "api-key": "vk-azure-gate-token" }),
);
assertEquals(apiKey.status, 200);
await apiKey.body?.cancel();
// Promotion is an admission path, not a bypass: a bogus api-key still 401s.
const bogus = await handler(post(path, chat, { "api-key": "not-a-key" }));
assertEquals(bogus.status, 401);
await bogus.body?.cancel();
// An explicit bearer is authoritative and is NEVER clobbered by api-key.
const bearerWins = await handler(post(path, chat, {
"Authorization": "Bearer vk-azure-gate-token",
"api-key": "not-a-key",
}));
assertEquals(bearerWins.status, 200);
await bearerWins.body?.cancel();
// Only the two admitted requests reached a provider.
assertEquals(mock.calls.length, 2);
} finally {
await mock.close();
}
});
Deno.test("azure ingress: a wrong-method request is a JSON error, never SPA HTML", async () => {
const mock = new MockProvider(() => jsonResponse(openAIChatBody("hei")));
const handler = createHandler(makeContext(mock.url));
try {
const res = await handler(
new Request(
"http://gateway.test/openai/deployments/gpt-4o-prod/chat/completions",
),
);
assertEquals(res.status, 405);
assertEquals(res.headers.get("Content-Type"), "application/json");
assertEquals(res.headers.get("Allow"), "POST");
const body = await res.json() as ErrorEnvelope;
assert(body.error.message.includes("not allowed"), body.error.message);
assertEquals(mock.calls.length, 0);
} finally {
await mock.close();
}
});