@dotsetlabs/bellwether 1.0.2 → 1.0.3

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.
@@ -13,6 +13,22 @@ import { getLogger } from '../logging/logger.js';
13
13
  */
14
14
  const PLACEHOLDER_CONTINUE = 'Continue.';
15
15
  const PLACEHOLDER_GREETING = 'Hello.';
16
+ function getErrorStatus(error) {
17
+ const err = error;
18
+ return err.status ?? err.statusCode;
19
+ }
20
+ function getErrorCode(error) {
21
+ const err = error;
22
+ return err.code ?? err.error?.code;
23
+ }
24
+ function getErrorType(error) {
25
+ const err = error;
26
+ return err.type ?? err.error?.type;
27
+ }
28
+ function getErrorMessage(error) {
29
+ const err = error;
30
+ return err.error?.message ?? err.message ?? '';
31
+ }
16
32
  /**
17
33
  * Anthropic Claude LLM client implementation.
18
34
  */
@@ -48,7 +64,7 @@ export class AnthropicClient {
48
64
  // Separate system message from conversation messages
49
65
  const systemPrompt = options?.systemPrompt;
50
66
  // Convert messages to Anthropic format
51
- const anthropicMessages = messages.map(m => ({
67
+ const anthropicMessages = messages.map((m) => ({
52
68
  role: m.role === 'assistant' ? 'assistant' : 'user',
53
69
  content: m.content,
54
70
  }));
@@ -72,7 +88,7 @@ export class AnthropicClient {
72
88
  max_tokens: options?.maxTokens ?? LLM_DEFAULTS.MAX_TOKENS,
73
89
  system: system,
74
90
  messages: normalizedMessages,
75
- });
91
+ }, { signal: options?.signal });
76
92
  // Track actual token usage from API response
77
93
  if (this.onUsage && response.usage) {
78
94
  this.onUsage(response.usage.input_tokens, response.usage.output_tokens);
@@ -80,26 +96,34 @@ export class AnthropicClient {
80
96
  // Check for content filtering refusal
81
97
  this.checkForRefusal(response, model);
82
98
  // Extract text content from response
83
- const textBlocks = response.content.filter(block => block.type === 'text');
99
+ const textBlocks = response.content.filter((block) => block.type === 'text');
84
100
  if (textBlocks.length === 0) {
85
101
  throw new Error('No text content in Claude response');
86
102
  }
87
- return textBlocks.map(block => block.text).join('');
103
+ return textBlocks.map((block) => block.text).join('');
88
104
  }
89
105
  catch (error) {
90
106
  // Don't re-process errors that are already typed LLM errors
91
- if (error instanceof LLMRefusalError || error instanceof LLMAuthError ||
92
- error instanceof LLMRateLimitError || error instanceof LLMQuotaError ||
107
+ if (error instanceof LLMRefusalError ||
108
+ error instanceof LLMAuthError ||
109
+ error instanceof LLMRateLimitError ||
110
+ error instanceof LLMQuotaError ||
93
111
  error instanceof LLMConnectionError) {
94
112
  throw error;
95
113
  }
96
114
  // Convert to typed errors for retry logic
97
115
  if (error instanceof Error) {
98
- const message = error.message.toLowerCase();
99
- if (message.includes('401') || message.includes('authentication')) {
116
+ const status = getErrorStatus(error);
117
+ const code = (getErrorCode(error) ?? '').toLowerCase();
118
+ const type = (getErrorType(error) ?? '').toLowerCase();
119
+ const message = getErrorMessage(error).toLowerCase();
120
+ if (status === 401 || status === 403 || message.includes('authentication')) {
100
121
  throw new LLMAuthError('anthropic', model);
101
122
  }
102
- if (message.includes('429') || message.includes('rate limit')) {
123
+ if (status === 429 ||
124
+ code.includes('rate_limit') ||
125
+ type.includes('rate_limit') ||
126
+ message.includes('rate limit')) {
103
127
  // Extract retry-after-ms header if available from Anthropic API error
104
128
  let retryAfterMs;
105
129
  const apiError = error;
@@ -127,7 +151,11 @@ export class AnthropicClient {
127
151
  }
128
152
  throw new LLMRateLimitError('anthropic', retryAfterMs, model);
129
153
  }
130
- if (message.includes('insufficient') || message.includes('credit')) {
154
+ if (status === 402 ||
155
+ code.includes('insufficient') ||
156
+ type.includes('insufficient') ||
157
+ message.includes('insufficient') ||
158
+ message.includes('credit')) {
131
159
  throw new LLMQuotaError('anthropic', model);
132
160
  }
133
161
  if (message.includes('econnrefused') || message.includes('fetch failed')) {
@@ -164,9 +192,12 @@ export class AnthropicClient {
164
192
  throw new LLMRefusalError('anthropic', 'Response blocked due to safety concerns', model);
165
193
  }
166
194
  // Check content for refusal indicators
167
- const textBlocks = response.content.filter(block => block.type === 'text');
195
+ const textBlocks = response.content.filter((block) => block.type === 'text');
168
196
  if (textBlocks.length > 0) {
169
- const fullText = textBlocks.map(block => block.text ?? '').join('').toLowerCase();
197
+ const fullText = textBlocks
198
+ .map((block) => block.text ?? '')
199
+ .join('')
200
+ .toLowerCase();
170
201
  // Check for common refusal patterns in Claude's responses
171
202
  const refusalPatterns = [
172
203
  'i cannot help with',
@@ -244,7 +275,7 @@ export class AnthropicClient {
244
275
  // Separate system message from conversation messages
245
276
  const systemPrompt = options?.systemPrompt;
246
277
  // Convert messages to Anthropic format
247
- const anthropicMessages = messages.map(m => ({
278
+ const anthropicMessages = messages.map((m) => ({
248
279
  role: m.role === 'assistant' ? 'assistant' : 'user',
249
280
  content: m.content,
250
281
  }));
@@ -266,7 +297,7 @@ export class AnthropicClient {
266
297
  max_tokens: options?.maxTokens ?? LLM_DEFAULTS.MAX_TOKENS,
267
298
  system: system,
268
299
  messages: normalizedMessages,
269
- });
300
+ }, { signal: options?.signal });
270
301
  let fullText = '';
271
302
  for await (const event of stream) {
272
303
  if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
@@ -294,8 +325,10 @@ export class AnthropicClient {
294
325
  catch (error) {
295
326
  options?.onError?.(error instanceof Error ? error : new Error(String(error)));
296
327
  // Don't re-process errors that are already typed LLM errors
297
- if (error instanceof LLMRefusalError || error instanceof LLMAuthError ||
298
- error instanceof LLMRateLimitError || error instanceof LLMQuotaError ||
328
+ if (error instanceof LLMRefusalError ||
329
+ error instanceof LLMAuthError ||
330
+ error instanceof LLMRateLimitError ||
331
+ error instanceof LLMQuotaError ||
299
332
  error instanceof LLMConnectionError) {
300
333
  throw error;
301
334
  }
@@ -16,6 +16,8 @@ export interface CompletionOptions {
16
16
  responseFormat?: 'text' | 'json';
17
17
  /** System prompt to set context */
18
18
  systemPrompt?: string;
19
+ /** Optional abort signal to cancel the request */
20
+ signal?: AbortSignal;
19
21
  }
20
22
  /**
21
23
  * Options for streaming completions.
@@ -36,7 +36,68 @@ export function parseJSONResponse(response) {
36
36
  return JSON.parse(cleaned);
37
37
  }
38
38
  catch (error) {
39
+ // Try to extract a JSON object/array from mixed content
40
+ const extracted = extractFirstJsonBlock(cleaned);
41
+ if (extracted) {
42
+ try {
43
+ return JSON.parse(extracted);
44
+ }
45
+ catch (extractedError) {
46
+ throw new Error(`Failed to parse extracted JSON from LLM response: ${extractedError}`);
47
+ }
48
+ }
39
49
  throw new Error(`Failed to parse JSON from LLM response: ${error}`);
40
50
  }
41
51
  }
52
+ /**
53
+ * Extract the first valid JSON object or array from a string.
54
+ * Handles mixed content like "Here is the JSON: {...}".
55
+ */
56
+ function extractFirstJsonBlock(text) {
57
+ const startIndexes = [];
58
+ for (let i = 0; i < text.length; i++) {
59
+ const char = text[i];
60
+ if (char === '{' || char === '[') {
61
+ startIndexes.push(i);
62
+ }
63
+ }
64
+ for (const start of startIndexes) {
65
+ const opening = text[start];
66
+ const closing = opening === '{' ? '}' : ']';
67
+ let depth = 0;
68
+ let inString = false;
69
+ let escape = false;
70
+ for (let i = start; i < text.length; i++) {
71
+ const char = text[i];
72
+ if (inString) {
73
+ if (escape) {
74
+ escape = false;
75
+ continue;
76
+ }
77
+ if (char === '\\') {
78
+ escape = true;
79
+ continue;
80
+ }
81
+ if (char === '"') {
82
+ inString = false;
83
+ }
84
+ continue;
85
+ }
86
+ if (char === '"') {
87
+ inString = true;
88
+ continue;
89
+ }
90
+ if (char === opening) {
91
+ depth += 1;
92
+ }
93
+ else if (char === closing) {
94
+ depth -= 1;
95
+ if (depth === 0) {
96
+ return text.slice(start, i + 1);
97
+ }
98
+ }
99
+ }
100
+ }
101
+ return null;
102
+ }
42
103
  //# sourceMappingURL=client.js.map
@@ -65,12 +65,13 @@ export class OllamaClient {
65
65
  'Content-Type': 'application/json',
66
66
  },
67
67
  body: JSON.stringify(request),
68
+ signal: options?.signal,
68
69
  });
69
70
  if (!response.ok) {
70
71
  const errorText = await response.text();
71
72
  throw new Error(`Ollama API error (${response.status}): ${errorText}`);
72
73
  }
73
- const result = await response.json();
74
+ const result = (await response.json());
74
75
  if (!result.message?.content) {
75
76
  throw new Error('No content in Ollama response');
76
77
  }
@@ -170,6 +171,7 @@ export class OllamaClient {
170
171
  'Content-Type': 'application/json',
171
172
  },
172
173
  body: JSON.stringify(request),
174
+ signal: options?.signal,
173
175
  });
174
176
  if (!response.ok) {
175
177
  const errorText = await response.text();
@@ -234,7 +236,10 @@ export class OllamaClient {
234
236
  }
235
237
  catch (parseError) {
236
238
  // Log parse error for debugging
237
- this.logger.debug({ buffer, error: parseError instanceof Error ? parseError.message : String(parseError) }, 'Failed to parse final buffer chunk');
239
+ this.logger.debug({
240
+ buffer,
241
+ error: parseError instanceof Error ? parseError.message : String(parseError),
242
+ }, 'Failed to parse final buffer chunk');
238
243
  }
239
244
  }
240
245
  }
@@ -318,8 +323,8 @@ export class OllamaClient {
318
323
  if (!response.ok) {
319
324
  return [];
320
325
  }
321
- const result = await response.json();
322
- return result.models?.map(m => m.name) ?? [];
326
+ const result = (await response.json());
327
+ return result.models?.map((m) => m.name) ?? [];
323
328
  }
324
329
  catch (error) {
325
330
  this.logger.debug({ error: error instanceof Error ? error.message : String(error) }, 'Failed to list Ollama models');
@@ -15,26 +15,35 @@ import { LLM_DEFAULTS } from '../constants.js';
15
15
  * - Don't support custom temperature (only default of 1)
16
16
  * - Use reasoning tokens that come out of the completion token budget
17
17
  */
18
- const MODELS_WITH_RESTRICTED_PARAMS = [
19
- 'o1',
20
- 'o1-mini',
21
- 'o1-preview',
22
- 'o3',
23
- 'o3-mini',
24
- 'gpt-5',
25
- ];
18
+ const MODELS_WITH_RESTRICTED_PARAMS = ['o1', 'o1-mini', 'o1-preview', 'o3', 'o3-mini', 'gpt-5'];
26
19
  /**
27
20
  * Minimum max_completion_tokens for reasoning models.
28
21
  * Reasoning models use tokens for internal "thinking" before producing output.
29
22
  * We need a high minimum to ensure there are enough tokens left for actual output.
30
23
  */
31
24
  const REASONING_MODEL_MIN_TOKENS = 8192;
25
+ function getErrorStatus(error) {
26
+ const err = error;
27
+ return err.status ?? err.statusCode;
28
+ }
29
+ function getErrorCode(error) {
30
+ const err = error;
31
+ return err.code ?? err.error?.code;
32
+ }
33
+ function getErrorType(error) {
34
+ const err = error;
35
+ return err.type ?? err.error?.type;
36
+ }
37
+ function getErrorMessage(error) {
38
+ const err = error;
39
+ return err.error?.message ?? err.message ?? '';
40
+ }
32
41
  /**
33
42
  * Check if a model has restricted parameters (newer reasoning/GPT-5 models).
34
43
  */
35
44
  function hasRestrictedParams(model) {
36
45
  const modelLower = model.toLowerCase();
37
- return MODELS_WITH_RESTRICTED_PARAMS.some(prefix => modelLower.startsWith(prefix));
46
+ return MODELS_WITH_RESTRICTED_PARAMS.some((prefix) => modelLower.startsWith(prefix));
38
47
  }
39
48
  /**
40
49
  * Get the effective max tokens for a model, accounting for reasoning overhead.
@@ -88,7 +97,7 @@ export class OpenAIClient {
88
97
  const maxTokensValue = getEffectiveMaxTokens(model, requestedMaxTokens);
89
98
  const response = await this.client.chat.completions.create({
90
99
  model,
91
- messages: allMessages.map(m => ({
100
+ messages: allMessages.map((m) => ({
92
101
  role: m.role,
93
102
  content: m.content,
94
103
  })),
@@ -102,10 +111,8 @@ export class OpenAIClient {
102
111
  ...(restrictedParams
103
112
  ? {}
104
113
  : { temperature: options?.temperature ?? LLM_DEFAULTS.TEMPERATURE }),
105
- response_format: options?.responseFormat === 'json'
106
- ? { type: 'json_object' }
107
- : undefined,
108
- });
114
+ response_format: options?.responseFormat === 'json' ? { type: 'json_object' } : undefined,
115
+ }, { signal: options?.signal });
109
116
  // Track actual token usage from API response
110
117
  if (this.onUsage && response.usage) {
111
118
  this.onUsage(response.usage.prompt_tokens, response.usage.completion_tokens);
@@ -138,11 +145,14 @@ export class OpenAIClient {
138
145
  catch (error) {
139
146
  // Convert to typed errors for retry logic
140
147
  if (error instanceof Error) {
141
- const message = error.message.toLowerCase();
142
- if (message.includes('401')) {
148
+ const status = getErrorStatus(error);
149
+ const code = (getErrorCode(error) ?? '').toLowerCase();
150
+ const type = (getErrorType(error) ?? '').toLowerCase();
151
+ const message = getErrorMessage(error).toLowerCase();
152
+ if (status === 401 || status === 403) {
143
153
  throw new LLMAuthError('openai', model);
144
154
  }
145
- if (message.includes('429')) {
155
+ if (status === 429 || code.includes('rate_limit') || type.includes('rate_limit')) {
146
156
  // Extract Retry-After header if available from OpenAI API error
147
157
  let retryAfterMs;
148
158
  const apiError = error;
@@ -172,7 +182,10 @@ export class OpenAIClient {
172
182
  }
173
183
  throw new LLMRateLimitError('openai', retryAfterMs, model);
174
184
  }
175
- if (message.includes('insufficient_quota')) {
185
+ if (status === 402 ||
186
+ code.includes('insufficient_quota') ||
187
+ type.includes('insufficient_quota') ||
188
+ message.includes('insufficient_quota')) {
176
189
  throw new LLMQuotaError('openai', model);
177
190
  }
178
191
  if (message.includes('econnrefused') || message.includes('fetch failed')) {
@@ -219,7 +232,7 @@ export class OpenAIClient {
219
232
  const maxTokensValue = getEffectiveMaxTokens(model, requestedMaxTokens);
220
233
  const stream = await this.client.chat.completions.create({
221
234
  model,
222
- messages: allMessages.map(m => ({
235
+ messages: allMessages.map((m) => ({
223
236
  role: m.role,
224
237
  content: m.content,
225
238
  })),
@@ -233,11 +246,9 @@ export class OpenAIClient {
233
246
  ...(restrictedParams
234
247
  ? {}
235
248
  : { temperature: options?.temperature ?? LLM_DEFAULTS.TEMPERATURE }),
236
- response_format: options?.responseFormat === 'json'
237
- ? { type: 'json_object' }
238
- : undefined,
249
+ response_format: options?.responseFormat === 'json' ? { type: 'json_object' } : undefined,
239
250
  stream: true,
240
- });
251
+ }, { signal: options?.signal });
241
252
  let fullText = '';
242
253
  let inputTokens = 0;
243
254
  let outputTokens = 0;
@@ -36,7 +36,7 @@ export declare abstract class BaseTransport extends EventEmitter {
36
36
  /**
37
37
  * Send a JSON-RPC message to the server.
38
38
  */
39
- abstract send(message: JSONRPCMessage): void;
39
+ abstract send(message: JSONRPCMessage, signal?: AbortSignal): void;
40
40
  /**
41
41
  * Close the transport connection.
42
42
  */
@@ -49,11 +49,11 @@ export declare class HTTPTransport extends BaseTransport {
49
49
  * Unlike SSE transport, this method handles the response synchronously
50
50
  * and emits a 'message' event with the response.
51
51
  */
52
- send(message: JSONRPCMessage): void;
52
+ send(message: JSONRPCMessage, signal?: AbortSignal): void;
53
53
  /**
54
54
  * Send a JSON-RPC message and wait for the response.
55
55
  */
56
- sendAsync(message: JSONRPCMessage): Promise<JSONRPCResponse | null>;
56
+ sendAsync(message: JSONRPCMessage, signal?: AbortSignal): Promise<JSONRPCResponse | null>;
57
57
  /**
58
58
  * Handle a streaming HTTP response (text/event-stream).
59
59
  * Includes timeout handling to prevent indefinite hangs.
@@ -59,21 +59,32 @@ export class HTTPTransport extends BaseTransport {
59
59
  * Unlike SSE transport, this method handles the response synchronously
60
60
  * and emits a 'message' event with the response.
61
61
  */
62
- send(message) {
62
+ send(message, signal) {
63
63
  if (!this.connected) {
64
64
  this.emit('error', new Error('Transport not connected'));
65
65
  return;
66
66
  }
67
- this.sendAsync(message).catch((error) => {
67
+ this.sendAsync(message, signal).catch((error) => {
68
68
  this.emit('error', error);
69
69
  });
70
70
  }
71
71
  /**
72
72
  * Send a JSON-RPC message and wait for the response.
73
73
  */
74
- async sendAsync(message) {
74
+ async sendAsync(message, signal) {
75
75
  this.log('Sending message', { message });
76
76
  this.abortController = new AbortController();
77
+ const controller = this.abortController;
78
+ let abortListener;
79
+ if (signal) {
80
+ if (signal.aborted) {
81
+ controller.abort();
82
+ }
83
+ else {
84
+ abortListener = () => controller.abort();
85
+ signal.addEventListener('abort', abortListener, { once: true });
86
+ }
87
+ }
77
88
  const timeoutId = setTimeout(() => {
78
89
  this.abortController?.abort();
79
90
  }, this.timeout);
@@ -82,7 +93,7 @@ export class HTTPTransport extends BaseTransport {
82
93
  method: 'POST',
83
94
  headers: this.buildHeaders(),
84
95
  body: JSON.stringify(message),
85
- signal: this.abortController.signal,
96
+ signal: controller.signal,
86
97
  });
87
98
  clearTimeout(timeoutId);
88
99
  if (!response.ok) {
@@ -117,6 +128,11 @@ export class HTTPTransport extends BaseTransport {
117
128
  }
118
129
  throw error;
119
130
  }
131
+ finally {
132
+ if (signal && abortListener) {
133
+ signal.removeEventListener('abort', abortListener);
134
+ }
135
+ }
120
136
  }
121
137
  /**
122
138
  * Handle a streaming HTTP response (text/event-stream).
@@ -177,7 +193,9 @@ export class HTTPTransport extends BaseTransport {
177
193
  }
178
194
  catch (error) {
179
195
  // Log streaming parse errors for visibility
180
- const preview = data.length > DISPLAY_LIMITS.TRANSPORT_DATA_PREVIEW ? `${data.substring(0, DISPLAY_LIMITS.TRANSPORT_DATA_PREVIEW)}...` : data;
196
+ const preview = data.length > DISPLAY_LIMITS.TRANSPORT_DATA_PREVIEW
197
+ ? `${data.substring(0, DISPLAY_LIMITS.TRANSPORT_DATA_PREVIEW)}...`
198
+ : data;
181
199
  this.logger.warn({ preview, error: error instanceof Error ? error.message : String(error) }, 'Failed to parse SSE message');
182
200
  }
183
201
  }
@@ -189,7 +207,9 @@ export class HTTPTransport extends BaseTransport {
189
207
  }
190
208
  catch {
191
209
  // Not JSON - this is common for non-JSON lines in streams, log only in debug
192
- this.log('Skipping non-JSON line', { preview: trimmedLine.substring(0, DISPLAY_LIMITS.RESPONSE_DATA_PREVIEW) });
210
+ this.log('Skipping non-JSON line', {
211
+ preview: trimmedLine.substring(0, DISPLAY_LIMITS.RESPONSE_DATA_PREVIEW),
212
+ });
193
213
  }
194
214
  }
195
215
  }
@@ -130,27 +130,39 @@ export declare class MCPClient {
130
130
  /**
131
131
  * List all tools available on the server.
132
132
  */
133
- listTools(): Promise<MCPTool[]>;
133
+ listTools(options?: {
134
+ signal?: AbortSignal;
135
+ }): Promise<MCPTool[]>;
134
136
  /**
135
137
  * List all prompts available on the server.
136
138
  */
137
- listPrompts(): Promise<MCPPrompt[]>;
139
+ listPrompts(options?: {
140
+ signal?: AbortSignal;
141
+ }): Promise<MCPPrompt[]>;
138
142
  /**
139
143
  * List all resources available on the server.
140
144
  */
141
- listResources(): Promise<MCPResource[]>;
145
+ listResources(options?: {
146
+ signal?: AbortSignal;
147
+ }): Promise<MCPResource[]>;
142
148
  /**
143
149
  * Read a resource from the server by URI.
144
150
  */
145
- readResource(uri: string): Promise<MCPResourceReadResult>;
151
+ readResource(uri: string, options?: {
152
+ signal?: AbortSignal;
153
+ }): Promise<MCPResourceReadResult>;
146
154
  /**
147
155
  * Get a prompt from the server with the given arguments.
148
156
  */
149
- getPrompt(name: string, args?: Record<string, string>): Promise<MCPPromptGetResult>;
157
+ getPrompt(name: string, args?: Record<string, string>, options?: {
158
+ signal?: AbortSignal;
159
+ }): Promise<MCPPromptGetResult>;
150
160
  /**
151
161
  * Call a tool on the server.
152
162
  */
153
- callTool(name: string, args?: Record<string, unknown>): Promise<MCPToolCallResult>;
163
+ callTool(name: string, args?: Record<string, unknown>, options?: {
164
+ signal?: AbortSignal;
165
+ }): Promise<MCPToolCallResult>;
154
166
  /**
155
167
  * Get server capabilities.
156
168
  */