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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@
"@vercel/functions": "^3.1.4",
"@xyflow/react": "^12.8.6",
"ai": "^6.0.72",
"aieo": "^0.1.37",
"aieo": "^0.1.38",
"autoprefixer": "^10.4.21",
"axios": "1.10.0",
"bcryptjs": "^3.0.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "workspace_members" ADD COLUMN "bifrost_vk_providers" TEXT[] DEFAULT ARRAY[]::TEXT[];
5 changes: 5 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,11 @@ model WorkspaceMember {
bifrostVkId String? @map("bifrost_vk_id")
bifrostCustomerId String? @map("bifrost_customer_id")
bifrostSyncedAt DateTime? @map("bifrost_synced_at")
// Provider grants the cached Bifrost VK carried when it was last
// created or refreshed (`bifrost_synced_at`). Empty = unknown (row
// predates this column or the gateway couldn't be read), never "no
// grants" — the reconciler treats empty as "refresh, then trust Bifrost".
bifrostVkProviders String[] @default([]) @map("bifrost_vk_providers")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)

Expand Down
79 changes: 78 additions & 1 deletion src/__tests__/unit/services/bifrost/orchestrator.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { BifrostProvider } from "@/services/bifrost/types";

// Mock the per-layer reconcilers BEFORE importing the SUT. The
// orchestrator imports them by their concrete module paths
Expand Down Expand Up @@ -68,7 +69,15 @@ function mockMintOk(runId = "run_fixed_abc") {
});
}

function mockVKOk(overrides: Partial<{ vkValue: string; baseUrl: string }> = {}) {
function mockVKOk(
overrides: Partial<{
vkValue: string;
baseUrl: string;
modelProvider: BifrostProvider;
providers: string[];
modelProviderGranted: boolean;
}> = {},
) {
vi.mocked(reconcilerModule.reconcileBifrostVK).mockResolvedValueOnce({
workspaceId: "ws-1",
userId: "u_alice",
Expand All @@ -77,6 +86,9 @@ function mockVKOk(overrides: Partial<{ vkValue: string; baseUrl: string }> = {})
vkValue: overrides.vkValue ?? "sk-bf-LIVE",
baseUrl: overrides.baseUrl ?? "http://bifrost.test:8181",
created: false,
modelProvider: overrides.modelProvider ?? "anthropic",
providers: overrides.providers,
modelProviderGranted: overrides.modelProviderGranted,
});
}

Expand Down Expand Up @@ -201,6 +213,70 @@ describe("getBifrostForLLM (master reconciler)", () => {
);
});

// ── Grant check ─────────────────────────────────────────────────────

it("returns undefined without minting when the VK has no grant for the model's provider", async () => {
process.env.BIFROST_ENABLED = "true";
vi.spyOn(console, "info").mockImplementation(() => {});
vi.spyOn(console, "log").mockImplementation(() => {});
mockVKOk({
baseUrl: "http://bifrost.test:8181/openai/v1",
modelProvider: "xai",
providers: ["anthropic", "openai", "openrouter", "gemini"],
modelProviderGranted: false,
});

const result = await getBifrostForLLM(auth, {
agentName: "coding-agent",
model: "xai/grok-4.6",
});
// Caller keeps its direct provider key — the gateway would have
// rejected the call, and nothing downstream retries.
expect(result).toBeUndefined();
expect(reconcilerModule.reconcileBifrostVK).toHaveBeenCalledWith(
"ws-1",
"u_alice",
{ model: "xai/grok-4.6" },
);
expect(issuerModule.mintInvocationMacaroon).not.toHaveBeenCalled();
});

it("routes through Bifrost when the grants are unknown (no snapshot yet)", async () => {
process.env.BIFROST_ENABLED = "true";
mockVKOk({
baseUrl: "http://bifrost.test:8181/openai/v1",
modelProvider: "xai",
providers: undefined,
modelProviderGranted: undefined,
});
mockMintOk();

const result = await getBifrostForLLM(auth, {
agentName: "coding-agent",
model: "xai/grok-4.6",
});
expect(result?.baseUrl).toBe("http://bifrost.test:8181/openai/v1");
expect(issuerModule.mintInvocationMacaroon).toHaveBeenCalledTimes(1);
});

it("routes through Bifrost when the model's provider is granted", async () => {
process.env.BIFROST_ENABLED = "true";
mockVKOk({
baseUrl: "http://bifrost.test:8181/openai/v1",
modelProvider: "xai",
providers: ["anthropic", "openai", "openrouter", "gemini", "xai"],
modelProviderGranted: true,
});
mockMintOk();

const result = await getBifrostForLLM(auth, {
agentName: "coding-agent",
model: "xai/grok-4.6",
});
expect(result?.apiKey).toBe("sk-bf-LIVE");
expect(issuerModule.mintInvocationMacaroon).toHaveBeenCalledTimes(1);
});

it("forwards caller-supplied runId + budget overrides to the issuer", async () => {
process.env.BIFROST_ENABLED = "true";
mockVKOk();
Expand Down Expand Up @@ -396,6 +472,7 @@ describe("getBifrostForLLM (master reconciler)", () => {
vkValue: "sk-bf-LIVE",
baseUrl: "http://bifrost.test:8181/anthropic/v1",
created: false,
modelProvider: "anthropic",
};
},
);
Expand Down
Loading
Loading