Calling getGenerativeModel() directly inside VisionService and TextToSpeechService duplicates firebase/ai imports, safety settings, and thinking configurations while recreating client-side GenerativeModel objects on every request and triggering unnecessary garbage collection. In this post, we extract AiModelFactory to centralize and cache model instances for multi-model routing, and we resolve two hidden caching traps: request-level generationConfig overwrites and stale AI backends after Remote Config updates.
Architecture & Mental Model
VisionService and TextToSpeechService delegate model creation and lifecycle management to AiModelFactory:
- On-Demand Model Requests:
VisionServicecallsgetModel(options)withAiModelOptions, whileTextToSpeechServicecallsgetTtsModel(). - Backend Validation and Cache Eviction (
getValidBackend()): Before returning a model,getValidBackend()comparesConfigService.getAiBackend()against#cachedBackend. If Remote Config refreshes theAIbackend, it clears#modelsand resets#ttsModeltonull. - Keyed Vision Model Cache (
#models):getModel(options)builds a deterministic key viagenerateKey()and caches instances in#models(Map<string, GenerativeModel>). - Singleton TTS Model Cache (
#ttsModel):getTtsModel()caches a single#ttsModelinstance configured withresponseModalities: [ResponseModality.AUDIO].
flowchart TD subgraph Callers["Domain Services"] VS["VisionService (1 model now -> 3 models with MediaPipe routing)"] TTS["TextToSpeechService (per-request voice speechConfig)"] end subgraph Factory["AiModelFactory (@/core/firebase)"] GVB["getValidBackend(): compare with #cachedBackend"] EVICT["Backend changed? #models.clear() & #ttsModel = null"] GM["getModel(options?: AiModelOptions)"] KEY["generateKey(): model_thinking_instruction_schema_tools"] MAP[("#models: Map<string, GenerativeModel>")] GTTS["getTtsModel()"] TTS_CACHE[("#ttsModel: GenerativeModel | null")] end VS -- "getModel(options)" --> GVB TTS -- "getTtsModel()" --> GVB GVB -- "New AI instance" --> EVICT --> GM & GTTS GVB -- "Same AI instance" --> GM & GTTS GM --> KEY --> MAP GTTS --> TTS_CACHE 1. Defining AiModelOptions and Deterministic Cache Keys
1.1. The AiModelOptions Interface
The AiModelOptions interface defines the configuration needed to create a vision model and compute its cache key:
- Optional Configuration Properties:
systemInstruction,schema(SchemaRequest | TypedSchema), and thetoolsarray (Tool[]) let callers configure structured JSON output, plain-text responses, or grounding tools.
// src/app/core/firebase/ai-model-options.interface.ts import type { SchemaRequest, Tool, TypedSchema } from 'firebase/ai'; export interface AiModelOptions { systemInstruction?: string; schema?: SchemaRequest | TypedSchema; tools?: Tool[]; } 1.2. Private State and generateKey() in AiModelFactory
- Configuration Injection (
#configService):AiModelFactoryinjectsConfigServiceinto#configServiceto read Remote Config values and obtain the Firebase AI Logic backend. - Cached AI Backend (
#cachedBackend):#cachedBackendstores the activeAIbackend reference used to create generative models. - Singleton TTS Cache (
#ttsModel):#ttsModelholds the single-instance TTS model used to synthesize speech from English text. - Vision Model Map (
#modelsandgenerateKey):#modelsis aMap<string, GenerativeModel>that stores vision models by the key returned fromgenerateKey(). The key joins the model name (geminiModelName),thinkingLevel,systemInstruction, the structured-outputschema, andtoolswith underscore (_) characters.
// src/app/core/firebase/ai-model-factory.service.ts (fields & key generation) import type { AI, GenerationConfig, GenerativeModel, ModelParams, SafetySetting } from 'firebase/ai'; @Service() export class AiModelFactory { readonly #configService = inject(ConfigService); readonly #models = new Map<string, GenerativeModel>(); #ttsModel: GenerativeModel | null = null; #cachedBackend: AI | null = null; private generateKey(options?: AiModelOptions): string { const { geminiModelName, thinkingLevel } = this.#configService.appConfig; const instruction = options?.systemInstruction ?? ''; const schemaKey = options?.schema ? JSON.stringify(options.schema) : ''; const toolsKey = options?.tools?.length ? JSON.stringify(options.tools) : ''; return `${geminiModelName}_${thinkingLevel}_${instruction}_${schemaKey}_${toolsKey}`; } 2. Creating Models On Demand and Invalidating the Cache in AiModelFactory
2.1. Lazily Loading the SDK and Assembling Parameters (createModel, createModelParams, createGenerationConfig)
It is tempting to import the HarmBlockThreshold and HarmCategory enums at the top of the file, but doing so leaks firebase/ai runtime code into main.js and increases the initial bundle size:
- Dynamic SDK Import (
createModel):createModel()dynamically importsgetGenerativeModel,HarmBlockThreshold, andHarmCategoryfromfirebase/ai. - Shared Safety Settings (
createModel):createModel()builds theSafetySetting[]array and callsgetGenerativeModel(backend, modelParams). - Thinking Config (
createGenerationConfig):createGenerationConfig()configuresthinkingLevelwithincludeThoughts: true. - Structured Output Schema (
createGenerationConfig):createGenerationConfig()attachesresponseSchemaandresponseMimeType: 'application/json'when the caller providesoptions?.schema. - Model Parameter Assembly (
createModelParams):createModelParams()combines the model name,generationConfig, andsafetySettings, and conditionally addstoolsandsystemInstruction.
// src/app/core/firebase/ai-model-factory.service.ts (model creation helpers) private async createModel(backend: AI, options?: AiModelOptions): Promise<GenerativeModel> { const { getGenerativeModel, HarmBlockThreshold, HarmCategory } = await import('firebase/ai'); const safetySettings: SafetySetting[] = [ { category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, threshold: HarmBlockThreshold.BLOCK_ONLY_HIGH }, { category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold: HarmBlockThreshold.BLOCK_ONLY_HIGH }, { category: HarmCategory.HARM_CATEGORY_HARASSMENT, threshold: HarmBlockThreshold.BLOCK_ONLY_HIGH }, { category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold: HarmBlockThreshold.BLOCK_ONLY_HIGH }, ]; const modelParams = this.createModelParams(safetySettings, options); return getGenerativeModel(backend, modelParams); } private createModelParams(safetySettings: SafetySetting[], options: AiModelOptions | undefined): ModelParams { const generationConfig = this.createGenerationConfig(options); const modelParams: ModelParams = { model: this.#configService.appConfig.geminiModelName, generationConfig, safetySettings, }; if (options?.tools?.length) { modelParams.tools = options.tools; } if (options?.systemInstruction) { modelParams.systemInstruction = options.systemInstruction; } return modelParams; } private createGenerationConfig(options: AiModelOptions | undefined): GenerationConfig { const generationConfig: GenerationConfig = { thinkingConfig: { thinkingLevel: this.#configService.appConfig.thinkingLevel, includeThoughts: true, }, }; if (options?.schema) { generationConfig.responseSchema = options.schema; generationConfig.responseMimeType = 'application/json'; } return generationConfig; } 2.2. Reusing Models On Demand (getModel and getTtsModel)
Obtaining a vision model and a TTS model follows two different caching flows in AiModelFactory:
- Vision Model Lookup (
getModel):getModel()generates a deterministic cache key fromAiModelOptionsviagenerateKey(options)and checksthis.#models.get(key). - Vision Model Cache Miss: When the key is not in
#models,getModel()callscreateModel()to instantiate the model and stores it in the map. - TTS Model Flow (
getTtsModel):getTtsModel()callscreateTtsModel()once to instantiate a singleton TTS model configured withresponseModalities: [ResponseModality.AUDIO]and assigns it to#ttsModel. - Eliminating Per-Call Allocation and Garbage Collection: Reusing cached instances across requests avoids repeated
getGenerativeModel()calls and unnecessary garbage collection.
// src/app/core/firebase/ai-model-factory.service.ts (on-demand getters) async getModel(options?: AiModelOptions): Promise<GenerativeModel> { const backend = await this.getValidBackend(); const key = this.generateKey(options); let model = this.#models.get(key); if (!model) { model = await this.createModel(backend, options); this.#models.set(key, model); } return model; } private async createTtsModel(ai: AI): Promise<GenerativeModel> { const { getGenerativeModel, ResponseModality } = await import('firebase/ai'); return getGenerativeModel(ai, { model: this.#configService.appConfig.geminiTTSModelName, generationConfig: { responseModalities: [ResponseModality.AUDIO], }, }); } async getTtsModel(): Promise<GenerativeModel> { const backend = await this.getValidBackend(); if (!this.#ttsModel) { this.#ttsModel = await this.createTtsModel(backend); } return this.#ttsModel; } 2.3. Evicting Cached Models When Remote Config Rotates the Backend (getValidBackend)
- Stale Backend on Remote Config Refresh: Constructing a
GenerativeModelrequires a valid Firebase AI LogicAIbackend, which becomes outdated when dependent Firebase Remote Config parameters (such asuseLimitedUseAppCheckTokensandvertexAILocation) receive updated values andConfigServiceresets itsAIinstance. - Purging Cached Models (
getValidBackend): WhengetValidBackend()detects thatthis.#configService.getAiBackend()no longer matches#cachedBackend, it updates#cachedBackend, clears the#modelsmap, and resets#ttsModeltonull.
// src/app/core/firebase/ai-model-factory.service.ts (cache invalidation) private async getValidBackend(): Promise<AI> { const backend = await this.#configService.getAiBackend(); if (backend !== this.#cachedBackend) { this.#cachedBackend = backend; this.#models.clear(); this.#ttsModel = null; } return this.#cachedBackend; } } 3. Refactoring VisionService and TextToSpeechService (and the Shallow-Spread Trap)
3.1. Simplifying VisionService for Future Multi-Model Routing
We refactored VisionService for extensibility and scalability by delegating model creation to AiModelFactory:
- Removing SDK Setup Boilerplate: Introducing
AiModelFactoryremovesSAFETY_SETTINGS,ConfigService, and the privategetGenerativeAIModel()helper fromVisionService. - Extracting
altTextPrompt: MovingaltTextPrompttovision.prompt.tskeepsVisionServicefocused on orchestrating image analysis—generating tags, writing accessible alt text, and finding an obscure fact. - On-Demand Model Retrieval with Custom Tools:
VisionServiceinjectsAiModelFactoryinto#aiModelFactoryand callsthis.#aiModelFactory.getModel({ schema: ImageAnalysisSchema, tools: [{ googleSearch: {} }] }). While the current model uses the Google Search grounding tool,VisionServicecan pass a Google Maps tool or custom function-calling tools throughAiModelOptionsto configure additional models. - Ready for MediaPipe Multi-Model Routing: Because
getModel()caches instances by their configuration key, we can extendVisionServicewith an on-device MediaPipe classifier to request 3 specialized models (places, people and faces, and general objects) on demand.
// src/app/core/vision/vision.service.ts (refactored excerpt) @Service() export class VisionService { readonly #window = inject(WINDOW); readonly #aiModelFactory = inject(AiModelFactory); async analyzeImage(image: File): Promise<ImageAnalysisResponse> { const { data, mimeType, optimizationMetrics } = await preprocessImageForVision(image, this.#window); const imagePart: InlineDataPart = { type: 'inlineData', inlineData: { data, mimeType }, }; const aiModel = await this.#aiModelFactory.getModel({ schema: ImageAnalysisSchema, tools: [{ googleSearch: {} }], }); const result = await aiModel.generateContent([altTextPrompt, imagePart]); // ... parse response, tokenUsage, and grounding citations } } 3.2. Reusing #ttsModel in TextToSpeechService Without Wiping Out responseModalities
Similarly, TextToSpeechService delegates TTS model creation to AiModelFactory while passing the selected voice per request:
- Shared TTS Model Lookup:
TextToSpeechServiceinjectsAiModelFactoryinto#aiModelFactory, andcreateAIModelGenerationRequest()retrieves the cached TTS model viathis.#aiModelFactory.getTtsModel()instead of instantiating a new model for every synthesis call. - Per-Request
speechConfigAssembly: ThecreateAIModelGenerationRequest(text, voiceName)helper builds theGenerateContentRequest(shared bysynthesizeStream()andsynthesize()) and configuresspeechConfigwith the requestedvoiceNameandlanguageCode: 'en-US'. - Preserving
ResponseModality.AUDIOAgainst Shallow Overwrites: In@firebase/ai, passing a request-levelgenerationConfigoverwrites the model's defaultaiModel.generationConfig. Spreading...aiModel.generationConfiginto the request'sgenerationConfigpreservesresponseModalities: [ResponseModality.AUDIO]alongsidespeechConfig.
// src/app/core/speech/text-to-speech.service.ts (refactored excerpt) @Service() export class TextToSpeechService { readonly #aiModelFactory = inject(AiModelFactory); private async createAIModelGenerationRequest(text: string, voiceName: string): Promise<AIModelGenerativeRequest> { const aiModel = await this.#aiModelFactory.getTtsModel(); const contents: Content[] = [ { role: 'user', parts: [{ type: 'text', text }], }, ]; const generationConfig: GenerationConfig = { ...aiModel.generationConfig, speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName }, }, languageCode: 'en-US', }, }; return { aiModel, generationRequest: { contents, generationConfig }, }; } async *synthesizeStream({ text, voice }: SpeechPrompt): AsyncGenerator<AudioStreamChunk> { const { aiModel, generationRequest } = await this.createAIModelGenerationRequest(text, voice); const responseStream = await aiModel.generateContentStream(generationRequest); for await (const chunk of responseStream.stream) { const chunkData = this.extractValidChunkData(chunk); if (chunkData) { yield decodeAudioChunk(chunkData.data, chunkData.mimeType); } } } async synthesize({ text, voice }: SpeechPrompt): Promise<Blob> { const { aiModel, generationRequest } = await this.createAIModelGenerationRequest(text, voice); const result = await aiModel.generateContent(generationRequest); const chunk = this.extractValidChunkData(result.response); if (!chunk) { throw new Error('No audio data received in response.'); } return toWavBlob(chunk.data, chunk.mimeType); } } Takeaways
- Centralized Lazy Model Creation:
AiModelFactorydynamically importsfirebase/aito construct both text-to-speech and image-analysis models. It centralizes safety settings andthinkingLevelfor the image-analysis model while lockingresponseModalitiesto[ResponseModality.AUDIO]for the TTS model. - Instance Reuse Over Per-Request Allocation:
AiModelFactorycaches models in the#modelsmap or the#ttsModelfield and reuses them across requests, eliminating unnecessary per-requestGenerativeModelinstantiation and garbage collection. - Scalable Multi-Model Routing for
VisionService: Keyed caching in the#modelsmap makesVisionServicescalable so an upcoming MediaPipe classifier can route images to specialized models (places, people and faces, and general objects) for analysis. - Preserving
ResponseModality.AUDIOAcross Per-Request Voices: Because text-to-speech synthesis accepts a voice per request, spreading...aiModel.generationConfiginto the request'sgenerationConfigpreservesResponseModality.AUDIOalongside the selectedvoiceName. - Automatic Cache Invalidation on Remote Config Updates: When
ConfigServicefetches Remote Config values in the background and invalidates the cachedAIbackend,AiModelFactorypurges all cached models and recreates fresh instances on the next call.
