More free models (#1244)

<!-- This is an auto-generated description by cubic. -->

## Summary by cubic
Adds support for free OpenRouter models and a new “Free (OpenRouter)”
auto option that fails over across free models for reliability. Improves
setup flow and UI with provider cards, a “Free” price badge, and an
OpenRouter setup prompt in chat.

- **New Features**
- Added OpenRouter free models: Qwen3 Coder (free), DeepSeek v3 (free),
DeepSeek v3.1 (free), marked with dollarSigns=0 and a “Free” badge.
- New auto model: “Free (OpenRouter)” that uses a fallback client to
cycle through free models with smart retry on transient errors.
- New SetupProviderCard component and updated SetupBanner with dedicated
Google and OpenRouter setup cards.
- Chat shows an OpenRouter setup prompt when “Free (OpenRouter)” is
selected and OpenRouter isn’t configured.
- New PriceBadge component in ModelPicker to display “Free” or price
tier.
- E2E: added setup flow test and option to show the setup screen in
tests.
- Model updates: added DeepSeek v3.1, updated Kimi K2 to kimi-k2-0905,
migrated providers to LanguageModelV2.

<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Will Chen
2025-09-10 14:20:17 -07:00
committed by GitHub
parent 7150082f5a
commit 72acb31d59
11 changed files with 573 additions and 72 deletions

View File

@@ -24,6 +24,7 @@ import { useLanguageModelsByProviders } from "@/hooks/useLanguageModelsByProvide
import { LocalModel } from "@/ipc/ipc_types";
import { useLanguageModelProviders } from "@/hooks/useLanguageModelProviders";
import { useSettings } from "@/hooks/useSettings";
import { PriceBadge } from "@/components/PriceBadge";
export function ModelPicker() {
const { settings, updateSettings } = useSettings();
@@ -106,7 +107,16 @@ export function ModelPicker() {
// Get auto provider models (if any)
const autoModels =
!loading && modelsByProviders && modelsByProviders["auto"]
? modelsByProviders["auto"]
? modelsByProviders["auto"].filter((model) => {
if (
settings &&
isDyadProEnabled(settings) &&
model.apiName === "free"
) {
return false;
}
return true;
})
: [];
// Determine availability of local models
@@ -251,6 +261,18 @@ export function ModelPicker() {
{/* Primary providers as submenus */}
{primaryProviders.map(([providerId, models]) => {
models = models.filter((model) => {
// Don't show free models if Dyad Pro is enabled because
// we will use the paid models (in Dyad Pro backend) which
// don't have the free limitations.
if (
isDyadProEnabled(settings) &&
model.apiName.endsWith(":free")
) {
return false;
}
return true;
});
const provider = providers?.find((p) => p.id === providerId);
return (
<DropdownMenuSub key={providerId}>
@@ -304,11 +326,7 @@ export function ModelPicker() {
>
<div className="flex justify-between items-start w-full">
<span>{model.displayName}</span>
{model.dollarSigns && (
<span className="text-[10px] bg-primary/10 text-primary px-1.5 py-0.5 rounded-full font-medium">
{"$".repeat(model.dollarSigns)}
</span>
)}
<PriceBadge dollarSigns={model.dollarSigns} />
{model.tag && (
<span className="text-[10px] bg-primary/10 text-primary px-1.5 py-0.5 rounded-full font-medium">
{model.tag}

View File

@@ -0,0 +1,20 @@
import React from "react";
export function PriceBadge({
dollarSigns,
}: {
dollarSigns: number | undefined;
}) {
if (dollarSigns === undefined || dollarSigns === null) return null;
const label = dollarSigns === 0 ? "Free" : "$".repeat(dollarSigns);
const className =
dollarSigns === 0
? "text-[10px] text-primary border border-primary px-1.5 py-0.5 rounded-full font-medium"
: "text-[10px] bg-primary/10 text-primary px-1.5 py-0.5 rounded-full font-medium";
return <span className={className}>{label}</span>;
}
export default PriceBadge;

View File

@@ -133,9 +133,7 @@ export function SetupBanner() {
return (
<>
<p className="text-xl text-zinc-700 dark:text-zinc-300 p-4">
Follow these steps and you'll be ready to start building with Dyad...
</p>
<p className="text-xl text-zinc-700 dark:text-zinc-300 p-4">Setup Dyad</p>
<div className={bannerClasses}>
<Accordion
type="multiple"
@@ -367,3 +365,36 @@ function NodeInstallButton({
const _exhaustiveCheck: never = nodeInstallStep;
}
}
export const OpenRouterSetupBanner = ({
className,
}: {
className?: string;
}) => {
const posthog = usePostHog();
const navigate = useNavigate();
return (
<SetupProviderCard
className={cn("mt-2", className)}
variant="openrouter"
onClick={() => {
posthog.capture("setup-flow:ai-provider-setup:openrouter:click");
navigate({
to: providerSettingsRoute.id,
params: { provider: "openrouter" },
});
}}
tabIndex={0}
leadingIcon={
<Sparkles className="w-4 h-4 text-purple-600 dark:text-purple-400" />
}
title="Setup OpenRouter API Key"
subtitle={
<>
<GiftIcon className="w-3 h-3" />
Free models available
</>
}
/>
);
};

View File

@@ -2,7 +2,7 @@ import type React from "react";
import type { Message } from "@/ipc/ipc_types";
import { forwardRef, useState } from "react";
import ChatMessage from "./ChatMessage";
import { SetupBanner } from "../SetupBanner";
import { OpenRouterSetupBanner, SetupBanner } from "../SetupBanner";
import { useStreamChat } from "@/hooks/useStreamChat";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
@@ -29,7 +29,7 @@ export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
const appId = useAtomValue(selectedAppIdAtom);
const { versions, revertVersion } = useVersions(appId);
const { streamMessage, isStreaming } = useStreamChat();
const { isAnyProviderSetup } = useLanguageModelProviders();
const { isAnyProviderSetup, isProviderSetup } = useLanguageModelProviders();
const { settings } = useSettings();
const setMessages = useSetAtom(chatMessagesAtom);
const [isUndoLoading, setIsUndoLoading] = useState(false);
@@ -37,28 +37,42 @@ export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
const selectedChatId = useAtomValue(selectedChatIdAtom);
const { userBudget } = useUserBudgetInfo();
const renderSetupBanner = () => {
const selectedModel = settings?.selectedModel;
if (
selectedModel?.name === "free" &&
selectedModel?.provider === "auto" &&
!isProviderSetup("openrouter")
) {
return <OpenRouterSetupBanner className="w-full" />;
}
if (!isAnyProviderSetup()) {
return <SetupBanner />;
}
return null;
};
return (
<div
className="flex-1 overflow-y-auto p-4"
ref={ref}
data-testid="messages-list"
>
{messages.length > 0 ? (
messages.map((message, index) => (
<ChatMessage
key={index}
message={message}
isLastMessage={index === messages.length - 1}
/>
))
) : (
<div className="flex flex-col items-center justify-center h-full max-w-2xl mx-auto">
<div className="flex flex-1 items-center justify-center text-gray-500">
No messages yet
</div>
{!isAnyProviderSetup() && <SetupBanner />}
</div>
)}
{messages.length > 0
? messages.map((message, index) => (
<ChatMessage
key={index}
message={message}
isLastMessage={index === messages.length - 1}
/>
))
: !renderSetupBanner() && (
<div className="flex flex-col items-center justify-center h-full max-w-2xl mx-auto">
<div className="flex flex-1 items-center justify-center text-gray-500">
No messages yet
</div>
</div>
)}
{!isStreaming && (
<div className="flex max-w-3xl mx-auto gap-2">
{!!messages.length &&
@@ -230,6 +244,7 @@ export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
/>
)}
<div ref={messagesEndRef} />
{renderSetupBanner()}
</div>
);
},

View File

@@ -98,8 +98,8 @@ export function ProviderSettingsHeader({
{providerWebsiteUrl && !isLoading && (
<Button
onClick={handleGetApiKeyClick}
className="mb-4 bg-(--background-lightest) cursor-pointer py-5"
variant="outline"
className="mb-4 cursor-pointer py-5 w-full"
// variant="primary"
>
{isConfigured ? (
<SettingsIcon className="mr-2 h-4 w-4" />