Skip to content

feat(client): add client SDKs with HTTP client, React hooks, and AI SDK adapter - #896

Merged
murdore merged 1 commit into
releasefrom
feat/client-sdks-rebased
Mar 23, 2026
Merged

murdore merged 1 commit into
releasefrom
feat/client-sdks-rebased

Conversation

@murdore

@murdore murdore commented Mar 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • HTTP Client with middleware/interceptor chain, retry logic, and streaming support
  • React Hooks (useChat, useAgent, useWorkflow, useVoice, useStream, useTools) with NeuroLinkProvider context
  • Vercel AI SDK Adapter implementing LanguageModelV1 for drop-in compatibility
  • SSE & WebSocket streaming clients with auto-reconnect
  • Authentication — OAuth2 token manager, JWT utilities, API key/Bearer middleware
  • Error handling — typed error hierarchy (ClientNetworkError, ClientRateLimitError, etc.)
  • Interceptors — composable middleware (logging, retry, rate limit, cache, timeout, auth)

Changes

New files

  • src/lib/client/ — 10 implementation files (httpClient, streamingClient, sseClient, wsClient, aiSdkAdapter, auth, errors, interceptors, reactHooks, index)
  • src/lib/types/clientTypes.ts — all client types in canonical src/lib/types/ location
  • docs/features/client-sdk.md — 614-line feature documentation with proper frontmatter
  • examples/client-sdks/ — 4 examples (node-backend, react-chat, vercel-ai-sdk, websocket-realtime)
  • test/continuous-test-suite-client.ts — 13-test continuous suite (all passing)

Modified files

  • src/lib/index.ts — added client SDK exports with Client-prefixed error names
  • src/lib/types/index.ts — added clientTypes selective exports with aliases
  • src/lib/server/adapters/honoAdapter.ts — fixed SSE streaming to emit proper StreamEvent format with [DONE] termination
  • package.json — added ./client sub-path export, test:client script, React devDeps
  • tsconfig.json / tsconfig.cli.json — added jsx: react-jsx for .tsx support
  • docs/features/index.md — added Client SDK entry to feature table

Architecture decisions

  • Types in src/lib/types/clientTypes.ts (canonical location, not in feature dir)
  • Error classes prefixed with Client to avoid collisions with core error types
  • No coupling to NeuroLink class — client SDK is a pure HTTP consumer
  • Server adapter SSE fix benefits all streaming consumers, not just client SDK

Test plan

  • npx tsc --noEmit --strict — 0 errors
  • pnpm run test:client — 13/13 passing (health, generate, stream, middleware, auth, errors, JWT, AI SDK, SSE, error handling, React hooks, retry)
  • Pre-commit hooks pass (typecheck, lint, format)

Summary by CodeRabbit

New Features

  • Client SDK – Type-safe JavaScript/TypeScript HTTP client with full API support, middleware system, and built-in retry/rate-limiting/caching interceptors.
  • React Integration – Hooks for chat, agents, workflows, voice, streaming, and tools; provider component for easy setup.
  • Streaming Support – Server-Sent Events (SSE) and WebSocket clients for real-time interactions across multiple transport protocols.
  • Authentication – Built-in OAuth2, JWT, and API key support with token managers and middleware helpers.
  • Vercel AI SDK Adapter – Language model provider to integrate NeuroLink with Vercel AI SDK's generateText, streamText, and generateObject.
  • Examples & Documentation – Comprehensive guides with runnable examples for Node.js backends, React apps, and real-time WebSocket use cases.

Documentation

  • Added Client SDK feature documentation with API reference and integration guides.

Tests

  • Added continuous test suite validating client-side behaviors and SDK functionality.

Copilot AI review requested due to automatic review settings March 22, 2026 19:33
@vercel

vercel Bot commented Mar 22, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
neurolink Error Error Mar 23, 2026 7:25pm

@coderabbitai

coderabbitai Bot commented Mar 22, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 82c8c8a9-4f6c-4da1-a084-b0c13b91690d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

This PR introduces a complete Client SDK for NeuroLink, featuring a typed HTTP client with middleware support, React hooks for chat/agents/workflows/voice/streaming/tools, OAuth2 and JWT authentication managers, comprehensive error handling, SSE and WebSocket streaming clients, Vercel AI SDK integration, and extensive examples and documentation.

Changes

Cohort / File(s) Summary
Documentation
docs/features/client-sdk.md, docs/features/index.md
Added comprehensive documentation for the Client SDK covering HTTP/SSE/WebSocket clients, React hooks, Vercel AI SDK adapter, authentication, interceptors, and error handling. Updated feature index with new entry.
HTTP Client Core
src/lib/client/httpClient.ts, src/lib/client/index.ts, src/lib/types/clientTypes.ts
Implemented NeuroLinkClient with middleware chaining, request/response handling, retry logic, and APIs for generation, agents, workflows, tools, providers, and health checks. Defined comprehensive client type definitions covering config, requests, responses, and streaming.
Authentication & Token Management
src/lib/client/auth.ts, src/lib/types/clientTypes.ts
Added OAuth2 and JWT token managers with proactive refresh deduplication, plus middleware factories for API key, bearer token, and auth-with-retry patterns. Includes JWT utilities and OAuth2 error classes.
Error Handling System
src/lib/client/errors.ts
Introduced structured error hierarchy with NeuroLinkError base class, specialized error types (HTTP, network, timeout, validation, auth, rate limit, provider), factory functions for error mapping, and classification utilities.
Middleware & Interceptors
src/lib/client/interceptors.ts
Implemented middleware factories for logging, retry with exponential backoff, rate limiting, request/response transformation, caching, timeout enforcement, and error handling, plus composition helpers (composeMiddleware, conditionalMiddleware).
Streaming Clients
src/lib/client/sseClient.ts, src/lib/client/wsClient.ts, src/lib/client/streamingClient.ts
Added SSE client with auto-reconnection, WebSocket client with heartbeat and message queueing, and unified streaming abstraction (createStreamingClient, createAsyncStream, collectStream) supporting both transports.
React Integration
src/lib/client/reactHooks.tsx
Implemented React hooks (useChat, useAgent, useWorkflow, useVoice, useStream, useTools) with provider context, state management, streaming support, and Web Speech API integration for voice.
AI SDK Adapter
src/lib/client/aiSdkAdapter.ts
Created Vercel AI SDK compatibility layer with NeuroLinkLanguageModel and NeuroLinkProvider classes, supporting generateText/streamText/generateObject, plus createStreamingResponse() for SSE output.
Examples
examples/client-sdks/node-backend.ts, examples/client-sdks/react-chat-app.tsx, examples/client-sdks/vercel-ai-sdk.ts, examples/client-sdks/websocket-realtime.ts
Added comprehensive examples: Express backend with full REST API endpoints and error handling, React UI with tabbed chat/agent/voice/tools interface supporting multimodal input, Vercel AI SDK integration patterns, and WebSocket real-time communication scenarios.
Test Suite & Configuration
test/continuous-test-suite-client.ts, package.json, tsconfig.json, tsconfig.cli.json
Added end-to-end test suite validating client health, generation, streaming, middleware, auth, error handling, and React hook availability. Updated package exports, added React dependencies, and enabled JSX transpilation.
Type Re-exports & Library Integration
src/lib/index.ts, src/lib/types/index.ts, src/lib/server/adapters/honoAdapter.ts
Re-exported all client SDK functionality from main library entry point with aliased types, updated server adapter to emit normalized StreamEvent objects, and aliased client-specific types in type index.

Sequence Diagram(s)

sequenceDiagram
    participant Client as React Component
    participant Provider as NeuroLinkProvider
    participant Middleware as Middleware Chain
    participant HTTP as HTTP Client
    participant Server as NeuroLink Server

    Client->>Provider: useChat(options)
    Provider->>Provider: createClient(config)
    
    Client->>HTTP: submit message
    HTTP->>Middleware: request(message)
    Middleware->>Middleware: api-key-auth
    Middleware->>Middleware: logging
    Middleware->>Middleware: retry-logic
    Middleware->>HTTP: continue
    
    HTTP->>Server: POST /api/chat
    Server-->>HTTP: SSE response (text/event-stream)
    HTTP->>HTTP: parse StreamEvent
    HTTP->>Client: onText callback
    HTTP->>Client: onToolCall callback
    HTTP->>Client: onDone callback
    
    Client->>Client: update chat state
    Client->>Client: render messages
Loading
sequenceDiagram
    participant App as Next.js App
    participant Adapter as NeuroLinkProvider
    participant Vercel as Vercel AI SDK
    participant Client as NeuroLink Client
    participant Server as LLM Server

    App->>Adapter: createNeuroLinkProvider(config)
    App->>Vercel: generateText({model: neurolink(...), prompt})
    
    Vercel->>Adapter: doGenerate(options)
    Adapter->>Client: generate(prompt, config)
    Client->>Server: POST /api/generate
    Server-->>Client: GenerateResponse
    Client->>Adapter: {text, finishReason, usage}
    Adapter->>Vercel: LanguageModelResponse
    Vercel->>App: {text, usage}
Loading
sequenceDiagram
    participant Browser as Browser
    participant SSEClient as SSEClient
    participant Fetch as fetch() API
    participant Server as SSE Endpoint
    participant Reconnect as Auto-Reconnect

    Browser->>SSEClient: stream(path, callbacks)
    SSEClient->>Fetch: POST /api/stream
    Fetch->>Server: connect
    Server-->>Fetch: 200 OK (text/event-stream)
    Fetch-->>SSEClient: ReadableStream
    
    Server-->>SSEClient: data: {type: "text", content: "..."}
    SSEClient->>Browser: onText callback
    
    Server-->>SSEClient: data: {type: "tool-call", name: "..."}
    SSEClient->>Browser: onToolCall callback
    
    Server-->>SSEClient: data: "[DONE]"
    SSEClient->>Browser: onDone callback
    
    alt Network Error
        Fetch-->>SSEClient: Error
        SSEClient->>SSEClient: autoReconnect enabled
        SSEClient->>Reconnect: exponential backoff
        Reconnect->>Fetch: retry connection
    end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • juspay/neurolink#181: Modifies centralized client/SDK type bundles and updates type re-export surface to support new Client SDK types.

Suggested labels

released

Poem

🐰 A rabbit hops through the SDK maze,
Middleware chains in a client craze,
Hooks for chat, voice, and agents too,
Streaming events in every hue,
React and types, all shiny new! ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the primary changes: introduction of a client SDK with HTTP client, React hooks, and AI SDK adapter support.
Docstring Coverage ✅ Passed Docstring coverage is 89.42% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/client-sdks-rebased

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 and usage tips.

@github-actions

github-actions Bot commented Mar 22, 2026 •

Copy link
Copy Markdown
Contributor

✅ Single Commit Policy - COMPLIANT

Status: Policy requirements met • 1 commit • Valid format • Ready for merge

📊 View validation details

📝 Commit Details

  • Hash: ff5baddfca7a3fdf3a33b67bc51feb23138beb04
  • Message: feat(client): add client SDKs with HTTP client, React hooks, and AI SDK adapter
  • Author: Sachin Sharma

