From e8b56ad14df5b755092c7d71cba3936d671a570d Mon Sep 17 00:00:00 2001 From: Matthew Petty Date: Thu, 25 Dec 2025 14:23:09 -0600 Subject: [PATCH 1/4] fix(sdk): address critical and high-priority review findings - Fix KoineError prototype chain for proper instanceof checks - Add textResolved flag to prevent double promise resolution in streams - Log warnings for SSE text parse failures instead of silent swallowing - Add validateConfig() for early config validation with clear errors - Wrap fetch errors in KoineError (TIMEOUT, NETWORK_ERROR codes) - Add AbortSignal support for user-controlled cancellation - Remove internal types from public exports (SSE*, *Response, ErrorResponse) - Add typed KoineErrorCode union for exhaustive error handling - Fix README: add missing timeout param, correct type documentation - Add comprehensive JSDoc to all public functions --- packages/sdks/typescript/README.md | 16 +- .../sdks/typescript/__tests__/client.test.ts | 2 +- packages/sdks/typescript/src/client.ts | 279 ++++++++++++++---- packages/sdks/typescript/src/errors.ts | 44 ++- packages/sdks/typescript/src/index.ts | 15 +- 5 files changed, 280 insertions(+), 76 deletions(-) diff --git a/packages/sdks/typescript/README.md b/packages/sdks/typescript/README.md index f3c70a1..8121305 100644 --- a/packages/sdks/typescript/README.md +++ b/packages/sdks/typescript/README.md @@ -27,6 +27,7 @@ import { generateText, KoineConfig } from '@patternzones/koine-sdk'; const config: KoineConfig = { baseUrl: 'http://localhost:3100', authKey: 'your-api-key', + timeout: 300000, // 5 minutes }; const result = await generateText(config, { @@ -39,10 +40,11 @@ console.log(result.text); ## Features - **Text Generation** — `generateText()` for simple prompts -- **Streaming** — `streamText()` with async iterators +- **Streaming** — `streamText()` with ReadableStream (async iterable) - **Structured Output** — `generateObject()` with Zod schema validation +- **Cancellation** — AbortSignal support for all requests - **Type Safety** — Full TypeScript types for all requests and responses -- **Error Handling** — `KoineError` class with status codes +- **Error Handling** — `KoineError` class with typed error codes ## API @@ -59,12 +61,10 @@ console.log(result.text); | Type | Description | |------|-------------| | `KoineConfig` | Client configuration (baseUrl, authKey, timeout, model) | -| `GenerateTextRequest` | Text generation request options | -| `GenerateTextResponse` | Text generation response with usage stats | -| `GenerateObjectRequest` | Object extraction request with Zod schema | -| `GenerateObjectResponse` | Object extraction response | -| `KoineStreamResult` | Streaming result with async iterators | -| `KoineError` | Error class with status and code | +| `KoineUsage` | Token usage stats (inputTokens, outputTokens, totalTokens) | +| `KoineStreamResult` | Streaming result with ReadableStream and promises | +| `KoineError` | Error class with typed `code` property | +| `KoineErrorCode` | Union type of all possible error codes | ## Documentation diff --git a/packages/sdks/typescript/__tests__/client.test.ts b/packages/sdks/typescript/__tests__/client.test.ts index 7bef3be..3c785ec 100644 --- a/packages/sdks/typescript/__tests__/client.test.ts +++ b/packages/sdks/typescript/__tests__/client.test.ts @@ -273,7 +273,7 @@ describe("Koine SDK Client", () => { await expect( generateText(testConfig, { prompt: "test" }), - ).rejects.toThrow("The operation was aborted"); + ).rejects.toThrow("Request aborted"); }); it("should handle empty text response", async () => { diff --git a/packages/sdks/typescript/src/client.ts b/packages/sdks/typescript/src/client.ts index ff84022..95d7ccf 100644 --- a/packages/sdks/typescript/src/client.ts +++ b/packages/sdks/typescript/src/client.ts @@ -1,6 +1,6 @@ import type { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; -import { KoineError } from "./errors.js"; +import { KoineError, type KoineErrorCode } from "./errors.js"; import type { ErrorResponse, GenerateObjectResponse, @@ -13,6 +13,107 @@ import type { SSETextEvent, } from "./types.js"; +/** + * Known error codes for type-safe validation. + */ +const KNOWN_ERROR_CODES = new Set([ + // SDK-generated errors + "HTTP_ERROR", + "INVALID_RESPONSE", + "INVALID_CONFIG", + "VALIDATION_ERROR", + "STREAM_ERROR", + "SSE_PARSE_ERROR", + "NO_SESSION", + "NO_USAGE", + "NO_RESPONSE_BODY", + "TIMEOUT", + "NETWORK_ERROR", + // Gateway-returned errors + "INVALID_PARAMS", + "AUTH_ERROR", + "UNAUTHORIZED", + "SERVER_ERROR", + "SCHEMA_ERROR", + "RATE_LIMITED", + "CONTEXT_OVERFLOW", +]); + +/** + * Coerces an API error code to a known KoineErrorCode. + * Falls back to the provided default if the code is unknown. + */ +function toErrorCode( + code: string | undefined, + fallback: KoineErrorCode, +): KoineErrorCode { + if (code && KNOWN_ERROR_CODES.has(code as KoineErrorCode)) { + return code as KoineErrorCode; + } + return fallback; +} + +/** + * Validates config parameters before making requests. + * @throws {KoineError} with code 'INVALID_CONFIG' if config is invalid + */ +function validateConfig(config: KoineConfig): void { + if (!config.baseUrl) { + throw new KoineError("baseUrl is required", "INVALID_CONFIG"); + } + if (!config.authKey) { + throw new KoineError("authKey is required", "INVALID_CONFIG"); + } + if (typeof config.timeout !== "number" || config.timeout <= 0) { + throw new KoineError("timeout must be a positive number", "INVALID_CONFIG"); + } +} + +/** + * Creates an AbortSignal that combines timeout with optional user signal. + */ +function createAbortSignal( + timeout: number, + userSignal?: AbortSignal, +): AbortSignal { + const timeoutSignal = AbortSignal.timeout(timeout); + if (!userSignal) { + return timeoutSignal; + } + // Combine signals - abort when either triggers + return AbortSignal.any([timeoutSignal, userSignal]); +} + +/** + * Wraps fetch errors in KoineError for consistent error handling. + */ +async function safeFetch( + url: string, + options: RequestInit, + timeout: number, +): Promise { + try { + return await fetch(url, options); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + // Check if it was a timeout or user cancellation + throw new KoineError( + `Request aborted (timeout: ${timeout}ms)`, + "TIMEOUT", + ); + } + if (error instanceof TypeError) { + // Network errors (DNS failure, connection refused, etc.) + throw new KoineError(`Network error: ${error.message}`, "NETWORK_ERROR"); + } + // Unknown error - wrap it + throw new KoineError( + `Request failed: ${error instanceof Error ? error.message : String(error)}`, + "NETWORK_ERROR", + ); + } +} + /** * Safely parses JSON from a response, handling non-JSON bodies gracefully. */ @@ -27,6 +128,15 @@ async function safeJsonParse(response: Response): Promise { /** * Generates plain text response from Koine gateway service. + * + * @param config - Client configuration including baseUrl, authKey, and timeout + * @param options - Request options + * @param options.prompt - The user prompt to send + * @param options.system - Optional system prompt for context + * @param options.sessionId - Optional session ID to continue a conversation + * @param options.signal - Optional AbortSignal for cancellation + * @returns Object containing response text, usage stats, and session ID + * @throws {KoineError} When the request fails or returns invalid response */ export async function generateText( config: KoineConfig, @@ -34,32 +144,39 @@ export async function generateText( system?: string; prompt: string; sessionId?: string; + signal?: AbortSignal; }, ): Promise<{ text: string; usage: KoineUsage; sessionId: string; }> { - const response = await fetch(`${config.baseUrl}/generate-text`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${config.authKey}`, + validateConfig(config); + + const response = await safeFetch( + `${config.baseUrl}/generate-text`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${config.authKey}`, + }, + body: JSON.stringify({ + system: options.system, + prompt: options.prompt, + sessionId: options.sessionId, + model: config.model, + }), + signal: createAbortSignal(config.timeout, options.signal), }, - body: JSON.stringify({ - system: options.system, - prompt: options.prompt, - sessionId: options.sessionId, - model: config.model, - }), - signal: AbortSignal.timeout(config.timeout), - }); + config.timeout, + ); if (!response.ok) { const errorBody = await safeJsonParse(response); throw new KoineError( errorBody?.error || `HTTP ${response.status} ${response.statusText}`, - errorBody?.code || "HTTP_ERROR", + toErrorCode(errorBody?.code, "HTTP_ERROR"), errorBody?.rawText, ); } @@ -145,7 +262,19 @@ function createSSEParser(): TransformStream< /** * Streams text response from Koine gateway service. - * Returns a ReadableStream of text chunks that can be consumed as they arrive. + * + * @param config - Client configuration including baseUrl, authKey, and timeout + * @param options - Request options + * @param options.prompt - The user prompt to send + * @param options.system - Optional system prompt for context + * @param options.sessionId - Optional session ID to continue a conversation + * @param options.signal - Optional AbortSignal for cancellation + * @returns KoineStreamResult containing: + * - textStream: ReadableStream of text chunks (async iterable) + * - sessionId: Promise that resolves early when session event arrives + * - usage: Promise that resolves when stream completes + * - text: Promise containing full accumulated text + * @throws {KoineError} When connection fails or stream encounters an error */ export async function streamText( config: KoineConfig, @@ -153,28 +282,35 @@ export async function streamText( system?: string; prompt: string; sessionId?: string; + signal?: AbortSignal; }, ): Promise { - const response = await fetch(`${config.baseUrl}/stream`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${config.authKey}`, + validateConfig(config); + + const response = await safeFetch( + `${config.baseUrl}/stream`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${config.authKey}`, + }, + body: JSON.stringify({ + system: options.system, + prompt: options.prompt, + sessionId: options.sessionId, + model: config.model, + }), + signal: createAbortSignal(config.timeout, options.signal), }, - body: JSON.stringify({ - system: options.system, - prompt: options.prompt, - sessionId: options.sessionId, - model: config.model, - }), - signal: AbortSignal.timeout(config.timeout), - }); + config.timeout, + ); if (!response.ok) { const errorBody = await safeJsonParse(response); throw new KoineError( errorBody?.error || `HTTP ${response.status} ${response.statusText}`, - errorBody?.code || "HTTP_ERROR", + toErrorCode(errorBody?.code, "HTTP_ERROR"), errorBody?.rawText, ); } @@ -211,6 +347,7 @@ export async function streamText( let accumulatedText = ""; let sessionIdReceived = false; let usageReceived = false; + let textResolved = false; // Transform SSE events into text chunks const textStream = response.body.pipeThrough(createSSEParser()).pipeThrough( @@ -252,7 +389,7 @@ export async function streamText( const parsed = JSON.parse(sseEvent.data) as SSEErrorEvent; const error = new KoineError( parsed.error, - parsed.code || "STREAM_ERROR", + toErrorCode(parsed.code, "STREAM_ERROR"), ); usageReceived = true; // Prevent double rejection in flush rejectUsage(error); @@ -265,28 +402,45 @@ export async function streamText( } case "done": { // Stream complete, resolve the text promise - resolveText(accumulatedText); + if (!textResolved) { + textResolved = true; + resolveText(accumulatedText); + } break; } } } catch (parseError) { + const parseErrorMessage = + parseError instanceof Error + ? parseError.message + : String(parseError); + if (isCriticalEvent) { // Critical event parse failure - propagate error const error = new KoineError( - `Failed to parse critical SSE event: ${sseEvent.event}`, + `Failed to parse critical SSE event '${sseEvent.event}': ${parseErrorMessage}`, "SSE_PARSE_ERROR", + sseEvent.data, ); if (!usageReceived) { usageReceived = true; rejectUsage(error); } - rejectText(error); + if (!textResolved) { + textResolved = true; + rejectText(error); + } if (!sessionIdReceived) { rejectSessionId(error); } controller.error(error); + } else { + // Non-critical event (text) - log warning but continue stream + // Degraded content is better than total failure + console.warn( + `[Koine SDK] Failed to parse SSE text event: ${parseErrorMessage}. Raw data: ${sseEvent.data?.substring(0, 100)}`, + ); } - // Non-critical event (text) - continue stream silently } }, flush() { @@ -304,7 +458,9 @@ export async function streamText( ), ); } - resolveText(accumulatedText); + if (!textResolved) { + resolveText(accumulatedText); + } }, }), ); @@ -319,7 +475,19 @@ export async function streamText( /** * Generates structured JSON response from Koine gateway service. - * Converts Zod schema to JSON Schema for the gateway service. + * Converts the provided Zod schema to JSON Schema format for the gateway. + * + * @typeParam T - The type of the expected response object, inferred from schema + * @param config - Client configuration including baseUrl, authKey, and timeout + * @param options - Request options + * @param options.prompt - The user prompt describing what to extract + * @param options.schema - Zod schema defining the expected response structure + * @param options.system - Optional system prompt for context + * @param options.sessionId - Optional session ID to continue a conversation + * @param options.signal - Optional AbortSignal for cancellation + * @returns Object containing parsed and validated response, raw text, usage, and sessionId + * @throws {KoineError} With code 'VALIDATION_ERROR' if response doesn't match schema + * @throws {KoineError} With code 'HTTP_ERROR' for network/authentication failures */ export async function generateObject( config: KoineConfig, @@ -328,6 +496,7 @@ export async function generateObject( prompt: string; schema: z.ZodSchema; sessionId?: string; + signal?: AbortSignal; }, ): Promise<{ object: T; @@ -335,33 +504,39 @@ export async function generateObject( usage: KoineUsage; sessionId: string; }> { + validateConfig(config); + // Convert Zod schema to JSON Schema for the gateway service const jsonSchema = zodToJsonSchema(options.schema, { $refStrategy: "none", target: "jsonSchema7", }); - const response = await fetch(`${config.baseUrl}/generate-object`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${config.authKey}`, + const response = await safeFetch( + `${config.baseUrl}/generate-object`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${config.authKey}`, + }, + body: JSON.stringify({ + system: options.system, + prompt: options.prompt, + schema: jsonSchema, + sessionId: options.sessionId, + model: config.model, + }), + signal: createAbortSignal(config.timeout, options.signal), }, - body: JSON.stringify({ - system: options.system, - prompt: options.prompt, - schema: jsonSchema, - sessionId: options.sessionId, - model: config.model, - }), - signal: AbortSignal.timeout(config.timeout), - }); + config.timeout, + ); if (!response.ok) { const errorBody = await safeJsonParse(response); throw new KoineError( errorBody?.error || `HTTP ${response.status} ${response.statusText}`, - errorBody?.code || "HTTP_ERROR", + toErrorCode(errorBody?.code, "HTTP_ERROR"), errorBody?.rawText, ); } diff --git a/packages/sdks/typescript/src/errors.ts b/packages/sdks/typescript/src/errors.ts index bc3a6ed..241009e 100644 --- a/packages/sdks/typescript/src/errors.ts +++ b/packages/sdks/typescript/src/errors.ts @@ -1,12 +1,50 @@ +/** + * Known error codes returned by the SDK and gateway. + */ +export type KoineErrorCode = + // SDK-generated errors + | "HTTP_ERROR" + | "INVALID_RESPONSE" + | "INVALID_CONFIG" + | "VALIDATION_ERROR" + | "STREAM_ERROR" + | "SSE_PARSE_ERROR" + | "NO_SESSION" + | "NO_USAGE" + | "NO_RESPONSE_BODY" + | "TIMEOUT" + | "NETWORK_ERROR" + // Gateway-returned errors + | "INVALID_PARAMS" + | "AUTH_ERROR" + | "UNAUTHORIZED" + | "SERVER_ERROR" + | "SCHEMA_ERROR" + | "RATE_LIMITED" + | "CONTEXT_OVERFLOW"; + /** * Custom error class for Koine client errors. + * + * @example + * ```typescript + * try { + * await generateText(config, { prompt: 'Hello' }); + * } catch (error) { + * if (error instanceof KoineError) { + * console.error(`[${error.code}]: ${error.message}`); + * } + * } + * ``` */ export class KoineError extends Error { - code: string; - rawText?: string; + readonly code: KoineErrorCode; + readonly rawText?: string; - constructor(message: string, code: string, rawText?: string) { + constructor(message: string, code: KoineErrorCode, rawText?: string) { super(message); + // Fix prototype chain for proper instanceof checks in transpiled code + Object.setPrototypeOf(this, KoineError.prototype); this.name = "KoineError"; this.code = code; this.rawText = rawText; diff --git a/packages/sdks/typescript/src/index.ts b/packages/sdks/typescript/src/index.ts index b590e74..152f06a 100644 --- a/packages/sdks/typescript/src/index.ts +++ b/packages/sdks/typescript/src/index.ts @@ -22,21 +22,12 @@ * ``` */ -// Types -export type { - KoineConfig, - KoineUsage, - KoineStreamResult, - GenerateTextResponse, - GenerateObjectResponse, - ErrorResponse, - SSETextEvent, - SSEResultEvent, - SSEErrorEvent, -} from "./types.js"; +// Public types - only export types that users need +export type { KoineConfig, KoineUsage, KoineStreamResult } from "./types.js"; // Errors export { KoineError } from "./errors.js"; +export type { KoineErrorCode } from "./errors.js"; // Client functions export { generateText, streamText, generateObject } from "./client.js"; From 41dc0729eccae468323724a611f7b806a1208da4 Mon Sep 17 00:00:00 2001 From: Matthew Petty Date: Thu, 25 Dec 2025 14:30:44 -0600 Subject: [PATCH 2/4] feat(sdk): add client factory pattern and readonly types API improvements: - Add createKoine() factory for cleaner API: `const koine = createKoine(config)` - Config validated once at creation time, not on each method call - Export KoineClient interface and request/result option types Type safety: - Add readonly modifiers to all response type properties - Add readonly to KoineUsage, KoineStreamResult fields Dependency cleanup: - Remove zod from peerDependencies (kept as bundled dependency) Tests: - Add 5 new tests for createKoine factory - Move SSE helper functions to shared scope for reuse --- packages/sdks/typescript/README.md | 29 ++- .../sdks/typescript/__tests__/client.test.ts | 211 +++++++++++++----- packages/sdks/typescript/package.json | 3 - packages/sdks/typescript/src/client.ts | 134 +++++++++++ packages/sdks/typescript/src/index.ts | 21 +- packages/sdks/typescript/src/types.ts | 52 ++--- 6 files changed, 351 insertions(+), 99 deletions(-) diff --git a/packages/sdks/typescript/README.md b/packages/sdks/typescript/README.md index 8121305..6749db2 100644 --- a/packages/sdks/typescript/README.md +++ b/packages/sdks/typescript/README.md @@ -22,15 +22,15 @@ npm install @patternzones/koine-sdk ## Quick Start ```typescript -import { generateText, KoineConfig } from '@patternzones/koine-sdk'; +import { createKoine } from '@patternzones/koine-sdk'; -const config: KoineConfig = { +const koine = createKoine({ baseUrl: 'http://localhost:3100', authKey: 'your-api-key', timeout: 300000, // 5 minutes -}; +}); -const result = await generateText(config, { +const result = await koine.generateText({ prompt: 'Hello, how are you?', }); @@ -48,19 +48,28 @@ console.log(result.text); ## API -### Functions +### Client Factory + +```typescript +const koine = createKoine(config); +``` + +Creates a client instance with the given configuration. The config is validated once at creation time. + +### Methods -| Function | Description | -|----------|-------------| -| `generateText(config, request)` | Generate text from a prompt | -| `streamText(config, request)` | Stream text via Server-Sent Events | -| `generateObject(config, request)` | Extract structured data using a Zod schema | +| Method | Description | +|--------|-------------| +| `koine.generateText(options)` | Generate text from a prompt | +| `koine.streamText(options)` | Stream text via Server-Sent Events | +| `koine.generateObject(options)` | Extract structured data using a Zod schema | ### Types | Type | Description | |------|-------------| | `KoineConfig` | Client configuration (baseUrl, authKey, timeout, model) | +| `KoineClient` | Client interface returned by `createKoine()` | | `KoineUsage` | Token usage stats (inputTokens, outputTokens, totalTokens) | | `KoineStreamResult` | Streaming result with ReadableStream and promises | | `KoineError` | Error class with typed `code` property | diff --git a/packages/sdks/typescript/__tests__/client.test.ts b/packages/sdks/typescript/__tests__/client.test.ts index 3c785ec..3f5971e 100644 --- a/packages/sdks/typescript/__tests__/client.test.ts +++ b/packages/sdks/typescript/__tests__/client.test.ts @@ -8,7 +8,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; -import { generateObject, generateText, streamText } from "../src/client.js"; +import { + createKoine, + generateObject, + generateText, + streamText, +} from "../src/client.js"; import { KoineError } from "../src/errors.js"; import type { KoineConfig } from "../src/types.js"; @@ -501,61 +506,61 @@ describe("Koine SDK Client", () => { }); }); - describe("streamText", () => { - /** - * Creates a mock SSE ReadableStream that emits events in SSE format. - * Used to simulate the gateway's /stream endpoint response. - */ - function createSSEStream( - events: Array<{ event: string; data: unknown }>, - ): ReadableStream { - const encoder = new TextEncoder(); - let index = 0; - - return new ReadableStream({ - pull(controller) { - if (index < events.length) { - const { event, data } = events[index]; - const sseData = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; - controller.enqueue(encoder.encode(sseData)); - index++; - } else { - controller.close(); - } - }, - }); - } - - /** - * Creates a mock Response with an SSE stream body. - */ - function createMockSSEResponse( - events: Array<{ event: string; data: unknown }>, - options: { status?: number; ok?: boolean } = {}, - ): Response { - const { status = 200, ok = true } = options; - const body = createSSEStream(events); - - return { - ok, - status, - statusText: ok ? "OK" : "Error", - headers: new Headers({ "Content-Type": "text/event-stream" }), - body, - text: vi.fn(), - json: vi.fn(), - redirected: false, - type: "basic", - url: "", - clone: vi.fn(), - bodyUsed: false, - arrayBuffer: vi.fn(), - blob: vi.fn(), - formData: vi.fn(), - bytes: vi.fn(), - } as unknown as Response; - } + /** + * Creates a mock SSE ReadableStream that emits events in SSE format. + * Used to simulate the gateway's /stream endpoint response. + */ + function createSSEStream( + events: Array<{ event: string; data: unknown }>, + ): ReadableStream { + const encoder = new TextEncoder(); + let index = 0; + + return new ReadableStream({ + pull(controller) { + if (index < events.length) { + const { event, data } = events[index]; + const sseData = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + controller.enqueue(encoder.encode(sseData)); + index++; + } else { + controller.close(); + } + }, + }); + } + + /** + * Creates a mock Response with an SSE stream body. + */ + function createMockSSEResponse( + events: Array<{ event: string; data: unknown }>, + options: { status?: number; ok?: boolean } = {}, + ): Response { + const { status = 200, ok = true } = options; + const body = createSSEStream(events); + + return { + ok, + status, + statusText: ok ? "OK" : "Error", + headers: new Headers({ "Content-Type": "text/event-stream" }), + body, + text: vi.fn(), + json: vi.fn(), + redirected: false, + type: "basic", + url: "", + clone: vi.fn(), + bodyUsed: false, + arrayBuffer: vi.fn(), + blob: vi.fn(), + formData: vi.fn(), + bytes: vi.fn(), + } as unknown as Response; + } + describe("streamText", () => { it("should make POST request to /stream endpoint", async () => { const events = [ { event: "session", data: { sessionId: "stream-session-123" } }, @@ -937,4 +942,100 @@ describe("Koine SDK Client", () => { expect(text).toBe("Content"); }); }); + + describe("createKoine", () => { + it("should create a client with generateText method", async () => { + const mockResponse = createMockResponse({ + text: "Hello from factory!", + usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, + sessionId: "factory-session", + }); + + global.fetch = vi.fn().mockResolvedValue(mockResponse); + + const koine = createKoine(testConfig); + const result = await koine.generateText({ prompt: "test" }); + + expect(result.text).toBe("Hello from factory!"); + expect(result.sessionId).toBe("factory-session"); + }); + + it("should create a client with streamText method", async () => { + const events = [ + { event: "session", data: { sessionId: "stream-session" } }, + { event: "text", data: { text: "Streamed!" } }, + { + event: "result", + data: { + sessionId: "stream-session", + usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, + }, + }, + { event: "done", data: { code: 0 } }, + ]; + + global.fetch = vi.fn().mockResolvedValue(createMockSSEResponse(events)); + + const koine = createKoine(testConfig); + const result = await koine.streamText({ prompt: "test" }); + + // Consume the stream first + const reader = result.textStream.getReader(); + while (true) { + const { done } = await reader.read(); + if (done) break; + } + + const text = await result.text; + expect(text).toBe("Streamed!"); + }); + + it("should create a client with generateObject method", async () => { + const schema = z.object({ name: z.string() }); + const mockResponse = createMockResponse({ + object: { name: "Factory Test" }, + rawText: '{"name":"Factory Test"}', + usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, + sessionId: "object-session", + }); + + global.fetch = vi.fn().mockResolvedValue(mockResponse); + + const koine = createKoine(testConfig); + const result = await koine.generateObject({ prompt: "test", schema }); + + expect(result.object.name).toBe("Factory Test"); + }); + + it("should validate config at creation time", () => { + expect(() => createKoine({ ...testConfig, baseUrl: "" })).toThrow( + KoineError, + ); + expect(() => createKoine({ ...testConfig, authKey: "" })).toThrow( + KoineError, + ); + expect(() => createKoine({ ...testConfig, timeout: -1 })).toThrow( + KoineError, + ); + }); + + it("should not validate config again on method calls", async () => { + const mockResponse = createMockResponse({ + text: "test", + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + sessionId: "s", + }); + + global.fetch = vi.fn().mockResolvedValue(mockResponse); + + const koine = createKoine(testConfig); + + // Config is validated at creation, not on each call + // So even if we could mutate config (we can't due to closure), + // the validation already passed + await expect( + koine.generateText({ prompt: "test" }), + ).resolves.toBeDefined(); + }); + }); }); diff --git a/packages/sdks/typescript/package.json b/packages/sdks/typescript/package.json index 167d0aa..0204d9a 100644 --- a/packages/sdks/typescript/package.json +++ b/packages/sdks/typescript/package.json @@ -39,9 +39,6 @@ "typescript": "5.9.3", "vitest": "3.2.4" }, - "peerDependencies": { - "zod": ">=3.0.0" - }, "engines": { "node": "22.21.1", "bun": "1.3.3" diff --git a/packages/sdks/typescript/src/client.ts b/packages/sdks/typescript/src/client.ts index 95d7ccf..7b86db4 100644 --- a/packages/sdks/typescript/src/client.ts +++ b/packages/sdks/typescript/src/client.ts @@ -566,3 +566,137 @@ export async function generateObject( sessionId: result.sessionId, }; } + +/** + * Request options for text generation. + */ +export interface GenerateTextOptions { + /** The user prompt to send */ + prompt: string; + /** Optional system prompt for context */ + system?: string; + /** Optional session ID to continue a conversation */ + sessionId?: string; + /** Optional AbortSignal for cancellation */ + signal?: AbortSignal; +} + +/** + * Request options for streaming text generation. + */ +export interface StreamTextOptions { + /** The user prompt to send */ + prompt: string; + /** Optional system prompt for context */ + system?: string; + /** Optional session ID to continue a conversation */ + sessionId?: string; + /** Optional AbortSignal for cancellation */ + signal?: AbortSignal; +} + +/** + * Request options for structured object generation. + */ +export interface GenerateObjectOptions { + /** The user prompt describing what to extract */ + prompt: string; + /** Zod schema defining the expected response structure */ + schema: z.ZodSchema; + /** Optional system prompt for context */ + system?: string; + /** Optional session ID to continue a conversation */ + sessionId?: string; + /** Optional AbortSignal for cancellation */ + signal?: AbortSignal; +} + +/** + * Text generation result. + */ +export interface GenerateTextResult { + readonly text: string; + readonly usage: KoineUsage; + readonly sessionId: string; +} + +/** + * Structured object generation result. + */ +export interface GenerateObjectResult { + readonly object: T; + readonly rawText: string; + readonly usage: KoineUsage; + readonly sessionId: string; +} + +/** + * Koine client interface returned by createKoine. + */ +export interface KoineClient { + /** + * Generates plain text response from Koine gateway service. + * + * @param options - Request options + * @returns Object containing response text, usage stats, and session ID + * @throws {KoineError} When the request fails or returns invalid response + */ + generateText(options: GenerateTextOptions): Promise; + + /** + * Streams text response from Koine gateway service. + * + * @param options - Request options + * @returns KoineStreamResult with textStream and promises for sessionId, usage, text + * @throws {KoineError} When connection fails or stream encounters an error + */ + streamText(options: StreamTextOptions): Promise; + + /** + * Generates structured JSON response from Koine gateway service. + * + * @typeParam T - The type of the expected response object, inferred from schema + * @param options - Request options including Zod schema + * @returns Object containing validated response, raw text, usage, and sessionId + * @throws {KoineError} With code 'VALIDATION_ERROR' if response doesn't match schema + */ + generateObject( + options: GenerateObjectOptions, + ): Promise>; +} + +/** + * Creates a Koine client instance with the given configuration. + * + * @param config - Client configuration including baseUrl, authKey, and timeout + * @returns KoineClient with generateText, streamText, and generateObject methods + * @throws {KoineError} With code 'INVALID_CONFIG' if config is invalid + * + * @example + * ```typescript + * import { createKoine } from '@patternzones/koine-sdk'; + * + * const koine = createKoine({ + * baseUrl: 'http://localhost:3100', + * authKey: 'your-api-key', + * timeout: 300000, + * }); + * + * const result = await koine.generateText({ + * prompt: 'Hello, how are you?', + * }); + * + * console.log(result.text); + * ``` + */ +export function createKoine(config: KoineConfig): KoineClient { + // Validate config once at creation time + validateConfig(config); + + return { + generateText: (options) => generateText(config, options), + streamText: (options) => streamText(config, options), + generateObject: (options: GenerateObjectOptions) => + generateObject(config, options), + }; +} diff --git a/packages/sdks/typescript/src/index.ts b/packages/sdks/typescript/src/index.ts index 152f06a..9f65645 100644 --- a/packages/sdks/typescript/src/index.ts +++ b/packages/sdks/typescript/src/index.ts @@ -5,16 +5,16 @@ * * @example * ```typescript - * import { generateText, KoineConfig } from '@patternzones/koine-sdk'; + * import { createKoine } from '@patternzones/koine-sdk'; * - * const config: KoineConfig = { + * const koine = createKoine({ * baseUrl: 'http://localhost:3100', * timeout: 300000, * authKey: 'your-api-key', * model: 'sonnet', - * }; + * }); * - * const result = await generateText(config, { + * const result = await koine.generateText({ * prompt: 'Hello, how are you?', * }); * @@ -29,5 +29,16 @@ export type { KoineConfig, KoineUsage, KoineStreamResult } from "./types.js"; export { KoineError } from "./errors.js"; export type { KoineErrorCode } from "./errors.js"; -// Client functions +// Client factory (recommended API) +export { createKoine } from "./client.js"; +export type { + KoineClient, + GenerateTextOptions, + GenerateTextResult, + StreamTextOptions, + GenerateObjectOptions, + GenerateObjectResult, +} from "./client.js"; + +// Standalone functions (legacy API - still supported) export { generateText, streamText, generateObject } from "./client.js"; diff --git a/packages/sdks/typescript/src/types.ts b/packages/sdks/typescript/src/types.ts index 1eeaa3c..8cd47c4 100644 --- a/packages/sdks/typescript/src/types.ts +++ b/packages/sdks/typescript/src/types.ts @@ -16,37 +16,37 @@ export interface KoineConfig { * Usage information from Koine gateway service. */ export interface KoineUsage { - inputTokens: number; - outputTokens: number; - totalTokens: number; + readonly inputTokens: number; + readonly outputTokens: number; + readonly totalTokens: number; } /** - * Response from generate-text endpoint. + * Response from generate-text endpoint (internal). */ export interface GenerateTextResponse { - text: string; - usage: KoineUsage; - sessionId: string; + readonly text: string; + readonly usage: KoineUsage; + readonly sessionId: string; } /** - * Response from generate-object endpoint. + * Response from generate-object endpoint (internal). */ export interface GenerateObjectResponse { - object: unknown; - rawText: string; - usage: KoineUsage; - sessionId: string; + readonly object: unknown; + readonly rawText: string; + readonly usage: KoineUsage; + readonly sessionId: string; } /** - * Error response from Koine gateway service. + * Error response from Koine gateway service (internal). */ export interface ErrorResponse { - error: string; - code: string; - rawText?: string; + readonly error: string; + readonly code: string; + readonly rawText?: string; } /** @@ -54,28 +54,28 @@ export interface ErrorResponse { */ export interface KoineStreamResult { /** ReadableStream of text chunks as they arrive */ - textStream: ReadableStream; + readonly textStream: ReadableStream; /** Session ID for conversation continuity (resolves early in stream, after session event) */ - sessionId: Promise; + readonly sessionId: Promise; /** Usage stats (resolves when stream completes via result event) */ - usage: Promise; + readonly usage: Promise; /** Full accumulated text (resolves when stream completes) */ - text: Promise; + readonly text: Promise; } /** - * SSE event types from Koine gateway /stream endpoint. + * SSE event types from Koine gateway /stream endpoint (internal). */ export interface SSETextEvent { - text: string; + readonly text: string; } export interface SSEResultEvent { - sessionId: string; - usage: KoineUsage; + readonly sessionId: string; + readonly usage: KoineUsage; } export interface SSEErrorEvent { - error: string; - code?: string; + readonly error: string; + readonly code?: string; } From 5a5f343f8dc057fc48d4af88bb4e5962970d4ab1 Mon Sep 17 00:00:00 2001 From: Matthew Petty Date: Thu, 25 Dec 2025 14:51:37 -0600 Subject: [PATCH 3/4] refactor(sdk): split client.ts into focused modules Split the monolithic client.ts (703 lines) into smaller, focused files: - http.ts: HTTP utilities (safeFetch, error handling) - text.ts: generateText function - stream/sse.ts: SSE parser - stream/index.ts: streamText function - object.ts: generateObject function - client.ts: Factory and re-exports --- packages/sdks/typescript/src/client.ts | 655 +------------------ packages/sdks/typescript/src/http.ts | 115 ++++ packages/sdks/typescript/src/object.ts | 136 ++++ packages/sdks/typescript/src/stream/index.ts | 245 +++++++ packages/sdks/typescript/src/stream/sse.ts | 63 ++ packages/sdks/typescript/src/text.ts | 98 +++ 6 files changed, 681 insertions(+), 631 deletions(-) create mode 100644 packages/sdks/typescript/src/http.ts create mode 100644 packages/sdks/typescript/src/object.ts create mode 100644 packages/sdks/typescript/src/stream/index.ts create mode 100644 packages/sdks/typescript/src/stream/sse.ts create mode 100644 packages/sdks/typescript/src/text.ts diff --git a/packages/sdks/typescript/src/client.ts b/packages/sdks/typescript/src/client.ts index 7b86db4..647e38d 100644 --- a/packages/sdks/typescript/src/client.ts +++ b/packages/sdks/typescript/src/client.ts @@ -1,634 +1,27 @@ -import type { z } from "zod"; -import { zodToJsonSchema } from "zod-to-json-schema"; -import { KoineError, type KoineErrorCode } from "./errors.js"; -import type { - ErrorResponse, - GenerateObjectResponse, - GenerateTextResponse, - KoineConfig, - KoineStreamResult, - KoineUsage, - SSEErrorEvent, - SSEResultEvent, - SSETextEvent, -} from "./types.js"; - -/** - * Known error codes for type-safe validation. - */ -const KNOWN_ERROR_CODES = new Set([ - // SDK-generated errors - "HTTP_ERROR", - "INVALID_RESPONSE", - "INVALID_CONFIG", - "VALIDATION_ERROR", - "STREAM_ERROR", - "SSE_PARSE_ERROR", - "NO_SESSION", - "NO_USAGE", - "NO_RESPONSE_BODY", - "TIMEOUT", - "NETWORK_ERROR", - // Gateway-returned errors - "INVALID_PARAMS", - "AUTH_ERROR", - "UNAUTHORIZED", - "SERVER_ERROR", - "SCHEMA_ERROR", - "RATE_LIMITED", - "CONTEXT_OVERFLOW", -]); - -/** - * Coerces an API error code to a known KoineErrorCode. - * Falls back to the provided default if the code is unknown. - */ -function toErrorCode( - code: string | undefined, - fallback: KoineErrorCode, -): KoineErrorCode { - if (code && KNOWN_ERROR_CODES.has(code as KoineErrorCode)) { - return code as KoineErrorCode; - } - return fallback; -} - -/** - * Validates config parameters before making requests. - * @throws {KoineError} with code 'INVALID_CONFIG' if config is invalid - */ -function validateConfig(config: KoineConfig): void { - if (!config.baseUrl) { - throw new KoineError("baseUrl is required", "INVALID_CONFIG"); - } - if (!config.authKey) { - throw new KoineError("authKey is required", "INVALID_CONFIG"); - } - if (typeof config.timeout !== "number" || config.timeout <= 0) { - throw new KoineError("timeout must be a positive number", "INVALID_CONFIG"); - } -} - -/** - * Creates an AbortSignal that combines timeout with optional user signal. - */ -function createAbortSignal( - timeout: number, - userSignal?: AbortSignal, -): AbortSignal { - const timeoutSignal = AbortSignal.timeout(timeout); - if (!userSignal) { - return timeoutSignal; - } - // Combine signals - abort when either triggers - return AbortSignal.any([timeoutSignal, userSignal]); -} - -/** - * Wraps fetch errors in KoineError for consistent error handling. - */ -async function safeFetch( - url: string, - options: RequestInit, - timeout: number, -): Promise { - try { - return await fetch(url, options); - } catch (error) { - if (error instanceof DOMException && error.name === "AbortError") { - // Check if it was a timeout or user cancellation - throw new KoineError( - `Request aborted (timeout: ${timeout}ms)`, - "TIMEOUT", - ); - } - if (error instanceof TypeError) { - // Network errors (DNS failure, connection refused, etc.) - throw new KoineError(`Network error: ${error.message}`, "NETWORK_ERROR"); - } - // Unknown error - wrap it - throw new KoineError( - `Request failed: ${error instanceof Error ? error.message : String(error)}`, - "NETWORK_ERROR", - ); - } -} - -/** - * Safely parses JSON from a response, handling non-JSON bodies gracefully. - */ -async function safeJsonParse(response: Response): Promise { - const text = await response.text(); - try { - return JSON.parse(text) as T; - } catch { - return null; - } -} - -/** - * Generates plain text response from Koine gateway service. - * - * @param config - Client configuration including baseUrl, authKey, and timeout - * @param options - Request options - * @param options.prompt - The user prompt to send - * @param options.system - Optional system prompt for context - * @param options.sessionId - Optional session ID to continue a conversation - * @param options.signal - Optional AbortSignal for cancellation - * @returns Object containing response text, usage stats, and session ID - * @throws {KoineError} When the request fails or returns invalid response - */ -export async function generateText( - config: KoineConfig, - options: { - system?: string; - prompt: string; - sessionId?: string; - signal?: AbortSignal; - }, -): Promise<{ - text: string; - usage: KoineUsage; - sessionId: string; -}> { - validateConfig(config); - - const response = await safeFetch( - `${config.baseUrl}/generate-text`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${config.authKey}`, - }, - body: JSON.stringify({ - system: options.system, - prompt: options.prompt, - sessionId: options.sessionId, - model: config.model, - }), - signal: createAbortSignal(config.timeout, options.signal), - }, - config.timeout, - ); - - if (!response.ok) { - const errorBody = await safeJsonParse(response); - throw new KoineError( - errorBody?.error || `HTTP ${response.status} ${response.statusText}`, - toErrorCode(errorBody?.code, "HTTP_ERROR"), - errorBody?.rawText, - ); - } - - const result = await safeJsonParse(response); - if (!result) { - throw new KoineError( - "Invalid response from Koine gateway: expected JSON", - "INVALID_RESPONSE", - ); - } - - return { - text: result.text, - usage: result.usage, - sessionId: result.sessionId, - }; -} - -/** - * Parses SSE events from a ReadableStream. - * SSE format: "event: name\ndata: {...}\n\n" - */ -function createSSEParser(): TransformStream< - Uint8Array, - { event: string; data: string } -> { - let buffer = ""; - // Reuse decoder with stream mode to correctly handle multi-byte UTF-8 chars spanning chunks - const decoder = new TextDecoder(); - - return new TransformStream({ - transform(chunk, controller) { - buffer += decoder.decode(chunk, { stream: true }); - - // SSE events are separated by double newlines - const events = buffer.split("\n\n"); - // Keep the last potentially incomplete event in the buffer - buffer = events.pop() || ""; - - for (const eventStr of events) { - if (!eventStr.trim()) continue; - - const lines = eventStr.split("\n"); - let eventType = ""; - let data = ""; - - for (const line of lines) { - if (line.startsWith("event: ")) { - eventType = line.slice(7); - } else if (line.startsWith("data: ")) { - data = line.slice(6); - } - } - - if (eventType && data) { - controller.enqueue({ event: eventType, data }); - } - } - }, - flush(controller) { - // Process any remaining data in buffer - if (buffer.trim()) { - const lines = buffer.split("\n"); - let eventType = ""; - let data = ""; - - for (const line of lines) { - if (line.startsWith("event: ")) { - eventType = line.slice(7); - } else if (line.startsWith("data: ")) { - data = line.slice(6); - } - } - - if (eventType && data) { - controller.enqueue({ event: eventType, data }); - } - } - }, - }); -} - -/** - * Streams text response from Koine gateway service. - * - * @param config - Client configuration including baseUrl, authKey, and timeout - * @param options - Request options - * @param options.prompt - The user prompt to send - * @param options.system - Optional system prompt for context - * @param options.sessionId - Optional session ID to continue a conversation - * @param options.signal - Optional AbortSignal for cancellation - * @returns KoineStreamResult containing: - * - textStream: ReadableStream of text chunks (async iterable) - * - sessionId: Promise that resolves early when session event arrives - * - usage: Promise that resolves when stream completes - * - text: Promise containing full accumulated text - * @throws {KoineError} When connection fails or stream encounters an error - */ -export async function streamText( - config: KoineConfig, - options: { - system?: string; - prompt: string; - sessionId?: string; - signal?: AbortSignal; - }, -): Promise { - validateConfig(config); - - const response = await safeFetch( - `${config.baseUrl}/stream`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${config.authKey}`, - }, - body: JSON.stringify({ - system: options.system, - prompt: options.prompt, - sessionId: options.sessionId, - model: config.model, - }), - signal: createAbortSignal(config.timeout, options.signal), - }, - config.timeout, - ); - - if (!response.ok) { - const errorBody = await safeJsonParse(response); - throw new KoineError( - errorBody?.error || `HTTP ${response.status} ${response.statusText}`, - toErrorCode(errorBody?.code, "HTTP_ERROR"), - errorBody?.rawText, - ); - } - - if (!response.body) { - throw new KoineError( - "No response body from Koine gateway", - "NO_RESPONSE_BODY", - ); - } - - // Set up promises for session, usage, and accumulated text - let resolveSessionId: (value: string) => void; - let rejectSessionId: (error: Error) => void; - const sessionIdPromise = new Promise((resolve, reject) => { - resolveSessionId = resolve; - rejectSessionId = reject; - }); - - let resolveUsage: (value: KoineUsage) => void; - let rejectUsage: (error: Error) => void; - const usagePromise = new Promise((resolve, reject) => { - resolveUsage = resolve; - rejectUsage = reject; - }); - - let resolveText: (value: string) => void; - let rejectText: (error: Error) => void; - const textPromise = new Promise((resolve, reject) => { - resolveText = resolve; - rejectText = reject; - }); - - let accumulatedText = ""; - let sessionIdReceived = false; - let usageReceived = false; - let textResolved = false; - - // Transform SSE events into text chunks - const textStream = response.body.pipeThrough(createSSEParser()).pipeThrough( - new TransformStream<{ event: string; data: string }, string>({ - transform(sseEvent, controller) { - // Critical events (session, result, error, done) must propagate parse errors - // Text events can log and continue - degraded content is better than total failure - const isCriticalEvent = ["session", "result", "error", "done"].includes( - sseEvent.event, - ); - - try { - switch (sseEvent.event) { - case "session": { - const parsed = JSON.parse(sseEvent.data) as { sessionId: string }; - if (!sessionIdReceived) { - sessionIdReceived = true; - resolveSessionId(parsed.sessionId); - } - break; - } - case "text": { - const parsed = JSON.parse(sseEvent.data) as SSETextEvent; - accumulatedText += parsed.text; - controller.enqueue(parsed.text); - break; - } - case "result": { - const parsed = JSON.parse(sseEvent.data) as SSEResultEvent; - usageReceived = true; - resolveUsage(parsed.usage); - if (!sessionIdReceived) { - sessionIdReceived = true; - resolveSessionId(parsed.sessionId); - } - break; - } - case "error": { - const parsed = JSON.parse(sseEvent.data) as SSEErrorEvent; - const error = new KoineError( - parsed.error, - toErrorCode(parsed.code, "STREAM_ERROR"), - ); - usageReceived = true; // Prevent double rejection in flush - rejectUsage(error); - rejectText(error); - if (!sessionIdReceived) { - rejectSessionId(error); - } - controller.error(error); - break; - } - case "done": { - // Stream complete, resolve the text promise - if (!textResolved) { - textResolved = true; - resolveText(accumulatedText); - } - break; - } - } - } catch (parseError) { - const parseErrorMessage = - parseError instanceof Error - ? parseError.message - : String(parseError); - - if (isCriticalEvent) { - // Critical event parse failure - propagate error - const error = new KoineError( - `Failed to parse critical SSE event '${sseEvent.event}': ${parseErrorMessage}`, - "SSE_PARSE_ERROR", - sseEvent.data, - ); - if (!usageReceived) { - usageReceived = true; - rejectUsage(error); - } - if (!textResolved) { - textResolved = true; - rejectText(error); - } - if (!sessionIdReceived) { - rejectSessionId(error); - } - controller.error(error); - } else { - // Non-critical event (text) - log warning but continue stream - // Degraded content is better than total failure - console.warn( - `[Koine SDK] Failed to parse SSE text event: ${parseErrorMessage}. Raw data: ${sseEvent.data?.substring(0, 100)}`, - ); - } - } - }, - flush() { - // Handle promises that were never resolved/rejected during stream - if (!sessionIdReceived) { - rejectSessionId( - new KoineError("Stream ended without session ID", "NO_SESSION"), - ); - } - if (!usageReceived) { - rejectUsage( - new KoineError( - "Stream ended without usage information", - "NO_USAGE", - ), - ); - } - if (!textResolved) { - resolveText(accumulatedText); - } - }, - }), - ); - - return { - textStream, - sessionId: sessionIdPromise, - usage: usagePromise, - text: textPromise, - }; -} - -/** - * Generates structured JSON response from Koine gateway service. - * Converts the provided Zod schema to JSON Schema format for the gateway. - * - * @typeParam T - The type of the expected response object, inferred from schema - * @param config - Client configuration including baseUrl, authKey, and timeout - * @param options - Request options - * @param options.prompt - The user prompt describing what to extract - * @param options.schema - Zod schema defining the expected response structure - * @param options.system - Optional system prompt for context - * @param options.sessionId - Optional session ID to continue a conversation - * @param options.signal - Optional AbortSignal for cancellation - * @returns Object containing parsed and validated response, raw text, usage, and sessionId - * @throws {KoineError} With code 'VALIDATION_ERROR' if response doesn't match schema - * @throws {KoineError} With code 'HTTP_ERROR' for network/authentication failures - */ -export async function generateObject( - config: KoineConfig, - options: { - system?: string; - prompt: string; - schema: z.ZodSchema; - sessionId?: string; - signal?: AbortSignal; - }, -): Promise<{ - object: T; - rawText: string; - usage: KoineUsage; - sessionId: string; -}> { - validateConfig(config); - - // Convert Zod schema to JSON Schema for the gateway service - const jsonSchema = zodToJsonSchema(options.schema, { - $refStrategy: "none", - target: "jsonSchema7", - }); - - const response = await safeFetch( - `${config.baseUrl}/generate-object`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${config.authKey}`, - }, - body: JSON.stringify({ - system: options.system, - prompt: options.prompt, - schema: jsonSchema, - sessionId: options.sessionId, - model: config.model, - }), - signal: createAbortSignal(config.timeout, options.signal), - }, - config.timeout, - ); - - if (!response.ok) { - const errorBody = await safeJsonParse(response); - throw new KoineError( - errorBody?.error || `HTTP ${response.status} ${response.statusText}`, - toErrorCode(errorBody?.code, "HTTP_ERROR"), - errorBody?.rawText, - ); - } - - const result = await safeJsonParse(response); - if (!result) { - throw new KoineError( - "Invalid response from Koine gateway: expected JSON", - "INVALID_RESPONSE", - ); - } - - // Validate the response against the Zod schema - const parseResult = options.schema.safeParse(result.object); - if (!parseResult.success) { - throw new KoineError( - `Response validation failed: ${parseResult.error.message}`, - "VALIDATION_ERROR", - result.rawText, - ); - } - - return { - object: parseResult.data, - rawText: result.rawText, - usage: result.usage, - sessionId: result.sessionId, - }; -} - -/** - * Request options for text generation. - */ -export interface GenerateTextOptions { - /** The user prompt to send */ - prompt: string; - /** Optional system prompt for context */ - system?: string; - /** Optional session ID to continue a conversation */ - sessionId?: string; - /** Optional AbortSignal for cancellation */ - signal?: AbortSignal; -} - -/** - * Request options for streaming text generation. - */ -export interface StreamTextOptions { - /** The user prompt to send */ - prompt: string; - /** Optional system prompt for context */ - system?: string; - /** Optional session ID to continue a conversation */ - sessionId?: string; - /** Optional AbortSignal for cancellation */ - signal?: AbortSignal; -} - -/** - * Request options for structured object generation. - */ -export interface GenerateObjectOptions { - /** The user prompt describing what to extract */ - prompt: string; - /** Zod schema defining the expected response structure */ - schema: z.ZodSchema; - /** Optional system prompt for context */ - system?: string; - /** Optional session ID to continue a conversation */ - sessionId?: string; - /** Optional AbortSignal for cancellation */ - signal?: AbortSignal; -} - -/** - * Text generation result. - */ -export interface GenerateTextResult { - readonly text: string; - readonly usage: KoineUsage; - readonly sessionId: string; -} - -/** - * Structured object generation result. - */ -export interface GenerateObjectResult { - readonly object: T; - readonly rawText: string; - readonly usage: KoineUsage; - readonly sessionId: string; -} +// Import and re-export functions from the new modules +import { validateConfig } from "./http.js"; +import { + type GenerateObjectOptions, + type GenerateObjectResult, + generateObject, +} from "./object.js"; +import { type StreamTextOptions, streamText } from "./stream/index.js"; +import { + type GenerateTextOptions, + type GenerateTextResult, + generateText, +} from "./text.js"; +import type { KoineConfig, KoineStreamResult } from "./types.js"; + +// Re-export functions and types for backwards compatibility +export { generateText, streamText, generateObject }; +export type { + GenerateTextOptions, + GenerateTextResult, + StreamTextOptions, + GenerateObjectOptions, + GenerateObjectResult, +}; /** * Koine client interface returned by createKoine. diff --git a/packages/sdks/typescript/src/http.ts b/packages/sdks/typescript/src/http.ts new file mode 100644 index 0000000..8dfac83 --- /dev/null +++ b/packages/sdks/typescript/src/http.ts @@ -0,0 +1,115 @@ +import { KoineError, type KoineErrorCode } from "./errors.js"; +import type { KoineConfig } from "./types.js"; + +/** + * Known error codes for type-safe validation. + */ +export const KNOWN_ERROR_CODES = new Set([ + // SDK-generated errors + "HTTP_ERROR", + "INVALID_RESPONSE", + "INVALID_CONFIG", + "VALIDATION_ERROR", + "STREAM_ERROR", + "SSE_PARSE_ERROR", + "NO_SESSION", + "NO_USAGE", + "NO_RESPONSE_BODY", + "TIMEOUT", + "NETWORK_ERROR", + // Gateway-returned errors + "INVALID_PARAMS", + "AUTH_ERROR", + "UNAUTHORIZED", + "SERVER_ERROR", + "SCHEMA_ERROR", + "RATE_LIMITED", + "CONTEXT_OVERFLOW", +]); + +/** + * Coerces an API error code to a known KoineErrorCode. + * Falls back to the provided default if the code is unknown. + */ +export function toErrorCode( + code: string | undefined, + fallback: KoineErrorCode, +): KoineErrorCode { + if (code && KNOWN_ERROR_CODES.has(code as KoineErrorCode)) { + return code as KoineErrorCode; + } + return fallback; +} + +/** + * Validates config parameters before making requests. + * @throws {KoineError} with code 'INVALID_CONFIG' if config is invalid + */ +export function validateConfig(config: KoineConfig): void { + if (!config.baseUrl) { + throw new KoineError("baseUrl is required", "INVALID_CONFIG"); + } + if (!config.authKey) { + throw new KoineError("authKey is required", "INVALID_CONFIG"); + } + if (typeof config.timeout !== "number" || config.timeout <= 0) { + throw new KoineError("timeout must be a positive number", "INVALID_CONFIG"); + } +} + +/** + * Creates an AbortSignal that combines timeout with optional user signal. + */ +export function createAbortSignal( + timeout: number, + userSignal?: AbortSignal, +): AbortSignal { + const timeoutSignal = AbortSignal.timeout(timeout); + if (!userSignal) { + return timeoutSignal; + } + // Combine signals - abort when either triggers + return AbortSignal.any([timeoutSignal, userSignal]); +} + +/** + * Wraps fetch errors in KoineError for consistent error handling. + */ +export async function safeFetch( + url: string, + options: RequestInit, + timeout: number, +): Promise { + try { + return await fetch(url, options); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + // Check if it was a timeout or user cancellation + throw new KoineError( + `Request aborted (timeout: ${timeout}ms)`, + "TIMEOUT", + ); + } + if (error instanceof TypeError) { + // Network errors (DNS failure, connection refused, etc.) + throw new KoineError(`Network error: ${error.message}`, "NETWORK_ERROR"); + } + // Unknown error - wrap it + throw new KoineError( + `Request failed: ${error instanceof Error ? error.message : String(error)}`, + "NETWORK_ERROR", + ); + } +} + +/** + * Safely parses JSON from a response, handling non-JSON bodies gracefully. + */ +export async function safeJsonParse(response: Response): Promise { + const text = await response.text(); + try { + return JSON.parse(text) as T; + } catch { + return null; + } +} diff --git a/packages/sdks/typescript/src/object.ts b/packages/sdks/typescript/src/object.ts new file mode 100644 index 0000000..346fab7 --- /dev/null +++ b/packages/sdks/typescript/src/object.ts @@ -0,0 +1,136 @@ +import type { z } from "zod"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import { KoineError } from "./errors.js"; +import { + createAbortSignal, + safeFetch, + safeJsonParse, + toErrorCode, + validateConfig, +} from "./http.js"; +import type { + ErrorResponse, + GenerateObjectResponse, + KoineConfig, + KoineUsage, +} from "./types.js"; + +/** + * Generates structured JSON response from Koine gateway service. + * Converts the provided Zod schema to JSON Schema format for the gateway. + * + * @typeParam T - The type of the expected response object, inferred from schema + * @param config - Client configuration including baseUrl, authKey, and timeout + * @param options - Request options + * @param options.prompt - The user prompt describing what to extract + * @param options.schema - Zod schema defining the expected response structure + * @param options.system - Optional system prompt for context + * @param options.sessionId - Optional session ID to continue a conversation + * @param options.signal - Optional AbortSignal for cancellation + * @returns Object containing parsed and validated response, raw text, usage, and sessionId + * @throws {KoineError} With code 'VALIDATION_ERROR' if response doesn't match schema + * @throws {KoineError} With code 'HTTP_ERROR' for network/authentication failures + */ +export async function generateObject( + config: KoineConfig, + options: { + system?: string; + prompt: string; + schema: z.ZodSchema; + sessionId?: string; + signal?: AbortSignal; + }, +): Promise<{ + object: T; + rawText: string; + usage: KoineUsage; + sessionId: string; +}> { + validateConfig(config); + + // Convert Zod schema to JSON Schema for the gateway service + const jsonSchema = zodToJsonSchema(options.schema, { + $refStrategy: "none", + target: "jsonSchema7", + }); + + const response = await safeFetch( + `${config.baseUrl}/generate-object`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${config.authKey}`, + }, + body: JSON.stringify({ + system: options.system, + prompt: options.prompt, + schema: jsonSchema, + sessionId: options.sessionId, + model: config.model, + }), + signal: createAbortSignal(config.timeout, options.signal), + }, + config.timeout, + ); + + if (!response.ok) { + const errorBody = await safeJsonParse(response); + throw new KoineError( + errorBody?.error || `HTTP ${response.status} ${response.statusText}`, + toErrorCode(errorBody?.code, "HTTP_ERROR"), + errorBody?.rawText, + ); + } + + const result = await safeJsonParse(response); + if (!result) { + throw new KoineError( + "Invalid response from Koine gateway: expected JSON", + "INVALID_RESPONSE", + ); + } + + // Validate the response against the Zod schema + const parseResult = options.schema.safeParse(result.object); + if (!parseResult.success) { + throw new KoineError( + `Response validation failed: ${parseResult.error.message}`, + "VALIDATION_ERROR", + result.rawText, + ); + } + + return { + object: parseResult.data, + rawText: result.rawText, + usage: result.usage, + sessionId: result.sessionId, + }; +} + +/** + * Request options for structured object generation. + */ +export interface GenerateObjectOptions { + /** The user prompt describing what to extract */ + prompt: string; + /** Zod schema defining the expected response structure */ + schema: z.ZodSchema; + /** Optional system prompt for context */ + system?: string; + /** Optional session ID to continue a conversation */ + sessionId?: string; + /** Optional AbortSignal for cancellation */ + signal?: AbortSignal; +} + +/** + * Structured object generation result. + */ +export interface GenerateObjectResult { + readonly object: T; + readonly rawText: string; + readonly usage: KoineUsage; + readonly sessionId: string; +} diff --git a/packages/sdks/typescript/src/stream/index.ts b/packages/sdks/typescript/src/stream/index.ts new file mode 100644 index 0000000..d2b1db3 --- /dev/null +++ b/packages/sdks/typescript/src/stream/index.ts @@ -0,0 +1,245 @@ +import { KoineError } from "../errors.js"; +import { + createAbortSignal, + safeFetch, + safeJsonParse, + toErrorCode, + validateConfig, +} from "../http.js"; +import type { + ErrorResponse, + KoineConfig, + KoineStreamResult, + KoineUsage, + SSEErrorEvent, + SSEResultEvent, + SSETextEvent, +} from "../types.js"; +import { createSSEParser } from "./sse.js"; + +/** + * Streams text response from Koine gateway service. + * + * @param config - Client configuration including baseUrl, authKey, and timeout + * @param options - Request options + * @param options.prompt - The user prompt to send + * @param options.system - Optional system prompt for context + * @param options.sessionId - Optional session ID to continue a conversation + * @param options.signal - Optional AbortSignal for cancellation + * @returns KoineStreamResult containing: + * - textStream: ReadableStream of text chunks (async iterable) + * - sessionId: Promise that resolves early when session event arrives + * - usage: Promise that resolves when stream completes + * - text: Promise containing full accumulated text + * @throws {KoineError} When connection fails or stream encounters an error + */ +export async function streamText( + config: KoineConfig, + options: { + system?: string; + prompt: string; + sessionId?: string; + signal?: AbortSignal; + }, +): Promise { + validateConfig(config); + + const response = await safeFetch( + `${config.baseUrl}/stream`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${config.authKey}`, + }, + body: JSON.stringify({ + system: options.system, + prompt: options.prompt, + sessionId: options.sessionId, + model: config.model, + }), + signal: createAbortSignal(config.timeout, options.signal), + }, + config.timeout, + ); + + if (!response.ok) { + const errorBody = await safeJsonParse(response); + throw new KoineError( + errorBody?.error || `HTTP ${response.status} ${response.statusText}`, + toErrorCode(errorBody?.code, "HTTP_ERROR"), + errorBody?.rawText, + ); + } + + if (!response.body) { + throw new KoineError( + "No response body from Koine gateway", + "NO_RESPONSE_BODY", + ); + } + + // Set up promises for session, usage, and accumulated text + let resolveSessionId: (value: string) => void; + let rejectSessionId: (error: Error) => void; + const sessionIdPromise = new Promise((resolve, reject) => { + resolveSessionId = resolve; + rejectSessionId = reject; + }); + + let resolveUsage: (value: KoineUsage) => void; + let rejectUsage: (error: Error) => void; + const usagePromise = new Promise((resolve, reject) => { + resolveUsage = resolve; + rejectUsage = reject; + }); + + let resolveText: (value: string) => void; + let rejectText: (error: Error) => void; + const textPromise = new Promise((resolve, reject) => { + resolveText = resolve; + rejectText = reject; + }); + + let accumulatedText = ""; + let sessionIdReceived = false; + let usageReceived = false; + let textResolved = false; + + // Transform SSE events into text chunks + const textStream = response.body.pipeThrough(createSSEParser()).pipeThrough( + new TransformStream<{ event: string; data: string }, string>({ + transform(sseEvent, controller) { + // Critical events (session, result, error, done) must propagate parse errors + // Text events can log and continue - degraded content is better than total failure + const isCriticalEvent = ["session", "result", "error", "done"].includes( + sseEvent.event, + ); + + try { + switch (sseEvent.event) { + case "session": { + const parsed = JSON.parse(sseEvent.data) as { sessionId: string }; + if (!sessionIdReceived) { + sessionIdReceived = true; + resolveSessionId(parsed.sessionId); + } + break; + } + case "text": { + const parsed = JSON.parse(sseEvent.data) as SSETextEvent; + accumulatedText += parsed.text; + controller.enqueue(parsed.text); + break; + } + case "result": { + const parsed = JSON.parse(sseEvent.data) as SSEResultEvent; + usageReceived = true; + resolveUsage(parsed.usage); + if (!sessionIdReceived) { + sessionIdReceived = true; + resolveSessionId(parsed.sessionId); + } + break; + } + case "error": { + const parsed = JSON.parse(sseEvent.data) as SSEErrorEvent; + const error = new KoineError( + parsed.error, + toErrorCode(parsed.code, "STREAM_ERROR"), + ); + usageReceived = true; // Prevent double rejection in flush + rejectUsage(error); + rejectText(error); + if (!sessionIdReceived) { + rejectSessionId(error); + } + controller.error(error); + break; + } + case "done": { + // Stream complete, resolve the text promise + if (!textResolved) { + textResolved = true; + resolveText(accumulatedText); + } + break; + } + } + } catch (parseError) { + const parseErrorMessage = + parseError instanceof Error + ? parseError.message + : String(parseError); + + if (isCriticalEvent) { + // Critical event parse failure - propagate error + const error = new KoineError( + `Failed to parse critical SSE event '${sseEvent.event}': ${parseErrorMessage}`, + "SSE_PARSE_ERROR", + sseEvent.data, + ); + if (!usageReceived) { + usageReceived = true; + rejectUsage(error); + } + if (!textResolved) { + textResolved = true; + rejectText(error); + } + if (!sessionIdReceived) { + rejectSessionId(error); + } + controller.error(error); + } else { + // Non-critical event (text) - log warning but continue stream + // Degraded content is better than total failure + console.warn( + `[Koine SDK] Failed to parse SSE text event: ${parseErrorMessage}. Raw data: ${sseEvent.data?.substring(0, 100)}`, + ); + } + } + }, + flush() { + // Handle promises that were never resolved/rejected during stream + if (!sessionIdReceived) { + rejectSessionId( + new KoineError("Stream ended without session ID", "NO_SESSION"), + ); + } + if (!usageReceived) { + rejectUsage( + new KoineError( + "Stream ended without usage information", + "NO_USAGE", + ), + ); + } + if (!textResolved) { + resolveText(accumulatedText); + } + }, + }), + ); + + return { + textStream, + sessionId: sessionIdPromise, + usage: usagePromise, + text: textPromise, + }; +} + +/** + * Request options for streaming text generation. + */ +export interface StreamTextOptions { + /** The user prompt to send */ + prompt: string; + /** Optional system prompt for context */ + system?: string; + /** Optional session ID to continue a conversation */ + sessionId?: string; + /** Optional AbortSignal for cancellation */ + signal?: AbortSignal; +} diff --git a/packages/sdks/typescript/src/stream/sse.ts b/packages/sdks/typescript/src/stream/sse.ts new file mode 100644 index 0000000..a00d804 --- /dev/null +++ b/packages/sdks/typescript/src/stream/sse.ts @@ -0,0 +1,63 @@ +/** + * Parses SSE events from a ReadableStream. + * SSE format: "event: name\ndata: {...}\n\n" + */ +export function createSSEParser(): TransformStream< + Uint8Array, + { event: string; data: string } +> { + let buffer = ""; + // Reuse decoder with stream mode to correctly handle multi-byte UTF-8 chars spanning chunks + const decoder = new TextDecoder(); + + return new TransformStream({ + transform(chunk, controller) { + buffer += decoder.decode(chunk, { stream: true }); + + // SSE events are separated by double newlines + const events = buffer.split("\n\n"); + // Keep the last potentially incomplete event in the buffer + buffer = events.pop() || ""; + + for (const eventStr of events) { + if (!eventStr.trim()) continue; + + const lines = eventStr.split("\n"); + let eventType = ""; + let data = ""; + + for (const line of lines) { + if (line.startsWith("event: ")) { + eventType = line.slice(7); + } else if (line.startsWith("data: ")) { + data = line.slice(6); + } + } + + if (eventType && data) { + controller.enqueue({ event: eventType, data }); + } + } + }, + flush(controller) { + // Process any remaining data in buffer + if (buffer.trim()) { + const lines = buffer.split("\n"); + let eventType = ""; + let data = ""; + + for (const line of lines) { + if (line.startsWith("event: ")) { + eventType = line.slice(7); + } else if (line.startsWith("data: ")) { + data = line.slice(6); + } + } + + if (eventType && data) { + controller.enqueue({ event: eventType, data }); + } + } + }, + }); +} diff --git a/packages/sdks/typescript/src/text.ts b/packages/sdks/typescript/src/text.ts new file mode 100644 index 0000000..03dfdb3 --- /dev/null +++ b/packages/sdks/typescript/src/text.ts @@ -0,0 +1,98 @@ +import { KoineError } from "./errors.js"; +import { + createAbortSignal, + safeFetch, + safeJsonParse, + toErrorCode, + validateConfig, +} from "./http.js"; +import type { + ErrorResponse, + GenerateTextResponse, + KoineConfig, + KoineUsage, +} from "./types.js"; + +/** + * Request options for text generation. + */ +export interface GenerateTextOptions { + /** The user prompt to send */ + prompt: string; + /** Optional system prompt for context */ + system?: string; + /** Optional session ID to continue a conversation */ + sessionId?: string; + /** Optional AbortSignal for cancellation */ + signal?: AbortSignal; +} + +/** + * Text generation result. + */ +export interface GenerateTextResult { + readonly text: string; + readonly usage: KoineUsage; + readonly sessionId: string; +} + +/** + * Generates plain text response from Koine gateway service. + * + * @param config - Client configuration including baseUrl, authKey, and timeout + * @param options - Request options + * @param options.prompt - The user prompt to send + * @param options.system - Optional system prompt for context + * @param options.sessionId - Optional session ID to continue a conversation + * @param options.signal - Optional AbortSignal for cancellation + * @returns Object containing response text, usage stats, and session ID + * @throws {KoineError} When the request fails or returns invalid response + */ +export async function generateText( + config: KoineConfig, + options: GenerateTextOptions, +): Promise { + validateConfig(config); + + const response = await safeFetch( + `${config.baseUrl}/generate-text`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${config.authKey}`, + }, + body: JSON.stringify({ + system: options.system, + prompt: options.prompt, + sessionId: options.sessionId, + model: config.model, + }), + signal: createAbortSignal(config.timeout, options.signal), + }, + config.timeout, + ); + + if (!response.ok) { + const errorBody = await safeJsonParse(response); + throw new KoineError( + errorBody?.error || `HTTP ${response.status} ${response.statusText}`, + toErrorCode(errorBody?.code, "HTTP_ERROR"), + errorBody?.rawText, + ); + } + + const result = await safeJsonParse(response); + if (!result) { + throw new KoineError( + "Invalid response from Koine gateway: expected JSON", + "INVALID_RESPONSE", + ); + } + + return { + text: result.text, + usage: result.usage, + sessionId: result.sessionId, + }; +} From 58ea345cb0ab69621e1f10dd11b6c83eecdac8a8 Mon Sep 17 00:00:00 2001 From: Matthew Petty Date: Thu, 25 Dec 2025 15:11:05 -0600 Subject: [PATCH 4/4] refactor(sdk): split client.test.ts into focused test modules Split the monolithic test file into separate files mirroring the source code structure: - helpers.ts: Shared test utilities (mocks, config) - text.test.ts: generateText tests - stream.test.ts: streamText tests - object.test.ts: generateObject tests - client.test.ts: KoineError and createKoine factory tests All 42 tests pass. No functionality changed. --- .../sdks/typescript/__tests__/client.test.ts | 1095 ++--------------- packages/sdks/typescript/__tests__/helpers.ts | 98 ++ .../sdks/typescript/__tests__/object.test.ts | 222 ++++ .../sdks/typescript/__tests__/stream.test.ts | 395 ++++++ .../sdks/typescript/__tests__/text.test.ts | 221 ++++ 5 files changed, 1036 insertions(+), 995 deletions(-) create mode 100644 packages/sdks/typescript/__tests__/helpers.ts create mode 100644 packages/sdks/typescript/__tests__/object.test.ts create mode 100644 packages/sdks/typescript/__tests__/stream.test.ts create mode 100644 packages/sdks/typescript/__tests__/text.test.ts diff --git a/packages/sdks/typescript/__tests__/client.test.ts b/packages/sdks/typescript/__tests__/client.test.ts index 3f5971e..6ca1290 100644 --- a/packages/sdks/typescript/__tests__/client.test.ts +++ b/packages/sdks/typescript/__tests__/client.test.ts @@ -1,1041 +1,146 @@ /** - * Tests for Koine SDK client functions. + * Tests for Koine SDK client core functionality. * - * Tests the HTTP client layer that communicates with the Koine - * gateway service. Covers successful responses, error handling, timeouts, - * and schema validation. + * Tests KoineError class and createKoine factory function. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; -import { - createKoine, - generateObject, - generateText, - streamText, -} from "../src/client.js"; +import { createKoine } from "../src/client.js"; import { KoineError } from "../src/errors.js"; -import type { KoineConfig } from "../src/types.js"; - -// Store original fetch to restore later -const originalFetch = global.fetch; +import { + createMockResponse, + createMockSSEResponse, + originalFetch, + testConfig, +} from "./helpers.js"; + +describe("KoineError", () => { + it("should create error with message and code", () => { + const error = new KoineError("Something went wrong", "TEST_ERROR"); + + expect(error.message).toBe("Something went wrong"); + expect(error.code).toBe("TEST_ERROR"); + expect(error.name).toBe("KoineError"); + expect(error.rawText).toBeUndefined(); + }); -// Helper to create mock Response objects -function createMockResponse( - body: unknown, - options: { status?: number; statusText?: string; ok?: boolean } = {}, -): Response { - const { status = 200, statusText = "OK", ok = true } = options; - const bodyText = typeof body === "string" ? body : JSON.stringify(body); + it("should create error with rawText for debugging", () => { + const error = new KoineError( + "Parse failed", + "PARSE_ERROR", + "raw output from CLI", + ); - return { - ok, - status, - statusText, - text: vi.fn().mockResolvedValue(bodyText), - json: vi.fn().mockResolvedValue(body), - headers: new Headers(), - redirected: false, - type: "basic", - url: "", - clone: vi.fn(), - body: null, - bodyUsed: false, - arrayBuffer: vi.fn(), - blob: vi.fn(), - formData: vi.fn(), - bytes: vi.fn(), - } as unknown as Response; -} + expect(error.message).toBe("Parse failed"); + expect(error.code).toBe("PARSE_ERROR"); + expect(error.rawText).toBe("raw output from CLI"); + }); -// Default test config -const testConfig: KoineConfig = { - baseUrl: "http://localhost:3100", - timeout: 30_000, - authKey: "test-auth-key-12345", -}; + it("should be instanceof Error", () => { + const error = new KoineError("test", "TEST"); + expect(error).toBeInstanceOf(Error); + }); +}); -describe("Koine SDK Client", () => { +describe("createKoine", () => { beforeEach(() => { vi.resetAllMocks(); }); afterEach(() => { - // Restore original fetch after each test global.fetch = originalFetch; }); - describe("KoineError", () => { - it("should create error with message and code", () => { - const error = new KoineError("Something went wrong", "TEST_ERROR"); - - expect(error.message).toBe("Something went wrong"); - expect(error.code).toBe("TEST_ERROR"); - expect(error.name).toBe("KoineError"); - expect(error.rawText).toBeUndefined(); + it("should create a client with generateText method", async () => { + const mockResponse = createMockResponse({ + text: "Hello from factory!", + usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, + sessionId: "factory-session", }); - it("should create error with rawText for debugging", () => { - const error = new KoineError( - "Parse failed", - "PARSE_ERROR", - "raw output from CLI", - ); + global.fetch = vi.fn().mockResolvedValue(mockResponse); - expect(error.message).toBe("Parse failed"); - expect(error.code).toBe("PARSE_ERROR"); - expect(error.rawText).toBe("raw output from CLI"); - }); + const koine = createKoine(testConfig); + const result = await koine.generateText({ prompt: "test" }); - it("should be instanceof Error", () => { - const error = new KoineError("test", "TEST"); - expect(error).toBeInstanceOf(Error); - }); + expect(result.text).toBe("Hello from factory!"); + expect(result.sessionId).toBe("factory-session"); }); - describe("generateText", () => { - it("should make POST request with correct headers and body", async () => { - const mockResponse = createMockResponse({ - text: "Hello, world!", - usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, - sessionId: "session-123", - }); - - const mockFetch = vi.fn().mockResolvedValue(mockResponse); - global.fetch = mockFetch; - - await generateText(testConfig, { - system: "You are helpful", - prompt: "Say hello", - }); - - expect(mockFetch).toHaveBeenCalledTimes(1); - const [url, options] = mockFetch.mock.calls[0]; - - expect(url).toBe("http://localhost:3100/generate-text"); - expect(options.method).toBe("POST"); - expect(options.headers["Content-Type"]).toBe("application/json"); - expect(options.headers.Authorization).toBe("Bearer test-auth-key-12345"); - - const body = JSON.parse(options.body); - expect(body.system).toBe("You are helpful"); - expect(body.prompt).toBe("Say hello"); - }); - - it("should return text, usage, and sessionId on success", async () => { - const mockResponse = createMockResponse({ - text: "Generated response", - usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 }, - sessionId: "sess-abc", - }); - - global.fetch = vi.fn().mockResolvedValue(mockResponse); - - const result = await generateText(testConfig, { - prompt: "Test prompt", - }); - - expect(result.text).toBe("Generated response"); - expect(result.usage).toEqual({ - inputTokens: 100, - outputTokens: 50, - totalTokens: 150, - }); - expect(result.sessionId).toBe("sess-abc"); - }); - - it("should pass sessionId when provided", async () => { - const mockResponse = createMockResponse({ - text: "Continued response", - usage: { inputTokens: 20, outputTokens: 10, totalTokens: 30 }, - sessionId: "existing-session", - }); - - const mockFetch = vi.fn().mockResolvedValue(mockResponse); - global.fetch = mockFetch; - - await generateText(testConfig, { - prompt: "Continue", - sessionId: "existing-session", - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.sessionId).toBe("existing-session"); - }); - - it("should throw KoineError on HTTP 4xx error with error body", async () => { - const errorResponse = createMockResponse( - { - error: "Invalid request parameters", - code: "INVALID_PARAMS", + it("should create a client with streamText method", async () => { + const events = [ + { event: "session", data: { sessionId: "stream-session" } }, + { event: "text", data: { text: "Streamed!" } }, + { + event: "result", + data: { + sessionId: "stream-session", + usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, }, - { status: 400, statusText: "Bad Request", ok: false }, - ); - - global.fetch = vi.fn().mockResolvedValue(errorResponse); - - await expect( - generateText(testConfig, { prompt: "test" }), - ).rejects.toMatchObject({ - name: "KoineError", - message: "Invalid request parameters", - code: "INVALID_PARAMS", - }); - }); - - it("should throw KoineError on 401 unauthorized", async () => { - const errorResponse = createMockResponse( - { error: "Invalid authentication key", code: "UNAUTHORIZED" }, - { status: 401, statusText: "Unauthorized", ok: false }, - ); - - global.fetch = vi.fn().mockResolvedValue(errorResponse); - - await expect( - generateText(testConfig, { prompt: "test" }), - ).rejects.toMatchObject({ - message: "Invalid authentication key", - code: "UNAUTHORIZED", - }); - }); - - it("should throw KoineError on HTTP 5xx error", async () => { - const errorResponse = createMockResponse( - { error: "Internal server error", code: "SERVER_ERROR" }, - { status: 500, statusText: "Internal Server Error", ok: false }, - ); - - global.fetch = vi.fn().mockResolvedValue(errorResponse); - - await expect( - generateText(testConfig, { prompt: "test" }), - ).rejects.toMatchObject({ - message: "Internal server error", - code: "SERVER_ERROR", - }); - }); - - it("should handle non-JSON error response gracefully", async () => { - const errorResponse = createMockResponse("Bad Gateway", { - status: 502, - statusText: "Bad Gateway", - ok: false, - }); - - global.fetch = vi.fn().mockResolvedValue(errorResponse); - - await expect( - generateText(testConfig, { prompt: "test" }), - ).rejects.toMatchObject({ - message: "HTTP 502 Bad Gateway", - code: "HTTP_ERROR", - }); - }); - - it("should throw KoineError when response is not valid JSON", async () => { - const invalidResponse = createMockResponse("not valid json at all"); - global.fetch = vi.fn().mockResolvedValue(invalidResponse); - - await expect( - generateText(testConfig, { prompt: "test" }), - ).rejects.toMatchObject({ - message: "Invalid response from Koine gateway: expected JSON", - code: "INVALID_RESPONSE", - }); - }); - - it("should include timeout signal in fetch call", async () => { - const mockResponse = createMockResponse({ - text: "response", - usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, - sessionId: "s", - }); - - const mockFetch = vi.fn().mockResolvedValue(mockResponse); - global.fetch = mockFetch; - - await generateText(testConfig, { prompt: "test" }); - - const [, options] = mockFetch.mock.calls[0]; - expect(options.signal).toBeDefined(); - expect(options.signal).toBeInstanceOf(AbortSignal); - }); - - it("should handle network errors", async () => { - global.fetch = vi.fn().mockRejectedValue(new Error("Network failure")); - - await expect( - generateText(testConfig, { prompt: "test" }), - ).rejects.toThrow("Network failure"); - }); - - it("should throw abort error when request times out", async () => { - const abortError = new DOMException( - "The operation was aborted.", - "AbortError", - ); - global.fetch = vi.fn().mockRejectedValue(abortError); - - await expect( - generateText(testConfig, { prompt: "test" }), - ).rejects.toThrow("Request aborted"); - }); + }, + { event: "done", data: { code: 0 } }, + ]; - it("should handle empty text response", async () => { - const mockResponse = createMockResponse({ - text: "", - usage: { inputTokens: 10, outputTokens: 0, totalTokens: 10 }, - sessionId: "empty-session", - }); + global.fetch = vi.fn().mockResolvedValue(createMockSSEResponse(events)); - global.fetch = vi.fn().mockResolvedValue(mockResponse); + const koine = createKoine(testConfig); + const result = await koine.streamText({ prompt: "test" }); - const result = await generateText(testConfig, { - prompt: "test", - }); + // Consume the stream first + const reader = result.textStream.getReader(); + while (true) { + const { done } = await reader.read(); + if (done) break; + } - expect(result.text).toBe(""); - expect(result.usage.outputTokens).toBe(0); - }); + const text = await result.text; + expect(text).toBe("Streamed!"); }); - describe("generateObject", () => { - const testSchema = z.object({ - name: z.string(), - age: z.number(), - active: z.boolean().optional(), - }); - - it("should convert Zod schema to JSON Schema in request", async () => { - const mockResponse = createMockResponse({ - object: { name: "John", age: 30 }, - rawText: '{"name": "John", "age": 30}', - usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, - sessionId: "session-123", - }); - - const mockFetch = vi.fn().mockResolvedValue(mockResponse); - global.fetch = mockFetch; - - await generateObject(testConfig, { - prompt: "Generate a person", - schema: testSchema, - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.schema).toBeDefined(); - expect(body.schema.type).toBe("object"); - expect(body.schema.properties).toHaveProperty("name"); - expect(body.schema.properties).toHaveProperty("age"); - expect(body.schema.properties).toHaveProperty("active"); - expect(body.schema.required).toContain("name"); - expect(body.schema.required).toContain("age"); - }); - - it("should return validated object on success", async () => { - const mockResponse = createMockResponse({ - object: { name: "Alice", age: 25, active: true }, - rawText: '{"name": "Alice", "age": 25, "active": true}', - usage: { inputTokens: 50, outputTokens: 20, totalTokens: 70 }, - sessionId: "obj-session", - }); - - global.fetch = vi.fn().mockResolvedValue(mockResponse); - - const result = await generateObject(testConfig, { - prompt: "Generate person", - schema: testSchema, - }); - - expect(result.object).toEqual({ name: "Alice", age: 25, active: true }); - expect(result.rawText).toBe( - '{"name": "Alice", "age": 25, "active": true}', - ); - expect(result.usage.totalTokens).toBe(70); - expect(result.sessionId).toBe("obj-session"); - }); - - it("should throw VALIDATION_ERROR when response fails Zod validation", async () => { - const mockResponse = createMockResponse({ - object: { name: "Bob", age: "not-a-number" }, // age should be number - rawText: '{"name": "Bob", "age": "not-a-number"}', - usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, - sessionId: "session", - }); - - global.fetch = vi.fn().mockResolvedValue(mockResponse); - - await expect( - generateObject(testConfig, { - prompt: "test", - schema: testSchema, - }), - ).rejects.toMatchObject({ - code: "VALIDATION_ERROR", - }); - }); - - it("should include rawText in validation error for debugging", async () => { - const mockResponse = createMockResponse({ - object: { invalid: "data" }, - rawText: '{"invalid": "data"}', - usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, - sessionId: "session", - }); - - global.fetch = vi.fn().mockResolvedValue(mockResponse); - - try { - await generateObject(testConfig, { - prompt: "test", - schema: testSchema, - }); - expect.fail("Should have thrown"); - } catch (error) { - expect(error).toBeInstanceOf(KoineError); - expect((error as KoineError).rawText).toBe('{"invalid": "data"}'); - } + it("should create a client with generateObject method", async () => { + const schema = z.object({ name: z.string() }); + const mockResponse = createMockResponse({ + object: { name: "Factory Test" }, + rawText: '{"name":"Factory Test"}', + usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, + sessionId: "object-session", }); - it("should throw KoineError on HTTP error", async () => { - const errorResponse = createMockResponse( - { error: "Schema parse error", code: "SCHEMA_ERROR", rawText: "..." }, - { status: 422, statusText: "Unprocessable Entity", ok: false }, - ); + global.fetch = vi.fn().mockResolvedValue(mockResponse); - global.fetch = vi.fn().mockResolvedValue(errorResponse); + const koine = createKoine(testConfig); + const result = await koine.generateObject({ prompt: "test", schema }); - await expect( - generateObject(testConfig, { - prompt: "test", - schema: testSchema, - }), - ).rejects.toMatchObject({ - message: "Schema parse error", - code: "SCHEMA_ERROR", - }); - }); - - it("should handle complex nested schemas", async () => { - const complexSchema = z.object({ - user: z.object({ - name: z.string(), - emails: z.array(z.string().email()), - }), - settings: z.object({ - notifications: z.boolean(), - theme: z.enum(["light", "dark"]), - }), - }); - - const responseData = { - user: { name: "Test", emails: ["test@example.com"] }, - settings: { notifications: true, theme: "dark" }, - }; - - const mockResponse = createMockResponse({ - object: responseData, - rawText: JSON.stringify(responseData), - usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 }, - sessionId: "complex-session", - }); - - global.fetch = vi.fn().mockResolvedValue(mockResponse); - - const result = await generateObject(testConfig, { - prompt: "Generate complex object", - schema: complexSchema, - }); - - expect(result.object).toEqual(responseData); - }); - - it("should send Authorization header", async () => { - const mockResponse = createMockResponse({ - object: { name: "Test", age: 20 }, - rawText: "{}", - usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, - sessionId: "s", - }); - - const mockFetch = vi.fn().mockResolvedValue(mockResponse); - global.fetch = mockFetch; - - await generateObject(testConfig, { - prompt: "test", - schema: testSchema, - }); - - const headers = mockFetch.mock.calls[0][1].headers; - expect(headers.Authorization).toBe("Bearer test-auth-key-12345"); - }); - - it("should call correct endpoint", async () => { - const mockResponse = createMockResponse({ - object: { name: "Test", age: 20 }, - rawText: "{}", - usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, - sessionId: "s", - }); - - const mockFetch = vi.fn().mockResolvedValue(mockResponse); - global.fetch = mockFetch; - - await generateObject(testConfig, { - prompt: "test", - schema: testSchema, - }); - - const url = mockFetch.mock.calls[0][0]; - expect(url).toBe("http://localhost:3100/generate-object"); - }); - - it("should throw INVALID_RESPONSE when response is not valid JSON", async () => { - const invalidResponse = createMockResponse("not json"); - global.fetch = vi.fn().mockResolvedValue(invalidResponse); - - await expect( - generateObject(testConfig, { - prompt: "test", - schema: testSchema, - }), - ).rejects.toMatchObject({ - message: "Invalid response from Koine gateway: expected JSON", - code: "INVALID_RESPONSE", - }); - }); + expect(result.object.name).toBe("Factory Test"); }); - /** - * Creates a mock SSE ReadableStream that emits events in SSE format. - * Used to simulate the gateway's /stream endpoint response. - */ - function createSSEStream( - events: Array<{ event: string; data: unknown }>, - ): ReadableStream { - const encoder = new TextEncoder(); - let index = 0; - - return new ReadableStream({ - pull(controller) { - if (index < events.length) { - const { event, data } = events[index]; - const sseData = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; - controller.enqueue(encoder.encode(sseData)); - index++; - } else { - controller.close(); - } - }, - }); - } - - /** - * Creates a mock Response with an SSE stream body. - */ - function createMockSSEResponse( - events: Array<{ event: string; data: unknown }>, - options: { status?: number; ok?: boolean } = {}, - ): Response { - const { status = 200, ok = true } = options; - const body = createSSEStream(events); - - return { - ok, - status, - statusText: ok ? "OK" : "Error", - headers: new Headers({ "Content-Type": "text/event-stream" }), - body, - text: vi.fn(), - json: vi.fn(), - redirected: false, - type: "basic", - url: "", - clone: vi.fn(), - bodyUsed: false, - arrayBuffer: vi.fn(), - blob: vi.fn(), - formData: vi.fn(), - bytes: vi.fn(), - } as unknown as Response; - } - - describe("streamText", () => { - it("should make POST request to /stream endpoint", async () => { - const events = [ - { event: "session", data: { sessionId: "stream-session-123" } }, - { event: "text", data: { text: "Hello" } }, - { - event: "result", - data: { - sessionId: "stream-session-123", - usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, - }, - }, - { event: "done", data: { code: 0 } }, - ]; - - const mockFetch = vi - .fn() - .mockResolvedValue(createMockSSEResponse(events)); - global.fetch = mockFetch; - - await streamText(testConfig, { - system: "You are helpful", - prompt: "Say hello", - }); - - expect(mockFetch).toHaveBeenCalledTimes(1); - const [url, options] = mockFetch.mock.calls[0]; - - expect(url).toBe("http://localhost:3100/stream"); - expect(options.method).toBe("POST"); - expect(options.headers["Content-Type"]).toBe("application/json"); - expect(options.headers.Authorization).toBe("Bearer test-auth-key-12345"); - - const body = JSON.parse(options.body); - expect(body.system).toBe("You are helpful"); - expect(body.prompt).toBe("Say hello"); - }); - - it("should return textStream that yields text chunks", async () => { - const events = [ - { event: "session", data: { sessionId: "session-abc" } }, - { event: "text", data: { text: "Hello" } }, - { event: "text", data: { text: " world" } }, - { event: "text", data: { text: "!" } }, - { - event: "result", - data: { - sessionId: "session-abc", - usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, - }, - }, - { event: "done", data: { code: 0 } }, - ]; - - global.fetch = vi.fn().mockResolvedValue(createMockSSEResponse(events)); - - const result = await streamText(testConfig, { - prompt: "Test prompt", - }); - - // Collect all chunks from the stream - const chunks: string[] = []; - const reader = result.textStream.getReader(); - while (true) { - const { done, value } = await reader.read(); - if (done) break; - chunks.push(value); - } - - expect(chunks).toEqual(["Hello", " world", "!"]); - }); - - it("should resolve text promise with accumulated text", async () => { - const events = [ - { event: "session", data: { sessionId: "session-xyz" } }, - { event: "text", data: { text: "First " } }, - { event: "text", data: { text: "Second " } }, - { event: "text", data: { text: "Third" } }, - { - event: "result", - data: { - sessionId: "session-xyz", - usage: { inputTokens: 20, outputTokens: 10, totalTokens: 30 }, - }, - }, - { event: "done", data: { code: 0 } }, - ]; - - global.fetch = vi.fn().mockResolvedValue(createMockSSEResponse(events)); - - const result = await streamText(testConfig, { - prompt: "Test", - }); - - // Consume the stream to trigger text accumulation - const reader = result.textStream.getReader(); - while (true) { - const { done } = await reader.read(); - if (done) break; - } - - const text = await result.text; - expect(text).toBe("First Second Third"); - }); - - it("should resolve usage promise with token counts", async () => { - const events = [ - { event: "session", data: { sessionId: "session-usage" } }, - { event: "text", data: { text: "Response" } }, - { - event: "result", - data: { - sessionId: "session-usage", - usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 }, - }, - }, - { event: "done", data: { code: 0 } }, - ]; - - global.fetch = vi.fn().mockResolvedValue(createMockSSEResponse(events)); - - const result = await streamText(testConfig, { - prompt: "Test", - }); - - // Consume stream - const reader = result.textStream.getReader(); - while (true) { - const { done } = await reader.read(); - if (done) break; - } - - const usage = await result.usage; - expect(usage).toEqual({ - inputTokens: 100, - outputTokens: 50, - totalTokens: 150, - }); - }); - - it("should resolve sessionId promise from session event", async () => { - const events = [ - { event: "session", data: { sessionId: "early-session-id" } }, - { event: "text", data: { text: "Content" } }, - { - event: "result", - data: { - sessionId: "early-session-id", - usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 }, - }, - }, - { event: "done", data: { code: 0 } }, - ]; - - global.fetch = vi.fn().mockResolvedValue(createMockSSEResponse(events)); - - const result = await streamText(testConfig, { - prompt: "Test", - }); - - // Consume stream to process SSE events - const reader = result.textStream.getReader(); - while (true) { - const { done } = await reader.read(); - if (done) break; - } - - const sessionId = await result.sessionId; - expect(sessionId).toBe("early-session-id"); - }); - - it("should pass sessionId when provided for continuation", async () => { - const events = [ - { event: "session", data: { sessionId: "continued-session" } }, - { event: "text", data: { text: "Continued" } }, - { - event: "result", - data: { - sessionId: "continued-session", - usage: { inputTokens: 15, outputTokens: 8, totalTokens: 23 }, - }, - }, - { event: "done", data: { code: 0 } }, - ]; - - const mockFetch = vi - .fn() - .mockResolvedValue(createMockSSEResponse(events)); - global.fetch = mockFetch; - - await streamText(testConfig, { - prompt: "Continue the conversation", - sessionId: "existing-session-123", - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.sessionId).toBe("existing-session-123"); - }); - - it("should throw KoineError on HTTP error", async () => { - const errorResponse = createMockResponse( - { error: "Rate limit exceeded", code: "RATE_LIMITED" }, - { status: 429, statusText: "Too Many Requests", ok: false }, - ); - - global.fetch = vi.fn().mockResolvedValue(errorResponse); - - await expect( - streamText(testConfig, { prompt: "test" }), - ).rejects.toMatchObject({ - message: "Rate limit exceeded", - code: "RATE_LIMITED", - }); - }); - - it("should throw KoineError when response body is null", async () => { - const noBodyResponse = { - ok: true, - status: 200, - statusText: "OK", - headers: new Headers(), - body: null, - text: vi.fn().mockResolvedValue(""), - json: vi.fn(), - redirected: false, - type: "basic", - url: "", - clone: vi.fn(), - bodyUsed: false, - arrayBuffer: vi.fn(), - blob: vi.fn(), - formData: vi.fn(), - bytes: vi.fn(), - } as unknown as Response; - - global.fetch = vi.fn().mockResolvedValue(noBodyResponse); - - await expect( - streamText(testConfig, { prompt: "test" }), - ).rejects.toMatchObject({ - message: "No response body from Koine gateway", - code: "NO_RESPONSE_BODY", - }); - }); - - it("should handle error SSE event and reject promises", async () => { - const events = [ - { event: "session", data: { sessionId: "error-session" } }, - { event: "text", data: { text: "Partial" } }, - { - event: "error", - data: { error: "Context window exceeded", code: "CONTEXT_OVERFLOW" }, - }, - ]; - - global.fetch = vi.fn().mockResolvedValue(createMockSSEResponse(events)); - - const result = await streamText(testConfig, { - prompt: "Very long prompt...", - }); - - // Consume stream - should encounter error - const reader = result.textStream.getReader(); - - await expect(async () => { - while (true) { - const { done } = await reader.read(); - if (done) break; - } - }).rejects.toMatchObject({ - message: "Context window exceeded", - code: "CONTEXT_OVERFLOW", - }); - - // Also verify that the usage and text promises reject - await expect(result.usage).rejects.toMatchObject({ - message: "Context window exceeded", - code: "CONTEXT_OVERFLOW", - }); - await expect(result.text).rejects.toMatchObject({ - message: "Context window exceeded", - code: "CONTEXT_OVERFLOW", - }); - }); - - it("should handle network errors", async () => { - global.fetch = vi.fn().mockRejectedValue(new Error("Connection refused")); - - await expect(streamText(testConfig, { prompt: "test" })).rejects.toThrow( - "Connection refused", - ); - }); - - it("should include timeout signal in fetch call", async () => { - const events = [ - { event: "session", data: { sessionId: "s" } }, - { event: "text", data: { text: "test" } }, - { - event: "result", - data: { - sessionId: "s", - usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, - }, - }, - { event: "done", data: { code: 0 } }, - ]; - - const mockFetch = vi - .fn() - .mockResolvedValue(createMockSSEResponse(events)); - global.fetch = mockFetch; - - await streamText(testConfig, { prompt: "test" }); - - const [, options] = mockFetch.mock.calls[0]; - expect(options.signal).toBeDefined(); - expect(options.signal).toBeInstanceOf(AbortSignal); - }); - - it("should pass model in request body", async () => { - const events = [ - { event: "session", data: { sessionId: "model-session" } }, - { event: "text", data: { text: "Output" } }, - { - event: "result", - data: { - sessionId: "model-session", - usage: { inputTokens: 5, outputTokens: 3, totalTokens: 8 }, - }, - }, - { event: "done", data: { code: 0 } }, - ]; - - const mockFetch = vi - .fn() - .mockResolvedValue(createMockSSEResponse(events)); - global.fetch = mockFetch; - - const configWithModel = { ...testConfig, model: "haiku" }; - await streamText(configWithModel, { prompt: "test" }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.model).toBe("haiku"); - }); - - it("should handle empty text events gracefully", async () => { - const events = [ - { event: "session", data: { sessionId: "empty-session" } }, - { event: "text", data: { text: "" } }, - { event: "text", data: { text: "Content" } }, - { event: "text", data: { text: "" } }, - { - event: "result", - data: { - sessionId: "empty-session", - usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, - }, - }, - { event: "done", data: { code: 0 } }, - ]; - - global.fetch = vi.fn().mockResolvedValue(createMockSSEResponse(events)); - - const result = await streamText(testConfig, { - prompt: "Test", - }); - - const chunks: string[] = []; - const reader = result.textStream.getReader(); - while (true) { - const { done, value } = await reader.read(); - if (done) break; - chunks.push(value); - } - - // Empty strings are valid text events and should be emitted - expect(chunks).toEqual(["", "Content", ""]); - - const text = await result.text; - expect(text).toBe("Content"); - }); + it("should validate config at creation time", () => { + expect(() => createKoine({ ...testConfig, baseUrl: "" })).toThrow( + KoineError, + ); + expect(() => createKoine({ ...testConfig, authKey: "" })).toThrow( + KoineError, + ); + expect(() => createKoine({ ...testConfig, timeout: -1 })).toThrow( + KoineError, + ); }); - describe("createKoine", () => { - it("should create a client with generateText method", async () => { - const mockResponse = createMockResponse({ - text: "Hello from factory!", - usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, - sessionId: "factory-session", - }); - - global.fetch = vi.fn().mockResolvedValue(mockResponse); - - const koine = createKoine(testConfig); - const result = await koine.generateText({ prompt: "test" }); - - expect(result.text).toBe("Hello from factory!"); - expect(result.sessionId).toBe("factory-session"); + it("should not validate config again on method calls", async () => { + const mockResponse = createMockResponse({ + text: "test", + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + sessionId: "s", }); - it("should create a client with streamText method", async () => { - const events = [ - { event: "session", data: { sessionId: "stream-session" } }, - { event: "text", data: { text: "Streamed!" } }, - { - event: "result", - data: { - sessionId: "stream-session", - usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, - }, - }, - { event: "done", data: { code: 0 } }, - ]; - - global.fetch = vi.fn().mockResolvedValue(createMockSSEResponse(events)); - - const koine = createKoine(testConfig); - const result = await koine.streamText({ prompt: "test" }); + global.fetch = vi.fn().mockResolvedValue(mockResponse); - // Consume the stream first - const reader = result.textStream.getReader(); - while (true) { - const { done } = await reader.read(); - if (done) break; - } + const koine = createKoine(testConfig); - const text = await result.text; - expect(text).toBe("Streamed!"); - }); - - it("should create a client with generateObject method", async () => { - const schema = z.object({ name: z.string() }); - const mockResponse = createMockResponse({ - object: { name: "Factory Test" }, - rawText: '{"name":"Factory Test"}', - usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, - sessionId: "object-session", - }); - - global.fetch = vi.fn().mockResolvedValue(mockResponse); - - const koine = createKoine(testConfig); - const result = await koine.generateObject({ prompt: "test", schema }); - - expect(result.object.name).toBe("Factory Test"); - }); - - it("should validate config at creation time", () => { - expect(() => createKoine({ ...testConfig, baseUrl: "" })).toThrow( - KoineError, - ); - expect(() => createKoine({ ...testConfig, authKey: "" })).toThrow( - KoineError, - ); - expect(() => createKoine({ ...testConfig, timeout: -1 })).toThrow( - KoineError, - ); - }); - - it("should not validate config again on method calls", async () => { - const mockResponse = createMockResponse({ - text: "test", - usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, - sessionId: "s", - }); - - global.fetch = vi.fn().mockResolvedValue(mockResponse); - - const koine = createKoine(testConfig); - - // Config is validated at creation, not on each call - // So even if we could mutate config (we can't due to closure), - // the validation already passed - await expect( - koine.generateText({ prompt: "test" }), - ).resolves.toBeDefined(); - }); + // Config is validated at creation, not on each call + // So even if we could mutate config (we can't due to closure), + // the validation already passed + await expect(koine.generateText({ prompt: "test" })).resolves.toBeDefined(); }); }); diff --git a/packages/sdks/typescript/__tests__/helpers.ts b/packages/sdks/typescript/__tests__/helpers.ts new file mode 100644 index 0000000..98a3cc6 --- /dev/null +++ b/packages/sdks/typescript/__tests__/helpers.ts @@ -0,0 +1,98 @@ +/** + * Shared test utilities for Koine SDK tests. + */ + +import { vi } from "vitest"; +import type { KoineConfig } from "../src/types.js"; + +// Store original fetch to restore later +export const originalFetch = global.fetch; + +// Default test config +export const testConfig: KoineConfig = { + baseUrl: "http://localhost:3100", + timeout: 30_000, + authKey: "test-auth-key-12345", +}; + +// Helper to create mock Response objects +export function createMockResponse( + body: unknown, + options: { status?: number; statusText?: string; ok?: boolean } = {}, +): Response { + const { status = 200, statusText = "OK", ok = true } = options; + const bodyText = typeof body === "string" ? body : JSON.stringify(body); + + return { + ok, + status, + statusText, + text: vi.fn().mockResolvedValue(bodyText), + json: vi.fn().mockResolvedValue(body), + headers: new Headers(), + redirected: false, + type: "basic", + url: "", + clone: vi.fn(), + body: null, + bodyUsed: false, + arrayBuffer: vi.fn(), + blob: vi.fn(), + formData: vi.fn(), + bytes: vi.fn(), + } as unknown as Response; +} + +/** + * Creates a mock SSE ReadableStream that emits events in SSE format. + * Used to simulate the gateway's /stream endpoint response. + */ +export function createSSEStream( + events: Array<{ event: string; data: unknown }>, +): ReadableStream { + const encoder = new TextEncoder(); + let index = 0; + + return new ReadableStream({ + pull(controller) { + if (index < events.length) { + const { event, data } = events[index]; + const sseData = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + controller.enqueue(encoder.encode(sseData)); + index++; + } else { + controller.close(); + } + }, + }); +} + +/** + * Creates a mock Response with an SSE stream body. + */ +export function createMockSSEResponse( + events: Array<{ event: string; data: unknown }>, + options: { status?: number; ok?: boolean } = {}, +): Response { + const { status = 200, ok = true } = options; + const body = createSSEStream(events); + + return { + ok, + status, + statusText: ok ? "OK" : "Error", + headers: new Headers({ "Content-Type": "text/event-stream" }), + body, + text: vi.fn(), + json: vi.fn(), + redirected: false, + type: "basic", + url: "", + clone: vi.fn(), + bodyUsed: false, + arrayBuffer: vi.fn(), + blob: vi.fn(), + formData: vi.fn(), + bytes: vi.fn(), + } as unknown as Response; +} diff --git a/packages/sdks/typescript/__tests__/object.test.ts b/packages/sdks/typescript/__tests__/object.test.ts new file mode 100644 index 0000000..e985763 --- /dev/null +++ b/packages/sdks/typescript/__tests__/object.test.ts @@ -0,0 +1,222 @@ +/** + * Tests for generateObject function. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { generateObject } from "../src/client.js"; +import { KoineError } from "../src/errors.js"; +import { createMockResponse, originalFetch, testConfig } from "./helpers.js"; + +const testSchema = z.object({ + name: z.string(), + age: z.number(), + active: z.boolean().optional(), +}); + +describe("generateObject", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("should convert Zod schema to JSON Schema in request", async () => { + const mockResponse = createMockResponse({ + object: { name: "John", age: 30 }, + rawText: '{"name": "John", "age": 30}', + usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, + sessionId: "session-123", + }); + + const mockFetch = vi.fn().mockResolvedValue(mockResponse); + global.fetch = mockFetch; + + await generateObject(testConfig, { + prompt: "Generate a person", + schema: testSchema, + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.schema).toBeDefined(); + expect(body.schema.type).toBe("object"); + expect(body.schema.properties).toHaveProperty("name"); + expect(body.schema.properties).toHaveProperty("age"); + expect(body.schema.properties).toHaveProperty("active"); + expect(body.schema.required).toContain("name"); + expect(body.schema.required).toContain("age"); + }); + + it("should return validated object on success", async () => { + const mockResponse = createMockResponse({ + object: { name: "Alice", age: 25, active: true }, + rawText: '{"name": "Alice", "age": 25, "active": true}', + usage: { inputTokens: 50, outputTokens: 20, totalTokens: 70 }, + sessionId: "obj-session", + }); + + global.fetch = vi.fn().mockResolvedValue(mockResponse); + + const result = await generateObject(testConfig, { + prompt: "Generate person", + schema: testSchema, + }); + + expect(result.object).toEqual({ name: "Alice", age: 25, active: true }); + expect(result.rawText).toBe('{"name": "Alice", "age": 25, "active": true}'); + expect(result.usage.totalTokens).toBe(70); + expect(result.sessionId).toBe("obj-session"); + }); + + it("should throw VALIDATION_ERROR when response fails Zod validation", async () => { + const mockResponse = createMockResponse({ + object: { name: "Bob", age: "not-a-number" }, // age should be number + rawText: '{"name": "Bob", "age": "not-a-number"}', + usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, + sessionId: "session", + }); + + global.fetch = vi.fn().mockResolvedValue(mockResponse); + + await expect( + generateObject(testConfig, { + prompt: "test", + schema: testSchema, + }), + ).rejects.toMatchObject({ + code: "VALIDATION_ERROR", + }); + }); + + it("should include rawText in validation error for debugging", async () => { + const mockResponse = createMockResponse({ + object: { invalid: "data" }, + rawText: '{"invalid": "data"}', + usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, + sessionId: "session", + }); + + global.fetch = vi.fn().mockResolvedValue(mockResponse); + + try { + await generateObject(testConfig, { + prompt: "test", + schema: testSchema, + }); + expect.fail("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(KoineError); + expect((error as KoineError).rawText).toBe('{"invalid": "data"}'); + } + }); + + it("should throw KoineError on HTTP error", async () => { + const errorResponse = createMockResponse( + { error: "Schema parse error", code: "SCHEMA_ERROR", rawText: "..." }, + { status: 422, statusText: "Unprocessable Entity", ok: false }, + ); + + global.fetch = vi.fn().mockResolvedValue(errorResponse); + + await expect( + generateObject(testConfig, { + prompt: "test", + schema: testSchema, + }), + ).rejects.toMatchObject({ + message: "Schema parse error", + code: "SCHEMA_ERROR", + }); + }); + + it("should handle complex nested schemas", async () => { + const complexSchema = z.object({ + user: z.object({ + name: z.string(), + emails: z.array(z.string().email()), + }), + settings: z.object({ + notifications: z.boolean(), + theme: z.enum(["light", "dark"]), + }), + }); + + const responseData = { + user: { name: "Test", emails: ["test@example.com"] }, + settings: { notifications: true, theme: "dark" }, + }; + + const mockResponse = createMockResponse({ + object: responseData, + rawText: JSON.stringify(responseData), + usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 }, + sessionId: "complex-session", + }); + + global.fetch = vi.fn().mockResolvedValue(mockResponse); + + const result = await generateObject(testConfig, { + prompt: "Generate complex object", + schema: complexSchema, + }); + + expect(result.object).toEqual(responseData); + }); + + it("should send Authorization header", async () => { + const mockResponse = createMockResponse({ + object: { name: "Test", age: 20 }, + rawText: "{}", + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + sessionId: "s", + }); + + const mockFetch = vi.fn().mockResolvedValue(mockResponse); + global.fetch = mockFetch; + + await generateObject(testConfig, { + prompt: "test", + schema: testSchema, + }); + + const headers = mockFetch.mock.calls[0][1].headers; + expect(headers.Authorization).toBe("Bearer test-auth-key-12345"); + }); + + it("should call correct endpoint", async () => { + const mockResponse = createMockResponse({ + object: { name: "Test", age: 20 }, + rawText: "{}", + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + sessionId: "s", + }); + + const mockFetch = vi.fn().mockResolvedValue(mockResponse); + global.fetch = mockFetch; + + await generateObject(testConfig, { + prompt: "test", + schema: testSchema, + }); + + const url = mockFetch.mock.calls[0][0]; + expect(url).toBe("http://localhost:3100/generate-object"); + }); + + it("should throw INVALID_RESPONSE when response is not valid JSON", async () => { + const invalidResponse = createMockResponse("not json"); + global.fetch = vi.fn().mockResolvedValue(invalidResponse); + + await expect( + generateObject(testConfig, { + prompt: "test", + schema: testSchema, + }), + ).rejects.toMatchObject({ + message: "Invalid response from Koine gateway: expected JSON", + code: "INVALID_RESPONSE", + }); + }); +}); diff --git a/packages/sdks/typescript/__tests__/stream.test.ts b/packages/sdks/typescript/__tests__/stream.test.ts new file mode 100644 index 0000000..8009447 --- /dev/null +++ b/packages/sdks/typescript/__tests__/stream.test.ts @@ -0,0 +1,395 @@ +/** + * Tests for streamText function. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { streamText } from "../src/client.js"; +import { + createMockResponse, + createMockSSEResponse, + originalFetch, + testConfig, +} from "./helpers.js"; + +describe("streamText", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("should make POST request to /stream endpoint", async () => { + const events = [ + { event: "session", data: { sessionId: "stream-session-123" } }, + { event: "text", data: { text: "Hello" } }, + { + event: "result", + data: { + sessionId: "stream-session-123", + usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, + }, + }, + { event: "done", data: { code: 0 } }, + ]; + + const mockFetch = vi.fn().mockResolvedValue(createMockSSEResponse(events)); + global.fetch = mockFetch; + + await streamText(testConfig, { + system: "You are helpful", + prompt: "Say hello", + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetch.mock.calls[0]; + + expect(url).toBe("http://localhost:3100/stream"); + expect(options.method).toBe("POST"); + expect(options.headers["Content-Type"]).toBe("application/json"); + expect(options.headers.Authorization).toBe("Bearer test-auth-key-12345"); + + const body = JSON.parse(options.body); + expect(body.system).toBe("You are helpful"); + expect(body.prompt).toBe("Say hello"); + }); + + it("should return textStream that yields text chunks", async () => { + const events = [ + { event: "session", data: { sessionId: "session-abc" } }, + { event: "text", data: { text: "Hello" } }, + { event: "text", data: { text: " world" } }, + { event: "text", data: { text: "!" } }, + { + event: "result", + data: { + sessionId: "session-abc", + usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, + }, + }, + { event: "done", data: { code: 0 } }, + ]; + + global.fetch = vi.fn().mockResolvedValue(createMockSSEResponse(events)); + + const result = await streamText(testConfig, { + prompt: "Test prompt", + }); + + // Collect all chunks from the stream + const chunks: string[] = []; + const reader = result.textStream.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + + expect(chunks).toEqual(["Hello", " world", "!"]); + }); + + it("should resolve text promise with accumulated text", async () => { + const events = [ + { event: "session", data: { sessionId: "session-xyz" } }, + { event: "text", data: { text: "First " } }, + { event: "text", data: { text: "Second " } }, + { event: "text", data: { text: "Third" } }, + { + event: "result", + data: { + sessionId: "session-xyz", + usage: { inputTokens: 20, outputTokens: 10, totalTokens: 30 }, + }, + }, + { event: "done", data: { code: 0 } }, + ]; + + global.fetch = vi.fn().mockResolvedValue(createMockSSEResponse(events)); + + const result = await streamText(testConfig, { + prompt: "Test", + }); + + // Consume the stream to trigger text accumulation + const reader = result.textStream.getReader(); + while (true) { + const { done } = await reader.read(); + if (done) break; + } + + const text = await result.text; + expect(text).toBe("First Second Third"); + }); + + it("should resolve usage promise with token counts", async () => { + const events = [ + { event: "session", data: { sessionId: "session-usage" } }, + { event: "text", data: { text: "Response" } }, + { + event: "result", + data: { + sessionId: "session-usage", + usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 }, + }, + }, + { event: "done", data: { code: 0 } }, + ]; + + global.fetch = vi.fn().mockResolvedValue(createMockSSEResponse(events)); + + const result = await streamText(testConfig, { + prompt: "Test", + }); + + // Consume stream + const reader = result.textStream.getReader(); + while (true) { + const { done } = await reader.read(); + if (done) break; + } + + const usage = await result.usage; + expect(usage).toEqual({ + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + }); + }); + + it("should resolve sessionId promise from session event", async () => { + const events = [ + { event: "session", data: { sessionId: "early-session-id" } }, + { event: "text", data: { text: "Content" } }, + { + event: "result", + data: { + sessionId: "early-session-id", + usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 }, + }, + }, + { event: "done", data: { code: 0 } }, + ]; + + global.fetch = vi.fn().mockResolvedValue(createMockSSEResponse(events)); + + const result = await streamText(testConfig, { + prompt: "Test", + }); + + // Consume stream to process SSE events + const reader = result.textStream.getReader(); + while (true) { + const { done } = await reader.read(); + if (done) break; + } + + const sessionId = await result.sessionId; + expect(sessionId).toBe("early-session-id"); + }); + + it("should pass sessionId when provided for continuation", async () => { + const events = [ + { event: "session", data: { sessionId: "continued-session" } }, + { event: "text", data: { text: "Continued" } }, + { + event: "result", + data: { + sessionId: "continued-session", + usage: { inputTokens: 15, outputTokens: 8, totalTokens: 23 }, + }, + }, + { event: "done", data: { code: 0 } }, + ]; + + const mockFetch = vi.fn().mockResolvedValue(createMockSSEResponse(events)); + global.fetch = mockFetch; + + await streamText(testConfig, { + prompt: "Continue the conversation", + sessionId: "existing-session-123", + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.sessionId).toBe("existing-session-123"); + }); + + it("should throw KoineError on HTTP error", async () => { + const errorResponse = createMockResponse( + { error: "Rate limit exceeded", code: "RATE_LIMITED" }, + { status: 429, statusText: "Too Many Requests", ok: false }, + ); + + global.fetch = vi.fn().mockResolvedValue(errorResponse); + + await expect( + streamText(testConfig, { prompt: "test" }), + ).rejects.toMatchObject({ + message: "Rate limit exceeded", + code: "RATE_LIMITED", + }); + }); + + it("should throw KoineError when response body is null", async () => { + const noBodyResponse = { + ok: true, + status: 200, + statusText: "OK", + headers: new Headers(), + body: null, + text: vi.fn().mockResolvedValue(""), + json: vi.fn(), + redirected: false, + type: "basic", + url: "", + clone: vi.fn(), + bodyUsed: false, + arrayBuffer: vi.fn(), + blob: vi.fn(), + formData: vi.fn(), + bytes: vi.fn(), + } as unknown as Response; + + global.fetch = vi.fn().mockResolvedValue(noBodyResponse); + + await expect( + streamText(testConfig, { prompt: "test" }), + ).rejects.toMatchObject({ + message: "No response body from Koine gateway", + code: "NO_RESPONSE_BODY", + }); + }); + + it("should handle error SSE event and reject promises", async () => { + const events = [ + { event: "session", data: { sessionId: "error-session" } }, + { event: "text", data: { text: "Partial" } }, + { + event: "error", + data: { error: "Context window exceeded", code: "CONTEXT_OVERFLOW" }, + }, + ]; + + global.fetch = vi.fn().mockResolvedValue(createMockSSEResponse(events)); + + const result = await streamText(testConfig, { + prompt: "Very long prompt...", + }); + + // Consume stream - should encounter error + const reader = result.textStream.getReader(); + + await expect(async () => { + while (true) { + const { done } = await reader.read(); + if (done) break; + } + }).rejects.toMatchObject({ + message: "Context window exceeded", + code: "CONTEXT_OVERFLOW", + }); + + // Also verify that the usage and text promises reject + await expect(result.usage).rejects.toMatchObject({ + message: "Context window exceeded", + code: "CONTEXT_OVERFLOW", + }); + await expect(result.text).rejects.toMatchObject({ + message: "Context window exceeded", + code: "CONTEXT_OVERFLOW", + }); + }); + + it("should handle network errors", async () => { + global.fetch = vi.fn().mockRejectedValue(new Error("Connection refused")); + + await expect(streamText(testConfig, { prompt: "test" })).rejects.toThrow( + "Connection refused", + ); + }); + + it("should include timeout signal in fetch call", async () => { + const events = [ + { event: "session", data: { sessionId: "s" } }, + { event: "text", data: { text: "test" } }, + { + event: "result", + data: { + sessionId: "s", + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + }, + }, + { event: "done", data: { code: 0 } }, + ]; + + const mockFetch = vi.fn().mockResolvedValue(createMockSSEResponse(events)); + global.fetch = mockFetch; + + await streamText(testConfig, { prompt: "test" }); + + const [, options] = mockFetch.mock.calls[0]; + expect(options.signal).toBeDefined(); + expect(options.signal).toBeInstanceOf(AbortSignal); + }); + + it("should pass model in request body", async () => { + const events = [ + { event: "session", data: { sessionId: "model-session" } }, + { event: "text", data: { text: "Output" } }, + { + event: "result", + data: { + sessionId: "model-session", + usage: { inputTokens: 5, outputTokens: 3, totalTokens: 8 }, + }, + }, + { event: "done", data: { code: 0 } }, + ]; + + const mockFetch = vi.fn().mockResolvedValue(createMockSSEResponse(events)); + global.fetch = mockFetch; + + const configWithModel = { ...testConfig, model: "haiku" }; + await streamText(configWithModel, { prompt: "test" }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.model).toBe("haiku"); + }); + + it("should handle empty text events gracefully", async () => { + const events = [ + { event: "session", data: { sessionId: "empty-session" } }, + { event: "text", data: { text: "" } }, + { event: "text", data: { text: "Content" } }, + { event: "text", data: { text: "" } }, + { + event: "result", + data: { + sessionId: "empty-session", + usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, + }, + }, + { event: "done", data: { code: 0 } }, + ]; + + global.fetch = vi.fn().mockResolvedValue(createMockSSEResponse(events)); + + const result = await streamText(testConfig, { + prompt: "Test", + }); + + const chunks: string[] = []; + const reader = result.textStream.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + + // Empty strings are valid text events and should be emitted + expect(chunks).toEqual(["", "Content", ""]); + + const text = await result.text; + expect(text).toBe("Content"); + }); +}); diff --git a/packages/sdks/typescript/__tests__/text.test.ts b/packages/sdks/typescript/__tests__/text.test.ts new file mode 100644 index 0000000..2aba996 --- /dev/null +++ b/packages/sdks/typescript/__tests__/text.test.ts @@ -0,0 +1,221 @@ +/** + * Tests for generateText function. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { generateText } from "../src/client.js"; +import { createMockResponse, originalFetch, testConfig } from "./helpers.js"; + +describe("generateText", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("should make POST request with correct headers and body", async () => { + const mockResponse = createMockResponse({ + text: "Hello, world!", + usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, + sessionId: "session-123", + }); + + const mockFetch = vi.fn().mockResolvedValue(mockResponse); + global.fetch = mockFetch; + + await generateText(testConfig, { + system: "You are helpful", + prompt: "Say hello", + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetch.mock.calls[0]; + + expect(url).toBe("http://localhost:3100/generate-text"); + expect(options.method).toBe("POST"); + expect(options.headers["Content-Type"]).toBe("application/json"); + expect(options.headers.Authorization).toBe("Bearer test-auth-key-12345"); + + const body = JSON.parse(options.body); + expect(body.system).toBe("You are helpful"); + expect(body.prompt).toBe("Say hello"); + }); + + it("should return text, usage, and sessionId on success", async () => { + const mockResponse = createMockResponse({ + text: "Generated response", + usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 }, + sessionId: "sess-abc", + }); + + global.fetch = vi.fn().mockResolvedValue(mockResponse); + + const result = await generateText(testConfig, { + prompt: "Test prompt", + }); + + expect(result.text).toBe("Generated response"); + expect(result.usage).toEqual({ + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + }); + expect(result.sessionId).toBe("sess-abc"); + }); + + it("should pass sessionId when provided", async () => { + const mockResponse = createMockResponse({ + text: "Continued response", + usage: { inputTokens: 20, outputTokens: 10, totalTokens: 30 }, + sessionId: "existing-session", + }); + + const mockFetch = vi.fn().mockResolvedValue(mockResponse); + global.fetch = mockFetch; + + await generateText(testConfig, { + prompt: "Continue", + sessionId: "existing-session", + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.sessionId).toBe("existing-session"); + }); + + it("should throw KoineError on HTTP 4xx error with error body", async () => { + const errorResponse = createMockResponse( + { + error: "Invalid request parameters", + code: "INVALID_PARAMS", + }, + { status: 400, statusText: "Bad Request", ok: false }, + ); + + global.fetch = vi.fn().mockResolvedValue(errorResponse); + + await expect( + generateText(testConfig, { prompt: "test" }), + ).rejects.toMatchObject({ + name: "KoineError", + message: "Invalid request parameters", + code: "INVALID_PARAMS", + }); + }); + + it("should throw KoineError on 401 unauthorized", async () => { + const errorResponse = createMockResponse( + { error: "Invalid authentication key", code: "UNAUTHORIZED" }, + { status: 401, statusText: "Unauthorized", ok: false }, + ); + + global.fetch = vi.fn().mockResolvedValue(errorResponse); + + await expect( + generateText(testConfig, { prompt: "test" }), + ).rejects.toMatchObject({ + message: "Invalid authentication key", + code: "UNAUTHORIZED", + }); + }); + + it("should throw KoineError on HTTP 5xx error", async () => { + const errorResponse = createMockResponse( + { error: "Internal server error", code: "SERVER_ERROR" }, + { status: 500, statusText: "Internal Server Error", ok: false }, + ); + + global.fetch = vi.fn().mockResolvedValue(errorResponse); + + await expect( + generateText(testConfig, { prompt: "test" }), + ).rejects.toMatchObject({ + message: "Internal server error", + code: "SERVER_ERROR", + }); + }); + + it("should handle non-JSON error response gracefully", async () => { + const errorResponse = createMockResponse("Bad Gateway", { + status: 502, + statusText: "Bad Gateway", + ok: false, + }); + + global.fetch = vi.fn().mockResolvedValue(errorResponse); + + await expect( + generateText(testConfig, { prompt: "test" }), + ).rejects.toMatchObject({ + message: "HTTP 502 Bad Gateway", + code: "HTTP_ERROR", + }); + }); + + it("should throw KoineError when response is not valid JSON", async () => { + const invalidResponse = createMockResponse("not valid json at all"); + global.fetch = vi.fn().mockResolvedValue(invalidResponse); + + await expect( + generateText(testConfig, { prompt: "test" }), + ).rejects.toMatchObject({ + message: "Invalid response from Koine gateway: expected JSON", + code: "INVALID_RESPONSE", + }); + }); + + it("should include timeout signal in fetch call", async () => { + const mockResponse = createMockResponse({ + text: "response", + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + sessionId: "s", + }); + + const mockFetch = vi.fn().mockResolvedValue(mockResponse); + global.fetch = mockFetch; + + await generateText(testConfig, { prompt: "test" }); + + const [, options] = mockFetch.mock.calls[0]; + expect(options.signal).toBeDefined(); + expect(options.signal).toBeInstanceOf(AbortSignal); + }); + + it("should handle network errors", async () => { + global.fetch = vi.fn().mockRejectedValue(new Error("Network failure")); + + await expect(generateText(testConfig, { prompt: "test" })).rejects.toThrow( + "Network failure", + ); + }); + + it("should throw abort error when request times out", async () => { + const abortError = new DOMException( + "The operation was aborted.", + "AbortError", + ); + global.fetch = vi.fn().mockRejectedValue(abortError); + + await expect(generateText(testConfig, { prompt: "test" })).rejects.toThrow( + "Request aborted", + ); + }); + + it("should handle empty text response", async () => { + const mockResponse = createMockResponse({ + text: "", + usage: { inputTokens: 10, outputTokens: 0, totalTokens: 10 }, + sessionId: "empty-session", + }); + + global.fetch = vi.fn().mockResolvedValue(mockResponse); + + const result = await generateText(testConfig, { + prompt: "test", + }); + + expect(result.text).toBe(""); + expect(result.usage.outputTokens).toBe(0); + }); +});