Skip to content
Open
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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ You can deploy your own version of Novel to Vercel with one click:

To set up Novel locally, you'll need to clone the repository and set up the following environment variables:

- `OPENAI_API_KEY` – your OpenAI API key (you can get one [here](https://platform.openai.com/account/api-keys))
- `OPENAI_API_KEY` – your OpenAI API key (you can get one [here](https://platform.openai.com/account/api-keys)), **or**
- `MINIMAX_API_KEY` – your MiniMax API key (you can get one [here](https://platform.minimax.io)). When set, MiniMax takes priority over OpenAI. Supported models: `MiniMax-M2.7` (default), `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`.
- `BLOB_READ_WRITE_TOKEN` – your Vercel Blob read/write token (currently [still in beta](https://vercel.com/docs/storage/vercel-blob/quickstart#quickstart), but feel free to [sign up on this form](https://vercel.fyi/blob-beta) for access)

If you've deployed this to Vercel, you can also use [`vc env pull`](https://vercel.com/docs/cli/env#exporting-development-environment-variables) to pull the environment variables from your Vercel project.
Expand Down Expand Up @@ -78,7 +79,7 @@ Novel is built on the following stack:

- [Next.js](https://nextjs.org/) – framework
- [Tiptap](https://tiptap.dev/) – text editor
- [OpenAI](https://openai.com/) - AI completions
- [OpenAI](https://openai.com/) / [MiniMax](https://www.minimax.io/) - AI completions
- [Vercel AI SDK](https://sdk.vercel.ai/docs) – AI library
- [Vercel](https://vercel.com) – deployments
- [TailwindCSS](https://tailwindcss.com/) – styles
Expand Down
15 changes: 13 additions & 2 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,21 @@
# secrets in it. If you are cloning this repo, create a copy of this file named
# ".env" and populate it with your secrets.

# --- LLM Provider (pick ONE) ---

# Option 1: OpenAI (default)
# Get your OpenAI API key here: https://platform.openai.com/account/api-keys
OPENAI_API_KEY=
# OPTIONAL: OpenAI Base URL (default to https://api.openai.com/v1)
OPENAI_BASE_URL=
# OPTIONAL: Override the default model (default: gpt-4o-mini)
# OPENAI_MODEL=gpt-4o-mini

# Option 2: MiniMax (OpenAI-compatible)
# Get your MiniMax API key here: https://platform.minimax.io
# When MINIMAX_API_KEY is set it takes priority over OpenAI.
# MINIMAX_API_KEY=
# OPTIONAL: Override the default model (default: MiniMax-M2.7)
# Supported models: MiniMax-M2.7, MiniMax-M2.7-highspeed, MiniMax-M2.5, MiniMax-M2.5-highspeed
# MINIMAX_MODEL=MiniMax-M2.7

# OPTIONAL: Vercel Blob (for uploading images)
# Get your Vercel Blob credentials here: https://vercel.com/docs/storage/vercel-blob/quickstart#quickstart
Expand Down
103 changes: 103 additions & 0 deletions apps/web/__tests__/ai-provider.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, it, expect, beforeAll } from "vitest";
import { createOpenAI } from "@ai-sdk/openai";
import { generateText } from "ai";

/**
* Integration tests that call the MiniMax API.
* Requires MINIMAX_API_KEY to be set in the environment.
*
* Run with:
* MINIMAX_API_KEY=<key> pnpm vitest run __tests__/ai-provider.integration.test.ts
*/
describe("MiniMax integration", () => {
const apiKey = process.env.MINIMAX_API_KEY;

beforeAll(() => {
if (!apiKey) {
console.warn("Skipping integration tests: MINIMAX_API_KEY not set");
}
});

it.skipIf(!apiKey)("generates text with MiniMax-M2.7", async () => {
const minimax = createOpenAI({
baseURL: "https://api.minimax.io/v1",
apiKey: apiKey!,
});

const result = await generateText({
model: minimax("MiniMax-M2.7"),
prompt: "Say hello in exactly 5 words.",
maxTokens: 64,
temperature: 0.7,
});

expect(result.text).toBeTruthy();
expect(result.text.length).toBeGreaterThan(0);
console.log("MiniMax-M2.7 response:", result.text);
}, 30000);

it.skipIf(!apiKey)("generates text with MiniMax-M2.7-highspeed", async () => {
const minimax = createOpenAI({
baseURL: "https://api.minimax.io/v1",
apiKey: apiKey!,
});

const result = await generateText({
model: minimax("MiniMax-M2.7-highspeed"),
prompt: "What is 2+2? Reply with just the number.",
maxTokens: 128,
temperature: 0.7,
});

expect(result.text).toBeTruthy();
// Strip potential thinking tokens before checking
const answer = result.text.replace(/<think>[\s\S]*?<\/think>\s*/g, "").trim();
expect(answer).toContain("4");
console.log("MiniMax-M2.7-highspeed response:", answer);
}, 30000);

it.skipIf(!apiKey)("generates text with MiniMax-M2.5 (legacy)", async () => {
const minimax = createOpenAI({
baseURL: "https://api.minimax.io/v1",
apiKey: apiKey!,
});

const result = await generateText({
model: minimax("MiniMax-M2.5"),
prompt: "Say hello in exactly 5 words.",
maxTokens: 64,
temperature: 0.7,
});

expect(result.text).toBeTruthy();
expect(result.text.length).toBeGreaterThan(0);
console.log("MiniMax-M2.5 response:", result.text);
}, 30000);

it.skipIf(!apiKey)("streams text with MiniMax-M2.7", async () => {
const { streamText } = await import("ai");
const minimax = createOpenAI({
baseURL: "https://api.minimax.io/v1",
apiKey: apiKey!,
});

const result = await streamText({
model: minimax("MiniMax-M2.7"),
prompt: "Count from 1 to 5, one number per line.",
maxTokens: 64,
temperature: 0.7,
});

let fullText = "";
for await (const chunk of result.textStream) {
fullText += chunk;
}

expect(fullText).toBeTruthy();
// Strip thinking tokens if present
const answer = fullText.replace(/<think>[\s\S]*?<\/think>\s*/g, "").trim();
expect(answer).toBeTruthy();
expect(answer).toContain("1");
console.log("MiniMax streaming response:", answer);
}, 60000);
});
97 changes: 97 additions & 0 deletions apps/web/__tests__/ai-provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { getModel, getTemperature, hasApiKey } from "@/lib/ai-provider";

// Save original env
const originalEnv = { ...process.env };

beforeEach(() => {
// Clear provider env vars before each test
delete process.env.OPENAI_API_KEY;
delete process.env.MINIMAX_API_KEY;
delete process.env.OPENAI_MODEL;
delete process.env.MINIMAX_MODEL;
});

afterEach(() => {
process.env = { ...originalEnv };
});

describe("hasApiKey", () => {
it("returns false when no API key is set", () => {
expect(hasApiKey()).toBe(false);
});

it("returns true when OPENAI_API_KEY is set", () => {
process.env.OPENAI_API_KEY = "sk-test-openai-key";
expect(hasApiKey()).toBe(true);
});

it("returns true when MINIMAX_API_KEY is set", () => {
process.env.MINIMAX_API_KEY = "sk-test-minimax-key";
expect(hasApiKey()).toBe(true);
});

it("returns true when both keys are set", () => {
process.env.OPENAI_API_KEY = "sk-test-openai-key";
process.env.MINIMAX_API_KEY = "sk-test-minimax-key";
expect(hasApiKey()).toBe(true);
});
});

describe("getTemperature", () => {
it("returns the base temperature when using OpenAI", () => {
process.env.OPENAI_API_KEY = "sk-test";
expect(getTemperature(0.7)).toBe(0.7);
expect(getTemperature(0)).toBe(0);
expect(getTemperature(1.5)).toBe(1.5);
});

it("clamps temperature to (0.0, 1.0] for MiniMax", () => {
process.env.MINIMAX_API_KEY = "sk-test";
// Normal value in range
expect(getTemperature(0.7)).toBe(0.7);
// Zero should be clamped to 0.01
expect(getTemperature(0)).toBe(0.01);
// Negative should be clamped to 0.01
expect(getTemperature(-0.5)).toBe(0.01);
// Values above 1.0 should be clamped to 1.0
expect(getTemperature(1.5)).toBe(1.0);
// Exactly 1.0 is valid
expect(getTemperature(1.0)).toBe(1.0);
});
});

describe("getModel", () => {
it("returns an OpenAI model when OPENAI_API_KEY is set", () => {
process.env.OPENAI_API_KEY = "sk-test-openai";
const model = getModel();
expect(model.modelId).toBe("gpt-4o-mini");
});

it("allows overriding OpenAI model via OPENAI_MODEL", () => {
process.env.OPENAI_API_KEY = "sk-test-openai";
process.env.OPENAI_MODEL = "gpt-4o";
const model = getModel();
expect(model.modelId).toBe("gpt-4o");
});

it("returns a MiniMax model when MINIMAX_API_KEY is set", () => {
process.env.MINIMAX_API_KEY = "sk-test-minimax";
const model = getModel();
expect(model.modelId).toBe("MiniMax-M2.7");
});

it("allows overriding MiniMax model via MINIMAX_MODEL", () => {
process.env.MINIMAX_API_KEY = "sk-test-minimax";
process.env.MINIMAX_MODEL = "MiniMax-M2.5-highspeed";
const model = getModel();
expect(model.modelId).toBe("MiniMax-M2.5-highspeed");
});

it("prioritizes MiniMax over OpenAI when both keys are set", () => {
process.env.OPENAI_API_KEY = "sk-test-openai";
process.env.MINIMAX_API_KEY = "sk-test-minimax";
const model = getModel();
expect(model.modelId).toBe("MiniMax-M2.7");
});
});
17 changes: 9 additions & 8 deletions apps/web/app/api/generate/route.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import { openai } from "@ai-sdk/openai";
import { Ratelimit } from "@upstash/ratelimit";
import { kv } from "@vercel/kv";
import { streamText } from "ai";
import { match } from "ts-pattern";
import { getModel, getTemperature, hasApiKey } from "@/lib/ai-provider";

// IMPORTANT! Set the runtime to edge: https://vercel.com/docs/functions/edge-functions/edge-runtime
export const runtime = "edge";

export async function POST(req: Request): Promise<Response> {
// Check if the OPENAI_API_KEY is set, if not return 400
if (!process.env.OPENAI_API_KEY || process.env.OPENAI_API_KEY === "") {
return new Response("Missing OPENAI_API_KEY - make sure to add it to your .env file.", {
status: 400,
});
// Check if an API key is set (supports OpenAI or MiniMax)
if (!hasApiKey()) {
return new Response(
"Missing API key – set OPENAI_API_KEY or MINIMAX_API_KEY in your .env file.",
{ status: 400 },
);
}
if (process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN) {
const ip = req.headers.get("x-forwarded-for");
Expand Down Expand Up @@ -118,11 +119,11 @@ export async function POST(req: Request): Promise<Response> {
const result = await streamText({
prompt: messages[messages.length - 1].content,
maxTokens: 4096,
temperature: 0.7,
temperature: getTemperature(0.7),
topP: 1,
frequencyPenalty: 0,
presencePenalty: 0,
model: openai("gpt-4o-mini"),
model: getModel(),
});

return result.toDataStreamResponse();
Expand Down
44 changes: 44 additions & 0 deletions apps/web/lib/ai-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { createOpenAI } from "@ai-sdk/openai";

/**
* Returns the configured LLM model for AI completions.
*
* Provider priority:
* 1. If MINIMAX_API_KEY is set → MiniMax (MiniMax-M2.7)
* 2. If OPENAI_API_KEY is set → OpenAI (gpt-4o-mini)
*
* MiniMax uses an OpenAI-compatible API, so we reuse @ai-sdk/openai
* with a custom baseURL and apiKey.
*/
export function getModel() {
if (process.env.MINIMAX_API_KEY) {
const minimax = createOpenAI({
baseURL: "https://api.minimax.io/v1",
apiKey: process.env.MINIMAX_API_KEY,
});
return minimax(process.env.MINIMAX_MODEL ?? "MiniMax-M2.7");
}

const openai = createOpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
return openai(process.env.OPENAI_MODEL ?? "gpt-4o-mini");
}

/**
* Clamp temperature for MiniMax: must be in (0.0, 1.0].
* OpenAI accepts 0–2, so no clamping needed for OpenAI.
*/
export function getTemperature(base: number): number {
if (process.env.MINIMAX_API_KEY) {
return Math.max(0.01, Math.min(base, 1.0));
}
return base;
}

/**
* Returns true when an LLM API key is configured (either provider).
*/
export function hasApiKey(): boolean {
return !!(process.env.MINIMAX_API_KEY || process.env.OPENAI_API_KEY);
}
3 changes: 2 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"@types/react": "^18.2.61",
"@types/react-dom": "18.2.19",
"tailwindcss": "^3.4.1",
"tsconfig": "workspace:*"
"tsconfig": "workspace:*",
"vitest": "^4.1.0"
}
}
13 changes: 13 additions & 0 deletions apps/web/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { defineConfig } from "vitest/config";
import path from "path";

export default defineConfig({
test: {
globals: true,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "."),
},
},
});
Loading