✅ Validation Results

  • Single commit requirement met
  • No merge commits in branch
  • Semantic commit message format verified
  • Ready for squash merge to release branch

🤖 Automated validation by NeuroLink Single Commit Enforcement

@github-actions

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

@github-actions

github-actions Bot commented Mar 22, 2026 •

Copy link
Copy Markdown
Contributor

Documentation Validation Results

🚀 Documentation validation passed!

Check Status Result
Frontmatter Validation ✅ Passed
TypeScript Check ✅ Passed
Build ✅ Passed
Link Validation ✅ Passed

📦 Build artifact uploaded successfully. Ready for deployment preview.

Commit: 0c1176740df9eb269569a91c1709efc399cc7abb | Workflow: View logs

Copilot AI 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.

Pull request overview

This PR introduces a full client-side SDK surface (HTTP client + streaming clients + React hooks + Vercel AI SDK adapter), wires up public exports/types, and updates the Hono SSE adapter so streaming output matches the client SDK’s expected StreamEvent + [DONE] termination format.

Changes:

  • Add new src/lib/client/* client SDK implementation (HTTP client, auth utilities, interceptors, SSE/WS streaming clients, AI SDK adapter, React hooks) and canonical client SDK types in src/lib/types/clientTypes.ts.
  • Export client SDK APIs/types from the root SDK entrypoints and package subpath (./client), and enable TSX builds via jsx: react-jsx.
  • Update server-side Hono SSE streaming to emit StreamEvent-shaped payloads and [DONE] termination.

Reviewed changes

Copilot reviewed 23 out of 25 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
tsconfig.json Enables react-jsx so .tsx client hook code can typecheck/build.
tsconfig.cli.json Extends CLI build to support .tsx under src/lib/**.
src/lib/types/index.ts Re-exports client SDK types with aliases to reduce collisions.
src/lib/types/clientTypes.ts Adds canonical client SDK type surface (config, streaming, hooks, auth, AI adapter).
src/lib/server/adapters/honoAdapter.ts Adjusts SSE streaming payloads to align with client SDK expectations.
src/lib/client/aiSdkAdapter.ts Adds a Vercel AI SDK-compatible adapter (LanguageModel-style).
src/lib/client/interceptors.ts Adds composable middleware (auth/logging/retry/rate-limit/cache/timeout/error handling).
src/lib/client/sseClient.ts Adds a dedicated SSE streaming client implementation.
src/lib/client/auth.ts Adds OAuth2/JWT token managers and JWT helpers.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +720 to +747
export function createTimeoutInterceptor(
options: TimeoutInterceptorOptions,
): Middleware {
const { timeout, onTimeout } = options;

return async (request, next) => {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
onTimeout?.(request);
}, timeout);

try {
// Store original abort handling
request.context.timeoutSignal = controller.signal;
const response = await next();
clearTimeout(timeoutId);
return response;
} catch (error) {
clearTimeout(timeoutId);
if ((error as Error).name === "AbortError") {
throw new Error(`Request timed out after ${timeout}ms`, {
cause: error,
});
}
throw error;
}
};

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

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

createTimeoutInterceptor() sets request.context.timeoutSignal, but the HTTP client request execution doesn’t read/use this field, so the interceptor won’t actually abort requests. Either (a) have the HTTP client combine options.signal with request.context.timeoutSignal when calling fetch, or (b) implement the timeout by racing next() against a timer and aborting via an AbortController that is actually wired into the request.

Copilot uses AI. Check for mistakes.
Comment on lines +119 to +170
constructor(config: SSEConfig) {
this.config = {
baseUrl: config.baseUrl,
apiKey: config.apiKey ?? "",
token: config.token ?? "",
timeout: config.timeout ?? 30000,
headers: config.headers ?? {},
autoReconnect: config.autoReconnect ?? true,
maxReconnectAttempts: config.maxReconnectAttempts ?? 5,
reconnectDelay: config.reconnectDelay ?? 1000,
maxReconnectDelay: config.maxReconnectDelay ?? 30000,
useNativeEventSource: config.useNativeEventSource ?? false,
};
}

/**
* Get current connection state
*/
getState(): SSEState {
return this.state;
}

/**
* Check if connected
*/
isConnected(): boolean {
return this.state === "connected";
}

