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
6 changes: 6 additions & 0 deletions env.example
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,12 @@ FEATURE_WHITEBOARD_STAKWORK_POSITIONING=false
ANTHROPIC_API_KEY="your-anthropic-api-key-here"
# OpenAI API Key (optional, for future features)
# OPENAI_API_KEY=""
# xAI API Key — direct credential for Grok models (provider "XAI" in the
# LlmModel admin table). Without this, xAI/Grok rows are filtered out of
# every model picker (see /api/llm-models) and dispatch falls back to a
# "model provider not configured" error rather than routing through
# OpenRouter. Get your key from: https://console.x.ai/
# XAI_API_KEY="your-xai-api-key-here"

# Payment Processing (for bounty system)
# STRIPE_SECRET_KEY=""
Expand Down
20 changes: 20 additions & 0 deletions prisma/migrations/20260902134634_add_xai_provider/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- AlterEnum
-- Add XAI as a first-class LlmProvider value (idempotent).
-- Only alter if the enum type exists (handles shadow DB scenario) and only
-- add the label if it isn't already present (handles preview-branch re-runs
-- and avoids Postgres 42710 "enum label already exists").
-- Mirrors the pattern in 20260124103308_add_pod_status_and_soft_delete.
--
-- One-way: Postgres cannot DROP an enum value. Walking this feature back
-- just means leaving an orphaned, unused label — no down-migration needed.
DO $$
DECLARE enum_oid OID;
BEGIN
SELECT oid INTO enum_oid FROM pg_type WHERE typname = 'LlmProvider';

IF enum_oid IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM pg_enum WHERE enumlabel = 'XAI' AND enumtypid = enum_oid
) THEN
ALTER TYPE "LlmProvider" ADD VALUE 'XAI';
END IF;
END $$;
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -1703,6 +1703,7 @@ enum LlmProvider {
ANTHROPIC
OPENAI
AWS_BEDROCK
XAI
OTHER
}

Expand Down
199 changes: 199 additions & 0 deletions src/__tests__/integration/api/admin/llm-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,5 +462,204 @@ describe("Admin LLM Models API", () => {

expect(response.status).toBe(401);
});

it("should return 403 when token-authenticated PATCH tries to set isPublic", async () => {
const model = await createTestLlmModel({ name: "sync-patch-isPublic-blocked", provider: "OPENAI" });
const request = createPatchRequestWithApiToken(
`/api/admin/llm-models/${model.id}`,
TEST_API_TOKEN,
{ isPublic: true },
);
const { PATCH } = await import("@/app/api/admin/llm-models/[id]/route");
const response = await PATCH(request, {
params: Promise.resolve({ id: model.id }),
});

expect(response.status).toBe(403);
const dbRecord = await db.llmModel.findUnique({ where: { id: model.id } });
expect(dbRecord?.isPublic).toBe(false);
});

it("should return 403 when token-authenticated PATCH tries to set isPlanDefault or isTaskDefault", async () => {
const model = await createTestLlmModel({ name: "sync-patch-defaults-blocked", provider: "OPENAI" });
const request = createPatchRequestWithApiToken(
`/api/admin/llm-models/${model.id}`,
TEST_API_TOKEN,
{ isPlanDefault: true, isTaskDefault: true },
);
const { PATCH } = await import("@/app/api/admin/llm-models/[id]/route");
const response = await PATCH(request, {
params: Promise.resolve({ id: model.id }),
});

expect(response.status).toBe(403);
});

it("should allow a SUPER_ADMIN session to set isPublic/isPlanDefault/isTaskDefault", async () => {
const model = await createTestLlmModel({ name: "session-patch-defaults-allowed", provider: "OPENAI" });
const request = createAuthenticatedPatchRequest(
`/api/admin/llm-models/${model.id}`,
{ isPublic: true, isPlanDefault: true },
superAdminUser,
);
const { PATCH } = await import("@/app/api/admin/llm-models/[id]/route");
const response = await PATCH(request, {
params: Promise.resolve({ id: model.id }),
});

expect(response.status).toBe(200);
const data = await response.json();
expect(data.model.isPublic).toBe(true);
expect(data.model.isPlanDefault).toBe(true);
});
});

