diff --git a/README.md b/README.md index b0620fe..221b4ec 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Put a language model in a web app without running an inference backend. It runs ## No API Key or BYOK -On-device models don't need API keys. Cloud models use BYOK (bring your own key): each user supplies their own, and it stays on the client instead of on a server you run. +On-device models don't need API keys. Most cloud providers use BYOK (bring your own key): each user supplies their own, and it stays on the client instead of on a server you run. Firebase AI Logic instead uses the host app's configured Firebase project, backend, Authentication, and App Check context. ## Run a model in the browser @@ -47,7 +47,7 @@ for await (const ev of client.chat({ } ``` -The weights download once via Transformers.js and run on WebGPU (WASM fallback when there's no GPU). `createEngineModelClient` wraps the engine as a `ModelClient` — the same interface every cloud provider implements, so swapping it for `geminiModelClient({ apiKey, model })` (or `openrouterModelClient`, `requestyModelClient`, `ollamaModelClient`, …) changes that one line and nothing else. +The weights download once via Transformers.js and run on WebGPU (WASM fallback when there's no GPU). `createEngineModelClient` wraps the engine as a `ModelClient` — the same interface every cloud provider implements, so swapping it for `geminiModelClient({ apiKey, model })`, `createFirebaseAiLogicModelClient(firebaseModel)`, or another provider changes that one line and nothing else. ## OpenRouter OAuth for BYOK @@ -149,13 +149,14 @@ The log is append-only and ordered by `seq`. A consumer that reconnects after a ### `@inbrowser/model` -The shared `ModelClient` contract that relay and agent both consume, a set of cloud provider factories, and an on-device engine (Transformers.js + ONNX Runtime Web). Single root entrypoint — `import { … } from '@inbrowser/model'`. +The shared `ModelClient` contract that relay and agent both consume, cloud provider factories and adapters, and an on-device engine (Transformers.js + ONNX Runtime Web). Single root entrypoint — `import { … } from '@inbrowser/model'`. -**Cloud providers** — each is a factory that returns a `ModelClient` from `{ apiKey, model, … }`: +**Cloud providers** — each returns a `ModelClient`; most are `{ apiKey, model, … }` factories, while Firebase AI Logic wraps a model constructed by the host app: | Factory | Config | Notes | |---------|--------|-------| | `geminiModelClient(config)` | `GeminiConfig` | Google AI Studio / Vertex | +| `createFirebaseAiLogicModelClient(model, opts?)` | Constructed Firebase `GenerativeModel` | Host owns Firebase, App Check, backend, and location; no Firebase dependency; not directly relay-registerable | | `openrouterModelClient(config)` | `OpenRouterConfig` | Unified API, many models | | `requestyModelClient(config)` | `RequestyConfig` | OpenAI-compatible gateway, many models | | `anthropicModelClient(config)` | `AnthropicConfig` | Anthropic Claude | diff --git a/packages/agent/docs/how-to/implement-llm-client.md b/packages/agent/docs/how-to/implement-llm-client.md index c238926..e3de644 100644 --- a/packages/agent/docs/how-to/implement-llm-client.md +++ b/packages/agent/docs/how-to/implement-llm-client.md @@ -9,10 +9,12 @@ shared contract from `@inbrowser/model/contract`, re-exported from provider and maps its stream to `ModelEvent`s. For the full event and usage shapes, see the [`ModelClient` reference](../reference/library.md). -> Already have a provider? The cloud providers (Gemini, OpenRouter, Anthropic, -> Ollama, the Claude CLI/Code bridges) ship as `ModelClient` factories in -> `@inbrowser/model`. Import one and hand it to a session — you only need this -> guide when wiring an API the package does not cover. +> Already have a provider? The API-key and subscription providers (Gemini, +> OpenRouter, Anthropic, Ollama, and the Claude CLI/Code bridges) ship as +> `ModelClient` factories in `@inbrowser/model`. Firebase AI Logic instead uses +> `createFirebaseAiLogicModelClient` to wrap a caller-constructed Firebase model. +> Import one and hand it to a session — you only need this guide when wiring an +> API the package does not cover. ## Choose your path diff --git a/packages/agent/docs/reference/library.md b/packages/agent/docs/reference/library.md index 67b5a41..07125c0 100644 --- a/packages/agent/docs/reference/library.md +++ b/packages/agent/docs/reference/library.md @@ -319,12 +319,14 @@ interface ModelClient { } ``` -The narrow model interface. The cloud providers in `@inbrowser/model` -(`geminiModelClient`, `openrouterModelClient`, `anthropicModelClient`, -`ollamaModelClient`, `claudeCliModelClient`, `claudeCodeModelClient`) are -factories returning one. The client knows about model calls and streamed events; -it knows nothing about BYOK forms, storage, model pickers, or pricing. `chat` -yields `ModelEvent`s, enumerated in [events.md](./events.md). `id` is a stable +The narrow model interface. The API-key and subscription providers in +`@inbrowser/model` (`geminiModelClient`, `openrouterModelClient`, +`anthropicModelClient`, `ollamaModelClient`, `claudeCliModelClient`, +`claudeCodeModelClient`) are factories returning one. +`createFirebaseAiLogicModelClient` instead wraps a caller-constructed Firebase +AI Logic model. The client knows about model calls and streamed events; it knows +nothing about BYOK forms, storage, model pickers, or pricing. `chat` yields +`ModelEvent`s, enumerated in [events.md](./events.md). `id` is a stable metrics/provenance string such as `gemini:gemini-3.5-flash`. ### `ModelRequest` diff --git a/packages/model/AGENTS.md b/packages/model/AGENTS.md index 9f83e8a..e159f8f 100644 --- a/packages/model/AGENTS.md +++ b/packages/model/AGENTS.md @@ -7,8 +7,10 @@ The model layer. Two halves: 1. **Contract + cloud providers.** `src/contract.ts` defines the one `ModelClient` contract the whole stack shares (relay + agent both consume it). `src/providers/*` are the cloud providers (Gemini, - OpenRouter, Requesty, Anthropic, Ollama, Claude-CLI, Claude-Code), each a - factory returning a `ModelClient`. `src/with-retry.ts` decorates one. + Firebase AI Logic, OpenRouter, Requesty, Anthropic, Ollama, Claude-CLI, + Claude-Code), each returning a `ModelClient`. Firebase AI Logic is a + constructed-model adapter; the others are provider factories. + `src/with-retry.ts` decorates one. 2. **On-device engine.** Wraps `@huggingface/transformers` behind a narrow `Engine` surface (`src/engine.ts`) that streams `EngineEvent`. @@ -28,10 +30,12 @@ still-forthcoming piece — the adapter is the building block it needs.) - `src/types.ts` is the canonical engine type surface. Engine-side files import engine types from here. - `src/engine.ts` is the only module that holds runtime model state. -- Each `src/providers/.ts` is self-contained: it imports the - contract types and emits `ModelEvent`s. Providers do not import the - relay or the agent — the dependency points inward (relay/agent depend - on this package's contract, never the reverse). +- Each `src/providers/.ts` imports the contract types and emits + `ModelEvent`s. Pure Gemini protocol helpers shared by the raw Gemini and + Firebase AI Logic transports live in `src/providers/gemini-protocol.ts`; + transport decoders remain provider-local. Providers do not import the relay + or the agent — the dependency points inward (relay/agent depend on this + package's contract, never the reverse). - `src/worker.ts` returns the same `Engine` shape `createEngine` returns. Consumers must not need to know which side of `postMessage` the engine lives on. @@ -65,6 +69,10 @@ Use the precise terms — they show up in types, comments, and PRs: - Don't make `@huggingface/transformers` a regular dependency. It's a peer dep; consumers control the version. (The Claude Code Agent SDK, used only by `claudeCodeModelClient`, is an optional peer dep.) +- Don't make `firebase` a dependency or initialize Firebase/App Check in this + package. `createFirebaseAiLogicModelClient` accepts a structural, + caller-constructed `GenerativeModel`; the host owns its Firebase app, + backend, location, authentication, and App Check lifecycle. ## Status @@ -73,4 +81,7 @@ consume a `ModelClient` from here. The engine loads and `generate()` streams real tokens, and the engine is now a `ModelClient` via `createEngineModelClient` (the engine→ModelClient adapter). The next slice is the site wiring that drives a local engine through the agent -end to end (the in-browser docs-chat toggle). +end to end (the in-browser docs-chat toggle). Firebase AI Logic's core +text/thinking/custom-tool path is implemented through +`createFirebaseAiLogicModelClient`; its Live, Imagen, template, multimodal, +and hybrid lifecycle surfaces remain intentionally outside `ModelClient`. diff --git a/packages/model/README.md b/packages/model/README.md index f84aa42..7a627e6 100644 --- a/packages/model/README.md +++ b/packages/model/README.md @@ -13,7 +13,8 @@ Two halves, one package: providers (`geminiModelClient`, `openrouterModelClient`, `requestyModelClient`, `anthropicModelClient`, `openaiCompatModelClient`, `ollamaModelClient`, `llamaServerModelClient`, `claudeCliModelClient`, - `claudeCodeModelClient`) are factories that each return a `ModelClient`. + `claudeCodeModelClient`) and the Firebase AI Logic constructed-model + adapter (`createFirebaseAiLogicModelClient`) each return a `ModelClient`. `withRetry` decorates one. - **The on-device engine.** `createEngine` loads ONNX models in the browser via `@huggingface/transformers` + ONNX Runtime Web (WebGPU / @@ -58,6 +59,34 @@ The turn ends when the iterable returns; a `usage` event (or a terminal `error` event) is the last thing emitted. There is no `turn_complete` event. +## Firebase AI Logic from an existing Firebase app + +Firebase AI Logic uses the Firebase app that the host already configured. The +host owns Firebase initialization, App Check, backend selection, Vertex AI +location, and construction of the `GenerativeModel`; this package only adapts +that model to `ModelClient`: + +```ts +import { initializeApp } from 'firebase/app'; +import { getAI, getGenerativeModel, GoogleAIBackend } from 'firebase/ai'; +import { createFirebaseAiLogicModelClient } from '@inbrowser/model'; + +const app = initializeApp(firebaseConfig); +// Initialize App Check for `app` before making production AI requests. +const ai = getAI(app, { backend: new GoogleAIBackend() }); +const firebaseModel = getGenerativeModel(ai, { model: 'gemini-3.5-flash' }); +const client = createFirebaseAiLogicModelClient(firebaseModel); +``` + +The adapter streams text and thinking, translates caller-run custom function +calls (including thought-signature replay), maps sampling and usage, forwards +cancellation, and normalizes Firebase errors. It has no `firebase` dependency: +the constructed model crosses a small structural interface. Live API, Imagen, +server templates, Firebase built-in/automatic tools, multimodal events, +structured-output configuration, token counting, and hybrid lifecycle control +are intentionally outside this adapter. See the +[Firebase AI Logic reference](docs/reference/firebase-ai-logic.md). + ## A local OpenAI-compatible server Ollama, llama.cpp's `llama-server`, vLLM, LM Studio, LocalAI, and friends all @@ -134,6 +163,7 @@ Everything is imported from the package root `@inbrowser/model`. |---|---| | `ModelClient`, `ModelRequest`, `ModelEvent`, `ModelMessage`, `ModelUsage`, `ToolSpec`, `ReasoningEffort` | The shared contract (type-only) | | `geminiModelClient`, `openrouterModelClient`, `requestyModelClient`, `anthropicModelClient`, `openaiCompatModelClient`, `ollamaModelClient`, `llamaServerModelClient`, `claudeCliModelClient`, `claudeCodeModelClient` | Cloud + local provider factories; each returns a `ModelClient` | +| `createFirebaseAiLogicModelClient(model, opts?)` | Wraps a caller-constructed Firebase AI Logic `GenerativeModel`; Firebase/App Check lifecycle stays with the host | | `OpenAiCompatConfig`, `OllamaConfig`, `LlamaServerConfig` | Config shapes for the OpenAI-compatible factory and its local presets | | `withRetry(client, opts?)` | Decorator that retries transient upstream errors while nothing has streamed | | `CloudProviderConfig`, `ModelClientFactory` | Shared provider config + the factory type the relay routes on | diff --git a/packages/model/docs/README.md b/packages/model/docs/README.md index 1271fdb..8e6e4c5 100644 --- a/packages/model/docs/README.md +++ b/packages/model/docs/README.md @@ -4,7 +4,8 @@ `ModelClient` contract (from `@inbrowser/model`) that both `@inbrowser/relay` (transport) and `@inbrowser/agent` (runtime) consume, the cloud providers that implement it (Gemini, OpenRouter, Requesty, Anthropic, Ollama, Claude-CLI, -Claude-Code), and the on-device LLM engine. The engine loads ONNX models in the browser +Claude-Code, plus the Firebase AI Logic constructed-model adapter), and the +on-device LLM engine. The engine loads ONNX models in the browser through `@huggingface/transformers` (ONNX Runtime Web over WebGPU or WASM) and exposes them behind a narrow `Engine` surface that streams `EngineEvent`s; a worker transport lets the same engine run off the main thread without any @@ -48,6 +49,7 @@ The facts: configuration shapes, event variants, exports. - [Presets](reference/presets.md) - [Worker](reference/adapters-and-worker.md) - [Gateway providers](reference/gateway-providers.md) +- [Firebase AI Logic](reference/firebase-ai-logic.md) ## Explanation @@ -55,3 +57,4 @@ Background and design rationale. - [Design](explanation/design.md) - [On-device inference](explanation/on-device-inference.md) +- [Firebase AI Logic provider assessment](explanation/firebase-ai-logic-provider-assessment.md) diff --git a/packages/model/docs/reference/engine.md b/packages/model/docs/reference/engine.md index 3a6d469..f5d78da 100644 --- a/packages/model/docs/reference/engine.md +++ b/packages/model/docs/reference/engine.md @@ -21,6 +21,7 @@ Everything is imported from the package root `@inbrowser/model`. | `createEngine`, `definePreset`, `parseToolCalls`, `splitThinking`, and engine types | The on-device engine surface | | The six bundled presets | `deepseek_r1_qwen_1_5b`, `gemma4_E2B`, `gemma4_E4B`, `qwen2_5_coder_1_5b`, `qwen3_1_7b`, `smollm2_360m`. See [./presets.md](./presets.md). | | The cloud provider factories (`geminiModelClient`, …), `withRetry` | Cloud providers + the retry decorator | +| `createFirebaseAiLogicModelClient` | Wraps a caller-constructed Firebase AI Logic model as a `ModelClient`; it is not an API-key cloud factory. | | `ModelClient`, `ModelRequest`, `ModelEvent`, `ModelMessage`, `ModelUsage`, `ToolSpec`, `ReasoningEffort` | The shared `ModelClient` contract types (type-only) | | `createEngineModelClient` | Wraps an `Engine` as a `ModelClient`. See [./adapters-and-worker.md](./adapters-and-worker.md). | | `hostEngineInWorker`, `connectWorkerEngine` | Worker host/connect helpers. See [./adapters-and-worker.md](./adapters-and-worker.md). | diff --git a/packages/model/docs/reference/firebase-ai-logic.md b/packages/model/docs/reference/firebase-ai-logic.md new file mode 100644 index 0000000..db11e4d --- /dev/null +++ b/packages/model/docs/reference/firebase-ai-logic.md @@ -0,0 +1,126 @@ +# Firebase AI Logic + +`createFirebaseAiLogicModelClient()` wraps a caller-constructed Firebase AI +Logic `GenerativeModel` as the package's shared `ModelClient`. + +## Construct the Model in the Host + +The application owns Firebase initialization, Authentication, App Check, +Gemini backend selection, Vertex AI location, and model construction: + +```ts +import { initializeApp } from 'firebase/app'; +import { getAI, getGenerativeModel, GoogleAIBackend } from 'firebase/ai'; +import { createFirebaseAiLogicModelClient } from '@inbrowser/model'; + +const app = initializeApp(firebaseConfig); +// Configure App Check for this app before production requests. +const ai = getAI(app, { backend: new GoogleAIBackend() }); +const model = getGenerativeModel(ai, { model: 'gemini-3.5-flash' }); + +const client = createFirebaseAiLogicModelClient(model); +``` + +To use the Vertex AI Gemini API, construct the `AI` instance with the +appropriate `VertexAIBackend` and location before creating the model. The +adapter does not inspect or change that choice. + +`@inbrowser/model` does not depend on `firebase`. The adapter accepts the +narrow structural shape it uses, so the host installs Firebase and controls +its version and lifecycle. + +## API + +```ts +createFirebaseAiLogicModelClient(model, options?): ModelClient +``` + +`model` must provide: + +```ts +interface FirebaseAiLogicGenerativeModelLike { + readonly model: string; + generateContentStream( + request: unknown, + options?: { signal?: AbortSignal }, + ): Promise<{ + stream: AsyncIterable; + response: Promise; + }>; +} +``` + +Options: + +| Option | Meaning | +| --- | --- | +| `id` | Metrics/provenance id; defaults to `firebase-ai-logic:${model.model}` | +| `temperature` | Construction-time default; a `ModelRequest.temperature` value wins | + +The returned client has `supportsTools: true`. + +## Supported Mapping + +Each `chat()` call is stateless and sends the full `ModelRequest` through +`generateContentStream()`; it does not create a Firebase `ChatSession`. + +- System messages are joined into one Firebase system instruction. +- User, assistant, and adjacent tool-result messages become Firebase `user`, + `model`, and grouped `function` content. +- Custom `ToolSpec` declarations are converted to Firebase function + declarations. Common JSON Schema-only annotations are removed; unsupported + structural or validation keywords fail as a terminal, non-retryable error + instead of being sent ambiguously. +- Tool execution remains with `@inbrowser/agent` or the caller. Firebase + `functionReference` and automatic tool execution are not used. +- Upstream function-call ids are preserved. Calls without one receive a stable + local id; local ids are not replayed to Firebase. +- Gemini thought signatures are captured from function-call parts and replayed + on the next request. +- Temperature, top-p, top-k, and `ReasoningEffort` are mapped. Gemini 3.x uses + uppercase thinking levels; Gemini 2.5 uses thinking budgets. `off` leaves the + model default unchanged. +- Requests use a `65,536` maximum output-token budget so large function + arguments are not silently truncated. + +The event stream contains: + +- `text` for visible text; +- `thinking` for returned thought summaries; +- completed `tool_call` events after streamed call snapshots are assembled; +- one terminal `usage` event using the latest prompt, output, cached, and + thinking-token counts; +- or one terminal `error` event with normalized Firebase status/details, + including prompt and candidate-level content blocks. + +The caller's `AbortSignal` is forwarded to Firebase. Caller cancellation ends +the stream silently, matching the other `ModelClient` implementations. + +## Deliberate Exclusions + +This adapter covers the common text and caller-run custom-tool path. It does +not expose: + +- Gemini Live API sessions; +- Imagen; +- server-side prompt templates; +- Firebase built-in Search, Maps, URL-context, or code-execution tools; +- Firebase automatic function execution; +- multimodal input/output events; +- structured-output or safety-setting request fields; +- token counting or model discovery; +- Firebase hybrid/on-device lifecycle controls. + +Those capabilities need contracts other than the current text/tool-oriented +`ModelClient` rather than more switches on this adapter. + +## Relay Compatibility + +The adapter is not directly a `ModelClientFactory`. The relay factory receives +only `{ apiKey?, model }`, while Firebase AI Logic requires a constructed model +bound to a Firebase app and security context. The relay also currently requires +a BYOK or server-managed key before invoking a provider. Page-direct use works; +first-class relay routing needs a separate provider-authentication policy. + +For the design and feature ROI, see the +[Firebase AI Logic provider assessment](../explanation/firebase-ai-logic-provider-assessment.md). diff --git a/packages/model/package.json b/packages/model/package.json index 5278172..8ef5e87 100644 --- a/packages/model/package.json +++ b/packages/model/package.json @@ -1,7 +1,7 @@ { "name": "@inbrowser/model", "version": "0.4.1", - "description": "The model layer for the inbrowser stack: it OWNS the shared ModelClient contract that @inbrowser/relay (transport) and @inbrowser/agent (runtime) both consume, the cloud provider factories (Gemini, OpenRouter, Requesty, Anthropic, Ollama, Claude-CLI, Claude-Code) that each return a ModelClient, a withRetry decorator, and the on-device LLM engine (lazy-loads @huggingface/transformers + ONNX Runtime Web behind a narrow EngineEvent-streaming Engine surface; Gemma 4 + Qwen + SmolLM2 presets; in-worker host/connect helpers). The engine is also a ModelClient via createEngineModelClient. Everything is exported from the package root.", + "description": "The model layer for the inbrowser stack: it OWNS the shared ModelClient contract that @inbrowser/relay (transport) and @inbrowser/agent (runtime) both consume, cloud provider factories, a constructed-model Firebase AI Logic adapter, a withRetry decorator, and the on-device LLM engine (lazy-loads @huggingface/transformers + ONNX Runtime Web behind a narrow EngineEvent-streaming Engine surface; Gemma 4 + Qwen + SmolLM2 presets; in-worker host/connect helpers). The engine is also a ModelClient via createEngineModelClient. Everything is exported from the package root.", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/model/src/index.ts b/packages/model/src/index.ts index 873ed15..97ff95b 100644 --- a/packages/model/src/index.ts +++ b/packages/model/src/index.ts @@ -102,6 +102,15 @@ export { // it. Importing this file is SSR-safe; the SDK is lazy-loaded inside chat. export { claudeCodeModelClient, type ClaudeCodeConfig } from './providers/claude-code.js'; +// Constructed-runtime adapter: the caller owns Firebase app, backend, App +// Check, and model lifecycle. This package has no Firebase dependency. +export { + createFirebaseAiLogicModelClient, + type FirebaseAiLogicGenerativeModelLike, + type FirebaseAiLogicModelClientOptions, + type FirebaseAiLogicRequestOptionsLike, +} from './providers/firebase-ai-logic.js'; + // The reusable transient-retry decorator (was the site's relay bridge). export { withRetry, type WithRetryOpts } from './with-retry.js'; diff --git a/packages/model/src/providers/firebase-ai-logic.ts b/packages/model/src/providers/firebase-ai-logic.ts new file mode 100644 index 0000000..787bde0 --- /dev/null +++ b/packages/model/src/providers/firebase-ai-logic.ts @@ -0,0 +1,479 @@ +import type { + ModelClient, + ModelErrorEvent, + ModelEvent, + ModelRequest, + ModelUsage, +} from '../contract.js'; +import { + UnsupportedGeminiSchemaError, + geminiNoOutputError, + parseJsonValue, + selectGeminiThinking, + toGeminiFunctionDeclarations, +} from './gemini-protocol.js'; + +/** The per-call option subset used from Firebase AI Logic. */ +export interface FirebaseAiLogicRequestOptionsLike { + signal?: AbortSignal; +} + +/** + * Narrow structural port implemented by Firebase AI Logic's `GenerativeModel`. + * + * Keeping this structural avoids a runtime or type dependency on `firebase`: + * callers construct the model with their own Firebase app/backend/App Check + * configuration, then hand that model to this adapter. + */ +export interface FirebaseAiLogicGenerativeModelLike { + readonly model: string; + generateContentStream( + request: unknown, + options?: FirebaseAiLogicRequestOptionsLike, + ): Promise<{ + stream: AsyncIterable; + /** Retains prompt feedback that Firebase omits from its public stream. */ + response: Promise; + }>; +} + +export interface FirebaseAiLogicModelClientOptions { + /** Stable metrics/provenance id. Defaults to `firebase-ai-logic:${model.model}`. */ + id?: string; + /** Construction-time sampling default; a per-request temperature wins. */ + temperature?: number; +} + +interface FirebaseAiLogicPart { + text?: string; + thought?: boolean; + thoughtSignature?: string; + functionCall?: { + id?: string; + name?: string; + args?: Record; + }; + functionResponse?: { + id?: string; + name: string; + response: Record; + }; +} + +interface PendingFunctionCall { + upstreamId?: string; + syntheticIndex: number; + name: string; + args: Record; + signature?: string; +} + +interface FirebaseAiLogicResponse { + candidates?: Array<{ + content?: { + parts?: FirebaseAiLogicPart[]; + }; + finishReason?: string; + finishMessage?: string; + }>; + usageMetadata?: { + promptTokenCount?: number; + candidatesTokenCount?: number; + cachedContentTokenCount?: number; + thoughtsTokenCount?: number; + }; + promptFeedback?: { + blockReason?: string; + blockReasonMessage?: string; + }; +} + +interface FirebaseAiLogicRequest { + contents: Array<{ + role: 'user' | 'model' | 'function'; + parts: FirebaseAiLogicPart[]; + }>; + systemInstruction?: string; + tools: unknown[]; + generationConfig: Record; +} + +// These are the finish reasons for which Firebase's own response helpers +// reject `text()` / `functionCalls()` instead of treating the candidate as a +// successful partial response. MAX_TOKENS and OTHER deliberately remain usable. +const BAD_FIREBASE_FINISH_REASONS = new Set([ + 'RECITATION', + 'SAFETY', + 'BLOCKLIST', + 'PROHIBITED_CONTENT', + 'SPII', + 'MALFORMED_FUNCTION_CALL', + 'IMAGE_SAFETY', + 'IMAGE_PROHIBITED_CONTENT', + 'IMAGE_OTHER', + 'NO_IMAGE', + 'IMAGE_RECITATION', + 'LANGUAGE', + 'UNEXPECTED_TOOL_CALL', + 'TOO_MANY_TOOL_CALLS', + 'MISSING_THOUGHT_SIGNATURE', + 'MALFORMED_RESPONSE', +]); + +/** Wrap a caller-constructed Firebase AI Logic model as a `ModelClient`. */ +export function createFirebaseAiLogicModelClient( + model: FirebaseAiLogicGenerativeModelLike, + options: FirebaseAiLogicModelClientOptions = {}, +): ModelClient { + return { + id: options.id ?? `firebase-ai-logic:${model.model}`, + supportsTools: true, + async *chat(req: ModelRequest, signal: AbortSignal): AsyncIterable { + if (signal.aborted) return; + try { + const result = await model.generateContentStream( + toFirebaseRequest(model.model, req, options), + { signal }, + ); + const aggregateResult = result.response.then( + (response) => ({ response }), + (error: unknown) => ({ error }), + ); + if (signal.aborted) return; + let usage: ModelUsage = { promptTokens: 0, outputTokens: 0 }; + const pendingCalls: PendingFunctionCall[] = []; + const callsByUpstreamId = new Map(); + let sawThinking = false; + let sawVisibleText = false; + let sawFunctionCall = false; + let lastFinishReason: string | undefined; + let lastFinishMessage: string | undefined; + let promptFeedback: FirebaseAiLogicResponse['promptFeedback']; + + for await (const rawChunk of result.stream) { + if (signal.aborted) return; + const chunk = rawChunk as FirebaseAiLogicResponse; + for (const part of chunk.candidates?.[0]?.content?.parts ?? []) { + if (typeof part.text === 'string' && part.text.length > 0) { + if (part.thought) sawThinking = true; + else sawVisibleText = true; + yield part.thought + ? { kind: 'thinking', text: part.text } + : { kind: 'text', text: part.text }; + } + if (part.functionCall) { + const upstreamId = part.functionCall.id; + let pending = upstreamId ? callsByUpstreamId.get(upstreamId) : undefined; + if (!pending) { + pending = { + ...(upstreamId ? { upstreamId } : {}), + syntheticIndex: pendingCalls.length, + name: '', + args: {}, + }; + pendingCalls.push(pending); + if (upstreamId) callsByUpstreamId.set(upstreamId, pending); + } + if (part.functionCall.name) { + sawFunctionCall = true; + pending.name = part.functionCall.name; + } + const args = part.functionCall.args; + if ( + args && + (Object.keys(args).length > 0 || Object.keys(pending.args).length === 0) + ) { + pending.args = args; + } + if (part.thoughtSignature) pending.signature = part.thoughtSignature; + } + } + + const finishReason = chunk.candidates?.[0]?.finishReason; + if (finishReason) { + lastFinishReason = finishReason; + lastFinishMessage = chunk.candidates?.[0]?.finishMessage; + } + if (chunk.promptFeedback?.blockReason) promptFeedback = chunk.promptFeedback; + + if (chunk.usageMetadata) { + usage = updateUsage(usage, chunk.usageMetadata); + } + } + if (signal.aborted) return; + + const aggregate = await aggregateResult; + if ('error' in aggregate) throw aggregate.error; + const aggregateResponse = aggregate.response as FirebaseAiLogicResponse; + promptFeedback = aggregateResponse.promptFeedback ?? promptFeedback; + const aggregateCandidate = aggregateResponse.candidates?.[0]; + if (aggregateCandidate?.finishReason) { + lastFinishReason = aggregateCandidate.finishReason; + lastFinishMessage = aggregateCandidate.finishMessage; + } + if (aggregateResponse.usageMetadata) { + usage = updateUsage(usage, aggregateResponse.usageMetadata); + } + if (signal.aborted) return; + + if (promptFeedback?.blockReason) { + yield firebasePromptBlockedError(promptFeedback); + return; + } + + if ( + lastFinishReason !== 'MALFORMED_FUNCTION_CALL' && + isBadFirebaseFinishReason(lastFinishReason) + ) { + yield firebaseCandidateBlockedError(lastFinishReason, lastFinishMessage); + return; + } + + if (!sawVisibleText && !sawFunctionCall) { + yield geminiNoOutputError('Firebase AI Logic', 'firebase-ai-logic', { + finishReason: lastFinishReason, + sawThinking, + sawVisibleText, + sawFunctionCall, + }); + return; + } + + if (isBadFirebaseFinishReason(lastFinishReason)) { + yield firebaseCandidateBlockedError(lastFinishReason, lastFinishMessage); + return; + } + + for (const call of pendingCalls) { + if (!call.name) continue; + yield { + kind: 'tool_call', + id: call.upstreamId ?? `firebase_${call.syntheticIndex}`, + name: call.name, + args: call.args, + ...(call.signature ? { signature: call.signature } : {}), + }; + } + + yield { kind: 'usage', usage }; + } catch (error) { + if (signal.aborted) return; + yield normalizeFirebaseError(error); + } + }, + }; +} + +function isBadFirebaseFinishReason(reason: string | undefined): reason is string { + return reason !== undefined && BAD_FIREBASE_FINISH_REASONS.has(reason); +} + +function firebaseCandidateBlockedError( + finishReason: string, + finishMessage: string | undefined, +): ModelErrorEvent { + const malformedFunctionCall = finishReason === 'MALFORMED_FUNCTION_CALL'; + return { + kind: 'error', + message: `Firebase AI Logic candidate ${ + malformedFunctionCall ? 'ended with' : 'was blocked due to' + } ${finishReason}${finishMessage ? `: ${finishMessage}` : ''}`, + code: malformedFunctionCall + ? 'firebase-ai-logic.malformed_function_call' + : 'firebase-ai-logic.candidate_blocked', + retryable: malformedFunctionCall, + details: { + finishReason, + ...(finishMessage ? { finishMessage } : {}), + }, + }; +} + +function firebasePromptBlockedError( + feedback: NonNullable, +): ModelErrorEvent { + const reason = feedback.blockReason ?? 'unknown'; + const message = feedback.blockReasonMessage + ? `Firebase AI Logic blocked the prompt: ${feedback.blockReasonMessage}` + : `Firebase AI Logic blocked the prompt (${reason})`; + return { + kind: 'error', + message, + code: 'firebase-ai-logic.prompt_blocked', + retryable: false, + details: { blockReason: reason }, + }; +} + +function updateUsage( + usage: ModelUsage, + next: NonNullable, +): ModelUsage { + const cachedTokens = next.cachedContentTokenCount ?? usage.cachedTokens; + const reasoningTokens = next.thoughtsTokenCount ?? usage.reasoningTokens; + return { + promptTokens: next.promptTokenCount ?? usage.promptTokens, + outputTokens: next.candidatesTokenCount ?? usage.outputTokens, + ...(typeof cachedTokens === 'number' ? { cachedTokens } : {}), + ...(typeof reasoningTokens === 'number' ? { reasoningTokens } : {}), + }; +} + +function normalizeFirebaseError(error: unknown): ModelErrorEvent { + if (error instanceof UnsupportedGeminiSchemaError) { + return { + kind: 'error', + message: error.message, + code: 'firebase-ai-logic.invalid-tool-schema', + retryable: false, + details: { keyword: error.keyword, path: error.path }, + }; + } + const source = error as { + code?: unknown; + message?: unknown; + customErrorData?: { + status?: unknown; + statusText?: unknown; + errorDetails?: unknown; + }; + }; + const rawCode = typeof source?.code === 'string' ? source.code : undefined; + const codeSuffix = rawCode?.split('/').at(-1); + const status = + typeof source?.customErrorData?.status === 'number' ? source.customErrorData.status : undefined; + const details: Record = {}; + if (status !== undefined) details.status = status; + if (typeof source?.customErrorData?.statusText === 'string') { + details.statusText = source.customErrorData.statusText; + } + if (source?.customErrorData?.errorDetails !== undefined) { + details.errorDetails = source.customErrorData.errorDetails; + } + const retryable = + status === 408 || + status === 429 || + (status !== undefined && status >= 500) || + codeSuffix === 'fetch-error' || + codeSuffix === 'parse-failed'; + let message = String(error); + if (error instanceof Error) message = error.message; + if (typeof source?.message === 'string') message = source.message; + + return { + kind: 'error', + message, + ...(codeSuffix ? { code: `firebase-ai-logic.${codeSuffix}` } : {}), + retryable, + ...(Object.keys(details).length > 0 ? { details } : {}), + }; +} + +function toFirebaseRequest( + model: string, + req: ModelRequest, + options: FirebaseAiLogicModelClientOptions, +): FirebaseAiLogicRequest { + let systemInstruction = ''; + const contents: FirebaseAiLogicRequest['contents'] = []; + + for (let index = 0; index < req.messages.length; index++) { + const message = req.messages[index]; + if (!message) continue; + if (message.role === 'system') { + systemInstruction += `${systemInstruction ? '\n\n' : ''}${message.text ?? ''}`; + } else if (message.role === 'user') { + contents.push({ role: 'user', parts: [{ text: message.text ?? '' }] }); + } else if (message.role === 'assistant') { + const parts: FirebaseAiLogicPart[] = []; + if (message.text) parts.push({ text: message.text }); + for (const call of message.toolCalls ?? []) { + parts.push({ + functionCall: { + ...(!isSyntheticId(call.id) ? { id: call.id } : {}), + name: call.name, + args: toArgsObject(call.args), + }, + ...(call.signature ? { thoughtSignature: call.signature } : {}), + }); + } + if (parts.length > 0) contents.push({ role: 'model', parts }); + } else if (message.role === 'tool') { + const parts: FirebaseAiLogicPart[] = []; + let toolMessage = message; + while (toolMessage?.role === 'tool') { + parts.push({ + functionResponse: { + ...(!isSyntheticId(toolMessage.toolCallId) && toolMessage.toolCallId + ? { id: toolMessage.toolCallId } + : {}), + name: toolMessage.name ?? 'tool', + response: parseFunctionResponse(toolMessage.resultJson), + }, + }); + index += 1; + toolMessage = req.messages[index]!; + } + index -= 1; + contents.push({ role: 'function', parts }); + } + } + + const generationConfig: Record = { maxOutputTokens: 65_536 }; + const temperature = req.temperature ?? options.temperature; + if (typeof temperature === 'number') generationConfig.temperature = temperature; + if (typeof req.topP === 'number') generationConfig.topP = req.topP; + if (typeof req.topK === 'number') generationConfig.topK = req.topK; + const thinking = selectGeminiThinking(model, req.reasoningEffort); + if (thinking) { + if (thinking.kind === 'level') { + generationConfig.thinkingConfig = { + includeThoughts: true, + thinkingLevel: thinking.effort.toUpperCase(), + }; + } else if (thinking.kind === 'budget') { + generationConfig.thinkingConfig = { + includeThoughts: true, + thinkingBudget: thinking.budget, + }; + } else { + generationConfig.thinkingConfig = { includeThoughts: true }; + } + } + + return { + contents, + ...(systemInstruction ? { systemInstruction } : {}), + tools: + req.toolUseEnabled && req.tools.length > 0 + ? [ + { + functionDeclarations: toGeminiFunctionDeclarations(req.tools, { + rejectUnsupported: true, + }), + }, + ] + : [], + generationConfig, + }; +} + +function isSyntheticId(id: string | undefined): boolean { + return id?.startsWith('firebase_') ?? false; +} + +function toArgsObject(args: unknown): Record { + if (args && typeof args === 'object' && !Array.isArray(args)) { + return args as Record; + } + if (args == null) return {}; + return { value: args }; +} + +function parseFunctionResponse(resultJson: string | undefined): Record { + const value = parseJsonValue(resultJson); + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return { result: value }; +} diff --git a/packages/model/src/providers/gemini-protocol.ts b/packages/model/src/providers/gemini-protocol.ts new file mode 100644 index 0000000..9751615 --- /dev/null +++ b/packages/model/src/providers/gemini-protocol.ts @@ -0,0 +1,182 @@ +import type { ModelErrorEvent, ReasoningEffort, ToolSpec } from '../contract.js'; + +const GEMINI_25_THINKING_BUDGET: Record, number> = { + low: 1024, + medium: 4096, + high: 8192, +}; + +export type GeminiThinkingSelection = + | { kind: 'level'; effort: Exclude } + | { kind: 'budget'; budget: number } + | { kind: 'generic' }; + +/** Select the family-specific Gemini thinking control without choosing a wire format. */ +export function selectGeminiThinking( + model: string, + effort: ReasoningEffort | undefined, +): GeminiThinkingSelection | undefined { + if (!effort || effort === 'off') return undefined; + + const normalized = model.toLowerCase(); + if (normalized.includes('gemini-3')) return { kind: 'level', effort }; + if (normalized.includes('gemini-2.5-')) { + return { kind: 'budget', budget: GEMINI_25_THINKING_BUDGET[effort] }; + } + return { kind: 'generic' }; +} + +export interface GeminiNoOutputState { + finishReason: string | undefined; + sawThinking: boolean; + sawVisibleText: boolean; + sawFunctionCall: boolean; +} + +/** Classify Gemini's transport-independent clean-stream/no-visible-output cases. */ +export function geminiNoOutputError( + providerName: string, + codePrefix: string, + state: GeminiNoOutputState, +): ModelErrorEvent { + const finishReason = state.finishReason ?? 'none'; + let code = `${codePrefix}.no_output`; + let retryable = false; + if (state.finishReason === undefined) { + code = `${codePrefix}.truncated_no_output`; + retryable = true; + } else if (state.finishReason === 'MALFORMED_FUNCTION_CALL') { + code = `${codePrefix}.malformed_function_call`; + retryable = true; + } else if (state.finishReason === 'STOP' && state.sawThinking) { + code = `${codePrefix}.thinking_only_stop`; + retryable = true; + } + + return { + kind: 'error', + message: `${providerName} produced no output — finishReason=${finishReason} (${ + state.sawThinking + ? 'response ended after thinking only' + : 'response ended with no visible output' + })`, + code, + retryable, + details: { + finishReason, + sawThinking: state.sawThinking, + sawVisibleText: state.sawVisibleText, + sawFunctionCall: state.sawFunctionCall, + }, + }; +} + +export interface SanitizeGeminiSchemaOptions { + /** Reject semantic/structural keywords Firebase documents as unsupported. */ + rejectUnsupported?: boolean; +} + +export class UnsupportedGeminiSchemaError extends TypeError { + readonly keyword: string; + readonly path: string; + + constructor(keyword: string, path: string) { + super(`Unsupported Gemini function schema keyword "${keyword}" at ${path}`); + this.name = 'UnsupportedGeminiSchemaError'; + this.keyword = keyword; + this.path = path; + } +} + +const STRIP_SCHEMA_KEYS = new Set([ + 'additionalProperties', + '$schema', + '$ref', + '$defs', + 'definitions', +]); +const FIREBASE_STRIP_SCHEMA_KEYS = new Set(['default']); +const REJECT_SCHEMA_KEYS = new Set([ + '$ref', + 'optional', + 'exclusiveMinimum', + 'exclusiveMaximum', + 'multipleOf', + 'oneOf', + 'allOf', + 'not', +]); + +/** + * Deep-clone a tool schema while removing JSON-Schema-only metadata Gemini + * rejects. Firebase callers can opt into clear failures for its documented + * unsupported semantic keywords instead of receiving a remote 400. + */ +export function sanitizeGeminiSchema( + node: unknown, + options: SanitizeGeminiSchemaOptions = {}, +): unknown { + return sanitizeGeminiSchemaNode(node, options, '$'); +} + +function sanitizeGeminiSchemaNode( + node: unknown, + options: SanitizeGeminiSchemaOptions, + path: string, +): unknown { + if (Array.isArray(node)) { + return node.map((value, index) => + sanitizeGeminiSchemaNode(value, options, `${path}[${index}]`), + ); + } + if (node && typeof node === 'object') { + const out: Record = {}; + for (const [key, value] of Object.entries(node)) { + if (key === 'properties' && isRecord(value)) { + out[key] = Object.fromEntries( + Object.entries(value).map(([propertyName, propertySchema]) => [ + propertyName, + sanitizeGeminiSchemaNode(propertySchema, options, `${path}.properties.${propertyName}`), + ]), + ); + continue; + } + if (options.rejectUnsupported && REJECT_SCHEMA_KEYS.has(key)) { + throw new UnsupportedGeminiSchemaError(key, `${path}.${key}`); + } + if ( + STRIP_SCHEMA_KEYS.has(key) || + (options.rejectUnsupported && FIREBASE_STRIP_SCHEMA_KEYS.has(key)) + ) { + continue; + } + out[key] = sanitizeGeminiSchemaNode(value, options, `${path}.${key}`); + } + return out; + } + return node; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +export function toGeminiFunctionDeclarations( + tools: ToolSpec[], + options: SanitizeGeminiSchemaOptions = {}, +): unknown[] { + return tools.map((tool) => ({ + name: tool.function.name, + description: tool.function.description, + parameters: sanitizeGeminiSchema(tool.function.parameters, options), + })); +} + +export function parseJsonValue(value: string | undefined): unknown { + if (value === undefined) return null; + try { + return JSON.parse(value); + } catch { + return value; + } +} diff --git a/packages/model/src/providers/gemini.ts b/packages/model/src/providers/gemini.ts index 3ea79b5..221f40c 100644 --- a/packages/model/src/providers/gemini.ts +++ b/packages/model/src/providers/gemini.ts @@ -1,13 +1,13 @@ -import type { - ModelClient, - ModelErrorEvent, - ModelEvent, - ModelRequest, - ReasoningEffort, - ToolSpec, -} from '../contract.js'; +import type { ModelClient, ModelErrorEvent, ModelEvent, ModelRequest } from '../contract.js'; import { readSseDataLines } from '../sse.js'; +import { + geminiNoOutputError, + selectGeminiThinking, + toGeminiFunctionDeclarations, +} from './gemini-protocol.js'; import type { CloudProviderConfig } from './types.js'; + +export { sanitizeGeminiSchema } from './gemini-protocol.js'; /** * Gemini provider — raw fetch against the Generative Language REST * API, parsing SSE directly. The `@google/genai` SDK is intentionally @@ -50,26 +50,16 @@ interface GeminiBody { generationConfig?: Record; } -const GEMINI_25_THINKING_BUDGET: Record, number> = { - low: 1024, - medium: 4096, - high: 8192, -}; - function buildGeminiThinkingConfig( model: string, - effort: ReasoningEffort | undefined, + effort: ModelRequest['reasoningEffort'], ): Record | undefined { - if (!effort || effort === 'off') return undefined; + const selection = selectGeminiThinking(model, effort); + if (!selection) return undefined; const thinkingConfig: Record = { includeThoughts: true }; - const normalized = model.toLowerCase(); - - if (normalized.includes('gemini-3.5-') || normalized.includes('gemini-3-flash')) { - thinkingConfig.thinkingLevel = effort; - } else if (normalized.includes('gemini-2.5-')) { - thinkingConfig.thinkingBudget = GEMINI_25_THINKING_BUDGET[effort]; - } + if (selection.kind === 'level') thinkingConfig.thinkingLevel = selection.effort; + if (selection.kind === 'budget') thinkingConfig.thinkingBudget = selection.budget; return thinkingConfig; } @@ -131,12 +121,7 @@ function toGeminiBody(config: CloudProviderConfig, req: ModelRequest): GeminiBod if (systemText) body.systemInstruction = { parts: [{ text: systemText }] }; if (req.tools.length > 0) { - const functionDeclarations = req.tools.map((t: ToolSpec) => ({ - name: t.function.name, - description: t.function.description, - parameters: sanitizeGeminiSchema(t.function.parameters), - })); - body.tools = [{ functionDeclarations }]; + body.tools = [{ functionDeclarations: toGeminiFunctionDeclarations(req.tools) }]; } const gen: Record = { @@ -374,7 +359,7 @@ export async function* geminiEventsFromResponse( // only thinking. Surface why: a non-STOP `finishReason` names it, // `none` means the stream was truncated before one arrived. if (!sawVisibleText && !sawFunctionCall) { - yield geminiNoOutputError({ + yield geminiNoOutputError('Gemini', 'gemini', { finishReason: lastFinishReason, sawThinking, sawVisibleText, @@ -414,46 +399,6 @@ export async function* geminiEventsFromResponse( }; } -function geminiNoOutputError(opts: { - finishReason: string | undefined; - sawThinking: boolean; - sawVisibleText: boolean; - sawFunctionCall: boolean; -}): ModelErrorEvent { - const finishReason = opts.finishReason ?? 'none'; - const message = `Gemini produced no output — finishReason=${finishReason} (${ - opts.sawThinking - ? 'response ended after thinking only' - : 'response ended with no visible output' - })`; - - let code = 'gemini.no_output'; - let retryable = false; - if (opts.finishReason === undefined) { - code = 'gemini.truncated_no_output'; - retryable = true; - } else if (opts.finishReason === 'MALFORMED_FUNCTION_CALL') { - code = 'gemini.malformed_function_call'; - retryable = true; - } else if (opts.finishReason === 'STOP' && opts.sawThinking) { - code = 'gemini.thinking_only_stop'; - retryable = true; - } - - return { - kind: 'error', - message, - code, - retryable, - details: { - finishReason, - sawThinking: opts.sawThinking, - sawVisibleText: opts.sawVisibleText, - sawFunctionCall: opts.sawFunctionCall, - }, - }; -} - /** * Total Gemini attempts per call. Three classes of failure benefit * from retry — all transient, all leave the turn with no usable @@ -554,39 +499,3 @@ export function geminiModelClient(config: GeminiConfig): ModelClient { }, }; } - -/** - * Strip JSON-Schema keywords Gemini's `function_declarations[].parameters` - * validator rejects. The validator is a narrow subset of OpenAPI 3.0 - * Schema — anything `zodToJsonSchema` (or hand-written JSON Schema) - * emits beyond that subset 400s with `Unknown name ""`. - * - * Keys stripped: - * - `additionalProperties` — emitted by `zodToJsonSchema` on every - * object; Gemini rejects it outright. - * - `$schema`, `$ref`, `$defs`, `definitions` — JSON-Schema-isms not - * supported in OpenAPI 3.0 Schema. - * - * OpenRouter's adapter accepts the standard JSON Schema unchanged — - * no equivalent sanitizer there. - * - * Implementation: deep-clone walk so we never mutate the caller's - * schema object (the same `parameters` reference is held by the - * ToolRegistry and shared across providers). - */ -const STRIP_KEYS = new Set(['additionalProperties', '$schema', '$ref', '$defs', 'definitions']); - -export function sanitizeGeminiSchema(node: unknown): unknown { - if (Array.isArray(node)) { - return node.map(sanitizeGeminiSchema); - } - if (node && typeof node === 'object') { - const out: Record = {}; - for (const [k, v] of Object.entries(node)) { - if (STRIP_KEYS.has(k)) continue; - out[k] = sanitizeGeminiSchema(v); - } - return out; - } - return node; -} diff --git a/packages/model/test/providers/firebase-ai-logic.test.ts b/packages/model/test/providers/firebase-ai-logic.test.ts new file mode 100644 index 0000000..f474b67 --- /dev/null +++ b/packages/model/test/providers/firebase-ai-logic.test.ts @@ -0,0 +1,1106 @@ +import { describe, expect, test } from 'bun:test'; +import { + type FirebaseAiLogicGenerativeModelLike, + type ModelEvent, + type ModelRequest, + createFirebaseAiLogicModelClient, +} from '../../src/index'; + +async function collect(source: AsyncIterable): Promise { + const events: ModelEvent[] = []; + for await (const event of source) events.push(event); + return events; +} + +function scriptedModel( + chunks: unknown[], + modelId = 'models/gemini-3.5-flash', + aggregateResponse: unknown = {}, +): { + model: FirebaseAiLogicGenerativeModelLike; + requests: unknown[]; + signals: Array; +} { + const requests: unknown[] = []; + const signals: Array = []; + return { + requests, + signals, + model: { + model: modelId, + async generateContentStream(request, options) { + requests.push(request); + signals.push(options?.signal); + return { + stream: (async function* () { + for (const chunk of chunks) yield chunk; + })(), + response: Promise.resolve(aggregateResponse), + }; + }, + }, + }; +} + +describe('Firebase AI Logic provider', () => { + test('wraps a constructed Firebase model as a ModelClient', () => { + const model = { + model: 'models/gemini-3.5-flash', + async generateContentStream() { + return { + stream: (async function* () {})(), + response: Promise.resolve({}), + }; + }, + }; + + const client = createFirebaseAiLogicModelClient(model); + + expect(client.id).toBe('firebase-ai-logic:models/gemini-3.5-flash'); + expect(client.supportsTools).toBe(true); + expect(typeof client.chat).toBe('function'); + }); + + test('maps a text conversation and streams text with final usage', async () => { + const fake = scriptedModel([ + { + candidates: [ + { + content: { role: 'model', parts: [{ text: 'Hello back.' }] }, + finishReason: 'STOP', + }, + ], + }, + { + usageMetadata: { + promptTokenCount: 11, + candidatesTokenCount: 3, + }, + }, + ]); + const client = createFirebaseAiLogicModelClient(fake.model); + const signal = new AbortController().signal; + const request: ModelRequest = { + messages: [ + { role: 'system', text: 'Be concise.' }, + { role: 'user', text: 'Hello' }, + { role: 'assistant', text: 'Earlier answer.' }, + ], + tools: [], + toolUseEnabled: false, + temperature: 0.2, + topP: 0.8, + topK: 20, + }; + + const events = await collect(client.chat(request, signal)); + + expect(fake.requests).toEqual([ + { + contents: [ + { role: 'user', parts: [{ text: 'Hello' }] }, + { role: 'model', parts: [{ text: 'Earlier answer.' }] }, + ], + systemInstruction: 'Be concise.', + tools: [], + generationConfig: { + maxOutputTokens: 65_536, + temperature: 0.2, + topP: 0.8, + topK: 20, + }, + }, + ]); + expect(fake.signals).toEqual([signal]); + expect(events).toEqual([ + { kind: 'text', text: 'Hello back.' }, + { kind: 'usage', usage: { promptTokens: 11, outputTokens: 3 } }, + ]); + }); + + test('maps reasoning effort and uses the latest streamed usage snapshot', async () => { + const fake = scriptedModel([ + { + candidates: [{ content: { parts: [{ text: 'Planning.', thought: true }] } }], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 1, + cachedContentTokenCount: 0, + thoughtsTokenCount: 1, + }, + }, + { + candidates: [ + { + content: { parts: [{ text: 'Done.' }] }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 12, + candidatesTokenCount: 3, + cachedContentTokenCount: 4, + thoughtsTokenCount: 2, + }, + }, + ]); + const client = createFirebaseAiLogicModelClient(fake.model, { temperature: 0.15 }); + + const events = await collect( + client.chat( + { + messages: [{ role: 'user', text: 'Solve it.' }], + tools: [], + toolUseEnabled: false, + reasoningEffort: 'medium', + }, + new AbortController().signal, + ), + ); + + expect(fake.requests).toEqual([ + { + contents: [{ role: 'user', parts: [{ text: 'Solve it.' }] }], + tools: [], + generationConfig: { + maxOutputTokens: 65_536, + temperature: 0.15, + thinkingConfig: { includeThoughts: true, thinkingLevel: 'MEDIUM' }, + }, + }, + ]); + expect(events).toEqual([ + { kind: 'thinking', text: 'Planning.' }, + { kind: 'text', text: 'Done.' }, + { + kind: 'usage', + usage: { + promptTokens: 12, + outputTokens: 3, + cachedTokens: 4, + reasoningTokens: 2, + }, + }, + ]); + }); + + test('maps Gemini 2.5 reasoning to a budget and leaves off unset', async () => { + const fake = scriptedModel( + [ + { + candidates: [ + { + content: { parts: [{ text: 'Answer.' }] }, + finishReason: 'STOP', + }, + ], + }, + ], + 'models/gemini-2.5-flash', + ); + const client = createFirebaseAiLogicModelClient(fake.model); + const baseRequest: Omit = { + messages: [{ role: 'user', text: 'Question' }], + tools: [], + toolUseEnabled: false, + }; + + await collect( + client.chat({ ...baseRequest, reasoningEffort: 'high' }, new AbortController().signal), + ); + await collect( + client.chat({ ...baseRequest, reasoningEffort: 'off' }, new AbortController().signal), + ); + + const requests = fake.requests as Array<{ generationConfig: Record }>; + expect(requests[0].generationConfig.thinkingConfig).toEqual({ + includeThoughts: true, + thinkingBudget: 8192, + }); + expect(requests[1].generationConfig.thinkingConfig).toBeUndefined(); + }); + + test('advertises sanitized function tools only when tool use is enabled', async () => { + const fake = scriptedModel([ + { + candidates: [ + { + content: { parts: [{ text: 'Answer.' }] }, + finishReason: 'STOP', + }, + ], + }, + ]); + const client = createFirebaseAiLogicModelClient(fake.model); + const toolRequest: ModelRequest = { + messages: [{ role: 'user', text: 'Search' }], + tools: [ + { + type: 'function', + function: { + name: 'search_docs', + description: 'Search documentation', + parameters: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + query: { type: 'string', default: 'all' }, + maximum: { type: 'number', minimum: 0, maximum: 10 }, + choice: { + anyOf: [{ type: 'string' }, { type: 'number' }], + }, + }, + required: ['query'], + additionalProperties: false, + }, + }, + }, + ], + toolUseEnabled: true, + }; + + await collect(client.chat(toolRequest, new AbortController().signal)); + await collect( + client.chat({ ...toolRequest, toolUseEnabled: false }, new AbortController().signal), + ); + + const requests = fake.requests as Array<{ tools: unknown[] }>; + expect(requests[0].tools).toEqual([ + { + functionDeclarations: [ + { + name: 'search_docs', + description: 'Search documentation', + parameters: { + type: 'object', + properties: { + query: { type: 'string' }, + maximum: { type: 'number', minimum: 0, maximum: 10 }, + choice: { + anyOf: [{ type: 'string' }, { type: 'number' }], + }, + }, + required: ['query'], + }, + }, + ], + }, + ]); + expect(requests[1].tools).toEqual([]); + }); + + test('collapses cumulative function-call chunks and preserves id and thought signature', async () => { + const fake = scriptedModel([ + { + candidates: [ + { + content: { + role: 'model', + parts: [{ functionCall: { id: 'call-7', name: 'lookup', args: {} } }], + }, + }, + ], + }, + { + candidates: [ + { + content: { + role: 'model', + parts: [ + { + functionCall: { + id: 'call-7', + name: 'lookup', + args: { query: 'firebase' }, + }, + thoughtSignature: 'sig-7', + }, + ], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { promptTokenCount: 8, candidatesTokenCount: 2 }, + }, + ]); + const client = createFirebaseAiLogicModelClient(fake.model); + + const events = await collect( + client.chat( + { + messages: [{ role: 'user', text: 'Find it.' }], + tools: [], + toolUseEnabled: false, + }, + new AbortController().signal, + ), + ); + + expect(events).toEqual([ + { + kind: 'tool_call', + id: 'call-7', + name: 'lookup', + args: { query: 'firebase' }, + signature: 'sig-7', + }, + { kind: 'usage', usage: { promptTokens: 8, outputTokens: 2 } }, + ]); + }); + + test('keeps distinct idless function calls from separate delta chunks', async () => { + const fake = scriptedModel([ + { + candidates: [ + { + content: { + parts: [ + { + functionCall: { name: 'lookup_first', args: { query: 'first' } }, + }, + ], + }, + }, + ], + }, + { + candidates: [ + { + content: { + parts: [ + { + functionCall: { name: 'lookup_second', args: { query: 'second' } }, + }, + ], + }, + finishReason: 'STOP', + }, + ], + }, + ]); + const client = createFirebaseAiLogicModelClient(fake.model); + + const events = await collect( + client.chat( + { + messages: [{ role: 'user', text: 'Find it.' }], + tools: [], + toolUseEnabled: false, + }, + new AbortController().signal, + ), + ); + + expect(events).toEqual([ + { + kind: 'tool_call', + id: 'firebase_0', + name: 'lookup_first', + args: { query: 'first' }, + }, + { + kind: 'tool_call', + id: 'firebase_1', + name: 'lookup_second', + args: { query: 'second' }, + }, + { kind: 'usage', usage: { promptTokens: 0, outputTokens: 0 } }, + ]); + }); + + test('replays an assistant function call and its result with signature and upstream id', async () => { + const fake = scriptedModel([ + { + candidates: [ + { + content: { parts: [{ text: 'Two matches.' }] }, + finishReason: 'STOP', + }, + ], + }, + ]); + const client = createFirebaseAiLogicModelClient(fake.model); + + await collect( + client.chat( + { + messages: [ + { role: 'user', text: 'Find it.' }, + { + role: 'assistant', + toolCalls: [ + { + id: 'call-7', + name: 'lookup', + args: { query: 'firebase' }, + signature: 'sig-7', + }, + ], + }, + { + role: 'tool', + toolCallId: 'call-7', + name: 'lookup', + resultJson: '{"hits":2}', + }, + ], + tools: [], + toolUseEnabled: false, + }, + new AbortController().signal, + ), + ); + + expect(fake.requests).toEqual([ + { + contents: [ + { role: 'user', parts: [{ text: 'Find it.' }] }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'call-7', + name: 'lookup', + args: { query: 'firebase' }, + }, + thoughtSignature: 'sig-7', + }, + ], + }, + { + role: 'function', + parts: [ + { + functionResponse: { + id: 'call-7', + name: 'lookup', + response: { hits: 2 }, + }, + }, + ], + }, + ], + tools: [], + generationConfig: { maxOutputTokens: 65_536 }, + }, + ]); + }); + + test('omits locally synthesized function-call ids when replaying Firebase history', async () => { + const fake = scriptedModel([ + { + candidates: [ + { + content: { + parts: [ + { + functionCall: { name: 'lookup', args: { query: 'firebase' } }, + thoughtSignature: 'sig-local', + }, + ], + }, + finishReason: 'STOP', + }, + ], + }, + ]); + const client = createFirebaseAiLogicModelClient(fake.model); + + const firstEvents = await collect( + client.chat( + { + messages: [{ role: 'user', text: 'Find it.' }], + tools: [], + toolUseEnabled: false, + }, + new AbortController().signal, + ), + ); + + expect(firstEvents[0]).toEqual({ + kind: 'tool_call', + id: 'firebase_0', + name: 'lookup', + args: { query: 'firebase' }, + signature: 'sig-local', + }); + + await collect( + client.chat( + { + messages: [ + { role: 'user', text: 'Find it.' }, + { + role: 'assistant', + toolCalls: [ + { + id: 'firebase_0', + name: 'lookup', + args: { query: 'firebase' }, + signature: 'sig-local', + }, + ], + }, + { + role: 'tool', + toolCallId: 'firebase_0', + name: 'lookup', + resultJson: '{"hits":2}', + }, + ], + tools: [], + toolUseEnabled: false, + }, + new AbortController().signal, + ), + ); + + expect(fake.requests[1]).toEqual({ + contents: [ + { role: 'user', parts: [{ text: 'Find it.' }] }, + { + role: 'model', + parts: [ + { + functionCall: { + name: 'lookup', + args: { query: 'firebase' }, + }, + thoughtSignature: 'sig-local', + }, + ], + }, + { + role: 'function', + parts: [ + { + functionResponse: { + name: 'lookup', + response: { hits: 2 }, + }, + }, + ], + }, + ], + tools: [], + generationConfig: { maxOutputTokens: 65_536 }, + }); + }); + + test('coalesces adjacent tool results and normalizes non-object result values', async () => { + const fake = scriptedModel([ + { + candidates: [ + { + content: { parts: [{ text: 'Combined.' }] }, + finishReason: 'STOP', + }, + ], + }, + ]); + const client = createFirebaseAiLogicModelClient(fake.model); + + await collect( + client.chat( + { + messages: [ + { role: 'user', text: 'Run both.' }, + { + role: 'tool', + toolCallId: 'call-1', + name: 'first', + resultJson: '42', + }, + { + role: 'tool', + toolCallId: 'call-2', + name: 'second', + resultJson: '["a","b"]', + }, + { role: 'user', text: 'Summarize.' }, + ], + tools: [], + toolUseEnabled: false, + }, + new AbortController().signal, + ), + ); + + expect(fake.requests[0]).toEqual({ + contents: [ + { role: 'user', parts: [{ text: 'Run both.' }] }, + { + role: 'function', + parts: [ + { + functionResponse: { + id: 'call-1', + name: 'first', + response: { result: 42 }, + }, + }, + { + functionResponse: { + id: 'call-2', + name: 'second', + response: { result: ['a', 'b'] }, + }, + }, + ], + }, + { role: 'user', parts: [{ text: 'Summarize.' }] }, + ], + tools: [], + generationConfig: { maxOutputTokens: 65_536 }, + }); + }); + + test('normalizes a retryable Firebase AI error as the terminal event', async () => { + const firebaseError = Object.assign(new Error('Firebase AI rate limited'), { + code: 'fetch-error', + customErrorData: { + status: 429, + statusText: 'Too Many Requests', + errorDetails: [{ reason: 'RATE_LIMIT_EXCEEDED' }], + }, + }); + const model: FirebaseAiLogicGenerativeModelLike = { + model: 'models/gemini-3.5-flash', + async generateContentStream() { + throw firebaseError; + }, + }; + const client = createFirebaseAiLogicModelClient(model); + + const events = await collect( + client.chat( + { + messages: [{ role: 'user', text: 'Hello' }], + tools: [], + toolUseEnabled: false, + }, + new AbortController().signal, + ), + ); + + expect(events).toEqual([ + { + kind: 'error', + message: 'Firebase AI rate limited', + code: 'firebase-ai-logic.fetch-error', + retryable: true, + details: { + status: 429, + statusText: 'Too Many Requests', + errorDetails: [{ reason: 'RATE_LIMIT_EXCEEDED' }], + }, + }, + ]); + }); + + test('marks deterministic Firebase AI request errors as non-retryable', async () => { + const firebaseError = Object.assign(new Error('Invalid conversation content'), { + code: 'ai/invalid-content', + customErrorData: { status: 400, statusText: 'Bad Request' }, + }); + const model: FirebaseAiLogicGenerativeModelLike = { + model: 'models/gemini-3.5-flash', + async generateContentStream() { + throw firebaseError; + }, + }; + const client = createFirebaseAiLogicModelClient(model); + + const events = await collect( + client.chat( + { + messages: [{ role: 'user', text: 'Hello' }], + tools: [], + toolUseEnabled: false, + }, + new AbortController().signal, + ), + ); + + expect(events).toEqual([ + { + kind: 'error', + message: 'Invalid conversation content', + code: 'firebase-ai-logic.invalid-content', + retryable: false, + details: { status: 400, statusText: 'Bad Request' }, + }, + ]); + }); + + test('rejects unsupported Firebase function-schema keywords before the SDK call', async () => { + let calls = 0; + const model: FirebaseAiLogicGenerativeModelLike = { + model: 'models/gemini-3.5-flash', + async generateContentStream() { + calls += 1; + return { + stream: (async function* () {})(), + response: Promise.resolve({}), + }; + }, + }; + const client = createFirebaseAiLogicModelClient(model); + + const events = await collect( + client.chat( + { + messages: [{ role: 'user', text: 'Choose.' }], + tools: [ + { + type: 'function', + function: { + name: 'choose', + description: 'Choose a value', + parameters: { + type: 'object', + properties: { + count: { + oneOf: [{ type: 'number' }, { type: 'string' }], + }, + }, + }, + }, + }, + ], + toolUseEnabled: true, + }, + new AbortController().signal, + ), + ); + + expect(calls).toBe(0); + expect(events).toEqual([ + { + kind: 'error', + message: 'Unsupported Gemini function schema keyword "oneOf" at $.properties.count.oneOf', + code: 'firebase-ai-logic.invalid-tool-schema', + retryable: false, + details: { + keyword: 'oneOf', + path: '$.properties.count.oneOf', + }, + }, + ]); + }); + + test('reads a blocked prompt from the aggregate response when the SDK stream is empty', async () => { + const fake = scriptedModel([], 'models/gemini-3.5-flash', { + promptFeedback: { + blockReason: 'SAFETY', + blockReasonMessage: 'The prompt was blocked.', + }, + }); + const client = createFirebaseAiLogicModelClient(fake.model); + + const events = await collect( + client.chat( + { + messages: [{ role: 'user', text: 'Hello' }], + tools: [], + toolUseEnabled: false, + }, + new AbortController().signal, + ), + ); + + expect(events).toEqual([ + { + kind: 'error', + message: 'Firebase AI Logic blocked the prompt: The prompt was blocked.', + code: 'firebase-ai-logic.prompt_blocked', + retryable: false, + details: { blockReason: 'SAFETY' }, + }, + ]); + }); + + test('ends a candidate-level safety block with an error instead of usage', async () => { + const fake = scriptedModel([ + { + candidates: [ + { + content: { parts: [{ text: 'Partial unsafe text.' }] }, + finishReason: 'SAFETY', + finishMessage: 'Candidate matched a safety filter.', + }, + ], + }, + ]); + const client = createFirebaseAiLogicModelClient(fake.model); + + const events = await collect( + client.chat( + { + messages: [{ role: 'user', text: 'Hello' }], + tools: [], + toolUseEnabled: false, + }, + new AbortController().signal, + ), + ); + + expect(events).toEqual([ + { kind: 'text', text: 'Partial unsafe text.' }, + { + kind: 'error', + message: + 'Firebase AI Logic candidate was blocked due to SAFETY: Candidate matched a safety filter.', + code: 'firebase-ai-logic.candidate_blocked', + retryable: false, + details: { + finishReason: 'SAFETY', + finishMessage: 'Candidate matched a safety filter.', + }, + }, + ]); + }); + + test('classifies blocked and no-output responses as terminal errors', async () => { + const cases = [ + { + chunk: { + promptFeedback: { + blockReason: 'SAFETY', + blockReasonMessage: 'The prompt was blocked.', + }, + }, + error: { + kind: 'error', + message: 'Firebase AI Logic blocked the prompt: The prompt was blocked.', + code: 'firebase-ai-logic.prompt_blocked', + retryable: false, + details: { blockReason: 'SAFETY' }, + }, + }, + { + chunk: { + candidates: [ + { + content: { parts: [{ text: 'Still thinking.', thought: true }] }, + finishReason: 'STOP', + }, + ], + }, + error: { + kind: 'error', + message: + 'Firebase AI Logic produced no output — finishReason=STOP (response ended after thinking only)', + code: 'firebase-ai-logic.thinking_only_stop', + retryable: true, + details: { + finishReason: 'STOP', + sawThinking: true, + sawVisibleText: false, + sawFunctionCall: false, + }, + }, + }, + { + chunk: { usageMetadata: { promptTokenCount: 2, candidatesTokenCount: 0 } }, + error: { + kind: 'error', + message: + 'Firebase AI Logic produced no output — finishReason=none (response ended with no visible output)', + code: 'firebase-ai-logic.truncated_no_output', + retryable: true, + details: { + finishReason: 'none', + sawThinking: false, + sawVisibleText: false, + sawFunctionCall: false, + }, + }, + }, + { + chunk: { + candidates: [ + { + content: { parts: [{ functionCall: {} }] }, + finishReason: 'MALFORMED_FUNCTION_CALL', + }, + ], + }, + error: { + kind: 'error', + message: + 'Firebase AI Logic produced no output — finishReason=MALFORMED_FUNCTION_CALL (response ended with no visible output)', + code: 'firebase-ai-logic.malformed_function_call', + retryable: true, + details: { + finishReason: 'MALFORMED_FUNCTION_CALL', + sawThinking: false, + sawVisibleText: false, + sawFunctionCall: false, + }, + }, + }, + ] as const; + + for (const entry of cases) { + const fake = scriptedModel([entry.chunk]); + const client = createFirebaseAiLogicModelClient(fake.model); + + const events = await collect( + client.chat( + { + messages: [{ role: 'user', text: 'Hello' }], + tools: [], + toolUseEnabled: false, + }, + new AbortController().signal, + ), + ); + + expect(events.at(-1)).toEqual(entry.error); + expect(events.some((event) => event.kind === 'usage')).toBe(false); + } + }); + + test('surfaces a mid-stream failure after prior output without a usage event', async () => { + const failure = Object.assign(new Error('Stream disconnected'), { + code: 'ai/fetch-error', + customErrorData: { status: 503 }, + }); + const model: FirebaseAiLogicGenerativeModelLike = { + model: 'models/gemini-3.5-flash', + async generateContentStream() { + return { + stream: (async function* () { + yield { candidates: [{ content: { parts: [{ text: 'Partial.' }] } }] }; + throw failure; + })(), + response: Promise.resolve({}), + }; + }, + }; + const client = createFirebaseAiLogicModelClient(model); + + const events = await collect( + client.chat( + { + messages: [{ role: 'user', text: 'Hello' }], + tools: [], + toolUseEnabled: false, + }, + new AbortController().signal, + ), + ); + + expect(events).toEqual([ + { kind: 'text', text: 'Partial.' }, + { + kind: 'error', + message: 'Stream disconnected', + code: 'firebase-ai-logic.fetch-error', + retryable: true, + details: { status: 503 }, + }, + ]); + }); + + test('returns silently without calling Firebase when already aborted', async () => { + let calls = 0; + const model: FirebaseAiLogicGenerativeModelLike = { + model: 'models/gemini-3.5-flash', + async generateContentStream() { + calls += 1; + return { + stream: (async function* () {})(), + response: Promise.resolve({}), + }; + }, + }; + const client = createFirebaseAiLogicModelClient(model); + const controller = new AbortController(); + controller.abort(); + + const events = await collect( + client.chat( + { + messages: [{ role: 'user', text: 'Hello' }], + tools: [], + toolUseEnabled: false, + }, + controller.signal, + ), + ); + + expect(calls).toBe(0); + expect(events).toEqual([]); + }); + + test('stops consuming a stream as soon as the request is aborted', async () => { + const controller = new AbortController(); + const model: FirebaseAiLogicGenerativeModelLike = { + model: 'models/gemini-3.5-flash', + async generateContentStream() { + return { + stream: (async function* () { + yield { candidates: [{ content: { parts: [{ text: 'First.' }] } }] }; + controller.abort(); + yield { + candidates: [ + { + content: { parts: [{ text: 'Must not be emitted.' }] }, + finishReason: 'STOP', + }, + ], + }; + })(), + response: Promise.resolve({}), + }; + }, + }; + const client = createFirebaseAiLogicModelClient(model); + + const events = await collect( + client.chat( + { + messages: [{ role: 'user', text: 'Hello' }], + tools: [], + toolUseEnabled: false, + }, + controller.signal, + ), + ); + + expect(events).toEqual([{ kind: 'text', text: 'First.' }]); + }); + + test('does not emit a terminal event when cancellation closes the stream', async () => { + const controller = new AbortController(); + const model: FirebaseAiLogicGenerativeModelLike = { + model: 'models/gemini-3.5-flash', + async generateContentStream() { + return { + stream: (async function* () { + yield { candidates: [{ content: { parts: [{ text: 'Partial.' }] } }] }; + controller.abort(); + })(), + response: Promise.resolve({}), + }; + }, + }; + const client = createFirebaseAiLogicModelClient(model); + + const events = await collect( + client.chat( + { + messages: [{ role: 'user', text: 'Hello' }], + tools: [], + toolUseEnabled: false, + }, + controller.signal, + ), + ); + + expect(events).toEqual([{ kind: 'text', text: 'Partial.' }]); + }); +}); diff --git a/scripts/smoke-pack.ts b/scripts/smoke-pack.ts index 752d537..91b8bf9 100755 --- a/scripts/smoke-pack.ts +++ b/scripts/smoke-pack.ts @@ -16,10 +16,10 @@ * 6. bundles `@inbrowser/relay/client/browser` for the `browser` target * to prove the browser sub-export has no Node API references * - * Model coverage is import-only — `createEngine` exists in node but - * needs `@huggingface/transformers` and a real model to do anything; - * this script asserts the export shape and stops before attempting - * to load a model. + * The on-device model coverage is import-only because `createEngine` + * needs a real model. Constructed-runtime adapters are invoked with + * structural fakes so their packed root exports and dependency seams + * are exercised. */ import { existsSync, mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs'; @@ -145,6 +145,8 @@ const SPECS: PackSpec[] = [ // (stage 4). 'package/dist/with-retry.js', 'package/dist/providers/gemini.js', + 'package/dist/providers/gemini-protocol.js', + 'package/dist/providers/firebase-ai-logic.js', 'package/dist/providers/openrouter.js', 'package/dist/providers/anthropic.js', 'package/dist/providers/oai-compat.js', @@ -379,6 +381,7 @@ import { llamaServerModelClient, anthropicModelClient, claudeCliModelClient, + createFirebaseAiLogicModelClient, withRetry, } from '@inbrowser/model'; for (const [name, fn] of [ @@ -388,6 +391,7 @@ for (const [name, fn] of [ ['llamaServerModelClient', llamaServerModelClient], ['anthropicModelClient', anthropicModelClient], ['claudeCliModelClient', claudeCliModelClient], + ['createFirebaseAiLogicModelClient', createFirebaseAiLogicModelClient], ['withRetry', withRetry], ]) { assert.equal(typeof fn, 'function', \`model root: \${name} should be a function\`); @@ -399,7 +403,43 @@ assert.equal(typeof geminiClient.chat, 'function'); const llamaClient = llamaServerModelClient({ model: 'qwen2.5-coder' }); assert.equal(llamaClient.id, 'llama:qwen2.5-coder'); assert.equal(typeof llamaClient.chat, 'function'); -console.log(' ✓ model: cloud provider factories + withRetry exported from root'); + +// Firebase itself is deliberately absent from this scratch install. A +// caller-constructed structural model is enough to use the adapter. +const firebaseClient = createFirebaseAiLogicModelClient({ + model: 'models/gemini-3.5-flash', + async generateContentStream(request, options) { + assert.equal(request.contents[0].parts[0].text, 'smoke'); + assert.equal(options.signal.aborted, false); + return { + stream: (async function* () { + yield { + candidates: [{ content: { parts: [{ text: 'ok' }] }, finishReason: 'STOP' }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, + }; + })(), + response: Promise.resolve({}), + }; + }, +}); +assert.equal(firebaseClient.id, 'firebase-ai-logic:models/gemini-3.5-flash'); +assert.equal(firebaseClient.supportsTools, true); +const firebaseEvents = []; +for await (const event of firebaseClient.chat( + { + messages: [{ role: 'user', text: 'smoke' }], + tools: [], + toolUseEnabled: false, + }, + new AbortController().signal, +)) { + firebaseEvents.push(event); +} +assert.deepEqual(firebaseEvents, [ + { kind: 'text', text: 'ok' }, + { kind: 'usage', usage: { promptTokens: 1, outputTokens: 1 } }, +]); +console.log(' ✓ model: provider factories, Firebase adapter, and withRetry exported from root'); // The engine→ModelClient adapter resolves from the root and is a function // (the on-device engine is now a ModelClient).