Build a Browser Model Adapter Core

Build a browser lab that streams Gemini through native and compatible adapters.

Introduction

30 Second Summary

New AI options can arrive faster than an app's built-in list can keep up. A stale list can block a useful choice before the app even tries it.

In this project, you will build a browser-based model adapter that accepts free-form model IDs. The lab uses the Google Gemini API to stream Gemini 3.8 Flash through a native transport or OpenAI compatibility.

What You'll Build

In Firefox, you will watch the same prompt produce a progressively streamed response through either transport.

By the end of this project, you'll have:

  • A free-form adapter probe that sends any model ID to the server while keeping the key as a server-side secret.
  • A before-and-after demonstration showing a current model move from catalog rejection to live endpoint validation.
  • A streamed transport comparison with active text plus streaming capability badges after each successful response.
  • Secret Mission: Add the Gemini Interactions API as a third adapter without changing the probe route.

Are there any prerequisites?

You need a Mac with Firefox, Node.js 22 or newer, internet access, an existing Gemini API key, plus the local Node.js workflow from your completed DeepSeek Harness project.

The project makes two short requests through that account, so its current pricing applies.

Before We Start

This opening checkpoint locks in the fixed model catalog bottleneck that your browser laboratory replaces with an adapter contract plus endpoint validation. It also defines what successful native Google and OpenAI-compatible streams prove about one free-form model ID.

Set Up the Browser Processor

Provider code needs a predictable server and browser boundary before you can test it safely. If that boundary drifts between installs, later adapter results become harder to trust.

A pinned Next.js shell gives every later probe the same foundation. This step proves that foundation works locally before any provider logic or credentials enter the project.

In this step, get ready to:
  • Create the local project folder with its pinned configuration files.
  • Add the root layout, global styles, starter page, and reusable Input component.
  • Install the dependencies and launch the starter page in Firefox.
Create the Pinned Project Shell

The project folder keeps the browser interface, server code, and shared components together. You will place it on your Desktop so its location stays easy to find.

  • Press Cmd+Space to open Spotlight.
  • Type Terminal and press Return.
  • Move to your Desktop by running this command:
cd ~/Desktop

What Does This Command Do?

The cd command changes Terminal's current location. The ~ shortcut points to your Mac user folder.

  • Create the browser-model-adapter-core folder and move into it by running these commands:
mkdir browser-model-adapter-core
cd browser-model-adapter-core

What Do These Commands Do?

  • The mkdir command creates the project folder on your Desktop.
  • The second command makes that folder Terminal's current location.
  • Confirm Terminal is inside the new folder by running:
pwd

What Does This Check Prove?

The pwd command prints Terminal's current location. You should see a path ending in Desktop/browser-model-adapter-core.

  • Create the required source folders by running these commands:
mkdir app components
mkdir components/ui

How Is the Project Organized?

  • The app folder holds the starter page, root layout, and global stylesheet.
  • The components/ui folder holds the reusable Input component.
  • Confirm the source folders exist by running:
ls

What Should You See?

The file list should contain app and components. This confirms that the empty project structure is ready for files.

  • Write the pinned package.json file by running:
cat > package.json <<'EOF'
{"name":"browser-model-adapter-core","version":"0.1.0","private":true,"engines":{"node":">=22"},"scripts":{"dev":"next dev","build":"next build","start":"next start"},"dependencies":{"@ai-sdk/google":"4.0.94","@ai-sdk/openai-compatible":"3.0.68","ai":"7.0.138","cn":"0.4.0","next":"16.4.0","react":"19.3.0","react-dom":"19.3.0","zod":"4.6.7"},"devDependencies":{"@types/node":"26.6.5","@types/react":"19.3.0","@types/react-dom":"19.3.0","typescript":"7.0.2"}}
EOF

What Does This File Control?

  • The dependency sections pin the framework, provider packages, validation library, and type packages to exact project versions.
  • The engines field records Node.js 22 as the minimum runtime.
  • The scripts field defines how npm starts, builds, and serves the project.
  • Write the strict tsconfig.json configuration by running:
cat > tsconfig.json <<'EOF'
{"compilerOptions":{"target":"ES2017","lib":["dom","dom.iterable","esnext"],"allowJs":false,"skipLibCheck":true,"strict":true,"noEmit":true,"esModuleInterop":true,"module":"esnext","moduleResolution":"bundler","resolveJsonModule":true,"isolatedModules":true,"jsx":"react-jsx","incremental":true,"plugins":[{"name":"next"}],"paths":{"@/*":["./*"]}},"include":["next-env.d.ts","**/*.ts","**/*.tsx",".next/types/**/*.ts"],"exclude":["node_modules"]}
EOF

