Skip to content

WIP: agent playground with a model × harness picker - #70

Draft
SPIKESPIGEL404 wants to merge 3 commits into
mainfrom
claude/bitrouter-chat-playground-ai-sdk-ed2e1f
Draft

WIP: agent playground with a model × harness picker#70
SPIKESPIGEL404 wants to merge 3 commits into
mainfrom
claude/bitrouter-chat-playground-ai-sdk-ed2e1f

Conversation

@SPIKESPIGEL404

Copy link
Copy Markdown
Contributor

Draft — not ready to merge. See Before this ships below.

Adds /chat: one playground where both axes are chosen per turn — the model BitRouter routes to, and the harness driving the agent loop. Replaces the earlier split between /chat and /chat/harness.

The harness axis

A flat registry (lib/harnesses.ts) rendered as one picker. Entries that cannot run stay visible with the reason rather than being hidden — the picker is the clearest statement of what the product is, so an unconfigured option should still show.

Entry What it is
ai-sdk streamText loop + docs tools, no server session
pi in-process runtime on @ai-sdk/sandbox-just-bash
bitrouter-claude-acp / -codex-acp / -gemini-cli / -pi-acp BitRouter's own catalog over ACP, in a sandbox VM
claude-code / codex / opencode listed; run against the vendor's own API

The BitRouter entries are the point. @ai-sdk/harness-acp is the AI SDK's generic adapter — @ai-sdk/harness-grok-build is nothing but a createACP() call — and bitrouter acp serve speaks vanilla ACP over stdio. So one createACP() per catalog id turns BitRouter's agents into AI SDK harnesses whose traffic is routed rather than going direct to a vendor. BitRouter · Claude Code and Claude Code sit side by side deliberately: same runtime, different endpoint.

Notable design points

  • Shared session pool (lib/harness-sessions.ts) — process-local and evictable by design, which one long-lived next start on Railway supports. Documented as such rather than pretending to durability.
  • Changing either axis rebuilds the runtime, so the client starts a fresh chat instead of showing a transcript the agent has no memory of (resetsHistory).
  • Sandbox behind one module (lib/harness-sandbox.server.ts) so the intended Railway swap is a single edit.

Verified

Typecheck clean, 129 tests, build compiling, picker checked in-browser. End-to-end against real Vercel Sandboxes, stages 1–3 (plan §8):

1 provision          PASS  Amazon Linux 2023, node 24.14.1, glibc 2.34
2 install bitrouter  PASS  1.0.0-alpha.27 runs (~24s)
3 ACP handshake      PASS  protocolVersion 1, agentInfo "Claude Code" 0.16.2

Two findings from that run are baked into the code:

  • xz is missing from the sandbox image. glibc 2.34 < 2.35 means bitrouter falls back to its musl .tar.xz, which tar cannot unpack. Fixable in onBootstrap — which runs after the adapter's bootstrap — only because pnpm 10 ignores bitrouter's build script, so the binary is fetched lazily on first spawn. Verified in production order.
  • @vercel/sandbox never reads VERCEL_TOKEN/PROJECT_ID/TEAM_ID. They appear only in its README as values a caller passes explicitly. They are now threaded through, and the local OIDC path is recognised.

Before this ships

  • No full harness turn has run. Stage 3 used --direct, so the routed path (--base-url + key), whether --model accepts a raw CHAT_MODELS id, and the bridge's WebSocket dial are all unexercised.
  • Auth and billing. /api/chat/playground is unauthenticated and spends the shared key; the BitRouter entries are a VM per chat session. Keep the sandbox credentials unset until this is addressed.
  • Decide plan §4.4 — whether BitRouter grows session/resume. The playground does not need it; recommendation is no, plus a two-line masking fix in bitrouter.

Known issues (deliberate)

  • bitrouter-gemini-cli is listed but permanently unavailable: it sends a non-Bearer key the router rejects, and is deprecated upstream. Encoded as knownIssue rather than shipped broken.
  • Pre-existing typecheck errors in components/ai/search.tsx (buttonVariants({ color })) and components/markdown.tsx are untouched.

Plan and full findings: docs/bitrouter-acp-harness-plan.md

🤖 Generated with Claude Code

SPIKESPIGEL404 and others added 2 commits August 9, 2026 16:18
Adds /chat: one playground where both axes are chosen per turn — the model
BitRouter routes to, and the harness driving the agent loop.

Harnesses are a flat registry (lib/harnesses.ts) rendered as a single picker.
Unavailable entries stay visible with the reason they cannot run, because the
picker is the clearest statement of what the product is.

