Skip to content

growth(day-31): @byok-relay/next — Next.js App Router route handler factory, middleware, React hooks, ByokRelayClient - #67

Open
alokit-bot wants to merge 8 commits into
mainfrom
feat/growth-2026-07-08
Open

growth(day-31): @byok-relay/next — Next.js App Router route handler factory, middleware, React hooks, ByokRelayClient#67
alokit-bot wants to merge 8 commits into
mainfrom
feat/growth-2026-07-08

Conversation

@alokit-bot

@alokit-bot alokit-bot commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Growth Day 31 — @byok-relay/next

Next.js App Router integration for BYOK AI relay. Targets the largest React meta-framework — Next.js 13+ App Router.

Four concerns

  1. createRelayRouteHandler(opts) — returns { GET, POST, PUT, PATCH, DELETE, OPTIONS } for app/api/relay/[...path]/route.js. RELAY_URL stays server-only in process.env.
  2. createRelayMiddleware(opts) — Next.js middleware.js export; Edge Runtime compatible.
  3. React hooks (useByokRelay, useChat, useStreamingChat, useRelayHealth) — for 'use client' components.
  4. ByokRelayClient — plain-JS class for Server Components, Server Actions, API Routes; custom storage adapter for cookie/session stores.

Tests

25 passed, 0 failed

Files changed

  • packages/next/ — new package
  • llms.txt — Next.js App Router section
  • README.md — @byok-relay/next section after Hono
  • metrics/daily.jsonl — 2026-07-08 snapshot

Next for Avi

cd packages/next && npm publish --access public

Summary by CodeRabbit

  • New Features
    • Added integrations for Angular, Astro, Hono, Next.js, Preact, Remix, Solid, and Vercel AI, including client hooks, server routes, and relay helpers.
    • Expanded framework documentation with setup, usage, SSR-safe guidance, and self-hosting examples.
  • Bug Fixes
    • Improved relay request handling across frameworks with safer streaming, cancellation, and error handling.
    • Added health-check and token persistence flows for more reliable app startup and usage.
  • Chores
    • Updated repository packaging to support workspace-based package management.
    • Added smoke tests and sample metrics data.

packages/solid/src/index.js: four stores with SolidJS signal contract ([getter,setter]):
- createByokRelayStore: token registration + key CRUD + logout (localStorage
  persistence, SolidStart SSR-safe via typeof window guard)
- createChatStore: stateful message list, non-streaming,
  openai/anthropic/groq/mistral/openrouter, systemPrompt + extraParams
- createStreamingChatStore: SSE streaming with AbortController,
  stopStreaming(), streamingContent() live signal, partial-commit on abort
- createRelayHealthStore: polls /health on configurable interval,
  refetch(), destroy() for onCleanup() lifecycle management

Signal shim: all stores work without solid-js installed (signals degrade to
plain getter/setter pairs for testing and non-Solid environments).

20 smoke tests passing (node test/stores.test.js).

packages/solid/README.md: full API docs, SolidJS quick-start, SolidStart
SSR guide, all four stores documented, provider table, self-hosting section,
related packages table.

llms.txt: SolidJS Signals section + npm link.
README.md: SolidJS stores section before 'For AI coding agents' with install
+ streaming example + links to other framework packages.
package.json: workspaces added.
metrics/daily.jsonl: 2026-07-03 snapshot (stars=52 forks=0 watchers=1 clones=110 views=20).

Next for Avi: cd packages/solid && npm publish --access public
… BYOK AI

packages/angular/src/index.js: four Angular injectable services:
- ByokRelayService (token registration + key CRUD + logout, localStorage persistence, Analog SSR-safe in-memory fallback)
- ChatService (stateful message list, non-streaming, openai/anthropic/groq/mistral/openrouter, systemPrompt + extraParams, rollback on error)
- StreamingChatService (SSE streaming with AbortController, stopStreaming(), streamingContent() live signal, partial-commit on abort)
- RelayHealthService (polls /health, check(deep=true) for upstream ping, destroy() for ngOnDestroy cleanup)

Angular signal shim: services use Angular 16+ signal() when @angular/core is detected; plain getter/setter shim otherwise — same reactive API in both cases.

provideByokRelay(config): makeEnvironmentProviders() with all four services wired together via deps when @angular/core is installed; plain createByokRelayBundle() otherwise (for testing and standalone use).

createByokRelayBundle(config): factory function for standalone / non-Angular usage (Analog islands, unit tests, vanilla JS).

29 smoke tests passing (node test/services.test.js, no DOM, no @angular/core required):
- ByokRelayService: 12 tests (register, getOrRegister, storeKey, listKeys, deleteKey, rotateKey, logout, error signals)
- ChatService: 5 tests (send, rollback, clear)
- StreamingChatService: 4 tests (stream, stopStreaming partial-commit)
- RelayHealthService: 5 tests (check, deep check, error, polling lifecycle)
- createByokRelayBundle: 2 tests (service wiring)

