Unverified 提交 72acb31d authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

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. -->
上级 7150082f
......@@ -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}
......
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,26 +37,40 @@ 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) => (
{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>
{!isAnyProviderSetup() && <SetupBanner />}
</div>
)}
{!isStreaming && (
......@@ -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" />
......
......@@ -167,15 +167,24 @@ export const MODEL_OPTIONS: Record<string, ModelOption[]> = {
],
openrouter: [
{
name: "qwen/qwen3-coder",
displayName: "Qwen3 Coder",
description: "Qwen's best coding model",
name: "qwen/qwen3-coder:free",
displayName: "Qwen3 Coder (free)",
description: "Use for free (data may be used for training)",
maxOutputTokens: 32_000,
contextWindow: 262_000,
temperature: 0,
dollarSigns: 2,
dollarSigns: 0,
},
// https://openrouter.ai/deepseek/deepseek-chat-v3-0324:free
{
name: "deepseek/deepseek-chat-v3.1:free",
displayName: "DeepSeek v3.1 (free)",
description: "Use for free (data may be used for training)",
maxOutputTokens: 32_000,
contextWindow: 128_000,
temperature: 0,
dollarSigns: 0,
},
{
name: "deepseek/deepseek-chat-v3-0324:free",
displayName: "DeepSeek v3 (free)",
......@@ -183,27 +192,36 @@ export const MODEL_OPTIONS: Record<string, ModelOption[]> = {
maxOutputTokens: 32_000,
contextWindow: 128_000,
temperature: 0,
dollarSigns: 2,
dollarSigns: 0,
},
// https://openrouter.ai/moonshotai/kimi-k2
{
name: "moonshotai/kimi-k2",
displayName: "Kimi K2",
description: "Powerful cost-effective model",
name: "qwen/qwen3-coder",
displayName: "Qwen3 Coder",
description: "Qwen's best coding model",
maxOutputTokens: 32_000,
contextWindow: 131_000,
contextWindow: 262_000,
temperature: 0,
dollarSigns: 2,
},
{
name: "deepseek/deepseek-r1-0528",
displayName: "DeepSeek R1",
description: "Good reasoning model with excellent price for performance",
name: "deepseek/deepseek-chat-v3.1",
displayName: "DeepSeek v3.1",
description: "Strong cost-effective model with optional thinking",
maxOutputTokens: 32_000,
contextWindow: 128_000,
temperature: 0,
dollarSigns: 2,
},
// https://openrouter.ai/moonshotai/kimi-k2
{
name: "moonshotai/kimi-k2-0905",
displayName: "Kimi K2",
description: "Powerful cost-effective model (updated to 0905)",
maxOutputTokens: 32_000,
contextWindow: 256_000,
temperature: 0,
dollarSigns: 2,
},
],
auto: [
{
......@@ -218,6 +236,18 @@ export const MODEL_OPTIONS: Record<string, ModelOption[]> = {
contextWindow: 1_000_000,
temperature: 0,
},
{
name: "free",
displayName: "Free (OpenRouter)",
description: "Selects from one of the free OpenRouter models",
tag: "Free",
// These are below Gemini 2.5 Pro & Flash limits
// which are the ones defaulted to for both regular auto
// and smart auto.
maxOutputTokens: 32_000,
contextWindow: 128_000,
temperature: 0,
},
],
azure: [
{
......@@ -311,6 +341,10 @@ export const MODEL_OPTIONS: Record<string, ModelOption[]> = {
],
};
export const FREE_OPENROUTER_MODEL_NAMES = MODEL_OPTIONS.openrouter
.filter((model) => model.name.endsWith(":free"))
.map((model) => model.name);
export const PROVIDER_TO_ENV_VAR: Record<string, string> = {
openai: "OPENAI_API_KEY",
anthropic: "ANTHROPIC_API_KEY",
......
差异被折叠。
......@@ -4,6 +4,7 @@ import { createAnthropic } from "@ai-sdk/anthropic";
import { createXai } from "@ai-sdk/xai";
import { createVertex as createGoogleVertex } from "@ai-sdk/google-vertex";
import { azure } from "@ai-sdk/azure";
import { LanguageModelV2 } from "@ai-sdk/provider";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock";
......@@ -14,14 +15,17 @@ import type {
} from "../../lib/schemas";
import { getEnvVar } from "./read_env";
import log from "electron-log";
import { getLanguageModelProviders } from "../shared/language_model_helpers";
import {
FREE_OPENROUTER_MODEL_NAMES,
getLanguageModelProviders,
} from "../shared/language_model_helpers";
import { LanguageModelProvider } from "../ipc_types";
import { createDyadEngine } from "./llm_engine_provider";
import { LM_STUDIO_BASE_URL } from "./lm_studio_utils";
import { LanguageModel } from "ai";
import { createOllamaProvider } from "./ollama_provider";
import { getOllamaApiUrl } from "../handlers/local_model_ollama_handler";
import { createFallback } from "./fallback_ai_model";
const dyadEngineUrl = process.env.DYAD_ENGINE_URL;
const dyadGatewayUrl = process.env.DYAD_GATEWAY_URL;
......@@ -31,6 +35,10 @@ const AUTO_MODELS = [
provider: "google",
name: "gemini-2.5-flash",
},
{
provider: "openrouter",
name: "qwen/qwen3-coder:free",
},
{
provider: "anthropic",
name: "claude-sonnet-4-20250514",
......@@ -42,7 +50,7 @@ const AUTO_MODELS = [
];
export interface ModelClient {
model: LanguageModel;
model: LanguageModelV2;
builtinProviderId?: string;
}
......@@ -142,6 +150,30 @@ export async function getModelClient(
}
// Handle 'auto' provider by trying each model in AUTO_MODELS until one works
if (model.provider === "auto") {
if (model.name === "free") {
const openRouterProvider = allProviders.find(
(p) => p.id === "openrouter",
);
if (!openRouterProvider) {
throw new Error("OpenRouter provider not found");
}
return {
modelClient: {
model: createFallback({
models: FREE_OPENROUTER_MODEL_NAMES.map(
(name: string) =>
getRegularModelClient(
{ provider: "openrouter", name },
settings,
openRouterProvider,
).modelClient.model,
),
}),
builtinProviderId: "openrouter",
},
isEngineEnabled: false,
};
}
for (const autoModel of AUTO_MODELS) {
const providerInfo = allProviders.find(
(p) => p.id === autoModel.provider,
......
import { LanguageModel } from "ai";
import { OpenAICompatibleChatLanguageModel } from "@ai-sdk/openai-compatible";
import {
FetchFunction,
......@@ -9,6 +8,7 @@ import {
import log from "electron-log";
import { getExtraProviderOptions } from "./thinking_utils";
import type { UserSettings } from "../../lib/schemas";
import { LanguageModelV2 } from "@ai-sdk/provider";
const logger = log.scope("llm_engine_provider");
......@@ -53,7 +53,10 @@ export interface DyadEngineProvider {
/**
Creates a model for text generation.
*/
(modelId: ExampleChatModelId, settings?: ExampleChatSettings): LanguageModel;
(
modelId: ExampleChatModelId,
settings?: ExampleChatSettings,
): LanguageModelV2;
/**
Creates a chat model for text generation.
......@@ -61,7 +64,7 @@ Creates a chat model for text generation.
chatModel(
modelId: ExampleChatModelId,
settings?: ExampleChatSettings,
): LanguageModel;
): LanguageModelV2;
}
export function createDyadEngine(
......
import { LanguageModel } from "ai";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import type { FetchFunction } from "@ai-sdk/provider-utils";
import { withoutTrailingSlash } from "@ai-sdk/provider-utils";
import type {} from "@ai-sdk/provider";
import type { LanguageModelV2 } from "@ai-sdk/provider";
type OllamaChatModelId = string;
......@@ -20,7 +19,7 @@ export interface OllamaProviderOptions {
export interface OllamaChatSettings {}
export interface OllamaProvider {
(modelId: OllamaChatModelId, settings?: OllamaChatSettings): LanguageModel;
(modelId: OllamaChatModelId, settings?: OllamaChatSettings): LanguageModelV2;
}
export function createOllamaProvider(
......
......@@ -179,24 +179,6 @@ export const INSPIRATION_PROMPTS = [
),
label: "AI Writing Assistant",
},
{
icon: (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
label: "Emoji Translator",
},
{
icon: (
<svg
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论