Before you submit
What happened?
Nothing I configure under Settings → Media providers changes which model actually generates images. Every image project is created pinned to vela/gpt-image-2 (OpenDesign Cloud), and there is no setting anywhere in the product that changes that. With BYOK keys configured and no Cloud subscription, image generation just fails.
I read this in the source rather than guessing from behavior. It is not one bug; it is six, and flipping the default: true flag would only paper over it.
1. The catalog default is a compiled-in Cloud model
apps/web/src/media/models.ts:329
{ id: 'vela/gpt-image-2', label: 'gpt-image-2 (Cloud)', hint: 'OpenDesign Cloud · managed image generation and editing', provider: 'vela', caps: ['t2i', 'i2i'], default: true },
apps/web/src/media/models.ts:670
export const DEFAULT_IMAGE_MODEL = IMAGE_MODELS.find((m) => m.default)?.id ?? IMAGE_MODELS[0]!.id;
Every creation path seeds from this constant — NewProjectPanel.tsx:361, EntryView.tsx:466, home-hero/media-surfaces.ts:98,317 — and persists the result into projects.metadata_json.imageModel.
2. There is no user-level media default anywhere in the product
This is the core of the issue. The one control that looks like a default is not one.
apps/web/src/components/SettingsDialog.tsx:5696 renders "Image generation model" (settings.byokImageModel) only under two BYOK chat protocols:
{apiProtocol === 'senseaudio' || apiProtocol === 'aihubmix' ? ( <label className="field"> <span className="field-label">{t('settings.byokImageModel')}</span>
and the code comment at SettingsDialog.tsx:1154 states the scope explicitly:
// byokImageModel applies to the protocols that inject the daemon-side // generate_image tool (SenseAudio, AIHubMix) — flipping to another BYOK // tab shouldn't carry an image-model choice into, say, the OpenAI form.
It feeds the BYOK chat proxy's injected tool (apps/daemon/src/routes/chat.ts:1677, defaultImageModel). It is not a global media default and was never designed as one.
Meanwhile the Media providers section (SettingsDialog.tsx:7798–7960) offers only: provider chips, API key, Base URL, and a per-provider Model field. No default-model selector. So a user who pastes an OpenAI, Grok, Nano Banana, Volcengine, Fal, ImageRouter or Leonardo key has configured a provider that the app will never choose on its own.
3. byokMediaDefaults — the only media-preference payload — is sent on exactly one runtime
apps/web/src/components/ProjectView.tsx:7786
const daemonByokOpenCode = config.agentId === 'byok-opencode';
apps/web/src/components/ProjectView.tsx:7889
...(daemonByokOpenCode ? { byokMediaDefaults: byokMediaDefaultsForRun({ /* ... */ }), } : {}),
The second streamViaDaemon call site (ProjectView.tsx:8062) passes it unconditionally, but that whole branch is the BYOK-OpenCode path. So for every local CLI agent — Claude Code, Codex, Cursor, DeepSeek Harness, Gemini, Qwen, Copilot — and for amr, no media preference of any kind reaches the daemon.
4. mediaDefaultsForRuntime re-pins Cloud for amr
apps/daemon/src/prompts/system.ts:566
function mediaDefaultsForRuntime(agentId, defaults) { if (agentId !== 'amr') return defaults; return { ...defaults, imageModel: defaults?.imageModel?.trim() || 'vela/gpt-image-2', videoModel: defaults?.videoModel?.trim() || 'vela/doubao-seedance-2-0-260128', }; }
5. The per-provider Model field cannot select a provider — routing is fixed by the catalog id
apps/daemon/src/media/index.ts:578
} else if (def.provider === 'vela' && surface === 'image') { /* renderVelaImage */ } else if (def.provider === 'openai' && surface === 'image') { /* renderOpenAIImage */
providers..model from media-config.json is only consulted after routing has landed on that provider (index.ts:991, 1026, 1680: const wireModel = (credentials.model || ctx.wireModel).trim()). It is a wire-name override, not a selector.
6. The alias map is not an escape hatch either
I checked this so maintainers don't have to. apps/daemon/src/media/index.ts:504
const canonicalModel = def.id; // provider already fixed by def const wireModel = await resolveModelAlias(projectRoot, canonicalModel);
Alias resolution runs after def is looked up, so OD_MEDIA_MODEL_ALIASES / media-config.json.aliases can only rename the wire model within the provider already chosen. Aliasing vela/gpt-image-2 → gpt-image-2 still routes through renderVelaImage.
7. Separate, self-contained bug: two creation paths disagree about prompt-template models
NewProjectPanel.tsx:565 honors the template's declared model, with a comment explaining exactly why that matters:
// When the user picks a curated prompt template, propagate the template's // declared modelandaspectonto the actual project state. Without // this the user picks (e.g.) a HyperFrames template butvideoModel // stays on the default seedance — the agent then dispatches the wrong // model and the render path mismatches the prompt. function handleImagePromptTemplate(pick) { const m = pick?.summary.model; if (m && (IMAGE_MODELS.some((x) => x.id === m) || m.startsWith('aihubmix-'))) setImageModel(m);
But the home-entry path throws that away — EntryView.tsx:466:
if (kind === 'image') { return { kind, imageModel: DEFAULT_IMAGE_MODEL, imageAspect: '1:1' }; }
and home-hero/media-surfaces.ts:317 likewise seeds model: DEFAULT_IMAGE_MODEL.
This is precisely what produced my broken project. It was created from the home entry using the bundled "3D Stone Staircase Evolution Infographic" template, whose summary declares "model": "gpt-image-2" — yet the project was written with "imageModel": "vela/gpt-image-2". The stored metadata contains both values, contradicting each other (see Logs).
Two contributing factors
Near-identical labels. models.ts:329 is gpt-image-2 (Cloud) (provider vela); models.ts:336 is gpt-image-2 (provider openai). They sit adjacent in the picker. A user who has just pasted an OpenAI key reasonably reads the first as theirs — only the Cloud entries carry a provider suffix, the BYOK ones do not.
The Cloud default is offered without checking it is usable. With no vela login, renderVelaImage shells out to vela media models --json (apps/daemon/src/media/vela.ts:265) and the raw CLI string surfaces verbatim as a 400. vela is one of only two providers that rethrow instead of falling back (index.ts:779), and nothing verifies the Cloud route is authenticated before handing that model out as the default on a fresh install.
Steps to reproduce
- Install OpenDesign 0.19.2 on a clean profile. Do not run vela login (no OpenDesign Cloud subscription).
- Select any local CLI agent in the top bar — DeepSeek Harness, Claude Code, Codex, etc.
- Settings → Media providers → paste a valid OpenAI API key, leave Base URL at https://api.openai.com/v1, save. The provider shows Configured.
- From the home entry, pick the bundled image prompt template "3D Stone Staircase Evolution Infographic" (its summary declares model: gpt-image-2) and create the project.
- Enter a prompt and generate.
- Generation fails. media_tasks.model is vela/gpt-image-2 — the OpenAI key is never reached, and the template's declared model was discarded.
- Look for any way to make the configured provider the default for future projects. There is none: the Media providers section has no default selector, and "Image generation model" only appears under the SenseAudio / AIHubMix BYOK tabs.
Expected behavior
A configured media provider must be usable as the default — for every provider and every agent, not just OpenAI and not just byok-opencode. Concretely:
- Introduce a real user-level media default per surface (image / video / speech / sfx / music), editable in Settings → Media providers, offering any model whose provider is configured. This is the missing primitive; items 2–4 all depend on it.
- Demote DEFAULT_IMAGE_MODEL to a last-resort fallback, consulted only when the user has expressed no preference. All three creation paths (NewProjectPanel, EntryView, home-hero) should seed from the stored preference first.
- Send media defaults on every runtime. Rename byokMediaDefaults → mediaDefaults and drop the daemonByokOpenCode gate at ProjectView.tsx:7889. If the daemon can dispatch media for a local CLI agent, it must also receive that agent's media preferences.
- Stop overwriting an explicit preference in mediaDefaultsForRuntime. The amr branch should fill a Cloud default only when the user has chosen nothing.
- Fix the creation-path inconsistency (defect 7). EntryView.tsx:466 and home-hero/media-surfaces.ts:317 should propagate the prompt template's declared model/aspect exactly as NewProjectPanel.handleImagePromptTemplate already does. This one is independently shippable and low-risk.
- Gate Cloud models on an authenticated Vela profile. On a machine with no valid profile, no vela/* model should be selected as a default; show those entries as requiring sign-in, and surface a real message instead of passing through Error: saved profile is missing control key; run \vela login``.
- Disambiguate catalog labels — suffix the provider on every entry, so gpt-image-2 (OpenAI) sits next to gpt-image-2 (Cloud).
- Allow the model to be changed after creation, and/or migrate existing projects.metadata_json.imageModel, so users are not stuck with a value a hardcoded constant chose for them.
OpenDesign version
0.19.2
Platform
macOS (Apple Silicon)
Logs (optional)
Screenshots (optional)
No response
Additional context
No response
Before you submit
od --version.What happened?
Nothing I configure under Settings → Media providers changes which model actually generates images. Every image project is created pinned to vela/gpt-image-2 (OpenDesign Cloud), and there is no setting anywhere in the product that changes that. With BYOK keys configured and no Cloud subscription, image generation just fails.
I read this in the source rather than guessing from behavior. It is not one bug; it is six, and flipping the default: true flag would only paper over it.
1. The catalog default is a compiled-in Cloud model
apps/web/src/media/models.ts:329
{ id: 'vela/gpt-image-2', label: 'gpt-image-2 (Cloud)', hint: 'OpenDesign Cloud · managed image generation and editing', provider: 'vela', caps: ['t2i', 'i2i'], default: true },apps/web/src/media/models.ts:670
export const DEFAULT_IMAGE_MODEL = IMAGE_MODELS.find((m) => m.default)?.id ?? IMAGE_MODELS[0]!.id;Every creation path seeds from this constant — NewProjectPanel.tsx:361, EntryView.tsx:466, home-hero/media-surfaces.ts:98,317 — and persists the result into projects.metadata_json.imageModel.
2. There is no user-level media default anywhere in the product
This is the core of the issue. The one control that looks like a default is not one.
apps/web/src/components/SettingsDialog.tsx:5696 renders "Image generation model" (settings.byokImageModel) only under two BYOK chat protocols:
{apiProtocol === 'senseaudio' || apiProtocol === 'aihubmix' ? ( <label className="field"> <span className="field-label">{t('settings.byokImageModel')}</span>and the code comment at SettingsDialog.tsx:1154 states the scope explicitly:
// byokImageModel applies to the protocols that inject the daemon-side // generate_image tool (SenseAudio, AIHubMix) — flipping to another BYOK // tab shouldn't carry an image-model choice into, say, the OpenAI form.It feeds the BYOK chat proxy's injected tool (apps/daemon/src/routes/chat.ts:1677, defaultImageModel). It is not a global media default and was never designed as one.
Meanwhile the Media providers section (SettingsDialog.tsx:7798–7960) offers only: provider chips, API key, Base URL, and a per-provider Model field. No default-model selector. So a user who pastes an OpenAI, Grok, Nano Banana, Volcengine, Fal, ImageRouter or Leonardo key has configured a provider that the app will never choose on its own.
3. byokMediaDefaults — the only media-preference payload — is sent on exactly one runtime
apps/web/src/components/ProjectView.tsx:7786
const daemonByokOpenCode = config.agentId === 'byok-opencode';apps/web/src/components/ProjectView.tsx:7889
...(daemonByokOpenCode ? { byokMediaDefaults: byokMediaDefaultsForRun({ /* ... */ }), } : {}),The second streamViaDaemon call site (ProjectView.tsx:8062) passes it unconditionally, but that whole branch is the BYOK-OpenCode path. So for every local CLI agent — Claude Code, Codex, Cursor, DeepSeek Harness, Gemini, Qwen, Copilot — and for amr, no media preference of any kind reaches the daemon.
4. mediaDefaultsForRuntime re-pins Cloud for amr
apps/daemon/src/prompts/system.ts:566
function mediaDefaultsForRuntime(agentId, defaults) { if (agentId !== 'amr') return defaults; return { ...defaults, imageModel: defaults?.imageModel?.trim() || 'vela/gpt-image-2', videoModel: defaults?.videoModel?.trim() || 'vela/doubao-seedance-2-0-260128', }; }5. The per-provider Model field cannot select a provider — routing is fixed by the catalog id
apps/daemon/src/media/index.ts:578
} else if (def.provider === 'vela' && surface === 'image') { /* renderVelaImage */ } else if (def.provider === 'openai' && surface === 'image') { /* renderOpenAIImage */providers..model from media-config.json is only consulted after routing has landed on that provider (index.ts:991, 1026, 1680: const wireModel = (credentials.model || ctx.wireModel).trim()). It is a wire-name override, not a selector.
6. The alias map is not an escape hatch either
I checked this so maintainers don't have to. apps/daemon/src/media/index.ts:504
const canonicalModel = def.id; // provider already fixed by def const wireModel = await resolveModelAlias(projectRoot, canonicalModel);Alias resolution runs after def is looked up, so OD_MEDIA_MODEL_ALIASES / media-config.json.aliases can only rename the wire model within the provider already chosen. Aliasing vela/gpt-image-2 → gpt-image-2 still routes through renderVelaImage.
7. Separate, self-contained bug: two creation paths disagree about prompt-template models
NewProjectPanel.tsx:565 honors the template's declared model, with a comment explaining exactly why that matters:
// When the user picks a curated prompt template, propagate the template's // declaredmodelandaspectonto the actual project state. Without // this the user picks (e.g.) a HyperFrames template butvideoModel// stays on the default seedance — the agent then dispatches the wrong // model and the render path mismatches the prompt. function handleImagePromptTemplate(pick) { const m = pick?.summary.model; if (m && (IMAGE_MODELS.some((x) => x.id === m) || m.startsWith('aihubmix-'))) setImageModel(m);But the home-entry path throws that away — EntryView.tsx:466:
if (kind === 'image') { return { kind, imageModel: DEFAULT_IMAGE_MODEL, imageAspect: '1:1' }; }and home-hero/media-surfaces.ts:317 likewise seeds model: DEFAULT_IMAGE_MODEL.
This is precisely what produced my broken project. It was created from the home entry using the bundled "3D Stone Staircase Evolution Infographic" template, whose summary declares "model": "gpt-image-2" — yet the project was written with "imageModel": "vela/gpt-image-2". The stored metadata contains both values, contradicting each other (see Logs).
Two contributing factors
Near-identical labels. models.ts:329 is gpt-image-2 (Cloud) (provider vela); models.ts:336 is gpt-image-2 (provider openai). They sit adjacent in the picker. A user who has just pasted an OpenAI key reasonably reads the first as theirs — only the Cloud entries carry a provider suffix, the BYOK ones do not.
The Cloud default is offered without checking it is usable. With no vela login, renderVelaImage shells out to vela media models --json (apps/daemon/src/media/vela.ts:265) and the raw CLI string surfaces verbatim as a 400. vela is one of only two providers that rethrow instead of falling back (index.ts:779), and nothing verifies the Cloud route is authenticated before handing that model out as the default on a fresh install.
Steps to reproduce
Expected behavior
A configured media provider must be usable as the default — for every provider and every agent, not just OpenAI and not just byok-opencode. Concretely:
OpenDesign version
0.19.2
Platform
macOS (Apple Silicon)
Logs (optional)
Screenshots (optional)
No response
Additional context
No response