packages/angular/README.md: full API docs, Angular 14+ quick-start, Angular 16+ signals note, Analog SSR guide, provider table, self-hosting section, related packages table.
llms.txt: Angular Injectable Services section + npm link.
README.md: Angular services section after SolidJS stores.
package.json: workspaces already includes packages/*.
metrics/daily.jsonl: 2026-07-04 snapshot (stars=52 forks=0 watchers=1 clones=112 views=22).

Next for Avi: cd packages/angular && npm publish --access public
… adapter

Implements LanguageModelV1 spec so byok-relay can be used as a first-class
provider with generateText, streamText, generateObject, and all AI SDK tools.

packages/vercel-ai/src/index.js:
- createByokRelayProviderSync(config): synchronous factory, lazy token registration;
  module-scope safe for Next.js / SvelteKit / Nuxt initialisation
- createByokRelayProvider(config): async factory, eager token registration
- createLanguageModel(): LanguageModelV1 implementation — specificationVersion=v1,
  doGenerate() (non-streaming, full OpenAI response parsing, tool_calls, finish_reason),
  doStream() (SSE ReadableStream, text-delta / tool-call-delta / finish / error parts)
- parseModelId(): 'provider/model' or bare model (default: openai)
- promptToMessages(): AI SDK LanguageModelV1Prompt → OpenAI messages array,
  handles system/user/assistant/tool roles, multi-part content, image_url conversion
- provider.storeKey(provider, apiKey): store user's API key in relay
- provider.health(deep?): relay liveness/readiness check
- provider.stats(appId?): per-user usage stats
- provider.deleteAccount(): GDPR erasure

packages/vercel-ai/test/smoke.test.js: 48 smoke tests, all passing
packages/vercel-ai/README.md: full API docs, Next.js/SvelteKit/Nuxt examples,
  all supported providers, AI SDK feature matrix, related packages table
llms.txt: Vercel AI SDK Custom Provider section + npm link
README.md: Vercel AI SDK section after Angular (before 'For AI coding agents')

metrics/daily.jsonl: 2026-07-04 snapshot (stars=52 forks=0 watchers=1 clones=112 views=22)
Next for Avi: cd packages/vercel-ai && npm publish --access public
packages/preact/src/index.js: four hooks with runtime hook resolution (preact/hooks
→ react → inline shim): useByokRelay (token registration + key CRUD + logout,
SSR-safe localStorage), useChat (stateful non-streaming, openai/anthropic/groq/
mistral/openrouter, systemPrompt + extraParams), useStreamingChat (SSE streaming
with AbortController, stopStreaming(), streamingContent live string, partial-commit
on abort), useRelayHealth (polls /health, check(deep?), intervalMs control).
Hook shim ensures the package works in test/SSR environments with no preact installed.

24 smoke tests passing. packages/preact/README.md: full API docs, Astro component
island quick-start, all four hooks documented, provider table, self-hosting section,
related packages table. llms.txt: Preact Hooks section + npm link. README.md: Preact
hooks section after Angular (before 'Vercel AI SDK') with Astro island code example.
metrics/daily.jsonl: 2026-07-05 snapshot (stars=52 forks=0 watchers=1 clones=112
views=22). **Next for Avi:** cd packages/preact && npm publish --access public.
…okRelayClient (Growth Day 28)

- packages/astro/src/index.js: createByokRelayMiddleware (onRequest handler, proxies
  /api/relay/* server-side, keepss RELAY_URL private), createRelayApiRoute (catch-all
  Astro API route factory, all HTTP methods, optional app_id whitelist),
  ByokRelayClient (plain-JS class for <script> blocks / View Transitions:
  register, ensureToken, logout, storeKey, listKeys, deleteKey, rotateKey,
  chat, streamChat with AbortController, health, stats, getModels, deleteAccount).
  SSR-safe window.localStorage fallback for server render context.
- packages/astro/README.md: full API docs, SSR quick-start (API route + script block),
  middleware quick-start, static/client-only mode, streaming example (View Transitions),
  ByokRelayClient API reference table, provider table, related packages table.
- llms.txt: Astro Integration section + npm link (before Links).
- README.md: @byok-relay/astro section before Preact hooks with API route + script block example.
- metrics/daily.jsonl: 2026-07-06 snapshot (stars=52 forks=0 watchers=1).
- 33 smoke tests passing (node test/integration.test.js).
Next for Avi: cd packages/astro && npm publish --access public
…+action factories, React hooks, ByokRelayClient

packages/remix/src/index.js: createRelayLoader (Remix LoaderFunction catch-all; maps params['*'] to relay sub-path; hop-by-hop header filtering; optional app_id allowlist; 403 on disallowed app), createRelayAction (ActionFunction; forwards POST/PUT/PATCH/DELETE with original body; optional app_id allowlist), useByokRelay (token registration + key CRUD + logout, localStorage persistence, auto-register on mount), useChat (stateful non-streaming, openai/anthropic/groq/mistral/openrouter, systemPrompt + extraParams), useStreamingChat (SSE streaming with AbortController, stopStreaming(), streamingContent live string, partial-commit on abort), useRelayHealth (polls /health, refetch(), check(deep?) readiness probe, intervalMs control), ByokRelayClient plain-JS class (register, ensureToken, logout, storeKey, listKeys, deleteKey, rotateKey, chat, streamChat with onChunk/onDone callbacks, health/deep, stats, getModels, deleteAccount — works in both loaders (server) and browser scripts). 32 smoke tests passing (node test/remix.test.js). packages/remix/README.md: full API docs, Remix v2 quick-start (createRelayLoader + createRelayAction catch-all route + window.ENV pattern), React Router v7 framework mode guide, all hooks documented with props/return tables, ByokRelayClient method table, provider table, self-hosting note, related packages table. llms.txt: Remix / React Router v7 Integration section + npm link. README.md: @byok-relay/remix section before Astro SSR integration with loader+action + useStreamingChat examples. metrics/daily.jsonl: 2026-07-06 snapshot (stars=52 forks=0 watchers=1 clones=105 views=24). Key differentiator vs @byok-relay/react: server-side loader/action pattern keeps RELAY_URL in process.env only; browser never sees the upstream relay URL — meaningful security improvement for production Remix deployments. Next for Avi: cd packages/remix && npm publish --access public.
…r Cloudflare Workers, Deno, Bun

packages/hono/src/index.js: createByokRelayMiddleware (Hono MiddlewareHandler; intercepts
pathPrefix; reads RELAY_URL from c.env binding so it stays server-side; hop-by-hop header
filtering; optional app_id allowlist); createRelayRoute (Hono Handler for explicit catch-all
route registration; all HTTP methods; optional allowlist); ByokRelayClient plain-JS class
(register, ensureToken, logout, storeKey, listKeys, deleteKey, rotateKey, relayRequest, chat,
streamChat async generator with AbortSignal support, health, stats, getModels, deleteAccount;
in-memory storage on edge runtimes with optional custom adapter for Workers KV / Deno KV).
24 smoke tests passing (node test/middleware.test.js). packages/hono/README.md: full API docs,
Cloudflare Workers quick-start (c.env.RELAY_URL binding, never in bundle), Bun/Node.js explicit
route quick-start, Deno Deploy quick-start, middleware vs route factory API tables, streaming
chat in Workers TransformStream example, all provider table, self-hosting note, related packages
table. llms.txt: Hono Middleware section (createByokRelayMiddleware + createRelayRoute +
ByokRelayClient, Workers + Bun snippets, npm link). README.md: Hono middleware section after
Vercel AI SDK (before 'For AI coding agents') with Workers binding example + Bun/Node catch-all
example. metrics/daily.jsonl: 2026-07-07 snapshot (stars=52 forks=0 watchers=1 clones=105
views=24). Key differentiator vs astro/remix: targets stateless edge runtimes (Cloudflare
Workers, Deno Deploy) where localStorage is unavailable; c.env binding keeps RELAY_URL in
Workers Secrets, never in the bundle; custom storage adapter enables Workers KV persistence.
Next for Avi: cd packages/hono && npm publish --access public.
…actory, middleware, React hooks, ByokRelayClient
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds eight new framework integration packages under packages/ (Angular, Astro, Hono, Next.js, Preact, Remix, Solid, Vercel AI SDK), each with a BYOK relay client/hooks/services implementation, package manifest, README, and Node-based smoke tests. Root README.md and llms.txt are extended with corresponding integration guides, root package.json gains a workspaces field, and metrics/daily.jsonl receives new dated metric records.

Changes

Root documentation and monorepo configuration

Layer / File(s) Summary
Root integration guides
README.md, llms.txt
Adds install/usage sections for SolidJS, Angular, Remix, Astro, Preact, Vercel AI SDK, Hono, and Next.js integrations.
Workspace config and metrics
package.json, metrics/daily.jsonl
Adds workspaces: ["packages/*"] and populates dated repository metric snapshots.

Framework integration packages (eight independent packages, each following the same pattern)

Layer / File(s) Summary
Angular
packages/angular/*
Injectable services (ByokRelayService, ChatService, StreamingChatService, RelayHealthService), signal shim, DI provider factory, manifest, README, tests.
Astro
packages/astro/*
createByokRelayMiddleware, createRelayApiRoute, ByokRelayClient, manifest, README, integration tests.
Hono
packages/hono/*
createByokRelayMiddleware, createRelayRoute, ByokRelayClient with SSE streaming, manifest, README, tests.
Next.js
packages/next/*
createRelayRouteHandler, createRelayMiddleware, React hooks, ByokRelayClient, manifest, README, tests.
Preact
packages/preact/*
useByokRelay, useChat, useStreamingChat, useRelayHealth hooks with SSR-safe storage, manifest, README, tests.
Remix
packages/remix/*
createRelayLoader, createRelayAction, React hooks, ByokRelayClient, manifest, README, tests.
Solid
packages/solid/*
createByokRelayStore, createChatStore, createStreamingChatStore, createRelayHealthStore, manifest, README, tests.
Vercel AI SDK
packages/vercel-ai/*
createByokRelayProvider/createByokRelayProviderSync implementing a LanguageModelV1 adapter, manifest, README, tests.

Estimated code review effort: 4 (Complex) | ~75 minutes

Suggested reviewers: avikalpg

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding the Next.js integration package with route handlers, middleware, hooks, and ByokRelayClient.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/growth-2026-07-08

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

🟠 Major comments (19)
README.md-101-110 (1)

101-110: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Use loader data instead of window in the Remix example

window.ENV.RELAY_URL runs during server rendering in Remix, so this example will throw before hydration. Pass relayUrl from loader data or another server-provided prop instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 101 - 110, The Remix example in AiChat is reading
RELAY_URL from window.ENV, which can execute during server rendering and break
before hydration. Update the example to take relayUrl from loader data or
another server-provided prop, then pass that value into useByokRelay and
useStreamingChat instead of referencing window directly.
packages/angular/src/index.js-423-435 (1)

423-435: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Support Anthropic SSE frames and trim the data: prefix here. This parser only reads choices[0].delta.content and requires data: , so Anthropic content_block_delta events and data: lines without a trailing space are skipped. Match the non-streaming service/Astro parser by trimming first and falling back to parsed.delta?.text.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/angular/src/index.js` around lines 423 - 435, The SSE parser in the
streaming loop only handles OpenAI-style chunks via choices[0].delta.content and
assumes lines start with data: , so Anthropic content_block_delta frames and
data: lines without a trailing space are skipped. Update the parser in the
streaming handler to trim the prefix before JSON parsing, then fall back from
parsed.choices[0].delta.content to parsed.delta?.text so it matches the
non-streaming service/Astro parser behavior and supports both event shapes.
packages/angular/src/index.js-169-177 (1)

169-177: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Normalize listKeys() to the documented shape.
/keys returns { providers: [...] }, but packages/angular/src/index.js:169-176 forwards resp.json() unchanged. That leaves const { keys } = await relay.listKeys() undefined in Angular apps and keeps the README/test contract out of sync. Return { keys: ... } here, or update the Angular docs/tests to use providers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/angular/src/index.js` around lines 169 - 177, `listKeys()` is
returning the raw `/keys` payload instead of the documented Angular shape, so
callers expecting `keys` get `undefined`. Update `listKeys()` in
`packages/angular/src/index.js` to map the response from `resp.json()` into the
expected `{ keys: ... }` structure (using the `providers` array from the relay
response), and keep the `listKeys` contract consistent with the existing
docs/tests; if you choose not to map here, update the Angular-facing docs and
tests to use `providers` everywhere instead.
packages/angular/src/index.js-156-166 (1)

156-166: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

storeKey sends the wrong JSON field. /keys/:provider expects { key } in src/index.js, but this client posts { api_key: apiKey }, so the relay rejects the request and the key is never stored. Switch this to { key: apiKey }.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/angular/src/index.js` around lines 156 - 166, The storeKey method in
the Angular client is sending the wrong request body field to the
/keys/:provider endpoint. Update the JSON payload in storeKey so it matches the
relay contract expected by the existing register/getOrRegister flow, using the
same apiKey value under the key field instead of api_key, and keep the rest of
the fetch logic in this method unchanged.
packages/solid/src/index.js-412-484 (1)

412-484: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Race condition: finally block overwrites new stream's abortController and loading state.

When sendMessage is called while a previous stream is still in flight, the old call's finally block (lines 481-484) unconditionally sets abortController = null and setLoading(false). Because the old reader.read() rejection is processed as a microtask after the new sendMessage has already set its own abortController (line 425) and setLoading(true) (line 421), the old finally overwrites both — making the new stream unstoppable and clearing the loading indicator prematurely.

The Next.js useStreamingChat avoids this by guarding with if (abortRef.current === ac) abortRef.current = null (see packages/next/src/index.js:518-630). The same pattern should be applied here.

createChatStore.sendMessage has a analogous but less severe issue — overlapping calls can have the old finally's setLoading(false) clear the new call's loading state.

🔒 Proposed fix: guard the finally block with a local controller reference
 async function sendMessage(content, relayToken) {
   if (!content?.trim()) throw new Error('Message content is required');
   if (!relayToken) throw new Error('Relay token is required');

   // Cancel any in-flight stream
   if (abortController) abortController.abort();

   const userMsg = { role: 'user', content: content.trim() };
   setMessages(prev => [...prev, userMsg]);
   setLoading(true);
   setStreamingContent('');
   setError(null);

-  abortController = new AbortController();
+  const ac = new AbortController();
+  abortController = ac;

   try {
     const history = messages();
     const msgs = systemPrompt
       ? [{ role: 'system', content: systemPrompt }, ...history]
       : [...history];

     let body;
     if (provider === 'anthropic') {
       const system = msgs.filter(m => m.role === 'system').map(m => m.content).join('\n');
       const convMsgs = msgs.filter(m => m.role !== 'system');
       body = { model, messages: convMsgs, max_tokens: 1024, stream: true, ...extraParams };
       if (system) body.system = system;
     } else {
       body = { model, messages: msgs, stream: true, ...extraParams };
     }

     const res = await fetch(`${relayUrl}/relay/${provider}`, {
       method:  'POST',
       headers: {
         'Content-Type':  'application/json',
         'x-relay-token': relayToken,
       },
       body:   JSON.stringify(body),
-      signal: abortController.signal,
+      signal: ac.signal,
     });

     if (!res.ok) throw new Error(`Relay error: ${res.status}`);

     const reader  = res.body.getReader();
     const decoder = new TextDecoder();
     let accumulated = '';

     while (true) {
       const { done, value } = await reader.read();
       if (done) break;

       const chunk = decoder.decode(value, { stream: true });
       for (const event of parseSSE(chunk)) {
         const delta = extractDelta(event, provider);
         if (delta) {
           accumulated += delta;
           setStreamingContent(accumulated);
         }
       }
     }

     // Commit finished message
     setMessages(prev => [...prev, { role: 'assistant', content: accumulated }]);
     setStreamingContent('');
   } catch (err) {
     if (err.name === 'AbortError') return; // user-initiated stop
     setError(err.message);
     setMessages(prev => prev.slice(0, -1)); // rollback user message
     throw err;
   } finally {
-    abortController = null;
-    setLoading(false);
+    if (abortController === ac) {
+      abortController = null;
+      setLoading(false);
+    }
   }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/solid/src/index.js` around lines 412 - 484, The sendMessage flow has
a race where an older in-flight request can clear the newer request’s controller
and loading state in the finally block. Update sendMessage to keep a local
reference to the AbortController created for that call, and in the finally block
only clear abortController and setLoading(false) when that local controller
still matches the active one, following the same guard pattern used in
useStreamingChat. Also make the same loading-state guard in
createChatStore.sendMessage so overlapping calls don’t stomp each other.
packages/astro/src/index.js-146-147 (1)

146-147: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Upstream error details are exposed to clients in 502 responses.

err.message from a failed fetch can contain internal hostnames, IP addresses, or connection details (e.g., ECONNREFUSED 10.0.0.1:3000). Including this in the response body leaks infrastructure information to the browser.

🔒️ Proposed fix (apply to both catch blocks)
     } catch (err) {
+      console.error('[byok-relay] proxy error:', err.message);
       return new Response(JSON.stringify({ error: 'Relay proxy error' }), {
         status: 502,
         headers: { 'Content-Type': 'application/json' },
       });
     }

Also applies to: 232-233

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/astro/src/index.js` around lines 146 - 147, The 502 responses in the
relay proxy are leaking internal fetch error details by returning err.message to
clients. Update both catch blocks in the relay proxy logic around the Response
construction to stop exposing raw upstream error text, and replace details with
a generic client-safe message while preserving the existing Relay proxy error
context. Keep the server-side logging or internal handling separate so the
sensitive error information is not sent in the response body.
packages/astro/src/index.js-121-132 (1)

121-132: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

No timeout on upstream fetch can hang requests indefinitely.

Both the middleware and route handler call fetch(upstreamUrl, init) without an AbortSignal. If the upstream relay is unresponsive, the request hangs until the client disconnects or an outer proxy times out, risking connection-pool exhaustion under load.

⏱️ Proposed fix (apply to both proxy functions)
     const init = {
       method: request.method,
       headers: forwardHeaders,
       redirect: 'follow',
+      signal: AbortSignal.timeout(30_000),
     };

In the catch block, map timeout aborts to 504:

     } catch (err) {
+      const status = err.name === 'TimeoutError' ? 504 : 502;
       return new Response(JSON.stringify({ error: 'Relay proxy error' }), {
-        status: 502,
+        status,
         headers: { 'Content-Type': 'application/json' },
       });
     }

Also applies to: 209-220

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/astro/src/index.js` around lines 121 - 132, The proxy requests in
the middleware and route handler currently call fetch(upstreamUrl, init) without
any timeout protection, so add an AbortSignal-based timeout around the upstream
fetch in both proxy functions. Use the existing request handling flow around the
fetch call to create and pass the signal, and update the catch path to detect
timeout aborts and return a 504 response instead of hanging indefinitely. Focus
on the shared proxy logic near the fetch call so both paths behave consistently.
packages/next/src/index.js-123-191 (1)

123-191: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Route handler drops query string when forwarding upstream.

upstreamUrl is built from the catch-all path segments only (line 127). The original request's query string (e.g. ?deep=1 from useRelayHealth) is never appended. The middleware factory (line 276-277) correctly forwards url.search, but the route handler does not.

This means GET /api/relay/health?deep=1 reaches the relay as GET /health — deep health checks silently become shallow.

🐛 Proposed fix
 async function _handler (request, context) {
   // Build upstream sub-path from catch-all segment
   const segments = (context && context.params && context.params.path) || [];
   const subPath = segments.length ? '/' + segments.join('/') : '';
-  const upstreamUrl = relayUrl.replace(/\/$/, '') + subPath;
+  const reqUrl = new URL(request.url);
+  const upstreamUrl = relayUrl.replace(/\/$/, '') + subPath +
+    (reqUrl.searchParams.size > 0 ? '?' + reqUrl.searchParams.toString() : '');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/next/src/index.js` around lines 123 - 191, The _handler route in
packages/next/src/index.js is dropping the incoming query string when it builds
upstreamUrl from the catch-all path segments only. Update _handler to preserve
request.url search params when composing the relay target, similar to how the
middleware factory forwards url.search, so requests like useRelayHealth keep
their deep-check flags. Locate the fix around _handler, upstreamUrl, and the
existing request.url handling, and make sure the forwarded URL includes both
path and query string.
packages/next/src/index.js-756-801 (1)

756-801: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

ByokRelayClient key-management methods don't check resp.ok — errors returned as data.

storeKey, listKeys, deleteKey, rotateKey, stats, getModels, and deleteAccount all return resp.json() without checking resp.ok. If the relay returns an error response, the error JSON is silently returned as if it were a success. register() (line 735) and chat() (line 829) correctly check resp.ok and throw — these methods should be consistent.

🐛 Proposed fix for storeKey (same pattern for others)
   async storeKey (provider, apiKey) {
     if (!this._token) throw new Error('call ensureToken() first');
     const resp = await fetch(`${this._base}/keys/${provider}`, {
       method: 'POST',
       headers: {
         'content-type': 'application/json',
         'authorization': `Bearer ${this._token}`,
       },
       body: JSON.stringify({ api_key: apiKey }),
     });
-    return resp.json();
+    const data = await resp.json();
+    if (!resp.ok) throw new Error(data.error || 'storeKey failed');
+    return data;
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/next/src/index.js` around lines 756 - 801, The ByokRelayClient
key-management methods are returning response JSON even when the relay responds
with an error, so make them consistent with register() and chat() by checking
resp.ok first. Update storeKey, listKeys, deleteKey, rotateKey, stats,
getModels, and deleteAccount to throw on non-OK responses before parsing or
returning data, using the existing ByokRelayClient methods as the places to
apply the fix.
packages/next/test/next.test.js-123-127 (1)

123-127: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Async test uses sync test() runner — assertion failures are silently swallowed.

test() calls fn() without awaiting the returned promise. The try/catch only catches synchronous errors, so the assert.strictEqual inside the async function would reject as an unhandled promise — the test always shows as passed.

🐛 Proposed fix
-test('OPTIONS returns 204 with CORS headers', async () => {
+asyncTest('OPTIONS returns 204 with CORS headers', async () => {
   const { OPTIONS } = createRelayRouteHandler({ relayUrl: 'http://localhost:3000' });
   const resp = await OPTIONS();
   assert.strictEqual(resp.status, 204);
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/next/test/next.test.js` around lines 123 - 127, The OPTIONS test in
next.test.js is declared as async but is registered with the sync test() helper,
so promise rejections from assert.strictEqual can be missed. Update the test
registration to use the async-aware runner or otherwise return/await the promise
from the test callback, and keep the check around createRelayRouteHandler and
OPTIONS so failures are properly surfaced.
packages/next/src/index.js-604-616 (1)

604-616: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Abort handler uses stale streamingContent instead of local accumulated.

On abort, the catch block (line 611) reads streamingContent from the closure, which is the value captured when sendMessage was created — not the latest streamed content. The local variable accumulated has the correct partial content but is not used. This means aborted streams may commit an empty or stale assistant message.

Additionally, streamingContent in the useCallback dependency array (line 621) causes the callback to be recreated on every delta, triggering unnecessary re-renders during streaming.

🐛 Proposed fix
     } catch (e) {
       if (e.name !== 'AbortError') {
         setMessages(messages); // revert
         setError(e.message);
         throw e;
       }
       // Committed partial content on abort
-      if (streamingContent) {
-        setMessages([...history, { role: 'assistant', content: streamingContent }]);
+      if (accumulated) {
+        setMessages([...history, { role: 'assistant', content: accumulated }]);
       } else {
         setMessages(messages);
       }
       setStreamingContent('');
     } finally {
       setLoading(false);
       if (abortRef.current === ac) abortRef.current = null;
     }
-  }, [base, token, model, messages, systemPrompt, extraParams, streamingContent, stopStreaming]);
+  }, [base, token, model, messages, systemPrompt, extraParams, stopStreaming]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/next/src/index.js` around lines 604 - 616, In sendMessage’s abort
handling, stop using the closure-captured streamingContent and commit the latest
partial response from the local accumulated variable instead, so aborted streams
save the correct assistant content. Update the catch block that handles
AbortError to build the final message from accumulated, and remove
streamingContent from the useCallback dependency list so the callback in
sendMessage is not recreated on every streamed delta.
packages/remix/src/index.js-447-465 (1)

447-465: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

SSE stream parsing loses content split across chunks and doesn't exit on [DONE]

chunk.split('\n') doesn't buffer incomplete lines — if a data: payload is split across two read() chunks, the first half fails JSON parsing (silently caught) and the second half doesn't start with data: (skipped), so content is lost. Additionally, break on [DONE] (lines 455 and 706) only exits the for loop, not the while loop, so the reader continues after the stream ends. The Hono package's streamChat correctly handles this with a buffer (buf = lines.pop()).

The same bug exists in both useStreamingChat (lines 447-465) and ByokRelayClient.streamChat (lines 698-713).

🐛 Proposed fix for useStreamingChat (lines 447-465)
       const reader = r.body.getReader();
       const decoder = new TextDecoder();
       let accumulated = '';
+      let buf = '';

       while (true) {
         const { done, value } = await reader.read();
         if (done) break;
-        const chunk = decoder.decode(value, { stream: true });
-        const lines = chunk.split('\n');
+        buf += decoder.decode(value, { stream: true });
+        const lines = buf.split('\n');
+        buf = lines.pop(); // keep incomplete line
+        let streamDone = false;
         for (const line of lines) {
           if (!line.startsWith('data: ')) continue;
           const payload = line.slice(6).trim();
-          if (payload === '[DONE]') break;
+          if (payload === '[DONE]') { streamDone = true; break; }
           try {
             const parsed = JSON.parse(payload);
             const delta = parsed.choices?.[0]?.delta?.content || '';
             if (delta) {
               accumulated += delta;
               setStreamingContent(accumulated);
             }
           } catch (_) {}
         }
+        if (streamDone) break;
       }
🐛 Proposed fix for streamChat (lines 698-713)
     const reader = r.body.getReader();
     const decoder = new TextDecoder();
     let accumulated = '';
+    let buf = '';

     try {
       while (true) {
         const { done, value } = await reader.read();
         if (done) break;
-        const chunk = decoder.decode(value, { stream: true });
-        for (const line of chunk.split('\n')) {
+        buf += decoder.decode(value, { stream: true });
+        const lines = buf.split('\n');
+        buf = lines.pop(); // keep incomplete line
+        let streamDone = false;
+        for (const line of lines) {
           if (!line.startsWith('data: ')) continue;
           const payload = line.slice(6).trim();
-          if (payload === '[DONE]') break;
+          if (payload === '[DONE]') { streamDone = true; break; }
           try {
             const parsed = JSON.parse(payload);
             const delta = parsed.choices?.[0]?.delta?.content || '';
             if (delta) { accumulated += delta; if (onChunk) onChunk(delta, accumulated); }
           } catch (_) {}
         }
+        if (streamDone) break;
       }
     } finally {
       reader.releaseLock();
     }

Also applies to: 698-713

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/remix/src/index.js` around lines 447 - 465, The SSE parsing in
useStreamingChat and ByokRelayClient.streamChat is dropping payloads that span
read() chunks and the [DONE] marker only breaks the inner loop. Add a rolling
buffer like the Hono streamChat implementation so incomplete lines are preserved
across iterations, parse only complete data: lines, and make [DONE] exit the
outer read loop/stop the reader as soon as it is seen.
packages/remix/test/remix.test.js-70-81 (1)

70-81: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Async tests use test() instead of asyncTest(), silently swallowing assertion failures

test() calls fn() without awaiting the returned promise — and passed++ execute before the assertion runs. If the assertion fails, the rejection is unhandled and failed is never incremented. Both the loader 403 test (lines 70-81) and the action 403 test (lines 90-103) are affected.

💚 Proposed fix for both async tests
-test('rejects disallowed app_id', async () => {
+promises.push(asyncTest('rejects disallowed app_id', async () => {
   const loader = createRelayLoader({
     relayUrl: 'https://relay.example.com',
     allowedApps: ['allowed-app'],
   });
   const req = {
     url: 'https://myapp.com/api/relay/health',
     headers: { get: () => '' },
   };
   const res = await loader({ request: req, params: { '*': 'health' } });
   assert.strictEqual(res.status, 403);
-});
+}));
-test('rejects disallowed app_id in action', async () => {
+promises.push(asyncTest('rejects disallowed app_id in action', async () => {
   const action = createRelayAction({
     relayUrl: 'https://relay.example.com',
     allowedApps: ['allowed-app'],
   });
   const req = {
     url: 'https://myapp.com/api/relay/users',
     method: 'POST',
     headers: { get: () => '' },
     arrayBuffer: async () => new ArrayBuffer(0),
   };
   const res = await action({ request: req, params: { '*': 'users' } });
   assert.strictEqual(res.status, 403);
-});
+}));

Also applies to: 90-103

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/remix/test/remix.test.js` around lines 70 - 81, The async rejection
checks in the relay tests are using test() instead of asyncTest(), so the
returned promise is not awaited and failures can be swallowed. Update the
affected cases in the test file, including the loader 403 case and the action
403 case, to use asyncTest() (or otherwise await the async assertion path) so
the harness increments failed on rejection and only marks the test passed after
the promise resolves.
packages/remix/src/index.js-469-475 (1)

469-475: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Partial commit on abort uses stale streamingContent state instead of local accumulated

streamingContent in the catch closure is captured from the render when send was created, not from the setStreamingContent(accumulated) calls during streaming. Since streamingContent starts as '', the else if (streamingContent) branch is never taken — partial content is silently discarded on abort.

Use the local accumulated variable, which always has the latest content. Also remove streamingContent from the send dependency array (line 480) since it will no longer be referenced inside send.

🐛 Proposed fix
     } catch (e) {
       if (e.name !== 'AbortError') setError(e.message);
-      else if (streamingContent) {
+      else if (accumulated) {
         // partial commit on abort
-        setMessages(m => [...m, { role: 'assistant', content: streamingContent }]);
+        setMessages(m => [...m, { role: 'assistant', content: accumulated }]);
         setStreamingContent('');
       }

And update the dependency array on line 480:

-  }, [relayUrl, token, provider, model, systemPrompt, extraParams, messages, streamingContent]);
+  }, [relayUrl, token, provider, model, systemPrompt, extraParams, messages]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/remix/src/index.js` around lines 469 - 475, The abort handling in
`send` is checking stale `streamingContent` from the closure, so partial
assistant text can be dropped on abort. Update the `catch` block in `send` to
use the local `accumulated` value for the partial commit path instead of
`streamingContent`, and then remove `streamingContent` from `send`’s dependency
array since it is no longer read inside that function.
packages/hono/src/index.js-322-334 (1)

322-334: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

register() returns inconsistent response shapes.

When a token is cached (!force && this._token), it returns { token: this._token } — missing expires_at. When fresh, it returns the full upstream response data which includes expires_at. The JSDoc declares @returns {Promise<{token:string,expires_at:string}>}. Callers destructuring expires_at will get undefined on the cached path.

🔧 Proposed fix
 async register (appId, force = false) {
-  if (!force && this._token) return { token: this._token };
+  if (!force && this._token) return { token: this._token, expires_at: null };
   const res = await fetch(`${this._relayUrl}/users`, {

Alternatively, cache the full data object from the initial registration and return it on the cached path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/hono/src/index.js` around lines 322 - 334, The register() method
returns different shapes depending on whether a token is cached, so update the
cached branch in register() to return the same object shape as the fresh fetch
path, including expires_at. Use the existing register() flow and the cached
token storage in this._token / this._storageKey, and either cache the full
registration response or reconstruct a consistent { token, expires_at } result
before returning.
packages/preact/src/index.js-170-174 (1)

170-174: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use x-relay-token for protected relay requests The relay server only accepts x-relay-token on /keys, /stats, and /relay/*; these Authorization: Bearer calls will 401 storeKey, deleteKey, listKeys, useChat, and useStreamingChat.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/preact/src/index.js` around lines 170 - 174, The protected relay
request in the users/relay flow is still using Authorization: Bearer, which will
be rejected by the relay server. Update the request setup in the relevant relay
helper(s) in index.js, especially the code path that builds the fetch call for
users/keys/stats/relay operations, to send x-relay-token instead of
Authorization for protected endpoints. Make sure the token header is applied
consistently in the functions that back storeKey, deleteKey, listKeys, useChat,
and useStreamingChat so those calls authenticate correctly.
packages/vercel-ai/README.md-107-113 (1)

107-113: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

createByokRelayProvider doesn't await token registration as documented.

The README states the async factory "awaits token registration before returning," but the implementation (src/index.js lines 393–405) returns immediately with a lazy getToken(). Users who await createByokRelayProvider(...) expecting the token to be ready will find it is not — the behavior is identical to the sync factory.

Additionally, unlike createByokRelayProviderSync, the async getToken() lacks _pending promise deduplication, so concurrent calls can trigger duplicate POST /users registrations.

🔧 Proposed fix: actually await registration in the async factory
 async function createByokRelayProvider({ relayUrl, appId = 'vercel-ai', storage, settings = {} } = {}) {
   if (!relayUrl) throw new Error('byok-relay: relayUrl is required');
   const store = storage ?? defaultStorage();
   const storageKey = `byok_relay_token_${appId}`;

   let _token = store.getItem(storageKey) ?? null;
-
-  async function getToken() {
-    if (!_token) {
-      _token = await registerToken({ relayUrl, appId, storage: store });
-    }
-    return _token;
-  }
+  if (!_token) {
+    _token = await registerToken({ relayUrl, appId, storage: store });
+  }
+
+  function getToken() { return _token; }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/vercel-ai/README.md` around lines 107 - 113, The async factory
createByokRelayProvider currently returns before token registration completes,
which does not match the documented behavior. Update createByokRelayProvider so
it actually awaits the initial registration before resolving, and make its
getToken path use the same _pending promise deduplication pattern as
createByokRelayProviderSync to prevent duplicate POST /users calls on concurrent
requests.
packages/vercel-ai/src/index.js-58-72 (1)

58-72: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use POST and { key } for storeProviderKey packages/vercel-ai/src/index.js:58-65
The relay only accepts POST /keys/:provider and reads req.body.key; this PUT + { api_key } request will fail against the current contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/vercel-ai/src/index.js` around lines 58 - 72, The storeProviderKey
helper is using the wrong HTTP contract for the relay. Update storeProviderKey
in packages/vercel-ai/src/index.js to send a POST request to /keys/:provider
instead of PUT, and change the JSON body field from api_key to key so it matches
what the relay expects; keep the existing error handling and response parsing
intact.
packages/vercel-ai/src/index.js-186-215 (1)

186-215: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Settings are spread into the request body without camelCase-to-snake_case conversion.

The ...settings spread (line 191) passes user-provided settings as-is. The README documents camelCase keys like maxTokens and topP in settings/overrides, but the relay expects snake_case (max_tokens, top_p). Per-call options from the AI SDK are correctly mapped (lines 193–199), but default settings and model-level overrides are not. A user setting settings: { maxTokens: 500 } will silently get the relay's default max_tokens instead.

The test doesn't catch this because Suite 8 uses top_p (already snake_case) and temperature (format-agnostic).

🔧 Proposed fix: map known settings keys to snake_case before spreading
   async function buildBody(options) {
     const messages = promptToMessages(options.prompt);
+    // Convert known camelCase settings to snake_case for the relay
+    const snakeSettings = {};
+    if (settings.maxTokens !== undefined) snakeSettings.max_tokens = settings.maxTokens;
+    if (settings.temperature !== undefined) snakeSettings.temperature = settings.temperature;
+    if (settings.topP !== undefined) snakeSettings.top_p = settings.topP;
+    if (settings.frequencyPenalty !== undefined) snakeSettings.frequency_penalty = settings.frequencyPenalty;
+    if (settings.presencePenalty !== undefined) snakeSettings.presence_penalty = settings.presencePenalty;
+    if (settings.stopSequences !== undefined) snakeSettings.stop = settings.stopSequences;
+    if (settings.seed !== undefined) snakeSettings.seed = settings.seed;
+    // Pass through any already-snake_case or unknown keys
+    for (const [k, v] of Object.entries(settings)) {
+      if (!(k in snakeSettings)) snakeSettings[k] = v;
+    }
     const body = {
       model,
       messages,
-      ...settings,
+      ...snakeSettings,
     };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/vercel-ai/src/index.js` around lines 186 - 215, The request body
built in buildBody is spreading settings directly, so camelCase defaults and
model overrides are not converted to the relay’s expected snake_case keys.
Update buildBody to normalize known settings fields (for example maxTokens,
topP, frequencyPenalty, presencePenalty, stopSequences) into max_tokens, top_p,
frequency_penalty, presence_penalty, and stop before merging them into body,
while preserving the existing per-call option mappings. Make sure the mapping
logic is located in buildBody so both settings and overrides are handled
consistently alongside model and promptToMessages usage.
🟡 Minor comments (12)
metrics/daily.jsonl-2-3 (1)

2-3: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Normalize the duplicate 2026-07-06 record.

The file now has two rows for the same date and mixes clones_14d/views_14d with clones/views, which makes the snapshot ambiguous for downstream consumers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@metrics/daily.jsonl` around lines 2 - 3, Normalize the duplicate 2026-07-06
entry in metrics/daily.jsonl by keeping a single record for that date in the
same schema used elsewhere in the snapshot. Update the daily JSONL so only one
row remains for that date and use the canonical field names consistently,
matching the existing metrics data format rather than mixing
clones_14d/views_14d with clones/views. Refer to the daily snapshot record
handling in metrics/daily.jsonl to locate and correct the duplicate.
README.md-57-78 (1)

57-78: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add the Angular imports for *ngFor
The example needs standalone: true plus imports: [CommonModule] (or NgFor) on the component; otherwise it won’t compile as shown.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 57 - 78, The Angular example is missing the template
import needed for `*ngFor`, so update `ChatComponent` to be a standalone
component and add the required `imports` on the component metadata, using
`CommonModule` or `NgFor` so the `chat.messages()` loop compiles as shown. Keep
the fix localized to the `ChatComponent` snippet in the README example and
ensure the imports match the directive used in the template.
README.md-28-47 (1)

28-47: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Import For and Show in the SolidJS example — the snippet uses both components without importing them from solid-js, so it won’t compile as written. Also, createStreamingChatStore doesn’t need appId.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 28 - 47, The SolidJS example is missing the imports
for the `For` and `Show` components used in `App`, so update the README snippet
to import them from `solid-js` alongside `createByokRelayStore` and
`createStreamingChatStore`. While editing the example, also remove `appId` from
the `createStreamingChatStore` call, since that option belongs only to
`createByokRelayStore`; use the identifiers `App`, `For`, `Show`,
`createByokRelayStore`, and `createStreamingChatStore` to locate the snippet.
packages/angular/src/index.js-584-618 (1)

584-618: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the dead token and guard Angular 14 explicitly

  • RELAY_CONFIG_TOKEN is never used; config is already captured by the factories, so this allocation can go away.
  • makeEnvironmentProviders is Angular 15+; with the current peerDependencies range (>=14.0.0), Angular 14 will hit the catch and return the plain bundle instead of providers. That should fail fast with a clear message or be gated before calling it.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/angular/src/index.js` around lines 584 - 618, The Angular provider
setup in provideByokRelay has a dead RELAY_CONFIG_TOKEN allocation that is never
used, so remove it and keep relying on the captured config in the factories.
Also, make the Angular version requirement explicit before calling
makeEnvironmentProviders: since it is Angular 15+, Angular 14 should not
silently fall through to the catch and return the plain bundle; instead guard
this path or fail fast with a clear version-specific message so the behavior is
intentional.
packages/astro/src/index.js-90-96 (1)

90-96: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Path prefix matching can over-match unrelated routes.

url.pathname.startsWith(pathPrefix) matches /api/relayfoo when pathPrefix is /api/relay, causing unrelated routes to be proxied instead of calling next(). The subPath would be 'foo' (no leading slash), producing an incorrect upstream URL.

🐛 Proposed fix
-    if (!url.pathname.startsWith(pathPrefix)) {
+    if (url.pathname !== pathPrefix && !url.pathname.startsWith(pathPrefix + '/')) {
       return next();
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/astro/src/index.js` around lines 90 - 96, The path-prefix check in
the request handler is too broad and can proxy unrelated routes; update the
matching logic around the url.pathname.startsWith(pathPrefix) and subPath
calculation so only exact prefix matches or proper boundary matches are
accepted. In the same handler, ensure the relay route in the index.js flow
rejects paths like /api/relayfoo by requiring a slash or exact end after
pathPrefix, and keep subPath construction in sync so upstreamUrl always gets a
valid leading slash path segment.
packages/astro/src/index.js-498-525 (1)

498-525: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Remaining SSE buffer is discarded after stream ends.

When the upstream closes the connection without a trailing newline, the last SSE event remains in buffer and is never parsed, potentially truncating the final output token.

💚 Proposed fix
     }
 
+    // Flush any remaining buffered line
+    buffer += decoder.decode();
+    if (buffer.trim().startsWith('data:')) {
+      const payload = buffer.trim().slice(5).trim();
+      if (payload !== '[DONE]') {
+        try {
+          const parsed = JSON.parse(payload);
+          let delta = '';
+          if (parsed.choices) {
+            delta = parsed.choices[0]?.delta?.content || '';
+          } else if (parsed.type === 'content_block_delta') {
+            delta = parsed.delta?.text || '';
+          }
+          if (delta) {
+            accumulated += delta;
+            if (typeof onChunk === 'function') onChunk(delta);
+          }
+        } catch (_) { /* skip */ }
+      }
+    }
+
     if (typeof onDone === 'function') onDone(accumulated);
     return accumulated;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/astro/src/index.js` around lines 498 - 525, The SSE loop in the
stream parsing logic leaves the final partial frame in buffer unprocessed when
the reader finishes, which can drop the last token. After the while loop in the
streaming handler, parse any remaining non-empty buffer as one final SSE line
using the same data:/payload/JSON handling as the main loop, and keep the
existing delta extraction behavior in place so the last chunk is emitted via
onChunk and accumulated before returning.
packages/next/src/index.js-255-259 (1)

255-259: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

startsWith prefix check can match unintended paths.

/relay would also match /relay-admin or /relayfoo. Use a boundary-aware check.

🛡️ Proposed fix
-    if (!url.pathname.startsWith(prefix)) {
+    if (url.pathname !== prefix && !url.pathname.startsWith(prefix + '/')) {
       return;
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/next/src/index.js` around lines 255 - 259, The pathname check in the
routing logic is too loose because the startsWith(prefix) test can incorrectly
match sibling paths like /relay-admin or /relayfoo. Update the prefix handling
in the relevant Next.js path check so it is boundary-aware in the same place
where url.pathname is compared against prefix, ensuring only the intended prefix
or a proper path segment match passes through.
packages/next/src/index.js-910-918 (1)

910-918: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

deleteAccount() clears in-memory token but not storage — stale token persists.

After account deletion, this._token is set to null but the storage adapter still holds the token. A subsequent ensureToken() call would find the stale (now invalid) token in storage and reuse it, causing auth failures on the next API call.

🐛 Proposed fix
-  async deleteAccount () {
+  async deleteAccount (appId = 'next-client') {
     if (!this._token) throw new Error('call ensureToken() first');
     const resp = await fetch(`${this._base}/users`, {
       method: 'DELETE',
       headers: { 'authorization': `Bearer ${this._token}` },
     });

     this._token = null;
+    this._storage.removeItem(this._tokenKey(appId));
     return resp.json();
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/next/src/index.js` around lines 910 - 918, The deleteAccount() flow
clears this._token but leaves the persisted token behind, so a later
ensureToken() can reload a stale credential. Update deleteAccount() in the same
place it invalidates this._token to also remove the token from the storage
adapter or token persistence used by ensureToken(), keeping in-memory and stored
auth state in sync after account deletion.
packages/hono/test/middleware.test.js-254-270 (1)

254-270: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Restore globalThis.fetch in finally to prevent mock leakage.

All fetch-mocking tests save origFetch, override globalThis.fetch, then restore it after assertions. If any assertion or awaited call throws, the restore line is skipped and the mock leaks to subsequent tests. Use try/finally to guarantee cleanup.

🧪 Proposed pattern (shown for one test, apply to all)
 await testAsync('register(appId, force=true) calls fetch even when token exists', async () => {
   let fetchCalled = false;
   const origFetch = globalThis.fetch;
   globalThis.fetch = async (_url, _opts) => {
     fetchCalled = true;
     return {
       ok: true,
       json: async () => ({ token: 'new-token', expires_at: '2027-01-01T00:00:00Z' }),
     };
   };
-  const client = new ByokRelayClient({ relayUrl: 'http://localhost:3000' });
-  client._token = 'old-token';
-  await client.register('myapp', true);
-  globalThis.fetch = origFetch;
-  assert.ok(fetchCalled, 'fetch should be called when force=true');
-  assert.strictEqual(client._token, 'new-token');
+  try {
+    const client = new ByokRelayClient({ relayUrl: 'http://localhost:3000' });
+    client._token = 'old-token';
+    await client.register('myapp', true);
+    assert.ok(fetchCalled, 'fetch should be called when force=true');
+    assert.strictEqual(client._token, 'new-token');
+  } finally {
+    globalThis.fetch = origFetch;
+  }
 });

Also applies to: 276-296, 302-314, 320-343

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/hono/test/middleware.test.js` around lines 254 - 270, The
fetch-mocking tests around ByokRelayClient.register are leaking the mocked
globalThis.fetch because restoration happens after assertions instead of being
guaranteed. Wrap each override in try/finally so origFetch is always restored
even if await register or an assertion throws, and apply the same cleanup
pattern to the other fetch-mocking tests referenced in this suite.
packages/preact/src/index.js-379-382 (1)

379-382: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

clearMessages should stop the active stream before clearing state.

If a stream is in progress when clearMessages is called, the stream continues running and its onDone callback will append an assistant message to the freshly-cleared list. The Remix implementation calls stopStreaming() before clearing to prevent this.

🛡️ Proposed fix
 const clearMessages = useCallback(() => {
+    if (abortRef.current) abortRef.current.abort();
+    setIsStreaming(false);
     setMessages([]);
     setStreamingContent('');
-  }, []);
+  }, []);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/preact/src/index.js` around lines 379 - 382, The clearMessages
callback in index.js clears message state without stopping any active stream, so
a running stream can still append via onDone after the reset. Update
clearMessages to invoke stopStreaming() before setMessages and
setStreamingContent, matching the behavior used in the Remix implementation and
preventing stale stream callbacks from repopulating the cleared list.
packages/preact/test/hooks.test.js-297-297 (1)

297-297: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

stopStreaming is never called — the assertion always passes.

assert(() => { h.stopStreaming(); return true; }, 'no throw') passes a function reference as the condition. Since a function is always truthy, assert never throws, and stopStreaming() is never invoked. The test doesn't verify what it claims.

💚 Proposed fix
-  assert(() => { h.stopStreaming(); return true; }, 'no throw');
+  h.stopStreaming(); // throws → test framework catches it
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/preact/test/hooks.test.js` at line 297, The test around
stopStreaming in hooks.test.js is asserting a function reference instead of
executing the call, so the assertion always passes without actually invoking
h.stopStreaming(). Update the affected test to call h.stopStreaming() as part of
the asserted expression and verify it does not throw, using the existing h
helper in the test case.
packages/vercel-ai/src/index.js-74-81 (1)

