From a161574d710fff48b634a9e195e0173e2ff064d9 Mon Sep 17 00:00:00 2001 From: Professor Synapse <131487882+ProfSynapse@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:27:38 -0400 Subject: [PATCH] feat(images): make Nano Banana 2.1 the default image model Google recommends gemini-nano-banana-2.1 over gemini-3.1-flash-image (now deprecated, no shutdown date) at half the price. The default was repeated as a literal in a dozen places; it is now one constant, DEFAULT_IMAGE_MODEL, and Nano Banana 2.1 is first in the Google and OpenRouter catalogs, which is what the generateImage tool and the chat picker fall back to (Google's first entry was Nano Banana 2 Lite). Saved image choices are unchanged. Adds a gated paid image smoke lane; verified live: one 1K image each via Google direct (generateContent) and OpenRouter. Co-Authored-By: Claude Opus 5.5 --- cli-first-tool-schemas.json | 4 +- docs/changelog.md | 1 + schemas/5.20.4/cli-tools.json | 4 +- schemas/5.20.4/mcp-tools.json | 2 +- .../promptManager/tools/generateImage.ts | 5 +- .../llm/adapters/google/GeminiImageAdapter.ts | 39 +++---- .../openrouter/OpenRouterImageAdapter.ts | 31 +++--- src/services/llm/types/ImageTypes.ts | 12 ++- src/settings/tabs/DefaultsTab.ts | 3 +- src/types/llm/ProviderTypes.ts | 3 +- src/ui/chat/components/ChatSettingsModal.ts | 3 +- .../services/ModelAgentDefaultsResolver.ts | 3 +- src/ui/chat/services/ModelAgentManager.ts | 3 +- .../debug/image-generation-live-smoke.test.ts | 102 ++++++++++++++++++ tests/unit/DefaultImageModel.test.ts | 24 +++++ tests/unit/GeminiImageAdapter.test.ts | 6 +- tests/unit/OpenRouterImageAdapter.test.ts | 6 +- 17 files changed, 198 insertions(+), 53 deletions(-) create mode 100644 tests/debug/image-generation-live-smoke.test.ts create mode 100644 tests/unit/DefaultImageModel.test.ts diff --git a/cli-first-tool-schemas.json b/cli-first-tool-schemas.json index 5b15b712..fc6edc6d 100644 --- a/cli-first-tool-schemas.json +++ b/cli-first-tool-schemas.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "generatedAt": "2026-10-14T17:55:57.022Z", + "generatedAt": "2026-10-14T18:25:35.464Z", "selector": "--help", "toolCount": 80, "agentCount": 14, @@ -1787,7 +1787,7 @@ "type": "string", "required": false, "positional": false, - "description": "[OPTIONAL] Image generation model (default: gemini-3.1-flash-image). Available: gemini-3.1-flash-lite-image, gemini-3.1-flash-image, gemini-nano-banana-2.1, gemini-3-pro-image, gpt-5.4-image-2, flux-2-pro, flux-2-flex, flux-2-klein-4b, gpt-image-2, seedream-4.5, seedream-5-lite, gpt-image-2.5-sunburst, gpt-image-2.5-flare, flux-3-image, seedream-5-pro, seedream-5-flash (One of: [\"gemini-3.1-flash-lite-image\", \"gemini-3.1-flash-image\", \"gemini-nano-banana-2.1\", \"gemini-3-pro-image\", \"gpt-5.4-image-2\", \"flux-2-pro\", \"flux-2-flex\", \"flux-2-klein-4b\", \"gpt-image-2\", \"seedream-4.5\", \"seedream-5-lite\", \"gpt-image-2.5-sunburst\", \"gpt-image-2.5-flare\", \"flux-3-image\", \"seedream-5-pro\", \"seedream-5-flash\"])" + "description": "[OPTIONAL] Image generation model (default: gemini-3.1-flash-image). Available: gemini-nano-banana-2.1, gemini-3.1-flash-lite-image, gemini-3.1-flash-image, gemini-3-pro-image, gpt-5.4-image-2, flux-2-pro, flux-2-flex, flux-2-klein-4b, gpt-image-2, seedream-4.5, seedream-5-lite, gpt-image-2.5-sunburst, gpt-image-2.5-flare, flux-3-image, seedream-5-pro, seedream-5-flash (One of: [\"gemini-nano-banana-2.1\", \"gemini-3.1-flash-lite-image\", \"gemini-3.1-flash-image\", \"gemini-3-pro-image\", \"gpt-5.4-image-2\", \"flux-2-pro\", \"flux-2-flex\", \"flux-2-klein-4b\", \"gpt-image-2\", \"seedream-4.5\", \"seedream-5-lite\", \"gpt-image-2.5-sunburst\", \"gpt-image-2.5-flare\", \"flux-3-image\", \"seedream-5-pro\", \"seedream-5-flash\"])" }, { "name": "aspectRatio", diff --git a/docs/changelog.md b/docs/changelog.md index 5b5a0e68..faa70af1 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -38,6 +38,7 @@ - Google-direct video generation now uses **Gemini Omni Flash** (`gemini-omni-1.1-flash`) through Google's Interactions API. Google shuts down every Veo 3.1 preview model on 2026-10-22 and names Omni as the replacement. Omni makes 3 to 10 second clips at 16:9 or 9:16, in 360p, 720p, 1080p or 4K, always with audio, and can start from a reference image. A negative prompt is added to the prompt as "Do not include: ...", because Omni has no separate field for it. Veo 3.1 and Veo 3.1 Fast through OpenRouter are unchanged, and Veo 3.1 Fast via OpenRouter stays the default video model. - The Veo 3.1 preview models are gone from the Google video list. A saved default, or a `generateVideo` call, that names one runs on Gemini Omni Flash and keeps its aspect ratio and resolution. The result says which model was used instead. - Image models that are shut down or scheduled to shut down are removed: GPT Image 1, 1.5 and 1 Mini (OpenAI), Gemini 2.5 Flash Image and the Gemini 3.1 Flash Image and 3 Pro Image previews (Google and OpenRouter), and GPT-5 Image and GPT-5 Image Mini (OpenRouter, built on GPT Image 1). A saved default, a conversation's image model, or a `generateImage` call that names one uses the provider's documented replacement: GPT Image 2.5 Sunburst for GPT Image 1, 1.5 and GPT-5 Image; GPT Image 2.5 Flare for the Mini models; Nano Banana 2 Lite for Gemini 2.5 Flash Image; Nano Banana 2.1 and Nano Banana Pro for the previews. A saved Gemini 2.5 Flash Image default used to move to Nano Banana 2; it now follows Google's recommendation, Nano Banana 2 Lite. +- **Nano Banana 2.1 is the default image model** for new installs and wherever no image model is chosen, on Google direct and OpenRouter. Google recommends it over Nano Banana 2 (`gemini-3.1-flash-image`, now deprecated with no shutdown date) at half the price. A saved image model choice is not changed. Nano Banana 2.1 starts at 1K; it has no 512px size. - These replacements are applied when settings load and are not written back to your settings file, so other devices that sync it and still run an older Nexus are not affected. The new id is saved the next time you change that setting. **Fixes** diff --git a/schemas/5.20.4/cli-tools.json b/schemas/5.20.4/cli-tools.json index 5b15b712..fc6edc6d 100644 --- a/schemas/5.20.4/cli-tools.json +++ b/schemas/5.20.4/cli-tools.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "generatedAt": "2026-10-14T17:55:57.022Z", + "generatedAt": "2026-10-14T18:25:35.464Z", "selector": "--help", "toolCount": 80, "agentCount": 14, @@ -1787,7 +1787,7 @@ "type": "string", "required": false, "positional": false, - "description": "[OPTIONAL] Image generation model (default: gemini-3.1-flash-image). Available: gemini-3.1-flash-lite-image, gemini-3.1-flash-image, gemini-nano-banana-2.1, gemini-3-pro-image, gpt-5.4-image-2, flux-2-pro, flux-2-flex, flux-2-klein-4b, gpt-image-2, seedream-4.5, seedream-5-lite, gpt-image-2.5-sunburst, gpt-image-2.5-flare, flux-3-image, seedream-5-pro, seedream-5-flash (One of: [\"gemini-3.1-flash-lite-image\", \"gemini-3.1-flash-image\", \"gemini-nano-banana-2.1\", \"gemini-3-pro-image\", \"gpt-5.4-image-2\", \"flux-2-pro\", \"flux-2-flex\", \"flux-2-klein-4b\", \"gpt-image-2\", \"seedream-4.5\", \"seedream-5-lite\", \"gpt-image-2.5-sunburst\", \"gpt-image-2.5-flare\", \"flux-3-image\", \"seedream-5-pro\", \"seedream-5-flash\"])" + "description": "[OPTIONAL] Image generation model (default: gemini-3.1-flash-image). Available: gemini-nano-banana-2.1, gemini-3.1-flash-lite-image, gemini-3.1-flash-image, gemini-3-pro-image, gpt-5.4-image-2, flux-2-pro, flux-2-flex, flux-2-klein-4b, gpt-image-2, seedream-4.5, seedream-5-lite, gpt-image-2.5-sunburst, gpt-image-2.5-flare, flux-3-image, seedream-5-pro, seedream-5-flash (One of: [\"gemini-nano-banana-2.1\", \"gemini-3.1-flash-lite-image\", \"gemini-3.1-flash-image\", \"gemini-3-pro-image\", \"gpt-5.4-image-2\", \"flux-2-pro\", \"flux-2-flex\", \"flux-2-klein-4b\", \"gpt-image-2\", \"seedream-4.5\", \"seedream-5-lite\", \"gpt-image-2.5-sunburst\", \"gpt-image-2.5-flare\", \"flux-3-image\", \"seedream-5-pro\", \"seedream-5-flash\"])" }, { "name": "aspectRatio", diff --git a/schemas/5.20.4/mcp-tools.json b/schemas/5.20.4/mcp-tools.json index 5f64d000..013eee91 100644 --- a/schemas/5.20.4/mcp-tools.json +++ b/schemas/5.20.4/mcp-tools.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "nexusVersion": "5.20.4", - "generatedAt": "2026-10-14T17:55:57.022Z", + "generatedAt": "2026-10-14T18:25:35.464Z", "toolCount": 2, "tools": [ { diff --git a/src/agents/promptManager/tools/generateImage.ts b/src/agents/promptManager/tools/generateImage.ts index 95c1f4d3..482f4b81 100644 --- a/src/agents/promptManager/tools/generateImage.ts +++ b/src/agents/promptManager/tools/generateImage.ts @@ -21,6 +21,7 @@ import { getRetiredImageModel, type RetiredModel } from '../../../services/llm/types/RetiredMediaModels'; +import { DEFAULT_IMAGE_MODEL } from '../../../services/llm/types/ImageTypes'; export interface GenerateImageParams extends CommonParameters { prompt: string; @@ -233,7 +234,7 @@ export class GenerateImageTool extends BaseTool = { }; export const GEMINI_IMAGE_MODEL_SPECS: Readonly> = { + // First on purpose: the generateImage tool and the chat image picker fall + // back to a provider's first catalog entry, and this is the default (DEFAULT_IMAGE_MODEL). + // GA 2026-10-06; Google's recommended successor to gemini-3.1-flash-image. + // Half the image-token price of Nano Banana 2 and no 512 size. The Gemini + // docs now show only Interactions API samples; the model page lists Batch + // API support, which is a generateContent-only feature. + // https://ai.google.dev/gemini-api/docs/models/gemini-nano-banana-2.1 + 'gemini-nano-banana-2.1': { + displayName: 'Nano Banana 2.1', + costPerImage: 0.0336, + pricingNote: '1120 image tokens at $30/M for 1K; 2K is $0.0504 (1680 tokens), 4K $0.113 (3780 tokens)', + imageTokenPricePerMillion: 30, + maxReferenceImages: 14, + imageSizes: ['1K', '2K', '4K'], + contextWindow: 131072, + lastUpdated: '2026-10-14' + }, 'gemini-3.1-flash-lite-image': { displayName: 'Nano Banana 2 Lite', costPerImage: 0.0336, @@ -148,21 +166,6 @@ export const GEMINI_IMAGE_MODEL_SPECS: Readonly { + getImageModelPricing(model: string = DEFAULT_IMAGE_MODEL): Promise { const basePrice = this.modelSpecs[model]?.costPerImage ?? 0.039; return Promise.resolve({ diff --git a/src/services/llm/adapters/openrouter/OpenRouterImageAdapter.ts b/src/services/llm/adapters/openrouter/OpenRouterImageAdapter.ts index 8e73dcc6..99eec114 100644 --- a/src/services/llm/adapters/openrouter/OpenRouterImageAdapter.ts +++ b/src/services/llm/adapters/openrouter/OpenRouterImageAdapter.ts @@ -32,6 +32,7 @@ import { } from '../types'; import { BRAND_NAME } from '../../../../constants/branding'; import { readImageDimensions, sniffImageFormat } from '../shared/imageDimensions'; +import { DEFAULT_IMAGE_MODEL } from '../../types/ImageTypes'; type OutputFormat = 'png' | 'jpeg' | 'webp'; @@ -126,7 +127,18 @@ const ASPECT_RATIO_TO_DIMENSIONS: Record = { */ export const OPENROUTER_IMAGE_MODEL_SPECS: Readonly> = { // First on purpose: the generateImage tool and the chat image picker fall - // back to a provider's first catalog entry, and this is the adapter default. + // back to a provider's first catalog entry, and this is the default (DEFAULT_IMAGE_MODEL). + // Added 2026-10-14 from /api/v1/images/models//endpoints. + 'gemini-nano-banana-2.1': { + openRouterId: 'google/gemini-nano-banana-2.1', + displayName: 'Nano Banana 2.1 (via OpenRouter)', + costPerImage: 0.0336, + pricingNote: '1120 output tokens at $30/M for 1K; 2K and 4K use more tokens; no 512', + maxReferenceImages: 14, + resolutions: ['1K', '2K', '4K'], + contextWindow: 65536, + lastUpdated: '2026-10-14' + }, 'gemini-3.1-flash-image': { openRouterId: 'google/gemini-3.1-flash-image', displayName: 'Nano Banana 2 (via OpenRouter)', @@ -229,17 +241,6 @@ export const OPENROUTER_IMAGE_MODEL_SPECS: Readonly/endpoints. - 'gemini-nano-banana-2.1': { - openRouterId: 'google/gemini-nano-banana-2.1', - displayName: 'Nano Banana 2.1 (via OpenRouter)', - costPerImage: 0.0336, - pricingNote: '1120 output tokens at $30/M for 1K; 2K and 4K use more tokens; no 512', - maxReferenceImages: 14, - resolutions: ['1K', '2K', '4K'], - contextWindow: 65536, - lastUpdated: '2026-10-14' - }, 'gpt-image-2.5-sunburst': { openRouterId: 'openai/gpt-image-2.5-sunburst', displayName: 'GPT Image 2.5 Sunburst (via OpenRouter)', @@ -316,7 +317,7 @@ export class OpenRouterImageAdapter extends BaseImageAdapter { private httpReferer: string; private xTitle: string; - private readonly defaultModel = 'gemini-3.1-flash-image'; + private readonly defaultModel = DEFAULT_IMAGE_MODEL; // Aspect ratios accepted across the catalog. Individual endpoints may // support fewer; OpenRouter rejects those with a 400 naming the parameter. @@ -327,7 +328,7 @@ export class OpenRouterImageAdapter extends BaseImageAdapter { constructor(config?: ProviderConfig & { vault?: Vault; httpReferer?: string; xTitle?: string }) { const apiKey = config?.apiKey || ''; - super(apiKey, 'gemini-3.1-flash-image', config?.baseUrl); + super(apiKey, DEFAULT_IMAGE_MODEL, config?.baseUrl); if (config?.vault) { this.vault = config.vault; @@ -574,7 +575,7 @@ export class OpenRouterImageAdapter extends BaseImageAdapter { * Per-image list price at the default resolution. The actual charge is in * the response's `usage.cost` and is recorded as `metadata.reportedCostUsd`. */ - getImageModelPricing(model = 'gemini-3.1-flash-image'): Promise { + getImageModelPricing(model: string = DEFAULT_IMAGE_MODEL): Promise { const basePrice = this.modelSpecs[model]?.costPerImage ?? 0.05; return Promise.resolve({ diff --git a/src/services/llm/types/ImageTypes.ts b/src/services/llm/types/ImageTypes.ts index 595616c3..385f0dcf 100644 --- a/src/services/llm/types/ImageTypes.ts +++ b/src/services/llm/types/ImageTypes.ts @@ -15,6 +15,14 @@ import { CostDetails, LLMProviderError } from '../adapters/types'; +/** + * The default image model, shared by Google direct and OpenRouter (both catalog + * it under this id, first in their catalogs). Every fallback reads this + * constant; do not repeat the literal. Google recommends Nano Banana 2.1 over + * gemini-3.1-flash-image (now deprecated, no shutdown date) at half the price. + */ +export const DEFAULT_IMAGE_MODEL = 'gemini-nano-banana-2.1'; + // Core image generation parameter interfaces export interface ImageGenerationParams { prompt: string; @@ -245,13 +253,13 @@ export type ImageModel = | 'flux-2-pro' // Black Forest Labs FLUX.2 Pro (OpenRouter only) | 'flux-2-flex' // Black Forest Labs FLUX.2 Flex (OpenRouter only) | 'flux-2-klein-4b' // Black Forest Labs FLUX.2 Klein 4B (OpenRouter only) - | 'gemini-3.1-flash-image' // Google Nano Banana 2, GA id, the default (Google direct and OpenRouter) + | 'gemini-3.1-flash-image' // Google Nano Banana 2, GA id (deprecated by Google in favour of 2.1, no shutdown date) | 'gemini-3.1-flash-lite-image' // Google Nano Banana 2 Lite (Google direct and OpenRouter) | 'gemini-3-pro-image' // Google Nano Banana Pro, GA id (Google direct and OpenRouter) | 'gpt-image-2' // OpenAI GPT Image 2 (OpenRouter, and the OpenAI adapter) | 'seedream-4.5' // ByteDance Seedream 4.5 (OpenRouter only) | 'seedream-5-lite' // ByteDance Seedream 5.0 Lite (OpenRouter only) - | 'gemini-nano-banana-2.1' // Google Nano Banana 2.1 (Google direct and OpenRouter) + | 'gemini-nano-banana-2.1' // Google Nano Banana 2.1, the default (Google direct and OpenRouter) | 'flux-3-image' // Black Forest Labs FLUX.3 Image (OpenRouter only) | 'seedream-5-pro' // ByteDance Seedream 5.0 Pro (OpenRouter only) | 'seedream-5-flash'; // ByteDance Seedream 5.0 Flash (OpenRouter only) diff --git a/src/settings/tabs/DefaultsTab.ts b/src/settings/tabs/DefaultsTab.ts index 9d47d53d..30d01257 100644 --- a/src/settings/tabs/DefaultsTab.ts +++ b/src/settings/tabs/DefaultsTab.ts @@ -25,6 +25,7 @@ import { SemanticSearchSettings, type SemanticSearchRuntime } from './defaults/S import { SemanticSearchOffModal } from './defaults/SemanticSearchOffModal'; import { ConfirmModal } from '../components/ConfirmModal'; import { SEMANTIC_DELETE_MODAL_COPY } from '../../components/shared/chatSettings/copy'; +import { DEFAULT_IMAGE_MODEL } from '../../services/llm/types/ImageTypes'; export interface DefaultsTabServices { app: App; @@ -139,7 +140,7 @@ export class DefaultsTab { agentTemperature: llmSettings?.agentTemperature, agentContextWindowOverrides: { ...(llmSettings?.agentContextWindowOverrides ?? {}) }, imageProvider: llmSettings?.defaultImageModel?.provider || 'google', - imageModel: llmSettings?.defaultImageModel?.model || 'gemini-3.1-flash-image', + imageModel: llmSettings?.defaultImageModel?.model || DEFAULT_IMAGE_MODEL, speechProvider: speechChosen ? speech?.provider : undefined, speechModel: speechChosen ? speech?.model : undefined, speechVoice: speechChosen ? speech?.voice : undefined, diff --git a/src/types/llm/ProviderTypes.ts b/src/types/llm/ProviderTypes.ts index c7976ca9..9582d6c7 100644 --- a/src/types/llm/ProviderTypes.ts +++ b/src/types/llm/ProviderTypes.ts @@ -5,6 +5,7 @@ import type { OAuthState } from '../../services/oauth/IOAuthProvider'; import type { VideoAspectRatio, VideoResolution } from '../../services/llm/types/VideoTypes'; +import { DEFAULT_IMAGE_MODEL } from '../../services/llm/types/ImageTypes'; /** * Thinking effort levels - unified across all providers @@ -260,7 +261,7 @@ export const DEFAULT_LLM_PROVIDER_SETTINGS: LLMProviderSettings = { }, defaultImageModel: { provider: 'google', - model: 'gemini-3.1-flash-image' + model: DEFAULT_IMAGE_MODEL }, // Veo 3.1 Fast via OpenRouter: GA, on a route that is not being retired, and // priced like Google-direct Gemini Omni Flash (about $0.10/s at 720p with diff --git a/src/ui/chat/components/ChatSettingsModal.ts b/src/ui/chat/components/ChatSettingsModal.ts index a98141b8..03de2639 100644 --- a/src/ui/chat/components/ChatSettingsModal.ts +++ b/src/ui/chat/components/ChatSettingsModal.ts @@ -14,6 +14,7 @@ import { Settings } from '../../../settings'; import { getContextWindowOverrideKey } from '../utils/ContextWindowSettings'; import { resolveSubagentTarget } from '../../../components/shared/chatSettings/subagentTarget'; import { getRegistryContextWindow } from '../../../services/llm/utils/modelCapabilityLookup'; +import { DEFAULT_IMAGE_MODEL } from '../../../services/llm/types/ImageTypes'; /** * Type for the NexusPlugin with settings property @@ -189,7 +190,7 @@ export class ChatSettingsModal extends Modal { agentTemperature: this.modelAgentManager.getAgentTemperature(), agentContextWindowOverrides, imageProvider: this.modelAgentManager.getImageProvider() || llmSettings?.defaultImageModel?.provider || 'google', - imageModel: this.modelAgentManager.getImageModel() || llmSettings?.defaultImageModel?.model || 'gemini-3.1-flash-image', + imageModel: this.modelAgentManager.getImageModel() || llmSettings?.defaultImageModel?.model || DEFAULT_IMAGE_MODEL, speechProvider: this.modelAgentManager.getSpeechProvider() || llmSettings?.defaultSpeechModel?.provider, speechModel: this.modelAgentManager.getSpeechModel() || llmSettings?.defaultSpeechModel?.model, speechVoice: this.modelAgentManager.getSpeechVoice() || llmSettings?.defaultSpeechModel?.voice, diff --git a/src/ui/chat/services/ModelAgentDefaultsResolver.ts b/src/ui/chat/services/ModelAgentDefaultsResolver.ts index 9275408e..548d4e8a 100644 --- a/src/ui/chat/services/ModelAgentDefaultsResolver.ts +++ b/src/ui/chat/services/ModelAgentDefaultsResolver.ts @@ -14,6 +14,7 @@ import type { ModelAgentWorkspaceContextService, ModelAgentWorkspaceState } from import { buildOpenAICompatibleModels, OPENAI_COMPATIBLE_CONTEXT_WINDOW } from '../../../services/llm/adapters/openai-compatible/OpenAICompatibleConfig'; import { resolveAgentTemperature } from '../../../services/llm/utils/SubagentModelResolver'; import { getContextWindowOverrideKey } from '../utils/ContextWindowSettings'; +import { DEFAULT_IMAGE_MODEL } from '../../../services/llm/types/ImageTypes'; interface PluginWithSettings { settings?: { @@ -124,7 +125,7 @@ export class ModelAgentDefaultsResolver { agentTemperature: resolveAgentTemperature(null, llmProviders), agentContextWindow: this.resolveAgentContextWindowDefault(llmProviders), imageProvider: llmProviders?.defaultImageModel?.provider || 'google', - imageModel: llmProviders?.defaultImageModel?.model || 'gemini-3.1-flash-image', + imageModel: llmProviders?.defaultImageModel?.model || DEFAULT_IMAGE_MODEL, speechProvider: llmProviders?.defaultSpeechModel?.provider || null, speechModel: llmProviders?.defaultSpeechModel?.model || null, speechVoice: llmProviders?.defaultSpeechModel?.voice || null, diff --git a/src/ui/chat/services/ModelAgentManager.ts b/src/ui/chat/services/ModelAgentManager.ts index a0519262..269fae31 100644 --- a/src/ui/chat/services/ModelAgentManager.ts +++ b/src/ui/chat/services/ModelAgentManager.ts @@ -54,6 +54,7 @@ import { type ConversationAgentSettings } from '../../../services/llm/utils/SubagentModelResolver'; import { getRetiredImageModel } from '../../../services/llm/types/RetiredMediaModels'; +import { DEFAULT_IMAGE_MODEL } from '../../../services/llm/types/ImageTypes'; /** A positive whole-token budget, or null for "use the model's default". */ function normalizeAgentContextWindow(value: unknown): number | null { @@ -126,7 +127,7 @@ export class ModelAgentManager { /** Subagent context budget for this chat; null uses the subagent model's default. */ private agentContextWindow: number | null = null; private imageProvider: 'google' | 'openrouter' | 'openai' = 'google'; - private imageModel = 'gemini-3.1-flash-image'; + private imageModel: string = DEFAULT_IMAGE_MODEL; private speechProvider: DefaultSpeechModelSettings['provider'] | null = null; private speechModel: DefaultSpeechModelSettings['model'] | null = null; private speechVoice: DefaultSpeechModelSettings['voice'] | null = null; diff --git a/tests/debug/image-generation-live-smoke.test.ts b/tests/debug/image-generation-live-smoke.test.ts new file mode 100644 index 00000000..8fe8bae9 --- /dev/null +++ b/tests/debug/image-generation-live-smoke.test.ts @@ -0,0 +1,102 @@ +/** + * Live smoke for the default image model through the shipped image adapters. + * Paid: one 1K image per provider. Skipped unless both flags are set. + * + * RUN_LIVE_IMAGE_SMOKE=1 RUN_PAID_IMAGE_SMOKE=1 \ + * npx jest tests/debug/image-generation-live-smoke.test.ts --runInBand + * + * Keys come from the environment or the repo's .env: GEMINI_API_KEY (Google + * direct) and OPENROUTER_API_KEY. A provider whose key is missing is skipped. + */ + +import * as fs from 'fs'; +import * as https from 'https'; +import * as path from 'path'; +import { __setRequestUrlMock } from '../mocks/obsidian'; +import { GeminiImageAdapter } from '../../src/services/llm/adapters/google/GeminiImageAdapter'; +import { OpenRouterImageAdapter } from '../../src/services/llm/adapters/openrouter/OpenRouterImageAdapter'; +import { DEFAULT_IMAGE_MODEL } from '../../src/services/llm/types/ImageTypes'; + +const RUN = process.env.RUN_LIVE_IMAGE_SMOKE === '1' && process.env.RUN_PAID_IMAGE_SMOKE === '1'; +const describeLive = RUN ? describe : describe.skip; + +function getEnv(name: string): string | undefined { + if (process.env[name]) return process.env[name]; + const envPath = path.join(process.cwd(), '.env'); + if (!fs.existsSync(envPath)) return undefined; + const line = fs.readFileSync(envPath, 'utf8').split(/\r?\n/).find(candidate => candidate.startsWith(`${name}=`)); + return line?.slice(name.length + 1).replace(/^['"]|['"]$/g, ''); +} + +/** Route requestUrl to the network. `trace` receives "METHOD url status" lines, never headers or keys. */ +function setRealRequestUrl(trace: string[]): void { + __setRequestUrlMock(async (request) => { + const headers = request.headers as Record | undefined; + const body = typeof request.body === 'string' ? request.body : undefined; + const response = await requestRaw(request.url, request.method || 'GET', headers, body); + trace.push(`${request.method || 'GET'} ${request.url.replace(/([?&]key=)[^&]+/, '$1REDACTED')} ${response.status}`); + const text = response.buffer.toString('utf8'); + const json = text.trim().startsWith('{') ? safeJson(text) : null; + return { + status: response.status, + headers: Object.fromEntries(Object.entries(response.headers).map(([key, value]) => [key, Array.isArray(value) ? value.join(', ') : String(value ?? '')])), + text, + json, + arrayBuffer: response.buffer.buffer.slice(response.buffer.byteOffset, response.buffer.byteOffset + response.buffer.byteLength), + }; + }); +} + +function requestRaw(url: string, method: string, headers: Record | undefined, body: string | undefined): + Promise<{ status: number; headers: Record; buffer: Buffer }> { + return new Promise((resolve, reject) => { + const req = https.request(url, { method, headers }, (res) => { + const chunks: Buffer[] = []; + res.on('data', chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + res.on('end', () => resolve({ status: res.statusCode || 0, headers: res.headers, buffer: Buffer.concat(chunks) })); + }); + req.on('error', reject); + req.setTimeout(120_000, () => req.destroy(new Error('request timeout'))); + if (body) req.write(body); + req.end(); + }); +} + +function safeJson(text: string): unknown { + try { return JSON.parse(text); } catch { return null; } +} + +function expectImage(data: Buffer): void { + const png = data.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); + const jpeg = data[0] === 0xff && data[1] === 0xd8; + const webp = data.subarray(0, 4).toString('ascii') === 'RIFF' && data.subarray(8, 12).toString('ascii') === 'WEBP'; + expect(png || jpeg || webp).toBe(true); + expect(data.byteLength).toBeGreaterThan(10_000); +} + +describeLive('default image model live smoke', () => { + jest.setTimeout(180_000); + const prompt = 'A small red bicycle leaning on a white wall, flat illustration'; + + it('Google direct generates with the default model and no model override', async () => { + const apiKey = getEnv('GEMINI_API_KEY') || getEnv('GOOGLE_API_KEY'); + if (!apiKey) return; + const trace: string[] = []; + setRealRequestUrl(trace); + const adapter = new GeminiImageAdapter({ apiKey }); + const result = await adapter.generateImage({ prompt, provider: 'google', imageSize: '1K', aspectRatio: '1:1', savePath: 'smoke.png' }); + console.log(`[image smoke] google ${DEFAULT_IMAGE_MODEL}: ${result.format} ${result.imageData.byteLength} bytes; ${trace.join(' | ')}`); + expectImage(result.imageData); + }); + + it('OpenRouter generates with the default model and no model override', async () => { + const apiKey = getEnv('OPENROUTER_API_KEY'); + if (!apiKey) return; + const trace: string[] = []; + setRealRequestUrl(trace); + const adapter = new OpenRouterImageAdapter({ apiKey }); + const result = await adapter.generateImage({ prompt, provider: 'openrouter', imageSize: '1K', aspectRatio: '1:1', savePath: 'smoke.png' }); + console.log(`[image smoke] openrouter ${DEFAULT_IMAGE_MODEL}: ${result.format} ${result.imageData.byteLength} bytes; ${trace.join(' | ')}`); + expectImage(result.imageData); + }); +}); diff --git a/tests/unit/DefaultImageModel.test.ts b/tests/unit/DefaultImageModel.test.ts new file mode 100644 index 00000000..a6ad3b7a --- /dev/null +++ b/tests/unit/DefaultImageModel.test.ts @@ -0,0 +1,24 @@ +/** + * The default image model is declared once (DEFAULT_IMAGE_MODEL) and every + * fallback must agree with it: the shipped settings, both adapters, and the + * first catalog entry the generateImage tool and the chat picker fall back to. + */ +import { DEFAULT_IMAGE_MODEL } from '../../src/services/llm/types/ImageTypes'; +import { DEFAULT_LLM_PROVIDER_SETTINGS } from '../../src/types/llm/ProviderTypes'; +import { GEMINI_IMAGE_MODEL_SPECS } from '../../src/services/llm/adapters/google/GeminiImageAdapter'; +import { OPENROUTER_IMAGE_MODEL_SPECS } from '../../src/services/llm/adapters/openrouter/OpenRouterImageAdapter'; + +describe('default image model', () => { + it('is Nano Banana 2.1, Google\'s recommended successor to gemini-3.1-flash-image', () => { + expect(DEFAULT_IMAGE_MODEL).toBe('gemini-nano-banana-2.1'); + }); + + it('is what a fresh install saves', () => { + expect(DEFAULT_LLM_PROVIDER_SETTINGS.defaultImageModel).toEqual({ provider: 'google', model: DEFAULT_IMAGE_MODEL }); + }); + + it('is the first catalog entry for Google direct and OpenRouter', () => { + expect(Object.keys(GEMINI_IMAGE_MODEL_SPECS)[0]).toBe(DEFAULT_IMAGE_MODEL); + expect(Object.keys(OPENROUTER_IMAGE_MODEL_SPECS)[0]).toBe(DEFAULT_IMAGE_MODEL); + }); +}); diff --git a/tests/unit/GeminiImageAdapter.test.ts b/tests/unit/GeminiImageAdapter.test.ts index f4fe8c94..1ff9de52 100644 --- a/tests/unit/GeminiImageAdapter.test.ts +++ b/tests/unit/GeminiImageAdapter.test.ts @@ -55,7 +55,7 @@ describe('GeminiImageAdapter', () => { }); describe('request shape', () => { - it('defaults to Nano Banana 2 and posts to generateContent with the API key header', async () => { + it('defaults to Nano Banana 2.1 and posts to generateContent with the API key header', async () => { const requests: CapturedRequest[] = []; __setRequestUrlMock(async (request) => { requests.push(request); @@ -65,7 +65,7 @@ describe('GeminiImageAdapter', () => { const adapter = new GeminiImageAdapter({ apiKey: 'g-test' }); await adapter.generateImage(baseParams({ aspectRatio: AspectRatio.SQUARE })); - expect(requests[0].url).toBe('https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-image:generateContent'); + expect(requests[0].url).toBe('https://generativelanguage.googleapis.com/v1beta/models/gemini-nano-banana-2.1:generateContent'); expect(requests[0].headers?.['x-goog-api-key']).toBe('g-test'); const body = parsedBody(requests[0]) as { @@ -131,7 +131,7 @@ describe('GeminiImageAdapter', () => { it('fills in the default model', () => { const result = adapter.validateImageParams(baseParams()); expect(result.isValid).toBe(true); - expect(result.adjustedParams?.model).toBe('gemini-3.1-flash-image'); + expect(result.adjustedParams?.model).toBe('gemini-nano-banana-2.1'); }); it('limits Lite to 1K and rejects 512px and 2K, as the live API does', () => { diff --git a/tests/unit/OpenRouterImageAdapter.test.ts b/tests/unit/OpenRouterImageAdapter.test.ts index f4c47fa6..249c005b 100644 --- a/tests/unit/OpenRouterImageAdapter.test.ts +++ b/tests/unit/OpenRouterImageAdapter.test.ts @@ -78,7 +78,7 @@ describe('OpenRouterImageAdapter', () => { }); describe('request shape', () => { - it('posts to /api/v1/images with Nano Banana 2 Lite as the default model', async () => { + it('posts to /api/v1/images with Nano Banana 2.1 as the default model', async () => { const requests: CapturedRequest[] = []; __setRequestUrlMock(async (request) => { requests.push(request); @@ -94,7 +94,7 @@ describe('OpenRouterImageAdapter', () => { expect(requests[0].headers?.Authorization).toBe('Bearer or-test'); const body = parsedBody(requests[0]); - expect(body.model).toBe('google/gemini-3.1-flash-image'); + expect(body.model).toBe('google/gemini-nano-banana-2.1'); expect(body.prompt).toBe('a red circle'); expect(body).not.toHaveProperty('modalities'); expect(body).not.toHaveProperty('messages'); @@ -250,7 +250,7 @@ describe('OpenRouterImageAdapter', () => { it('fills in the default model', () => { const result = adapter.validateImageParams(baseParams()); expect(result.isValid).toBe(true); - expect(result.adjustedParams?.model).toBe('gemini-3.1-flash-image'); + expect(result.adjustedParams?.model).toBe('gemini-nano-banana-2.1'); }); it('rejects an unknown model instead of guessing an OpenRouter id', () => {