growth(day-31): @byok-relay/next — Next.js App Router route handler factory, middleware, React hooks, ByokRelayClient - #67
Conversation
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
📝 WalkthroughWalkthroughThis PR adds eight new framework integration packages under ChangesRoot documentation and monorepo configuration
Framework integration packages (eight independent packages, each following the same pattern)
Estimated code review effort: 4 (Complex) | ~75 minutes Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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 winUse loader data instead of
windowin the Remix example
window.ENV.RELAY_URLruns during server rendering in Remix, so this example will throw before hydration. PassrelayUrlfrom 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 winSupport Anthropic SSE frames and trim the
data:prefix here. This parser only readschoices[0].delta.contentand requiresdata:, so Anthropiccontent_block_deltaevents anddata:lines without a trailing space are skipped. Match the non-streaming service/Astro parser by trimming first and falling back toparsed.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 winNormalize
listKeys()to the documented shape.
/keysreturns{ providers: [...] }, butpackages/angular/src/index.js:169-176forwardsresp.json()unchanged. That leavesconst { 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 useproviders.🤖 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
storeKeysends the wrong JSON field./keys/:providerexpects{ key }insrc/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 winRace condition:
finallyblock overwrites new stream'sabortControllerandloadingstate.When
sendMessageis called while a previous stream is still in flight, the old call'sfinallyblock (lines 481-484) unconditionally setsabortController = nullandsetLoading(false). Because the oldreader.read()rejection is processed as a microtask after the newsendMessagehas already set its ownabortController(line 425) andsetLoading(true)(line 421), the oldfinallyoverwrites both — making the new stream unstoppable and clearing the loading indicator prematurely.The Next.js
useStreamingChatavoids this by guarding withif (abortRef.current === ac) abortRef.current = null(seepackages/next/src/index.js:518-630). The same pattern should be applied here.
createChatStore.sendMessagehas a analogous but less severe issue — overlapping calls can have the oldfinally'ssetLoading(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 winUpstream error details are exposed to clients in 502 responses.
err.messagefrom a failedfetchcan 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 winNo timeout on upstream
fetchcan hang requests indefinitely.Both the middleware and route handler call
fetch(upstreamUrl, init)without anAbortSignal. 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 winRoute handler drops query string when forwarding upstream.
upstreamUrlis built from the catch-all path segments only (line 127). The original request's query string (e.g.?deep=1fromuseRelayHealth) is never appended. The middleware factory (line 276-277) correctly forwardsurl.search, but the route handler does not.This means
GET /api/relay/health?deep=1reaches the relay asGET /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
ByokRelayClientkey-management methods don't checkresp.ok— errors returned as data.
storeKey,listKeys,deleteKey,rotateKey,stats,getModels, anddeleteAccountall returnresp.json()without checkingresp.ok. If the relay returns an error response, the error JSON is silently returned as if it were a success.register()(line 735) andchat()(line 829) correctly checkresp.okand 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 winAsync test uses sync
test()runner — assertion failures are silently swallowed.
test()callsfn()without awaiting the returned promise. Thetry/catchonly catches synchronous errors, so theassert.strictEqualinside 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 winAbort handler uses stale
streamingContentinstead of localaccumulated.On abort, the catch block (line 611) reads
streamingContentfrom the closure, which is the value captured whensendMessagewas created — not the latest streamed content. The local variableaccumulatedhas the correct partial content but is not used. This means aborted streams may commit an empty or stale assistant message.Additionally,
streamingContentin theuseCallbackdependency 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 winSSE stream parsing loses content split across chunks and doesn't exit on
[DONE]
chunk.split('\n')doesn't buffer incomplete lines — if adata:payload is split across tworead()chunks, the first half fails JSON parsing (silently caught) and the second half doesn't start withdata:(skipped), so content is lost. Additionally,breakon[DONE](lines 455 and 706) only exits theforloop, not thewhileloop, so the reader continues after the stream ends. The Hono package'sstreamChatcorrectly handles this with a buffer (buf = lines.pop()).The same bug exists in both
useStreamingChat(lines 447-465) andByokRelayClient.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 winAsync tests use
test()instead ofasyncTest(), silently swallowing assertion failures
test()callsfn()without awaiting the returned promise —✅andpassed++execute before the assertion runs. If the assertion fails, the rejection is unhandled andfailedis 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 winPartial commit on abort uses stale
streamingContentstate instead of localaccumulated
streamingContentin the catch closure is captured from the render whensendwas created, not from thesetStreamingContent(accumulated)calls during streaming. SincestreamingContentstarts as'', theelse if (streamingContent)branch is never taken — partial content is silently discarded on abort.Use the local
accumulatedvariable, which always has the latest content. Also removestreamingContentfrom thesenddependency array (line 480) since it will no longer be referenced insidesend.🐛 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 }— missingexpires_at. When fresh, it returns the full upstream responsedatawhich includesexpires_at. The JSDoc declares@returns {Promise<{token:string,expires_at:string}>}. Callers destructuringexpires_atwill getundefinedon 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
dataobject 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 winUse
x-relay-tokenfor protected relay requests The relay server only acceptsx-relay-tokenon/keys,/stats, and/relay/*; theseAuthorization: Bearercalls will 401storeKey,deleteKey,listKeys,useChat, anduseStreamingChat.🤖 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
createByokRelayProviderdoesn't await token registration as documented.The README states the async factory "awaits token registration before returning," but the implementation (
src/index.jslines 393–405) returns immediately with a lazygetToken(). Users whoawait createByokRelayProvider(...)expecting the token to be ready will find it is not — the behavior is identical to the sync factory.Additionally, unlike
createByokRelayProviderSync, the asyncgetToken()lacks_pendingpromise deduplication, so concurrent calls can trigger duplicatePOST /usersregistrations.🔧 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 winUse POST and
{ key }forstoreProviderKeypackages/vercel-ai/src/index.js:58-65
The relay only acceptsPOST /keys/:providerand readsreq.body.key; thisPUT+{ 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 winSettings are spread into the request body without camelCase-to-snake_case conversion.
The
...settingsspread (line 191) passes user-provided settings as-is. The README documents camelCase keys likemaxTokensandtopPin 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 settingsettings: { 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) andtemperature(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 winNormalize the duplicate 2026-07-06 record.
The file now has two rows for the same date and mixes
clones_14d/views_14dwithclones/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 winAdd the Angular imports for
*ngFor
The example needsstandalone: trueplusimports: [CommonModule](orNgFor) 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 winImport
ForandShowin the SolidJS example — the snippet uses both components without importing them fromsolid-js, so it won’t compile as written. Also,createStreamingChatStoredoesn’t needappId.🤖 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 winRemove the dead token and guard Angular 14 explicitly
RELAY_CONFIG_TOKENis never used;configis already captured by the factories, so this allocation can go away.makeEnvironmentProvidersis Angular 15+; with the currentpeerDependenciesrange (>=14.0.0), Angular 14 will hit thecatchand 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 winPath prefix matching can over-match unrelated routes.
url.pathname.startsWith(pathPrefix)matches/api/relayfoowhenpathPrefixis/api/relay, causing unrelated routes to be proxied instead of callingnext(). 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 winRemaining SSE buffer is discarded after stream ends.
When the upstream closes the connection without a trailing newline, the last SSE event remains in
bufferand 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
startsWithprefix check can match unintended paths.
/relaywould also match/relay-adminor/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._tokenis set tonullbut the storage adapter still holds the token. A subsequentensureToken()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 winRestore
globalThis.fetchinfinallyto prevent mock leakage.All fetch-mocking tests save
origFetch, overrideglobalThis.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. Usetry/finallyto 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
clearMessagesshould stop the active stream before clearing state.If a stream is in progress when
clearMessagesis called, the stream continues running and itsonDonecallback will append an assistant message to the freshly-cleared list. The Remix implementation callsstopStreaming()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
stopStreamingis 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,assertnever throws, andstopStreaming()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
deleteAccountremoves the local token before verifying server-side deletion.
storage.removeItemruns unconditionally after theDELETErequest, 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
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (36)
README.mdllms.txtmetrics/daily.jsonlpackage.jsonpackages/angular/README.mdpackages/angular/package.jsonpackages/angular/src/index.jspackages/angular/test/services.test.jspackages/astro/README.mdpackages/astro/package.jsonpackages/astro/src/index.jspackages/astro/test/integration.test.jspackages/hono/README.mdpackages/hono/package.jsonpackages/hono/src/index.jspackages/hono/test/middleware.test.jspackages/next/README.mdpackages/next/package.jsonpackages/next/src/index.jspackages/next/test/next.test.jspackages/preact/README.mdpackages/preact/package.jsonpackages/preact/src/index.jspackages/preact/test/hooks.test.jspackages/remix/README.mdpackages/remix/package.jsonpackages/remix/src/index.jspackages/remix/test/remix.test.jspackages/solid/README.mdpackages/solid/package.jsonpackages/solid/src/index.jspackages/solid/test/stores.test.jspackages/vercel-ai/README.mdpackages/vercel-ai/package.jsonpackages/vercel-ai/src/index.jspackages/vercel-ai/test/smoke.test.js
| const PROVIDER_PATHS = { | ||
| openai: 'chat/completions', | ||
| anthropic: 'messages', | ||
| google: 'models/{model}:generateContent', | ||
| groq: 'chat/completions', | ||
| mistral: 'chat/completions', | ||
| openrouter: 'chat/completions', | ||
| }; |
There was a problem hiding this comment.
🎯 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.
| 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); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🔒 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.
| 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 (_) {} | ||
| } | ||
| } |
There was a problem hiding this comment.
🗄️ 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.
| 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.
Growth Day 31 —
@byok-relay/nextNext.js App Router integration for BYOK AI relay. Targets the largest React meta-framework — Next.js 13+ App Router.
Four concerns
createRelayRouteHandler(opts)— returns{ GET, POST, PUT, PATCH, DELETE, OPTIONS }forapp/api/relay/[...path]/route.js.RELAY_URLstays server-only inprocess.env.createRelayMiddleware(opts)— Next.jsmiddleware.jsexport; Edge Runtime compatible.useByokRelay,useChat,useStreamingChat,useRelayHealth) — for'use client'components.ByokRelayClient— plain-JS class for Server Components, Server Actions, API Routes; customstorageadapter for cookie/session stores.Tests
25 passed, 0 failed
Files changed
packages/next/— new packagellms.txt— Next.js App Router sectionREADME.md— @byok-relay/next section after Honometrics/daily.jsonl— 2026-07-08 snapshotNext for Avi
Summary by CodeRabbit