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

Add dynamic language model catalog support (#2914)

## Summary - fetch the builtin language model catalog from api.dyad.sh with local fallback data and alias resolution - migrate theme generation, auto mode, and help bot to use dynamic catalog aliases - add E2E coverage for both remote-catalog and fallback behavior ## Test plan - npm run fmt - npm run lint:fix - npm run ts - npm test - PLAYWRIGHT_HTML_OPEN=never npm run e2e -- e2e-tests/dynamic_models.spec.ts (run outside sandbox) 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2914" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarclaude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
上级 ea36c831
import { expect } from "@playwright/test";
import { testWithConfig } from "./helpers/test_helper";
const testWithRemoteCatalog = testWithConfig({
preLaunchHook: async ({ fakeLlmPort }) => {
process.env.DYAD_LANGUAGE_MODEL_CATALOG_URL = `http://localhost:${fakeLlmPort}/api/language-model-catalog`;
},
postLaunchHook: async () => {
delete process.env.DYAD_LANGUAGE_MODEL_CATALOG_URL;
},
});
const testWithFallbackCatalog = testWithConfig({
preLaunchHook: async ({ fakeLlmPort }) => {
process.env.DYAD_LANGUAGE_MODEL_CATALOG_URL = `http://localhost:${fakeLlmPort}/missing-language-model-catalog`;
},
postLaunchHook: async () => {
delete process.env.DYAD_LANGUAGE_MODEL_CATALOG_URL;
},
});
testWithRemoteCatalog(
"dynamic models - uses remote catalog when API is available",
async ({ po }) => {
await po.setUp();
await po.page.getByTestId("model-picker").click();
await po.page.getByText("OpenAI", { exact: true }).click();
await expect(po.page.getByText("GPT 5.2", { exact: true })).toBeVisible();
await expect(
po.page.getByText("GPT 5.2 Remote Only", { exact: true }),
).toBeVisible();
await po.navigation.goToLibraryTab();
await po.page.getByRole("link", { name: "Themes" }).click();
await po.page.getByRole("button", { name: "New Theme" }).click();
await expect(
po.page.getByRole("dialog").getByText("Create Custom Theme"),
).toBeVisible();
await expect(
po.page.getByText("Google Remote", { exact: true }),
).toBeVisible();
await expect(
po.page.getByText("Anthropic Remote", { exact: true }),
).toBeVisible();
await expect(
po.page.getByText("OpenAI Remote", { exact: true }),
).toBeVisible();
},
);
testWithFallbackCatalog(
"dynamic models - falls back to local catalog when API is unavailable",
async ({ po }) => {
await po.setUp();
await po.page.getByTestId("model-picker").click();
await po.page.getByText("OpenAI", { exact: true }).click();
await expect(po.page.getByText("GPT 5.2", { exact: true })).toBeVisible();
await expect(
po.page.getByText("GPT 5.2 Remote Only", { exact: true }),
).not.toBeVisible();
await expect(
po.page.getByText("GPT 5.2 Remote", { exact: true }),
).not.toBeVisible();
await po.navigation.goToLibraryTab();
await po.page.getByRole("link", { name: "Themes" }).click();
await po.page.getByRole("button", { name: "New Theme" }).click();
await expect(
po.page.getByRole("dialog").getByText("Create Custom Theme"),
).toBeVisible();
await expect(po.page.getByText("Google", { exact: true })).toBeVisible();
await expect(
po.page.getByText("Anthropic Remote", { exact: true }),
).not.toBeVisible();
await expect(
po.page.getByText("OpenAI Remote", { exact: true }),
).not.toBeVisible();
},
);
import { expect } from "@playwright/test";
import { testWithConfig, Timeout } from "./helpers/test_helper";
const testWithRealCatalog = testWithConfig({
preLaunchHook: async () => {
process.env.DYAD_LANGUAGE_MODEL_CATALOG_URL =
"https://api.dyad.sh/v1/language-model-catalog";
},
postLaunchHook: async () => {
delete process.env.DYAD_LANGUAGE_MODEL_CATALOG_URL;
},
});
testWithRealCatalog(
"dynamic models - loads real catalog from api.dyad.sh",
async ({ po }) => {
await po.setUp();
// Open model picker and wait for providers to load from real API
await po.page.getByTestId("model-picker").click();
// Wait for loading to finish (real API may take a moment)
await expect(po.page.getByText("Loading models...")).not.toBeVisible({
timeout: Timeout.MEDIUM,
});
// Verify primary providers appear from the real catalog
await expect(po.page.getByText("OpenAI", { exact: true })).toBeVisible();
await expect(po.page.getByText("Anthropic", { exact: true })).toBeVisible();
// Select OpenAI submenu and verify models submenu header appears
await po.page.getByText("OpenAI", { exact: true }).click();
await expect(po.page.getByText("OpenAI Models")).toBeVisible({
timeout: Timeout.SHORT,
});
// Close the model picker
await po.page.keyboard.press("Escape");
// Navigate to Themes and verify theme generation model options from real API
await po.navigation.goToLibraryTab();
await po.page.getByRole("link", { name: "Themes" }).click();
await po.page.getByRole("button", { name: "New Theme" }).click();
await expect(
po.page.getByRole("dialog").getByText("Create Custom Theme"),
).toBeVisible();
// Verify the "Model Selection" label is visible and at least one model
// option button is rendered from the real catalog
const dialog = po.page.getByRole("dialog");
await expect(dialog.getByText("Model Selection")).toBeVisible({
timeout: Timeout.MEDIUM,
});
// The real catalog provides 3 theme generation model options;
// verify at least one is rendered as a button after the label
await expect(dialog.getByText("Generate Theme Prompt")).toBeVisible();
},
);
...@@ -22,6 +22,7 @@ export interface ElectronConfig { ...@@ -22,6 +22,7 @@ export interface ElectronConfig {
userDataDir: string; userDataDir: string;
fakeLlmPort: number; fakeLlmPort: number;
}) => Promise<void>; }) => Promise<void>;
postLaunchHook?: () => Promise<void>;
showSetupScreen?: boolean; showSetupScreen?: boolean;
} }
...@@ -145,6 +146,9 @@ export const test = base.extend<{ ...@@ -145,6 +146,9 @@ export const test = base.extend<{
}); });
await use(electronApp); await use(electronApp);
if (electronConfig.postLaunchHook) {
await electronConfig.postLaunchHook();
}
// Why are we doing a force kill on Windows? // Why are we doing a force kill on Windows?
// //
// Otherwise, Playwright will just hang on the test cleanup // Otherwise, Playwright will just hang on the test cleanup
......
import { z } from "zod";
export const ProviderIdSchema = z.enum([
"openai",
"anthropic",
"google",
"vertex",
"openrouter",
"xai",
]);
export const ThemeGenerationAliasIdSchema = z.enum([
"dyad/theme-generator/google",
"dyad/theme-generator/anthropic",
"dyad/theme-generator/openai",
]);
export const AliasIdSchema = z.enum([
"dyad/theme-generator/google",
"dyad/theme-generator/anthropic",
"dyad/theme-generator/openai",
"dyad/auto/openai",
"dyad/auto/anthropic",
"dyad/auto/google",
"dyad/help-bot/default",
]);
export const CatalogProviderSchema = z.object({
id: ProviderIdSchema,
displayName: z.string(),
type: z.literal("cloud"),
hasFreeTier: z.boolean().optional(),
websiteUrl: z.string().url().optional(),
secondary: z.boolean().optional(),
supportsThinking: z.boolean().optional(),
gatewayPrefix: z.string().optional(),
});
export const CatalogModelSchema = z.object({
apiName: z.string(),
displayName: z.string(),
description: z.string(),
tag: z.string().optional(),
tagColor: z.string().optional(),
dollarSigns: z.number().int().nonnegative().optional(),
temperature: z.number().optional(),
maxOutputTokens: z.number().int().positive().optional(),
contextWindow: z.number().int().positive().optional(),
lifecycle: z
.object({
stage: z.enum(["stable", "preview", "deprecated"]).optional(),
})
.optional(),
});
export const CatalogAliasSchema = z.object({
id: AliasIdSchema,
resolvedModel: z.object({
providerId: ProviderIdSchema,
apiName: z.string(),
}),
displayName: z.string().optional(),
purpose: z.enum(["theme-generation", "auto-mode", "help-bot"]).optional(),
});
export const LanguageModelCatalogResponseSchema = z.object({
version: z.string(),
expiresAt: z.string().datetime(),
providers: z.array(CatalogProviderSchema),
modelsByProvider: z.record(z.string(), z.array(CatalogModelSchema)),
aliases: z.array(CatalogAliasSchema),
curatedSelections: z.object({
themeGenerationOptions: z.array(
z.object({
id: ThemeGenerationAliasIdSchema,
label: z.string(),
}),
),
}),
});
export type LanguageModelCatalogResponse = z.infer<
typeof LanguageModelCatalogResponseSchema
>;
const ONE_HOUR_IN_MS = 60 * 60 * 1000;
const providers = [
{
id: "openai",
displayName: "OpenAI",
type: "cloud",
websiteUrl: "https://platform.openai.com/docs/models",
supportsThinking: true,
},
{
id: "anthropic",
displayName: "Anthropic",
type: "cloud",
websiteUrl: "https://docs.anthropic.com/en/docs/about-claude/models",
supportsThinking: true,
},
{
id: "google",
displayName: "Google AI Studio",
type: "cloud",
hasFreeTier: true,
websiteUrl: "https://ai.google.dev/gemini-api/docs/models",
supportsThinking: true,
gatewayPrefix: "gemini/",
},
{
id: "vertex",
displayName: "Google Vertex AI",
type: "cloud",
websiteUrl: "https://cloud.google.com/vertex-ai/generative-ai/docs/models",
supportsThinking: true,
gatewayPrefix: "gemini/",
},
{
id: "openrouter",
displayName: "OpenRouter",
type: "cloud",
hasFreeTier: true,
websiteUrl: "https://openrouter.ai/models",
},
{
id: "xai",
displayName: "xAI",
type: "cloud",
websiteUrl: "https://docs.x.ai/docs/models",
},
] satisfies z.infer<typeof CatalogProviderSchema>[];
const modelsByProvider = {
openai: [
{
apiName: "gpt-5.2",
displayName: "GPT 5.2",
description: "OpenAI's latest flagship model",
dollarSigns: 3,
temperature: 1,
contextWindow: 400_000,
},
{
apiName: "gpt-5.1-codex",
displayName: "GPT 5.1 Codex",
description: "OpenAI model optimized for coding workflows",
dollarSigns: 3,
temperature: 1,
contextWindow: 400_000,
},
{
apiName: "gpt-5-mini",
displayName: "GPT 5 Mini",
description: "OpenAI lightweight model for faster lower-cost tasks",
dollarSigns: 2,
temperature: 1,
contextWindow: 400_000,
},
{
apiName: "gpt-5-nano",
displayName: "GPT 5 Nano",
description: "OpenAI compact budget-friendly model",
dollarSigns: 1,
temperature: 1,
contextWindow: 400_000,
},
],
anthropic: [
{
apiName: "claude-sonnet-4-6",
displayName: "Claude Sonnet 4.6",
description: "Anthropic fast and high-quality coding model",
dollarSigns: 5,
temperature: 0,
maxOutputTokens: 32_000,
contextWindow: 1_000_000,
},
{
apiName: "claude-opus-4-6",
displayName: "Claude Opus 4.6",
description: "Anthropic most capable model",
dollarSigns: 6,
temperature: 0,
maxOutputTokens: 32_000,
contextWindow: 1_000_000,
},
],
google: [
{
apiName: "gemini-3.1-pro-preview",
displayName: "Gemini 3.1 Pro (Preview)",
description: "Google's highest-quality Gemini model",
dollarSigns: 4,
temperature: 1,
maxOutputTokens: 65_535,
contextWindow: 1_048_576,
lifecycle: { stage: "preview" },
},
{
apiName: "gemini-3-flash-preview",
displayName: "Gemini 3 Flash (Preview)",
description: "Google fast and affordable Gemini model",
dollarSigns: 2,
temperature: 1,
maxOutputTokens: 65_535,
contextWindow: 1_048_576,
lifecycle: { stage: "preview" },
},
{
apiName: "gemini-flash-latest",
displayName: "Gemini 2.5 Flash",
description: "Google fast Gemini model with broad availability",
dollarSigns: 2,
temperature: 0,
maxOutputTokens: 65_535,
contextWindow: 1_048_576,
},
],
vertex: [
{
apiName: "gemini-2.5-pro",
displayName: "Gemini 2.5 Pro",
description: "Vertex Gemini 2.5 Pro",
temperature: 0,
maxOutputTokens: 65_535,
contextWindow: 1_048_576,
},
{
apiName: "gemini-flash-latest",
displayName: "Gemini 2.5 Flash",
description: "Vertex Gemini 2.5 Flash",
temperature: 0,
maxOutputTokens: 65_535,
contextWindow: 1_048_576,
},
],
openrouter: [
{
apiName: "openrouter/free",
displayName: "Free (OpenRouter)",
description: "A rotating free-tier OpenRouter model",
dollarSigns: 0,
temperature: 0,
maxOutputTokens: 32_000,
contextWindow: 200_000,
},
{
apiName: "moonshotai/kimi-k2.5",
displayName: "Kimi K2.5",
description: "Moonshot AI's capable model via OpenRouter",
dollarSigns: 2,
temperature: 0,
maxOutputTokens: 32_000,
contextWindow: 256_000,
},
],
xai: [
{
apiName: "grok-4",
displayName: "Grok 4",
description: "xAI flagship model",
dollarSigns: 3,
temperature: 0,
contextWindow: 256_000,
},
],
} satisfies z.infer<
typeof LanguageModelCatalogResponseSchema.shape.modelsByProvider
>;
const aliases = [
{
id: "dyad/theme-generator/google",
resolvedModel: {
providerId: "google",
apiName: "gemini-3.1-pro-preview",
},
displayName: "Google",
purpose: "theme-generation",
},
{
id: "dyad/theme-generator/anthropic",
resolvedModel: {
providerId: "anthropic",
apiName: "claude-opus-4-6",
},
displayName: "Anthropic",
purpose: "theme-generation",
},
{
id: "dyad/theme-generator/openai",
resolvedModel: {
providerId: "openai",
apiName: "gpt-5.2",
},
displayName: "OpenAI",
purpose: "theme-generation",
},
{
id: "dyad/auto/openai",
resolvedModel: {
providerId: "openai",
apiName: "gpt-5.2",
},
displayName: "Auto OpenAI",
purpose: "auto-mode",
},
{
id: "dyad/auto/anthropic",
resolvedModel: {
providerId: "anthropic",
apiName: "claude-sonnet-4-6",
},
displayName: "Auto Anthropic",
purpose: "auto-mode",
},
{
id: "dyad/auto/google",
resolvedModel: {
providerId: "google",
apiName: "gemini-3-flash-preview",
},
displayName: "Auto Google",
purpose: "auto-mode",
},
{
id: "dyad/help-bot/default",
resolvedModel: {
providerId: "openai",
apiName: "gpt-5-nano",
},
displayName: "Help Bot",
purpose: "help-bot",
},
] satisfies z.infer<typeof CatalogAliasSchema>[];
export function buildLanguageModelCatalogResponse(
now = new Date(),
): LanguageModelCatalogResponse {
return LanguageModelCatalogResponseSchema.parse({
version: now.toISOString(),
expiresAt: new Date(now.getTime() + ONE_HOUR_IN_MS).toISOString(),
providers,
modelsByProvider,
aliases,
curatedSelections: {
themeGenerationOptions: [
{
id: "dyad/theme-generator/google",
label: "Google",
},
{
id: "dyad/theme-generator/anthropic",
label: "Anthropic",
},
{
id: "dyad/theme-generator/openai",
label: "OpenAI",
},
],
},
});
}
# Dynamic Models Plan
## Goal
Replace the baked-in builtin language model catalog in `src/ipc/shared/language_model_constants.ts` with an API-first catalog fetched from `api.dyad.sh`, while preserving `language_model_constants.ts` as a local fallback when the API is unavailable or invalid.
Also remove product-facing hardcoded model IDs where we currently encode specific model names in feature code, and instead derive those choices from API-provided aliases and ordered selections.
## Non-goals
- This plan does not implement the API itself.
- This plan does not migrate image generation or transcription model IDs unless we explicitly decide to broaden the API from "language model catalog" to a larger "AI model catalog".
- This plan does not remove custom provider/model support stored in the local DB.
## Design principles
- API-first: builtin provider/model metadata should come from `api.dyad.sh`.
- Fallback-safe: the app must still work offline or during API outages.
- IPC-stable: existing renderer IPC consumers should continue to read providers/models through the current IPC surface.
- Product-intent driven: feature code should reference stable aliases, not concrete vendor model IDs.
- Minimal scope: only add the alias set required by current product behavior.
## Current state
Today `src/ipc/shared/language_model_constants.ts` mixes several responsibilities:
- builtin provider catalog
- builtin model catalog
- product defaults and curated model choices
- app-internal provider metadata
Builtin model data is surfaced through `src/ipc/shared/language_model_helpers.ts`, which is already the main-process source of truth behind:
- `get-language-model-providers`
- `get-language-models`
- `get-language-models-by-providers`
This is good because we can make the model catalog dynamic inside the main process without forcing a renderer-wide contract change.
## Proposed architecture
### 1. Split remote catalog from local fallback
Keep `src/ipc/shared/language_model_constants.ts`, but reposition it as fallback data and app-local metadata instead of the primary source of builtin models.
The remote API should own:
- builtin cloud providers
- builtin cloud models
- display names
- descriptions
- pricing tier indicators
- tags
- context/output token limits
- curated aliases for product selections
The local app code should continue to own:
- custom providers/models from the DB
- local providers like Ollama / LM Studio
- app-only wiring that should not depend on API reachability
- fallback copies of builtin provider/model metadata
### 2. Fetch remote catalog in main process
Add a main-process fetch utility for the language model catalog, similar in spirit to `src/ipc/utils/template_utils.ts`.
Behavior:
- fetch from `https://api.dyad.sh/v1/language-model-catalog`
- validate with Zod
- cache in memory
- de-duplicate in-flight fetches
- use TTL or `expiresAt` from the response
- on fetch or validation failure, log and return `null`
### 3. Keep renderer IPC unchanged where possible
`src/ipc/shared/language_model_helpers.ts` should become the source that:
- loads the remote builtin catalog when available
- falls back to local builtin constants otherwise
- merges local DB custom providers/models on top
This keeps the existing IPC contracts intact while changing the builtin data source underneath.
### 4. Add alias resolution for product-level model choices
Any product code that currently hardcodes a concrete builtin model should stop importing exact model IDs and instead resolve an alias to a `{ providerId, apiName }` pair.
This allows the API to update the concrete model without requiring an app release.
## Minimal alias set needed today
We agreed to keep the alias surface minimal and not add provider-level aliases yet.
Required aliases:
- `dyad/theme-generator/google`
- `dyad/theme-generator/anthropic`
- `dyad/theme-generator/openai`
- `dyad/auto/openai`
- `dyad/auto/anthropic`
- `dyad/auto/google`
- `dyad/help-bot/default`
Not needed:
- `dyad/theme-generator/default`
For theme generation, the UI will use the first option returned by the API as the default selected option.
## Proposed API schema
Endpoint:
`GET https://api.dyad.sh/v1/language-model-catalog`
Suggested response shape:
```ts
type LanguageModelCatalogResponse = {
version: string;
expiresAt?: string;
providers: Array<{
id: string;
displayName: string;
type: "cloud";
hasFreeTier?: boolean;
websiteUrl?: string;
secondary?: boolean;
supportsThinking?: boolean;
gatewayPrefix?: string;
}>;
modelsByProvider: Record<
string,
Array<{
apiName: string;
displayName: string;
description: string;
tag?: string;
tagColor?: string;
dollarSigns?: number;
temperature?: number;
maxOutputTokens?: number;
contextWindow?: number;
lifecycle?: {
stage?: "stable" | "preview" | "deprecated";
};
}>
>;
aliases: Array<{
id: string;
resolvedModel: {
providerId: string;
apiName: string;
};
displayName?: string;
purpose?: "theme-generation" | "auto-mode" | "help-bot";
}>;
curatedSelections?: {
themeGenerationOptions: Array<{
id:
| "dyad/theme-generator/google"
| "dyad/theme-generator/anthropic"
| "dyad/theme-generator/openai";
label: string;
}>;
};
};
```
## API semantics
### Builtin providers/models
- The API owns the builtin cloud catalog.
- The app still injects local providers and DB-backed custom providers/models separately.
### Aliases
Aliases are stable app-facing identifiers for product decisions.
For example:
- `dyad/theme-generator/google` resolves to the concrete Google model to use for theme generation.
- `dyad/auto/openai` resolves to the concrete OpenAI model used in auto mode.
- `dyad/help-bot/default` resolves to the concrete model used by the help bot.
### Theme generator ordering
The API should return `curatedSelections.themeGenerationOptions` in display order.
The client will:
- render the returned options in that order
- use the first returned option as the default selected option
- use the first returned option again when resetting the dialog state
This removes the need for a dedicated `dyad/theme-generator/default` alias.
### Auto mode ordering
Keep auto-mode order in app code for now.
The app can try aliases in this order:
1. `dyad/auto/openai`
2. `dyad/auto/anthropic`
3. `dyad/auto/google`
That keeps the API smaller while still eliminating hardcoded concrete model IDs.
## Planned implementation steps
### 1. Add remote catalog schema + fetch utility
Add a new main-process utility to:
- fetch the remote catalog
- validate it with Zod
- cache it in memory
- expose helpers like:
- `getRemoteLanguageModelCatalog()`
- `resolveBuiltinModelAlias(aliasId)`
### 2. Refactor local constants into fallback role
Update `src/ipc/shared/language_model_constants.ts` so it is clearly the fallback builtin catalog plus app-local metadata.
Avoid using it as the source of product-curated model choices.
### 3. Update language model helpers to use API-first resolution
Refactor `src/ipc/shared/language_model_helpers.ts`:
- `getLanguageModelProviders()`
- use remote builtin providers when available
- fall back to local builtin providers otherwise
- merge DB custom providers
- append local providers
- `getLanguageModels({ providerId })`
- use remote builtin models when available
- fall back to local builtin models otherwise
- merge DB custom models
- `getLanguageModelsByProviders()`
- keep existing behavior, but sourcing builtin data from the API-backed helper
### 4. Add alias resolver for product code
Add a helper that resolves aliases from the remote catalog, with local fallback mapping if the API is unavailable.
Suggested shape:
```ts
type ResolvedBuiltinModel = {
providerId: string;
apiName: string;
};
async function resolveBuiltinModelAlias(
aliasId: string,
): Promise<ResolvedBuiltinModel | null>;
```
### 5. Migrate theme generator to alias-based options
Replace the hardcoded theme generator model enum and mapping with API-derived ordered options.
Desired end state:
- the UI no longer hardcodes `gemini-3-pro`, `claude-opus-4.5`, `gpt-5.2`
- `ThemeGenerationModel` becomes a string alias ID rather than a fixed `z.enum([...])`
- the backend resolves the alias to the concrete provider/model pair before calling `getModelClient`
### 6. Migrate auto mode to alias-based builtin model resolution
Replace the current hardcoded concrete auto-model list with:
- `dyad/auto/openai`
- `dyad/auto/anthropic`
- `dyad/auto/google`
The app keeps the fallback ordering logic locally.
### 7. Migrate help bot to alias-based resolution
Replace the concrete help-bot model ID with:
- `dyad/help-bot/default`
### 8. Leave tests and unrelated model types alone unless necessary
Do not broaden scope into:
- image generation model constants
- transcription model constants
- test-only literals like `gpt-4`
unless the implementation forces us to touch them.
## Hardcoded model-name audit
### High-priority product-facing hardcodes
#### Theme generator UI
File:
- `src/components/AIGeneratorTab.tsx`
Current issues:
- hardcoded default theme generation model
- hardcoded UI choices for Google / Anthropic / OpenAI
Planned change:
- fetch theme-generation options from API-backed IPC
- use first returned option as default
- store alias ID instead of concrete model name
#### Theme generator IPC types
File:
- `src/ipc/types/templates.ts`
Current issues:
- `ThemeGenerationModelSchema` is a fixed `z.enum([...])`
Planned change:
- replace with `z.string()` or a constrained alias-oriented schema
- treat the value as an alias ID, not a concrete model ID
#### Theme generator backend mapping
File:
- `src/pro/main/ipc/handlers/themes_handlers.ts`
Current issues:
- `THEME_GENERATION_MODEL_MAP` hardcodes alias-like UI values to concrete provider/model pairs
Planned change:
- replace this with alias resolution from the API-backed catalog
#### Auto mode
File:
- `src/ipc/utils/get_model_client.ts`
Current issues:
- `AUTO_MODELS` hardcodes exact provider/model pairs
- the Dyad Pro local-agent fallback also hardcodes exact concrete models
Planned change:
- resolve `dyad/auto/openai`
- resolve `dyad/auto/anthropic`
- resolve `dyad/auto/google`
- keep the ordering in app code
#### Help bot
File:
- `src/ipc/handlers/help_bot_handlers.ts`
Current issues:
- concrete model ID is hardcoded
Planned change:
- resolve `dyad/help-bot/default`
### Lower-priority hardcodes not in scope for first pass
#### Image generation
File:
- `src/pro/main/ipc/handlers/local_agent/tools/generate_image.ts`
Current issue:
- hardcoded image generation model
Reason not in first pass:
- this plan is for builtin language model catalog migration, not a broader AI model registry
#### Test fixtures and assertions
Examples:
- `src/__tests__/local_agent_handler.test.ts`
- `src/__tests__/prepare_step_utils.test.ts`
- `src/__tests__/readSettings.test.ts`
Reason not in first pass:
- these are test literals and not user-facing model-catalog decisions
## Rollout order
1. Add remote catalog schema and fetch utility
2. Switch builtin providers/models in `language_model_helpers.ts` to API-first with fallback
3. Add alias resolution helper
4. Migrate theme generator to ordered alias-based options
5. Migrate auto mode to alias-based resolution
6. Migrate help bot to alias-based resolution
7. Remove remaining product-facing imports of concrete builtin model constants where possible
## Risks and tradeoffs
### API unavailability
Risk:
- builtin model catalog could fail to load at runtime
Mitigation:
- local fallback catalog remains complete and functional
### Invalid API payload
Risk:
- malformed API response could break model loading
Mitigation:
- strict Zod validation
- log and fall back locally on any validation error
### Theme generator contract migration
Risk:
- changing `ThemeGenerationModel` from fixed enum to alias string touches both UI and IPC contracts
Mitigation:
- keep the change narrow and migrate both sides together
### Partial migration
Risk:
- model catalog becomes dynamic but product code still hardcodes concrete model IDs
Mitigation:
- explicitly migrate the high-priority hardcoded call sites in the same project
## Success criteria
- Builtin cloud providers/models are fetched from `api.dyad.sh` when available.
- The app falls back to `language_model_constants.ts` when the API fails or returns invalid data.
- Existing IPC provider/model queries continue to work.
- Theme generator no longer hardcodes specific builtin model IDs.
- Auto mode no longer hardcodes specific builtin model IDs.
- Help bot no longer hardcodes a specific builtin model ID.
- The minimal alias set above is sufficient for current product behavior.
import { z } from "zod";
import { NextResponse } from "next/server";
const ProviderIdSchema = z.enum([
"openai",
"anthropic",
"google",
"vertex",
"openrouter",
"xai",
]);
const ThemeGenerationAliasIdSchema = z.enum([
"dyad/theme-generator/google",
"dyad/theme-generator/anthropic",
"dyad/theme-generator/openai",
]);
const AliasIdSchema = z.enum([
"dyad/theme-generator/google",
"dyad/theme-generator/anthropic",
"dyad/theme-generator/openai",
"dyad/auto/openai",
"dyad/auto/anthropic",
"dyad/auto/google",
"dyad/help-bot/default",
]);
const LanguageModelCatalogResponseSchema = z.object({
version: z.string(),
expiresAt: z.string().datetime(),
providers: z.array(
z.object({
id: ProviderIdSchema,
displayName: z.string(),
type: z.literal("cloud"),
hasFreeTier: z.boolean().optional(),
websiteUrl: z.string().url().optional(),
secondary: z.boolean().optional(),
supportsThinking: z.boolean().optional(),
gatewayPrefix: z.string().optional(),
}),
),
modelsByProvider: z.record(
z.string(),
z.array(
z.object({
apiName: z.string(),
displayName: z.string(),
description: z.string(),
tag: z.string().optional(),
tagColor: z.string().optional(),
dollarSigns: z.number().int().nonnegative().optional(),
temperature: z.number().optional(),
maxOutputTokens: z.number().int().positive().optional(),
contextWindow: z.number().int().positive().optional(),
lifecycle: z
.object({
stage: z.enum(["stable", "preview", "deprecated"]).optional(),
})
.optional(),
}),
),
),
aliases: z.array(
z.object({
id: AliasIdSchema,
resolvedModel: z.object({
providerId: ProviderIdSchema,
apiName: z.string(),
}),
displayName: z.string().optional(),
purpose: z.enum(["theme-generation", "auto-mode", "help-bot"]).optional(),
}),
),
curatedSelections: z.object({
themeGenerationOptions: z.array(
z.object({
id: ThemeGenerationAliasIdSchema,
label: z.string(),
}),
),
}),
});
const ONE_HOUR_IN_MS = 60 * 60 * 1000;
function buildCatalogResponse(now = new Date()) {
return {
version: now.toISOString(),
expiresAt: new Date(now.getTime() + ONE_HOUR_IN_MS).toISOString(),
providers: [
{
id: "openai",
displayName: "OpenAI",
type: "cloud",
websiteUrl: "https://platform.openai.com/docs/models",
supportsThinking: true,
},
{
id: "anthropic",
displayName: "Anthropic",
type: "cloud",
websiteUrl: "https://docs.anthropic.com/en/docs/about-claude/models",
supportsThinking: true,
},
{
id: "google",
displayName: "Google AI Studio",
type: "cloud",
hasFreeTier: true,
websiteUrl: "https://ai.google.dev/gemini-api/docs/models",
supportsThinking: true,
gatewayPrefix: "gemini/",
},
{
id: "vertex",
displayName: "Google Vertex AI",
type: "cloud",
websiteUrl:
"https://cloud.google.com/vertex-ai/generative-ai/docs/models",
supportsThinking: true,
gatewayPrefix: "gemini/",
},
{
id: "openrouter",
displayName: "OpenRouter",
type: "cloud",
hasFreeTier: true,
websiteUrl: "https://openrouter.ai/models",
},
{
id: "xai",
displayName: "xAI",
type: "cloud",
websiteUrl: "https://docs.x.ai/docs/models",
},
],
modelsByProvider: {
openai: [
{
apiName: "gpt-5.2",
displayName: "GPT 5.2",
description: "OpenAI's latest flagship model",
dollarSigns: 3,
temperature: 1,
contextWindow: 400_000,
},
{
apiName: "gpt-5.1-codex",
displayName: "GPT 5.1 Codex",
description: "OpenAI model optimized for coding workflows",
dollarSigns: 3,
temperature: 1,
contextWindow: 400_000,
},
{
apiName: "gpt-5-mini",
displayName: "GPT 5 Mini",
description: "OpenAI lightweight model for faster lower-cost tasks",
dollarSigns: 2,
temperature: 1,
contextWindow: 400_000,
},
{
apiName: "gpt-5-nano",
displayName: "GPT 5 Nano",
description: "OpenAI compact budget-friendly model",
dollarSigns: 1,
temperature: 1,
contextWindow: 400_000,
},
],
anthropic: [
{
apiName: "claude-sonnet-4-6",
displayName: "Claude Sonnet 4.6",
description: "Anthropic fast and high-quality coding model",
dollarSigns: 5,
temperature: 0,
maxOutputTokens: 32_000,
contextWindow: 1_000_000,
},
{
apiName: "claude-opus-4-6",
displayName: "Claude Opus 4.6",
description: "Anthropic most capable model",
dollarSigns: 6,
temperature: 0,
maxOutputTokens: 32_000,
contextWindow: 1_000_000,
},
],
google: [
{
apiName: "gemini-3.1-pro-preview",
displayName: "Gemini 3.1 Pro (Preview)",
description: "Google's highest-quality Gemini model",
dollarSigns: 4,
temperature: 1,
maxOutputTokens: 65_535,
contextWindow: 1_048_576,
lifecycle: { stage: "preview" },
},
{
apiName: "gemini-3-flash-preview",
displayName: "Gemini 3 Flash (Preview)",
description: "Google fast and affordable Gemini model",
dollarSigns: 2,
temperature: 1,
maxOutputTokens: 65_535,
contextWindow: 1_048_576,
lifecycle: { stage: "preview" },
},
{
apiName: "gemini-flash-latest",
displayName: "Gemini 2.5 Flash",
description: "Google fast Gemini model with broad availability",
dollarSigns: 2,
temperature: 0,
maxOutputTokens: 65_535,
contextWindow: 1_048_576,
},
],
vertex: [
{
apiName: "gemini-2.5-pro",
displayName: "Gemini 2.5 Pro",
description: "Vertex Gemini 2.5 Pro",
temperature: 0,
maxOutputTokens: 65_535,
contextWindow: 1_048_576,
},
{
apiName: "gemini-flash-latest",
displayName: "Gemini 2.5 Flash",
description: "Vertex Gemini 2.5 Flash",
temperature: 0,
maxOutputTokens: 65_535,
contextWindow: 1_048_576,
},
],
openrouter: [
{
apiName: "openrouter/free",
displayName: "Free (OpenRouter)",
description: "A rotating free-tier OpenRouter model",
dollarSigns: 0,
temperature: 0,
maxOutputTokens: 32_000,
contextWindow: 200_000,
},
{
apiName: "moonshotai/kimi-k2.5",
displayName: "Kimi K2.5",
description: "Moonshot AI's capable model via OpenRouter",
dollarSigns: 2,
temperature: 0,
maxOutputTokens: 32_000,
contextWindow: 256_000,
},
],
xai: [
{
apiName: "grok-4",
displayName: "Grok 4",
description: "xAI flagship model",
dollarSigns: 3,
temperature: 0,
contextWindow: 256_000,
},
],
},
aliases: [
{
id: "dyad/theme-generator/google",
resolvedModel: {
providerId: "google",
apiName: "gemini-3.1-pro-preview",
},
displayName: "Google",
purpose: "theme-generation",
},
{
id: "dyad/theme-generator/anthropic",
resolvedModel: {
providerId: "anthropic",
apiName: "claude-opus-4-6",
},
displayName: "Anthropic",
purpose: "theme-generation",
},
{
id: "dyad/theme-generator/openai",
resolvedModel: {
providerId: "openai",
apiName: "gpt-5.2",
},
displayName: "OpenAI",
purpose: "theme-generation",
},
{
id: "dyad/auto/openai",
resolvedModel: {
providerId: "openai",
apiName: "gpt-5.2",
},
displayName: "Auto OpenAI",
purpose: "auto-mode",
},
{
id: "dyad/auto/anthropic",
resolvedModel: {
providerId: "anthropic",
apiName: "claude-sonnet-4-6",
},
displayName: "Auto Anthropic",
purpose: "auto-mode",
},
{
id: "dyad/auto/google",
resolvedModel: {
providerId: "google",
apiName: "gemini-3-flash-preview",
},
displayName: "Auto Google",
purpose: "auto-mode",
},
{
id: "dyad/help-bot/default",
resolvedModel: {
providerId: "openai",
apiName: "gpt-5-nano",
},
displayName: "Help Bot",
purpose: "help-bot",
},
],
curatedSelections: {
themeGenerationOptions: [
{
id: "dyad/theme-generator/google",
label: "Google",
},
{
id: "dyad/theme-generator/anthropic",
label: "Anthropic",
},
{
id: "dyad/theme-generator/openai",
label: "OpenAI",
},
],
},
};
}
export async function GET() {
const body = buildCatalogResponse();
const validatedBody = LanguageModelCatalogResponseSchema.parse(body);
return NextResponse.json(validatedBody, {
headers: {
"Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400",
},
});
}
import { NextResponse } from "next/server";
import {
LanguageModelCatalogResponseSchema,
buildLanguageModelCatalogResponse,
} from "./catalog-data";
export async function GET() {
const body = buildLanguageModelCatalogResponse();
const validatedBody = LanguageModelCatalogResponseSchema.parse(body);
return NextResponse.json(validatedBody, {
headers: {
"Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400",
},
});
}
...@@ -97,6 +97,18 @@ Each parallel Playwright worker gets its own fake LLM server on port `FAKE_LLM_B ...@@ -97,6 +97,18 @@ Each parallel Playwright worker gets its own fake LLM server on port `FAKE_LLM_B
When adding new test server URLs, update **both** the test fixtures (`e2e-tests/helpers/fixtures.ts`) and the Electron app source that consumes them. The app reads `process.env.FAKE_LLM_PORT` to build its `TEST_SERVER_BASE` URL — if you hardcode a port in app source, parallel workers will all hit the same server. When adding new test server URLs, update **both** the test fixtures (`e2e-tests/helpers/fixtures.ts`) and the Electron app source that consumes them. The app reads `process.env.FAKE_LLM_PORT` to build its `TEST_SERVER_BASE` URL — if you hardcode a port in app source, parallel workers will all hit the same server.
For app features that fetch `api.dyad.sh` directly, add a test-only env override in app code and point it at the worker-specific fake server during E2E. Without that override, E2E tests cannot deterministically exercise both the remote-success and local-fallback paths.
## Sandbox-related Electron launch failures
Packaged Electron E2E runs may fail inside the Codex sandbox before any test logic executes, with Playwright reporting `electron.launch: Process failed to launch!` and the Electron process exiting with `SIGABRT`.
If this happens:
1. Verify whether the failure reproduces on an existing known-good E2E spec.
2. Re-run the same `npm run e2e -- e2e-tests/<spec>` command outside the sandbox before treating it as an app regression.
3. If the test passes outside the sandbox, treat the sandbox launch failure as environmental rather than a product bug.
## Common flaky test patterns and fixes ## Common flaky test patterns and fixes
- **After `page.reload()`**: Always add `await page.waitForLoadState("domcontentloaded")` before interacting with elements. Without this, the page may not have re-rendered yet. - **After `page.reload()`**: Always add `await page.waitForLoadState("domcontentloaded")` before interacting with elements. Without this, the page may not have re-rendered yet.
......
...@@ -7,6 +7,7 @@ import { Loader2, Upload, X, Sparkles, Lock, Link } from "lucide-react"; ...@@ -7,6 +7,7 @@ import { Loader2, Upload, X, Sparkles, Lock, Link } from "lucide-react";
import { import {
useGenerateThemePrompt, useGenerateThemePrompt,
useGenerateThemeFromUrl, useGenerateThemeFromUrl,
useThemeGenerationModelOptions,
} from "@/hooks/useCustomThemes"; } from "@/hooks/useCustomThemes";
import { ipc } from "@/ipc/types"; import { ipc } from "@/ipc/types";
import { showError } from "@/lib/toast"; import { showError } from "@/lib/toast";
...@@ -23,9 +24,6 @@ import type { ...@@ -23,9 +24,6 @@ import type {
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB per image (raw file size) const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB per image (raw file size)
const MAX_IMAGES = 5; const MAX_IMAGES = 5;
// Default model for AI theme generation
const DEFAULT_THEME_GENERATION_MODEL: ThemeGenerationModel = "gemini-3-pro";
// Image stored with file path (for IPC) and blob URL (for preview) // Image stored with file path (for IPC) and blob URL (for preview)
interface ThemeImage { interface ThemeImage {
path: string; // File path in temp directory path: string; // File path in temp directory
...@@ -59,9 +57,8 @@ export function AIGeneratorTab({ ...@@ -59,9 +57,8 @@ export function AIGeneratorTab({
const [aiKeywords, setAiKeywords] = useState(""); const [aiKeywords, setAiKeywords] = useState("");
const [aiGenerationMode, setAiGenerationMode] = const [aiGenerationMode, setAiGenerationMode] =
useState<ThemeGenerationMode>("inspired"); useState<ThemeGenerationMode>("inspired");
const [aiSelectedModel, setAiSelectedModel] = useState<ThemeGenerationModel>( const [aiSelectedModel, setAiSelectedModel] =
DEFAULT_THEME_GENERATION_MODEL, useState<ThemeGenerationModel>("");
);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// Track if dialog is open to prevent orphaned uploads from adding images after close // Track if dialog is open to prevent orphaned uploads from adding images after close
...@@ -76,6 +73,8 @@ export function AIGeneratorTab({ ...@@ -76,6 +73,8 @@ export function AIGeneratorTab({
const isGenerating = const isGenerating =
generatePromptMutation.isPending || generateFromUrlMutation.isPending; generatePromptMutation.isPending || generateFromUrlMutation.isPending;
const { userBudget } = useUserBudgetInfo(); const { userBudget } = useUserBudgetInfo();
const { themeGenerationModelOptions, isLoadingThemeGenerationModelOptions } =
useThemeGenerationModelOptions();
// Cleanup function to revoke blob URLs and delete temp files // Cleanup function to revoke blob URLs and delete temp files
const cleanupImages = useCallback( const cleanupImages = useCallback(
...@@ -105,6 +104,20 @@ export function AIGeneratorTab({ ...@@ -105,6 +104,20 @@ export function AIGeneratorTab({
isDialogOpenRef.current = isDialogOpen; isDialogOpenRef.current = isDialogOpen;
}, [isDialogOpen]); }, [isDialogOpen]);
useEffect(() => {
const firstModelId = themeGenerationModelOptions[0]?.id ?? "";
if (!firstModelId) {
return;
}
if (
!aiSelectedModel ||
!themeGenerationModelOptions.some((model) => model.id === aiSelectedModel)
) {
setAiSelectedModel(firstModelId);
}
}, [aiSelectedModel, themeGenerationModelOptions]);
// Keep a ref to current images for cleanup without causing effect re-runs // Keep a ref to current images for cleanup without causing effect re-runs
const aiImagesRef = useRef<ThemeImage[]>([]); const aiImagesRef = useRef<ThemeImage[]>([]);
useEffect(() => { useEffect(() => {
...@@ -122,11 +135,11 @@ export function AIGeneratorTab({ ...@@ -122,11 +135,11 @@ export function AIGeneratorTab({
} }
setAiKeywords(""); setAiKeywords("");
setAiGenerationMode("inspired"); setAiGenerationMode("inspired");
setAiSelectedModel(DEFAULT_THEME_GENERATION_MODEL); setAiSelectedModel(themeGenerationModelOptions[0]?.id ?? "");
setInputSource("images"); setInputSource("images");
setWebsiteUrl(""); setWebsiteUrl("");
} }
}, [isDialogOpen, cleanupImages]); }, [isDialogOpen, cleanupImages, themeGenerationModelOptions]);
const handleImageUpload = useCallback( const handleImageUpload = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => { async (e: React.ChangeEvent<HTMLInputElement>) => {
...@@ -513,49 +526,38 @@ export function AIGeneratorTab({ ...@@ -513,49 +526,38 @@ export function AIGeneratorTab({
{/* Model Selection */} {/* Model Selection */}
<div className="space-y-3"> <div className="space-y-3">
<Label>Model Selection</Label> <Label>Model Selection</Label>
<div className="grid grid-cols-3 gap-3"> <div
<button className="grid grid-cols-[repeat(auto-fit,minmax(8rem,1fr))] gap-3"
type="button" role="radiogroup"
onClick={() => setAiSelectedModel("gemini-3-pro")} aria-label="Model Selection"
className={`flex flex-col items-center rounded-lg border p-3 text-center transition-colors ${
aiSelectedModel === "gemini-3-pro"
? "border-primary bg-primary/5"
: "hover:bg-muted/50"
}`}
>
<span className="font-medium text-sm">Gemini 3 Pro</span>
<span className="text-xs text-muted-foreground mt-1">
Most capable
</span>
</button>
<button
type="button"
onClick={() => setAiSelectedModel("claude-opus-4.5")}
className={`flex flex-col items-center rounded-lg border p-3 text-center transition-colors ${
aiSelectedModel === "claude-opus-4.5"
? "border-primary bg-primary/5"
: "hover:bg-muted/50"
}`}
> >
<span className="font-medium text-sm">Claude Opus 4.5</span> {isLoadingThemeGenerationModelOptions ? (
<span className="text-xs text-muted-foreground mt-1"> <div className="col-span-full flex items-center justify-center py-3 text-sm text-muted-foreground">
Creative & detailed <Loader2 className="mr-2 h-4 w-4 animate-spin" />
</span> Loading models...
</button> </div>
) : themeGenerationModelOptions.length === 0 ? (
<div className="col-span-full text-center py-3 text-sm text-muted-foreground">
No models available
</div>
) : (
themeGenerationModelOptions.map((modelOption) => (
<button <button
key={modelOption.id}
type="button" type="button"
onClick={() => setAiSelectedModel("gpt-5.2")} role="radio"
aria-checked={aiSelectedModel === modelOption.id}
onClick={() => setAiSelectedModel(modelOption.id)}
className={`flex flex-col items-center rounded-lg border p-3 text-center transition-colors ${ className={`flex flex-col items-center rounded-lg border p-3 text-center transition-colors ${
aiSelectedModel === "gpt-5.2" aiSelectedModel === modelOption.id
? "border-primary bg-primary/5" ? "border-primary bg-primary/5"
: "hover:bg-muted/50" : "hover:bg-muted/50"
}`} }`}
> >
<span className="font-medium text-sm">GPT 5.2</span> <span className="font-medium text-sm">{modelOption.label}</span>
<span className="text-xs text-muted-foreground mt-1">
Latest OpenAI
</span>
</button> </button>
))
)}
</div> </div>
</div> </div>
...@@ -563,6 +565,8 @@ export function AIGeneratorTab({ ...@@ -563,6 +565,8 @@ export function AIGeneratorTab({
<Button <Button
onClick={handleGenerate} onClick={handleGenerate}
disabled={ disabled={
isLoadingThemeGenerationModelOptions ||
!aiSelectedModel ||
isGenerating || isGenerating ||
(inputSource === "images" && aiImages.length === 0) || (inputSource === "images" && aiImages.length === 0) ||
(inputSource === "url" && !websiteUrl.trim()) (inputSource === "url" && !websiteUrl.trim())
......
...@@ -7,6 +7,7 @@ import type { ...@@ -7,6 +7,7 @@ import type {
GenerateThemePromptParams, GenerateThemePromptParams,
GenerateThemePromptResult, GenerateThemePromptResult,
GenerateThemeFromUrlParams, GenerateThemeFromUrlParams,
ThemeGenerationModelOption,
} from "@/ipc/types"; } from "@/ipc/types";
import { queryKeys } from "@/lib/queryKeys"; import { queryKeys } from "@/lib/queryKeys";
...@@ -103,3 +104,20 @@ export function useGenerateThemeFromUrl() { ...@@ -103,3 +104,20 @@ export function useGenerateThemeFromUrl() {
}, },
}); });
} }
export function useThemeGenerationModelOptions() {
const query = useQuery({
queryKey: queryKeys.themeGenerationModelOptions.all,
queryFn: async (): Promise<ThemeGenerationModelOption[]> => {
return ipc.template.getThemeGenerationModelOptions();
},
meta: {
showErrorToast: true,
},
});
return {
themeGenerationModelOptions: query.data ?? [],
isLoadingThemeGenerationModelOptions: query.isLoading,
};
}
...@@ -10,6 +10,7 @@ import { ...@@ -10,6 +10,7 @@ import {
} from "@ai-sdk/openai"; } from "@ai-sdk/openai";
import { createTypedHandler } from "./base"; import { createTypedHandler } from "./base";
import { helpContracts } from "../types/help"; import { helpContracts } from "../types/help";
import { resolveBuiltinModelAlias } from "../shared/remote_language_model_catalog";
const logger = log.scope("help-bot"); const logger = log.scope("help-bot");
...@@ -48,11 +49,23 @@ export function registerHelpBotHandlers() { ...@@ -48,11 +49,23 @@ export function registerHelpBotHandlers() {
baseURL: "https://helpchat.dyad.sh/v1", baseURL: "https://helpchat.dyad.sh/v1",
apiKey, apiKey,
}); });
const helpBotModel = await resolveBuiltinModelAlias(
"dyad/help-bot/default",
);
if (!helpBotModel || helpBotModel.providerId !== "openai") {
// Help bot requires OpenAI provider because it uses the OpenAI
// responses API via a custom baseURL (helpchat.dyad.sh).
throw new Error(
`Help bot requires an OpenAI model (got provider: ${helpBotModel?.providerId ?? "none"}). ` +
`The 'dyad/help-bot/default' alias must resolve to an OpenAI model.`,
);
}
let assistantContent = ""; let assistantContent = "";
const stream = streamText({ const stream = streamText({
model: provider.responses("gpt-5-nano"), model: provider.responses(helpBotModel.apiName),
providerOptions: { providerOptions: {
openai: { openai: {
reasoningSummary: "auto", reasoningSummary: "auto",
......
...@@ -19,7 +19,10 @@ export interface ModelOption { ...@@ -19,7 +19,10 @@ export interface ModelOption {
export const GPT_5_2_MODEL_NAME = "gpt-5.2"; export const GPT_5_2_MODEL_NAME = "gpt-5.2";
export const SONNET_4_6 = "claude-sonnet-4-6"; export const SONNET_4_6 = "claude-sonnet-4-6";
export const OPUS_4_6 = "claude-opus-4-6";
export const GEMINI_3_FLASH = "gemini-3-flash-preview"; export const GEMINI_3_FLASH = "gemini-3-flash-preview";
export const GEMINI_3_1_PRO_PREVIEW = "gemini-3.1-pro-preview";
export const GPT_5_NANO = "gpt-5-nano";
export const MODEL_OPTIONS: Record<string, ModelOption[]> = { export const MODEL_OPTIONS: Record<string, ModelOption[]> = {
openai: [ openai: [
......
...@@ -5,12 +5,16 @@ import { ...@@ -5,12 +5,16 @@ import {
} from "@/db/schema"; } from "@/db/schema";
import type { LanguageModelProvider, LanguageModel } from "@/ipc/types"; import type { LanguageModelProvider, LanguageModel } from "@/ipc/types";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import log from "electron-log";
import { import {
LOCAL_PROVIDERS,
CLOUD_PROVIDERS, CLOUD_PROVIDERS,
LOCAL_PROVIDERS,
MODEL_OPTIONS, MODEL_OPTIONS,
PROVIDER_TO_ENV_VAR, PROVIDER_TO_ENV_VAR,
} from "./language_model_constants"; } from "./language_model_constants";
import { getBuiltinLanguageModelCatalog } from "./remote_language_model_catalog";
const logger = log.scope("language_model_helpers");
/** /**
* Fetches language model providers from both the database (custom) and hardcoded constants (cloud), * Fetches language model providers from both the database (custom) and hardcoded constants (cloud),
* merging them with custom providers taking precedence. * merging them with custom providers taking precedence.
...@@ -37,29 +41,35 @@ export async function getLanguageModelProviders(): Promise< ...@@ -37,29 +41,35 @@ export async function getLanguageModelProviders(): Promise<
}); });
} }
// Get hardcoded cloud providers const builtinCatalog = await getBuiltinLanguageModelCatalog();
const hardcodedProviders: LanguageModelProvider[] = []; logger.info("Loaded builtin catalog for provider list", {
for (const providerKey in CLOUD_PROVIDERS) { source: builtinCatalog.source,
if (Object.prototype.hasOwnProperty.call(CLOUD_PROVIDERS, providerKey)) { version: builtinCatalog.version,
// Ensure providerKey is a key of PROVIDERS providerCount: builtinCatalog.providers.length,
const key = providerKey as keyof typeof CLOUD_PROVIDERS; });
const providerDetails = CLOUD_PROVIDERS[key];
if (providerDetails) { const hardcodedProviders: LanguageModelProvider[] = [
// Ensure providerDetails is not undefined ...builtinCatalog.providers,
];
// Merge in any CLOUD_PROVIDERS not present in the remote catalog
// (e.g. auto, azure, bedrock which are not in the remote API).
for (const [providerId, providerDetails] of Object.entries(CLOUD_PROVIDERS)) {
if (!hardcodedProviders.some((p) => p.id === providerId)) {
hardcodedProviders.push({ hardcodedProviders.push({
id: key, id: providerId,
name: providerDetails.displayName, name: providerDetails.displayName,
hasFreeTier: providerDetails.hasFreeTier, hasFreeTier: providerDetails.hasFreeTier,
websiteUrl: providerDetails.websiteUrl, websiteUrl: providerDetails.websiteUrl,
gatewayPrefix: providerDetails.gatewayPrefix, gatewayPrefix: providerDetails.gatewayPrefix,
secondary: providerDetails.secondary, secondary: providerDetails.secondary,
envVarName: PROVIDER_TO_ENV_VAR[key] ?? undefined, envVarName:
PROVIDER_TO_ENV_VAR[providerId as keyof typeof PROVIDER_TO_ENV_VAR] ??
undefined,
type: "cloud", type: "cloud",
// apiBaseUrl is not directly in PROVIDERS
}); });
} }
} }
}
for (const providerKey in LOCAL_PROVIDERS) { for (const providerKey in LOCAL_PROVIDERS) {
if (Object.prototype.hasOwnProperty.call(LOCAL_PROVIDERS, providerKey)) { if (Object.prototype.hasOwnProperty.call(LOCAL_PROVIDERS, providerKey)) {
...@@ -134,16 +144,33 @@ export async function getLanguageModels({ ...@@ -134,16 +144,33 @@ export async function getLanguageModels({
// If it's a cloud provider, also get the hardcoded models // If it's a cloud provider, also get the hardcoded models
let hardcodedModels: LanguageModel[] = []; let hardcodedModels: LanguageModel[] = [];
if (provider.type === "cloud") { if (provider.type === "cloud") {
if (providerId in MODEL_OPTIONS) { const builtinCatalog = await getBuiltinLanguageModelCatalog();
const models = MODEL_OPTIONS[providerId] || []; logger.info("Loading cloud models from builtin catalog", {
hardcodedModels = models.map((model) => ({ providerId,
...model, source: builtinCatalog.source,
version: builtinCatalog.version,
hasProviderModels: providerId in builtinCatalog.modelsByProvider,
});
if (providerId in builtinCatalog.modelsByProvider) {
hardcodedModels = builtinCatalog.modelsByProvider[providerId] || [];
} else if (providerId in MODEL_OPTIONS) {
// Fall back to hardcoded MODEL_OPTIONS for providers not in the remote
// catalog (e.g. auto, azure, bedrock).
hardcodedModels = MODEL_OPTIONS[providerId].map((model) => ({
apiName: model.name, apiName: model.name,
type: "cloud", displayName: model.displayName,
description: model.description,
tag: model.tag,
tagColor: model.tagColor,
maxOutputTokens: model.maxOutputTokens,
contextWindow: model.contextWindow,
temperature: model.temperature,
dollarSigns: model.dollarSigns,
type: "cloud" as const,
})); }));
} else { } else {
console.warn( console.warn(
`Provider "${providerId}" is cloud type but not found in MODEL_OPTIONS.`, `Provider "${providerId}" is cloud type but not found in builtin catalog or MODEL_OPTIONS.`,
); );
} }
} }
......
import log from "electron-log";
import { z } from "zod";
import type {
LanguageModel,
LanguageModelProvider,
} from "@/ipc/types/language-model";
import {
ThemeGenerationModelOptionSchema,
type ThemeGenerationModelOption,
} from "@/ipc/types/templates";
import {
CLOUD_PROVIDERS,
GEMINI_3_1_PRO_PREVIEW,
GPT_5_2_MODEL_NAME,
GPT_5_NANO,
MODEL_OPTIONS,
OPUS_4_6,
PROVIDER_TO_ENV_VAR,
SONNET_4_6,
GEMINI_3_FLASH,
} from "./language_model_constants";
const logger = log.scope("remote_language_model_catalog");
const REMOTE_LANGUAGE_MODEL_CATALOG_TIMEOUT_MS = 5_000;
const DEFAULT_CACHE_TTL_MS = 60 * 60 * 1000;
const FALLBACK_CACHE_TTL_MS = 30 * 1000;
function getRemoteLanguageModelCatalogUrl() {
if (process.env.DYAD_LANGUAGE_MODEL_CATALOG_URL) {
return process.env.DYAD_LANGUAGE_MODEL_CATALOG_URL;
}
if (process.env.E2E_TEST_BUILD === "true" && process.env.FAKE_LLM_PORT) {
return `http://localhost:${process.env.FAKE_LLM_PORT}/api/language-model-catalog`;
}
return "https://api.dyad.sh/v1/language-model-catalog";
}
export type { ThemeGenerationModelOption };
const CatalogProviderSchema = z.object({
id: z.string(),
displayName: z.string(),
type: z.literal("cloud"),
hasFreeTier: z.boolean().optional(),
websiteUrl: z.string().optional(),
secondary: z.boolean().optional(),
supportsThinking: z.boolean().optional(),
gatewayPrefix: z.string().optional(),
});
const CatalogModelSchema = z.object({
apiName: z.string(),
displayName: z.string(),
description: z.string(),
tag: z.string().optional(),
tagColor: z.string().optional(),
dollarSigns: z.number().optional(),
temperature: z.number().optional(),
maxOutputTokens: z.number().optional(),
contextWindow: z.number().optional(),
lifecycle: z
.object({
stage: z.enum(["stable", "preview", "deprecated"]).optional(),
})
.optional(),
});
const KNOWN_BUILTIN_MODEL_ALIASES = [
"dyad/theme-generator/google",
"dyad/theme-generator/anthropic",
"dyad/theme-generator/openai",
"dyad/auto/openai",
"dyad/auto/anthropic",
"dyad/auto/google",
"dyad/help-bot/default",
] as const;
export type BuiltinModelAlias = (typeof KNOWN_BUILTIN_MODEL_ALIASES)[number];
const LanguageModelCatalogResponseSchema = z.object({
version: z.string(),
expiresAt: z.string().datetime().optional(),
providers: z.array(CatalogProviderSchema),
modelsByProvider: z.record(z.string(), z.array(CatalogModelSchema)),
aliases: z.array(
z.object({
id: z.string(),
resolvedModel: z.object({
providerId: z.string(),
apiName: z.string(),
}),
displayName: z.string().optional(),
purpose: z.enum(["theme-generation", "auto-mode", "help-bot"]).optional(),
}),
),
curatedSelections: z
.object({
themeGenerationOptions: z.array(ThemeGenerationModelOptionSchema),
})
.optional(),
});
type LanguageModelCatalogResponse = z.infer<
typeof LanguageModelCatalogResponseSchema
>;
type BuiltinLanguageModelCatalog = {
providers: LanguageModelProvider[];
modelsByProvider: Record<string, LanguageModel[]>;
aliases: LanguageModelCatalogResponse["aliases"];
themeGenerationOptions: ThemeGenerationModelOption[];
expiresAt: number;
source: "fallback" | "remote";
version?: string;
};
type ResolvedBuiltinModel = {
providerId: string;
apiName: string;
};
let builtinCatalogCache: BuiltinLanguageModelCatalog | null = null;
let builtinCatalogFetchPromise: Promise<BuiltinLanguageModelCatalog> | null =
null;
const DEFAULT_THEME_GENERATION_OPTIONS: ThemeGenerationModelOption[] = [
{ id: "dyad/theme-generator/google", label: "Google" },
{ id: "dyad/theme-generator/anthropic", label: "Anthropic" },
{ id: "dyad/theme-generator/openai", label: "OpenAI" },
];
function buildFallbackCatalog(): BuiltinLanguageModelCatalog {
const providers: LanguageModelProvider[] = Object.entries(
CLOUD_PROVIDERS,
).map(([providerId, provider]) => ({
id: providerId,
name: provider.displayName,
hasFreeTier: provider.hasFreeTier,
websiteUrl: provider.websiteUrl,
gatewayPrefix: provider.gatewayPrefix,
secondary: provider.secondary,
envVarName:
PROVIDER_TO_ENV_VAR[providerId as keyof typeof PROVIDER_TO_ENV_VAR] ??
undefined,
type: "cloud",
}));
const modelsByProvider: Record<string, LanguageModel[]> = {};
for (const [providerId, models] of Object.entries(MODEL_OPTIONS)) {
modelsByProvider[providerId] = models.map((model) => ({
apiName: model.name,
displayName: model.displayName,
description: model.description,
tag: model.tag,
tagColor: model.tagColor,
maxOutputTokens: model.maxOutputTokens,
contextWindow: model.contextWindow,
temperature: model.temperature,
dollarSigns: model.dollarSigns,
type: "cloud",
}));
}
return {
providers,
modelsByProvider,
aliases: [
{
id: "dyad/theme-generator/google",
resolvedModel: {
providerId: "google",
apiName: GEMINI_3_1_PRO_PREVIEW,
},
displayName: "Google",
purpose: "theme-generation",
},
{
id: "dyad/theme-generator/anthropic",
resolvedModel: {
providerId: "anthropic",
apiName: OPUS_4_6,
},
displayName: "Anthropic",
purpose: "theme-generation",
},
{
id: "dyad/theme-generator/openai",
resolvedModel: {
providerId: "openai",
apiName: GPT_5_2_MODEL_NAME,
},
displayName: "OpenAI",
purpose: "theme-generation",
},
{
id: "dyad/auto/openai",
resolvedModel: {
providerId: "openai",
apiName: GPT_5_2_MODEL_NAME,
},
displayName: "Auto OpenAI",
purpose: "auto-mode",
},
{
id: "dyad/auto/anthropic",
resolvedModel: {
providerId: "anthropic",
apiName: SONNET_4_6,
},
displayName: "Auto Anthropic",
purpose: "auto-mode",
},
{
id: "dyad/auto/google",
resolvedModel: {
providerId: "google",
apiName: GEMINI_3_FLASH,
},
displayName: "Auto Google",
purpose: "auto-mode",
},
{
id: "dyad/help-bot/default",
resolvedModel: {
providerId: "openai",
apiName: GPT_5_NANO,
},
displayName: "Help Bot",
purpose: "help-bot",
},
],
themeGenerationOptions: DEFAULT_THEME_GENERATION_OPTIONS,
expiresAt: Date.now() + FALLBACK_CACHE_TTL_MS,
source: "fallback",
};
}
function convertRemoteCatalog(
remoteCatalog: LanguageModelCatalogResponse,
): BuiltinLanguageModelCatalog {
const providers: LanguageModelProvider[] = remoteCatalog.providers.map(
(provider) => ({
id: provider.id,
name: provider.displayName,
hasFreeTier: provider.hasFreeTier,
websiteUrl: provider.websiteUrl,
gatewayPrefix:
provider.gatewayPrefix ??
CLOUD_PROVIDERS[provider.id as keyof typeof CLOUD_PROVIDERS]
?.gatewayPrefix,
secondary: provider.secondary,
envVarName:
PROVIDER_TO_ENV_VAR[provider.id as keyof typeof PROVIDER_TO_ENV_VAR] ??
undefined,
type: "cloud",
}),
);
const modelsByProvider = Object.fromEntries(
Object.entries(remoteCatalog.modelsByProvider).map(
([providerId, models]) => [
providerId,
models.map((model) => ({
apiName: model.apiName,
displayName: model.displayName,
description: model.description,
tag: model.tag,
tagColor: model.tagColor,
maxOutputTokens: model.maxOutputTokens,
contextWindow: model.contextWindow,
temperature: model.temperature,
dollarSigns: model.dollarSigns,
type: "cloud" as const,
})),
],
),
);
const parsedExpiresAt = remoteCatalog.expiresAt
? new Date(remoteCatalog.expiresAt).getTime()
: NaN;
// Merge required builtin aliases that may be missing from the remote catalog.
const fallback = buildFallbackCatalog();
const remoteAliasIds = new Set(remoteCatalog.aliases.map((a) => a.id));
const mergedAliases = [
...remoteCatalog.aliases,
...fallback.aliases.filter((a) => !remoteAliasIds.has(a.id)),
];
return {
providers,
modelsByProvider,
aliases: mergedAliases,
themeGenerationOptions: remoteCatalog.curatedSelections
?.themeGenerationOptions?.length
? remoteCatalog.curatedSelections.themeGenerationOptions
: DEFAULT_THEME_GENERATION_OPTIONS,
expiresAt:
Number.isFinite(parsedExpiresAt) && parsedExpiresAt > Date.now()
? parsedExpiresAt
: Date.now() + DEFAULT_CACHE_TTL_MS,
source: "remote",
version: remoteCatalog.version,
};
}
async function fetchRemoteCatalog(): Promise<BuiltinLanguageModelCatalog | null> {
const controller = new AbortController();
const catalogUrl = getRemoteLanguageModelCatalogUrl();
const timeoutId = setTimeout(
() => controller.abort(),
REMOTE_LANGUAGE_MODEL_CATALOG_TIMEOUT_MS,
);
try {
logger.info("Fetching remote language model catalog", {
catalogUrl,
timeoutMs: REMOTE_LANGUAGE_MODEL_CATALOG_TIMEOUT_MS,
});
const response = await fetch(catalogUrl, {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(
`Failed to fetch language model catalog: ${response.status} ${response.statusText}`,
);
}
const rawCatalog = await response.json();
const remoteCatalog = LanguageModelCatalogResponseSchema.parse(rawCatalog);
const convertedCatalog = convertRemoteCatalog(remoteCatalog);
logger.info("Loaded remote language model catalog", {
catalogUrl,
version: convertedCatalog.version,
providerCount: convertedCatalog.providers.length,
aliasCount: convertedCatalog.aliases.length,
themeGenerationOptionCount:
convertedCatalog.themeGenerationOptions.length,
});
return convertedCatalog;
} catch (error) {
logger.warn("Failed to fetch remote language model catalog", {
catalogUrl,
error,
});
return null;
} finally {
clearTimeout(timeoutId);
}
}
function getFallbackCatalog(): BuiltinLanguageModelCatalog {
return buildFallbackCatalog();
}
function triggerBackgroundRefresh(): void {
if (!builtinCatalogFetchPromise) {
logger.info("Starting background refresh for language model catalog", {
cachedSource: builtinCatalogCache?.source,
});
builtinCatalogFetchPromise = (async () => {
try {
const remoteCatalog = await fetchRemoteCatalog();
builtinCatalogCache = remoteCatalog ?? getFallbackCatalog();
logger.info("Background refresh completed for language model catalog", {
source: builtinCatalogCache.source,
version: builtinCatalogCache.version,
providerCount: builtinCatalogCache.providers.length,
});
return builtinCatalogCache;
} finally {
builtinCatalogFetchPromise = null;
}
})();
} else {
logger.info(
"Skipping language model catalog refresh because one is in flight",
);
}
}
export async function getBuiltinLanguageModelCatalog(): Promise<BuiltinLanguageModelCatalog> {
if (builtinCatalogCache && builtinCatalogCache.expiresAt > Date.now()) {
logger.info("Returning cached language model catalog", {
source: builtinCatalogCache.source,
version: builtinCatalogCache.version,
expiresAt: new Date(builtinCatalogCache.expiresAt).toISOString(),
});
return builtinCatalogCache;
}
// Serve stale data while revalidating in the background to avoid blocking
// callers on a network fetch (stale-while-revalidate pattern).
if (builtinCatalogCache) {
logger.info(
"Returning stale language model catalog and refreshing in background",
{
source: builtinCatalogCache.source,
version: builtinCatalogCache.version,
},
);
triggerBackgroundRefresh();
return builtinCatalogCache;
}
// On cold start, wait for the initial remote fetch so renderer queries do not
// cache fallback data and miss the later background refresh result.
if (!builtinCatalogFetchPromise) {
logger.info("Cold start catalog request; waiting for initial remote fetch");
builtinCatalogFetchPromise = (async () => {
try {
const remoteCatalog = await fetchRemoteCatalog();
builtinCatalogCache = remoteCatalog ?? getFallbackCatalog();
logger.info(
"Initialized language model catalog after cold start fetch",
{
source: builtinCatalogCache.source,
version: builtinCatalogCache.version,
providerCount: builtinCatalogCache.providers.length,
aliasCount: builtinCatalogCache.aliases.length,
},
);
return builtinCatalogCache;
} finally {
builtinCatalogFetchPromise = null;
}
})();
} else {
logger.info("Cold start catalog request is waiting on in-flight fetch");
}
return builtinCatalogFetchPromise;
}
export async function getThemeGenerationModelOptions(): Promise<
ThemeGenerationModelOption[]
> {
const catalog = await getBuiltinLanguageModelCatalog();
return catalog.themeGenerationOptions;
}
export async function resolveBuiltinModelAlias(
aliasId: BuiltinModelAlias | string,
): Promise<ResolvedBuiltinModel | null> {
const catalog = await getBuiltinLanguageModelCatalog();
const resolvedModel =
catalog.aliases.find((alias) => alias.id === aliasId)?.resolvedModel ??
null;
logger.info("Resolved builtin model alias", {
aliasId,
source: catalog.source,
version: catalog.version,
resolvedProviderId: resolvedModel?.providerId,
resolvedApiName: resolvedModel?.apiName,
});
return resolvedModel;
}
...@@ -246,6 +246,7 @@ export type { ...@@ -246,6 +246,7 @@ export type {
DeleteCustomThemeParams, DeleteCustomThemeParams,
ThemeGenerationMode, ThemeGenerationMode,
ThemeGenerationModel, ThemeGenerationModel,
ThemeGenerationModelOption,
ThemeInputSource, ThemeInputSource,
CrawlStatus, CrawlStatus,
GenerateThemePromptParams, GenerateThemePromptParams,
......
...@@ -93,13 +93,17 @@ export type DeleteCustomThemeParams = z.infer< ...@@ -93,13 +93,17 @@ export type DeleteCustomThemeParams = z.infer<
export const ThemeGenerationModeSchema = z.enum(["inspired", "high-fidelity"]); export const ThemeGenerationModeSchema = z.enum(["inspired", "high-fidelity"]);
export type ThemeGenerationMode = z.infer<typeof ThemeGenerationModeSchema>; export type ThemeGenerationMode = z.infer<typeof ThemeGenerationModeSchema>;
export const ThemeGenerationModelSchema = z.enum([ export const ThemeGenerationModelSchema = z.string().min(1);
"gemini-3-pro",
"claude-opus-4.5",
"gpt-5.2",
]);
export type ThemeGenerationModel = z.infer<typeof ThemeGenerationModelSchema>; export type ThemeGenerationModel = z.infer<typeof ThemeGenerationModelSchema>;
export const ThemeGenerationModelOptionSchema = z.object({
id: z.string(),
label: z.string(),
});
export type ThemeGenerationModelOption = z.infer<
typeof ThemeGenerationModelOptionSchema
>;
// Theme input source (images or URL) // Theme input source (images or URL)
export const ThemeInputSourceSchema = z.enum(["images", "url"]); export const ThemeInputSourceSchema = z.enum(["images", "url"]);
export type ThemeInputSource = z.infer<typeof ThemeInputSourceSchema>; export type ThemeInputSource = z.infer<typeof ThemeInputSourceSchema>;
...@@ -209,6 +213,12 @@ export const templateContracts = { ...@@ -209,6 +213,12 @@ export const templateContracts = {
output: z.array(CustomThemeSchema), output: z.array(CustomThemeSchema),
}), }),
getThemeGenerationModelOptions: defineContract({
channel: "get-theme-generation-model-options",
input: z.void(),
output: z.array(ThemeGenerationModelOptionSchema),
}),
createCustomTheme: defineContract({ createCustomTheme: defineContract({
channel: "create-custom-theme", channel: "create-custom-theme",
input: CreateCustomThemeParamsSchema, input: CreateCustomThemeParamsSchema,
......
...@@ -15,13 +15,9 @@ import type { ...@@ -15,13 +15,9 @@ import type {
} from "../../lib/schemas"; } from "../../lib/schemas";
import { getEnvVar } from "./read_env"; import { getEnvVar } from "./read_env";
import log from "electron-log"; import log from "electron-log";
import { import { FREE_OPENROUTER_MODEL_NAMES } from "../shared/language_model_constants";
FREE_OPENROUTER_MODEL_NAMES,
GEMINI_3_FLASH,
GPT_5_2_MODEL_NAME,
SONNET_4_6,
} from "../shared/language_model_constants";
import { getLanguageModelProviders } from "../shared/language_model_helpers"; import { getLanguageModelProviders } from "../shared/language_model_helpers";
import { resolveBuiltinModelAlias } from "../shared/remote_language_model_catalog";
import { LanguageModelProvider } from "@/ipc/types"; import { LanguageModelProvider } from "@/ipc/types";
import { import {
createDyadEngine, createDyadEngine,
...@@ -35,24 +31,11 @@ import { createFallback } from "./fallback_ai_model"; ...@@ -35,24 +31,11 @@ import { createFallback } from "./fallback_ai_model";
const dyadEngineUrl = process.env.DYAD_ENGINE_URL; const dyadEngineUrl = process.env.DYAD_ENGINE_URL;
const AUTO_MODELS = [ const AUTO_MODEL_ALIASES = [
{ "dyad/auto/openai",
provider: "openai", "dyad/auto/anthropic",
name: GPT_5_2_MODEL_NAME, "dyad/auto/google",
}, ] as const;
{
provider: "anthropic",
name: SONNET_4_6,
},
{
provider: "google",
name: GEMINI_3_FLASH,
},
{
provider: "google",
name: "gemini-2.5-flash",
},
];
export interface ModelClient { export interface ModelClient {
model: LanguageModel; model: LanguageModel;
...@@ -113,7 +96,7 @@ export async function getModelClient( ...@@ -113,7 +96,7 @@ export async function getModelClient(
// Do not use free variant (for openrouter). // Do not use free variant (for openrouter).
const modelName = model.name.split(":free")[0]; const modelName = model.name.split(":free")[0];
const proModelClient = getProModelClient({ const proModelClient = await getProModelClient({
model, model,
settings, settings,
provider, provider,
...@@ -158,25 +141,30 @@ export async function getModelClient( ...@@ -158,25 +141,30 @@ export async function getModelClient(
isEngineEnabled: false, isEngineEnabled: false,
}; };
} }
for (const autoModel of AUTO_MODELS) { for (const autoModelAlias of AUTO_MODEL_ALIASES) {
const resolvedModel = await resolveBuiltinModelAlias(autoModelAlias);
if (!resolvedModel) {
continue;
}
const providerInfo = allProviders.find( const providerInfo = allProviders.find(
(p) => p.id === autoModel.provider, (p) => p.id === resolvedModel.providerId,
); );
const envVarName = providerInfo?.envVarName; const envVarName = providerInfo?.envVarName;
const apiKey = const apiKey =
settings.providerSettings?.[autoModel.provider]?.apiKey?.value || settings.providerSettings?.[resolvedModel.providerId]?.apiKey?.value ||
(envVarName ? getEnvVar(envVarName) : undefined); (envVarName ? getEnvVar(envVarName) : undefined);
if (apiKey) { if (apiKey) {
logger.log( logger.log(
`Using provider: ${autoModel.provider} model: ${autoModel.name}`, `Using provider: ${resolvedModel.providerId} model: ${resolvedModel.apiName}`,
); );
// Recursively call with the specific model found // Recursively call with the specific model found
return await getModelClient( return await getModelClient(
{ {
provider: autoModel.provider, provider: resolvedModel.providerId,
name: autoModel.name, name: resolvedModel.apiName,
}, },
settings, settings,
); );
...@@ -190,7 +178,7 @@ export async function getModelClient( ...@@ -190,7 +178,7 @@ export async function getModelClient(
return getRegularModelClient(model, settings, providerConfig); return getRegularModelClient(model, settings, providerConfig);
} }
function getProModelClient({ async function getProModelClient({
model, model,
settings, settings,
provider, provider,
...@@ -200,23 +188,52 @@ function getProModelClient({ ...@@ -200,23 +188,52 @@ function getProModelClient({
settings: UserSettings; settings: UserSettings;
provider: DyadEngineProvider; provider: DyadEngineProvider;
modelId: string; modelId: string;
}): ModelClient { }): Promise<ModelClient> {
if ( if (
settings.selectedChatMode === "local-agent" && settings.selectedChatMode === "local-agent" &&
model.provider === "auto" && model.provider === "auto" &&
model.name === "auto" model.name === "auto"
) { ) {
const providers = await getLanguageModelProviders();
const fallbackModels = await Promise.all(
AUTO_MODEL_ALIASES.map(async (aliasId) => {
const resolvedModel = await resolveBuiltinModelAlias(aliasId);
if (!resolvedModel) {
return null;
}
const resolvedProvider = providers.find(
(providerInfo) => providerInfo.id === resolvedModel.providerId,
);
const resolvedModelId = `${
resolvedProvider?.gatewayPrefix || ""
}${resolvedModel.apiName}`;
if (resolvedModel.providerId === "openai") {
return provider.responses(resolvedModel.apiName, {
providerId: resolvedModel.providerId,
});
}
return provider(resolvedModelId, {
providerId: resolvedModel.providerId,
});
}),
);
const validModels = fallbackModels.filter(
(candidate) => candidate !== null,
);
if (validModels.length === 0) {
throw new Error("No auto-mode models could be resolved from the catalog");
}
return { return {
// We need to do the fallback here (and not server-side) // We need to do the fallback here (and not server-side)
// because GPT-5* models need to use responses API to get // because GPT-5* models need to use responses API to get
// full functionality (e.g. thinking summaries). // full functionality (e.g. thinking summaries).
model: createFallback({ model: createFallback({
models: [ models: validModels,
// openai requires no prefix.
provider.responses(`${GPT_5_2_MODEL_NAME}`, { providerId: "openai" }),
provider(`anthropic/${SONNET_4_6}`, { providerId: "anthropic" }),
provider(`gemini/${GEMINI_3_FLASH}`, { providerId: "google" }),
],
}), }),
// Using openAI as the default provider. // Using openAI as the default provider.
// TODO: we should remove this and rely on the provider id passed into the provider(). // TODO: we should remove this and rely on the provider id passed into the provider().
......
...@@ -183,6 +183,9 @@ export const queryKeys = { ...@@ -183,6 +183,9 @@ export const queryKeys = {
customThemes: { customThemes: {
all: ["custom-themes"] as const, all: ["custom-themes"] as const,
}, },
themeGenerationModelOptions: {
all: ["theme-generation-model-options"] as const,
},
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Templates // Templates
......
...@@ -26,25 +26,17 @@ import type { ...@@ -26,25 +26,17 @@ import type {
SaveThemeImageParams, SaveThemeImageParams,
SaveThemeImageResult, SaveThemeImageResult,
CleanupThemeImagesParams, CleanupThemeImagesParams,
ThemeGenerationModelOption,
} from "@/ipc/types"; } from "@/ipc/types";
import { webCrawlResponseSchema } from "./local_agent/tools/web_crawl"; import { webCrawlResponseSchema } from "./local_agent/tools/web_crawl";
import {
getThemeGenerationModelOptions,
resolveBuiltinModelAlias,
} from "@/ipc/shared/remote_language_model_catalog";
const logger = log.scope("themes_handlers"); const logger = log.scope("themes_handlers");
const handle = createLoggedHandler(logger); const handle = createLoggedHandler(logger);
// Shared model map for theme generation (used by both image and URL handlers)
const THEME_GENERATION_MODEL_MAP: Record<
string,
{ provider: string; name: string }
> = {
"gemini-3-pro": { provider: "google", name: "gemini-3-pro-preview" },
"claude-opus-4.5": {
provider: "anthropic",
name: "claude-opus-4-5",
},
"gpt-5.2": { provider: "openai", name: "gpt-5.2" },
};
// Timeout for web crawl requests (120 seconds) // Timeout for web crawl requests (120 seconds)
const WEB_CRAWL_TIMEOUT_MS = 120_000; const WEB_CRAWL_TIMEOUT_MS = 120_000;
...@@ -327,6 +319,13 @@ export function registerThemesHandlers() { ...@@ -327,6 +319,13 @@ export function registerThemesHandlers() {
})); }));
}); });
handle(
"get-theme-generation-model-options",
async (): Promise<ThemeGenerationModelOption[]> => {
return getThemeGenerationModelOptions();
},
);
// Create custom theme // Create custom theme
handle( handle(
"create-custom-theme", "create-custom-theme",
...@@ -605,13 +604,21 @@ Modern dark theme with purple accents for testing. ...@@ -605,13 +604,21 @@ Modern dark theme with purple accents for testing.
} }
// Validate and map model selection // Validate and map model selection
const selectedModel = THEME_GENERATION_MODEL_MAP[params.model]; const selectedModel = await resolveBuiltinModelAlias(params.model);
if (!selectedModel) { if (!selectedModel) {
throw new Error("Invalid model selection"); throw new Error(
`Invalid model selection: alias "${params.model}" could not be resolved`,
);
} }
// Use the selected model for theme generation // Use the selected model for theme generation
const { modelClient } = await getModelClient(selectedModel, settings); const { modelClient } = await getModelClient(
{
provider: selectedModel.providerId,
name: selectedModel.apiName,
},
settings,
);
// Select system prompt based on generation mode // Select system prompt based on generation mode
const systemPrompt = const systemPrompt =
...@@ -755,9 +762,11 @@ Modern theme extracted from website for testing. ...@@ -755,9 +762,11 @@ Modern theme extracted from website for testing.
} }
// Validate and map model selection // Validate and map model selection
const selectedModel = THEME_GENERATION_MODEL_MAP[params.model]; const selectedModel = await resolveBuiltinModelAlias(params.model);
if (!selectedModel) { if (!selectedModel) {
throw new Error("Invalid model selection"); throw new Error(
`Invalid model selection: alias "${params.model}" could not be resolved`,
);
} }
// Get API key for Dyad Engine // Get API key for Dyad Engine
...@@ -837,7 +846,13 @@ Modern theme extracted from website for testing. ...@@ -837,7 +846,13 @@ Modern theme extracted from website for testing.
logger.log(`Website crawled successfully: ${params.url}`); logger.log(`Website crawled successfully: ${params.url}`);
// Use the selected model for theme generation // Use the selected model for theme generation
const { modelClient } = await getModelClient(selectedModel, settings); const { modelClient } = await getModelClient(
{
provider: selectedModel.providerId,
name: selectedModel.apiName,
},
settings,
);
// Select system prompt based on generation mode // Select system prompt based on generation mode
const systemPrompt = const systemPrompt =
......
...@@ -78,6 +78,166 @@ app.get("/health", (req, res) => { ...@@ -78,6 +78,166 @@ app.get("/health", (req, res) => {
res.send("OK"); res.send("OK");
}); });
app.get("/api/language-model-catalog", (req, res) => {
res.json({
version: "e2e-test-catalog-v1",
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
providers: [
{
id: "openai",
displayName: "OpenAI",
type: "cloud",
},
{
id: "anthropic",
displayName: "Anthropic",
type: "cloud",
},
{
id: "google",
displayName: "Google",
type: "cloud",
hasFreeTier: true,
gatewayPrefix: "gemini/",
},
],
modelsByProvider: {
openai: [
{
apiName: "gpt-5.2",
displayName: "GPT 5.2",
description: "Remote catalog OpenAI model",
},
{
apiName: "gpt-5",
temperature: 1,
displayName: "GPT 5",
description: "Remote catalog OpenAI model",
},
{
apiName: "gpt-5.2-remote-only",
displayName: "GPT 5.2 Remote Only",
description: "Remote-only catalog OpenAI model for E2E coverage",
},
],
anthropic: [
{
apiName: "claude-opus-4-6",
displayName: "Claude Opus 4.6",
description: "Remote catalog Anthropic model",
},
{
apiName: "claude-sonnet-4-6",
displayName: "Claude Sonnet 4.6",
description: "Remote catalog Anthropic model",
},
{
apiName: "claude-opus-4-5",
displayName: "Claude Opus 4.5",
description: "Remote catalog Anthropic model",
maxOutputTokens: 32_000,
},
{
apiName: "claude-sonnet-4-20250514",
displayName: "Claude Sonnet 4",
description: "Remote catalog Anthropic model",
maxOutputTokens: 32_000,
},
],
google: [
{
apiName: "gemini-3.1-pro-preview",
displayName: "Gemini 3.1 Pro (Preview)",
description: "Remote catalog Google model",
},
{
apiName: "gemini-2.5-pro",
displayName: "Gemini 2.5 Pro",
description: "Remote catalog Google model",
maxOutputTokens: 65_535,
},
],
},
aliases: [
{
id: "dyad/theme-generator/google",
resolvedModel: {
providerId: "google",
apiName: "gemini-3.1-pro-preview",
},
displayName: "Google Remote",
purpose: "theme-generation",
},
{
id: "dyad/theme-generator/anthropic",
resolvedModel: {
providerId: "anthropic",
apiName: "claude-sonnet-4-6",
},
displayName: "Anthropic Remote",
purpose: "theme-generation",
},
{
id: "dyad/theme-generator/openai",
resolvedModel: {
providerId: "openai",
apiName: "gpt-5.2",
},
displayName: "OpenAI Remote",
purpose: "theme-generation",
},
{
id: "dyad/auto/openai",
resolvedModel: {
providerId: "openai",
apiName: "gpt-5.2",
},
purpose: "auto-mode",
},
{
id: "dyad/auto/anthropic",
resolvedModel: {
providerId: "anthropic",
apiName: "claude-sonnet-4-6",
},
purpose: "auto-mode",
},
{
id: "dyad/auto/google",
resolvedModel: {
providerId: "google",
apiName: "gemini-3.1-pro-preview",
},
purpose: "auto-mode",
},
{
id: "dyad/help-bot/default",
resolvedModel: {
providerId: "openai",
apiName: "gpt-5.2",
},
purpose: "help-bot",
},
],
curatedSelections: {
themeGenerationOptions: [
{
id: "dyad/theme-generator/google",
label: "Google Remote",
},
{
id: "dyad/theme-generator/anthropic",
label: "Anthropic Remote",
},
{
id: "dyad/theme-generator/openai",
label: "OpenAI Remote",
},
],
},
});
});
// Ollama-specific endpoints // Ollama-specific endpoints
app.get("/ollama/api/tags", (req, res) => { app.get("/ollama/api/tags", (req, res) => {
const ollamaModels = { const ollamaModels = {
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论