Allow direct Gateway keys in the browser example

This commit is contained in:
Pranit committed 2026-09-08 09:03:38 -04:00
1 parent 3850d0d312
commit 8b8d12d228
6 files changed
+158 -13

No files matched your search

+2
View File
@@ -33,5 +33,7 @@ jobs:
- run: npm ci
- if: matrix.example == 'node-chat'
run: node --test ../shared/*.test.mjs
- if: matrix.example == 'browser-agent'
run: node --experimental-vm-modules --test main.test.mjs
- if: matrix.example != 'node-chat'
run: npm run build
+14 -7
View File
@@ -21,7 +21,7 @@ cd fx/examples/node-chat
npm install
```
Create `.env.local` in that directory:
For the server-backed examples, create `.env.local` in that directory:
```sh
AI_GATEWAY_API_KEY=your-key
@@ -32,9 +32,14 @@ Run `npm run dev` for its browser demo, Next.js, or Nuxt. Those web handlers sta
a new agent per request; the readline and browser WebAssembly examples keep one
conversation open.
For the browser example, run `npx vercel dev` from `browser-agent/`. This runs Vite
and the local `/api/gateway` function together. `npm run dev` by itself serves only
the frontend. WebAssembly needs a browser with [JSPI support](https://fx.sh/docs/lib/webassembly#check-runtime-support).
For the browser example, run `npm run dev` from `browser-agent/` and enter your
AI Gateway API key in the page. Requests go directly to AI Gateway; no server key
is needed. The key stays in memory, and changing it starts a new conversation.
The hosted browser demo works without a key through a proxy with some free tokens.
Entering a key bypasses that proxy and uses your Gateway balance. To run the proxy
locally, set `AI_GATEWAY_API_KEY` in `.env.local` and run `npx vercel dev` instead.
WebAssembly needs a browser with [JSPI support](https://fx.sh/docs/lib/webassembly#check-runtime-support).
Keep the sibling `shared/` directory when copying a web example. It contains the
public-demo model and transport policy, not another agent framework.
@@ -45,9 +50,10 @@ Create a Vercel project for each example, with its directory as the project root
Enable **Include source files outside of the Root Directory in the Build Step**
so the project can load `examples/shared/`. Use Node.js 24.
Set `AI_GATEWAY_API_KEY` as a server-only environment variable. Use a dedicated
Gateway key with a daily budget. Never place a provider credential in a browser
bundle or a `NEXT_PUBLIC_` variable.
Set the shared demo key as a server-only `AI_GATEWAY_API_KEY` environment variable.
Use a dedicated Gateway key with a daily budget. Do not build that shared key into
the browser bundle. The browser example accepts each visitor’s own key at runtime
for direct requests; it does not save or send that key to the proxy.
Before making the demo public, configure a Vercel Firewall rule that rate-limits
`POST /api/*` by IP. The hosted examples use 10 requests per minute per project.
@@ -64,6 +70,7 @@ From the repository root:
```sh
node --test examples/shared/*.test.mjs
node --experimental-vm-modules --test examples/browser-agent/main.test.mjs
```
Build the browser, Next.js, and Nuxt examples with `npm run build` in their
+4
View File
@@ -6,6 +6,10 @@
<main>
<h1>Browser agent</h1>
<p>libfx runs in this browser through WebAssembly. Reload to start a new conversation.</p>
<label for="api-key">AI Gateway API key (optional)</label><br>
<input id="api-key" type="password" size="30" autocomplete="off" autocapitalize="none" spellcheck="false" aria-describedby="key-help">
<p id="key-help">Leave blank to use our proxy with some free tokens. Enter your own key to connect directly to AI Gateway using your balance. Your key is not saved. Changing it starts a new conversation.</p>
<p><a href="https://vercel.com/docs/ai-gateway/authentication-and-byok/api-keys" target="_blank" rel="noreferrer">Get an AI Gateway API key</a></p>
<form>
<label for="prompt">Prompt</label><br>
<textarea id="prompt" name="prompt" rows="3" cols="30" required maxlength="2000">Explain WebAssembly in two sentences.</textarea><br>
+15 -4
View File
@@ -5,36 +5,47 @@ const form = document.querySelector('form')
const button = document.querySelector('button')
const reply = document.querySelector('#reply')
const status = document.querySelector('#status')
const keyInput = document.querySelector('#api-key')
let agent
let activeKey
if (!supportsJspi()) {
status.textContent = 'This browser does not support WebAssembly JSPI. Try a current version of Chrome or Edge.'
status.textContent = 'This browser does not support WebAssembly JSPI. Try desktop Chrome or Edge.'
button.disabled = true
}
form.addEventListener('submit', async (event) => {
event.preventDefault()
const apiKey = keyInput.value.trim()
button.disabled = true
keyInput.disabled = true
reply.value = ''
status.textContent = 'Replying…'
try {
if (apiKey !== activeKey) {
await agent?.close()
agent = undefined
}
agent ??= await createFxAgent({
apiKey: 'demo', model,
apiKey: apiKey || 'demo', model,
fetch(url, init) {
if (apiKey) return fetch(url, init)
const path = new URL(url).pathname
return fetch(`/api/gateway?path=${encodeURIComponent(path)}`, init)
},
})
activeKey = apiKey
const turn = agent.prompt(new FormData(form).get('prompt'))
for await (const event of turn) {
if (event.type === 'text_delta') reply.value += event.delta
}
await turn.result
status.textContent = 'Reply complete.'
const { stopReason } = await turn.result
status.textContent = stopReason === 'refused' ? 'Request failed. See the reply for details.' : 'Reply complete.'
} catch (error) {
status.textContent = error.message
} finally {
button.disabled = false
keyInput.disabled = false
}
})
+121
View File
@@ -0,0 +1,121 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { test } from 'node:test'
import { createContext, SourceTextModule, SyntheticModule } from 'node:vm'
const source = await readFile(new URL('./main.js', import.meta.url), 'utf8')
async function page({ key = '', fail = false, stopReason = 'stop', duringFetch } = {}) {
const elements = Object.fromEntries(['form', 'button', '#reply', '#status', '#api-key'].map(
(name) => [name, { value: '', textContent: '', disabled: false }],
))
elements['#api-key'].value = key
let submit
elements.form.addEventListener = (name, handler) => { if (name === 'submit') submit = handler }
const calls = []
const agents = []
const storage = { setItem() { assert.fail('Keys must not be saved') } }
const context = createContext({
URL,
document: { querySelector: (name) => elements[name] },
window: { addEventListener() {} },
localStorage: storage,
sessionStorage: storage,
FormData: class { get() { return 'hello' } },
fetch: async (url, init) => {
calls.push({ url, init })
duringFetch?.(elements)
if (fail) throw new Error('Invalid key')
return 'hello'
},
})
const sdk = new SyntheticModule(['createFxAgent', 'supportsJspi'], function () {
this.setExport('supportsJspi', () => true)
this.setExport('createFxAgent', async (options) => {
const agent = {
options, closed: false,
async close() { this.closed = true },
prompt() {
return {
result: Promise.resolve({ stopReason }),
async *[Symbol.asyncIterator]() {
await options.fetch('https://ai-gateway.vercel.sh/v3/ai/language-model', {
method: 'POST', headers: { authorization: `Bearer ${options.apiKey}` },
})
yield { type: 'text_delta', delta: 'hello' }
},
}
},
}
agents.push(agent)
return agent
})
}, { context })
const model = new SyntheticModule(['model'], function () {
this.setExport('model', 'test/model')
}, { context })
const module = new SourceTextModule(source, { context })
await module.link((name) => name === 'libfx/browser' ? sdk : model)
await module.evaluate()
return { elements, calls, agents, submit: () => submit({ preventDefault() {} }) }
}
test('a blank key keeps the free proxy and reuses the conversation', async () => {
const app = await page()
await app.submit()
await app.submit()
assert.equal(app.agents.length, 1)
assert.equal(app.agents[0].options.apiKey, 'demo')
assert.equal(app.calls[0].url, '/api/gateway?path=%2Fv3%2Fai%2Flanguage-model')
assert.equal(app.elements['#reply'].value, 'hello')
})
test('an entered key goes directly to Gateway without touching the proxy', async () => {
const app = await page({ key: ' test-user-key ' })
await app.submit()
assert.equal(app.agents[0].options.apiKey, 'test-user-key')
assert.equal(app.calls.length, 1)
assert.equal(app.calls[0].url, 'https://ai-gateway.vercel.sh/v3/ai/language-model')
assert.equal(app.calls[0].init.headers.authorization, 'Bearer test-user-key')
})
test('changing or clearing a key closes the old conversation and changes the route', async () => {
const app = await page()
await app.submit()
app.elements['#api-key'].value = 'test-user-key'
await app.submit()
app.elements['#api-key'].value = ''
await app.submit()
assert.equal(app.agents.length, 3)
assert.equal(app.agents[0].closed, true)
assert.equal(app.agents[1].closed, true)
assert.equal(app.agents[2].closed, false)
assert.equal(app.calls[1].url, 'https://ai-gateway.vercel.sh/v3/ai/language-model')
assert.equal(app.calls[2].url, '/api/gateway?path=%2Fv3%2Fai%2Flanguage-model')
})
test('a rejected personal key never falls back to the free proxy', async () => {
const app = await page({ key: 'invalid-key', fail: true })
await app.submit()
assert.equal(app.calls.length, 1)
assert.equal(app.calls[0].url, 'https://ai-gateway.vercel.sh/v3/ai/language-model')
assert.equal(app.elements['#status'].textContent, 'Invalid key')
assert.equal(app.elements.button.disabled, false)
assert.equal(app.elements['#api-key'].disabled, false)
})
test('the key field is locked during the request and restored afterward', async () => {
const app = await page({ key: 'test-user-key', duringFetch(elements) {
assert.equal(elements['#api-key'].disabled, true)
assert.equal(elements.button.disabled, true)
} })
await app.submit()
assert.equal(app.elements['#api-key'].disabled, false)
assert.equal(app.elements['#reply'].value, 'hello')
})
test('a refused request is not reported as a successful reply', async () => {
const app = await page({ key: 'test-user-key', stopReason: 'refused' })
await app.submit()
assert.equal(app.elements['#status'].textContent, 'Request failed. See the reply for details.')
})
+2 -2
View File
@@ -11,9 +11,9 @@
{
"id": "browser-agent",
"title": "Browser agent",
"prompt": "Build a browser agent with libfx running through WebAssembly. Use a plain HTML form without CSS, keep the conversation in the browser, and route model requests through a server that holds the API key. Use the files below as a starting point.",
"prompt": "Build a browser agent with libfx and WebAssembly using plain HTML without CSS. Use the free-token proxy by default. Let visitors enter an AI Gateway API key to connect directly, without sending their key to the proxy or saving it. Start a new conversation when the key changes. Use the files below as a starting point.",
"entry": "main.js",
"command": "npx vercel dev",
"command": "npm run dev",
"files": ["package.json", "index.html", "main.js", "api/gateway.js"],
"demo": "https://fx-demo-browser-agent.vercel.app"
},