diff --git a/.env.example b/.env.example
index 073c320..f290e89 100644
--- a/.env.example
+++ b/.env.example
@@ -18,3 +18,16 @@
# VITE_AGENT_API_BASE=same-origin
# VITE_AGENT_API_BASE=http://localhost:8787
+
+# Hosted Claude Opus (or another model) on the tool proxy. The key stays on the
+# server — never in the frontend bundle. Visitors then chat without installing
+# 448 MB or having a GPU. Get a key at https://platform.claude.com
+#
+# ANTHROPIC_API_KEY=sk-ant-...
+# ANTHROPIC_MODEL=claude-opus-5
+#
+# OpenAI-compatible fallback (OpenRouter, Groq, …) if you are not using Anthropic:
+# OPENAI_API_KEY=sk-...
+# OPENAI_BASE_URL=https://openrouter.ai/api/v1
+# OPENAI_MODEL=anthropic/claude-opus-5
+# Set either key to `mock` to exercise the wiring without a provider.
diff --git a/Dockerfile b/Dockerfile
index 42ecad1..e3b20e0 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,9 +1,9 @@
-# Optional tool proxy only. The Pages site stays static; inference stays in the tab.
-# Railway (or any host) runs this image and generates a public URL for Tools → Tool proxy URL.
+# Optional tool proxy, and hosted Claude Opus when ANTHROPIC_API_KEY is set.
+# The Pages site stays static. Railway (or any host) runs this image.
FROM node:22-alpine
WORKDIR /app
-COPY tools/agent-api.ts tools/agent-api-listen.ts ./tools/
+COPY tools/agent-api.ts tools/agent-api-listen.ts tools/agent-chat.ts ./tools/
ENV NODE_ENV=production
EXPOSE 8787
diff --git a/README.md b/README.md
index 9d010de..61f6b77 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ It does need a connection to answer, which is a deliberate limit rather than a m
The agent can search, read pages, calculate exactly, remember things you tell it, and call any MCP server you connect. Because a 0.8B model needs the help, common requests are routed through [skills](#skills) that show it a worked example rather than telling it what to do.
-The published site has no backend. `pnpm build` produces a directory of static files that GitHub Pages can host, and every tool ships there — that is why the deployed site above has the full tool set rather than a reduced one. An optional [tool proxy](#optional-tool-proxy) lives in `tools/` for DuckDuckGo search and page reads without CORS: `pnpm dev` uses it locally, `pnpm proxy` runs it on its own. Inference stays in the tab either way.
+The published site has no backend. `pnpm build` produces a directory of static files that GitHub Pages can host, and every tool ships there — that is why the deployed site above has the full tool set rather than a reduced one. An optional [tool proxy](#optional-tool-proxy) lives in `tools/` for DuckDuckGo search and page reads without CORS. Put an `ANTHROPIC_API_KEY` on that same process and it also hosts [Claude Opus](#hosted-model-claude-opus): visitors then chat without a GPU or a 448 MB download. Leave the key unset and inference stays in the tab, which is how the public demo still works.
## How it works
@@ -302,7 +302,7 @@ GitHub Pages cannot host a process, so the published site stays browser-direct.
- **`pnpm dev`** — the Vite plugin serves `POST /api/search` and `POST /api/fetch` on the dev server. `.env.development` sets `VITE_AGENT_API_BASE=same-origin`, so DuckDuckGo search and non-Wikipedia page reads go there automatically.
- **`pnpm proxy`** — the same handlers on http://localhost:8787, for a static build or the hosted site. Paste that origin into **Tools → Tool proxy URL**, or build with `VITE_AGENT_API_BASE=http://localhost:8787`.
-- **Railway (or any host)** — the `Dockerfile` in the repo root runs only this process. Create a project, connect `devbadya/Jarvis`, set `PROXY_ORIGINS` to `https://devbadya.github.io`, wait until the deploy is live, then **Settings → Networking → Generate domain**. That `https://….up.railway.app` origin is the URL: paste it into **Tools → Tool proxy URL** on the hosted site. There is no URL until a domain exists; the Hobby plan alone does not create one.
+- **Railway (or any host)** — the `Dockerfile` in the repo root runs only this process. Create a project, connect `devbadya/Jarvis`, set `PROXY_ORIGINS` to `https://devbadya.github.io`, add `ANTHROPIC_API_KEY` if you want [hosted Opus](#hosted-model-claude-opus), wait until the deploy is live, then **Settings → Networking → Generate domain**. That `https://….up.railway.app` origin is the URL: paste it into **Tools → Tool proxy URL** on the hosted site. There is no URL until a domain exists; the Hobby plan alone does not create one.
To give every visitor that proxy without asking them to paste anything, set the repository variable **`AGENT_API_BASE`** to its origin: `deploy.yml` passes it to the build as `VITE_AGENT_API_BASE`. Leave it unset and the hosted site stays browser-direct, which is what a fork with no proxy of its own needs — an empty value is not a proxy, so a workflow forwarding a variable nobody set cannot aim the build at an `/api` the host does not serve.
@@ -310,15 +310,29 @@ To give every visitor that proxy without asking them to paste anything, set the
The proxy scrapes DuckDuckGo HTML itself and fetches pages itself. It does not spend the Jina reader budget, and it is not limited to CORS-friendly endpoints. Wikipedia, LangSearch and Jina still leave the tab directly — they already send the headers, and their keys must not travel through this process.
-A fetch-on-behalf proxy is still a confused deputy. Every target is resolved and refused if it lands on loopback, link-local or RFC1918, and redirects are re-checked. Do not bind `pnpm proxy` to the public internet without setting `PROXY_ORIGINS` to the pages that may call it (for example `https://devbadya.github.io`). Inference never goes through it.
+A fetch-on-behalf proxy is still a confused deputy. Every target is resolved and refused if it lands on loopback, link-local or RFC1918, and redirects are re-checked. Do not bind `pnpm proxy` to the public internet without setting `PROXY_ORIGINS` to the pages that may call it (for example `https://devbadya.github.io`). Search and fetch never need a model key; chat does, and that key stays on this process.
-The allowlist says who may call, not how often, and an allowed page is exactly what a scraper would forge. `pnpm proxy` therefore allows **30 requests a minute per caller** and answers `429` beyond that, counted from the forwarded address rather than the socket, since every edge terminates the connection itself. `PROXY_RATE_LIMIT` changes the number; `0` switches it off. Health checks are exempt.
+The allowlist says who may call, not how often, and an allowed page is exactly what a scraper would forge. `pnpm proxy` therefore allows **30 requests a minute per caller** and answers `429` beyond that, counted from the forwarded address rather than the socket, since every edge terminates the connection itself. `PROXY_RATE_LIMIT` changes the number; `0` switches it off. Health checks are exempt. When `PROXY_ORIGINS` is set, `POST /api/chat` also requires a matching `Origin` header, so a curl from the public internet cannot spend the model key.
+
+### Hosted model (Claude Opus)
+
+The 0.8B on-device model will not become ChatGPT. To give visitors a frontier model without a GPU or a 448 MB install, put Anthropic's key on the same proxy:
+
+1. Create an API key at **[platform.claude.com](https://platform.claude.com)** (Console → API keys). Add prepaid credit under Billing. That is the official Opus API; do not buy keys from resellers.
+2. On Railway (or `pnpm proxy`), set `ANTHROPIC_API_KEY`. The default model is `claude-opus-5`. `ANTHROPIC_MODEL` overrides it. Opus is billed per token — currently $5 / million input tokens and $25 / million output, and **you** pay for every visitor.
+3. Point the site at the proxy: paste the origin into **Tool proxy URL**, or set the GitHub variable `AGENT_API_BASE` so every visitor uses it.
+
+`GET /api/health` then reports `{ ok: true, chat: { model: "claude-opus-5", provider: "anthropic" } }`. The landing page switches to **Start chatting** and skips the download. Tools still run in the tab; only generation goes to Anthropic.
+
+An OpenAI-compatible host still works if `ANTHROPIC_API_KEY` is unset: `OPENAI_API_KEY` plus optional `OPENAI_BASE_URL` / `OPENAI_MODEL` (Groq, OpenRouter, and so on). OpenRouter can serve Opus too (`OPENAI_BASE_URL=https://openrouter.ai/api/v1`, `OPENAI_MODEL=anthropic/claude-opus-5`) but you then have a middleman on top of Anthropic. Prefer the official Console.
+
+Set either key to `mock` to exercise the wiring without a provider.
### What leaves the browser
-Inference does not: prompts, reasoning, and replies never leave the GPU, and neither do [memories](#memory), which are written to IndexedDB in this browser and read back into a prompt that goes no further than the GPU either. Tools are the exception, and always were. A `web_search` call sends the query to the chosen provider, a `read_page` call sends the URL to the reader, a `weather` call sends the place name to Open-Meteo's geocoder and its coordinates to the two forecast services, and a `current_time` call with a place sends the name to the same geocoder.
+On the on-device path, inference does not leave: prompts, reasoning, and replies stay on the GPU, and neither do [memories](#memory), which are written to IndexedDB in this browser. Tools are the exception, and always were. A `web_search` call sends the query to the chosen provider, a `read_page` call sends the URL to the reader, a `weather` call sends the place name to Open-Meteo's geocoder and its coordinates to the two forecast services, and a `current_time` call with a place sends the name to the same geocoder.
-On the hosted site those go direct, with no server of ours in the path to log them. With the optional proxy, DuckDuckGo search and non-Wikipedia page reads go to that process first — one more party than a search API, and the one you run.
+With hosted Opus, the conversation itself goes to Anthropic through your proxy. Memories and MCP keys still stay in this browser. On the hosted site without a proxy, searches go direct, with no server of ours in the path to log them. With the optional proxy, DuckDuckGo search and non-Wikipedia page reads go to that process first — one more party than a search API, and the one you run.
Worth being precise about on the default provider without a proxy: a search sends the query to `r.jina.ai`, which then sends it to DuckDuckGo. That is one more party than a search API like LangSearch or Jina involves.
diff --git a/src/App.test.tsx b/src/App.test.tsx
new file mode 100644
index 0000000..a71b0ae
--- /dev/null
+++ b/src/App.test.tsx
@@ -0,0 +1,49 @@
+import { render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import App from './App'
+import { EMPTY_STORAGE_STATUS } from '@/lib/storage'
+import { useChatStore } from '@/store/chat'
+
+function stubAdapter(): void {
+ Object.defineProperty(navigator, 'gpu', {
+ configurable: true,
+ value: {
+ requestAdapter: async () => ({
+ info: { vendor: 'test', architecture: 'gpu' },
+ limits: { maxBufferSize: 1024 * 1024 * 1024 },
+ }),
+ },
+ })
+}
+
+beforeEach(() => {
+ stubAdapter()
+ vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('no proxy'))
+ vi.spyOn(useChatStore.getState(), 'refreshStorage').mockResolvedValue()
+ vi.spyOn(useChatStore.getState(), 'probeHosted').mockResolvedValue()
+ useChatStore.setState({
+ status: 'idle',
+ error: null,
+ storage: EMPTY_STORAGE_STATUS,
+ hostedChat: null,
+ })
+})
+
+afterEach(() => {
+ vi.restoreAllMocks()
+ useChatStore.setState({ status: 'idle', storage: EMPTY_STORAGE_STATUS, hostedChat: null })
+})
+
+describe('App header', () => {
+ it('names the on-device model until a hosted one is advertised', () => {
+ render( )
+ expect(screen.getByText('Qwen3.5-0.8B · on-device')).toBeInTheDocument()
+ })
+
+ it('names the hosted model once the proxy advertises one', () => {
+ useChatStore.setState({ hostedChat: { base: 'https://proxy.example', model: 'claude-opus-5' } })
+ render( )
+ expect(screen.getByText('claude-opus-5 · hosted')).toBeInTheDocument()
+ expect(screen.queryByText('Qwen3.5-0.8B · on-device')).not.toBeInTheDocument()
+ })
+})
diff --git a/src/App.tsx b/src/App.tsx
index d7ad789..557e456 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -23,15 +23,19 @@ const EVAL_MODE = new URLSearchParams(window.location.search).has('eval')
*/
function BrandMark() {
const busy = useChatStore((state) => state.busy)
+ const hostedChat = useChatStore((state) => state.hostedChat)
+ const label = EVAL_MODE
+ ? 'eval harness'
+ : hostedChat
+ ? `${hostedChat.model} · hosted`
+ : 'Qwen3.5-0.8B · on-device'
return (
Jarvis
-
- {EVAL_MODE ? 'eval harness' : 'Qwen3.5-0.8B · on-device'}
-
+
{label}
)
diff --git a/src/agent/loop.test.ts b/src/agent/loop.test.ts
index 957ce41..fc0e9ac 100644
--- a/src/agent/loop.test.ts
+++ b/src/agent/loop.test.ts
@@ -413,3 +413,42 @@ describe('checking the answer before returning it', () => {
expect(result.content).toBe('Ama Osei has led the airline since 2023.')
})
})
+
+describe('hosted native tool calls', () => {
+ function nativeClient(
+ rounds: {
+ text: string
+ toolCalls?: { id: string; name: string; arguments: Record }[]
+ }[],
+ ): LlmClient {
+ let round = 0
+ return {
+ generate: vi.fn(async () => {
+ const output = rounds[round] ?? { text: '' }
+ round += 1
+ return { text: output.text, tokens: 10, thinkTokens: 0, durationMs: 50, toolCalls: output.toolCalls }
+ }),
+ } as unknown as LlmClient
+ }
+
+ it('executes structured calls and pairs the result with the provider id', async () => {
+ const execute = vi.fn(async () => '4')
+ const calculator = defineTool('calculator', 'maths', { type: 'object', properties: {} }, execute)
+ const client = nativeClient([
+ { text: '', toolCalls: [{ id: 'call_1', name: 'calculator', arguments: { expression: '2+2' } }] },
+ { text: '2 + 2 = 4' },
+ ])
+
+ const result = await runAgent(client, turns, [calculator], callbacks())
+
+ expect(execute).toHaveBeenCalledWith({ expression: '2+2' })
+ expect(result.content).toBe('2 + 2 = 4')
+ const conversation = vi.mocked(client.generate).mock.calls[1]?.[0] ?? []
+ expect(conversation).toContainEqual({
+ role: 'assistant',
+ content: '',
+ toolCalls: [{ id: 'call_1', name: 'calculator', arguments: { expression: '2+2' } }],
+ })
+ expect(conversation).toContainEqual({ role: 'tool', content: '4', toolCallId: 'call_1' })
+ })
+})
diff --git a/src/agent/loop.ts b/src/agent/loop.ts
index 30e8616..2f84749 100644
--- a/src/agent/loop.ts
+++ b/src/agent/loop.ts
@@ -1,4 +1,4 @@
-import type { LlmClient } from '@/llm/client'
+import type { InferenceClient } from '@/llm/client'
import type { ChatTurn } from '@/llm/protocol'
import {
DEFAULT_STRATEGY,
@@ -119,7 +119,7 @@ function settle(latest: AgentResult, remaining: ReviewCheck[], draft: Draft | nu
* them.
*/
export async function runAgent(
- client: LlmClient,
+ client: InferenceClient,
turns: ChatTurn[],
tools: Tool[],
callbacks: AgentCallbacks,
@@ -172,11 +172,18 @@ export async function runAgent(
durationMs: generation.durationMs,
tokensPerSecond: generation.durationMs > 0 ? (generation.tokens / generation.durationMs) * 1000 : 0,
}
+ // Hosted models return native tool calls; the on-device path parses XML.
+ const nativeCalls = generation.toolCalls ?? []
+ const parsedCalls = nativeCalls.length > 0 ? nativeCalls : parsed.toolCalls
// A model that asks for a tool anyway once they are gone is asking for
// something nothing can run, so the request is dropped and the round counts
// as the answer it was meant to be.
- const toolCalls = windDown ? [] : parsed.toolCalls
- const outcome = { content: parsed.content, reasoning: parsed.reasoning, stats }
+ const toolCalls = windDown ? [] : parsedCalls
+ const outcome = {
+ content: nativeCalls.length > 0 ? generation.text || parsed.content : parsed.content,
+ reasoning: parsed.reasoning,
+ stats,
+ }
// Only when the turn is over: before a tool call, reasoning is just reasoning.
last = toolCalls.length === 0 ? promoteReasoningIfEmpty(outcome) : outcome
callbacks.onRoundEnd(last)
@@ -207,18 +214,32 @@ export async function runAgent(
}
// Echo the assistant's tool request back so the model sees its own decision.
- conversation.push({ role: 'assistant', content: generation.text || raw })
+ // Hosted APIs need the structured calls and the matching tool ids; the
+ // on-device worker needs the raw XML the chat template already emitted.
+ const pending = toolCalls.map((call) => ({
+ ...call,
+ id: call.id ?? crypto.randomUUID(),
+ }))
+ if (nativeCalls.length > 0) {
+ conversation.push({
+ role: 'assistant',
+ content: generation.text || parsed.content || '',
+ toolCalls: pending.map((call) => ({ id: call.id, name: call.name, arguments: call.arguments })),
+ })
+ } else {
+ conversation.push({ role: 'assistant', content: generation.text || raw })
+ }
- for (const call of toolCalls) {
- const id = crypto.randomUUID()
- callbacks.onToolStart({ ...call, id })
+ for (const call of pending) {
+ const { id } = call
+ callbacks.onToolStart(call)
const startedAt = performance.now()
const tool = byName.get(call.name)
if (!tool) {
const error = `Unknown tool "${call.name}". Available tools: ${[...byName.keys()].join(', ')}`
callbacks.onToolEnd(id, { error, durationMs: performance.now() - startedAt })
- conversation.push({ role: 'tool', content: error })
+ conversation.push({ role: 'tool', content: error, toolCallId: id })
continue
}
@@ -231,7 +252,7 @@ export async function runAgent(
if (earlier !== undefined) {
const note = repeatedCallNote(call.name, earlier)
callbacks.onToolEnd(id, { result: note, durationMs: performance.now() - startedAt })
- conversation.push({ role: 'tool', content: note })
+ conversation.push({ role: 'tool', content: note, toolCallId: id })
continue
}
@@ -239,7 +260,7 @@ export async function runAgent(
const result = await tool.execute(call.arguments)
executed.set(fingerprint, result)
callbacks.onToolEnd(id, { result, durationMs: performance.now() - startedAt })
- conversation.push({ role: 'tool', content: result })
+ conversation.push({ role: 'tool', content: result, toolCallId: id })
// Only what a tool actually returned is evidence. A failure has nothing
// to check an answer against, and demanding a citation for a page that
// never loaded would be worse than saying nothing.
@@ -247,7 +268,7 @@ export async function runAgent(
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
callbacks.onToolEnd(id, { error: message, durationMs: performance.now() - startedAt })
- conversation.push({ role: 'tool', content: `Tool "${call.name}" failed: ${message}` })
+ conversation.push({ role: 'tool', content: `Tool "${call.name}" failed: ${message}`, toolCallId: id })
}
}
diff --git a/src/agent/parse.ts b/src/agent/parse.ts
index e3481f7..0efe830 100644
--- a/src/agent/parse.ts
+++ b/src/agent/parse.ts
@@ -1,6 +1,8 @@
export interface ParsedToolCall {
name: string
arguments: Record
+ /** Provider id, when the call came from a hosted API rather than XML. */
+ id?: string
}
export interface ParsedOutput {
diff --git a/src/components/InstallPanel.tsx b/src/components/InstallPanel.tsx
index 8ab31d6..dcd081a 100644
--- a/src/components/InstallPanel.tsx
+++ b/src/components/InstallPanel.tsx
@@ -2,14 +2,20 @@ import { useEffect, useState, type ReactNode } from 'react'
import { Alert } from '@heroui/react/alert'
import { Button } from '@heroui/react/button'
import { Chip } from '@heroui/react/chip'
+import { Description } from '@heroui/react/description'
+import { FieldError } from '@heroui/react/field-error'
+import { Input } from '@heroui/react/input'
+import { Label } from '@heroui/react/label'
import { Link } from '@heroui/react/link'
import { Meter } from '@heroui/react/meter'
import { ProgressBar } from '@heroui/react/progress-bar'
import { Spinner } from '@heroui/react/spinner'
+import { TextField } from '@heroui/react/textfield'
import { MODEL_DOWNLOAD_BYTES, MODEL_ID } from '@/llm/config'
import { detectWebGpu, type GpuCapability } from '@/lib/webgpu'
import { formatBytes } from '@/lib/format'
import { hasRoomFor } from '@/lib/storage'
+import { isHttpUrl } from '@/tools/mcp'
import { useChatStore } from '@/store/chat'
/** One row of the specification list, so the labels stay in one column. */
@@ -23,22 +29,32 @@ function Row({ children, label }: { children: ReactNode; label: string }) {
}
/**
- * Everything the one-time download needs to be legible: whether this browser can
- * run the model at all, what is already on disk, whether there is room for the
- * rest, and the button that starts it.
- *
- * It is the landing page's call to action, which is why it carries no heading of
- * its own — the hero above it already said what this is.
+ * The landing page's call to action. Hosted chat (Claude Opus on the tool
+ * proxy) starts with no download. The on-device path still shows the GPU
+ * check, the storage figures, and the 448 MB install.
*/
export function InstallPanel() {
const [gpu, setGpu] = useState(null)
- const { status, loadMessage, loadProgress, error, storage, initialize, refreshStorage, removeModel } =
- useChatStore()
+ const {
+ status,
+ loadMessage,
+ loadProgress,
+ error,
+ storage,
+ hostedChat,
+ initialize,
+ refreshStorage,
+ removeModel,
+ probeHosted,
+ webAccess,
+ setWebAccess,
+ } = useChatStore()
useEffect(() => {
void detectWebGpu().then(setGpu)
void refreshStorage()
- }, [refreshStorage])
+ void probeHosted()
+ }, [refreshStorage, probeHosted])
const loaded = loadProgress.reduce((sum, file) => sum + file.loaded, 0)
const total = loadProgress.reduce((sum, file) => sum + file.total, 0)
@@ -51,6 +67,9 @@ export function InstallPanel() {
// continues from it, so the gate offers to resume rather than to start again.
const resumeBytes = installed ? 0 : storage.partialBytes
const remainingBytes = Math.max(MODEL_DOWNLOAD_BYTES - resumeBytes, 0)
+ const typedProxy = webAccess.proxyUrl ?? ''
+ const badProxy = typedProxy.trim().length > 0 && !isHttpUrl(typedProxy.trim())
+ const hosted = hostedChat !== null
return (
@@ -62,29 +81,55 @@ export function InstallPanel() {
/>
- {gpu === null && (
+ {gpu === null && !hosted && (
Checking GPU support…
)}
- {gpu?.supported === false && (
+ {gpu?.supported === false && !hosted && (
WebGPU is unavailable
- {gpu.reason} Generation has no CPU fallback, so the chat cannot start here.{' '}
+ {gpu.reason} On-device generation has no CPU fallback.{' '}
Which browsers support WebGPU
+ . Paste a tool proxy with Claude Opus below to chat without a GPU.
)}
- {gpu?.supported && status !== 'loading' && (
+ {hosted && status !== 'loading' && (
+ <>
+
+ void initialize()}>
+ {status === 'error' ? 'Try again' : 'Start chatting'}
+
+
+ {status === 'error' && (
+
+
+
+ Could not reach the hosted model
+ {error}
+
+
+ )}
+
+
+ {hostedChat.model}
+
+ Hosted via the tool proxy — nothing downloads to this browser
+
+ >
+ )}
+
+ {!hosted && gpu?.supported && status !== 'loading' && (
<>
void initialize()}>
@@ -199,6 +244,24 @@ export function InstallPanel() {
>
)}
+ {status !== 'loading' && (
+ setWebAccess({ ...webAccess, proxyUrl: value })}
+ >
+ Tool proxy URL
+
+
+ {hosted
+ ? `Using ${hostedChat.base || 'this origin'} for Claude Opus. Visitors do not paste an API key.`
+ : 'Optional. A proxy with ANTHROPIC_API_KEY starts a hosted model instead of the on-device download.'}
+
+ Needs a full http:// or https:// address.
+
+ )}
+
{status === 'loading' && (
@@ -217,8 +280,9 @@ export function InstallPanel() {
>
)}
- Downloading only happens once. Afterwards the model is served from this browser, and a transfer
- that is interrupted continues from where it stopped rather than starting again.
+ {hosted
+ ? 'Connecting to the hosted model. Nothing is downloaded to this browser.'
+ : 'Downloading only happens once. Afterwards the model is served from this browser, and a transfer that is interrupted continues from where it stopped rather than starting again.'}
)}
diff --git a/src/components/Landing.test.tsx b/src/components/Landing.test.tsx
index 51bf99e..df3bb2d 100644
--- a/src/components/Landing.test.tsx
+++ b/src/components/Landing.test.tsx
@@ -19,13 +19,15 @@ function stubAdapter(): void {
beforeEach(() => {
stubAdapter()
+ vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('no proxy'))
vi.spyOn(useChatStore.getState(), 'refreshStorage').mockResolvedValue()
- useChatStore.setState({ status: 'idle', error: null, storage: EMPTY_STORAGE_STATUS })
+ vi.spyOn(useChatStore.getState(), 'probeHosted').mockResolvedValue()
+ useChatStore.setState({ status: 'idle', error: null, storage: EMPTY_STORAGE_STATUS, hostedChat: null })
})
afterEach(() => {
vi.restoreAllMocks()
- useChatStore.setState({ status: 'idle', storage: EMPTY_STORAGE_STATUS })
+ useChatStore.setState({ status: 'idle', storage: EMPTY_STORAGE_STATUS, hostedChat: null })
})
describe('Landing', () => {
@@ -57,4 +59,16 @@ describe('Landing', () => {
await screen.findByRole('button', { name: /Install model/ })
})
+
+ it('offers hosted chat when the proxy advertises a model', async () => {
+ useChatStore.setState({ hostedChat: { base: 'https://proxy.example', model: 'claude-opus-5' } })
+ render( )
+
+ expect(screen.getByRole('heading', { name: 'A frontier model, in this chat.' })).toBeInTheDocument()
+ expect(await screen.findByRole('button', { name: 'Start chatting' })).toBeInTheDocument()
+ expect(screen.getAllByText('claude-opus-5').length).toBeGreaterThan(0)
+ expect(screen.getByText('Claude Opus answers')).toBeInTheDocument()
+ expect(screen.getByText('Stays in this browser')).toBeInTheDocument()
+ expect(screen.queryByText(/4 GB of GPU memory/)).not.toBeInTheDocument()
+ })
})
diff --git a/src/components/Landing.tsx b/src/components/Landing.tsx
index 3e60c4d..ef3b007 100644
--- a/src/components/Landing.tsx
+++ b/src/components/Landing.tsx
@@ -17,10 +17,11 @@ import {
} from './ui/icons'
import { MODEL_ID } from '@/llm/config'
import { scrollBehavior } from '@/lib/motion'
+import { useChatStore } from '@/store/chat'
type IconComponent = (props: SVGProps) => ReactNode
-const CAPABILITIES: { body: string; icon: IconComponent; title: string }[] = [
+const LOCAL_CAPABILITIES: { body: string; icon: IconComponent; title: string }[] = [
{
icon: ChipIcon,
title: 'Your GPU does the work',
@@ -53,7 +54,40 @@ const CAPABILITIES: { body: string; icon: IconComponent; title: string }[] = [
},
]
-const STEPS: { body: string; title: string }[] = [
+const HOSTED_CAPABILITIES: { body: string; icon: IconComponent; title: string }[] = [
+ {
+ icon: ChipIcon,
+ title: 'Claude Opus answers',
+ body: 'Generation runs on Anthropic through the tool proxy you host. Visitors never paste a key, and this tab does not need a GPU.',
+ },
+ {
+ icon: WifiOffIcon,
+ title: 'Needs a connection',
+ body: 'Every reply goes through the proxy. Offline it cannot reach Opus, so it does not pretend to answer from memory alone.',
+ },
+ {
+ icon: GlobeIcon,
+ title: 'Searches and reads the web',
+ body: 'DuckDuckGo, Wikipedia or Jina for search and a reader for whole pages, both still called from this tab.',
+ },
+ {
+ icon: CalculatorIcon,
+ title: 'Arithmetic it cannot fumble',
+ body: 'Exact calculations go to the calculator tool rather than being guessed in the reply.',
+ },
+ {
+ icon: BookmarkIcon,
+ title: 'Remembers across chats',
+ body: 'Tell it something worth keeping and it is recalled into the prompt next time, editable and deletable by you.',
+ },
+ {
+ icon: PlugIcon,
+ title: 'Connects to MCP servers',
+ body: 'Point it at an HTTP endpoint and that server’s tools join the list this model is allowed to call.',
+ },
+]
+
+const LOCAL_STEPS: { body: string; title: string }[] = [
{
title: 'Install once',
body: 'The weights stream into this browser’s storage. A download interrupted half way through continues from where it stopped rather than starting again.',
@@ -72,6 +106,25 @@ const STEPS: { body: string; title: string }[] = [
},
]
+const HOSTED_STEPS: { body: string; title: string }[] = [
+ {
+ title: 'No download',
+ body: 'Nothing is fetched into this browser. The landing page starts the hosted model as soon as the proxy advertises one.',
+ },
+ {
+ title: 'Ask in any language',
+ body: 'The same tools and memories are available. Opus does the answering; this tab still executes every tool call.',
+ },
+ {
+ title: 'It reaches for tools',
+ body: 'Search, a page reader, the calculator, its memory and any server you connected. Every call is named in words while it runs.',
+ },
+ {
+ title: 'The answer is checked',
+ body: 'Before a reply is shown it is read back against what the tools returned. A number the tools disagree with, or a source nothing ever fetched, is corrected or flagged.',
+ },
+]
+
function SectionTitle({ children, eyebrow }: { children: string; eyebrow: string }) {
return (
@@ -91,6 +144,8 @@ function SectionTitle({ children, eyebrow }: { children: string; eyebrow: string
*/
export function Landing() {
const scrollRef = useRef(null)
+ const hostedChat = useChatStore((state) => state.hostedChat)
+ const hosted = hostedChat !== null
const backToTop = (): void => {
scrollRef.current?.scrollTo({ top: 0, behavior: scrollBehavior() })
@@ -105,17 +160,27 @@ export function Landing() {
- On-device · WebGPU · no account, no API key
+ {hosted
+ ? 'Hosted · Claude Opus · no install, no GPU'
+ : 'On-device · WebGPU · no account, no API key'}
- The model runs in this tab .
+ {hosted ? (
+ <>
+ A frontier model, in this chat .
+ >
+ ) : (
+ <>
+ The model runs in this tab .
+ >
+ )}
- Jarvis is a chat agent whose language model never leaves this tab. It is downloaded once, kept in
- this browser and executed on your own GPU — so there is no per-token cost, and no conversation is
- handed to a model provider.
+ {hosted
+ ? 'Jarvis sends this conversation to Claude Opus through your tool proxy. Visitors do not paste an API key. Tools still run in this tab, and memories stay in this browser.'
+ : 'Jarvis is a chat agent whose language model never leaves this tab. It is downloaded once, kept in this browser and executed on your own GPU — so there is no per-token cost, and no conversation is handed to a model provider.'}
@@ -124,9 +189,9 @@ export function Landing() {
{[
- ['448 MB', 'downloaded once'],
- ['0', 'requests to a model provider'],
- ['1 tab', 'the entire stack'],
+ hosted ? [hostedChat.model, 'on the proxy'] : ['448 MB', 'downloaded once'],
+ hosted ? ['0', 'files to install'] : ['0', 'requests to a model provider'],
+ hosted ? ['1 key', 'yours, on the server'] : ['1 tab', 'the entire stack'],
].map(([value, label]) => (
{value}
@@ -137,9 +202,11 @@ export function Landing() {
- A small model, given help
+
+ {hosted ? 'A frontier model, with tools' : 'A small model, given help'}
+
- {CAPABILITIES.map(({ body, icon: Icon, title }, index) => (
+ {(hosted ? HOSTED_CAPABILITIES : LOCAL_CAPABILITIES).map(({ body, icon: Icon, title }, index) => (
@@ -156,7 +223,7 @@ export function Landing() {
From your question to a checked answer
- {STEPS.map(({ body, title }, index) => (
+ {(hosted ? HOSTED_STEPS : LOCAL_STEPS).map(({ body, title }, index) => (
@@ -181,45 +248,87 @@ export function Landing() {
- Stays in this tab
+ {hosted ? 'Stays in this browser' : 'Stays in this tab'}
- Everything you type, and every reply
- The model’s reasoning and its tool results
- Whatever it has been asked to remember
- The weights themselves, after the download
+ {hosted ? (
+ <>
+ Memories you asked it to keep
+ MCP keys and the servers you added
+ The page itself, and this install
+ >
+ ) : (
+ <>
+ Everything you type, and every reply
+ The model’s reasoning and its tool results
+ Whatever it has been asked to remember
+ The weights themselves, after the download
+ >
+ )}
-
Goes out, and only when a tool runs
+
+ {hosted ? 'Goes to Anthropic, and to tools' : 'Goes out, and only when a tool runs'}
+
- The search terms of a web search
- The address of a page you asked it to read
- A place name, when you ask about the weather or the time somewhere else
- Whatever you send to an MCP server you added
+ {hosted ? (
+ <>
+ The conversation, through the tool proxy you run
+ The search terms of a web search
+ The address of a page you asked it to read
+ A place name, when you ask about the weather or the time somewhere else
+ >
+ ) : (
+ <>
+ The search terms of a web search
+ The address of a page you asked it to read
+ A place name, when you ask about the weather or the time somewhere else
+ Whatever you send to an MCP server you added
+ >
+ )}
- There is no server of ours in either column on the hosted site. The build is a directory of static
- files. A tool proxy you run yourself can sit in front of search and page reads; the model still
- does not leave this tab.
+ {hosted
+ ? 'Chats go to Anthropic through the tool proxy you run. Memories, MCP keys and the page itself stay in this browser. Search and page reads still leave when a tool runs.'
+ : 'There is no server of ours in either column on the hosted site. The build is a directory of static files. A tool proxy you run yourself can sit in front of search and page reads; the model still does not leave this tab.'}
What this browser needs
- {[
- [
- 'Chrome or Edge 113+',
- 'Generation has no CPU fallback — WebGPU is the only path the weights can run on.',
- ],
- ['About 4 GB of GPU memory', 'Less than that and the model will not fit beside your desktop.'],
- ['448 MB of free space', 'Kept for as long as you keep it. Removing it is one button.'],
- ].map(([term, detail]) => (
+ {(hosted
+ ? [
+ [
+ 'A live tool proxy',
+ 'Railway or pnpm proxy, with ANTHROPIC_API_KEY set. The key never enters this page.',
+ ],
+ [
+ 'A matching origin allowlist',
+ 'PROXY_ORIGINS must include this site, or strangers can spend the model key.',
+ ],
+ [
+ 'A network connection',
+ 'Every reply goes through that proxy to Anthropic. There is no on-device fallback on this path.',
+ ],
+ ]
+ : [
+ [
+ 'Chrome or Edge 113+',
+ 'Generation has no CPU fallback — WebGPU is the only path the weights can run on.',
+ ],
+ [
+ 'About 4 GB of GPU memory',
+ 'Less than that and the model will not fit beside your desktop.',
+ ],
+ ['448 MB of free space', 'Kept for as long as you keep it. Removing it is one button.'],
+ ]
+ ).map(([term, detail]) => (
{term}
{detail}
diff --git a/src/components/ModelGate.test.tsx b/src/components/ModelGate.test.tsx
index afd4816..a1562ad 100644
--- a/src/components/ModelGate.test.tsx
+++ b/src/components/ModelGate.test.tsx
@@ -29,12 +29,13 @@ function storage(overrides: Partial
): StorageStatus {
beforeEach(() => {
stubAdapter()
- useChatStore.setState({ status: 'idle', error: null })
+ vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('no proxy'))
+ useChatStore.setState({ status: 'idle', error: null, hostedChat: null })
})
afterEach(() => {
vi.restoreAllMocks()
- useChatStore.setState({ status: 'idle', storage: EMPTY_STORAGE_STATUS })
+ useChatStore.setState({ status: 'idle', storage: EMPTY_STORAGE_STATUS, hostedChat: null })
})
describe('ModelGate', () => {
diff --git a/src/eval/runner.ts b/src/eval/runner.ts
index bbe0931..31ad903 100644
--- a/src/eval/runner.ts
+++ b/src/eval/runner.ts
@@ -1,6 +1,6 @@
import { runAgent } from '@/agent/loop'
import type { ReviewCheck } from '@/agent/review'
-import type { LlmClient } from '@/llm/client'
+import type { InferenceClient } from '@/llm/client'
import type { GenerationStrategy } from '@/llm/config'
import type { ChatTurn } from '@/llm/protocol'
import { recallFor } from '@/memory/select'
@@ -126,7 +126,7 @@ function promptNotes(scenario: Scenario, skill: string | null, now = Date.now())
}
async function runAttempt(
- client: LlmClient,
+ client: InferenceClient,
scenario: Scenario,
arm: EvalArm,
repeat: number,
@@ -211,7 +211,7 @@ async function runAttempt(
* through would otherwise penalise whichever arm happened to be scheduled last,
* and comparing the arms is the entire point.
*/
-export async function runEval(client: LlmClient, options: RunOptions): Promise {
+export async function runEval(client: InferenceClient, options: RunOptions): Promise {
const results: Attempt[] = []
for (let repeat = 0; repeat < options.repeats; repeat += 1) {
diff --git a/src/llm/client.ts b/src/llm/client.ts
index 1b1690d..293912b 100644
--- a/src/llm/client.ts
+++ b/src/llm/client.ts
@@ -1,6 +1,7 @@
import type { ToolSchema } from '@/types'
import { DEFAULT_STRATEGY, type GenerationStrategy } from './config'
import type { ChatTurn, LoadProgress, MainToWorker, WorkerToMain } from './protocol'
+import type { ParsedToolCall } from '@/agent/parse'
export interface GenerateHandlers {
onChunk: (text: string) => void
@@ -12,6 +13,8 @@ export interface GenerateResult {
tokens: number
thinkTokens: number
durationMs: number
+ /** Set when a hosted model used native function calling instead of XML. */
+ toolCalls?: ParsedToolCall[]
}
export interface LoadHandlers {
@@ -19,11 +22,22 @@ export interface LoadHandlers {
onProgress: (files: LoadProgress[]) => void
}
+/**
+ * What `runAgent` and the store need. The on-device worker and the hosted
+ * proxy both satisfy this; tests fake it with a scripted `generate`.
+ */
+export interface InferenceClient {
+ load(handlers: LoadHandlers): Promise
+ generate(turns: ChatTurn[], tools: ToolSchema[], handlers: GenerateHandlers): Promise
+ interrupt(): void
+ dispose(): void
+}
+
/**
* Promise-shaped facade over the inference worker. Requests are correlated by id
* so a stale generation can never resolve a newer one.
*/
-export class LlmClient {
+export class LlmClient implements InferenceClient {
private worker: Worker
private pending = new Map<
string,
diff --git a/src/llm/config.ts b/src/llm/config.ts
index 2de6676..2d3ca72 100644
--- a/src/llm/config.ts
+++ b/src/llm/config.ts
@@ -57,7 +57,7 @@ export const DEFAULT_GENERATION = {
do_sample: true,
} as const
-export const SYSTEM_PROMPT = `You are Jarvis, a concise and precise assistant running entirely inside the user's browser.
+export const SYSTEM_PROMPT = `You are Jarvis, a concise and precise assistant.
Guidelines:
- Answer directly. Do not pad replies with filler.
diff --git a/src/llm/hosted.test.ts b/src/llm/hosted.test.ts
new file mode 100644
index 0000000..f4ff75f
--- /dev/null
+++ b/src/llm/hosted.test.ts
@@ -0,0 +1,95 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { eventFromSseFrame, HostedLlmClient, probeHostedChat, readChatSse } from './hosted'
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+ vi.unstubAllEnvs()
+})
+
+describe('probeHostedChat', () => {
+ it('is off when no proxy is configured', async () => {
+ vi.stubEnv('VITE_AGENT_API_BASE', '')
+ await expect(probeHostedChat({ provider: 'duckduckgo' })).resolves.toBeNull()
+ })
+
+ it('reads the model name from health', async () => {
+ vi.stubEnv('VITE_AGENT_API_BASE', 'https://proxy.example')
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response(JSON.stringify({ ok: true, chat: { model: 'gpt-4o-mini' } }))),
+ )
+ await expect(probeHostedChat({ provider: 'duckduckgo' })).resolves.toEqual({
+ base: 'https://proxy.example',
+ model: 'gpt-4o-mini',
+ })
+ })
+
+ it('treats a healthy proxy without chat as on-device', async () => {
+ vi.stubEnv('VITE_AGENT_API_BASE', 'https://proxy.example')
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
+ )
+ await expect(probeHostedChat({ provider: 'duckduckgo' })).resolves.toBeNull()
+ })
+})
+
+describe('eventFromSseFrame', () => {
+ it('reads a data line and ignores done', () => {
+ expect(eventFromSseFrame('data: {"text":"Hi"}')).toEqual({ text: 'Hi' })
+ expect(eventFromSseFrame('data: [DONE]')).toBeNull()
+ })
+})
+
+function sseBody(frames: string[]): ReadableStream {
+ const bytes = new TextEncoder().encode(frames.join(''))
+ return new ReadableStream({
+ start(controller) {
+ controller.enqueue(bytes)
+ controller.close()
+ },
+ })
+}
+
+describe('HostedLlmClient', () => {
+ it('streams text and returns native tool calls', async () => {
+ const frames = [
+ 'data: {"text":"Let me calculate."}\n\n',
+ 'data: {"tool_calls":[{"id":"c1","name":"calculator","arguments":{"expression":"2+2"}}]}\n\n',
+ 'data: {"usage":{"completion_tokens":8}}\n\n',
+ ]
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response(sseBody(frames), { headers: { 'content-type': 'text/event-stream' } })),
+ )
+ const chunks: string[] = []
+ const client = new HostedLlmClient('https://proxy.example')
+ const result = await client.generate([{ role: 'user', content: '2+2' }], [], {
+ onChunk: (text) => chunks.push(text),
+ })
+ expect(chunks).toEqual(['Let me calculate.'])
+ expect(result.text).toBe('Let me calculate.')
+ expect(result.toolCalls).toEqual([{ id: 'c1', name: 'calculator', arguments: { expression: '2+2' } }])
+ expect(result.tokens).toBe(8)
+ })
+
+ it('raises the proxy error body', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response(JSON.stringify({ error: 'quota' }), { status: 502 })),
+ )
+ const client = new HostedLlmClient('https://proxy.example')
+ await expect(
+ client.generate([{ role: 'user', content: 'hi' }], [], { onChunk: () => undefined }),
+ ).rejects.toThrow('quota')
+ })
+})
+
+describe('readChatSse', () => {
+ it('yields events as frames arrive', async () => {
+ const stream = sseBody(['data: {"text":"A"}\n\n', 'data: {"text":"B"}\n\n'])
+ const events = []
+ for await (const event of readChatSse(stream)) events.push(event)
+ expect(events).toEqual([{ text: 'A' }, { text: 'B' }])
+ })
+})
diff --git a/src/llm/hosted.ts b/src/llm/hosted.ts
new file mode 100644
index 0000000..2c010af
--- /dev/null
+++ b/src/llm/hosted.ts
@@ -0,0 +1,146 @@
+/**
+ * Inference through `POST /api/chat` on the tool proxy, used when that process
+ * has a model key. The tab still executes tools; this client only generates.
+ */
+
+import type { ToolSchema } from '@/types'
+import type { ParsedToolCall } from '@/agent/parse'
+import { configuredProxyBase, type WebAccessConfig } from '@/tools/web'
+import type { GenerateHandlers, GenerateResult, InferenceClient, LoadHandlers } from './client'
+import type { ChatTurn } from './protocol'
+
+export interface HostedChatInfo {
+ base: string
+ model: string
+}
+
+interface ChatStreamEvent {
+ text?: string
+ tool_calls?: ParsedToolCall[]
+ usage?: { completion_tokens?: number; total_tokens?: number }
+ error?: string
+}
+
+export async function probeHostedChat(config: WebAccessConfig): Promise {
+ const base = configuredProxyBase(config)
+ if (base === undefined) return null
+ try {
+ const response = await fetch(`${base}/api/health`, { signal: AbortSignal.timeout(4_000) })
+ if (!response.ok) return null
+ const payload = (await response.json()) as { chat?: { model?: unknown } }
+ const model = payload.chat?.model
+ if (typeof model !== 'string' || !model.trim()) return null
+ return { base, model: model.trim() }
+ } catch {
+ return null
+ }
+}
+
+async function readError(response: Response): Promise {
+ try {
+ const payload = (await response.json()) as { error?: string }
+ if (payload.error?.trim()) return payload.error.trim()
+ } catch {
+ // Fall through to the status line.
+ }
+ return `The hosted model responded with ${response.status}`
+}
+
+export async function* readChatSse(body: ReadableStream): AsyncGenerator {
+ const reader = body.getReader()
+ const decoder = new TextDecoder()
+ let buffer = ''
+ for (;;) {
+ const { done, value } = await reader.read()
+ if (done) break
+ buffer += decoder.decode(value, { stream: true })
+ const frames = buffer.split('\n\n')
+ buffer = frames.pop() ?? ''
+ for (const frame of frames) {
+ const event = eventFromSseFrame(frame)
+ if (event) yield event
+ }
+ }
+ const trailing = eventFromSseFrame(buffer)
+ if (trailing) yield trailing
+}
+
+export function eventFromSseFrame(frame: string): ChatStreamEvent | null {
+ for (const line of frame.split('\n')) {
+ const data = line.startsWith('data:') ? line.slice(5).trim() : ''
+ if (!data || data === '[DONE]') continue
+ try {
+ return JSON.parse(data) as ChatStreamEvent
+ } catch {
+ return null
+ }
+ }
+ return null
+}
+
+/**
+ * Same shape as `LlmClient`, talking to `/api/chat` instead of a Web Worker.
+ */
+export class HostedLlmClient implements InferenceClient {
+ private abort: AbortController | null = null
+ private readonly base: string
+
+ constructor(base: string) {
+ this.base = base
+ }
+
+ load(handlers: LoadHandlers): Promise {
+ handlers.onStatus('Connected to the hosted model')
+ handlers.onProgress([])
+ return Promise.resolve()
+ }
+
+ async generate(
+ turns: ChatTurn[],
+ tools: ToolSchema[],
+ handlers: GenerateHandlers,
+ ): Promise {
+ this.abort?.abort()
+ this.abort = new AbortController()
+ const startedAt = performance.now()
+ const response = await fetch(`${this.base}/api/chat`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
+ body: JSON.stringify({ messages: turns, tools }),
+ signal: AbortSignal.any([this.abort.signal, AbortSignal.timeout(120_000)]),
+ })
+ if (!response.ok) throw new Error(await readError(response))
+ if (!response.body) throw new Error('The hosted model returned an empty body')
+
+ let text = ''
+ let tokens = 0
+ let toolCalls: ParsedToolCall[] | undefined
+ for await (const event of readChatSse(response.body)) {
+ if (event.error) throw new Error(event.error)
+ if (event.text) {
+ text += event.text
+ handlers.onChunk(event.text)
+ }
+ if (event.tool_calls?.length) toolCalls = event.tool_calls
+ const counted = event.usage?.completion_tokens ?? event.usage?.total_tokens
+ if (typeof counted === 'number') tokens = counted
+ }
+
+ return {
+ text,
+ tokens: tokens || Math.max(1, Math.ceil(text.length / 4)),
+ thinkTokens: 0,
+ durationMs: performance.now() - startedAt,
+ ...(toolCalls?.length ? { toolCalls } : {}),
+ }
+ }
+
+ interrupt(): void {
+ this.abort?.abort()
+ this.abort = null
+ }
+
+ dispose(): void {
+ this.interrupt()
+ }
+}
diff --git a/src/llm/protocol.ts b/src/llm/protocol.ts
index 05ec631..81b3930 100644
--- a/src/llm/protocol.ts
+++ b/src/llm/protocol.ts
@@ -5,6 +5,10 @@ import type { GenerationStrategy } from './config'
export interface ChatTurn {
role: 'system' | 'user' | 'assistant' | 'tool'
content: string
+ /** Native tool calls from a hosted model, ignored by the on-device worker. */
+ toolCalls?: { id: string; name: string; arguments: Record }[]
+ /** Matches `toolCalls[].id` so a hosted API can pair the result. */
+ toolCallId?: string
}
export type MainToWorker =
diff --git a/src/llm/worker.ts b/src/llm/worker.ts
index 486cfed..4a0006b 100644
--- a/src/llm/worker.ts
+++ b/src/llm/worker.ts
@@ -36,6 +36,11 @@ let stopper = new InterruptableStoppingCriteria()
/** Mirrors `stopper`, which does not report whether it has been tripped. */
let interrupted = false
+/** Extra fields on ChatTurn are for hosted APIs; the chat template wants role+content. */
+function tokenizerTurns(turns: ChatTurn[]): Pick[] {
+ return turns.map((turn) => ({ role: turn.role, content: turn.content }))
+}
+
const progressByFile = new Map()
function post(message: WorkerToMain): void {
@@ -179,7 +184,7 @@ async function generateUncapped(
emit: (chunk: string) => void,
): Promise {
return runPhase(
- request.turns,
+ tokenizerTurns(request.turns),
{
...DEFAULT_GENERATION,
max_new_tokens: request.strategy.answerBudget,
@@ -214,7 +219,7 @@ async function generateCapped(
if (!generator) throw new Error('Model is not loaded')
const { strategy, tools, turns } = request
- const prompt = generator.tokenizer.apply_chat_template(turns, {
+ const prompt = generator.tokenizer.apply_chat_template(tokenizerTurns(turns), {
tokenize: false,
add_generation_prompt: true,
enable_thinking: true,
diff --git a/src/store/chat.ts b/src/store/chat.ts
index 92ab436..c267705 100644
--- a/src/store/chat.ts
+++ b/src/store/chat.ts
@@ -1,6 +1,7 @@
import { create } from 'zustand'
import { runAgent } from '@/agent/loop'
-import { LlmClient } from '@/llm/client'
+import { LlmClient, type InferenceClient } from '@/llm/client'
+import { HostedLlmClient, probeHostedChat, type HostedChatInfo } from '@/llm/hosted'
import type { ChatTurn, LoadProgress } from '@/llm/protocol'
import { MODEL_ID, MODEL_WEIGHTS_FILE } from '@/llm/config'
import {
@@ -40,6 +41,7 @@ const WEB_ACCESS_STORAGE_KEY = 'jarvis.web-access'
const MEMORY_ENABLED_KEY = 'jarvis.memory-enabled'
export type ModelStatus = 'idle' | 'loading' | 'ready' | 'error'
+export type InferenceBackend = 'local' | 'hosted'
interface ChatState {
status: ModelStatus
@@ -73,7 +75,15 @@ interface ChatState {
memoryError: string | null
storage: StorageStatus
+ /**
+ * Set after a health probe finds `POST /api/chat` on the tool proxy.
+ * The landing page switches copy off this, before anyone presses Start.
+ */
+ hostedChat: HostedChatInfo | null
+ /** Which generate path `initialize` actually started. */
+ inference: InferenceBackend | null
+ probeHosted: () => Promise
initialize: () => Promise
refreshStorage: () => Promise
removeModel: () => Promise
@@ -124,13 +134,19 @@ function composeTools(webAccess: WebAccessConfig, mcpTools: Tool[], memoryEnable
return [...createBuiltinTools(webAccess, { memory: memoryEnabled }), ...mcpTools]
}
-let client: LlmClient | null = null
+let client: InferenceClient | null = null
+
+function replaceClient(next: InferenceClient): InferenceClient {
+ if (client && client !== next) client.dispose()
+ client = next
+ return client
+}
/**
* Exported so the eval harness can drive the same loaded model rather than
* spawning a second worker and paying for another 448 MB of weights.
*/
-export function getClient(): LlmClient {
+export function getClient(): InferenceClient {
client ??= new LlmClient()
return client
}
@@ -355,24 +371,55 @@ export const useChatStore = create((set, get) => {
trashedMemories: [],
memoryError: null,
storage: EMPTY_STORAGE_STATUS,
+ hostedChat: null,
+ inference: null,
+
+ async probeHosted() {
+ const hosted = await probeHostedChat(get().webAccess)
+ set({ hostedChat: hosted })
+ },
async initialize() {
if (get().status === 'loading' || get().status === 'ready') return
- set({ status: 'loading', error: null, loadMessage: 'Requesting persistent storage' })
+ set({ status: 'loading', error: null, loadMessage: 'Looking for a hosted model' })
+
+ void get().refreshMemories()
+ const hosted = get().hostedChat ?? (await probeHostedChat(get().webAccess))
+ set({ hostedChat: hosted })
+
+ if (hosted) {
+ try {
+ const hostedClient = replaceClient(new HostedLlmClient(hosted.base))
+ await hostedClient.load({
+ onStatus: (loadMessage) => set({ loadMessage }),
+ onProgress: () => undefined,
+ })
+ set({
+ status: 'ready',
+ inference: 'hosted',
+ loadMessage: '',
+ loadProgress: [],
+ })
+ await get().setMcpServers(get().mcpServers)
+ } catch (error) {
+ set({ status: 'error', error: error instanceof Error ? error.message : String(error) })
+ }
+ return
+ }
+
+ set({ loadMessage: 'Requesting persistent storage' })
// Ask before downloading: weights fetched into best-effort storage can be
// evicted, and re-downloading 448 MB is exactly what installing should avoid.
await requestPersistence()
- // Before the first turn can need them, and before the panel is opened.
- void get().refreshMemories()
try {
set({ loadMessage: 'Starting the inference worker' })
- await getClient().load({
+ await replaceClient(new LlmClient()).load({
onStatus: (loadMessage) => set({ loadMessage }),
onProgress: (loadProgress) => set({ loadProgress }),
})
- set({ status: 'ready', loadMessage: '', loadProgress: [] })
+ set({ status: 'ready', inference: 'local', loadMessage: '', loadProgress: [] })
void get().refreshStorage()
await get().setMcpServers(get().mcpServers)
} catch (error) {
@@ -410,6 +457,7 @@ export const useChatStore = create((set, get) => {
setWebAccess(config) {
localStorage.setItem(WEB_ACCESS_STORAGE_KEY, JSON.stringify(config))
set({ webAccess: config, tools: composeTools(config, get().mcpTools, get().memoryEnabled) })
+ if (get().status === 'idle') void get().probeHosted()
},
async refreshMemories() {
diff --git a/tools/agent-api-listen.ts b/tools/agent-api-listen.ts
index 1bb6425..2e8697a 100644
--- a/tools/agent-api-listen.ts
+++ b/tools/agent-api-listen.ts
@@ -11,6 +11,7 @@
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import { createRateLimiter, handleAgentApiRequest } from './agent-api.ts'
+import { chatPublicInfo } from './agent-chat.ts'
const PORT = Number(process.env.PORT) || 8787
const HOST = process.env.HOST ?? '0.0.0.0'
@@ -57,6 +58,17 @@ function applyCors(req: IncomingMessage, res: ServerResponse): boolean {
return true
}
+/**
+ * Search and fetch without an Origin are annoying; chat without one is a bill.
+ * When an allowlist is set, `/api/chat` requires a matching Origin so a curl
+ * from the public internet cannot spend the model key.
+ */
+function chatOriginAllowed(req: IncomingMessage, pathname: string): boolean {
+ if (pathname !== '/api/chat' || ALLOWLIST.length === 0) return true
+ const origin = req.headers.origin
+ return Boolean(origin && ALLOWLIST.includes(origin))
+}
+
const server = createServer((req, res) => {
void (async () => {
if (!applyCors(req, res)) return
@@ -65,9 +77,15 @@ const server = createServer((req, res) => {
res.end()
return
}
+ const url = new URL(req.url ?? '/', 'http://localhost')
+ if (!chatOriginAllowed(req, url.pathname)) {
+ res.statusCode = 403
+ res.setHeader('content-type', 'application/json; charset=utf-8')
+ res.end(JSON.stringify({ error: 'Origin not allowed' }))
+ return
+ }
// Health is exempt: the platform polls it far more often than a person
// searches, and a restart loop triggered by our own limit would be absurd.
- const url = new URL(req.url ?? '/', 'http://localhost')
if (url.pathname !== '/api/health' && !limiter.take(callerKey(req))) {
res.statusCode = 429
res.setHeader('content-type', 'application/json; charset=utf-8')
@@ -94,6 +112,12 @@ server.listen(PORT, HOST, () => {
console.log(`Jarvis tool proxy on http://${HOST}:${PORT}`)
console.log('POST /api/search { query, limit?, region? }')
console.log('POST /api/fetch { url }')
+ const chat = chatPublicInfo()
+ console.log(
+ chat
+ ? `POST /api/chat hosted model ${chat.model}`
+ : 'POST /api/chat disabled (set ANTHROPIC_API_KEY)',
+ )
if (ALLOWLIST.length > 0) console.log(`Origins: ${ALLOWLIST.join(', ')}`)
console.log(RATE_LIMIT > 0 ? `Rate limit: ${RATE_LIMIT} per minute per caller` : 'Rate limit: off')
})
diff --git a/tools/agent-api.test.ts b/tools/agent-api.test.ts
index 520d854..cab9ac5 100644
--- a/tools/agent-api.test.ts
+++ b/tools/agent-api.test.ts
@@ -24,6 +24,9 @@ beforeEach(() => {
afterEach(() => {
resolveHostAddresses.lookup = realLookup
vi.unstubAllGlobals()
+ delete process.env.OPENAI_API_KEY
+ delete process.env.OPENAI_MODEL
+ delete process.env.ANTHROPIC_API_KEY
})
const HTML_RESULTS = `
@@ -149,6 +152,32 @@ describe('routeAgentApi', () => {
})
})
+ it('advertises hosted Opus on health when the Anthropic key is set', async () => {
+ process.env.ANTHROPIC_API_KEY = 'sk-ant-test'
+ try {
+ await expect(routeAgentApi('GET', '/api/health', undefined)).resolves.toEqual({
+ status: 200,
+ payload: { ok: true, chat: { model: 'claude-opus-5', provider: 'anthropic' } },
+ })
+ } finally {
+ delete process.env.ANTHROPIC_API_KEY
+ }
+ })
+
+ it('advertises hosted chat on health when a key is set', async () => {
+ process.env.OPENAI_API_KEY = 'sk-test'
+ process.env.OPENAI_MODEL = 'gpt-4o-mini'
+ try {
+ await expect(routeAgentApi('GET', '/api/health', undefined)).resolves.toEqual({
+ status: 200,
+ payload: { ok: true, chat: { model: 'gpt-4o-mini', provider: 'openai' } },
+ })
+ } finally {
+ delete process.env.OPENAI_API_KEY
+ delete process.env.OPENAI_MODEL
+ }
+ })
+
it('refuses a search with no query', async () => {
await expect(routeAgentApi('POST', '/api/search', {})).resolves.toEqual({
status: 400,
diff --git a/tools/agent-api.ts b/tools/agent-api.ts
index 9855bec..eb28674 100644
--- a/tools/agent-api.ts
+++ b/tools/agent-api.ts
@@ -1,10 +1,12 @@
/**
- * Handlers for the optional tool proxy: `/api/search` and `/api/fetch`.
+ * Handlers for the optional tool proxy: `/api/search`, `/api/fetch`, and
+ * `/api/chat` when a model key is set.
*
* The published GitHub Pages site does not run this. `pnpm dev` does, via the
* Vite plugin, and `pnpm proxy` runs the same handlers as a standalone server
- * so a hosted static build can point at them. Inference stays in the tab either
- * way — this process only fetches.
+ * so a hosted static build can point at them. Search and fetch stay optional;
+ * generation stays in the tab unless `ANTHROPIC_API_KEY` (or `OPENAI_API_KEY`)
+ * is set on this process.
*
* A fetch-on-behalf proxy is a confused deputy. Every target is resolved and
* refused if it lands on loopback, link-local or RFC1918, and redirects are
@@ -14,6 +16,7 @@
import type { IncomingMessage, ServerResponse } from 'node:http'
import { lookup as dnsLookup } from 'node:dns/promises'
import { isIP } from 'node:net'
+import { chatPublicInfo, handleChatRequest } from './agent-chat.ts'
const USER_AGENT =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36'
@@ -396,7 +399,13 @@ export async function routeAgentApi(
if (pathname === '/api/health') {
if (verb !== 'GET' && verb !== 'HEAD') return { status: 405, payload: { error: 'Method not allowed' } }
- return { status: 200, payload: { ok: true } }
+ const chat = chatPublicInfo()
+ return { status: 200, payload: chat ? { ok: true, chat } : { ok: true } }
+ }
+
+ if (pathname === '/api/chat') {
+ if (verb !== 'POST') return { status: 405, payload: { error: 'Method not allowed' } }
+ return { status: 503, payload: { error: 'Hosted chat must be streamed; use handleChatRequest' } }
}
if (pathname === '/api/search') {
@@ -482,6 +491,15 @@ export async function handleAgentApiRequest(req: IncomingMessage, res: ServerRes
}
}
+ if (url.pathname === '/api/chat') {
+ if (verb !== 'POST') {
+ sendJson(res, 405, { error: 'Method not allowed' })
+ return true
+ }
+ await handleChatRequest(parsed, res)
+ return true
+ }
+
const result = await routeAgentApi(verb, url.pathname, parsed)
sendJson(res, result.status, result.payload)
return true
diff --git a/tools/agent-chat.test.ts b/tools/agent-chat.test.ts
new file mode 100644
index 0000000..2c430e8
--- /dev/null
+++ b/tools/agent-chat.test.ts
@@ -0,0 +1,350 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+ anthropicChatBody,
+ applyOpenAiToolDelta,
+ chatConfig,
+ chatPublicInfo,
+ eventsFromAnthropicEvent,
+ eventsFromOpenAiChunk,
+ finalizeToolCalls,
+ handleChatRequest,
+ mockChatEvents,
+ parseToolArguments,
+ toAnthropicRequest,
+ toAnthropicTools,
+ toOpenAiMessages,
+} from './agent-chat.ts'
+import type { ServerResponse } from 'node:http'
+
+afterEach(() => {
+ delete process.env.OPENAI_API_KEY
+ delete process.env.OPENAI_BASE_URL
+ delete process.env.OPENAI_MODEL
+ delete process.env.ANTHROPIC_API_KEY
+ delete process.env.ANTHROPIC_BASE_URL
+ delete process.env.ANTHROPIC_MODEL
+})
+
+describe('chatConfig', () => {
+ it('is off when no key is set', () => {
+ expect(chatConfig()).toBeNull()
+ expect(chatPublicInfo()).toBeNull()
+ })
+
+ it('treats the mock key as a local stand-in', () => {
+ process.env.ANTHROPIC_API_KEY = 'mock'
+ expect(chatConfig()).toEqual({ provider: 'mock', apiKey: 'mock', baseUrl: '', model: 'mock' })
+ expect(chatPublicInfo()).toEqual({ model: 'mock', provider: 'mock' })
+ })
+
+ it('prefers Anthropic Opus when that key is set', () => {
+ process.env.ANTHROPIC_API_KEY = 'sk-ant-test'
+ expect(chatConfig()).toEqual({
+ provider: 'anthropic',
+ apiKey: 'sk-ant-test',
+ baseUrl: 'https://api.anthropic.com',
+ model: 'claude-opus-5',
+ })
+ expect(chatPublicInfo()).toEqual({ model: 'claude-opus-5', provider: 'anthropic' })
+ })
+
+ it('falls back to an OpenAI-compatible host', () => {
+ process.env.OPENAI_API_KEY = 'sk-test'
+ expect(chatConfig()).toEqual({
+ provider: 'openai',
+ apiKey: 'sk-test',
+ baseUrl: 'https://api.openai.com/v1',
+ model: 'gpt-4o-mini',
+ })
+ })
+
+ it('accepts Groq or any other OpenAI-compatible host', () => {
+ process.env.OPENAI_API_KEY = 'gsk-test'
+ process.env.OPENAI_BASE_URL = 'https://api.groq.com/openai/v1/'
+ process.env.OPENAI_MODEL = 'llama-3.3-70b-versatile'
+ expect(chatConfig()).toMatchObject({
+ provider: 'openai',
+ baseUrl: 'https://api.groq.com/openai/v1',
+ model: 'llama-3.3-70b-versatile',
+ })
+ })
+})
+
+describe('toOpenAiMessages', () => {
+ it('keeps roles the chat template already uses', () => {
+ expect(
+ toOpenAiMessages([
+ { role: 'system', content: 'Be brief.' },
+ { role: 'user', content: 'Hi' },
+ { role: 'assistant', content: 'Hello' },
+ ]),
+ ).toEqual([
+ { role: 'system', content: 'Be brief.' },
+ { role: 'user', content: 'Hi' },
+ { role: 'assistant', content: 'Hello' },
+ ])
+ })
+
+ it('turns structured tool calls into the Completions shape', () => {
+ expect(
+ toOpenAiMessages([
+ {
+ role: 'assistant',
+ content: '',
+ toolCalls: [{ id: 'call_1', name: 'calculator', arguments: { expression: '2+2' } }],
+ },
+ { role: 'tool', content: '2 + 2 = 4', toolCallId: 'call_1' },
+ ]),
+ ).toEqual([
+ {
+ role: 'assistant',
+ content: null,
+ tool_calls: [
+ {
+ id: 'call_1',
+ type: 'function',
+ function: { name: 'calculator', arguments: '{"expression":"2+2"}' },
+ },
+ ],
+ },
+ { role: 'tool', content: '2 + 2 = 4', tool_call_id: 'call_1' },
+ ])
+ })
+
+ it('drops junk rather than 400ing the turn', () => {
+ expect(
+ toOpenAiMessages([{ role: 'nope', content: 'x' }, 'leave', { role: 'user', content: 'ok' }]),
+ ).toEqual([{ role: 'user', content: 'ok' }])
+ })
+})
+
+describe('parseToolArguments', () => {
+ it('parses a JSON object and keeps a bare string', () => {
+ expect(parseToolArguments('{"expression":"3*4"}')).toEqual({ expression: '3*4' })
+ expect(parseToolArguments('3 * 4')).toEqual({ value: '3 * 4' })
+ })
+})
+
+describe('eventsFromOpenAiChunk', () => {
+ it('streams content deltas and assembles a tool call', () => {
+ const buckets = new Map()
+ expect(eventsFromOpenAiChunk({ choices: [{ delta: { content: 'Hi' } }] }, buckets)).toEqual([
+ { text: 'Hi' },
+ ])
+
+ applyOpenAiToolDelta(buckets, [
+ { index: 0, id: 'call_9', function: { name: 'web_search', arguments: '{"query":' } },
+ ])
+ applyOpenAiToolDelta(buckets, [{ index: 0, function: { arguments: '"webgpu"}' } }])
+ expect(eventsFromOpenAiChunk({ choices: [{ delta: {}, finish_reason: 'tool_calls' }] }, buckets)).toEqual(
+ [{ tool_calls: [{ id: 'call_9', name: 'web_search', arguments: { query: 'webgpu' } }] }],
+ )
+ })
+
+ it('surfaces a provider error object', () => {
+ expect(eventsFromOpenAiChunk({ error: { message: 'quota' } }, new Map())).toEqual([{ error: 'quota' }])
+ })
+})
+
+describe('finalizeToolCalls', () => {
+ it('skips a bucket that never got a name or id', () => {
+ const buckets = new Map([
+ [0, { arguments: '{}' }],
+ [1, { id: 'call_1', name: 'calculator', arguments: '{"expression":"1+1"}' }],
+ ])
+ expect(finalizeToolCalls(buckets)).toEqual([
+ { id: 'call_1', name: 'calculator', arguments: { expression: '1+1' } },
+ ])
+ })
+})
+
+describe('mockChatEvents', () => {
+ const calculator = [{ type: 'function', function: { name: 'calculator' } }]
+
+ it('calls the calculator for an arithmetic question', () => {
+ expect(mockChatEvents([{ role: 'user', content: 'What is 12 * 8?' }], calculator)).toEqual([
+ { tool_calls: [{ id: 'mock_calc', name: 'calculator', arguments: { expression: '12 * 8' } }] },
+ ])
+ })
+
+ it('answers from the tool result on the next round', () => {
+ const events = mockChatEvents(
+ [
+ { role: 'user', content: 'What is 12 * 8?' },
+ { role: 'tool', content: '12 * 8 = 96' },
+ ],
+ calculator,
+ )
+ expect(events[0]).toEqual({ text: '12 * 8 = 96' })
+ })
+
+ it('ignores tool results that belong to earlier skill exemplars', () => {
+ expect(
+ mockChatEvents(
+ [
+ { role: 'user', content: 'How much is 12 percent of 340?' },
+ { role: 'tool', content: '340 * 0.12 = 40.8' },
+ { role: 'assistant', content: '12% of 340 is 40.8.' },
+ { role: 'user', content: 'What is 12 * 8?' },
+ ],
+ calculator,
+ ),
+ ).toEqual([
+ { tool_calls: [{ id: 'mock_calc', name: 'calculator', arguments: { expression: '12 * 8' } }] },
+ ])
+ })
+
+ it('answers in prose when no tool is needed', () => {
+ const events = mockChatEvents([{ role: 'user', content: 'Hello' }], [])
+ expect(events[0]?.text).toMatch(/search/i)
+ })
+})
+
+describe('toAnthropicRequest', () => {
+ it('lifts the system prompt and turns tool results into user blocks', () => {
+ expect(
+ toAnthropicRequest([
+ { role: 'system', content: 'Be brief.' },
+ { role: 'user', content: '2+2' },
+ {
+ role: 'assistant',
+ content: '',
+ toolCalls: [{ id: 'toolu_1', name: 'calculator', arguments: { expression: '2+2' } }],
+ },
+ { role: 'tool', content: '4', toolCallId: 'toolu_1' },
+ ]),
+ ).toEqual({
+ system: 'Be brief.',
+ messages: [
+ { role: 'user', content: '2+2' },
+ {
+ role: 'assistant',
+ content: [{ type: 'tool_use', id: 'toolu_1', name: 'calculator', input: { expression: '2+2' } }],
+ },
+ {
+ role: 'user',
+ content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: '4' }],
+ },
+ ],
+ })
+ })
+})
+
+describe('toAnthropicTools', () => {
+ it('rewrites our function schemas into input_schema', () => {
+ expect(
+ toAnthropicTools([
+ {
+ type: 'function',
+ function: {
+ name: 'calculator',
+ description: 'Exact arithmetic.',
+ parameters: {
+ type: 'object',
+ properties: { expression: { type: 'string' } },
+ required: ['expression'],
+ },
+ },
+ },
+ ]),
+ ).toEqual([
+ {
+ name: 'calculator',
+ description: 'Exact arithmetic.',
+ input_schema: {
+ type: 'object',
+ properties: { expression: { type: 'string' } },
+ required: ['expression'],
+ },
+ },
+ ])
+ })
+})
+
+describe('eventsFromAnthropicEvent', () => {
+ it('streams text and assembles a tool_use block', () => {
+ const buckets = new Map()
+ expect(
+ eventsFromAnthropicEvent(
+ { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Hi' } },
+ buckets,
+ ),
+ ).toEqual([{ text: 'Hi' }])
+
+ eventsFromAnthropicEvent(
+ {
+ type: 'content_block_start',
+ index: 0,
+ content_block: { type: 'tool_use', id: 'toolu_9', name: 'web_search' },
+ },
+ buckets,
+ )
+ eventsFromAnthropicEvent(
+ {
+ type: 'content_block_delta',
+ index: 0,
+ delta: { type: 'input_json_delta', partial_json: '{"query":"webgpu"}' },
+ },
+ buckets,
+ )
+ expect(
+ eventsFromAnthropicEvent({ type: 'message_delta', delta: { stop_reason: 'tool_use' } }, buckets),
+ ).toEqual([{ tool_calls: [{ id: 'toolu_9', name: 'web_search', arguments: { query: 'webgpu' } }] }])
+ })
+})
+
+describe('anthropicChatBody', () => {
+ it('disables thinking at high effort so rebuilt tool turns stay valid', () => {
+ const converted = toAnthropicRequest([{ role: 'user', content: 'Hi' }])
+ expect(anthropicChatBody('claude-opus-5', converted, [])).toMatchObject({
+ model: 'claude-opus-5',
+ stream: true,
+ thinking: { type: 'disabled' },
+ output_config: { effort: 'high' },
+ messages: [{ role: 'user', content: 'Hi' }],
+ })
+ })
+
+ it('is empty when there is nothing to send', () => {
+ expect(anthropicChatBody('claude-opus-5', { messages: [] }, [])).toBeNull()
+ })
+})
+
+function fakeRes(): { res: ServerResponse; body: () => string; status: () => number } {
+ const chunks: string[] = []
+ const res = {
+ statusCode: 0,
+ setHeader() {
+ return this
+ },
+ write(chunk: string) {
+ chunks.push(chunk)
+ return true
+ },
+ end(chunk?: string) {
+ if (chunk) chunks.push(chunk)
+ },
+ }
+ return {
+ res: res as unknown as ServerResponse,
+ body: () => chunks.join(''),
+ status: () => res.statusCode,
+ }
+}
+
+describe('handleChatRequest', () => {
+ it('refuses chat when no key is configured', async () => {
+ const captured = fakeRes()
+ await handleChatRequest({ messages: [{ role: 'user', content: 'Hi' }] }, captured.res)
+ expect(captured.status()).toBe(503)
+ expect(captured.body()).toMatch(/ANTHROPIC_API_KEY/)
+ })
+
+ it('streams the mock model without calling a provider', async () => {
+ process.env.ANTHROPIC_API_KEY = 'mock'
+ const captured = fakeRes()
+ await handleChatRequest({ messages: [{ role: 'user', content: 'Hello' }] }, captured.res)
+ expect(captured.status()).toBe(200)
+ expect(captured.body()).toMatch(/search/i)
+ })
+})
diff --git a/tools/agent-chat.ts b/tools/agent-chat.ts
new file mode 100644
index 0000000..f451bec
--- /dev/null
+++ b/tools/agent-chat.ts
@@ -0,0 +1,716 @@
+/**
+ * Hosted chat through the tool proxy: `POST /api/chat`.
+ *
+ * Default provider is Anthropic Claude Opus (`ANTHROPIC_API_KEY`). An
+ * OpenAI-compatible host (`OPENAI_API_KEY`, Groq, OpenRouter, …) still works.
+ * The key never enters the bundle. Tools still run in the tab.
+ *
+ * Set either key to the literal `mock` to exercise the wiring without a provider.
+ */
+
+import type { ServerResponse } from 'node:http'
+
+export const CHAT_TIMEOUT_MS = 120_000
+export const MAX_CHAT_TURNS = 64
+export const MOCK_CHAT_KEY = 'mock'
+export const DEFAULT_ANTHROPIC_MODEL = 'claude-opus-5'
+export const ANTHROPIC_VERSION = '2023-06-01'
+
+export interface ChatToolCall {
+ id: string
+ name: string
+ arguments: Record
+}
+
+export interface ChatEvent {
+ text?: string
+ tool_calls?: ChatToolCall[]
+ usage?: { completion_tokens?: number; total_tokens?: number }
+ error?: string
+}
+
+export type ChatProvider = 'anthropic' | 'openai' | 'mock'
+
+export interface ChatConfig {
+ provider: ChatProvider
+ apiKey: string
+ baseUrl: string
+ model: string
+}
+
+export interface OpenAiMessage {
+ role: string
+ content: string | null
+ tool_call_id?: string
+ tool_calls?: {
+ id: string
+ type: 'function'
+ function: { name: string; arguments: string }
+ }[]
+}
+
+export interface AnthropicContentBlock {
+ type: string
+ text?: string
+ id?: string
+ name?: string
+ input?: Record
+ tool_use_id?: string
+ content?: string
+}
+
+export interface AnthropicMessage {
+ role: 'user' | 'assistant'
+ content: string | AnthropicContentBlock[]
+}
+
+interface IncomingTurn {
+ role?: unknown
+ content?: unknown
+ toolCallId?: unknown
+ toolCalls?: unknown
+}
+
+export function chatConfig(): ChatConfig | null {
+ const anthropic = process.env.ANTHROPIC_API_KEY?.trim()
+ const openai = process.env.OPENAI_API_KEY?.trim()
+ if (anthropic === MOCK_CHAT_KEY || openai === MOCK_CHAT_KEY) {
+ return { provider: 'mock', apiKey: MOCK_CHAT_KEY, baseUrl: '', model: 'mock' }
+ }
+ if (anthropic) {
+ return {
+ provider: 'anthropic',
+ apiKey: anthropic,
+ baseUrl: (process.env.ANTHROPIC_BASE_URL ?? 'https://api.anthropic.com').replace(/\/$/, ''),
+ model: process.env.ANTHROPIC_MODEL?.trim() || DEFAULT_ANTHROPIC_MODEL,
+ }
+ }
+ if (openai) {
+ return {
+ provider: 'openai',
+ apiKey: openai,
+ baseUrl: (process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1').replace(/\/$/, ''),
+ model: process.env.OPENAI_MODEL?.trim() || 'gpt-4o-mini',
+ }
+ }
+ return null
+}
+
+export function chatPublicInfo(): { model: string; provider: ChatProvider } | null {
+ const config = chatConfig()
+ return config ? { model: config.model, provider: config.provider } : null
+}
+
+export function parseToolArguments(raw: string): Record {
+ const trimmed = raw.trim()
+ if (!trimmed) return {}
+ try {
+ const parsed: unknown = JSON.parse(trimmed)
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+ return parsed as Record
+ }
+ } catch {
+ // The model sometimes emits a bare string. Keep it so the tool can coerce.
+ }
+ return { value: trimmed }
+}
+
+function asTurn(value: unknown): IncomingTurn | null {
+ if (!value || typeof value !== 'object') return null
+ return value as IncomingTurn
+}
+
+/**
+ * Turns our `{ role, content, toolCalls?, toolCallId? }` conversation into the
+ * Chat Completions shape. Invalid entries are dropped rather than failing the
+ * whole request — a stale tool id is not worth a 400.
+ */
+export function toOpenAiMessages(raw: unknown): OpenAiMessage[] {
+ if (!Array.isArray(raw)) return []
+ const messages: OpenAiMessage[] = []
+
+ for (const entry of raw.slice(-MAX_CHAT_TURNS)) {
+ const turn = asTurn(entry)
+ if (!turn) continue
+ const role = String(turn.role ?? '')
+ if (!['system', 'user', 'assistant', 'tool'].includes(role)) continue
+ const content = typeof turn.content === 'string' ? turn.content : ''
+
+ if (role === 'tool') {
+ const toolCallId = typeof turn.toolCallId === 'string' ? turn.toolCallId.trim() : ''
+ messages.push({
+ role: 'tool',
+ content,
+ ...(toolCallId ? { tool_call_id: toolCallId } : {}),
+ })
+ continue
+ }
+
+ if (role === 'assistant' && Array.isArray(turn.toolCalls) && turn.toolCalls.length > 0) {
+ const tool_calls: NonNullable = []
+ for (const call of turn.toolCalls) {
+ if (!call || typeof call !== 'object') continue
+ const record = call as { id?: unknown; name?: unknown; arguments?: unknown }
+ const id = String(record.id ?? '').trim()
+ const name = String(record.name ?? '').trim()
+ if (!id || !name) continue
+ tool_calls.push({
+ id,
+ type: 'function',
+ function: {
+ name,
+ arguments: JSON.stringify(
+ record.arguments && typeof record.arguments === 'object' ? record.arguments : {},
+ ),
+ },
+ })
+ }
+ if (tool_calls.length > 0) {
+ messages.push({ role: 'assistant', content: content || null, tool_calls })
+ continue
+ }
+ }
+
+ messages.push({ role, content })
+ }
+
+ return messages
+}
+
+function asRecord(value: unknown): Record | null {
+ return value && typeof value === 'object' && !Array.isArray(value)
+ ? (value as Record)
+ : null
+}
+
+export function toAnthropicTools(
+ raw: unknown,
+): { name: string; description: string; input_schema: Record }[] {
+ if (!Array.isArray(raw)) return []
+ const tools: { name: string; description: string; input_schema: Record }[] = []
+ for (const entry of raw.slice(0, 32)) {
+ const record = asRecord(entry)
+ const fn = asRecord(record?.function)
+ const name = typeof fn?.name === 'string' ? fn.name.trim() : ''
+ if (!fn || !name) continue
+ const parameters = asRecord(fn.parameters) ?? { type: 'object', properties: {} }
+ tools.push({
+ name,
+ description: typeof fn.description === 'string' ? fn.description : '',
+ input_schema: parameters,
+ })
+ }
+ return tools
+}
+
+function assistantBlocks(turn: IncomingTurn): AnthropicContentBlock[] {
+ const blocks: AnthropicContentBlock[] = []
+ const text = typeof turn.content === 'string' ? turn.content.trim() : ''
+ if (text) blocks.push({ type: 'text', text })
+ if (!Array.isArray(turn.toolCalls)) return blocks
+ for (const call of turn.toolCalls) {
+ const record = asRecord(call)
+ const id = typeof record?.id === 'string' ? record.id.trim() : ''
+ const name = typeof record?.name === 'string' ? record.name.trim() : ''
+ if (!record || !id || !name) continue
+ blocks.push({
+ type: 'tool_use',
+ id,
+ name,
+ input: asRecord(record.arguments) ?? {},
+ })
+ }
+ return blocks
+}
+
+function mergeAnthropic(messages: AnthropicMessage[]): AnthropicMessage[] {
+ const merged: AnthropicMessage[] = []
+ for (const message of messages) {
+ const previous = merged.at(-1)
+ if (!previous || previous.role !== message.role) {
+ merged.push(message)
+ continue
+ }
+ const left =
+ typeof previous.content === 'string' ? [{ type: 'text', text: previous.content }] : previous.content
+ const right =
+ typeof message.content === 'string' ? [{ type: 'text', text: message.content }] : message.content
+ previous.content = [...left, ...right]
+ }
+ return merged
+}
+
+/**
+ * Anthropic wants `system` as a top-level field, tool results as `user` blocks,
+ * and strictly alternating roles. Our loop already has that information; this
+ * just rearranges it.
+ */
+export function toAnthropicRequest(raw: unknown): { system?: string; messages: AnthropicMessage[] } {
+ if (!Array.isArray(raw)) return { messages: [] }
+ const system: string[] = []
+ const staged: AnthropicMessage[] = []
+
+ for (const entry of raw.slice(-MAX_CHAT_TURNS)) {
+ const turn = asTurn(entry)
+ if (!turn) continue
+ const role = String(turn.role ?? '')
+ const content = typeof turn.content === 'string' ? turn.content : ''
+ if (role === 'system') {
+ if (content.trim()) system.push(content.trim())
+ continue
+ }
+ if (role === 'tool') {
+ const toolUseId = typeof turn.toolCallId === 'string' ? turn.toolCallId.trim() : ''
+ staged.push({
+ role: 'user',
+ content: [
+ {
+ type: 'tool_result',
+ tool_use_id: toolUseId || 'missing',
+ content,
+ },
+ ],
+ })
+ continue
+ }
+ if (role === 'assistant') {
+ const blocks = assistantBlocks(turn)
+ staged.push({ role: 'assistant', content: blocks.length > 0 ? blocks : content })
+ continue
+ }
+ if (role === 'user') staged.push({ role: 'user', content })
+ }
+
+ return { ...(system.length > 0 ? { system: system.join('\n\n') } : {}), messages: mergeAnthropic(staged) }
+}
+
+/**
+ * Opus 5 thinks by default, and thinking blocks must be echoed unmodified on
+ * the next tool round or the API 400s. This proxy rebuilds messages from our
+ * `{ content, toolCalls }` shape, so thinking is switched off. Disabling is
+ * only valid at effort `high` or below.
+ */
+export function anthropicChatBody(
+ model: string,
+ converted: { system?: string; messages: AnthropicMessage[] },
+ tools: unknown[],
+): Record | null {
+ if (converted.messages.length === 0) return null
+ const anthropicTools = toAnthropicTools(tools)
+ return {
+ model,
+ max_tokens: 8192,
+ stream: true,
+ thinking: { type: 'disabled' },
+ output_config: { effort: 'high' },
+ ...converted,
+ ...(anthropicTools.length > 0 ? { tools: anthropicTools } : {}),
+ }
+}
+
+type AnthropicToolBucket = { id?: string; name?: string; arguments: string }
+
+export function eventsFromAnthropicEvent(
+ event: unknown,
+ buckets: Map,
+): ChatEvent[] {
+ const record = asRecord(event)
+ if (!record) return []
+ if (record.type === 'error') {
+ const error = asRecord(record.error)
+ return [{ error: String(error?.message ?? 'The model provider returned an error') }]
+ }
+
+ const events: ChatEvent[] = []
+ const index = typeof record.index === 'number' ? record.index : 0
+
+ if (record.type === 'content_block_start') {
+ const block = asRecord(record.content_block)
+ if (block?.type === 'tool_use') {
+ buckets.set(index, {
+ id: typeof block.id === 'string' ? block.id : undefined,
+ name: typeof block.name === 'string' ? block.name : undefined,
+ arguments: '',
+ })
+ }
+ return events
+ }
+
+ if (record.type === 'content_block_delta') {
+ const delta = asRecord(record.delta)
+ if (delta?.type === 'text_delta' && typeof delta.text === 'string' && delta.text) {
+ events.push({ text: delta.text })
+ }
+ if (delta?.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
+ const bucket = buckets.get(index) ?? { arguments: '' }
+ bucket.arguments += delta.partial_json
+ buckets.set(index, bucket)
+ }
+ return events
+ }
+
+ if (record.type === 'message_delta') {
+ const delta = asRecord(record.delta)
+ const usage = asRecord(record.usage)
+ if (delta?.stop_reason === 'tool_use') {
+ const tool_calls = finalizeToolCalls(buckets)
+ if (tool_calls.length > 0) events.push({ tool_calls })
+ buckets.clear()
+ }
+ if (typeof usage?.output_tokens === 'number') {
+ events.push({ usage: { completion_tokens: usage.output_tokens } })
+ }
+ }
+
+ return events
+}
+
+type ToolCallBucket = { id?: string; name?: string; arguments: string }
+
+export function applyOpenAiToolDelta(buckets: Map, deltas: unknown): void {
+ if (!Array.isArray(deltas)) return
+ for (const delta of deltas) {
+ if (!delta || typeof delta !== 'object') continue
+ const entry = delta as {
+ index?: unknown
+ id?: unknown
+ function?: { name?: unknown; arguments?: unknown }
+ }
+ const index = typeof entry.index === 'number' ? entry.index : 0
+ const bucket = buckets.get(index) ?? { arguments: '' }
+ if (typeof entry.id === 'string' && entry.id) bucket.id = entry.id
+ if (typeof entry.function?.name === 'string' && entry.function.name) bucket.name = entry.function.name
+ if (typeof entry.function?.arguments === 'string') bucket.arguments += entry.function.arguments
+ buckets.set(index, bucket)
+ }
+}
+
+export function finalizeToolCalls(buckets: Map): ChatToolCall[] {
+ return [...buckets.entries()]
+ .sort(([a], [b]) => a - b)
+ .flatMap(([, bucket]) => {
+ const name = bucket.name?.trim()
+ const id = bucket.id?.trim()
+ if (!name || !id) return []
+ return [{ id, name, arguments: parseToolArguments(bucket.arguments) }]
+ })
+}
+
+export function eventsFromOpenAiChunk(chunk: unknown, buckets: Map): ChatEvent[] {
+ if (!chunk || typeof chunk !== 'object') return []
+ const record = chunk as {
+ error?: { message?: unknown }
+ usage?: { completion_tokens?: unknown; total_tokens?: unknown }
+ choices?: { delta?: { content?: unknown; tool_calls?: unknown }; finish_reason?: unknown }[]
+ }
+ if (record.error?.message) return [{ error: String(record.error.message) }]
+
+ const events: ChatEvent[] = []
+ const choice = record.choices?.[0]
+ const content = choice?.delta?.content
+ if (typeof content === 'string' && content) events.push({ text: content })
+ if (choice?.delta?.tool_calls) applyOpenAiToolDelta(buckets, choice.delta.tool_calls)
+ if (choice?.finish_reason === 'tool_calls') {
+ const tool_calls = finalizeToolCalls(buckets)
+ if (tool_calls.length > 0) events.push({ tool_calls })
+ buckets.clear()
+ }
+ if (record.usage) {
+ events.push({
+ usage: {
+ completion_tokens:
+ typeof record.usage.completion_tokens === 'number' ? record.usage.completion_tokens : undefined,
+ total_tokens: typeof record.usage.total_tokens === 'number' ? record.usage.total_tokens : undefined,
+ },
+ })
+ }
+ return events
+}
+
+function lastUserText(messages: OpenAiMessage[]): string {
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
+ const message = messages[index]
+ if (message?.role === 'user' && typeof message.content === 'string') return message.content
+ }
+ return ''
+}
+
+function lastToolText(messages: OpenAiMessage[]): string {
+ const last = messages.at(-1)
+ if (last?.role === 'tool' && typeof last.content === 'string') return last.content
+ return ''
+}
+
+function toolNames(tools: unknown): Set {
+ const names = new Set()
+ if (!Array.isArray(tools)) return names
+ for (const tool of tools) {
+ if (!tool || typeof tool !== 'object') continue
+ const name = (tool as { function?: { name?: unknown } }).function?.name
+ if (typeof name === 'string' && name) names.add(name)
+ }
+ return names
+}
+
+/**
+ * A tiny stand-in so the hosted path can be exercised without a provider key.
+ * It calls `calculator` for a sum and otherwise answers in one sentence.
+ */
+export function mockChatEvents(messages: OpenAiMessage[], tools: unknown): ChatEvent[] {
+ const names = toolNames(tools)
+ const toolText = lastToolText(messages)
+ if (toolText)
+ return [{ text: toolText.slice(0, 800) }, { usage: { completion_tokens: 16, total_tokens: 48 } }]
+
+ const asked = lastUserText(messages)
+ if (names.has('calculator') && /[\d.][\d.\s]*[+\-*/][\d.\s]+/.test(asked)) {
+ const expression =
+ asked
+ .match(/[\d.+\-*/() \t]+/g)
+ ?.find((part) => /[+\-*/]/.test(part))
+ ?.trim() ?? asked
+ return [
+ {
+ tool_calls: [{ id: 'mock_calc', name: 'calculator', arguments: { expression } }],
+ },
+ ]
+ }
+
+ const text = asked.trim()
+ ? `I can help with that. For facts I will search or read a page rather than guess.`
+ : 'Hello. Ask a question and I will use tools when I need a fact.'
+ return [{ text }, { usage: { completion_tokens: 24, total_tokens: 40 } }]
+}
+
+function writeSse(res: ServerResponse, event: ChatEvent): void {
+ res.write(`data: ${JSON.stringify(event)}\n\n`)
+}
+
+function sendJson(res: ServerResponse, status: number, payload: unknown): void {
+ const body = JSON.stringify(payload)
+ res.statusCode = status
+ res.setHeader('content-type', 'application/json; charset=utf-8')
+ res.end(body)
+}
+
+function openSse(res: ServerResponse): void {
+ res.statusCode = 200
+ res.setHeader('content-type', 'text/event-stream; charset=utf-8')
+ res.setHeader('cache-control', 'no-cache')
+ res.setHeader('connection', 'keep-alive')
+ res.setHeader('x-accel-buffering', 'no')
+}
+
+async function relayOpenAiStream(response: Response, res: ServerResponse): Promise {
+ if (!response.body) throw new Error('The model provider returned an empty body')
+ const reader = response.body.getReader()
+ const decoder = new TextDecoder()
+ const buckets = new Map()
+ let buffer = ''
+
+ for (;;) {
+ const { done, value } = await reader.read()
+ if (done) break
+ buffer += decoder.decode(value, { stream: true })
+ const frames = buffer.split('\n\n')
+ buffer = frames.pop() ?? ''
+ for (const frame of frames) {
+ for (const line of frame.split('\n')) {
+ const data = line.startsWith('data:') ? line.slice(5).trim() : ''
+ if (!data || data === '[DONE]') continue
+ let chunk: unknown
+ try {
+ chunk = JSON.parse(data) as unknown
+ } catch {
+ continue
+ }
+ for (const event of eventsFromOpenAiChunk(chunk, buckets)) writeSse(res, event)
+ }
+ }
+ }
+
+ const leftover = finalizeToolCalls(buckets)
+ if (leftover.length > 0) writeSse(res, { tool_calls: leftover })
+}
+
+function asToolSchemas(raw: unknown): unknown[] {
+ return Array.isArray(raw) ? raw.slice(0, 32) : []
+}
+
+/**
+ * Answers `POST /api/chat` by streaming `ChatEvent` frames. Callers must not
+ * have written to `res` yet.
+ */
+export async function handleChatRequest(body: unknown, res: ServerResponse): Promise {
+ const config = chatConfig()
+ if (!config) {
+ sendJson(res, 503, {
+ error: 'Hosted chat is not configured. Set ANTHROPIC_API_KEY on the tool proxy.',
+ })
+ return
+ }
+
+ const payload = body && typeof body === 'object' ? (body as Record) : {}
+ const tools = asToolSchemas(payload.tools)
+ const openaiMessages = toOpenAiMessages(payload.messages)
+ if (openaiMessages.length === 0 && toAnthropicRequest(payload.messages).messages.length === 0) {
+ sendJson(res, 400, { error: 'Missing messages' })
+ return
+ }
+
+ if (config.provider === 'mock') {
+ openSse(res)
+ for (const event of mockChatEvents(openaiMessages, tools)) writeSse(res, event)
+ res.end()
+ return
+ }
+
+ if (config.provider === 'anthropic') {
+ await streamAnthropic(config, payload.messages, tools, res)
+ return
+ }
+
+ await streamOpenAi(config, openaiMessages, tools, res)
+}
+
+async function streamAnthropic(
+ config: ChatConfig,
+ rawMessages: unknown,
+ tools: unknown[],
+ res: ServerResponse,
+): Promise {
+ const converted = toAnthropicRequest(rawMessages)
+ const request = anthropicChatBody(config.model, converted, tools)
+ if (!request) {
+ sendJson(res, 400, { error: 'Missing messages' })
+ return
+ }
+
+ const upstream = await callProvider(
+ `${config.baseUrl}/v1/messages`,
+ {
+ 'x-api-key': config.apiKey,
+ 'anthropic-version': ANTHROPIC_VERSION,
+ 'content-type': 'application/json',
+ accept: 'text/event-stream',
+ },
+ request,
+ res,
+ )
+ if (!upstream) return
+
+ openSse(res)
+ try {
+ await relayAnthropicStream(upstream, res)
+ } catch (error) {
+ writeSse(res, { error: error instanceof Error ? error.message : 'The model stream failed' })
+ }
+ res.end()
+}
+
+async function streamOpenAi(
+ config: ChatConfig,
+ messages: OpenAiMessage[],
+ tools: unknown[],
+ res: ServerResponse,
+): Promise {
+ const request: Record = {
+ model: config.model,
+ messages,
+ stream: true,
+ stream_options: { include_usage: true },
+ temperature: 0.7,
+ }
+ if (tools.length > 0) {
+ request.tools = tools
+ request.tool_choice = 'auto'
+ }
+
+ const upstream = await callProvider(
+ `${config.baseUrl}/chat/completions`,
+ {
+ authorization: `Bearer ${config.apiKey}`,
+ 'content-type': 'application/json',
+ accept: 'text/event-stream',
+ },
+ request,
+ res,
+ )
+ if (!upstream) return
+
+ openSse(res)
+ try {
+ await relayOpenAiStream(upstream, res)
+ } catch (error) {
+ writeSse(res, { error: error instanceof Error ? error.message : 'The model stream failed' })
+ }
+ res.end()
+}
+
+async function callProvider(
+ url: string,
+ headers: Record,
+ request: Record,
+ res: ServerResponse,
+): Promise {
+ let upstream: Response
+ try {
+ upstream = await fetch(url, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(request),
+ signal: AbortSignal.timeout(CHAT_TIMEOUT_MS),
+ })
+ } catch (error) {
+ sendJson(res, 502, {
+ error: error instanceof Error ? error.message : 'The model provider could not be reached',
+ })
+ return null
+ }
+
+ if (!upstream.ok) {
+ let detail = `The model provider responded with ${upstream.status}`
+ try {
+ const failed = (await upstream.json()) as { error?: { message?: string }; message?: string }
+ if (failed.error?.message) detail = failed.error.message
+ else if (failed.message) detail = failed.message
+ } catch {
+ // Keep the status line when the body is not JSON.
+ }
+ sendJson(res, 502, { error: detail })
+ return null
+ }
+ return upstream
+}
+
+async function relayAnthropicStream(response: Response, res: ServerResponse): Promise {
+ if (!response.body) throw new Error('The model provider returned an empty body')
+ const reader = response.body.getReader()
+ const decoder = new TextDecoder()
+ const buckets = new Map()
+ let buffer = ''
+
+ for (;;) {
+ const { done, value } = await reader.read()
+ if (done) break
+ buffer += decoder.decode(value, { stream: true })
+ const frames = buffer.split('\n\n')
+ buffer = frames.pop() ?? ''
+ for (const frame of frames) {
+ for (const line of frame.split('\n')) {
+ const data = line.startsWith('data:') ? line.slice(5).trim() : ''
+ if (!data || data === '[DONE]') continue
+ let event: unknown
+ try {
+ event = JSON.parse(data) as unknown
+ } catch {
+ continue
+ }
+ for (const next of eventsFromAnthropicEvent(event, buckets)) writeSse(res, next)
+ }
+ }
+ }
+
+ const leftover = finalizeToolCalls(buckets)
+ if (leftover.length > 0) writeSse(res, { tool_calls: leftover })
+}
diff --git a/tools/vite-plugin-agent-api.ts b/tools/vite-plugin-agent-api.ts
index de5fca2..c0fd860 100644
--- a/tools/vite-plugin-agent-api.ts
+++ b/tools/vite-plugin-agent-api.ts
@@ -2,11 +2,13 @@ import type { Connect, Plugin } from 'vite'
import { handleAgentApiRequest } from './agent-api.ts'
/**
- * Serves `/api/search` and `/api/fetch` from the Vite dev and preview servers.
+ * Serves `/api/search`, `/api/fetch`, and `/api/chat` from the Vite dev and
+ * preview servers.
*
* The production Pages build does not include this plugin's routes — there is
* no Node process there. Locally it is how DuckDuckGo search and page reads
- * skip CORS without going through the reader.
+ * skip CORS without going through the reader, and how a mock or real model key
+ * can host chat without Railway.
*/
export function agentApi(): Plugin {
const middleware: Connect.NextHandleFunction = (req, res, next) => {