74-81: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

deleteAccount removes the local token before verifying server-side deletion.

storage.removeItem runs unconditionally after the DELETE request, even if the response is non-ok. If the server-side deletion fails, the user loses their local token but their account and keys persist on the relay — a GDPR compliance gap.

🛡️ Proposed fix: only remove local token on successful deletion
 async function deleteAccount({ relayUrl, token, storage, appId }) {
   const res = await fetch(`${relayUrl}/users`, {
     method: 'DELETE',
     headers: { Authorization: `Bearer ${token}` },
   });
-  storage.removeItem(`byok_relay_token_${appId}`);
-  return res.ok;
+  if (!res.ok) {
+    const body = await res.text();
+    throw new Error(`byok-relay: failed to delete account (${res.status}): ${body}`);
+  }
+  storage.removeItem(`byok_relay_token_${appId}`);
+  return true;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/vercel-ai/src/index.js` around lines 74 - 81, The deleteAccount flow
removes the local relay token unconditionally, even when the server-side DELETE
fails. Update deleteAccount to only call storage.removeItem for
byok_relay_token_${appId} after confirming res.ok, and keep the token intact on
failure so the local state stays aligned with the relay deletion result.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 76056ccd-aa92-4abd-9548-51a8d6bc4312

📥 Commits

Reviewing files that changed from the base of the PR and between f0e1a43 and f5d4146.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (36)
  • README.md
  • llms.txt
  • metrics/daily.jsonl
  • package.json
  • packages/angular/README.md
  • packages/angular/package.json
  • packages/angular/src/index.js
  • packages/angular/test/services.test.js
  • packages/astro/README.md
  • packages/astro/package.json
  • packages/astro/src/index.js
  • packages/astro/test/integration.test.js
  • packages/hono/README.md
  • packages/hono/package.json
  • packages/hono/src/index.js
  • packages/hono/test/middleware.test.js
  • packages/next/README.md
  • packages/next/package.json
  • packages/next/src/index.js
  • packages/next/test/next.test.js
  • packages/preact/README.md
  • packages/preact/package.json
  • packages/preact/src/index.js
  • packages/preact/test/hooks.test.js
  • packages/remix/README.md
  • packages/remix/package.json
  • packages/remix/src/index.js
  • packages/remix/test/remix.test.js
  • packages/solid/README.md
  • packages/solid/package.json
  • packages/solid/src/index.js
  • packages/solid/test/stores.test.js
  • packages/vercel-ai/README.md
  • packages/vercel-ai/package.json
  • packages/vercel-ai/src/index.js
  • packages/vercel-ai/test/smoke.test.js

Comment on lines +29 to +36
const PROVIDER_PATHS = {
openai: 'chat/completions',
anthropic: 'messages',
google: 'models/{model}:generateContent',
groq: 'chat/completions',
mistral: 'chat/completions',
openrouter: 'chat/completions',
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Google provider path contains unreplaced {model} placeholder.

PROVIDER_PATHS.google is 'models/{model}:generateContent', but neither sendMessage (Line 288) nor streamMessage (Line 397) substitute {model} with the actual model name. The resulting URL would contain the literal string {model} (e.g., /relay/google/models/{model}:generateContent), causing every Google provider request to fail.

🐛 Proposed fix: replace `{model}` in path construction

Apply in both sendMessage and streamMessage:

-      const path = PROVIDER_PATHS[provider] || 'chat/completions';
+      const path = (PROVIDER_PATHS[provider] || 'chat/completions').replace('{model}', encodeURIComponent(model));

Also applies to: 288-288, 397-397

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/angular/src/index.js` around lines 29 - 36, `PROVIDER_PATHS.google`
still contains the `{model}` placeholder, and both `sendMessage` and
`streamMessage` are building Google URLs without substituting the actual model
name. Update the Google path construction in those two functions to replace
`{model}` with the current model value before sending the request, keeping the
existing provider-specific path logic in `PROVIDER_PATHS` and the request
builders aligned.

Comment on lines +134 to +144
if (allowedAppIds && allowedAppIds.length > 0) {
const auth = c.req.header('authorization') || '';
if (auth) {
// We can't decode the JWT server-side without the HMAC secret, but we
// can check the app_id that the client explicitly sends.
const appId = c.req.header('x-app-id') || '';
if (appId && !allowedAppIds.includes(appId)) {
return c.json({ error: 'app_id not allowed' }, 403);
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Allowlist is trivially bypassed by omitting the x-app-id header.

Both createByokRelayMiddleware and createRelayRoute skip the allowlist check when x-app-id is missing (if (appId && !allowedAppIds.includes(appId)) — empty string is falsy). The middleware additionally skips the entire check when no authorization header is present. This means any request without an x-app-id header passes through unrestricted, giving a false sense of security.

🔒 Proposed fix for both factories

Middleware fix (lines 134-144):

     if (allowedAppIds && allowedAppIds.length > 0) {
-      const auth = c.req.header('authorization') || '';
-      if (auth) {
-        // We can't decode the JWT server-side without the HMAC secret, but we
-        // can check the app_id that the client explicitly sends.
-        const appId = c.req.header('x-app-id') || '';
-        if (appId && !allowedAppIds.includes(appId)) {
-          return c.json({ error: 'app_id not allowed' }, 403);
-        }
+      const appId = c.req.header('x-app-id') || '';
+      if (!appId || !allowedAppIds.includes(appId)) {
+        return c.json({ error: 'app_id not allowed' }, 403);
       }
     }

Route handler fix (lines 216-221):

     if (allowedAppIds && allowedAppIds.length > 0) {
       const appId = c.req.header('x-app-id') || '';
-      if (appId && !allowedAppIds.includes(appId)) {
+      if (!appId || !allowedAppIds.includes(appId)) {
         return c.json({ error: 'app_id not allowed' }, 403);
       }
     }

Also applies to: 216-221

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/hono/src/index.js` around lines 134 - 144, The allowlist check in
createByokRelayMiddleware and createRelayRoute is bypassed when x-app-id is
missing, and the middleware also skips validation unless authorization is
present. Update both paths so a request is rejected whenever allowedAppIds is
configured and x-app-id is absent or not included in the allowlist, regardless
of authorization presence; keep the existing 403 response behavior and make the
check unconditional for any request reaching these handlers.

Comment on lines +569 to +595
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let accumulated = '';

while (true) {
const { done, value } = await reader.read();
if (done) break;

const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');

for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const raw = line.slice(6).trim();
if (raw === '[DONE]') continue;
try {
const parsed = JSON.parse(raw);
const delta =
parsed.choices?.[0]?.delta?.content ||
parsed.delta?.text || '';
if (delta) {
accumulated += delta;
setStreamingContent(accumulated);
}
} catch (_) {}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

SSE parser doesn't buffer partial lines across chunks — deltas are silently dropped.

Both useStreamingChat (line 578) and ByokRelayClient.streamChat (line 869) split each chunk on \n and process lines immediately. If the network delivers a chunk boundary in the middle of a data: {...} line, JSON.parse fails (caught by catch (_) {}), the delta is lost, and the remainder doesn't start with data: so it's also skipped.

A proper SSE parser must maintain a line buffer across chunks.

🐛 Proposed fix for useStreamingChat
       const reader = resp.body.getReader();
       const decoder = new TextDecoder();
       let accumulated = '';
+      let sseBuffer = '';

       while (true) {
         const { done, value } = await reader.read();
         if (done) break;

         const chunk = decoder.decode(value, { stream: true });
-        const lines = chunk.split('\n');
+        sseBuffer += chunk;
+        const lines = sseBuffer.split('\n');
+        sseBuffer = lines.pop(); // keep incomplete line in buffer

         for (const line of lines) {
           if (!line.startsWith('data: ')) continue;
           const raw = line.slice(6).trim();
           if (raw === '[DONE]') continue;
           try {
             const parsed = JSON.parse(raw);
             const delta =
               parsed.choices?.[0]?.delta?.content ||
               parsed.delta?.text || '';
             if (delta) {
               accumulated += delta;
               setStreamingContent(accumulated);
             }
           } catch (_) {}
         }
       }

The same fix applies to ByokRelayClient.streamChat (line 864-881).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let accumulated = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const raw = line.slice(6).trim();
if (raw === '[DONE]') continue;
try {
const parsed = JSON.parse(raw);
const delta =
parsed.choices?.[0]?.delta?.content ||
parsed.delta?.text || '';
if (delta) {
accumulated += delta;
setStreamingContent(accumulated);
}
} catch (_) {}
}
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let accumulated = '';
let sseBuffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
sseBuffer += chunk;
const lines = sseBuffer.split('\n');
sseBuffer = lines.pop(); // keep incomplete line in buffer
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const raw = line.slice(6).trim();
if (raw === '[DONE]') continue;
try {
const parsed = JSON.parse(raw);
const delta =
parsed.choices?.[0]?.delta?.content ||
parsed.delta?.text || '';
if (delta) {
accumulated += delta;
setStreamingContent(accumulated);
}
} catch (_) {}
}
}
🧰 Tools
🪛 ast-grep (0.44.1)

[warning] 590-590: Avoid using the initial state variable in setState
Context: setStreamingContent(accumulated)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/next/src/index.js` around lines 569 - 595, The SSE parsing in
useStreamingChat and ByokRelayClient.streamChat drops deltas when a data line is
split across chunk boundaries because each chunk is split and processed
immediately. Add a persistent text buffer in the reader loop, append decoded
chunk text to it, only parse complete newline-delimited lines, and keep any
trailing partial line for the next read. Apply the same buffering logic in both
useStreamingChat and ByokRelayClient.streamChat so JSON parsing only runs on
complete data: payloads and no content is silently lost.

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