Skip to content

Commit 91ff66c

Browse files
authored
Merge pull request #71 from homanp/feat/add-free-models-via-openrouter
feat: add support for free models via openrouter
2 parents 92f2201 + c98246a commit 91ff66c

File tree

11 files changed

+523
-42
lines changed

11 files changed

+523
-42
lines changed

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# You can also enter keys directly in the UI (BYOK).
33

44
# --- AI Providers (pick one or more) ---
5+
OPENROUTER_API_KEY= # shared starter key (set in Railway and local .env)
56
ANTHROPIC_API_KEY=
67
OPENAI_API_KEY=
78
GOOGLE_GENERATIVE_AI_API_KEY=
@@ -18,6 +19,13 @@ CEREBRAS_API_KEY=
1819
MOONSHOT_API_KEY=
1920
ALIBABA_API_KEY=
2021

22+
# Optional OpenRouter headers / guardrails
23+
OPENROUTER_APP_URL= # optional HTTP-Referer header for OpenRouter
24+
OPENROUTER_APP_NAME= # optional X-Title header for OpenRouter
25+
OPENROUTER_STARTER_DISABLED=0 # set to 1 to disable shared starter access
26+
OPENROUTER_STARTER_RATE_LIMIT=30
27+
OPENROUTER_STARTER_WINDOW_MS=60000
28+
2129
# --- Share / Publish ---
2230
DURABLE_STREAM_BASE_URL= # defaults to https://stream.tonbo.dev
2331
SHARE_ID_SECRET= # secret for deriving stable share IDs

src/app/api/chat/route.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { streamText, stepCountIs, tool } from "ai";
22
import { createMCPClient } from "@ai-sdk/mcp";
33
import { z } from "zod";
44
import { createModel, isAnthropicModel } from "@/lib/create-model";
5+
import { DEFAULT_MODEL } from "@/lib/model-registry";
6+
import { OpenRouterAccessError, resolveOpenRouterApiKey } from "@/lib/openrouter";
57
import type { CustomApiConfig } from "@/store/settings-store";
68
import { Bash } from "just-bash";
79
import { createBashTool } from "bash-tool";
@@ -17,7 +19,6 @@ import {
1719
} from "@/db/widgets";
1820
import { webSearch, type SearchProvider } from "@/lib/web-search";
1921
import { scanUrls } from "@/lib/brin";
20-
import { nanoid } from "nanoid";
2122
import { maybeCreateTraceRecorder, publishDashboardStateForWidgetIfShared } from "@/lib/share-recorder";
2223
import type { SharedChatMessage } from "@/lib/share-types";
2324

@@ -195,8 +196,21 @@ export async function POST(request: Request) {
195196
const traceRecorder = await maybeCreateTraceRecorder(widgetId).catch(() => null);
196197
traceRecorder?.startRun();
197198

198-
const selectedModel = modelStr ?? "anthropic:claude-sonnet-4-6";
199+
const selectedModel = modelStr ?? DEFAULT_MODEL;
199200
const useAnthropic = isAnthropicModel(selectedModel);
201+
let resolvedApiKey: string | undefined;
202+
203+
try {
204+
resolvedApiKey = resolveOpenRouterApiKey(selectedModel, apiKey, request, {
205+
route: "chat",
206+
widgetId,
207+
});
208+
} catch (error) {
209+
if (error instanceof OpenRouterAccessError) {
210+
return Response.json({ error: error.message }, { status: error.status });
211+
}
212+
throw error;
213+
}
200214

201215
// Prepare custom API config if using a custom provider
202216
const customConfig: CustomApiConfig | undefined = customApi
@@ -360,7 +374,7 @@ export async function POST(request: Request) {
360374
}
361375

362376
const result = streamText({
363-
model: createModel(selectedModel, apiKey, customConfig),
377+
model: createModel(selectedModel, resolvedApiKey, customConfig),
364378
system: SYSTEM_PROMPT,
365379
// eslint-disable-next-line @typescript-eslint/no-explicit-any
366380
messages: messages as any,

src/app/api/generate-title/route.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { generateText } from "ai";
22
import { createModel } from "@/lib/create-model";
3+
import { DEFAULT_MODEL } from "@/lib/model-registry";
4+
import { resolveOpenRouterApiKey } from "@/lib/openrouter";
35

46
export async function POST(request: Request) {
57
const { message, model, apiKey } = (await request.json()) as {
@@ -13,8 +15,13 @@ export async function POST(request: Request) {
1315
}
1416

1517
try {
18+
const selectedModel = model ?? DEFAULT_MODEL;
19+
const resolvedApiKey = resolveOpenRouterApiKey(selectedModel, apiKey, request, {
20+
route: "generate-title",
21+
});
22+
1623
const { text } = await generateText({
17-
model: createModel(model ?? "anthropic:claude-haiku-4-5", apiKey),
24+
model: createModel(selectedModel, resolvedApiKey),
1825
prompt: `Generate a short widget title (2-4 words max, no punctuation) for this request: "${message}". Reply with only the title, nothing else.`,
1926
});
2027

src/app/page.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
"use client";
2-
31
import { DashboardGrid } from "@/components/dashboard-grid";
42
import { ChatSidebar } from "@/components/chat-sidebar";
53
import { AddMenu } from "@/components/add-menu";
64
import { DashboardPicker } from "@/components/dashboard-picker";
75
import { ShareDashboardButton } from "@/components/share-dashboard-button";
86
import { AppHeader } from "@/components/app-header";
7+
import { hasOpenRouterStarter } from "@/lib/openrouter";
8+
9+
export const dynamic = "force-dynamic";
910

1011
export default function Home() {
1112
return (
@@ -18,7 +19,7 @@ export default function Home() {
1819
</AppHeader>
1920
<DashboardGrid />
2021
</div>
21-
<ChatSidebar />
22+
<ChatSidebar hasOpenRouterStarter={hasOpenRouterStarter()} />
2223
</div>
2324
);
2425
}

src/components/chat-sidebar.tsx

Lines changed: 75 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,13 @@ import { SearchProviderPicker } from "@/components/search-provider-picker";
4747
import { McpConfigDialog } from "@/components/mcp-config-dialog";
4848
import { CustomApiDialog } from "@/components/custom-api-dialog";
4949
import {
50-
PROVIDERS,
50+
getBuiltinProviders,
5151
parseModelString,
5252
createCustomProviderInfo,
5353
isCustomProvider,
5454
CUSTOM_PROVIDER_PREFIX,
55+
OPENROUTER_PROVIDER_ID,
56+
OPENROUTER_STARTER_MODELS,
5557
} from "@/lib/model-registry";
5658
import { Switch } from "@/components/ui/switch";
5759

@@ -108,6 +110,7 @@ function KittLoader() {
108110
const abortControllers = new Map<string, AbortController>();
109111
const MAX_RETRIES = 2;
110112
const RETRY_DELAY_MS = 1500;
113+
const OPENROUTER_STARTER_MODEL_IDS = new Set(OPENROUTER_STARTER_MODELS.map((model) => model.id));
111114

112115
function ReasoningBlock({
113116
text,
@@ -461,7 +464,7 @@ async function streamToWidget(
461464
}
462465
}
463466

464-
function useModelSelector() {
467+
function useModelSelector({ hasOpenRouterStarter }: { hasOpenRouterStarter: boolean }) {
465468
const selectedModel = useSettingsStore((s) => s.selectedModel);
466469
const setModel = useSettingsStore((s) => s.setModel);
467470
const apiKeys = useSettingsStore((s) => s.apiKeys);
@@ -475,27 +478,43 @@ function useModelSelector() {
475478
const [customApiOpen, setCustomApiOpen] = useState(false);
476479

477480
const { providerId, modelId } = parseModelString(selectedModel);
481+
const hasOpenRouterByokKey = !!apiKeys[OPENROUTER_PROVIDER_ID];
478482

479483
const allProviders = useMemo(() => {
484+
const builtInProviders = getBuiltinProviders({
485+
includeOpenRouterByokModels: hasOpenRouterByokKey,
486+
});
480487
const customProviders = customApis
481488
.filter((c) => c.enabled)
482489
.map((c) => createCustomProviderInfo(c));
483-
return [...PROVIDERS, ...customProviders];
484-
}, [customApis]);
490+
return [...builtInProviders, ...customProviders];
491+
}, [customApis, hasOpenRouterByokKey]);
485492

486493
const provider = allProviders.find((p) => p.id === providerId);
487494
const model = provider?.models.find((m) => m.id === modelId);
495+
const isOpenRouterStarterModel = providerId === OPENROUTER_PROVIDER_ID
496+
&& OPENROUTER_STARTER_MODEL_IDS.has(modelId);
488497

489-
const hasKey = isCustomProvider(providerId)
498+
const hasSavedKey = isCustomProvider(providerId)
490499
? !!customApis.find((c) => `${CUSTOM_PROVIDER_PREFIX}${c.id}` === providerId)?.apiKey
491500
: !!apiKeys[providerId];
501+
const hasStarterAccess = isOpenRouterStarterModel && hasOpenRouterStarter;
502+
const hasAccess = hasSavedKey || hasStarterAccess;
503+
const showOpenRouterStarterNotice = isOpenRouterStarterModel
504+
&& hasOpenRouterStarter
505+
&& !hasSavedKey
506+
&& !showKeyInput;
492507

493508
const handleSelect = (newModel: string) => {
494509
setModel(newModel);
495510
setOpen(false);
496-
const { providerId: pid } = parseModelString(newModel);
511+
const { providerId: pid, modelId: nextModelId } = parseModelString(newModel);
512+
const isStarterOpenRouterModel = pid === OPENROUTER_PROVIDER_ID
513+
&& OPENROUTER_STARTER_MODEL_IDS.has(nextModelId);
497514
if (isCustomProvider(pid)) {
498515
setShowKeyInput(false);
516+
} else if (isStarterOpenRouterModel && hasOpenRouterStarter && !apiKeys[pid]) {
517+
setShowKeyInput(false);
499518
} else if (!apiKeys[pid]) {
500519
setShowKeyInput(true);
501520
} else {
@@ -526,7 +545,7 @@ function useModelSelector() {
526545
<ModelSelectorLogo provider={providerId as "anthropic"} className="size-3.5" />
527546
)}
528547
<span>{model?.name ?? modelId}</span>
529-
{!hasKey && !isCustomProvider(providerId) && (
548+
{!hasAccess && !isCustomProvider(providerId) && (
530549
<span className="size-1.5 rounded-full bg-yellow-500/70 shrink-0" />
531550
)}
532551
</ModelSelectorTrigger>
@@ -549,6 +568,11 @@ function useModelSelector() {
549568
<ModelSelectorLogo provider={p.id as "anthropic"} />
550569
)}
551570
<ModelSelectorName>{m.name}</ModelSelectorName>
571+
{p.id === OPENROUTER_PROVIDER_ID && OPENROUTER_STARTER_MODEL_IDS.has(m.id) && (
572+
<span className="shrink-0 border border-emerald-500/30 bg-emerald-500/10 px-1.5 py-0.5 text-[9px] uppercase tracking-wider text-emerald-300">
573+
Free
574+
</span>
575+
)}
552576
</ModelSelectorItem>
553577
))}
554578
</ModelSelectorGroup>
@@ -595,30 +619,51 @@ function useModelSelector() {
595619
</>
596620
);
597621

598-
const keyInputEl = !isCustomProvider(providerId) && (showKeyInput || !hasKey) ? (
599-
<div className="flex items-center gap-1.5">
600-
<input
601-
type="password"
602-
value={keyInput}
603-
onChange={(e) => setKeyInput(e.target.value)}
604-
onKeyDown={(e) => e.key === "Enter" && handleSaveKey()}
605-
placeholder={`${provider?.name ?? providerId} API key...`}
606-
className="flex-1 bg-zinc-900 border border-zinc-800 text-xs px-2.5 py-1.5 text-zinc-300 placeholder:text-zinc-600 focus:outline-none focus:border-zinc-600"
607-
/>
608-
<button
609-
onClick={handleSaveKey}
610-
className="px-2.5 py-1.5 text-xs uppercase tracking-wider bg-zinc-800 hover:bg-zinc-700 text-zinc-300 transition-colors"
611-
>
612-
Save
613-
</button>
614-
</div>
615-
) : null;
622+
const shouldShowKeyInput = !isCustomProvider(providerId) && (showKeyInput || !hasAccess);
623+
624+
const keyInputEl = !isCustomProvider(providerId)
625+
? shouldShowKeyInput
626+
? (
627+
<div className="flex items-center gap-1.5">
628+
<input
629+
type="password"
630+
value={keyInput}
631+
onChange={(e) => setKeyInput(e.target.value)}
632+
onKeyDown={(e) => e.key === "Enter" && handleSaveKey()}
633+
placeholder={`${provider?.name ?? providerId} API key...`}
634+
className="flex-1 bg-zinc-900 border border-zinc-800 text-xs px-2.5 py-1.5 text-zinc-300 placeholder:text-zinc-600 focus:outline-none focus:border-zinc-600"
635+
/>
636+
<button
637+
onClick={handleSaveKey}
638+
className="px-2.5 py-1.5 text-xs uppercase tracking-wider bg-zinc-800 hover:bg-zinc-700 text-zinc-300 transition-colors"
639+
>
640+
Save
641+
</button>
642+
</div>
643+
)
644+
: showOpenRouterStarterNotice
645+
? (
646+
<div className="flex items-center justify-between gap-3 border border-zinc-800 bg-zinc-950 px-2.5 py-2 text-[11px] text-zinc-400">
647+
<span>Starter OpenRouter models work out of the box. Add your own key to unlock frontier models.</span>
648+
<button
649+
type="button"
650+
onClick={() => setShowKeyInput(true)}
651+
className="shrink-0 px-2 py-1 text-[10px] uppercase tracking-wider bg-zinc-800 hover:bg-zinc-700 text-zinc-200 transition-colors"
652+
>
653+
Add key
654+
</button>
655+
</div>
656+
)
657+
: null
658+
: null;
616659

617660
return { trigger, keyInputEl };
618661
}
619662

620-
export function ChatSidebar() {
621-
const { trigger: modelTrigger, keyInputEl: modelKeyInput } = useModelSelector();
663+
export function ChatSidebar({ hasOpenRouterStarter = false }: { hasOpenRouterStarter?: boolean }) {
664+
const { trigger: modelTrigger, keyInputEl: modelKeyInput } = useModelSelector({
665+
hasOpenRouterStarter,
666+
});
622667
const [mcpOpen, setMcpOpen] = useState(false);
623668
const mcpServers = useSettingsStore((s) => s.mcpServers);
624669
const enabledMcpCount = mcpServers.filter((s) => s.enabled).length;
@@ -643,10 +688,10 @@ export function ChatSidebar() {
643688
? streamingWidgetIds.includes(activeWidgetId)
644689
: false;
645690

646-
const handleInterrupt = () => {
691+
const handleInterrupt = useCallback(() => {
647692
if (!activeWidgetId) return;
648693
abortControllers.get(activeWidgetId)?.abort();
649-
};
694+
}, [activeWidgetId]);
650695

651696
useEffect(() => {
652697
if (!isActiveStreaming) return;
@@ -655,7 +700,7 @@ export function ChatSidebar() {
655700
};
656701
document.addEventListener("keydown", onKey);
657702
return () => document.removeEventListener("keydown", onKey);
658-
}, [isActiveStreaming, activeWidgetId]);
703+
}, [handleInterrupt, isActiveStreaming]);
659704

660705
const [input, setInput] = useState("");
661706
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);

src/lib/__tests__/create-model.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ describe("isAnthropicModel", () => {
1414
expect(isAnthropicModel("openai:gpt-5.4")).toBe(false);
1515
expect(isAnthropicModel("google:gemini-2.5-pro")).toBe(false);
1616
expect(isAnthropicModel("xai:grok-3")).toBe(false);
17+
expect(isAnthropicModel("openrouter:qwen/qwen3-coder:free")).toBe(false);
1718
});
1819

1920
it("returns true for empty string (no colon)", () => {

src/lib/__tests__/model-registry.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import {
44
ALL_MODELS,
55
DEFAULT_MODEL,
66
findProvider,
7+
getBuiltinProviders,
8+
OPENROUTER_BYOK_MODELS,
9+
OPENROUTER_DEFAULT_STARTER_MODEL_ID,
10+
OPENROUTER_PROVIDER_ID,
11+
OPENROUTER_STARTER_MODELS,
712
parseModelString,
813
} from "@/lib/model-registry";
914

@@ -94,6 +99,49 @@ describe("PROVIDERS", () => {
9499
expect(modelIds.has("gpt-5.4-mini")).toBe(true);
95100
expect(modelIds.has("gpt-5.4-nano")).toBe(true);
96101
});
102+
103+
it("registers OpenRouter with starter and BYOK models", () => {
104+
const openRouter = findProvider(OPENROUTER_PROVIDER_ID);
105+
expect(openRouter).toBeDefined();
106+
107+
const modelIds = new Set(openRouter!.models.map((m) => m.id));
108+
expect(modelIds.has("qwen/qwen3.6-plus-preview:free")).toBe(true);
109+
expect(modelIds.has("nvidia/nemotron-3-super-120b-a12b:free")).toBe(true);
110+
expect(modelIds.has("minimax/minimax-m2.5:free")).toBe(true);
111+
expect(modelIds.has("qwen/qwen3-coder:free")).toBe(true);
112+
expect(modelIds.has("z-ai/glm-4.5-air:free")).toBe(true);
113+
expect(modelIds.has("anthropic/claude-sonnet-4.6")).toBe(true);
114+
});
115+
});
116+
117+
describe("getBuiltinProviders", () => {
118+
it("limits OpenRouter to starter models without a BYOK key", () => {
119+
const openRouter = getBuiltinProviders({
120+
includeOpenRouterByokModels: false,
121+
}).find((provider) => provider.id === OPENROUTER_PROVIDER_ID);
122+
123+
expect(openRouter).toBeDefined();
124+
expect(openRouter!.models).toEqual(OPENROUTER_STARTER_MODELS);
125+
expect(openRouter!.models.map((model) => model.id)).toEqual([
126+
"qwen/qwen3.6-plus-preview:free",
127+
"nvidia/nemotron-3-super-120b-a12b:free",
128+
"minimax/minimax-m2.5:free",
129+
"qwen/qwen3-coder:free",
130+
"z-ai/glm-4.5-air:free",
131+
]);
132+
});
133+
134+
it("includes the expanded OpenRouter catalog with a BYOK key", () => {
135+
const openRouter = getBuiltinProviders({
136+
includeOpenRouterByokModels: true,
137+
}).find((provider) => provider.id === OPENROUTER_PROVIDER_ID);
138+
139+
expect(openRouter).toBeDefined();
140+
expect(openRouter!.models).toEqual([
141+
...OPENROUTER_STARTER_MODELS,
142+
...OPENROUTER_BYOK_MODELS,
143+
]);
144+
});
97145
});
98146

99147
describe("ALL_MODELS", () => {
@@ -118,4 +166,8 @@ describe("DEFAULT_MODEL", () => {
118166
const model = provider!.models.find((m) => m.id === modelId);
119167
expect(model).toBeDefined();
120168
});
169+
170+
it("defaults new sessions to OpenRouter starter access", () => {
171+
expect(DEFAULT_MODEL).toBe(`openrouter:${OPENROUTER_DEFAULT_STARTER_MODEL_ID}`);
172+
});
121173
});

0 commit comments

Comments
 (0)