mirror of
https://github.com/vercel-labs/fx.git
synced 2026-10-28 10:15:17 +08:00
Allow direct Gateway keys in the browser example
This commit is contained in:
6 files changed
+158
-13
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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.')
|
||||
})
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user