What Does This Configuration Do?

  • The configuration enables strict TypeScript checks across the project.
  • The @/* path mapping lets later files import project modules from a stable root alias.
  • The noEmit setting leaves code generation to Next.js.
  • Confirm both configuration files are present by running:
ls

What Does This Check Prove?

You should now see package.json and tsconfig.json beside the two source folders. The project now has a reproducible dependency and compiler definition.

Add the Starter Interface

A root layout gives every page the same document shell. The temporary page keeps the first browser check focused on one visible title.

  • Add the root layout to app/layout.tsx by running:
cat > app/layout.tsx <<'EOF'
import "./globals.css";
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
  return <html lang="en"><body>{children}</body></html>;
}
EOF

What Does This Layout Do?

  • The layout imports globals.css once for every page.
  • The children value represents the page content rendered inside the document body.
  • The React.ReactNode type allows the layout to accept valid React content.
  • Create the temporary starter page in app/page.tsx by running:
cat > app/page.tsx <<'EOF'
export default function Page() {
  return <main className="shell"><h1>Browser Model Adapter Core</h1></main>;
}
EOF

What Does This Page Prove?

The Page component renders one heading inside the shared shell layout class. Seeing that heading later confirms that Next.js can compile and serve the project.

  • Confirm the layout and starter page exist by running:
ls app

What Should You See?

The output should list layout.tsx and page.tsx. Both parts of the initial document structure are now saved.

  • Add the global stylesheet to app/globals.css by running:
cat > app/globals.css <<'EOF'
:root{color-scheme:dark;--panel:rgba(14,27,46,.9);--line:rgba(148,163,184,.22);--text:#e6edf7;--muted:#93a4ba;--accent:#6ee7b7;--danger:#fb7185;--warning:#fbbf24}*{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(circle at top left,rgba(16,185,129,.13),transparent 32rem),linear-gradient(145deg,#07111f,#0b1526 55%,#07111f);color:var(--text);font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}button,input,select,textarea{font:inherit}.shell{width:min(1120px,calc(100% - 32px));margin:0 auto;padding:64px 0}.hero{max-width:760px;margin-bottom:32px}h1,h2,p{margin-top:0}h1{margin-bottom:12px;font-size:clamp(2.2rem,6vw,4.5rem);line-height:.98;letter-spacing:-.05em}h2{margin-bottom:0}.hero p:last-child,.footnote{color:var(--muted);line-height:1.65}.eyebrow{margin-bottom:8px;color:var(--accent);font-size:.75rem;font-weight:800;letter-spacing:.16em;text-transform:uppercase}.grid{display:grid;grid-template-columns:1fr 1fr;gap:20px}.panel{min-width:0;padding:24px;border:1px solid var(--line);border-radius:20px;background:var(--panel);box-shadow:0 24px 80px rgba(0,0,0,.24)}.panel-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:24px}label{display:grid;gap:8px;margin-bottom:18px;color:#cbd5e1;font-size:.9rem;font-weight:700}[data-slot="input"],select,textarea{width:100%;border:1px solid rgba(148,163,184,.28);border-radius:10px;outline:0;background:rgba(2,8,23,.72);color:var(--text)}[data-slot="input"],select{min-height:42px;padding:0 12px}textarea{resize:vertical;padding:12px;line-height:1.5}[data-slot="input"]:focus,select:focus,textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(110,231,183,.12)}.check-row{display:flex;align-items:center;gap:10px}.check-row input{width:18px;height:18px;accent-color:#10b981}button{width:100%;min-height:44px;border:0;border-radius:11px;background:linear-gradient(135deg,var(--accent),#10b981);color:#032014;font-weight:900;cursor:pointer}button:disabled{cursor:wait;opacity:.6}.status,.badges span{display:inline-flex;border:1px solid var(--line);border-radius:999px;padding:6px 10px;color:var(--muted);font-size:.75rem;font-weight:800}.status-running,.status-success,.badges span[data-active="true"]{border-color:rgba(110,231,183,.45);background:rgba(16,185,129,.13);color:var(--accent)}.status-blocked{color:var(--warning)}.status-error{color:var(--danger)}.badges{display:flex;flex-wrap:wrap;gap:8px;margin:20px 0}pre{min-height:240px;margin:0;overflow:auto;border:1px solid var(--line);border-radius:14px;padding:18px;background:rgba(2,8,23,.82);color:#d8f8e9;font-family:"SFMono-Regular",Consolas,monospace;font-size:.88rem;line-height:1.65;white-space:pre-wrap}.error-box{margin:16px 0 0;border:1px solid rgba(251,113,133,.4);border-radius:10px;padding:12px;background:rgba(127,29,29,.2);color:#fecdd3}.footnote{margin:18px 0 0;font-size:.82rem}@media(max-width:800px){.shell{padding:36px 0}.grid{grid-template-columns:1fr}}
EOF

What Does This Stylesheet Do?

  • The body styles create the dark background and shared typography.
  • The .shell selector centers the starter page with responsive spacing.
  • The remaining selectors define the controls, status badges, panels, and output area used by the adapter laboratory.
  • Add the reusable Input component to components/ui/input.tsx by running:
cat > components/ui/input.tsx <<'EOF'
import * as React from "react";
import { cn } from "cn";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
  return <input type={type} data-slot="input" className={cn("h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base outline-none", className)} {...props} />;
}
export { Input };
EOF

What Does This Component Do?

  • This shadcn/ui-style component wraps a native input while preserving its normal properties.
  • The cn helper combines the default classes with any extra class supplied by a page.
  • The data-slot="input" attribute gives the plain CSS stylesheet a stable styling target.
  • Confirm the stylesheet and Input component exist by running:
ls app components/ui

What Should You See?

The app listing should contain globals.css, layout.tsx, and page.tsx. The components/ui listing should contain input.tsx.

✔️ Awesome, I've got everything!

Good. These six files now form the exact pinned starter project.

ⓧ I'd like to double check the full code

Compare each saved file with the complete versions below.

{"name":"browser-model-adapter-core","version":"0.1.0","private":true,"engines":{"node":">=22"},"scripts":{"dev":"next dev","build":"next build","start":"next start"},"dependencies":{"@ai-sdk/google":"4.0.94","@ai-sdk/openai-compatible":"3.0.68","ai":"7.0.138","cn":"0.4.0","next":"16.4.0","react":"19.3.0","react-dom":"19.3.0","zod":"4.6.7"},"devDependencies":{"@types/node":"26.6.5","@types/react":"19.3.0","@types/react-dom":"19.3.0","typescript":"7.0.2"}}
{"compilerOptions":{"target":"ES2017","lib":["dom","dom.iterable","esnext"],"allowJs":false,"skipLibCheck":true,"strict":true,"noEmit":true,"esModuleInterop":true,"module":"esnext","moduleResolution":"bundler","resolveJsonModule":true,"isolatedModules":true,"jsx":"react-jsx","incremental":true,"plugins":[{"name":"next"}],"paths":{"@/*":["./*"]}},"include":["next-env.d.ts","**/*.ts","**/*.tsx",".next/types/**/*.ts"],"exclude":["node_modules"]}
import "./globals.css";
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
  return <html lang="en"><body>{children}</body></html>;
}
export default function Page() {
  return <main className="shell"><h1>Browser Model Adapter Core</h1></main>;
}
:root{color-scheme:dark;--panel:rgba(14,27,46,.9);--line:rgba(148,163,184,.22);--text:#e6edf7;--muted:#93a4ba;--accent:#6ee7b7;--danger:#fb7185;--warning:#fbbf24}*{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(circle at top left,rgba(16,185,129,.13),transparent 32rem),linear-gradient(145deg,#07111f,#0b1526 55%,#07111f);color:var(--text);font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}button,input,select,textarea{font:inherit}.shell{width:min(1120px,calc(100% - 32px));margin:0 auto;padding:64px 0}.hero{max-width:760px;margin-bottom:32px}h1,h2,p{margin-top:0}h1{margin-bottom:12px;font-size:clamp(2.2rem,6vw,4.5rem);line-height:.98;letter-spacing:-.05em}h2{margin-bottom:0}.hero p:last-child,.footnote{color:var(--muted);line-height:1.65}.eyebrow{margin-bottom:8px;color:var(--accent);font-size:.75rem;font-weight:800;letter-spacing:.16em;text-transform:uppercase}.grid{display:grid;grid-template-columns:1fr 1fr;gap:20px}.panel{min-width:0;padding:24px;border:1px solid var(--line);border-radius:20px;background:var(--panel);box-shadow:0 24px 80px rgba(0,0,0,.24)}.panel-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:24px}label{display:grid;gap:8px;margin-bottom:18px;color:#cbd5e1;font-size:.9rem;font-weight:700}[data-slot="input"],select,textarea{width:100%;border:1px solid rgba(148,163,184,.28);border-radius:10px;outline:0;background:rgba(2,8,23,.72);color:var(--text)}[data-slot="input"],select{min-height:42px;padding:0 12px}textarea{resize:vertical;padding:12px;line-height:1.5}[data-slot="input"]:focus,select:focus,textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(110,231,183,.12)}.check-row{display:flex;align-items:center;gap:10px}.check-row input{width:18px;height:18px;accent-color:#10b981}button{width:100%;min-height:44px;border:0;border-radius:11px;background:linear-gradient(135deg,var(--accent),#10b981);color:#032014;font-weight:900;cursor:pointer}button:disabled{cursor:wait;opacity:.6}.status,.badges span{display:inline-flex;border:1px solid var(--line);border-radius:999px;padding:6px 10px;color:var(--muted);font-size:.75rem;font-weight:800}.status-running,.status-success,.badges span[data-active="true"]{border-color:rgba(110,231,183,.45);background:rgba(16,185,129,.13);color:var(--accent)}.status-blocked{color:var(--warning)}.status-error{color:var(--danger)}.badges{display:flex;flex-wrap:wrap;gap:8px;margin:20px 0}pre{min-height:240px;margin:0;overflow:auto;border:1px solid var(--line);border-radius:14px;padding:18px;background:rgba(2,8,23,.82);color:#d8f8e9;font-family:"SFMono-Regular",Consolas,monospace;font-size:.88rem;line-height:1.65;white-space:pre-wrap}.error-box{margin:16px 0 0;border:1px solid rgba(251,113,133,.4);border-radius:10px;padding:12px;background:rgba(127,29,29,.2);color:#fecdd3}.footnote{margin:18px 0 0;font-size:.82rem}@media(max-width:800px){.shell{padding:36px 0}.grid{grid-template-columns:1fr}}
import * as React from "react";
import { cn } from "cn";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
  return <input type={type} data-slot="input" className={cn("h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base outline-none", className)} {...props} />;
}
export { Input };
Install and Run the Project

