@bhooai/nexus-ai-client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/package.json +18 -0
- package/src/client.ts +159 -0
- package/src/errors.ts +21 -0
- package/src/index.ts +4 -0
- package/src/sse.ts +53 -0
- package/src/types.ts +174 -0
- package/tests/ai-client.test.ts +172 -0
- package/tsconfig.json +8 -0
- package/vitest.config.ts +10 -0
package/README.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# @bhooai/nexus-ai-client
|
|
2
|
+
|
|
3
|
+
Node client for the Python AI server: streaming (SSE), retries, and timeouts.
|
|
4
|
+
The browser never calls Python directly — the Node backend proxies/re-emits SSE.
|
|
5
|
+
|
|
6
|
+
## Exports
|
|
7
|
+
|
|
8
|
+
- **AiClient** — `chat`, `chatStream` (async generator over an SSE stream),
|
|
9
|
+
`embeddings`, `models`. Retries with backoff, configurable timeout.
|
|
10
|
+
- **parseSseStream** — the SSE parser (buffer → split on `\n\n` → collect `data:`
|
|
11
|
+
lines → JSON.parse → stop at `[DONE]`).
|
|
12
|
+
- types + errors.
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { AiClient } from '@bhooai/nexus-ai-client';
|
|
18
|
+
const ai = new AiClient({ serverUrl: 'http://localhost:8000', timeoutMs: 60_000 });
|
|
19
|
+
for await (const chunk of ai.chatStream({ messages: [{ role: 'user', content: 'hi' }] })) {
|
|
20
|
+
process.stdout.write(chunk.delta ?? '');
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The Node→Python contract is defined in `contracts/` (single source of truth).
|
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bhooai/nexus-ai-client",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"publishConfig": { "access": "public" },
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsc -p tsconfig.json",
|
|
10
|
+
"test": "vitest run"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"@types/node": "^22.5.0",
|
|
15
|
+
"typescript": "^5.6.2",
|
|
16
|
+
"vitest": "^2.1.1"
|
|
17
|
+
}
|
|
18
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AiClientOptions,
|
|
3
|
+
ChatCompletionRequest,
|
|
4
|
+
ChatCompletionResponse,
|
|
5
|
+
ChatCompletionChunk,
|
|
6
|
+
EmbeddingRequest,
|
|
7
|
+
EmbeddingResponse,
|
|
8
|
+
ModelsResponse,
|
|
9
|
+
ImageGenRequest,
|
|
10
|
+
ImageGenResponse,
|
|
11
|
+
ImageGenModelsResponse,
|
|
12
|
+
AgentImageRequest,
|
|
13
|
+
AgentImageEvent,
|
|
14
|
+
} from './types.js';
|
|
15
|
+
import { AiError, isRetryable } from './errors.js';
|
|
16
|
+
import { parseSseStream } from './sse.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Node client for the BhooAI Nexus AI server (Python FastAPI). OpenAI-compatible.
|
|
20
|
+
* Supports non-streaming + streaming chat completions, embeddings, and model
|
|
21
|
+
* listing, with timeout (AbortController) and exponential-backoff retries on
|
|
22
|
+
* transient failures (network errors, 5xx, 429).
|
|
23
|
+
*/
|
|
24
|
+
export class AiClient {
|
|
25
|
+
private readonly serverUrl: string;
|
|
26
|
+
private readonly timeoutMs: number;
|
|
27
|
+
private readonly maxRetries: number;
|
|
28
|
+
private readonly retryDelayMs: number;
|
|
29
|
+
private readonly authToken?: string;
|
|
30
|
+
|
|
31
|
+
constructor(opts: AiClientOptions) {
|
|
32
|
+
this.serverUrl = opts.serverUrl.replace(/\/+$/, '');
|
|
33
|
+
this.timeoutMs = opts.timeoutMs ?? 60_000;
|
|
34
|
+
this.maxRetries = opts.maxRetries ?? 2;
|
|
35
|
+
this.retryDelayMs = opts.retryDelayMs ?? 500;
|
|
36
|
+
this.authToken = opts.authToken;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private headers(extra: Record<string, string> = {}): Record<string, string> {
|
|
40
|
+
const h: Record<string, string> = { 'content-type': 'application/json', accept: 'application/json', ...extra };
|
|
41
|
+
if (this.authToken) h.authorization = `Bearer ${this.authToken}`;
|
|
42
|
+
return h;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private async request<T>(path: string, init: RequestInit, { stream = false } = {}): Promise<T> {
|
|
46
|
+
let lastErr: unknown;
|
|
47
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
48
|
+
const controller = new AbortController();
|
|
49
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
50
|
+
try {
|
|
51
|
+
const res = await fetch(`${this.serverUrl}${path}`, {
|
|
52
|
+
...init,
|
|
53
|
+
signal: controller.signal,
|
|
54
|
+
headers: this.headers(init.headers as Record<string, string> | undefined),
|
|
55
|
+
});
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
if (!res.ok) {
|
|
58
|
+
let message = `AI server ${res.status}`;
|
|
59
|
+
let code: string | undefined;
|
|
60
|
+
try {
|
|
61
|
+
const body = await res.json() as { error?: { message?: string; code?: string } };
|
|
62
|
+
message = body?.error?.message ?? message;
|
|
63
|
+
code = body?.error?.code;
|
|
64
|
+
} catch { /* non-JSON error body */ }
|
|
65
|
+
const err = new AiError(message, { status: res.status, code });
|
|
66
|
+
if (isRetryable(err) && attempt < this.maxRetries) {
|
|
67
|
+
lastErr = err;
|
|
68
|
+
await this.backoff(attempt);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
return (stream ? res : await res.json() as T) as T;
|
|
74
|
+
} catch (e) {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
// Abort → timeout; network errors are retryable.
|
|
77
|
+
const isAbort = e instanceof Error && e.name === 'AbortError';
|
|
78
|
+
const err = e instanceof AiError ? e : new AiError(isAbort ? 'AI request timed out' : String((e as Error)?.message ?? e));
|
|
79
|
+
if (isRetryable(err) && attempt < this.maxRetries) {
|
|
80
|
+
lastErr = err;
|
|
81
|
+
await this.backoff(attempt);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
throw lastErr instanceof Error ? lastErr : new AiError('AI request failed');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private async backoff(attempt: number): Promise<void> {
|
|
91
|
+
const delay = this.retryDelayMs * Math.pow(2, attempt);
|
|
92
|
+
// Tiny jitter without Math.random (deterministic): fold attempt into the delay.
|
|
93
|
+
await new Promise((r) => setTimeout(r, delay + attempt));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Non-streaming chat completion. */
|
|
97
|
+
async chat(req: ChatCompletionRequest): Promise<ChatCompletionResponse> {
|
|
98
|
+
const body = { ...req, stream: false };
|
|
99
|
+
return this.request<ChatCompletionResponse>('/chat/completions', {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
body: JSON.stringify(body),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Streaming chat completion — yields chunks until the stream ends. */
|
|
106
|
+
async *chatStream(req: ChatCompletionRequest): AsyncGenerator<ChatCompletionChunk, void, unknown> {
|
|
107
|
+
const body = { ...req, stream: true };
|
|
108
|
+
const res = await this.request<Response>('/chat/completions', {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
body: JSON.stringify(body),
|
|
111
|
+
headers: { accept: 'text/event-stream' },
|
|
112
|
+
}, { stream: true });
|
|
113
|
+
if (!res.body) throw new AiError('streaming response has no body');
|
|
114
|
+
for await (const chunk of parseSseStream(res.body as unknown as ReadableStream<Uint8Array>)) {
|
|
115
|
+
yield chunk as ChatCompletionChunk;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Create embeddings for one or more inputs. */
|
|
120
|
+
async embeddings(req: EmbeddingRequest): Promise<EmbeddingResponse> {
|
|
121
|
+
return this.request<EmbeddingResponse>('/embeddings', {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
body: JSON.stringify(req),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** List available models (optionally for a specific provider). */
|
|
128
|
+
async listModels(provider?: string): Promise<ModelsResponse> {
|
|
129
|
+
const qs = provider ? `?provider=${encodeURIComponent(provider)}` : '';
|
|
130
|
+
return this.request<ModelsResponse>(`/models${qs}`, { method: 'GET' });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Generate images via the local Stable Diffusion HTTP server. */
|
|
134
|
+
async images(req: ImageGenRequest): Promise<ImageGenResponse> {
|
|
135
|
+
return this.request<ImageGenResponse>('/images/generations', {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
body: JSON.stringify(req),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** List available image-generation models (SD checkpoints). */
|
|
142
|
+
async listImageModels(): Promise<ImageGenModelsResponse> {
|
|
143
|
+
return this.request<ImageGenModelsResponse>('/images/models', { method: 'GET' });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Agentic image loop — streams reasoning/tool/image/done events.
|
|
147
|
+
* Drives the Ollama LLM (reasoning) + local SD server (pixels). */
|
|
148
|
+
async *agentImageStream(req: AgentImageRequest): AsyncGenerator<AgentImageEvent, void, unknown> {
|
|
149
|
+
const res = await this.request<Response>('/agent/image', {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
body: JSON.stringify(req),
|
|
152
|
+
headers: { accept: 'text/event-stream' },
|
|
153
|
+
}, { stream: true });
|
|
154
|
+
if (!res.body) throw new AiError('agent stream response has no body');
|
|
155
|
+
for await (const chunk of parseSseStream(res.body as unknown as ReadableStream<Uint8Array>)) {
|
|
156
|
+
yield chunk as AgentImageEvent;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** AI client errors. */
|
|
2
|
+
|
|
3
|
+
export class AiError extends Error {
|
|
4
|
+
readonly status?: number;
|
|
5
|
+
readonly code?: string;
|
|
6
|
+
constructor(message: string, opts: { status?: number; code?: string } = {}) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'AiError';
|
|
9
|
+
this.status = opts.status;
|
|
10
|
+
this.code = opts.code;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** True for transient errors worth retrying: network failures + 5xx + 429. */
|
|
15
|
+
export function isRetryable(err: unknown): boolean {
|
|
16
|
+
if (err instanceof AiError) {
|
|
17
|
+
if (err.status === undefined) return true; // network-level failure
|
|
18
|
+
return err.status >= 500 || err.status === 429;
|
|
19
|
+
}
|
|
20
|
+
return true;
|
|
21
|
+
}
|
package/src/index.ts
ADDED
package/src/sse.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal SSE parser for the streaming chat endpoint. Reads a web
|
|
3
|
+
* ReadableStream<Uint8Array>, buffers bytes, splits events on blank lines, and
|
|
4
|
+
* yields the parsed `data:` JSON payload of each event. Stops at `[DONE]`.
|
|
5
|
+
*/
|
|
6
|
+
export async function* parseSseStream(stream: ReadableStream<Uint8Array>): AsyncGenerator<any, void, unknown> {
|
|
7
|
+
const reader = stream.getReader();
|
|
8
|
+
const decoder = new TextDecoder();
|
|
9
|
+
let buffer = '';
|
|
10
|
+
try {
|
|
11
|
+
while (true) {
|
|
12
|
+
const { done, value } = await reader.read();
|
|
13
|
+
if (done) break;
|
|
14
|
+
buffer += decoder.decode(value, { stream: true });
|
|
15
|
+
// SSE events are separated by a blank line (\n\n). Process complete ones.
|
|
16
|
+
let idx: number;
|
|
17
|
+
while ((idx = buffer.indexOf('\n\n')) !== -1) {
|
|
18
|
+
const rawEvent = buffer.slice(0, idx);
|
|
19
|
+
buffer = buffer.slice(idx + 2);
|
|
20
|
+
const payload = extractData(rawEvent);
|
|
21
|
+
if (payload === undefined) continue;
|
|
22
|
+
if (payload === '[DONE]') return;
|
|
23
|
+
try {
|
|
24
|
+
yield JSON.parse(payload);
|
|
25
|
+
} catch {
|
|
26
|
+
/* skip malformed */
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// Flush any trailing event without a blank-line terminator.
|
|
31
|
+
if (buffer.trim()) {
|
|
32
|
+
const payload = extractData(buffer);
|
|
33
|
+
if (payload && payload !== '[DONE]') {
|
|
34
|
+
try { yield JSON.parse(payload); } catch { /* skip */ }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
} finally {
|
|
38
|
+
reader.releaseLock();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function extractData(event: string): string | undefined {
|
|
43
|
+
// An event may have multiple lines; collect `data:` fields (concatenated).
|
|
44
|
+
const parts: string[] = [];
|
|
45
|
+
for (const line of event.split('\n')) {
|
|
46
|
+
const trimmed = line.trim();
|
|
47
|
+
if (trimmed.startsWith('data:')) {
|
|
48
|
+
parts.push(trimmed.slice(5).trimStart());
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (parts.length === 0) return undefined;
|
|
52
|
+
return parts.join('\n');
|
|
53
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/** Types mirroring contracts/ai-openapi.yaml — the Node↔Python AI boundary. */
|
|
2
|
+
|
|
3
|
+
export type Provider = 'openai' | 'ollama' | 'auto';
|
|
4
|
+
export type Role = 'system' | 'user' | 'assistant' | 'tool';
|
|
5
|
+
|
|
6
|
+
export interface Message {
|
|
7
|
+
role: Role;
|
|
8
|
+
content: string;
|
|
9
|
+
name?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ChatCompletionRequest {
|
|
13
|
+
model: string;
|
|
14
|
+
messages: Message[];
|
|
15
|
+
stream?: boolean;
|
|
16
|
+
temperature?: number;
|
|
17
|
+
max_tokens?: number;
|
|
18
|
+
provider?: Provider;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Choice {
|
|
22
|
+
index: number;
|
|
23
|
+
message: Message;
|
|
24
|
+
finish_reason: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Usage {
|
|
28
|
+
prompt_tokens?: number;
|
|
29
|
+
completion_tokens?: number;
|
|
30
|
+
total_tokens?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ChatCompletionResponse {
|
|
34
|
+
id?: string;
|
|
35
|
+
model: string;
|
|
36
|
+
provider?: string;
|
|
37
|
+
choices: Choice[];
|
|
38
|
+
usage?: Usage;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface Delta {
|
|
42
|
+
role?: Role;
|
|
43
|
+
content?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ChunkChoice {
|
|
47
|
+
index: number;
|
|
48
|
+
delta: Delta;
|
|
49
|
+
finish_reason: string | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ChatCompletionChunk {
|
|
53
|
+
id?: string;
|
|
54
|
+
model: string;
|
|
55
|
+
provider?: string;
|
|
56
|
+
choices: ChunkChoice[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface EmbeddingRequest {
|
|
60
|
+
model: string;
|
|
61
|
+
input: string | string[];
|
|
62
|
+
provider?: Provider;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface EmbeddingData {
|
|
66
|
+
index: number;
|
|
67
|
+
embedding: number[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface EmbeddingResponse {
|
|
71
|
+
model: string;
|
|
72
|
+
provider?: string;
|
|
73
|
+
data: EmbeddingData[];
|
|
74
|
+
usage?: Usage;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ModelInfo {
|
|
78
|
+
id: string;
|
|
79
|
+
owned_by?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ModelsResponse {
|
|
83
|
+
provider?: string;
|
|
84
|
+
data: ModelInfo[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface AiClientOptions {
|
|
88
|
+
/** AI server base URL, e.g. http://localhost:8000. */
|
|
89
|
+
serverUrl: string;
|
|
90
|
+
/** Request timeout in ms (default 60000). */
|
|
91
|
+
timeoutMs?: number;
|
|
92
|
+
/** Max retry attempts on network/5xx errors (default 2). */
|
|
93
|
+
maxRetries?: number;
|
|
94
|
+
/** Base delay for exponential backoff in ms (default 500). */
|
|
95
|
+
retryDelayMs?: number;
|
|
96
|
+
/** Optional Bearer token sent to the AI server. */
|
|
97
|
+
authToken?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Image generation (local Stable Diffusion HTTP server) ──────────────────
|
|
101
|
+
// OpenAI-compatible /images/generations surface, plus the agentic loop.
|
|
102
|
+
|
|
103
|
+
export interface ImageGenRequest {
|
|
104
|
+
prompt: string;
|
|
105
|
+
n?: number;
|
|
106
|
+
size?: string;
|
|
107
|
+
model?: string;
|
|
108
|
+
negative_prompt?: string;
|
|
109
|
+
steps?: number;
|
|
110
|
+
sampler?: string;
|
|
111
|
+
seed?: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface ImageGenData {
|
|
115
|
+
b64_json?: string;
|
|
116
|
+
url?: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface ImageGenMeta {
|
|
120
|
+
prompt: string;
|
|
121
|
+
negative_prompt: string;
|
|
122
|
+
model: string;
|
|
123
|
+
steps: number;
|
|
124
|
+
width: number;
|
|
125
|
+
height: number;
|
|
126
|
+
sampler: string;
|
|
127
|
+
seed: number | null;
|
|
128
|
+
n: number;
|
|
129
|
+
api: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface ImageGenResponse {
|
|
133
|
+
created: number;
|
|
134
|
+
data: ImageGenData[];
|
|
135
|
+
meta?: ImageGenMeta;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface ImageGenModelsResponse {
|
|
139
|
+
api: string;
|
|
140
|
+
data: Array<{ id: string; title: string }>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Agentic image loop ─────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
export interface AgentImageRequest {
|
|
146
|
+
/** The user's rough idea, e.g. "a cyberpunk fox in neon rain". */
|
|
147
|
+
idea: string;
|
|
148
|
+
/** Max agent iterations (default from server config). */
|
|
149
|
+
max_iterations?: number;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** SSE event emitted by POST /agent/image. Discriminated by `type`. */
|
|
153
|
+
export type AgentImageEvent =
|
|
154
|
+
| { type: 'thinking'; content: string; iteration: number }
|
|
155
|
+
| { type: 'tool'; name: string; args: Record<string, unknown>; iteration: number }
|
|
156
|
+
| { type: 'tool_error'; name: string; message: string; iteration: number }
|
|
157
|
+
| { type: 'image'; b64: string; prompt: string; iteration: number }
|
|
158
|
+
| { type: 'error'; message: string }
|
|
159
|
+
| AgentImageDoneEvent;
|
|
160
|
+
|
|
161
|
+
export interface AgentImageDoneEvent {
|
|
162
|
+
type: 'done';
|
|
163
|
+
image_b64: string;
|
|
164
|
+
prompt: string;
|
|
165
|
+
negative_prompt: string;
|
|
166
|
+
style?: string | null;
|
|
167
|
+
aspect_ratio?: string | null;
|
|
168
|
+
model: string;
|
|
169
|
+
steps: number;
|
|
170
|
+
sampler?: string | null;
|
|
171
|
+
seed?: number | null;
|
|
172
|
+
iterations: number;
|
|
173
|
+
critique_log: Array<{ score?: number; feedback?: string; suggestions?: string[] }>;
|
|
174
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, beforeEach } from 'vitest';
|
|
2
|
+
import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
3
|
+
import { AiClient, AiError } from '../src/index.js';
|
|
4
|
+
import type { ChatCompletionChunk } from '../src/index.js';
|
|
5
|
+
|
|
6
|
+
type Handler = (req: IncomingMessage, res: ServerResponse, body: any) => void;
|
|
7
|
+
|
|
8
|
+
function startServer(handler: Handler): Promise<{ server: Server; url: string }> {
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
const server = createServer((req, res) => {
|
|
11
|
+
const chunks: Buffer[] = [];
|
|
12
|
+
req.on('data', (c) => chunks.push(c));
|
|
13
|
+
req.on('end', () => {
|
|
14
|
+
let body: any = undefined;
|
|
15
|
+
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { /* */ }
|
|
16
|
+
handler(req, res, body);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
server.listen(0, '127.0.0.1', () => {
|
|
20
|
+
const addr = server.address();
|
|
21
|
+
const url = `http://127.0.0.1:${(addr as { port: number }).port}`;
|
|
22
|
+
resolve({ server, url });
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
const chunks: Buffer[] = [];
|
|
30
|
+
req.on('data', (c) => chunks.push(c));
|
|
31
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let servers: Server[] = [];
|
|
36
|
+
async function withServer(handler: Handler, fn: (url: string) => Promise<void>): Promise<void> {
|
|
37
|
+
const { server, url } = await startServer(handler);
|
|
38
|
+
servers.push(server);
|
|
39
|
+
try { await fn(url); } finally { /* closed in afterEach */ }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
for (const s of servers) { (s as any).closeAllConnections?.(); s.close(); }
|
|
44
|
+
servers = [];
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('AiClient', () => {
|
|
48
|
+
it('chat (non-stream) parses the response', async () => {
|
|
49
|
+
await withServer((_req, res, body) => {
|
|
50
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
51
|
+
res.end(JSON.stringify({
|
|
52
|
+
id: 'c1', model: body.model, provider: 'ollama',
|
|
53
|
+
choices: [{ index: 0, message: { role: 'assistant', content: 'Hi' }, finish_reason: 'stop' }],
|
|
54
|
+
usage: { prompt_tokens: 3, completion_tokens: 1, total_tokens: 4 },
|
|
55
|
+
}));
|
|
56
|
+
}, async (url) => {
|
|
57
|
+
const client = new AiClient({ serverUrl: url });
|
|
58
|
+
const r = await client.chat({ model: 'gpt-test', messages: [{ role: 'user', content: 'hi' }] });
|
|
59
|
+
expect(r.choices[0].message.content).toBe('Hi');
|
|
60
|
+
expect(r.provider).toBe('ollama');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('chatStream yields chunks until [DONE]', async () => {
|
|
65
|
+
await withServer((_req, res, body) => {
|
|
66
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
67
|
+
const chunks: ChatCompletionChunk[] = [
|
|
68
|
+
{ id: 'c1', model: body.model, provider: 'openai', choices: [{ index: 0, delta: { role: 'assistant', content: 'Hel' }, finish_reason: null }] },
|
|
69
|
+
{ id: 'c1', model: body.model, provider: 'openai', choices: [{ index: 0, delta: { content: 'lo' }, finish_reason: null }] },
|
|
70
|
+
{ id: 'c1', model: body.model, provider: 'openai', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] },
|
|
71
|
+
];
|
|
72
|
+
for (const c of chunks) res.write(`data: ${JSON.stringify(c)}\n\n`);
|
|
73
|
+
res.write('data: [DONE]\n\n');
|
|
74
|
+
res.end();
|
|
75
|
+
}, async (url) => {
|
|
76
|
+
const client = new AiClient({ serverUrl: url });
|
|
77
|
+
const out: string[] = [];
|
|
78
|
+
for await (const c of client.chatStream({ model: 'gpt-test', messages: [{ role: 'user', content: 'hi' }] })) {
|
|
79
|
+
out.push(c.choices[0].delta.content ?? '');
|
|
80
|
+
}
|
|
81
|
+
expect(out.join('')).toBe('Hello');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('embeddings (single + batch)', async () => {
|
|
86
|
+
await withServer((_req, res, body) => {
|
|
87
|
+
const n = Array.isArray(body.input) ? body.input.length : 1;
|
|
88
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
89
|
+
res.end(JSON.stringify({
|
|
90
|
+
model: body.model, provider: 'openai',
|
|
91
|
+
data: Array.from({ length: n }, (_, i) => ({ index: i, embedding: [0.1, 0.2] })),
|
|
92
|
+
usage: { prompt_tokens: n, total_tokens: n },
|
|
93
|
+
}));
|
|
94
|
+
}, async (url) => {
|
|
95
|
+
const client = new AiClient({ serverUrl: url });
|
|
96
|
+
const single = await client.embeddings({ model: 'e', input: 'hello' });
|
|
97
|
+
expect(single.data).toHaveLength(1);
|
|
98
|
+
const batch = await client.embeddings({ model: 'e', input: ['a', 'b', 'c'] });
|
|
99
|
+
expect(batch.data).toHaveLength(3);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('listModels forwards the provider query param', async () => {
|
|
104
|
+
await withServer((req, res) => {
|
|
105
|
+
expect(req.url).toBe('/models?provider=ollama');
|
|
106
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
107
|
+
res.end(JSON.stringify({ provider: 'ollama', data: [{ id: 'llama3', owned_by: 'ollama' }] }));
|
|
108
|
+
}, async (url) => {
|
|
109
|
+
const client = new AiClient({ serverUrl: url });
|
|
110
|
+
const r = await client.listModels('ollama');
|
|
111
|
+
expect(r.data[0].id).toBe('llama3');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('retries on 503 then succeeds', async () => {
|
|
116
|
+
let calls = 0;
|
|
117
|
+
await withServer((_req, res) => {
|
|
118
|
+
calls += 1;
|
|
119
|
+
if (calls < 3) { res.writeHead(503); res.end(JSON.stringify({ error: { message: 'down' } })); return; }
|
|
120
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
121
|
+
res.end(JSON.stringify({ model: 'm', choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], provider: 'openai' }));
|
|
122
|
+
}, async (url) => {
|
|
123
|
+
const client = new AiClient({ serverUrl: url, maxRetries: 3, retryDelayMs: 10 });
|
|
124
|
+
const r = await client.chat({ model: 'm', messages: [{ role: 'user', content: 'x' }] });
|
|
125
|
+
expect(r.choices[0].message.content).toBe('ok');
|
|
126
|
+
expect(calls).toBe(3);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('throws AiError (non-retryable) on 400', async () => {
|
|
131
|
+
await withServer((_req, res) => {
|
|
132
|
+
res.writeHead(400); res.end(JSON.stringify({ error: { message: 'bad', code: 'BAD' } }));
|
|
133
|
+
}, async (url) => {
|
|
134
|
+
const client = new AiClient({ serverUrl: url, maxRetries: 2, retryDelayMs: 10 });
|
|
135
|
+
await expect(client.chat({ model: 'm', messages: [] })).rejects.toThrow(/bad/);
|
|
136
|
+
await expect(client.chat({ model: 'm', messages: [] })).rejects.toBeInstanceOf(AiError);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('times out and aborts the request', async () => {
|
|
141
|
+
await withServer((_req, res) => {
|
|
142
|
+
// Never respond.
|
|
143
|
+
setTimeout(() => res.end(), 5000);
|
|
144
|
+
}, async (url) => {
|
|
145
|
+
const client = new AiClient({ serverUrl: url, timeoutMs: 100, maxRetries: 0 });
|
|
146
|
+
await expect(client.chat({ model: 'm', messages: [] })).rejects.toThrow(/timed out|aborted/i);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('sends the auth token header when configured', async () => {
|
|
151
|
+
await withServer((req, res) => {
|
|
152
|
+
expect(req.headers['authorization']).toBe('Bearer secret-token');
|
|
153
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
154
|
+
res.end(JSON.stringify({ model: 'm', choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], provider: 'openai' }));
|
|
155
|
+
}, async (url) => {
|
|
156
|
+
const client = new AiClient({ serverUrl: url, authToken: 'secret-token' });
|
|
157
|
+
await client.chat({ model: 'm', messages: [{ role: 'user', content: 'x' }] });
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('forwards the `provider` field to the AI server for routing', async () => {
|
|
162
|
+
await withServer((_req, res, body) => {
|
|
163
|
+
expect(body.provider).toBe('auto'); // server routes on this; strips before upstream
|
|
164
|
+
expect(body.model).toBe('m');
|
|
165
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
166
|
+
res.end(JSON.stringify({ model: 'm', choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], provider: 'openai' }));
|
|
167
|
+
}, async (url) => {
|
|
168
|
+
const client = new AiClient({ serverUrl: url });
|
|
169
|
+
await client.chat({ model: 'm', messages: [{ role: 'user', content: 'x' }], provider: 'auto' });
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
package/tsconfig.json
ADDED