Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
202 changes: 202 additions & 0 deletions apps/gateway/src/realtime/account-gate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { Decimal } from "decimal.js";

import {
assertApiKeyWithinUsageLimits,
assertMemberWithinBudget,
} from "@/lib/api-key-usage-limits.js";
import {
findApiKeyByToken,
findOrganizationById,
findProjectById,
} from "@/lib/cached-queries.js";
import { assertProviderCompliant } from "@/lib/compliance.js";
import { validateRequestModelAccess } from "@/lib/iam.js";

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

import { getUnsettledRealtimeOrganizationSpend } from "./billing.js";
import { getAvailableCredits } from "./preflight.js";

import type { RealtimeMappingMatch } from "./catalog.js";
import type { RealtimePreflightResult } from "./preflight.js";

type AccountOrganization = Awaited<ReturnType<typeof findOrganizationById>>;

export type AuthorizeAccountResult =
| { ok: true; organization: NonNullable<AccountOrganization> }
| {
ok: false;
code: string;
message: string;
severity: "transient" | "deny" | "close";
};

export interface AuthorizeAccountInput {
preflight: RealtimePreflightResult;
gatewayToken: string;
/**
* Model string the caller requested, used for compliance audit context.
*/
requestedModel: string;
/**
* Catalogue mapping the billable unit of work runs against. This is the
* session's realtime mapping for generations and the pinned ASR mapping for
* transcription re-checks.
*/
match: RealtimeMappingMatch;
}

/**
* Account-level authorization for one billable unit of work against `match`:
* fresh key/org/project state, per-key usage limits, IAM access to the model,
* the org's compliance policy, and the member's spend budget. Shared by every
* realtime session implementation so each provider reacts to a revoked key,
* budget or model within one turn.
*
* `severity` tells the caller what a failure means: "transient" when the
* state could not be read at all, "deny" when only this unit of work is
* refused, "close" when the session must not continue.
*/
export async function authorizeAccount(
input: AuthorizeAccountInput,
): Promise<AuthorizeAccountResult> {
const { preflight, match } = input;
let freshKey;
let freshOrg;
let freshProject;
try {
freshKey = await findApiKeyByToken(input.gatewayToken);
freshOrg = await findOrganizationById(preflight.project.organizationId);
freshProject = await findProjectById(preflight.project.id);
} catch (error) {
logger.error("Realtime authorization lookup failed", error as Error);
return {
ok: false,
code: "gate_unavailable",
message:
"Unable to verify account state; generation is temporarily unavailable.",
severity: "transient",
};
}

if (!freshKey || freshKey.status !== "active") {
return {
ok: false,
code: "api_key_revoked",
message: "The LLMGateway API key for this session is no longer active.",
severity: "close",
};
}
try {
assertApiKeyWithinUsageLimits(freshKey);
} catch (error) {
return {
ok: false,
code: "api_key_limit",
message:
error instanceof Error ? error.message : "API key usage limit reached.",
severity: "deny",
};
}

if (!freshOrg || freshOrg.status === "deleted") {
return {
ok: false,
code: "organization_unavailable",
message: "The organization for this session is no longer active.",
severity: "close",
};
}

if (!freshProject || freshProject.status === "deleted") {
return {
ok: false,
code: "project_archived",
message: "The project for this session has been archived.",
severity: "close",
};
}

// IAM rules and the organization's compliance policy can change while a
// session is open, and a session may live for the whole duration limit.
// Re-evaluating them per billable unit makes a revoked model, IP range or
// provider take effect within one turn, as it would for HTTP requests.
const iamValidation = await validateRequestModelAccess({
apiKey: freshKey,
organizationId: preflight.project.organizationId,
requestedModel: match.modelId,
requestedProvider: match.mapping.providerId,
activeModelInfo: match.modelDef,
clientIp: preflight.clientIp,
});
if (!iamValidation.allowed) {
return {
ok: false,
code: "model_access_denied",
message: iamValidation.reason ?? "Model access denied.",
severity: "close",
};
}
try {
await assertProviderCompliant(freshOrg, match.mapping.providerId, {
organizationId: preflight.project.organizationId,
modelId: match.modelId,
apiKeyId: freshKey.id,
model: input.requestedModel,
});
} catch (error) {
return {
ok: false,
code: "compliance_blocked",
message:
error instanceof Error
? error.message
: "This provider is blocked by the organization's compliance policy.",
severity: "close",
};
}

try {
await assertMemberWithinBudget(
freshKey.createdBy,
preflight.project.organizationId,
);
} catch (error) {
return {
ok: false,
code: "member_budget_exceeded",
message:
error instanceof Error ? error.message : "Member spend budget reached.",
severity: "deny",
};
}

return { ok: true, organization: freshOrg };
}