/**
* Stream from an endpoint using SSE
*/
async stream(
path: string,
options: SSERequestOptions = {},
callbacks: StreamCallbacks = {},
): Promise<void> {
this.setState("connecting");
this.abortController = new AbortController();

const url = this.buildUrl(path);
const headers = this.buildHeaders(options.headers);
logger.debug(`[NeuroLinkSSE] Connecting to ${url}`);

try {
const response = await fetch(url, {
method: options.body ? "POST" : "GET",
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
signal: options.signal ?? this.abortController.signal,
});

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

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

SSE client config includes timeout and useNativeEventSource, but neither is currently applied: requests can hang indefinitely, and the native EventSource path is never used. Either implement these options (timeout via AbortController timer; native EventSource when enabled) or remove them to avoid a misleading API surface.

Copilot uses AI. Check for mistakes.
Comment on lines +163 to +169
try {
const response = await fetch(url, {
method: options.body ? "POST" : "GET",
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
signal: options.signal ?? this.abortController.signal,
});

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

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

NeuroLinkSSE.stream() always uses the global fetch instead of the ClientConfig.fetch override. This breaks usage in environments without a native fetch (or where a custom fetch is required, e.g., proxies/instrumentation). Consider accepting/using config.fetch similar to NeuroLinkClient (defaulting to globalThis.fetch).

Copilot uses AI. Check for mistakes.
Comment thread src/lib/server/adapters/honoAdapter.ts Outdated
Comment on lines +512 to +515
// Unknown shape — wrap as data event
return {
type: "data",
data: chunk,

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

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

toStreamEvent() returns { type: "data", data: chunk } for unknown chunk shapes, but StreamEventType (in clientTypes.ts) does not include "data" and the SSE client switch won’t dispatch it. Consider mapping unknown chunks to a supported event type (e.g., metadata with metadata: { raw: chunk }) or extending the shared StreamEventType to include "data" so clients can handle it explicitly.

Suggested change
// Unknown shape — wrap as data event
return {
type: "data",
data: chunk,
// Unknown shape — wrap as metadata event so clients can still consume it
return {
type: "metadata",
metadata: { raw: chunk },

Copilot uses AI. Check for mistakes.
Comment on lines +431 to +444
// Error result, send as error event
const errorResult = result as {
error?: { message?: string; code?: string };
};
await stream.writeSSE({
data: JSON.stringify({
type: "error",
error: {
code: errorResult.error?.code ?? "STREAM_ERROR",
message: errorResult.error?.message ?? "Stream error",
},
timestamp: Date.now(),
}),
});

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

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

SSE error events emitted here don’t include the status field required by the client-side ApiError type (you only send { code, message }). This can lead to inconsistent error handling on the client. Include a numeric HTTP status (e.g., errorResult.httpStatus ?? 500 / 500) when building the error payload.

Copilot uses AI. Check for mistakes.
Comment thread src/lib/server/adapters/honoAdapter.ts Outdated
Comment on lines 446 to 448
// Single result, send as complete event
await stream.writeSSE({
data: JSON.stringify(result),

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

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

In the non-iterable success path, the code writes JSON.stringify(result) directly. If result is not already a StreamEvent (no type), the client SSE parser will JSON.parse it but then ignore it because event.type is missing/unknown. Consider wrapping single results into a valid StreamEvent (e.g., done or text) or running them through toStreamEvent() as well.

Suggested change
// Single result, send as complete event
await stream.writeSSE({
data: JSON.stringify(result),
// Single result, normalize to StreamEvent and send as complete event
const streamEvent = this.toStreamEvent(result);
await stream.writeSSE({
data: JSON.stringify(streamEvent),

Copilot uses AI. Check for mistakes.
Comment thread src/lib/client/aiSdkAdapter.ts Outdated
Comment on lines +152 to +197
let fullText = "";

try {
const result = await self.client.stream(
{
input: { text: inputText },
provider: self.provider,
model: self.modelId,
temperature: temperature ?? self.options.temperature,
maxTokens: maxTokens ?? self.options.maxTokens,
systemPrompt,
},
{
onText: (text) => {
fullText += text;
},
},
{
signal: abortSignal,
},
);

// Yield the accumulated text as a single delta
if (fullText) {
yield {
type: "text-delta" as const,
textDelta: fullText,
};
}

// Yield finish event
yield {
type: "finish" as const,
finishReason: self.mapFinishReason(result.finishReason),
usage: result.usage
? {
promptTokens: result.usage.promptTokens,
completionTokens: result.usage.completionTokens,
}
: undefined,
};
} catch {
yield {
type: "finish" as const,
finishReason: "error",
};

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

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

doStream() buffers the entire response into fullText and yields a single text-delta only after streaming completes, which defeats incremental streaming expected by the AI SDK (streamText consumers won’t receive partial deltas). Consider implementing an async-queue/AsyncIterable that yields each delta as onText fires (or reuse the SDK’s createAsyncStream helper) and then yield the final finish event.

Suggested change
let fullText = "";
try {
const result = await self.client.stream(
{
input: { text: inputText },
provider: self.provider,
model: self.modelId,
temperature: temperature ?? self.options.temperature,
maxTokens: maxTokens ?? self.options.maxTokens,
systemPrompt,
},
{
onText: (text) => {
fullText += text;
},
},
{
signal: abortSignal,
},
);
// Yield the accumulated text as a single delta
if (fullText) {
yield {
type: "text-delta" as const,
textDelta: fullText,
};
}
// Yield finish event
yield {
type: "finish" as const,
finishReason: self.mapFinishReason(result.finishReason),
usage: result.usage
? {
promptTokens: result.usage.promptTokens,
completionTokens: result.usage.completionTokens,
}
: undefined,
};
} catch {
yield {
type: "finish" as const,
finishReason: "error",
};
// Async queue to deliver events to the consumer as they arrive
const queue: any[] = [];
const waiters: Array<() => void> = [];
let closed = false;
const notify = () => {
while (waiters.length > 0) {
const resolve = waiters.shift();
if (resolve) {
resolve();
}
}
};
const push = (item: any) => {
queue.push(item);
notify();
};
// Start the underlying client stream in the background so we can
// yield incremental deltas from this async generator.
(async () => {
try {
const result = await self.client.stream(
{
input: { text: inputText },
provider: self.provider,
model: self.modelId,
temperature: temperature ?? self.options.temperature,
maxTokens: maxTokens ?? self.options.maxTokens,
systemPrompt,
},
{
onText: (text) => {
if (text) {
push({
type: "text-delta" as const,
textDelta: text,
});
}
},
},
{
signal: abortSignal,
},
);
// Yield finish event once the underlying stream has completed
push({
type: "finish" as const,
finishReason: self.mapFinishReason(result.finishReason),
usage: result.usage
? {
promptTokens: result.usage.promptTokens,
completionTokens: result.usage.completionTokens,
}
: undefined,
});
} catch {
// On error, emit a finish event with error reason
push({
type: "finish" as const,
finishReason: "error",
});
} finally {
closed = true;
notify();
}
})();
// Async generator loop: yield items from the queue as they arrive
while (!closed || queue.length > 0) {
if (queue.length === 0) {
await new Promise<void>((resolve) => {
waiters.push(resolve);
});
continue;
}
const item = queue.shift();
if (item !== undefined) {
yield item;
}

Copilot uses AI. Check for mistakes.
Comment thread src/lib/client/auth.ts
Comment on lines +480 to +492
export function decodeJWTPayload(token: string): Record<string, unknown> {
try {
const parts = token.split(".");
if (parts.length !== 3) {
throw new Error("Invalid JWT format");
}

const payload = parts[1];
const decoded = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));
return JSON.parse(decoded);
} catch {
throw new Error("Failed to decode JWT token");
}

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

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

decodeJWTPayload() uses atob() directly on a base64url string without adding required padding, and atob is not available in all Node.js runtimes. This can make JWT decoding fail unexpectedly in Node environments. Consider a base64url decoder that normalizes padding and uses globalThis.atob when available, otherwise falls back to Buffer.from(payload, "base64").

Copilot uses AI. Check for mistakes.

@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: 19

Note

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
package.json (1)

64-79: ⚠️ Potential issue | 🟠 Major

Ensure the new client suite runs as part of the default test gate.

The test:ci, quality:all, pre-push, and release scripts all route through pnpm run test, which executes only continuous-test-suite.ts. The new test:client script (pointing to continuous-test-suite-client.ts) is defined but exists as a parallel, standalone test—it is not invoked by the umbrella suite. This means the new client SDK code is outside the normal shipping gate and will not be tested by default CI runs.

Either compose the client tests into the umbrella suite or update the default test gate to include both suites.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package.json` around lines 64 - 79, The default CI test gate doesn't run the
new client suite; update the umbrella test invocation so the client tests run
under the default gate—either (A) compose continuous-test-suite-client.ts into
the master umbrella test runner (continuous-test-suite.ts) by
importing/executing the client suite from within the existing runner, or (B)
change the test aggregation script (what "pnpm run test" triggers / the
"test:ci" / "test" script) to invoke both continuous-test-suite.ts and
continuous-test-suite-client.ts (or run the named scripts "test:client"
alongside the existing suite) so that test:client is executed by the CI gate.
Ensure you modify the script entries referencing test:ci / test / pnpm run test
accordingly and keep naming consistent with the existing script keys
(test:client, test:ci, test).
🟡 Minor comments (7)
src/lib/server/adapters/honoAdapter.ts-512-517 (1)

512-517: ⚠️ Potential issue | 🟡 Minor

Don't emit the unsupported "data" event type.

StreamEventType in src/lib/types/clientTypes.ts:140-148 does not include "data", and src/lib/client/sseClient.ts:472-507 has no handler for it. Any chunk that falls through here is silently dropped unless the client contract is extended on both sides in the same PR.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/server/adapters/honoAdapter.ts` around lines 512 - 517, The code in
honoAdapter.ts currently emits an unsupported event object with type "data" for
unknown chunk shapes; instead, stop emitting that unsupported "data" event
(which is not part of StreamEventType) — change the fallback to return
undefined/null (or throw) so the caller drops the chunk, and add a
debug/processLogger.warn indicating an unexpected chunk shape; ensure the
function's return type allows undefined and that the caller filters out falsy
results, and reference StreamEventType and the SSE client handlers in
src/lib/client/sseClient.ts to confirm no "data" handling is required.
test/continuous-test-suite-client.ts-289-304 (1)

289-304: ⚠️ Potential issue | 🟡 Minor

Fail this test when the completion event is missing.

This branch still passes as long as any text arrives. If the stream regresses and stops emitting [DONE]/onDone, the suite will stay green and miss the SSE completion fix this PR is supposed to protect.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/continuous-test-suite-client.ts` around lines 289 - 304, The current
post-stream check in the test (variables result, content, textChunks,
doneReceived, and function logTest) marks the test PASS as long as any text
arrived, but it must also ensure the SSE completion event was received; update
the logic so that if doneReceived is false you call logTest with a FAIL and a
descriptive message (e.g., "Stream ended without completion ([DONE] missing)"),
return false, and only log PASS and return true when textChunks has data and
doneReceived is true.
examples/client-sdks/vercel-ai-sdk.ts-327-345 (1)

327-345: ⚠️ Potential issue | 🟡 Minor

Use the package that this PR actually publishes in these snippets.

Both Next.js examples import createNeuroLinkProvider from @neurolink/ai-sdk, but the adapter added in this PR is exposed from @juspay/neurolink/client. Copy-pasting either template currently produces a module resolution failure.

Also applies to: 351-385

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/client-sdks/vercel-ai-sdk.ts` around lines 327 - 345, The example
imports the NeuroLink provider from the wrong package; update the import so
createNeuroLinkProvider is imported from the package published by this PR
(replace imports of createNeuroLinkProvider from '@neurolink/ai-sdk' with the
published package, e.g., '@juspay/neurolink/client') wherever it appears
(symbols: createNeuroLinkProvider, neurolink) in the Next.js snippets that also
call streamText and return StreamingTextResponse so the runtime can resolve the
module.
src/lib/client/sseClient.ts-514-548 (1)

514-548: ⚠️ Potential issue | 🟡 Minor

Reconnect attempts counter is never reset on successful reconnection.

When attemptReconnect successfully reconnects by calling this.stream(), the reconnectAttempts counter is not reset. If the connection drops again later, it will continue from the previous count and may hit maxReconnectAttempts prematurely.

🔧 Proposed fix

Add a reset in stream() after successful connection:

       this.setState("connected");
       this.eventHandlers.onOpen?.();
       logger.debug(`[NeuroLinkSSE] Connected to ${url}`);
+      this.reconnectAttempts = 0; // Reset on successful connection

       await this.processStream(response, callbacks);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/client/sseClient.ts` around lines 514 - 548, The reconnectAttempts
counter is never reset, so after a successful reconnect the next drop will start
from the previous attempt count; update the stream() success path to reset
this.reconnectAttempts = 0 (or reset where the connection/open is confirmed) so
attemptReconnect() starts fresh on subsequent drops; specifically modify the
stream() implementation (the code that runs after a successful
connection/handshake/open) to set reconnectAttempts to 0, leaving
attemptReconnect, reconnectDelay calculation, and the maxReconnectAttempts
checks unchanged.
src/lib/client/auth.ts-403-414 (1)

403-414: ⚠️ Potential issue | 🟡 Minor

Token refresh result is not persisted back to config.

When refreshToken() succeeds at line 406, the new token is used for the current request but config.token and config.tokenExpiresAt are not updated. Subsequent requests will still see the old (possibly expired) values and trigger refresh again.

🔧 Proposed fix
     // Handle dynamic token refresh
     if (config.refreshToken && config.tokenExpiresAt) {
       const bufferMs = config.refreshBufferMs ?? 60000;
       if (Date.now() > config.tokenExpiresAt - bufferMs) {
         const newToken = await config.refreshToken();
+        // Update config so subsequent requests use the new token
+        config.token = newToken;
+        config.tokenExpiresAt = Date.now() + 3600000; // Or extract from refresh response
         const headerName = config.headerName ?? "Authorization";
         request.headers[headerName] = `Bearer ${newToken}`;
       }
     }

Note: This requires config to be mutable or the middleware to maintain internal state. Consider using a token manager pattern similar to OAuth2TokenManager for consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/client/auth.ts` around lines 403 - 414, The middleware currently
calls config.refreshToken() and uses the returned newToken for the outgoing
request but does not persist it back to config, so future requests still use the
old token; modify the middleware (the block that checks config.refreshToken,
config.tokenExpiresAt, refreshBufferMs and sets request.headers[headerName]) to
persist the refresh result: after calling config.refreshToken(), detect whether
the result is a string or an object (e.g. { token, expiresAt } or { token,
tokenExpiresAt }), set config.token = token (or the string), set
config.tokenExpiresAt = expiresAt (or Date.now() + (config.tokenTTLMs ??
3600000) if expiresAt is not provided), then update request.headers[headerName]
= `Bearer ${token}`; this ensures config.token and config.tokenExpiresAt are
updated for subsequent requests.
src/lib/client/streamingClient.ts-970-976 (1)

970-976: ⚠️ Potential issue | 🟡 Minor

SSE transport getState() returns hardcoded "connected".

For the SSE transport path, getState() always returns "connected" regardless of actual connection state. This is inconsistent with the WebSocket transport which returns actual state.

🔧 Proposed fix

Track state in the SSE transport or return a more accurate value:

+  let sseState: WebSocketState = "disconnected";
   // SSE transport
   return {
-    connect: () => Promise.resolve(),
-    disconnect: () => {},
+    connect: () => { sseState = "connected"; return Promise.resolve(); },
+    disconnect: () => { sseState = "disconnected"; },
     // ... stream implementation ...
-    getState: () => "connected" as WebSocketState,
+    getState: () => sseState,
   };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/client/streamingClient.ts` around lines 970 - 976, The SSE transport
currently returns a hardcoded "connected" from getState(), so change getState()
on the SSE client to reflect the real EventSource state instead of a constant:
either read EventSource.readyState and map its numeric values to your
WebSocketState union or maintain an internal state variable updated on the
EventSource handlers (onopen -> "connected"/"open", onerror/close ->
"closed"/"disconnected", and initial -> "connecting"), then return that value
from getState(); update references to send, on, off, getState in the SSE
transport implementation accordingly.
src/lib/client/httpClient.ts-956-1020 (1)

956-1020: ⚠️ Potential issue | 🟡 Minor

WebSocket auto-reconnect may cause infinite loop if connection keeps failing.

The onclose handler schedules reconnection via setTimeout when autoReconnect is true and wsState === "disconnected". However, there's no retry limit, so a permanently unavailable server will cause infinite reconnection attempts.

🔧 Proposed fix

Add reconnection attempt tracking similar to WebSocketStreamingClient:

   private wsReconnectAttempts = 0;
+  private maxWsReconnectAttempts = 10;

   // In connectWebSocket:
   this.wsConnection.onopen = () => {
     this.wsState = "connected";
+    this.wsReconnectAttempts = 0; // Reset on success
     // ...
   };

   this.wsConnection.onclose = () => {
     // ...
-    if (options?.autoReconnect) {
+    if (options?.autoReconnect && this.wsReconnectAttempts < this.maxWsReconnectAttempts) {
+      this.wsReconnectAttempts++;
       const interval = options.reconnectInterval ?? 5000;
       setTimeout(() => {
         if (this.wsState === "disconnected") {
           this.connectWebSocket(options);
         }
       }, interval);
     }
   };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/client/httpClient.ts` around lines 956 - 1020, The onclose handler in
connectWebSocket can cause infinite reconnection attempts; add a retry counter
(e.g., this.wsReconnectAttempts) and a max retry limit (e.g.,
options.maxReconnectAttempts or this.config.maxReconnectAttempts) checked before
scheduling setTimeout, incrementing the counter on each scheduled reconnect and
resetting it to 0 in onopen; additionally, implement backoff by using either
options.reconnectInterval as a base and multiplying by the attempt count (or cap
at a max interval) and ensure wsState/wsConnection logic uses these symbols
(connectWebSocket, wsState, wsConnection, onopen, onclose,
options.autoReconnect) to locate and update the code.
🧹 Nitpick comments (7)
examples/client-sdks/react-chat-app.tsx (1)

124-144: Stabilize the demo session ID.

sessionId: \session-${Date.now()}`is regenerated on every render. Even ifuseChat()snapshots it today, this teaches a brittle pattern and will fragment conversations as soon as the hook readsoptions.sessionId` reactively.

Possible fix
 function ChatTab() {
   const [selectedImages, setSelectedImages] = useState<string[]>([]);
   const fileInputRef = useRef<HTMLInputElement>(null);
+  const sessionIdRef = useRef(`session-${Date.now()}`);
@@
   } = useChat({
     agentId: "general-assistant",
-    sessionId: `session-${Date.now()}`,
+    sessionId: sessionIdRef.current,
     systemPrompt: "You are a helpful assistant. Be concise and friendly.",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/client-sdks/react-chat-app.tsx` around lines 124 - 144, The
sessionId passed inline to useChat is recreated on every render (sessionId:
`session-${Date.now()}`) which can fragment conversations; inside ChatTab create
a stable session identifier once (for example with useRef or useMemo) and pass
that stable value to useChat (replace the inline template with
stableSessionIdRef.current or stableSessionId). Keep the generation logic (e.g.,
`session-${Date.now()}`) but ensure it runs only once when the component mounts
so useChat receives an immutable sessionId across renders.
src/lib/client/index.ts (2)

239-245: SSEConnectionState and SSEState are both exported.

Both types represent SSE connection states but have different values:

  • SSEConnectionState (from streamingClient.ts): includes "reconnecting"
  • SSEState (from sseClient.ts): includes "error" instead

Consider documenting the difference or unifying them.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/client/index.ts` around lines 239 - 245, There are two overlapping
SSE enums/types (SSEConnectionState from streamingClient.js and SSEState from
sseClient.ts) with inconsistent literal values ("reconnecting" vs "error"); pick
a single canonical type (preferably SSEConnectionState) and unify usages: update
sseClient.ts to use and export the canonical SSEConnectionState (or re-export
SSEConnectionState from streamingClient.js as SSEState if backward compatibility
is required), remove the duplicate definition, and ensure all references and the
public export block export only the unified type so the project has one
authoritative SSE state type and consistent literal values.

140-152: Potential naming conflict: NeuroLinkProvider is exported from both reactHooks and aiSdkAdapter.

Line 141 exports NeuroLinkProvider from reactHooks.js (React context provider), while line 182 exports NeuroLinkProvider as NeuroLinkAIProvider from aiSdkAdapter.js. The aliasing avoids a direct conflict, but consumers may be confused about which NeuroLinkProvider to use.

Consider renaming the React provider to NeuroLinkReactProvider or NeuroLinkContextProvider to make the distinction clearer:

 export {
-  NeuroLinkProvider,
+  NeuroLinkProvider as NeuroLinkReactProvider,
   useNeuroLinkClient,
   // ...
 } from "./reactHooks.js";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/client/index.ts` around lines 140 - 152, The exported React context
provider NeuroLinkProvider from reactHooks.js conflicts conceptually with
NeuroLinkProvider (aliased as NeuroLinkAIProvider) from aiSdkAdapter.js; rename
the React export and its type to a clearer name (e.g., NeuroLinkReactProvider or
NeuroLinkContextProvider) by updating the export statement in
src/lib/client/index.ts to export the provider under the new identifier as well
as renaming the corresponding type NeuroLinkProviderProps (e.g.,
NeuroLinkReactProviderProps) in reactHooks.js and adjusting any imports/usages
of NeuroLinkProvider, NeuroLinkProviderProps, and the export lines referencing
reactHooks.js so consumers clearly distinguish the React context provider from
the AI SDK provider.
src/lib/client/streamingClient.ts (1)

343-351: Linear backoff may cause rapid reconnection attempts.

The reconnect delay uses linear scaling (reconnectDelay * reconnectAttempts) rather than exponential backoff. For 5 attempts with 1000ms base delay, the delays are 1s, 2s, 3s, 4s, 5s. Consider exponential backoff with jitter for better server protection during outages.

💡 Exponential backoff suggestion
-    const delayMs =
-      (this.options.reconnectDelay ?? 1000) * this.reconnectAttempts;
+    const baseDelay = this.options.reconnectDelay ?? 1000;
+    const delayMs = Math.min(
+      baseDelay * Math.pow(2, this.reconnectAttempts - 1),
+      30000 // max 30s
+    );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/client/streamingClient.ts` around lines 343 - 351, The current
reconnection logic in SSEClient uses linear backoff: delayMs =
(this.options.reconnectDelay ?? 1000) * this.reconnectAttempts; change this to
use exponential backoff with jitter to avoid rapid retries: compute a baseDelay
(this.options.reconnectDelay ?? 1000), then set delayMs = min(maxDelay,
baseDelay * 2^(this.reconnectAttempts - 1)), and apply randomized jitter (e.g.,
multiply by a random factor between 0.5 and 1.5) before calling sleep(delayMs);
update the debug log around logger.debug to show the computed exponential delay
and attempt number and ensure reconnectAttempts increments/limits remain
respected when calling this.connect(requestOptions).
src/lib/client/sseClient.ts (1)

27-27: Inconsistent SSE state types across modules.

SSEState here differs from SSEConnectionState in streamingClient.ts:

  • SSEState: "connecting" | "connected" | "disconnected" | "error"
  • SSEConnectionState: "connecting" | "connected" | "disconnected" | "reconnecting" | "error"

Both are exported from src/lib/client/index.ts, which may confuse consumers. Consider unifying these types or using distinct, descriptive names.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/client/sseClient.ts` at line 27, SSE state types are inconsistent:
SSEState and SSEConnectionState differ (SSEState lacks "reconnecting") which
confuses consumers; unify them by choosing a single canonical type name (e.g.,
SSEConnectionState) and updating all exports/usages to use that type, or rename
one to a distinct descriptive name if they must differ; update the exported
symbol(s) and replace references to SSEState and SSEConnectionState across the
codebase (including imports/exports) so both type definitions no longer conflict
and include the full union ("connecting" | "connected" | "disconnected" |
"reconnecting" | "error") if reconnection is needed.
src/lib/client/interceptors.ts (1)

598-620: Cache eviction is FIFO, not LRU.

The SimpleCache evicts the "oldest" entry using this.cache.keys().next().value, which gives the first-inserted key (FIFO) due to Map iteration order. This is not true LRU (least recently used) since accessed entries aren't moved to the end.

💡 For true LRU behavior
   get(key: string): T | undefined {
     const entry = this.cache.get(key);
     if (!entry) {
       return undefined;
     }
     if (Date.now() > entry.expires) {
       this.cache.delete(key);
       return undefined;
     }
+    // Move to end for LRU behavior
+    this.cache.delete(key);
+    this.cache.set(key, entry);
     return entry.value;
   }

This is acceptable for a simple cache but worth documenting if LRU is expected.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/client/interceptors.ts` around lines 598 - 620, The current eviction
in SimpleCache uses Map insertion order (this.cache.keys().next().value) which
yields FIFO, not LRU; update the cache to behave LRU by ensuring accesses bump
entries to the end of the Map: in the get method (and in set when updating an
existing key) remove and re-insert the key so it becomes most-recently-used,
keep eviction logic in set to delete the first Map key when size >= maxSize, and
continue using this.cache, set, delete, clear and maxSize as the referenced
symbols when making these changes.
src/lib/client/aiSdkAdapter.ts (1)

297-317: Provider inference has overlapping patterns that may cause incorrect routing.

Both google-ai and vertex patterns include /^gemini-/ and /^palm-/. A model ID like gemini-pro will match google-ai first due to object iteration order, which may not be the intended provider for all users.

Consider:

  1. Documenting that google-ai takes precedence for gemini-* models
  2. Using more specific patterns for Vertex (e.g., require project-qualified model IDs)
  3. Requiring explicit provider option for ambiguous model IDs
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/client/aiSdkAdapter.ts` around lines 297 - 317, The inferProvider
function uses overlapping regexes in providerPatterns causing ambiguous matches
(e.g., gemini-* matches both "google-ai" and "vertex"); update providerPatterns
in inferProvider to make Vertex patterns more specific (e.g., require
project-qualified IDs like
/^projects\/[^\/]+\/locations\/[^\/]+\/models\/(gemini-|palm-)/ and similar for
codechat), and add an ambiguity check: if a modelId matches more than one
provider, prefer an explicit provider option (check this.options.provider or
this.defaultProvider) or throw/log an explicit error asking callers to pass
provider; reference inferProvider, providerPatterns, and this.defaultProvider
when making changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@examples/client-sdks/node-backend.ts`:
- Around line 281-317: The stream call creates an AbortController that is never
aborted and the streamAgent branch omits any signal, so if the HTTP client
disconnects the upstream provider keeps running; fix by creating a single
AbortController before calling neurolinkClient.stream (and before calling
streamAgent), pass controller.signal into both call sites, and attach a listener
to the request/response close event (e.g., req.on('close') or res.on('close'))
to call controller.abort() so the onDone/onError handlers and upstream are
cancelled when the SSE client disconnects.

In `@examples/client-sdks/react-chat-app.tsx`:
- Around line 796-802: The tool cards rendered from tools currently use a
clickable div (class "tool-card") with onClick only; make selection
keyboard-accessible by converting the div into a semantic interactive element or
adding accessibility props: replace or update the element that uses
key={tool.name} / className={`tool-card ${selectedTool === tool.name ?
"selected" : ""}`} / onClick={() => setSelectedTool(tool.name)} so it is
focusable (tabIndex=0 or use a <button>), exposes role="button" if not a button,
and handles keyboard activation by calling setSelectedTool(tool.name) on Enter
and Space in an onKeyDown handler; also add aria-pressed or aria-selected to
reflect selectedTool for screen readers and ensure visible focus styles.
- Around line 45-48: The environment variables in the client config (the
baseUrl, apiKey, and debug flags) use CRA's process.env.REACT_APP_* pattern
which won't exist under Vite; update the config where baseUrl, apiKey, and debug
are set (the object containing baseUrl, apiKey, debug) to read from
import.meta.env using VITE-prefixed names (e.g., VITE_NEUROLINK_BASE_URL and
VITE_NEUROLINK_API_KEY) and use import.meta.env.DEV or import.meta.env.MODE ===
'development' for the debug check; also ensure your .env uses the VITE_ prefixes
or alternatively inject the values from a backend endpoint if you want
bundler-agnostic config.

In `@package.json`:
- Around line 154-157: The package.json currently exposes a "./client" entry
that includes React code (see src/lib/client/reactHooks.tsx) but React is only a
devDependency; add "react" (and optionally "react-dom" if used) to
package.json's peerDependencies with a compatible semver range (e.g. ^17 || ^18)
so consumers must provide React, and update peerDependenciesMeta if you need to
mark it optional; ensure you remove it from only devDependencies if you want
peer-only.

In `@src/lib/client/aiSdkAdapter.ts`:
- Around line 145-204: doStream() currently buffers all text in createStream()
by awaiting self.client.stream() and then yielding a single text-delta; change
it to produce incremental yields as chunks arrive by turning the client.stream
call into a background/async callback producer that pushes events into a queue
consumed by the async generator. Specifically, in createStream() (inside
doStream()) start self.client.stream(...) without awaiting it and use its onText
to push {type: "text-delta", textDelta} entries, onDone to push a finish event
(use this.mapFinishReason for finishReason and include usage), and onError to
record an error; have the generator await a resolver when the queue is empty and
yield items as they arrive, finally draining remaining items and yielding a
finish with finishReason "error" if an error occurred so the returned
LanguageModelStreamResponse.stream yields incremental updates instead of a
single buffered chunk.

In `@src/lib/client/interceptors.ts`:
- Around line 720-748: createTimeoutInterceptor creates an AbortController and
stores its signal on request.context.timeoutSignal but downstream code (e.g.,
httpClient.request) does not use that signal so requests aren't actually
aborted; either update httpClient.request to respect and merge
request.context.timeoutSignal with its own AbortController (so the fetch call
listens to the interceptor signal), or change createTimeoutInterceptor to
attach/merge its controller.signal into the request init used by downstream
(e.g., set/merge request.init.signal) and ensure the fetch uses that signal;
refer to createTimeoutInterceptor, request.context.timeoutSignal, and
httpClient.request when making the change.

In `@src/lib/client/reactHooks.tsx`:
- Around line 243-247: The outbound request body is being built from the
closure-captured messages array inside append(), causing stale assistant turns
to be sent when reload() mutates state; update append() (and the other
occurrence around the reload flow) to read the latest messages from the current
state (e.g., messagesRef.current or by calling the state getter) instead of
using the captured messages variable so JSON.stringify([...messages,
userMessage].map(...)) always serializes the up-to-date transcript before
sending.
- Around line 180-195: The useChat hook is bypassing the configured
NeuroLinkClient by calling raw fetch() to "/api/chat"; replace the direct fetch
calls in useChat (and the similar raw-fetch transport hooks referenced around
the other transport functions) with the client returned from
useNeuroLinkClient() so requests go through the provider's base URL, auth,
retry/interceptor chain, and typed error handling; specifically, call
useNeuroLinkClient() inside useChat, use its request/post/fetch wrapper (or
client.request) instead of window.fetch, pass the same body/headers/credentials,
and propagate errors via the client's typed error handling while keeping
existing callbacks (onResponse/onFinish/onError/onToolCall) and generateId
behavior.
- Around line 69-77: NeuroLinkProviderProps is a reusable public type defined
locally; move it into the central types module by creating a new/exported
declaration in src/lib/types (e.g., export type NeuroLinkProviderProps = {
config: ClientConfig; children: ReactNode; }), remove the local declaration from
reactHooks.tsx, and update all references/imports of NeuroLinkProviderProps
(including the NeuroLinkProvider component and any tests) to import it from the
new types module; ensure the exported name matches exactly and that ClientConfig
and ReactNode are imported or re-exported as needed.

In `@src/lib/client/wsClient.ts`:
- Around line 291-312: The ws.onopen handler currently only sends auth and
flushes queued messages, so existing channel listeners in the subscriptions map
never resend subscribe frames after a reconnect; modify the onopen block (in
ws.onopen within wsClient.ts) to iterate this.subscriptions (the map that holds
channel -> callbacks) and send a subscribe frame for each channel (same shape
used by your subscribe logic) after clearing pendingAuth and before/alongside
flushMessageQueue, ensuring each subscription is replayed to the server on
reconnect and does not rely on callers to re-subscribe manually; keep calling
this.eventHandlers.onOpen(), this.startHeartbeat(), and this.flushMessageQueue()
as before.
- Around line 208-218: The disconnect() implementation wrongly flips
config.autoReconnect and fails to clear any pending reconnect timer; change it
so disconnect() only performs a manual shutdown (call stopHeartbeat(), close
this.ws, set this.ws = null, setState("disconnected")) and set a new boolean
flag like this.manualDisconnect = true instead of mutating config.autoReconnect;
also add a tracked reconnectTimer property (e.g., this.reconnectTimer) that the
reconnect scheduling code (the place that calls setTimeout for reconnection)
assigns and returns, and ensure disconnect() clears that timer
(clearTimeout(this.reconnectTimer)) so no scheduled reconnect fires after manual
disconnect; finally, ensure connect() clears this.manualDisconnect when invoked
so a subsequent manual connect can re-enable reconnection behavior.
- Around line 23-70: The WebSocket public types (WebSocketState,
WebSocketConfig, WebSocketMessage, WebSocketEventHandlers) were defined locally
in wsClient.ts and diverge from the canonical client types used by
httpClient.ts; move these type definitions into the central
src/lib/types/clientTypes.ts (or update that file) so there is a single source
of truth, update their literal unions/shape to match the canonical definitions
used by httpClient.ts (e.g., reconcile "error" vs "disconnecting" |
"reconnecting"), then remove the local type declarations from wsClient.ts and
import the types from clientTypes.ts wherever wsClient.ts and other modules
(like httpClient.ts) consume them. Ensure exports/imports are updated so
consumers use the shared types.
- Around line 186-199: The WebSocket constructor usage in wsClient.ts is
incorrect: the native/browser WebSocket doesn't accept a headers option and the
current cast around new WebSocket(...) hides the bug, so Node clients won't send
auth headers; update the implementation to explicitly use the ws package
WebSocket in Node (import WebSocket from 'ws') and construct the socket with the
third-argument options object containing headers when isNode and authHeaders is
non-empty (e.g. new WebSocket(url.toString(), undefined, { headers: authHeaders
})), or alternatively unify behavior by always sending credentials as the first
post-open message (reuse the existing post-open auth message path) so both
browser and Node follow the same auth flow; modify the code paths around isNode,
authHeaders and this.ws accordingly.

In `@src/lib/index.ts`:
- Around line 47-128: Remove all React-related exports (NeuroLinkProvider,
useNeuroLinkClient, useChat, useAgent, useWorkflow, useVoice, useStream,
useTools and any related type exports) from the root barrel in src/lib/index.ts
so they are only provided via the client subpath; update the export list in the
file to exclude these symbols and ensure only non-React APIs (e.g.,
NeuroLinkClient, createClient, NeuroLinkApiError, AI SDK Adapter, interceptors,
streaming client, authentication, and error exports) remain in the root export
block, leaving React hooks exported solely from ./client/index.js (the new
`@juspay/neurolink/client` entry).

In `@src/lib/server/adapters/honoAdapter.ts`:
- Around line 430-444: The streamed error frames sent in the isErrorResponse
branch (using stream.writeSSE with the errorResult object) omit the required
ApiError.status field, causing clients to see undefined status; update the
payload produced in the isErrorResponse branches (the block handling result &&
isErrorResponse(result) and the similar block around lines 457-465) to include
status: errorResult.error?.status ?? a sensible default (e.g., 500) so
StreamEvent.error meets the ApiError shape; ensure you reference the same
errorResult and StreamEvent.error structure when building the JSON passed to
stream.writeSSE.
- Around line 445-449: The SSE frame for single-result paths uses
JSON.stringify(result) which lacks the StreamEvent shape and a required type, so
handleEvent() ignores it; update the else branch around stream.writeSSE to
normalize the result into a StreamEvent object (include the same "type" field
used by the stream parser and any other fields expected by handleEvent) before
stringifying and sending; reference the existing StreamEvent shape/creator used
elsewhere in honoAdapter.ts (and ensure compatibility with handleEvent and
stream.writeSSE) so one-shot responses are parsed and accumulated correctly.

In `@src/lib/types/clientTypes.ts`:
- Around line 181-198: StreamCallbacks now includes onMetadata, onAudio, and
onThinking but the SSE client's event dispatch logic only handles
text/tool-call/tool-result/done/error so those callbacks never run; update the
SSE client's event handler (the function that parses incoming SSE events and
currently dispatches text, tool-call, tool-result, done, error) to detect the
new event types (e.g., "metadata", "audio", "thinking"), parse their payloads
into JsonObject / {data, format} / string as appropriate, and invoke onMetadata,
onAudio, and onThinking when present (preserving existing error parsing for
ApiError and onDone for StreamResult). Ensure you handle malformed payloads
safely and keep typings consistent with StreamCallbacks.
- Around line 998-1052: The AuthConfig, OAuth2Config, and TokenRefreshResult
type declarations are duplicated here; remove these local declarations and
instead use the canonical auth types module's definitions: delete the
AuthConfig, OAuth2Config, and TokenRefreshResult blocks and replace them with
imports (and re-exports if needed) pointing to the single source of truth so the
client entry references the same types (keep references to AuthConfig,
OAuth2Config, and TokenRefreshResult in the file but have them
imported/re-exported from the canonical auth types module).

In `@test/continuous-test-suite-client.ts`:
- Around line 134-135: SERVER_URL is computed too early using
TEST_CONFIG.serverPort (const SERVER_URL =
`http://localhost:${TEST_CONFIG.serverPort}`) before CLI parsing mutates
TEST_CONFIG.serverPort; update the code to compute SERVER_URL after CLI args are
parsed (or replace the const with a function/getter that reads
TEST_CONFIG.serverPort at use-time) and ensure any derived URLs (SSE/client URL
constructions in the same file) use this late-computed value so the --port
override is respected.

---

Outside diff comments:
In `@package.json`:
- Around line 64-79: The default CI test gate doesn't run the new client suite;
update the umbrella test invocation so the client tests run under the default
gate—either (A) compose continuous-test-suite-client.ts into the master umbrella
test runner (continuous-test-suite.ts) by importing/executing the client suite
from within the existing runner, or (B) change the test aggregation script (what
"pnpm run test" triggers / the "test:ci" / "test" script) to invoke both
continuous-test-suite.ts and continuous-test-suite-client.ts (or run the named
scripts "test:client" alongside the existing suite) so that test:client is
executed by the CI gate. Ensure you modify the script entries referencing
test:ci / test / pnpm run test accordingly and keep naming consistent with the
existing script keys (test:client, test:ci, test).

---

Minor comments:
In `@examples/client-sdks/vercel-ai-sdk.ts`:
- Around line 327-345: The example imports the NeuroLink provider from the wrong
package; update the import so createNeuroLinkProvider is imported from the
package published by this PR (replace imports of createNeuroLinkProvider from
'@neurolink/ai-sdk' with the published package, e.g.,
'@juspay/neurolink/client') wherever it appears (symbols:
createNeuroLinkProvider, neurolink) in the Next.js snippets that also call
streamText and return StreamingTextResponse so the runtime can resolve the
module.

In `@src/lib/client/auth.ts`:
- Around line 403-414: The middleware currently calls config.refreshToken() and
uses the returned newToken for the outgoing request but does not persist it back
to config, so future requests still use the old token; modify the middleware
(the block that checks config.refreshToken, config.tokenExpiresAt,
refreshBufferMs and sets request.headers[headerName]) to persist the refresh
result: after calling config.refreshToken(), detect whether the result is a
string or an object (e.g. { token, expiresAt } or { token, tokenExpiresAt }),
set config.token = token (or the string), set config.tokenExpiresAt = expiresAt
(or Date.now() + (config.tokenTTLMs ?? 3600000) if expiresAt is not provided),
then update request.headers[headerName] = `Bearer ${token}`; this ensures
config.token and config.tokenExpiresAt are updated for subsequent requests.

In `@src/lib/client/httpClient.ts`:
- Around line 956-1020: The onclose handler in connectWebSocket can cause
infinite reconnection attempts; add a retry counter (e.g.,
this.wsReconnectAttempts) and a max retry limit (e.g.,
options.maxReconnectAttempts or this.config.maxReconnectAttempts) checked before
scheduling setTimeout, incrementing the counter on each scheduled reconnect and
resetting it to 0 in onopen; additionally, implement backoff by using either
options.reconnectInterval as a base and multiplying by the attempt count (or cap
at a max interval) and ensure wsState/wsConnection logic uses these symbols
(connectWebSocket, wsState, wsConnection, onopen, onclose,
options.autoReconnect) to locate and update the code.

In `@src/lib/client/sseClient.ts`:
- Around line 514-548: The reconnectAttempts counter is never reset, so after a
successful reconnect the next drop will start from the previous attempt count;
update the stream() success path to reset this.reconnectAttempts = 0 (or reset
where the connection/open is confirmed) so attemptReconnect() starts fresh on
subsequent drops; specifically modify the stream() implementation (the code that
runs after a successful connection/handshake/open) to set reconnectAttempts to
0, leaving attemptReconnect, reconnectDelay calculation, and the
maxReconnectAttempts checks unchanged.

In `@src/lib/client/streamingClient.ts`:
- Around line 970-976: The SSE transport currently returns a hardcoded
"connected" from getState(), so change getState() on the SSE client to reflect
the real EventSource state instead of a constant: either read
EventSource.readyState and map its numeric values to your WebSocketState union
or maintain an internal state variable updated on the EventSource handlers
(onopen -> "connected"/"open", onerror/close -> "closed"/"disconnected", and
initial -> "connecting"), then return that value from getState(); update
references to send, on, off, getState in the SSE transport implementation
accordingly.

In `@src/lib/server/adapters/honoAdapter.ts`:
- Around line 512-517: The code in honoAdapter.ts currently emits an unsupported
event object with type "data" for unknown chunk shapes; instead, stop emitting
that unsupported "data" event (which is not part of StreamEventType) — change
the fallback to return undefined/null (or throw) so the caller drops the chunk,
and add a debug/processLogger.warn indicating an unexpected chunk shape; ensure
the function's return type allows undefined and that the caller filters out
falsy results, and reference StreamEventType and the SSE client handlers in
src/lib/client/sseClient.ts to confirm no "data" handling is required.

In `@test/continuous-test-suite-client.ts`:
- Around line 289-304: The current post-stream check in the test (variables
result, content, textChunks, doneReceived, and function logTest) marks the test
PASS as long as any text arrived, but it must also ensure the SSE completion
event was received; update the logic so that if doneReceived is false you call
logTest with a FAIL and a descriptive message (e.g., "Stream ended without
completion ([DONE] missing)"), return false, and only log PASS and return true
when textChunks has data and doneReceived is true.

---

Nitpick comments:
In `@examples/client-sdks/react-chat-app.tsx`:
- Around line 124-144: The sessionId passed inline to useChat is recreated on
every render (sessionId: `session-${Date.now()}`) which can fragment
conversations; inside ChatTab create a stable session identifier once (for
example with useRef or useMemo) and pass that stable value to useChat (replace
the inline template with stableSessionIdRef.current or stableSessionId). Keep
the generation logic (e.g., `session-${Date.now()}`) but ensure it runs only
once when the component mounts so useChat receives an immutable sessionId across
renders.

In `@src/lib/client/aiSdkAdapter.ts`:
- Around line 297-317: The inferProvider function uses overlapping regexes in
providerPatterns causing ambiguous matches (e.g., gemini-* matches both
"google-ai" and "vertex"); update providerPatterns in inferProvider to make
Vertex patterns more specific (e.g., require project-qualified IDs like
/^projects\/[^\/]+\/locations\/[^\/]+\/models\/(gemini-|palm-)/ and similar for
codechat), and add an ambiguity check: if a modelId matches more than one
provider, prefer an explicit provider option (check this.options.provider or
this.defaultProvider) or throw/log an explicit error asking callers to pass
provider; reference inferProvider, providerPatterns, and this.defaultProvider
when making changes.

In `@src/lib/client/index.ts`:
- Around line 239-245: There are two overlapping SSE enums/types
(SSEConnectionState from streamingClient.js and SSEState from sseClient.ts) with
inconsistent literal values ("reconnecting" vs "error"); pick a single canonical
type (preferably SSEConnectionState) and unify usages: update sseClient.ts to
use and export the canonical SSEConnectionState (or re-export SSEConnectionState
from streamingClient.js as SSEState if backward compatibility is required),
remove the duplicate definition, and ensure all references and the public export
block export only the unified type so the project has one authoritative SSE
state type and consistent literal values.
- Around line 140-152: The exported React context provider NeuroLinkProvider
from reactHooks.js conflicts conceptually with NeuroLinkProvider (aliased as
NeuroLinkAIProvider) from aiSdkAdapter.js; rename the React export and its type
to a clearer name (e.g., NeuroLinkReactProvider or NeuroLinkContextProvider) by
updating the export statement in src/lib/client/index.ts to export the provider
under the new identifier as well as renaming the corresponding type
NeuroLinkProviderProps (e.g., NeuroLinkReactProviderProps) in reactHooks.js and
adjusting any imports/usages of NeuroLinkProvider, NeuroLinkProviderProps, and
the export lines referencing reactHooks.js so consumers clearly distinguish the
React context provider from the AI SDK provider.

In `@src/lib/client/interceptors.ts`:
- Around line 598-620: The current eviction in SimpleCache uses Map insertion
order (this.cache.keys().next().value) which yields FIFO, not LRU; update the
cache to behave LRU by ensuring accesses bump entries to the end of the Map: in
the get method (and in set when updating an existing key) remove and re-insert
the key so it becomes most-recently-used, keep eviction logic in set to delete
the first Map key when size >= maxSize, and continue using this.cache, set,
delete, clear and maxSize as the referenced symbols when making these changes.

In `@src/lib/client/sseClient.ts`:
- Line 27: SSE state types are inconsistent: SSEState and SSEConnectionState
differ (SSEState lacks "reconnecting") which confuses consumers; unify them by
choosing a single canonical type name (e.g., SSEConnectionState) and updating
all exports/usages to use that type, or rename one to a distinct descriptive
name if they must differ; update the exported symbol(s) and replace references
to SSEState and SSEConnectionState across the codebase (including
imports/exports) so both type definitions no longer conflict and include the
full union ("connecting" | "connected" | "disconnected" | "reconnecting" |
"error") if reconnection is needed.

In `@src/lib/client/streamingClient.ts`:
- Around line 343-351: The current reconnection logic in SSEClient uses linear
backoff: delayMs = (this.options.reconnectDelay ?? 1000) *
this.reconnectAttempts; change this to use exponential backoff with jitter to
avoid rapid retries: compute a baseDelay (this.options.reconnectDelay ?? 1000),
then set delayMs = min(maxDelay, baseDelay * 2^(this.reconnectAttempts - 1)),
and apply randomized jitter (e.g., multiply by a random factor between 0.5 and
1.5) before calling sleep(delayMs); update the debug log around logger.debug to
show the computed exponential delay and attempt number and ensure
reconnectAttempts increments/limits remain respected when calling
this.connect(requestOptions).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a6bebc11-82bf-4abe-8faa-eadd4fc111e5

📥 Commits

Reviewing files that changed from the base of the PR and between 86b0a32 and b06897d.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (24)
  • docs/features/client-sdk.md
  • docs/features/index.md
  • examples/client-sdks/node-backend.ts
  • examples/client-sdks/react-chat-app.tsx
  • examples/client-sdks/vercel-ai-sdk.ts
  • examples/client-sdks/websocket-realtime.ts
  • package.json
  • src/lib/client/aiSdkAdapter.ts
  • src/lib/client/auth.ts
  • src/lib/client/errors.ts
  • src/lib/client/httpClient.ts
  • src/lib/client/index.ts
  • src/lib/client/interceptors.ts
  • src/lib/client/reactHooks.tsx
  • src/lib/client/sseClient.ts
  • src/lib/client/streamingClient.ts
  • src/lib/client/wsClient.ts
  • src/lib/index.ts
  • src/lib/server/adapters/honoAdapter.ts
  • src/lib/types/clientTypes.ts
  • src/lib/types/index.ts
  • test/continuous-test-suite-client.ts
  • tsconfig.cli.json
  • tsconfig.json

Comment on lines +281 to +317
await neurolinkClient.stream(
options,
{
onText: (text) => {
res.write(
`data: ${JSON.stringify({ type: "text", content: text })}\n\n`,
);
},
onToolCall: (toolCall) => {
res.write(
`data: ${JSON.stringify({ type: "tool-call", toolCall })}\n\n`,
);
},
onToolResult: (toolResult) => {
res.write(
`data: ${JSON.stringify({ type: "tool-result", toolResult })}\n\n`,
);
},
onMetadata: (metadata) => {
res.write(
`data: ${JSON.stringify({ type: "metadata", metadata })}\n\n`,
);
},
onDone: (result) => {
res.write(`data: ${JSON.stringify({ type: "done", result })}\n\n`);
res.write("data: [DONE]\n\n");
res.end();
},
onError: (error) => {
res.write(`data: ${JSON.stringify({ type: "error", error })}\n\n`);
res.end();
},
},
{
signal: req.socket.destroyed ? new AbortController().signal : undefined,
},
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Abort upstream streaming when the HTTP client disconnects.

Line 315 creates a fresh AbortController but never aborts it, and the streamAgent() branch does not pass any signal at all. If the browser closes the SSE connection, the provider stream keeps running in the background and continues consuming resources.

Suggested fix
 app.post("/api/stream", async (req: Request, res: Response) => {
   try {
+    const abortController = new AbortController();
+    req.on("close", () => abortController.abort());
+
     const options: GenerateRequestOptions = {
       input: {
         text: req.body.input?.text || req.body.prompt,
@@
         },
       },
-      {
-        signal: req.socket.destroyed ? new AbortController().signal : undefined,
-      },
+      { signal: abortController.signal },
     );
   } catch (error) {
     if (!res.headersSent) {
       handleApiError(error, res);
@@
 app.post(
   "/api/agents/:agentId/execute",
   async (req: Request, res: Response) => {
     try {
+      const abortController = new AbortController();
+      req.on("close", () => abortController.abort());
+
       const options: AgentExecuteOptions = {
         agentId: req.params.agentId,
         input: req.body.input,
@@
-        await neurolinkClient.streamAgent(options, {
-          onText: (text) => {
-            res.write(
-              `data: ${JSON.stringify({ type: "text", content: text })}\n\n`,
-            );
-          },
-          onDone: (result) => {
-            res.write(`data: ${JSON.stringify({ type: "done", result })}\n\n`);
-            res.write("data: [DONE]\n\n");
-            res.end();
-          },
-        });
+        await neurolinkClient.streamAgent(
+          options,
+          {
+            onText: (text) => {
+              res.write(
+                `data: ${JSON.stringify({ type: "text", content: text })}\n\n`,
+              );
+            },
+            onDone: (result) => {
+              res.write(`data: ${JSON.stringify({ type: "done", result })}\n\n`);
+              res.write("data: [DONE]\n\n");
+              res.end();
+            },
+          },
+          { signal: abortController.signal },
+        );
       } else {

Also applies to: 357-368

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/client-sdks/node-backend.ts` around lines 281 - 317, The stream call
creates an AbortController that is never aborted and the streamAgent branch
omits any signal, so if the HTTP client disconnects the upstream provider keeps
running; fix by creating a single AbortController before calling
neurolinkClient.stream (and before calling streamAgent), pass controller.signal
into both call sites, and attach a listener to the request/response close event
(e.g., req.on('close') or res.on('close')) to call controller.abort() so the
onDone/onError handlers and upstream are cancelled when the SSE client
disconnects.

Comment thread examples/client-sdks/react-chat-app.tsx Outdated
Comment on lines +45 to +48
baseUrl:
process.env.REACT_APP_NEUROLINK_BASE_URL || "http://localhost:3000",
apiKey: process.env.REACT_APP_NEUROLINK_API_KEY,
debug: process.env.NODE_ENV === "development",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

For a Vite React application, are browser environment variables exposed via process.env.REACT_APP_, or via import.meta.env.VITE_?

💡 Result:

In a Vite React application, browser environment variables are exposed via import.meta.env.VITE_, not process.env.REACT_APP_. Vite uses import.meta.env to expose environment variables prefixed with VITE_ (by default) to client-side code. Only variables with the VITE_ prefix are exposed to prevent accidental leakage. For example, VITE_API_KEY is accessed as import.meta.env.VITE_API_KEY. This differs from Create React App, which uses process.env.REACT_APP_. Vite does not support process.env in browser code by default; attempting process.env.REACT_APP_ will fail as process is undefined in the browser. The prefix can be customized via envPrefix in vite.config.js, but VITE_ is the standard.

Citations:


🏁 Script executed:

# Check if Vite is configured in the repo
find . -maxdepth 3 -name "vite.config.*" -o -name "package.json" | head -20

Repository: juspay/neurolink

Length of output: 221


🏁 Script executed:

# Check CLAUDE.md for environment variable guidance
cat CLAUDE.md | head -100

Repository: juspay/neurolink

Length of output: 4498


🏁 Script executed:

# Verify the actual content of the file under review
cat -n examples/client-sdks/react-chat-app.tsx | sed -n '40,55p'

Repository: juspay/neurolink

Length of output: 618


🏁 Script executed:

# Look for .env.example or similar configuration files
find . -maxdepth 3 \( -name ".env*" -o -name "*env.example*" \) -type f

Repository: juspay/neurolink

Length of output: 175


🏁 Script executed:

# Check the root vite.config.ts
cat -n vite.config.ts | head -50

Repository: juspay/neurolink

Length of output: 2201


🏁 Script executed:

# Check .env.example to see documented env vars
cat .env.example | head -50

Repository: juspay/neurolink

Length of output: 2368


🏁 Script executed:

# Check if examples directory has vite setup
ls -la examples/ | head -20

Repository: juspay/neurolink

Length of output: 1329


🏁 Script executed:

# Check if there's a vite.config for examples
find examples -name "vite.config.*" -o -name "package.json"

Repository: juspay/neurolink

Length of output: 191


🏁 Script executed:

# Check what's in examples/client-sdks
ls -la examples/client-sdks/

Repository: juspay/neurolink

Length of output: 453


🏁 Script executed:

# Check if react-chat-app has any build setup
find examples -name "package.json" -exec grep -l "react\|vite\|webpack" {} \;

Repository: juspay/neurolink

Length of output: 105


🏁 Script executed:

# Check the full react-chat-app.tsx to understand context
wc -l examples/client-sdks/react-chat-app.tsx

Repository: juspay/neurolink

Length of output: 104


🏁 Script executed:

# Check example projects to see if any use process.env
rg "process\.env\." examples/ -A 2 -B 2

Repository: juspay/neurolink

Length of output: 29581


Replace CRA-style REACT_APP_* env vars with Vite's import.meta.env.VITE_* pattern.

This React example uses process.env.REACT_APP_*, which is from Create React App. The repo uses Vite, which does not expose process in browser code; these variables will be undefined at runtime. Switch to import.meta.env.VITE_* (e.g., VITE_NEUROLINK_BASE_URL) and update the .env configuration to match, or inject config from a backend endpoint to remain bundler-agnostic.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/client-sdks/react-chat-app.tsx` around lines 45 - 48, The
environment variables in the client config (the baseUrl, apiKey, and debug
flags) use CRA's process.env.REACT_APP_* pattern which won't exist under Vite;
update the config where baseUrl, apiKey, and debug are set (the object
containing baseUrl, apiKey, debug) to read from import.meta.env using
VITE-prefixed names (e.g., VITE_NEUROLINK_BASE_URL and VITE_NEUROLINK_API_KEY)
and use import.meta.env.DEV or import.meta.env.MODE === 'development' for the
debug check; also ensure your .env uses the VITE_ prefixes or alternatively
inject the values from a backend endpoint if you want bundler-agnostic config.

Comment on lines +796 to +802
<div className="tools-grid">
{tools.map((tool) => (
<div
key={tool.name}
className={`tool-card ${selectedTool === tool.name ? "selected" : ""}`}
onClick={() => setSelectedTool(tool.name)}
>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Make tool selection keyboard-accessible.

The selectable tool card is a clickable div with no focus or keyboard handling, so keyboard-only users cannot choose a tool to execute from this screen.

Possible fix
           <div
             key={tool.name}
+            role="button"
+            tabIndex={0}
             className={`tool-card ${selectedTool === tool.name ? "selected" : ""}`}
             onClick={() => setSelectedTool(tool.name)}
+            onKeyDown={(e) => {
+              if (e.key === "Enter" || e.key === " ") {
+                e.preventDefault();
+                setSelectedTool(tool.name);
+              }
+            }}
           >
🧰 Tools
🪛 Biome (2.4.7)

[error] 798-802: Enforce to have the onClick mouse event with the onKeyUp, the onKeyDown, or the onKeyPress keyboard event.

(lint/a11y/useKeyWithClickEvents)


[error] 798-802: Static Elements should not be interactive.

(lint/a11y/noStaticElementInteractions)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/client-sdks/react-chat-app.tsx` around lines 796 - 802, The tool
cards rendered from tools currently use a clickable div (class "tool-card") with
onClick only; make selection keyboard-accessible by converting the div into a
semantic interactive element or adding accessibility props: replace or update
the element that uses key={tool.name} / className={`tool-card ${selectedTool ===
tool.name ? "selected" : ""}`} / onClick={() => setSelectedTool(tool.name)} so
it is focusable (tabIndex=0 or use a <button>), exposes role="button" if not a
button, and handles keyboard activation by calling setSelectedTool(tool.name) on
Enter and Space in an onKeyDown handler; also add aria-pressed or aria-selected
to reflect selectedTool for screen readers and ensure visible focus styles.

Comment thread package.json
Comment on lines +154 to +157
"./client": {
"types": "./dist/client/index.d.ts",
"import": "./dist/client/index.js",
"default": "./dist/client/index.js"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm whether the published client sources import React / ReactDOM, and compare that to the manifest.
rg -n --glob 'src/lib/client/**/*.{ts,tsx}' 'from "react"|from "react-dom"'
python - <<'PY'
import json, pathlib
pkg = json.loads(pathlib.Path("package.json").read_text())
print("peerDependencies:", pkg.get("peerDependencies", {}))
print("devDependencies.react:", pkg.get("devDependencies", {}).get("react"))
print("devDependencies.react-dom:", pkg.get("devDependencies", {}).get("react-dom"))
PY

Repository: juspay/neurolink

Length of output: 384


Declare React as a peer dependency for the published client entry.

The ./client export includes React hooks code (src/lib/client/reactHooks.tsx imports from "react"), but the package manifest lists React only as a devDependency. react must be declared in peerDependencies to ensure consumers can resolve it correctly and avoid duplicate or missing React instances at runtime.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package.json` around lines 154 - 157, The package.json currently exposes a
"./client" entry that includes React code (see src/lib/client/reactHooks.tsx)
but React is only a devDependency; add "react" (and optionally "react-dom" if
used) to package.json's peerDependencies with a compatible semver range (e.g.
^17 || ^18) so consumers must provide React, and update peerDependenciesMeta if
you need to mark it optional; ensure you remove it from only devDependencies if
you want peer-only.

Comment thread src/lib/client/aiSdkAdapter.ts Outdated
Comment on lines +145 to +204
// Create an async generator for the stream
async function* createStream(): AsyncIterable<{
type: "text-delta" | "finish";
textDelta?: string;
finishReason?: string;
usage?: { promptTokens: number; completionTokens: number };
}> {
let fullText = "";

try {
const result = await self.client.stream(
{
input: { text: inputText },
provider: self.provider,
model: self.modelId,
temperature: temperature ?? self.options.temperature,
maxTokens: maxTokens ?? self.options.maxTokens,
systemPrompt,
},
{
onText: (text) => {
fullText += text;
},
},
{
signal: abortSignal,
},
);

// Yield the accumulated text as a single delta
if (fullText) {
yield {
type: "text-delta" as const,
textDelta: fullText,
};
}

// Yield finish event
yield {
type: "finish" as const,
finishReason: self.mapFinishReason(result.finishReason),
usage: result.usage
? {
promptTokens: result.usage.promptTokens,
completionTokens: result.usage.completionTokens,
}
: undefined,
};
} catch {
yield {
type: "finish" as const,
finishReason: "error",
};
}
}

return {
stream: createStream(),
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

doStream() buffers all content before yielding, defeating streaming purpose.

The doStream() implementation waits for this.client.stream() to complete (accumulating all text via onText callback), then yields a single text-delta with the full content. This defeats the purpose of streaming as consumers won't receive incremental updates.

Per the LanguageModelStreamResponse type in clientTypes.ts, the stream should yield chunks as they arrive. The current implementation is effectively non-streaming.

🔧 Proposed fix using async queue pattern
async doStream(
  options: LanguageModelCallOptions,
): Promise<LanguageModelStreamResponse> {
  // ... setup code ...
  
  const self = this;
  
  async function* createStream() {
    const queue: Array<{ type: "text-delta" | "finish"; textDelta?: string; finishReason?: string; usage?: object }> = [];
    let done = false;
    let resolver: (() => void) | null = null;
    let streamError: Error | null = null;

    // Start streaming in background
    self.client.stream(
      { /* request options */ },
      {
        onText: (text) => {
          queue.push({ type: "text-delta", textDelta: text });
          resolver?.();
        },
        onDone: (result) => {
          queue.push({
            type: "finish",
            finishReason: self.mapFinishReason(result.finishReason),
            usage: result.usage,
          });
          done = true;
          resolver?.();
        },
        onError: (error) => {
          streamError = new Error(error.message);
          resolver?.();
        },
      },
    ).catch((err) => {
      streamError = err;
      resolver?.();
    });

    while (!done && !streamError) {
      if (queue.length > 0) {
        yield queue.shift()!;
      } else {
        await new Promise<void>((r) => { resolver = r; });
      }
    }
    
    // Yield remaining
    while (queue.length > 0) {
      yield queue.shift()!;
    }
    
    if (streamError) {
      yield { type: "finish" as const, finishReason: "error" };
    }
  }

  return { stream: createStream() };
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/client/aiSdkAdapter.ts` around lines 145 - 204, doStream() currently
buffers all text in createStream() by awaiting self.client.stream() and then
yielding a single text-delta; change it to produce incremental yields as chunks
arrive by turning the client.stream call into a background/async callback
producer that pushes events into a queue consumed by the async generator.
Specifically, in createStream() (inside doStream()) start
self.client.stream(...) without awaiting it and use its onText to push {type:
"text-delta", textDelta} entries, onDone to push a finish event (use
this.mapFinishReason for finishReason and include usage), and onError to record
an error; have the generator await a resolver when the queue is empty and yield
items as they arrive, finally draining remaining items and yielding a finish
with finishReason "error" if an error occurred so the returned
LanguageModelStreamResponse.stream yields incremental updates instead of a
single buffered chunk.

Comment on lines +430 to +444
} else if (result && isErrorResponse(result)) {
// Error result, send as error event
const errorResult = result as {
error?: { message?: string; code?: string };
};
await stream.writeSSE({
data: JSON.stringify({
type: "error",
error: {
code: errorResult.error?.code ?? "STREAM_ERROR",
message: errorResult.error?.message ?? "Stream error",
},
timestamp: Date.now(),
}),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Include status in streamed error frames.

StreamEvent.error is typed as ApiError, where status is required in src/lib/types/clientTypes.ts:97-110. These payloads only send code and message, so client retry/rate-limit branches see error.status === undefined for both handler errors and runtime stream failures.

Possible fix
               error: {
                 code: errorResult.error?.code ?? "STREAM_ERROR",
                 message: errorResult.error?.message ?? "Stream error",
+                status: result.httpStatus ?? 500,
               },
@@
             error: {
               code: "STREAM_ERROR",
               message: error instanceof Error ? error.message : "Stream error",
+              status: 500,
             },

Also applies to: 457-465

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/server/adapters/honoAdapter.ts` around lines 430 - 444, The streamed
error frames sent in the isErrorResponse branch (using stream.writeSSE with the
errorResult object) omit the required ApiError.status field, causing clients to
see undefined status; update the payload produced in the isErrorResponse
branches (the block handling result && isErrorResponse(result) and the similar
block around lines 457-465) to include status: errorResult.error?.status ?? a
sensible default (e.g., 500) so StreamEvent.error meets the ApiError shape;
ensure you reference the same errorResult and StreamEvent.error structure when
building the JSON passed to stream.writeSSE.

Comment on lines 445 to 449
} else {
// Single result, send as complete event
await stream.writeSSE({
data: JSON.stringify(result),
event: "complete",
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Normalize one-shot results before sending SSE.

The SSE client parses every frame as a StreamEvent. Sending raw JSON.stringify(result) here produces objects with no type, so handleEvent() ignores the frame and [DONE] closes the stream with empty accumulated content.

Possible fix
         } else {
           // Single result, send as complete event
           await stream.writeSSE({
-            data: JSON.stringify(result),
+            data: JSON.stringify(this.toStreamEvent(result)),
           });
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/server/adapters/honoAdapter.ts` around lines 445 - 449, The SSE frame
for single-result paths uses JSON.stringify(result) which lacks the StreamEvent
shape and a required type, so handleEvent() ignores it; update the else branch
around stream.writeSSE to normalize the result into a StreamEvent object
(include the same "type" field used by the stream parser and any other fields
expected by handleEvent) before stringifying and sending; reference the existing
StreamEvent shape/creator used elsewhere in honoAdapter.ts (and ensure
compatibility with handleEvent and stream.writeSSE) so one-shot responses are
parsed and accumulated correctly.

Comment on lines +181 to +198
export type StreamCallbacks = {
/** Called for each text chunk */
onText?: (text: string) => void;
/** Called for each tool call */
onToolCall?: (toolCall: ToolCall) => void;
/** Called for each tool result */
onToolResult?: (toolResult: ToolResult) => void;
/** Called on stream error */
onError?: (error: ApiError) => void;
/** Called when stream completes */
onDone?: (result: StreamResult) => void;
/** Called for metadata updates */
onMetadata?: (metadata: JsonObject) => void;
/** Called for audio chunks */
onAudio?: (audio: { data: string; format: string }) => void;
/** Called for thinking/reasoning output */
onThinking?: (thinking: string) => void;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Wire the new stream callbacks through the SSE client first.

StreamCallbacks now exposes onMetadata, onAudio, and onThinking, but src/lib/client/sseClient.ts:472-507 only dispatches text/tool-call/tool-result/done/error. On the default SSE path those callbacks never fire. Either add the missing cases there or keep them out of the public callback surface for now.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/types/clientTypes.ts` around lines 181 - 198, StreamCallbacks now
includes onMetadata, onAudio, and onThinking but the SSE client's event dispatch
logic only handles text/tool-call/tool-result/done/error so those callbacks
never run; update the SSE client's event handler (the function that parses
incoming SSE events and currently dispatches text, tool-call, tool-result, done,
error) to detect the new event types (e.g., "metadata", "audio", "thinking"),
parse their payloads into JsonObject / {data, format} / string as appropriate,
and invoke onMetadata, onAudio, and onThinking when present (preserving existing
error parsing for ApiError and onDone for StreamResult). Ensure you handle
malformed payloads safely and keep typings consistent with StreamCallbacks.

Comment on lines +998 to +1052
// =============================================================================
// Authentication Types
// =============================================================================

/**
* Authentication configuration options
*/
export type AuthConfig = {
/** API key for header-based authentication */
apiKey?: string;
/** Bearer token for JWT/OAuth authentication */
token?: string;
/** Token refresh function for automatic token renewal */
refreshToken?: () => Promise<string>;
/** Token expiry time in milliseconds */
tokenExpiresAt?: number;
/** Buffer time before expiry to refresh token (default: 60000ms) */
refreshBufferMs?: number;
/** Custom authorization header name (default: "Authorization") */
headerName?: string;
/** Custom API key header name (default: "X-API-Key") */
apiKeyHeaderName?: string;
};

/**
* OAuth2 client credentials configuration
*/
export type OAuth2Config = {
/** Token endpoint URL */
tokenUrl: string;
/** OAuth2 client ID */
clientId: string;
/** OAuth2 client secret */
clientSecret: string;
/** OAuth2 scope (optional) */
scope?: string;
/** Audience for the token (optional) */
audience?: string;
};

/**
* Token refresh result
*/
export type TokenRefreshResult = {
/** Access token */
accessToken: string;
/** Token expiry time in seconds */
expiresIn: number;
/** Token type (usually "Bearer") */
tokenType: string;
/** Refresh token (if provided) */
refreshToken?: string;
/** OAuth2 scope (if provided) */
scope?: string;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Keep the client auth models in the canonical auth types module.

AuthConfig, OAuth2Config, and TokenRefreshResult recreate auth models in a second file. That brings back the split source of truth PR #892 removed. Please move these declarations to src/lib/types/authTypes.ts and re-export them here if the client entry needs them. Based on learnings, auth-related TypeScript types must live in src/lib/types/authTypes.ts, and the previous duplicate auth-types path was deleted in PR #892.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/types/clientTypes.ts` around lines 998 - 1052, The AuthConfig,
OAuth2Config, and TokenRefreshResult type declarations are duplicated here;
remove these local declarations and instead use the canonical auth types
module's definitions: delete the AuthConfig, OAuth2Config, and
TokenRefreshResult blocks and replace them with imports (and re-exports if
needed) pointing to the single source of truth so the client entry references
the same types (keep references to AuthConfig, OAuth2Config, and
TokenRefreshResult in the file but have them imported/re-exported from the
canonical auth types module).

Comment thread test/continuous-test-suite-client.ts Outdated
Comment on lines +134 to +135
const SERVER_URL = `http://localhost:${TEST_CONFIG.serverPort}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Compute SERVER_URL after parsing CLI args.

SERVER_URL is frozen with the default port at Lines 134-135, but Line 1216 mutates TEST_CONFIG.serverPort. With --port, the server starts on the new port while every client and SSE URL still points at 9200, so the advertised CLI override cannot work.

Also applies to: 1208-1217

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/continuous-test-suite-client.ts` around lines 134 - 135, SERVER_URL is
computed too early using TEST_CONFIG.serverPort (const SERVER_URL =
`http://localhost:${TEST_CONFIG.serverPort}`) before CLI parsing mutates
TEST_CONFIG.serverPort; update the code to compute SERVER_URL after CLI args are
parsed (or replace the const with a function/getter that reads
TEST_CONFIG.serverPort at use-time) and ensure any derived URLs (SSE/client URL
constructions in the same file) use this late-computed value so the --port
override is respected.

@github-actions

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

@github-actions

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

@github-actions

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

@github-actions

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

…DK adapter

Type-safe client libraries for accessing NeuroLink APIs from browser and
Node.js applications. Includes HTTP client with middleware/interceptors,
SSE and WebSocket streaming, React hooks (useChat, useAgent, useWorkflow,
useVoice, useStream, useTools), Vercel AI SDK compatibility layer, OAuth2/JWT
authentication, and comprehensive error handling.

- Add src/lib/client/ with 10 implementation files
- Add src/lib/types/clientTypes.ts with all client types in canonical location
- Add ./client sub-path export in package.json
- Add docs/features/client-sdk.md with full feature documentation
- Add 4 examples (node-backend, react-chat, vercel-ai-sdk, websocket)
- Add continuous test suite (13 tests, all passing)
- Fix server SSE streaming to emit proper StreamEvent format with [DONE]
- Add jsx: react-jsx to tsconfig for .tsx support
@github-actions

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

@murdore
murdore merged commit 610db38 into release Mar 23, 2026
17 of 18 checks passed
@murdore
murdore deleted the feat/client-sdks-rebased branch March 23, 2026 19:41
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 9.31.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

This branch had an error being deployed

1 failed deployment
Preview — ff5baddf Deployed Mar 23, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants