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:
@@ -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}
|
||||
|
||||
20
src/components/PriceBadge.tsx
Normal file
20
src/components/PriceBadge.tsx
Normal 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;
|
||||
@@ -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
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user