The files define what the project needs. Installing now turns those version pins into a local dependency tree that the development server can use.

The first install can take a few minutes while npm downloads the pinned packages. A pause in Terminal during this step is normal.

  • Install the dependencies declared in package.json by running:
npm install

What Does This Command Do?

The npm install command reads package.json and installs its dependencies into the local node_modules folder. It also records the resolved dependency tree in a lock file.

  • Confirm the installed dependency folder exists by running:
ls

What Does This Check Prove?

You should see node_modules in the file list. This confirms that the pinned packages are available to the local project.

Installation Not Finishing?

  • Confirm your Mac still has an active internet connection.
  • Check that Terminal's current path ends in browser-model-adapter-core.
  • Confirm package.json appears in the current folder.

Help me diagnose why npm installation is failing.

Before you start the server, what do you expect Firefox to show at the local project address? Hold that prediction while you run the final check.

  • Start the Next.js development server by running:
npm run dev

What Happens When the Server Starts?

  • The development script starts Next.js in local development mode.
  • The process remains attached to this Terminal window while the server is active.
  • Pressing Control-C stops the server when you finish working later.
  • Keep the development server active in its Terminal window.
  • Press Cmd+Space to open Spotlight.
  • Type Firefox and press Return.
  • Enter http://localhost:3000 in the Firefox address bar.
  • Press Return to load the starter page.

You should see Browser Model Adapter Core on a dark page. The development server should remain active in Terminal.

Starter Page Not Loading?

  • Check that the Terminal window still shows the active development server.
  • Confirm the Firefox address is exactly http://localhost:3000.
  • Review the Terminal output for a file name that points to a typing mismatch.

Help me troubleshoot the local starter page.

Your pinned browser processor is live. Next, you will make the fixed catalog reject a current model before any network request can leave the browser.

Reproduce the Model Catalog Bottleneck

Your Next.js starter page proves the local server works. Firefox can already display its output.

A hard-coded model catalog can reject a current model before any provider request starts. You will reproduce that bottleneck with gemini-3.8-flash so the limitation becomes visible.

In this step, get ready to:
  • Replace the starter page with a browser adapter form.
  • Add a fixed legacy allowlist containing one older model ID.
  • Submit the current model to trigger a local rejection.
Build the temporary adapter form

The temporary page keeps its adapter options beside the interface. This lets the browser form run before shared contracts or a server route exist.

  • Return to the code editor from Step 1.
  • Select app/page.tsx in the file sidebar.
  • Replace the starter component with the temporary adapter form below:
"use client";
import { useState } from "react";
import { Input } from "@/components/ui/input";
const ADAPTER_OPTIONS = [
  { value: "google-native", label: "Google native" },
  { value: "openai-compatible", label: "OpenAI-compatible" },
] as const;
type AdapterKind = (typeof ADAPTER_OPTIONS)[number]["value"];
const DEFAULT_MODEL_ID = "gemini-3.8-flash";
const DEFAULT_OPENAI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/";
const LEGACY_ALLOWED_MODELS = ["gemini-3.6-flash"];
type ProbeStatus = "idle" | "blocked" | "running" | "success" | "error";
export default function Page() {
  const [adapter, setAdapter] = useState<AdapterKind>("google-native");
  const [modelId, setModelId] = useState(DEFAULT_MODEL_ID);
  const [baseURL, setBaseURL] = useState(DEFAULT_OPENAI_BASE_URL);
  const [prompt, setPrompt] = useState("Reply with a short sentence confirming that adapter streaming works.");
  const [legacyMode, setLegacyMode] = useState(false);
  const [status, setStatus] = useState<ProbeStatus>("idle");
  const [output, setOutput] = useState("");
  const [error, setError] = useState("");
  const [capabilities, setCapabilities] = useState({ text: false, streaming: false });
  async function runProbe() {
    setOutput(""); setError(""); setCapabilities({ text: false, streaming: false });
    if (legacyMode && !LEGACY_ALLOWED_MODELS.includes(modelId)) { setStatus("blocked"); setError(`Blocked by legacy catalog: ${modelId} is not in the fixed model list.`); return; }
  }
  const statusLabel = { idle: "Ready", blocked: "Blocked by legacy catalog", running: "Streaming", success: "Probe passed", error: "Probe failed" }[status];
  return <main className="shell"><section className="hero"><p className="eyebrow">Browser-based AI processor foundation</p><h1>Browser Model Adapter Core</h1><p>Test a free-form model ID against multiple server-side transports.</p></section><section className="grid"><div className="panel"><div className="panel-heading"><div><p className="eyebrow">Adapter configuration</p><h2>Capability probe</h2></div><span className={`status status-${status}`}>{statusLabel}</span></div><label>Adapter<select value={adapter} onChange={(event) => setAdapter(event.target.value as AdapterKind)}>{ADAPTER_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label><label>Model ID<Input value={modelId} onChange={(event) => setModelId(event.target.value)} spellCheck={false} /></label>{adapter === "openai-compatible" && <label>OpenAI-compatible base URL<Input value={baseURL} onChange={(event) => setBaseURL(event.target.value)} spellCheck={false} /></label>}<label>Probe prompt<textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} rows={4} /></label><label className="check-row"><input type="checkbox" checked={legacyMode} onChange={(event) => setLegacyMode(event.target.checked)} />Simulate legacy catalog</label><button type="button" onClick={runProbe} disabled={status === "running"}>{status === "running" ? "Streaming response" : "Run capability probe"}</button>{error && <p className="error-box">{error}</p>}</div><div className="panel"><p className="eyebrow">Observed capabilities</p><h2>Live result</h2><div className="badges"><span data-active={capabilities.text}>Text supported</span><span data-active={capabilities.streaming}>Streaming supported</span></div><pre>{output || "The streamed provider response will appear here."}</pre><p className="footnote">The API key stays in the server environment.</p></div></section></main>;
}