/**
* Credits still available to a session: the organization's balance minus its
* realtime spend the worker has NOT yet settled (once the worker debits a row
* the organization balance already reflects it, so subtracting it again would
* double-count). The unsettled sum spans the organization's sessions, not just
* one, so concurrent sessions cannot each authorize work against the same
* undebited balance. Null when billing state is unreadable.
*/
export async function availableCredits(
organizationId: string,
organization: Parameters<typeof getAvailableCredits>[0],
): Promise<Decimal | null> {
const unsettled = await getUnsettledRealtimeOrganizationSpend(
organizationId,
).catch((error: unknown) => {
logger.error(
"Failed to read unsettled realtime organization spend",
error as Error,
);
return null;
});
if (unsettled === null) {
return null;
}
return new Decimal(getAvailableCredits(organization)).minus(unsettled);
}
18 changes: 18 additions & 0 deletions apps/gateway/src/realtime/catalog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,24 @@ describe("findRealtimeMapping", () => {
expect(findRealtimeMapping("anthropic/gpt-realtime")).toBeNull();
});

it("resolves the Gemini Live model to its google-ai-studio mapping", () => {
const match = findRealtimeMapping(
"gemini-2.5-flash-native-audio-preview-12-2025",
);
expect(match?.modelId).toBe(
"gemini-2.5-flash-native-audio-preview-12-2025",
);
expect(match?.mapping.providerId).toBe("google-ai-studio");
expect(match?.mapping.realtime).toBe(true);
});

it("resolves the provider-pinned Gemini Live model string", () => {
const match = findRealtimeMapping(
"google-ai-studio/gemini-2.5-flash-native-audio-preview-12-2025",
);
expect(match?.mapping.providerId).toBe("google-ai-studio");
});

it("returns null for chat models without realtime support", () => {
expect(findRealtimeMapping("gpt-4o-mini")).toBeNull();
});
Expand Down
94 changes: 94 additions & 0 deletions apps/gateway/src/realtime/connect-upstream.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { afterEach, describe, expect, it } from "vitest";

import { resolveUpstreamTarget } from "./connect-upstream.js";

import type { RealtimePreflightResult } from "./preflight.js";

function buildPreflight(
providerId: string,
externalId: string,
configIndex = 0,
): RealtimePreflightResult {
return {
match: {
modelId: externalId,
modelDef: { id: externalId },
mapping: { providerId, externalId },
},
upstreamToken: "upstream-secret",
configIndex,
safetyIdentifier: "safety-hash",
} as unknown as RealtimePreflightResult;
}

afterEach(() => {
delete process.env.LLM_GOOGLE_AI_STUDIO_BASE_URL;
delete process.env.LLM_OPENAI_BASE_URL;
});

describe("resolveUpstreamTarget", () => {
it("builds the Gemini Live BidiGenerateContent URL", () => {
const target = resolveUpstreamTarget(
buildPreflight(
"google-ai-studio",
"gemini-2.5-flash-native-audio-preview-12-2025",
),
);
expect(target.url).toBe(
"wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent",
);
});

it("authenticates Gemini with x-goog-api-key only", () => {
const target = resolveUpstreamTarget(
buildPreflight(
"google-ai-studio",
"gemini-2.5-flash-native-audio-preview-12-2025",
),
);
expect(target.headers).toEqual({ "x-goog-api-key": "upstream-secret" });
// OpenAI-only headers must not leak onto the Google connection.
expect(target.headers["OpenAI-Safety-Identifier"]).toBeUndefined();
expect(target.headers.Authorization).toBeUndefined();
});

it("never places the Gemini credential in the URL", () => {
const target = resolveUpstreamTarget(
buildPreflight(
"google-ai-studio",
"gemini-2.5-flash-native-audio-preview-12-2025",
),
);
expect(target.url).not.toContain("upstream-secret");
expect(target.url).not.toContain("key=");
expect(target.url).not.toContain("?");
});

it("honors the indexed Gemini base-URL override", () => {
process.env.LLM_GOOGLE_AI_STUDIO_BASE_URL =
"https://primary.example.com,https://secondary.example.com";
const target = resolveUpstreamTarget(
buildPreflight(
"google-ai-studio",
"gemini-2.5-flash-native-audio-preview-12-2025",
1,
),
);
expect(target.url).toBe(
"wss://secondary.example.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent",
);
});

it("leaves the OpenAI URL and headers unchanged", () => {
const target = resolveUpstreamTarget(
buildPreflight("openai", "gpt-realtime-2.1-mini"),
);
expect(target.url).toBe(
"wss://api.openai.com/v1/realtime?model=gpt-realtime-2.1-mini",
);
expect(target.headers).toEqual({
Authorization: "Bearer upstream-secret",
"OpenAI-Safety-Identifier": "safety-hash",
});
});
});
Loading
Loading