- ai-sdk        streamText loop + docs tools, no server session
- pi            in-process runtime on @ai-sdk/sandbox-just-bash
- bitrouter-*   BitRouter's own catalog over ACP, in a sandbox VM
- claude-code / codex / opencode  listed, vendor-API entries

The BitRouter entries are the point: @ai-sdk/harness-acp is the AI SDK's
generic adapter, and `bitrouter acp serve` speaks vanilla ACP over stdio, so
one createACP() per catalog id turns BitRouter's agents into AI SDK harnesses
with their traffic routed rather than going direct to a vendor.

Session-backed harnesses share one pool (lib/harness-sessions.ts): process-local
and evictable by design, which one long-lived `next start` on Railway supports.
Changing either axis rebuilds the runtime, so the client starts a fresh chat
rather than showing a transcript the agent has no memory of.

Verified end-to-end against real Vercel Sandboxes (stages 1-3; see
docs/bitrouter-acp-harness-plan.md §8). Two findings are baked into the code:
`xz` is missing from the sandbox image and must be installed in onBootstrap,
and @vercel/sandbox does not read the VERCEL_* env vars — they are threaded
through explicitly.

WIP: no full harness turn has run yet (needs router credentials and spends
model tokens), and the gemini-cli entry is listed but permanently unavailable —
it sends a non-Bearer key the router rejects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A turn completes end to end on codex-acp (stop_reason=EndTurn, 4.7s), which
settles the routed path, --model, and the bridge's WebSocket dial.

Three blockers found, none in the AI SDK integration:
- anthropic/claude-haiku-4.5 and qwen/qwen3.8-max have no tools-capable
  provider, so they cannot back any ACP harness
- --model does not take effect for claude-acp (ANTHROPIC_MODEL is ignored by
  claude-code-acp 0.16.2); codex-acp is unaffected
- host tools reach the sandbox but are not surfaced into the agent's toolset

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@SPIKESPIGEL404

Copy link
Copy Markdown
Contributor Author

Stage 4 findings are recorded in the plan (docs/bitrouter-acp-harness-plan.md §8.4). A turn now completes end to end on codex-acp:

acp turn completed agent=codex-acp stop_reason=EndTurn latency_ms=4666

That settles the routed path, --model, and the bridge's WebSocket dial.

Three blockers remain, all upstream of this PR rather than in the AI SDK wiring:

  1. Model capability — filed as Some catalog models have no tools-capable provider, which blocks every ACP harness bitrouter#783. anthropic/claude-haiku-4.5 and qwen/qwen3.8-max reject any request with tools, so they cannot back any ACP harness.
  2. --model ignored by claude-acp — BitRouter sets ANTHROPIC_MODEL, but claude-code-acp@0.16.2 does not honour it and uses its own default. codex-acp is unaffected (model goes via args).
  3. Host tools do not reach the modelcodex-acp passes the bridge's MCP catalog gate but the tool is never surfaced into its toolset, so the docs tools are unavailable. This is the one that blocks shipping.

Given (2) and (3), I'd narrow the shipped set to codex-acp and mark the other three knownIssue before this leaves draft.

The playground spent one shared BITROUTER_API_KEY on behalf of anonymous
visitors, read from process.env at each point of use. That is the reason
Pi and the ACP harnesses ship dark: a live agent session per visitor,
with a shell and a sandbox VM behind it, and nothing capping the spend.

Introduce lib/playground-credential.ts as the single place the playground
learns who is paying, and make everything downstream take a resolved
credential as an argument. Two implementations:

  byo-key  (default) BITROUTER_API_KEY from the environment. A clone with
           nothing configured behaves exactly as before.
  session            forwards the visitor's console session cookie to the
           console's token endpoint, server-side, and receives a
           short-lived credential scoped to them. Keeping the forward on
           the server matters: a minted bra_ is a signed JWT the router
           verifies with no round trip, so nothing can revoke it before it
           expires, and it must not reach page JavaScript.

resolveCredential throws CredentialError with the status the caller should
send, so the route can tell "not signed in" (401) from "grant spent" (402)
from "console down" (503) from "misconfigured deployment" (500) — three
cases the playground has to report differently.

Session-backed harnesses hold their token in a process environment with no
refresh path, so the credential is bound when the agent is built. The pool
holds it, revokes it on teardown, and rebuilds rather than hand back a
session whose next turn would 401.

The ACP change is the subtle one. forwardEnv names variables on *this*
process, so it can only ever forward the house key. The adapter's `env`
option carries a value, and is documented as persisting it "in bootstrap
and lifecycle compatibility identity" — but that value reaches only
implementationIdentity, a hash gating resume-compatibility within one
session, where the credential is fixed. It does not reach the sandbox's
implementation.json (env keys only) or the bootstrap file set, so the
snapshot BOOTSTRAP_HASH gates stays shared across visitors instead of
paying a cold `dnf install xz` per session.