describe("name/providerLabel validation", () => {
it("should reject a name containing a slash on POST", async () => {
const request = createAuthenticatedPostRequest(
"/api/admin/llm-models",
superAdminUser,
{ name: "anthropic/claude-x", provider: "XAI", inputPricePer1M: 1, outputPricePer1M: 2 },
);
const { POST } = await import("@/app/api/admin/llm-models/route");
const response = await POST(request);

expect(response.status).toBe(400);
});

it("should reject a name containing a slash on PATCH", async () => {
const model = await createTestLlmModel({ name: "safe-name-model", provider: "OPENAI" });
const request = createAuthenticatedPatchRequest(
`/api/admin/llm-models/${model.id}`,
{ name: "openai/gpt-x" },
superAdminUser,
);
const { PATCH } = await import("@/app/api/admin/llm-models/[id]/route");
const response = await PATCH(request, {
params: Promise.resolve({ id: model.id }),
});

expect(response.status).toBe(400);
});

it("should reject a providerLabel containing a slash", async () => {
const request = createAuthenticatedPostRequest(
"/api/admin/llm-models",
superAdminUser,
{
name: "custom-model",
provider: "OTHER",
providerLabel: "Foo/Bar",
inputPricePer1M: 1,
outputPricePer1M: 2,
},
);
const { POST } = await import("@/app/api/admin/llm-models/route");
const response = await POST(request);

expect(response.status).toBe(400);
});

it("should accept a valid XAI model name", async () => {
const request = createAuthenticatedPostRequest(
"/api/admin/llm-models",
superAdminUser,
{ name: "grok-4", provider: "XAI", inputPricePer1M: 3, outputPricePer1M: 15 },
);
const { POST } = await import("@/app/api/admin/llm-models/route");
const response = await POST(request);

expect(response.status).toBe(201);
const data = await response.json();
expect(data.model.provider).toBe("XAI");
});
});

describe("Duplicate name conflict", () => {
it("should return 409 with the existing row's id when creating a duplicate name", async () => {
const existing = await createTestLlmModel({ name: "dup-model", provider: "OPENAI" });

const request = createAuthenticatedPostRequest(
"/api/admin/llm-models",
superAdminUser,
{ name: "dup-model", provider: "ANTHROPIC", inputPricePer1M: 1, outputPricePer1M: 2 },
);
const { POST } = await import("@/app/api/admin/llm-models/route");
const response = await POST(request);

expect(response.status).toBe(409);
const data = await response.json();
expect(data.existingId).toBe(existing.id);
});
});

describe("Default-flip atomicity", () => {
it("should leave exactly one isPlanDefault: true row after sequential flips", async () => {
const first = await createTestLlmModel({ name: "default-flip-a", provider: "OPENAI", isPlanDefault: true } as never);

const request = createAuthenticatedPostRequest(
"/api/admin/llm-models",
superAdminUser,
{
name: "default-flip-b",
provider: "ANTHROPIC",
inputPricePer1M: 1,
outputPricePer1M: 2,
isPlanDefault: true,
},
);
const { POST } = await import("@/app/api/admin/llm-models/route");
await POST(request);

const defaults = await db.llmModel.findMany({
where: { isPlanDefault: true, id: { in: [first.id] } },
});
// The original row should have had its default cleared by the
// transactional flip triggered by the new row's create.
expect(defaults).toHaveLength(0);

const allDefaults = await db.llmModel.findMany({ where: { isPlanDefault: true } });
const relevant = allDefaults.filter((m) => ["default-flip-a", "default-flip-b"].includes(m.name));
expect(relevant).toHaveLength(1);
expect(relevant[0].name).toBe("default-flip-b");
});
});

describe("Sync-revert regression (provider/providerLabel excluded from batch-upsert update)", () => {
beforeEach(() => {
process.env.API_TOKEN = TEST_API_TOKEN;
});

it("should leave provider unchanged on batch upsert of an existing XAI row, while still updating pricing", async () => {
await createTestLlmModel({
name: "grok-sync-model",
provider: "XAI",
inputPricePer1M: 3.0,
outputPricePer1M: 15.0,
});

// Simulate the nightly sync workflow re-classifying the row to OTHER —
// this must NOT stick; only pricing should update.
const request = createRequestWithApiToken("/api/admin/llm-models", TEST_API_TOKEN, {
models: [
{
name: "grok-sync-model",
provider: "OTHER",
providerLabel: "OpenRouter",
inputPricePer1M: 9.99,
outputPricePer1M: 29.99,
},
],
});
const { POST } = await import("@/app/api/admin/llm-models/route");
const response = await POST(request);
expect(response.status).toBe(201);

const dbRecord = await db.llmModel.findUnique({ where: { name: "grok-sync-model" } });
expect(dbRecord?.provider).toBe("XAI");
expect(dbRecord?.providerLabel).toBeNull();
expect(dbRecord?.inputPricePer1M).toBe(9.99);
expect(dbRecord?.outputPricePer1M).toBe(29.99);
});
});
});
71 changes: 71 additions & 0 deletions src/__tests__/integration/api/llm-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,22 @@ function createGetRequest(token?: string) {
describe("GET /api/llm-models - Integration Tests", () => {
let seededModels: LlmModel[] = [];
let testUser: User;
const originalXaiKey = process.env.XAI_API_KEY;
const originalAnthropicKey = process.env.ANTHROPIC_API_KEY;
const originalOpenaiKey = process.env.OPENAI_API_KEY;
const originalGoogleKey = process.env.GOOGLE_API_KEY;

beforeEach(async () => {
process.env.API_TOKEN = VALID_API_TOKEN;
// The provider-key-availability filter (isProviderKeyConfigured) means
// every pre-existing test that seeds OPENAI/ANTHROPIC/GOOGLE models
// needs those providers "configured" to see them in the response —
// mirroring production, where these are always set. Only the new
// "Provider key availability filter" tests below manipulate these
// per-case.
process.env.OPENAI_API_KEY = "test-openai-key";
process.env.ANTHROPIC_API_KEY = "test-anthropic-key";
process.env.GOOGLE_API_KEY = "test-google-key";

testUser = await createTestUser();

Expand Down Expand Up @@ -79,6 +92,10 @@ describe("GET /api/llm-models - Integration Tests", () => {
});
await db.user.deleteMany({ where: { id: testUser.id } });
seededModels = [];
if (originalXaiKey === undefined) delete process.env.XAI_API_KEY;
else process.env.XAI_API_KEY = originalXaiKey;
if (originalAnthropicKey === undefined) delete process.env.ANTHROPIC_API_KEY;
else process.env.ANTHROPIC_API_KEY = originalAnthropicKey;
});

describe("Authentication", () => {
Expand Down Expand Up @@ -283,4 +300,58 @@ describe("GET /api/llm-models - Integration Tests", () => {
expect(claude.providerLabel).toBe("Anthropic");
});
});

describe("Provider key availability filter", () => {
test("excludes an XAI model when XAI_API_KEY is not set", async () => {
delete process.env.XAI_API_KEY;
const xaiModel = await createTestLlmModel({
name: "grok-4",
provider: "XAI",
inputPricePer1M: 3.0,
outputPricePer1M: 15.0,
dateEnd: null,
isPublic: true,
});
seededModels.push(xaiModel);

const request = createGetRequest(VALID_API_TOKEN);
const response = await GET(request as any);
const data = await response.json();
const returnedIds = data.models.map((m: { id: string }) => m.id);

expect(returnedIds).not.toContain(xaiModel.id);
});

test("includes an XAI model when XAI_API_KEY is set", async () => {
process.env.XAI_API_KEY = "test-xai-key";
const xaiModel = await createTestLlmModel({
name: "grok-4-set",
provider: "XAI",
inputPricePer1M: 3.0,
outputPricePer1M: 15.0,
dateEnd: null,
isPublic: true,
});
seededModels.push(xaiModel);

const request = createGetRequest(VALID_API_TOKEN);
const response = await GET(request as any);
const data = await response.json();
const returnedIds = data.models.map((m: { id: string }) => m.id);

expect(returnedIds).toContain(xaiModel.id);
});

test("does not affect an existing provider's models when its key is set (regression)", async () => {
process.env.ANTHROPIC_API_KEY = "test-anthropic-key";

const request = createGetRequest(VALID_API_TOKEN);
const response = await GET(request as any);
const data = await response.json();
const returnedIds = data.models.map((m: { id: string }) => m.id);
const claudeModel = seededModels.find((m) => m.name === "claude-3-5-sonnet")!;

expect(returnedIds).toContain(claudeModel.id);
});
});
});
Loading
Loading