What does this page prove?

  • The local ADAPTER_OPTIONS array supplies two transport choices to the selector.
  • The DEFAULT_MODEL_ID constant puts the current Gemini model in the form.
  • The DEFAULT_OPENAI_BASE_URL constant prepares the compatibility field.
  • React state tracks the form values plus each observable probe result.
  • The LEGACY_ALLOWED_MODELS array contains only gemini-3.6-flash.
  • The early return stops runProbe() as soon as the fixed list rejects the model.
  • Save app/page.tsx by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Switch back to Firefox from earlier.
  • Refresh http://localhost:3000.

You should see two panels below the Browser Model Adapter Core heading. The left panel contains the adapter form while the right panel contains capability badges plus an output area.

You have turned the starter page into a working browser lab.

✔️ Awesome, I've got everything!

The adapter form is visible in Firefox. Your temporary page is ready for the legacy catalog test.

ⓧ I'd like to double check the full code

  • Compare your complete app/page.tsx file with this reference:
"use client";
import { useState } from "react";
import { Input } from "@/components/ui/input";
const ADAPTER_OPTIONS = [
  { value: "google-native", label: "Google native" },
  { value: "openai-compatible", label: "OpenAI-compatible" },
] as const;
type AdapterKind = (typeof ADAPTER_OPTIONS)[number]["value"];
const DEFAULT_MODEL_ID = "gemini-3.8-flash";
const DEFAULT_OPENAI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/";
const LEGACY_ALLOWED_MODELS = ["gemini-3.6-flash"];
type ProbeStatus = "idle" | "blocked" | "running" | "success" | "error";
export default function Page() {
  const [adapter, setAdapter] = useState<AdapterKind>("google-native");
  const [modelId, setModelId] = useState(DEFAULT_MODEL_ID);
  const [baseURL, setBaseURL] = useState(DEFAULT_OPENAI_BASE_URL);
  const [prompt, setPrompt] = useState("Reply with a short sentence confirming that adapter streaming works.");
  const [legacyMode, setLegacyMode] = useState(false);
  const [status, setStatus] = useState<ProbeStatus>("idle");
  const [output, setOutput] = useState("");
  const [error, setError] = useState("");
  const [capabilities, setCapabilities] = useState({ text: false, streaming: false });
  async function runProbe() {
    setOutput(""); setError(""); setCapabilities({ text: false, streaming: false });
    if (legacyMode && !LEGACY_ALLOWED_MODELS.includes(modelId)) { setStatus("blocked"); setError(`Blocked by legacy catalog: ${modelId} is not in the fixed model list.`); return; }
  }
  const statusLabel = { idle: "Ready", blocked: "Blocked by legacy catalog", running: "Streaming", success: "Probe passed", error: "Probe failed" }[status];
  return <main className="shell"><section className="hero"><p className="eyebrow">Browser-based AI processor foundation</p><h1>Browser Model Adapter Core</h1><p>Test a free-form model ID against multiple server-side transports.</p></section><section className="grid"><div className="panel"><div className="panel-heading"><div><p className="eyebrow">Adapter configuration</p><h2>Capability probe</h2></div><span className={`status status-${status}`}>{statusLabel}</span></div><label>Adapter<select value={adapter} onChange={(event) => setAdapter(event.target.value as AdapterKind)}>{ADAPTER_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label><label>Model ID<Input value={modelId} onChange={(event) => setModelId(event.target.value)} spellCheck={false} /></label>{adapter === "openai-compatible" && <label>OpenAI-compatible base URL<Input value={baseURL} onChange={(event) => setBaseURL(event.target.value)} spellCheck={false} /></label>}<label>Probe prompt<textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} rows={4} /></label><label className="check-row"><input type="checkbox" checked={legacyMode} onChange={(event) => setLegacyMode(event.target.checked)} />Simulate legacy catalog</label><button type="button" onClick={runProbe} disabled={status === "running"}>{status === "running" ? "Streaming response" : "Run capability probe"}</button>{error && <p className="error-box">{error}</p>}</div><div className="panel"><p className="eyebrow">Observed capabilities</p><h2>Live result</h2><div className="badges"><span data-active={capabilities.text}>Text supported</span><span data-active={capabilities.streaming}>Streaming supported</span></div><pre>{output || "The streamed provider response will appear here."}</pre><p className="footnote">The API key stays in the server environment.</p></div></section></main>;
}

Form not appearing?

  • Confirm that you edited app/page.tsx inside browser-model-adapter-core.
  • Check that "use client"; is the first line of the file.
  • Check the active Terminal from earlier for a compilation problem.

Help me diagnose why the adapter form is not rendering in Firefox.

Prepare the legacy check

The fixed allowlist contains only gemini-3.6-flash. The model field starts with gemini-3.8-flash.

  • Keep gemini-3.8-flash in the Model ID field.
  • Select the Simulate legacy catalog checkbox.

The selected checkbox activates the fixed-list condition inside runProbe().

Test the current model

Before you run the check, do you think the browser will reach a provider or stop inside the page?

  • Click Run capability probe.

You will see the status change to Blocked by legacy catalog. The form will show Blocked by legacy catalog: gemini-3.8-flash is not in the fixed model list.

The output panel still shows its original placeholder. No request reaches /api/probe.

You have made the catalog bottleneck visible. A local list rejected a current model before the provider could judge it.

Status still says Ready?

  • Confirm that the Simulate legacy catalog checkbox is selected.
  • Confirm that the Model ID field contains gemini-3.8-flash.
  • Click Run capability probe again.

Help me find why the local legacy check is not changing the probe status.

The fixed list has now exposed the bottleneck. Next, you will move model acceptance into a shared adapter contract so the request can reach the server boundary.

Replace the Catalog with an Adapter Contract

You made the fixed catalog bottleneck visible. The browser rejected a current model before the request could reach a provider.

A stable adapter contract fixes that limitation by validating the transport configuration plus the model ID syntax. The live provider can then decide whether the free-form model ID is available.

In this step, get ready to:
  • Move the adapter options plus request validation into a shared contract.
  • Resolve native Google plus OpenAI-compatible models on the server.
  • Add a streaming probe route that returns readable diagnostics.
Define the shared adapter contract

The contract gives the browser plus server one definition of a valid request. Its parser checks the JSON object without comparing the model ID against a built-in catalog.

  • Return to the editor from earlier.
  • Use the editor's file explorer to create a `lib` folder inside `browser-model-adapter-core`.

You should see the new lib folder beside app in the file explorer.

  • Create a contracts.ts file inside the new lib folder.
  • Build the shared request contract by pasting this code into lib/contracts.ts:
export const ADAPTER_OPTIONS = [
  { value: "google-native", label: "Google native" },
  { value: "openai-compatible", label: "OpenAI-compatible" },
] as const;
export type AdapterKind = (typeof ADAPTER_OPTIONS)[number]["value"];
export type ProbeRequest = { adapter: AdapterKind; modelId: string; baseURL?: string; prompt: string };
export const DEFAULT_MODEL_ID = "gemini-3.8-flash";
export const DEFAULT_OPENAI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/";
export function parseProbeRequest(value: unknown): ProbeRequest {
  if (typeof value !== "object" || value === null) throw new Error("The probe request must be a JSON object.");
  const candidate = value as Partial<ProbeRequest>;
  const adapter = candidate.adapter;
  if (adapter !== "google-native" && adapter !== "openai-compatible") throw new Error("Choose a supported adapter.");
  if (typeof candidate.modelId !== "string" || !candidate.modelId.trim()) throw new Error("Enter a model ID.");
  if (typeof candidate.prompt !== "string" || !candidate.prompt.trim()) throw new Error("Enter a probe prompt.");
  let baseURL: string | undefined;
  if (adapter === "openai-compatible") {
    if (typeof candidate.baseURL !== "string" || !candidate.baseURL.trim()) throw new Error("Enter an OpenAI-compatible base URL.");
    const parsedURL = new URL(candidate.baseURL);
    if (parsedURL.protocol !== "https:" && parsedURL.protocol !== "http:") throw new Error("The base URL must use HTTP or HTTPS.");
    baseURL = parsedURL.toString();
  }
  return { adapter, modelId: candidate.modelId.trim(), baseURL, prompt: candidate.prompt.trim() };
}

What does this contract do?

  • The ADAPTER_OPTIONS array gives the browser one shared list of available transports.
  • The ProbeRequest type defines the values that cross the browser and server boundary.
  • The parseProbeRequest() function rejects missing request fields plus unsupported transports.
  • The model check accepts any non-empty string. The optional legacy simulation remains the only fixed catalog check.

The browser can now import these shared values. Replacing app/page.tsx also adds the streaming reader that the probe route uses later in this step.

  • Select all existing code in app/page.tsx.
  • Replace the selected code with the imports plus shared page types below:
"use client";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { ADAPTER_OPTIONS, DEFAULT_MODEL_ID, DEFAULT_OPENAI_BASE_URL, type AdapterKind } from "@/lib/contracts";
const LEGACY_ALLOWED_MODELS = ["gemini-3.6-flash"];
type ProbeStatus = "idle" | "blocked" | "running" | "success" | "error";

Why move these values?

  • The page imports the adapter choices plus defaults from lib/contracts.ts.
  • The LEGACY_ALLOWED_MODELS array stays in the page because it only powers the optional bottleneck simulation.
  • The ProbeStatus type tracks each visible stage of a probe.
  • Complete app/page.tsx by pasting this component directly below the page types:
export default function Page() {
  const [adapter, setAdapter] = useState<AdapterKind>("google-native");
  const [modelId, setModelId] = useState(DEFAULT_MODEL_ID);
  const [baseURL, setBaseURL] = useState(DEFAULT_OPENAI_BASE_URL);
  const [prompt, setPrompt] = useState("Reply with a short sentence confirming that adapter streaming works.");
  const [legacyMode, setLegacyMode] = useState(false);
  const [status, setStatus] = useState<ProbeStatus>("idle");
  const [output, setOutput] = useState("");
  const [error, setError] = useState("");
  const [capabilities, setCapabilities] = useState({ text: false, streaming: false });
  async function runProbe() {
    setOutput(""); setError(""); setCapabilities({ text: false, streaming: false });
    if (legacyMode && !LEGACY_ALLOWED_MODELS.includes(modelId)) { setStatus("blocked"); setError(`Blocked by legacy catalog: ${modelId} is not in the fixed model list.`); return; }
    setStatus("running");
    try {
      const response = await fetch("/api/probe", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ adapter, modelId, baseURL, prompt }) });
      if (!response.ok) { const problem = (await response.json()) as { error?: string }; throw new Error(problem.error ?? "The probe request was rejected."); }
      if (!response.body) throw new Error("The response did not include a readable stream.");
      const reader = response.body.getReader(); const decoder = new TextDecoder(); let streamedOutput = ""; let chunkCount = 0;
      while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); if (chunk) { chunkCount += 1; streamedOutput += chunk; setOutput(streamedOutput); } }
      streamedOutput += decoder.decode(); setOutput(streamedOutput);
      if (!streamedOutput.trim()) throw new Error("The provider returned no text. Check the Terminal for the provider error.");
      setCapabilities({ text: true, streaming: chunkCount > 0 }); setStatus("success");
    } catch (caughtError) { setStatus("error"); setError(caughtError instanceof Error ? caughtError.message : "The capability probe failed."); }
  }
  const statusLabel = { idle: "Ready", blocked: "Blocked by legacy catalog", running: "Streaming", success: "Probe passed", error: "Probe failed" }[status];
  return <main className="shell"><section className="hero"><p className="eyebrow">Browser-based AI processor foundation</p><h1>Browser Model Adapter Core</h1><p>Test a free-form model ID against multiple server-side transports.</p></section><section className="grid"><div className="panel"><div className="panel-heading"><div><p className="eyebrow">Adapter configuration</p><h2>Capability probe</h2></div><span className={`status status-${status}`}>{statusLabel}</span></div><label>Adapter<select value={adapter} onChange={(event) => setAdapter(event.target.value as AdapterKind)}>{ADAPTER_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label><label>Model ID<Input value={modelId} onChange={(event) => setModelId(event.target.value)} spellCheck={false} /></label>{adapter === "openai-compatible" && <label>OpenAI-compatible base URL<Input value={baseURL} onChange={(event) => setBaseURL(event.target.value)} spellCheck={false} /></label>}<label>Probe prompt<textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} rows={4} /></label><label className="check-row"><input type="checkbox" checked={legacyMode} onChange={(event) => setLegacyMode(event.target.checked)} />Simulate legacy catalog</label><button type="button" onClick={runProbe} disabled={status === "running"}>{status === "running" ? "Streaming response" : "Run capability probe"}</button>{error && <p className="error-box">{error}</p>}</div><div className="panel"><p className="eyebrow">Observed capabilities</p><h2>Live result</h2><div className="badges"><span data-active={capabilities.text}>Text supported</span><span data-active={capabilities.streaming}>Streaming supported</span></div><pre>{output || "The streamed provider response will appear here."}</pre><p className="footnote">The API key stays in the server environment.</p></div></section></main>;
}

How does the browser handle a probe?

  • The runProbe() function keeps the legacy rejection behind the simulation checkbox.
  • The request sends the adapter configuration plus free-form model ID to /api/probe.
  • The response reader decodes each chunk. Every chunk updates the visible output.
  • A completed response activates the text plus streaming capability badges.
  • Save lib/contracts.ts.
  • Save app/page.tsx.
  • Return to Firefox at http://localhost:3000.

You should see the capability probe with Google native selected. The status should show Ready.

Page not loading after the replacement?

  • Confirm that app/page.tsx contains both code sections in the order shown.
  • Confirm that the imported file is saved at lib/contracts.ts.
  • Check the running development terminal for the file plus line named in the compilation error.

Help me diagnose the page compilation error.

Resolve each transport on the server

The resolver turns a validated request into the provider-specific model object expected by the streaming function. It reads the Google Gemini API key from the server environment so browser code never receives it.

  • Create an adapters.ts file inside the existing lib folder.
  • Add the server-side model resolver by pasting this code into lib/adapters.ts:
import { createGoogle } from "@ai-sdk/google";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import type { ProbeRequest } from "@/lib/contracts";
export function resolveModel(config: ProbeRequest) {
  const apiKey = process.env.GEMINI_API_KEY;
  if (!apiKey) throw new Error("Missing GEMINI_API_KEY. Add it to the server environment and restart npm run dev.");
  if (config.adapter === "google-native") {
    const google = createGoogle({ apiKey });
    return google(config.modelId);
  }
  const compatible = createOpenAICompatible({ name: "browser-processor-compatible", apiKey, baseURL: config.baseURL! });
  return compatible.chatModel(config.modelId);
}

How does the resolver protect the key?

  • The resolveModel() function reads GEMINI_API_KEY from process.env on the server.
  • The native branch creates a Google provider. It passes the free-form model ID to that provider.
  • The compatible branch creates a provider with the submitted base URL. Its chatModel() call receives the same model ID.
  • A missing key produces a readable diagnostic before any provider request begins.
  • Save lib/adapters.ts.
  • Confirm that adapters.ts appears beside contracts.ts in the editor's lib folder.

The two files now separate transport resolution from request validation. Each file has one responsibility.

Seeing an import error in adapters.ts?

  • Confirm that the file path is exactly lib/adapters.ts.
  • Check that ProbeRequest is exported from lib/contracts.ts.
  • Compare both provider imports with the code block above.

Help me fix the adapter resolver import error.

Expose the streaming probe route

A Next.js Route Handler gives the browser a server boundary for model requests. The route parses the request before passing the resolved model plus prompt to the streaming function.

  • Create an api folder inside the existing app folder.
  • Create a probe folder inside app/api.

You should now see the nested app/api/probe path in the editor's file explorer.

  • Create a route.ts file inside app/api/probe.
  • Implement the streaming endpoint by pasting this code into app/api/probe/route.ts:
import { streamText } from "ai";
import { resolveModel } from "@/lib/adapters";
import { parseProbeRequest } from "@/lib/contracts";
export const runtime = "nodejs";
export async function POST(request: Request) {
  try {
    const config = parseProbeRequest(await request.json());
    const result = streamText({ model: resolveModel(config), prompt: config.prompt, maxOutputTokens: 80, onError: ({ error }) => console.error("Model capability probe failed:", error) });
    return result.toTextStreamResponse();
  } catch (error) {
    const message = error instanceof Error ? error.message : "The capability probe failed.";
    return Response.json({ error: message }, { status: 400 });
  }
}

How does the route stream safely?

  • The POST() handler parses the browser request through parseProbeRequest().
  • The streamText() call receives the resolved model plus validated prompt.
  • The maxOutputTokens value limits the response to 80 output tokens.
  • The toTextStreamResponse() method returns text chunks that the browser can render progressively.
  • The catch block converts setup plus validation failures into a readable response with status 400.
  • Save app/api/probe/route.ts.
  • Return to Firefox at http://localhost:3000.
  • Clear the Simulate legacy catalog checkbox if it is selected.

Before you run the probe, do you think the model ID will be blocked in the browser or reach the new server route?

  • Choose Run capability probe.

You should see the status change to Probe failed. The error panel should show Missing GEMINI_API_KEY. Add it to the server environment and restart npm run dev.

That diagnostic is the result you wanted. The free-form model ID passed the browser check plus shared contract before server-side configuration stopped the request.

Still blocked by the legacy catalog?

  • Clear the Simulate legacy catalog checkbox.
  • Keep gemini-3.8-flash in the Model ID field.
  • Run the capability probe again.

Help me trace why the legacy catalog still blocks the request.

Use this comparison if you want to check every file changed in this step.

✔️ Awesome, I've got everything!

Great. Your browser now reaches the server through a shared adapter contract.

ⓧ I'd like to double check the full code

The complete lib/contracts.ts file should match this reference.

export const ADAPTER_OPTIONS = [
  { value: "google-native", label: "Google native" },
  { value: "openai-compatible", label: "OpenAI-compatible" },
] as const;
export type AdapterKind = (typeof ADAPTER_OPTIONS)[number]["value"];
export type ProbeRequest = { adapter: AdapterKind; modelId: string; baseURL?: string; prompt: string };
export const DEFAULT_MODEL_ID = "gemini-3.8-flash";
export const DEFAULT_OPENAI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/";
export function parseProbeRequest(value: unknown): ProbeRequest {
  if (typeof value !== "object" || value === null) throw new Error("The probe request must be a JSON object.");
  const candidate = value as Partial<ProbeRequest>;
  const adapter = candidate.adapter;
  if (adapter !== "google-native" && adapter !== "openai-compatible") throw new Error("Choose a supported adapter.");
  if (typeof candidate.modelId !== "string" || !candidate.modelId.trim()) throw new Error("Enter a model ID.");
  if (typeof candidate.prompt !== "string" || !candidate.prompt.trim()) throw new Error("Enter a probe prompt.");
  let baseURL: string | undefined;
  if (adapter === "openai-compatible") {
    if (typeof candidate.baseURL !== "string" || !candidate.baseURL.trim()) throw new Error("Enter an OpenAI-compatible base URL.");
    const parsedURL = new URL(candidate.baseURL);
    if (parsedURL.protocol !== "https:" && parsedURL.protocol !== "http:") throw new Error("The base URL must use HTTP or HTTPS.");
    baseURL = parsedURL.toString();
  }
  return { adapter, modelId: candidate.modelId.trim(), baseURL, prompt: candidate.prompt.trim() };
}

The complete lib/adapters.ts file should match this reference.

import { createGoogle } from "@ai-sdk/google";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import type { ProbeRequest } from "@/lib/contracts";
export function resolveModel(config: ProbeRequest) {
  const apiKey = process.env.GEMINI_API_KEY;
  if (!apiKey) throw new Error("Missing GEMINI_API_KEY. Add it to the server environment and restart npm run dev.");
  if (config.adapter === "google-native") {
    const google = createGoogle({ apiKey });
    return google(config.modelId);
  }
  const compatible = createOpenAICompatible({ name: "browser-processor-compatible", apiKey, baseURL: config.baseURL! });
  return compatible.chatModel(config.modelId);
}

The complete app/api/probe/route.ts file should match this reference.

import { streamText } from "ai";
import { resolveModel } from "@/lib/adapters";
import { parseProbeRequest } from "@/lib/contracts";
export const runtime = "nodejs";
export async function POST(request: Request) {
  try {
    const config = parseProbeRequest(await request.json());
    const result = streamText({ model: resolveModel(config), prompt: config.prompt, maxOutputTokens: 80, onError: ({ error }) => console.error("Model capability probe failed:", error) });
    return result.toTextStreamResponse();
  } catch (error) {
    const message = error instanceof Error ? error.message : "The capability probe failed.";
    return Response.json({ error: message }, { status: 400 });
  }
}

The complete app/page.tsx file should match this reference.

"use client";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { ADAPTER_OPTIONS, DEFAULT_MODEL_ID, DEFAULT_OPENAI_BASE_URL, type AdapterKind } from "@/lib/contracts";
const LEGACY_ALLOWED_MODELS = ["gemini-3.6-flash"];
type ProbeStatus = "idle" | "blocked" | "running" | "success" | "error";
export default function Page() {
  const [adapter, setAdapter] = useState<AdapterKind>("google-native");
  const [modelId, setModelId] = useState(DEFAULT_MODEL_ID);
  const [baseURL, setBaseURL] = useState(DEFAULT_OPENAI_BASE_URL);
  const [prompt, setPrompt] = useState("Reply with a short sentence confirming that adapter streaming works.");
  const [legacyMode, setLegacyMode] = useState(false);
  const [status, setStatus] = useState<ProbeStatus>("idle");
  const [output, setOutput] = useState("");
  const [error, setError] = useState("");
  const [capabilities, setCapabilities] = useState({ text: false, streaming: false });
  async function runProbe() {
    setOutput(""); setError(""); setCapabilities({ text: false, streaming: false });
    if (legacyMode && !LEGACY_ALLOWED_MODELS.includes(modelId)) { setStatus("blocked"); setError(`Blocked by legacy catalog: ${modelId} is not in the fixed model list.`); return; }
    setStatus("running");
    try {
      const response = await fetch("/api/probe", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ adapter, modelId, baseURL, prompt }) });
      if (!response.ok) { const problem = (await response.json()) as { error?: string }; throw new Error(problem.error ?? "The probe request was rejected."); }
      if (!response.body) throw new Error("The response did not include a readable stream.");
      const reader = response.body.getReader(); const decoder = new TextDecoder(); let streamedOutput = ""; let chunkCount = 0;
      while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); if (chunk) { chunkCount += 1; streamedOutput += chunk; setOutput(streamedOutput); } }
      streamedOutput += decoder.decode(); setOutput(streamedOutput);
      if (!streamedOutput.trim()) throw new Error("The provider returned no text. Check the Terminal for the provider error.");
      setCapabilities({ text: true, streaming: chunkCount > 0 }); setStatus("success");
    } catch (caughtError) { setStatus("error"); setError(caughtError instanceof Error ? caughtError.message : "The capability probe failed."); }
  }
  const statusLabel = { idle: "Ready", blocked: "Blocked by legacy catalog", running: "Streaming", success: "Probe passed", error: "Probe failed" }[status];
  return <main className="shell"><section className="hero"><p className="eyebrow">Browser-based AI processor foundation</p><h1>Browser Model Adapter Core</h1><p>Test a free-form model ID against multiple server-side transports.</p></section><section className="grid"><div className="panel"><div className="panel-heading"><div><p className="eyebrow">Adapter configuration</p><h2>Capability probe</h2></div><span className={`status status-${status}`}>{statusLabel}</span></div><label>Adapter<select value={adapter} onChange={(event) => setAdapter(event.target.value as AdapterKind)}>{ADAPTER_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label><label>Model ID<Input value={modelId} onChange={(event) => setModelId(event.target.value)} spellCheck={false} /></label>{adapter === "openai-compatible" && <label>OpenAI-compatible base URL<Input value={baseURL} onChange={(event) => setBaseURL(event.target.value)} spellCheck={false} /></label>}<label>Probe prompt<textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} rows={4} /></label><label className="check-row"><input type="checkbox" checked={legacyMode} onChange={(event) => setLegacyMode(event.target.checked)} />Simulate legacy catalog</label><button type="button" onClick={runProbe} disabled={status === "running"}>{status === "running" ? "Streaming response" : "Run capability probe"}</button>{error && <p className="error-box">{error}</p>}</div><div className="panel"><p className="eyebrow">Observed capabilities</p><h2>Live result</h2><div className="badges"><span data-active={capabilities.text}>Text supported</span><span data-active={capabilities.streaming}>Streaming supported</span></div><pre>{output || "The streamed provider response will appear here."}</pre><p className="footnote">The API key stays in the server environment.</p></div></section></main>;
}

Your model ID now reaches a stable server contract without requiring catalog approval. Next up, you will configure the server key plus stream the first native Gemini response.

Stream a Native Gemini Probe

Your free-form adapter contract now carries gemini-3.8-flash from Firefox to the server. The next proof comes from a live response from the Gemini API.

Live output from the native provider proves the model works across the server boundary. This step adds the server-side credential before exposing each streamed chunk in the browser.

In this step, get ready to:
  • Add GEMINI_API_KEY to the active macOS shell environment.
  • Restart the development server with the environment variable available.
  • Run the native capability probe to receive a streamed response.
Add the Gemini key to the server environment

The adapter reads GEMINI_API_KEY from process.env inside the server process. Keeping it there prevents source files and browser requests from carrying the credential.

Working with a live credential can feel risky. This command limits the key to the active Terminal session and the processes started from it.

  • Switch back to the Terminal session that runs the development server.
  • Stop the development server by pressing Control+C.

You will see the shell prompt again. The server is now stopped without changing any project files.

  • Set the key for this shell by replacing YOUR_API_KEY with your existing Gemini API key before running this command:
export GEMINI_API_KEY="YOUR_API_KEY"

Why does the key stay on the server?

The export command makes the key available to processes launched from this Terminal session. It does not add the credential to your project files.

The development server inherits the value when it starts. The browser receives model output without receiving the key.

  • Restart the development server from the same Terminal session by running:
npm run dev

Why does the server need a restart?

The new server process inherits GEMINI_API_KEY from the active shell. The resolveModel function can now read it through process.env when a probe arrives.

You will see the development server become ready with http://localhost:3000 available. Keep this Terminal session running.

Still seeing the missing-key diagnostic?

  • Confirm you replaced YOUR_API_KEY with the existing key before running the export command.
  • Repeat the export command in the same Terminal session that starts the development server.
  • Confirm the Terminal prompt points to the browser-model-adapter-core folder before restarting the server.

Help me check why the server cannot read my Gemini API key.

Prepare the native probe

Using the same model and prompt keeps the provider transport as the observable variable. The native adapter sends the free-form model ID through createGoogle without consulting a required catalog.

  • Return to the Firefox window from earlier.
  • Refresh the page at http://localhost:3000.
  • Select Google native from the Adapter menu.
  • Confirm the Model ID field contains gemini-3.8-flash.
  • Clear the Simulate legacy catalog checkbox.
  • Confirm the Probe prompt field contains Reply with a short sentence confirming that adapter streaming works..

With Google native selected, the OpenAI-compatible base URL field is hidden. The form is ready to use the native transport.

Run the native capability probe

This probe uses your Gemini account

This action sends one real Gemini API request through your existing account. Current account pricing and quota settings apply, so keep the default short prompt.

Before you run the probe, do you expect the response to arrive all at once or appear in pieces?

  • Choose Run capability probe.

You will see the status change to Streaming. The output panel fills progressively with a short confirmation response.

When the stream finishes, you will see Probe passed. The Text supported and Streaming supported badges both become active.

That is the core proof complete. The native provider accepted the free-form model ID through the server boundary and returned live output.

Did the native probe fail?

  • Check the running Terminal for the provider diagnostic logged by the Route Handler.
  • Restart the development server from the Terminal session where you exported GEMINI_API_KEY if the browser reports a missing key.
  • Confirm your existing key is authorized for the Gemini API if the provider rejects it. Unrestricted standard keys are rejected.

Help me diagnose my native Gemini probe without exposing my API key.

Your native adapter now streams through one stable browser contract. Next up, you will route the same model through the OpenAI-compatible transport without rebuilding the interface.

Prove the OpenAI-Compatible Path

Your native request to the Google Gemini API already streams gemini-3.8-flash through the server boundary. The browser now shows both supported capabilities.

Now you will route the same free-form model ID through OpenAI compatibility. A second successful stream proves the adapter contract can swap protocols without a new catalog or a rewritten interface.

In this step, get ready to:
  • Switch the browser form to the OpenAI-compatible transport.
  • Stream a second response with the same model ID and prompt.
  • Compare the capability result with the native Google result.
Switch to the compatible transport

An OpenAI-compatible transport points the existing server adapter at a compatible endpoint. The browser reveals the base URL only when this transport is selected.

  • Select OpenAI-compatible from the Adapter menu.
  • Confirm the Model ID field still shows gemini-3.8-flash.
  • Confirm the OpenAI-compatible base URL field appears.
  • Confirm the base URL is https://generativelanguage.googleapis.com/v1beta/openai/.
  • Confirm the Probe prompt field still shows Reply with a short sentence confirming that adapter streaming works.
  • Confirm Simulate legacy catalog remains cleared.

The browser now exposes the transport setting for the compatible request. The free-form model field remains unchanged.

Why use the same model twice?

Using the same model removes model quality from the comparison. Using the same key removes account differences.

The protocol is the meaningful variable. A matching streamed result shows that the stable contract can swap transports.

Run the second streamed probe

The existing runProbe() function sends the same request shape to /api/probe. The selected adapter tells resolveModel() to use the compatible provider on the server.

This request uses your Gemini account

This sends a second real Gemini API request through your existing account. Current account pricing applies.

The browser never receives GEMINI_API_KEY.

  • Keep the default short prompt to limit this probe.

Before you run the second probe, do you expect the browser request shape or capability display to change when only the server-side transport changes?

  • Click Run capability probe.

You'll see the output panel fill progressively while the status reads Streaming. When the response finishes, the status changes to Probe passed.

The Text supported badge becomes active. The Streaming supported badge becomes active.

Did the compatible probe fail?

  • Confirm the Adapter menu still shows OpenAI-compatible.
  • Confirm the OpenAI-compatible base URL field contains https://generativelanguage.googleapis.com/v1beta/openai/.
  • Confirm the development server remains active in the Terminal panel from earlier.
  • Check that the browser diagnostic identifies the failed request.

Help me diagnose my OpenAI-compatible probe failure.

Compare both transport results

Before you compare the two runs, which parts of the browser experience do you expect to remain unchanged?

  • Compare the current streaming behavior with the native result you observed earlier.
  • Confirm the Adapter menu shows OpenAI-compatible.
  • Confirm the Model ID field still shows gemini-3.8-flash.
  • Confirm the status shows Probe passed.
  • Confirm the Text supported badge remains active.
  • Confirm the Streaming supported badge remains active.

The second route uses the same browser form and capability display as the native route. Only the server-side transport branch changed.

What did this prove?

  • The runProbe() function sends the same browser request contract to /api/probe.
  • The POST Route Handler parses the common ProbeRequest with parseProbeRequest().
  • The resolveModel() function selects createOpenAICompatible for the compatible adapter.
  • The streamText function streams the selected model result.
  • The toTextStreamResponse() method returns the text stream to the existing browser loop.
  • No source file changed during this test. The transport swap came from the existing contract.

You proved that one free-form model ID works through two provider protocols. The adapter contract can now switch transports without rebuilding model acceptance.

Secret mission

Add the Gemini Interactions Adapter

A third provider protocol puts your adapter contract to a stronger test. Add Gemini Interactions through one new option and one resolver branch while keeping the shared probe route unchanged.

Clean Up Your Resources

Clean Up Your Resources

Keeping the local project has no ongoing infrastructure cost. New Gemini API requests still follow the pricing on your existing account, so decide whether to keep the workflow active, pause it for later, or delete it entirely.

Resources you used:

  • The development server running in macOS Terminal.
  • The local browser-model-adapter-core folder, including its installed npm dependencies.
  • A persistent GEMINI_API_KEY shell entry, if you added one to your shell profile.

Keep everything running

No action needed. Choose this if you are still testing the three adapter protocols.

  • Leave the development server running in the Terminal session from earlier.
  • Keep the browser-model-adapter-core folder on your Mac.
  • Keep the GEMINI_API_KEY shell entry only while your local applications need it.

The server makes no API request by itself. Any future live probe uses the pricing on your existing Gemini account.

Pause - I'll come back to this later

Pause the running process to free the Terminal session. Your project files remain available.

  • Switch back to the Terminal development server from earlier.
  • Press Control-C to stop the development server.

You will return to the shell prompt. Your project folder remains on your Mac.

Your shell entry also remains in place. When you return, restart the project with the same development command from Step 1.

Delete - I don't want to use this again

Remove the local project resources to start fresh. Deleting the folder becomes permanent after you empty the Bin.

  • Switch back to the Terminal development server from earlier.
  • Press Control-C if the development server is still active.
  • Press Cmd+Space on your Mac to open Spotlight.
  • Type Finder into Spotlight.
  • Press Enter to open Finder.
  • Search for the browser-model-adapter-core folder in Finder.
  • Move the browser-model-adapter-core folder to the Bin.
  • Empty the Bin to complete the folder deletion.

That removes the local adapter project. The shell setting needs one final check because it lives outside the project folder.

  • Return to the same shell profile file from Step 4 if you stored GEMINI_API_KEY there.
  • Find the line containing GEMINI_API_KEY.
  • Delete that line if no other application uses the shell entry.
  • Save the shell profile file.
  • Close the Terminal session to clear any temporary GEMINI_API_KEY value.

Revoking the existing key is a broader change than deleting this project. Every application using that key loses access.

  • Revoke your existing Gemini API key in Google AI Studio only if no other application uses it.

Nice Work!

Nice Work!

You did it! You built a browser model adapter core that routes one free-form model ID through three provider protocols.

You've learned how to:

  • Built a browser capability probe that accepts free-form model IDs. Added visible capability badges for text responses plus streaming.
  • Reproduced the hard-coded catalog failure. Replaced it with a stable adapter contract that validates transport configuration plus model syntax.
  • Kept your Gemini API key in the server environment as GEMINI_API_KEY. Streamed gemini-3.8-flash through the Google native transport. Reused the same interface through OpenAI compatibility.
  • Completed the optional Secret Mission by adding the Gemini Interactions adapter through one resolver branch. Preserved the existing /api/probe route.

Ready to quiz yourself?