Availability in harnesses.server.ts is now mode-aware — session mode has no
BITROUTER_API_KEY, and every harness would otherwise have greyed out.

Phase 1 ships gate-don't-degrade, the opposite of the plan's §5.2. The
anonymous house-key fallback is only safe behind an IP rate limit and a
restricted model set, both later phases; shipping it now would mean an
uncapped house key on an unauthenticated page. Recorded in the plan with
the two places to reopen it.

Nothing mints yet — the console endpoint is phase 2. Verified locally that
byo-key reaches the router unchanged and that session mode 401s a
signed-out request.

Testing the seam needed a server-only stub alias in vitest.config.ts; that
module is a Next build-time marker with no npm package behind it, which is
why this repo's server surface had no tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@SPIKESPIGEL404

Copy link
Copy Markdown
Contributor Author

Phase 1: a per-visitor credential seam (e14bfbd)

The playground spent one shared BITROUTER_API_KEY on behalf of anonymous visitors, read from process.env at each point of use. That's the reason Pi and the ACP harnesses ship dark — a live agent session per visitor, with a shell and a sandbox VM behind it, and nothing capping the spend.

lib/playground-credential.ts is now the only place the playground learns who is paying. Everything downstream takes a resolved credential as an argument.

Mode Where spend authority comes from
byo-key (default) BITROUTER_API_KEY from the environment. A clone with nothing configured behaves exactly as before.
session Forwards the visitor's console session cookie to the console's token endpoint, server-side, and receives a short-lived credential scoped to them.

The forward stays on the server deliberately. A minted bra_ is a signed JWT the router verifies with no round trip, so nothing can revoke it before it expires — worth an extra hop to keep it out of page JavaScript.

resolveCredential throws CredentialError carrying the status the caller should send, so the route distinguishes 401 not signed in / 402 grant spent / 503 console down / 500 misconfigured deployment. The playground has to report those differently.

The ACP change is the subtle one

forwardEnv names variables on this process, so it can only ever forward the house key — it structurally cannot carry a per-visitor token. The adapter's env option can, and it's documented as persisting values "in bootstrap and lifecycle compatibility identity", which reads alarming. Traced it:

  • the value reaches only implementationIdentity, a hash gating resume-compatibility within one session, where the credential is fixed;
  • it does not reach the sandbox's implementation.json (env keys only) or the bootstrap file set.

So the snapshot BOOTSTRAP_HASH guards stays shared across visitors instead of paying a cold dnf install xz per session. The adapter also rejects a key present in both options, so this had to be a swap rather than an addition.

Also in here

  • Session lifecycle — the pool holds the credential, revokes it on teardown, and rebuilds rather than hand back a session whose next turn would 401. These runtimes bake the token into a process environment with no refresh path, so it can't be swapped mid-session.
  • harnesses.server.ts is mode-aware. Latent bug: session mode has no BITROUTER_API_KEY, so every harness would have greyed out.
  • 20 tests over the seam — mint, attribution, the status split, malformed responses, revocation. This needed a server-only stub alias in vitest.config.ts; that module is a Next build-time marker with no npm package behind it, which is why this repo's server surface had no tests until now.

Verified locally

  • byo-key — no gate, composer enabled, turn reaches api.bitrouter.ai/v1/chat/completions carrying the env key. Unchanged for a fork.
  • session, signed out — composer disabled with a sign-in link, and POST /api/chat/playground returns 401 {"error":"Sign in to use the playground."}.
  • session availability — Pi available with no API key set; ACP entries fall through to the sandbox reason.

149 tests pass; no new type errors (the 7 remaining are pre-existing, in components/ai/search.tsx and components/markdown.tsx).

One deliberate divergence

Phase 1 ships gate, don't degrade — the opposite of the plan's §5.2, which wanted signed-out visitors to fall back to the house key on ai-sdk.

That fallback is only safe behind an IP rate limit and a restricted model set, both later phases. Shipping it now would mean an uncapped house key on an unauthenticated page — the exact thing this plan exists to end. Gating is the conservative state to hold meanwhile, and reopening it is a change to two places, recorded in §3.4.

Not in here

Nothing mints yet. The console's POST /api/playground/token is phase 2, in bitrouter-cloud. Until it exists, PLAYGROUND_CREDENTIAL_MODE stays byo-key and this commit is a pure refactor in behaviour terms.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant