genai-lite 0.11.0 → 0.12.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 +29 -1
- package/dist/index.d.ts +1 -1
- package/dist/llm/LLMService.d.ts +23 -1
- package/dist/llm/LLMService.js +326 -186
- package/dist/llm/clients/AnthropicClientAdapter.d.ts +6 -1
- package/dist/llm/clients/AnthropicClientAdapter.js +271 -87
- package/dist/llm/clients/GeminiClientAdapter.d.ts +8 -1
- package/dist/llm/clients/GeminiClientAdapter.js +208 -54
- package/dist/llm/clients/LlamaCppClientAdapter.d.ts +7 -1
- package/dist/llm/clients/LlamaCppClientAdapter.js +303 -125
- package/dist/llm/clients/MistralClientAdapter.d.ts +8 -1
- package/dist/llm/clients/MistralClientAdapter.js +216 -49
- package/dist/llm/clients/MockClientAdapter.d.ts +2 -1
- package/dist/llm/clients/MockClientAdapter.js +51 -0
- package/dist/llm/clients/OpenAIClientAdapter.d.ts +6 -1
- package/dist/llm/clients/OpenAIClientAdapter.js +200 -72
- package/dist/llm/clients/OpenRouterClientAdapter.d.ts +6 -1
- package/dist/llm/clients/OpenRouterClientAdapter.js +242 -109
- package/dist/llm/clients/types.d.ts +9 -1
- package/dist/llm/types.d.ts +28 -0
- package/dist/llm/types.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,6 +6,7 @@ A lightweight, portable Node.js/TypeScript library providing a unified interface
|
|
|
6
6
|
|
|
7
7
|
- 🔌 **Unified API** - Single interface for multiple AI providers
|
|
8
8
|
- 🏠 **Local & Cloud Models** - Run models locally with llama.cpp or use cloud APIs
|
|
9
|
+
- ⚡ **Text Streaming** - Async iterable token deltas for OpenAI, Anthropic, Gemini, Mistral, OpenRouter, and llama.cpp
|
|
9
10
|
- 🖼️ **Image Generation** - First-class support for AI image generation (OpenAI, local diffusion)
|
|
10
11
|
- 🔐 **Flexible API Key Management** - Bring your own key storage solution
|
|
11
12
|
- 📦 **Zero Electron Dependencies** - Works in any Node.js environment
|
|
@@ -77,6 +78,33 @@ if (response.object === 'chat.completion') {
|
|
|
77
78
|
}
|
|
78
79
|
```
|
|
79
80
|
|
|
81
|
+
### Streaming Text
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
import { LLMService } from 'genai-lite';
|
|
85
|
+
|
|
86
|
+
const llmService = new LLMService(async () => 'not-needed');
|
|
87
|
+
|
|
88
|
+
for await (const event of llmService.streamMessage({
|
|
89
|
+
providerId: 'llamacpp',
|
|
90
|
+
modelId: 'llamacpp',
|
|
91
|
+
messages: [{ role: 'user', content: 'Say hello in five words.' }],
|
|
92
|
+
settings: { maxTokens: 32 }
|
|
93
|
+
})) {
|
|
94
|
+
if (event.type === 'content_delta') {
|
|
95
|
+
process.stdout.write(event.delta);
|
|
96
|
+
} else if (event.type === 'reasoning_delta') {
|
|
97
|
+
process.stderr.write(event.delta);
|
|
98
|
+
} else if (event.type === 'complete') {
|
|
99
|
+
console.log('\nTokens:', event.response.usage?.total_tokens);
|
|
100
|
+
} else if (event.type === 'error') {
|
|
101
|
+
console.error(event.error.error.message);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Streaming is implemented for all text providers: `openai`, `anthropic`, `gemini`, `mistral`, `openrouter`, and `llamacpp`. The final `complete.response` event contains the same normalized response shape returned by `sendMessage()`.
|
|
107
|
+
|
|
80
108
|
### Image Generation
|
|
81
109
|
|
|
82
110
|
```typescript
|
|
@@ -134,7 +162,7 @@ Comprehensive documentation is available in the **[`genai-lite-docs`](./genai-li
|
|
|
134
162
|
- **Google Gemini** - Gemini 3 (Pro, Flash preview), Gemini 2.5, Gemma 3 & 4 (free)
|
|
135
163
|
- **Mistral** - Codestral, Devstral
|
|
136
164
|
- **OpenRouter** - Unified gateway to 100+ models (unknown models assumed reasoning-capable)
|
|
137
|
-
- **llama.cpp** - Run any GGUF model locally (no API keys required); local reasoning toggle for detected models
|
|
165
|
+
- **llama.cpp** - Run any GGUF model locally (no API keys required); streaming and local reasoning toggle for detected models
|
|
138
166
|
|
|
139
167
|
### Image Providers
|
|
140
168
|
- **OpenAI Images** - gpt-image-1, dall-e-3, dall-e-2
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type { ApiKeyProvider, PresetMode } from "./types";
|
|
2
2
|
export { LLMService } from "./llm/LLMService";
|
|
3
|
-
export type { LLMServiceOptions, SendMessageOptions, CreateMessagesResult } from "./llm/LLMService";
|
|
3
|
+
export type { LLMServiceOptions, SendMessageOptions, StreamMessageOptions, CreateMessagesResult } from "./llm/LLMService";
|
|
4
4
|
export { withRetry, DEFAULT_RETRY_POLICY } from "./shared/services/withRetry";
|
|
5
5
|
export type { RetryPolicy, RetryVerdict, WithRetryOptions } from "./shared/services/withRetry";
|
|
6
6
|
export type { ModelPreset } from "./types/presets";
|
package/dist/llm/LLMService.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ApiKeyProvider, PresetMode } from '../types';
|
|
2
2
|
import type { Logger, LogLevel } from '../logging/types';
|
|
3
|
-
import type { LLMChatRequest, LLMChatRequestWithPreset, LLMResponse, LLMFailureResponse, ProviderInfo, ModelInfo, ApiProviderId, LLMSettings, ModelContext, LLMMessage } from "./types";
|
|
3
|
+
import type { LLMChatRequest, LLMChatRequestWithPreset, LLMResponse, LLMFailureResponse, ProviderInfo, ModelInfo, ApiProviderId, LLMSettings, ModelContext, LLMMessage, LLMStreamEvent } from "./types";
|
|
4
4
|
import type { ModelPreset } from "../types/presets";
|
|
5
5
|
import { type RetryPolicy } from "../shared/services/withRetry";
|
|
6
6
|
export type { PresetMode };
|
|
@@ -43,6 +43,18 @@ export interface SendMessageOptions {
|
|
|
43
43
|
/** Per-request retry cap (overrides the service-level retry.maxRetries) */
|
|
44
44
|
maxRetries?: number;
|
|
45
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Per-call options for LLMService.streamMessage
|
|
48
|
+
*/
|
|
49
|
+
export interface StreamMessageOptions {
|
|
50
|
+
/**
|
|
51
|
+
* Abort signal to cancel the request (client-side - the provider may still
|
|
52
|
+
* process and bill an already-dispatched request).
|
|
53
|
+
*/
|
|
54
|
+
signal?: AbortSignal;
|
|
55
|
+
/** Per-request timeout in ms (overrides the service-level timeoutMs) */
|
|
56
|
+
timeoutMs?: number;
|
|
57
|
+
}
|
|
46
58
|
/**
|
|
47
59
|
* Result from createMessages method
|
|
48
60
|
*/
|
|
@@ -96,6 +108,16 @@ export declare class LLMService {
|
|
|
96
108
|
* @returns Promise resolving to either success or failure response
|
|
97
109
|
*/
|
|
98
110
|
sendMessage(request: LLMChatRequest | LLMChatRequestWithPreset, callOptions?: SendMessageOptions): Promise<LLMResponse | LLMFailureResponse>;
|
|
111
|
+
/**
|
|
112
|
+
* Streams a chat message from an LLM provider.
|
|
113
|
+
*
|
|
114
|
+
* The final `complete` event contains the same normalized response shape as
|
|
115
|
+
* sendMessage(). Validation/API key failures and unsupported streaming adapters
|
|
116
|
+
* are yielded as a single `error` event.
|
|
117
|
+
*/
|
|
118
|
+
streamMessage(request: LLMChatRequest | LLMChatRequestWithPreset, callOptions?: StreamMessageOptions): AsyncGenerator<LLMStreamEvent>;
|
|
119
|
+
private prepareRequest;
|
|
120
|
+
private postProcessResponse;
|
|
99
121
|
/**
|
|
100
122
|
* Gets all configured model presets
|
|
101
123
|
*
|
package/dist/llm/LLMService.js
CHANGED
|
@@ -85,116 +85,23 @@ class LLMService {
|
|
|
85
85
|
async sendMessage(request, callOptions) {
|
|
86
86
|
this.logger.info(`LLMService.sendMessage called with presetId: ${request.presetId}, provider: ${request.providerId}, model: ${request.modelId}`);
|
|
87
87
|
try {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return resolved.error;
|
|
92
|
-
}
|
|
93
|
-
const { providerId, modelId, modelInfo, settings: resolvedSettings } = resolved;
|
|
94
|
-
// Create a proper LLMChatRequest with resolved values
|
|
95
|
-
const resolvedRequest = {
|
|
96
|
-
...request,
|
|
97
|
-
providerId: providerId,
|
|
98
|
-
modelId: modelId,
|
|
99
|
-
};
|
|
100
|
-
// Validate basic request structure
|
|
101
|
-
const structureValidationResult = this.requestValidator.validateRequestStructure(resolvedRequest);
|
|
102
|
-
if (structureValidationResult) {
|
|
103
|
-
return structureValidationResult;
|
|
104
|
-
}
|
|
105
|
-
// Validate settings if provided
|
|
106
|
-
const combinedSettings = { ...resolvedSettings, ...request.settings };
|
|
107
|
-
if (combinedSettings) {
|
|
108
|
-
const settingsValidation = this.requestValidator.validateSettings(combinedSettings, providerId, modelId);
|
|
109
|
-
if (settingsValidation) {
|
|
110
|
-
return settingsValidation;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
// Apply model-specific defaults and merge with user settings
|
|
114
|
-
const finalSettings = this.settingsManager.mergeSettingsForModel(modelId, providerId, combinedSettings, modelInfo);
|
|
115
|
-
// Validate reasoning settings for model capabilities
|
|
116
|
-
const reasoningValidation = this.requestValidator.validateReasoningSettings(modelInfo, finalSettings.reasoning, resolvedRequest);
|
|
117
|
-
if (reasoningValidation) {
|
|
118
|
-
return reasoningValidation;
|
|
88
|
+
const preparedResult = await this.prepareRequest(request, callOptions);
|
|
89
|
+
if ("error" in preparedResult) {
|
|
90
|
+
return preparedResult.error;
|
|
119
91
|
}
|
|
120
|
-
|
|
121
|
-
const structuredOutputValidation = this.requestValidator.validateStructuredOutputSettings(modelInfo, finalSettings.structuredOutput, resolvedRequest);
|
|
122
|
-
if (structuredOutputValidation) {
|
|
123
|
-
return structuredOutputValidation;
|
|
124
|
-
}
|
|
125
|
-
// Get provider info for parameter filtering
|
|
126
|
-
const providerInfo = (0, config_1.getProviderById)(providerId);
|
|
127
|
-
if (!providerInfo) {
|
|
128
|
-
return {
|
|
129
|
-
provider: providerId,
|
|
130
|
-
model: modelId,
|
|
131
|
-
error: {
|
|
132
|
-
message: `Provider information not found: ${providerId}`,
|
|
133
|
-
code: "PROVIDER_ERROR",
|
|
134
|
-
type: "server_error",
|
|
135
|
-
},
|
|
136
|
-
object: "error",
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
// Filter out unsupported parameters
|
|
140
|
-
const filteredSettings = this.settingsManager.filterUnsupportedParameters(finalSettings, modelInfo, providerInfo);
|
|
141
|
-
const internalRequest = {
|
|
142
|
-
...resolvedRequest,
|
|
143
|
-
settings: filteredSettings,
|
|
144
|
-
};
|
|
145
|
-
this.logger.debug(`Processing LLM request with (potentially filtered) settings:`, {
|
|
146
|
-
provider: providerId,
|
|
147
|
-
model: modelId,
|
|
148
|
-
settings: filteredSettings,
|
|
149
|
-
messageCount: resolvedRequest.messages.length,
|
|
150
|
-
});
|
|
151
|
-
// Get client adapter
|
|
152
|
-
const clientAdapter = this.adapterRegistry.getAdapter(providerId);
|
|
153
|
-
// Use ApiKeyProvider to get the API key and make the request
|
|
92
|
+
const prepared = preparedResult.prepared;
|
|
154
93
|
try {
|
|
155
|
-
|
|
156
|
-
if (!apiKey) {
|
|
157
|
-
return {
|
|
158
|
-
provider: providerId,
|
|
159
|
-
model: modelId,
|
|
160
|
-
error: {
|
|
161
|
-
message: `API key for provider '${providerId}' could not be retrieved. Ensure your ApiKeyProvider is configured correctly.`,
|
|
162
|
-
code: "API_KEY_ERROR",
|
|
163
|
-
type: "authentication_error",
|
|
164
|
-
},
|
|
165
|
-
object: "error",
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
// Validate API key format if adapter supports it
|
|
169
|
-
if (clientAdapter.validateApiKey && !clientAdapter.validateApiKey(apiKey)) {
|
|
170
|
-
return {
|
|
171
|
-
provider: providerId,
|
|
172
|
-
model: modelId,
|
|
173
|
-
error: {
|
|
174
|
-
message: `Invalid API key format for provider '${providerId}'. Please check your API key.`,
|
|
175
|
-
code: "INVALID_API_KEY",
|
|
176
|
-
type: "authentication_error",
|
|
177
|
-
},
|
|
178
|
-
object: "error",
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
this.logger.info(`Making LLM request with ${clientAdapter.constructor.name} for provider: ${providerId}`);
|
|
94
|
+
this.logger.info(`Making LLM request with ${prepared.clientAdapter.constructor.name} for provider: ${prepared.providerId}`);
|
|
182
95
|
// Unified retry layer: adapters never throw, so retry decisions are made on
|
|
183
96
|
// RETURNED failure responses. SDK-internal retries are disabled (maxRetries: 0
|
|
184
97
|
// at client construction) — this loop is the single owner of retry behavior.
|
|
185
|
-
const adapterOptions = {
|
|
186
|
-
...(callOptions?.signal && { signal: callOptions.signal }),
|
|
187
|
-
...((callOptions?.timeoutMs ?? this.defaultTimeoutMs) !== undefined && {
|
|
188
|
-
timeoutMs: callOptions?.timeoutMs ?? this.defaultTimeoutMs,
|
|
189
|
-
}),
|
|
190
|
-
};
|
|
191
98
|
const retryOnTimeout = this.retryOptions?.retryOnTimeout ?? true;
|
|
192
99
|
const retryableCodes = new Set([
|
|
193
100
|
types_1.ADAPTER_ERROR_CODES.RATE_LIMIT_EXCEEDED,
|
|
194
101
|
types_1.ADAPTER_ERROR_CODES.NETWORK_ERROR,
|
|
195
102
|
...(retryOnTimeout ? [types_1.ADAPTER_ERROR_CODES.REQUEST_TIMEOUT] : []),
|
|
196
103
|
]);
|
|
197
|
-
const result = await (0, withRetry_1.withRetry)(() => clientAdapter.sendMessage(internalRequest, apiKey, adapterOptions), (res) => {
|
|
104
|
+
const result = await (0, withRetry_1.withRetry)(() => prepared.clientAdapter.sendMessage(prepared.internalRequest, prepared.apiKey, prepared.adapterOptions), (res) => {
|
|
198
105
|
if (res.object !== 'error') {
|
|
199
106
|
return { retry: false };
|
|
200
107
|
}
|
|
@@ -212,99 +119,19 @@ class LLMService {
|
|
|
212
119
|
}),
|
|
213
120
|
signal: callOptions?.signal,
|
|
214
121
|
logger: this.logger,
|
|
215
|
-
label: `${providerId}/${modelId}`,
|
|
122
|
+
label: `${prepared.providerId}/${prepared.modelId}`,
|
|
216
123
|
});
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const fallbackSettings = internalRequest.settings.thinkingTagFallback;
|
|
221
|
-
if (result.object === 'chat.completion' && fallbackSettings && fallbackSettings.enabled !== false) {
|
|
222
|
-
const tagName = fallbackSettings.tagName || 'thinking';
|
|
223
|
-
// Check if native reasoning is active for this request
|
|
224
|
-
const isNativeReasoningActive = modelInfo.reasoning?.supported === true &&
|
|
225
|
-
(internalRequest.settings.reasoning?.enabled === true ||
|
|
226
|
-
(modelInfo.reasoning?.enabledByDefault === true &&
|
|
227
|
-
internalRequest.settings.reasoning?.enabled !== false) ||
|
|
228
|
-
modelInfo.reasoning?.canDisable === false);
|
|
229
|
-
// Process the response - extract thinking tags if present
|
|
230
|
-
const choice = result.choices[0];
|
|
231
|
-
if (choice?.message?.content) {
|
|
232
|
-
const { extracted, remaining } = (0, parser_1.extractInitialTaggedContent)(choice.message.content, tagName);
|
|
233
|
-
if (extracted !== null) {
|
|
234
|
-
// Success: thinking tag found
|
|
235
|
-
this.logger.debug(`Extracted <${tagName}> block from response.`);
|
|
236
|
-
// Handle the edge case: append to existing reasoning if present (e.g., native reasoning + thinking tags)
|
|
237
|
-
const existingReasoning = choice.reasoning || '';
|
|
238
|
-
if (existingReasoning) {
|
|
239
|
-
// Use a neutral markdown header that works for any consumer (human or AI)
|
|
240
|
-
choice.reasoning = `${existingReasoning}\n\n#### Additional Reasoning\n\n${extracted}`;
|
|
241
|
-
}
|
|
242
|
-
else {
|
|
243
|
-
// No existing reasoning, just use the extracted content directly
|
|
244
|
-
choice.reasoning = extracted;
|
|
245
|
-
}
|
|
246
|
-
choice.message.content = remaining;
|
|
247
|
-
}
|
|
248
|
-
else {
|
|
249
|
-
// Tag was not found
|
|
250
|
-
// Enforce only if: (1) enforce: true AND (2) native reasoning is NOT active
|
|
251
|
-
if (fallbackSettings.enforce === true && !isNativeReasoningActive) {
|
|
252
|
-
const nativeReasoningCapable = modelInfo.reasoning?.supported === true;
|
|
253
|
-
return {
|
|
254
|
-
provider: providerId,
|
|
255
|
-
model: modelId,
|
|
256
|
-
error: {
|
|
257
|
-
message: `Model response missing required <${tagName}> tags.`,
|
|
258
|
-
code: "THINKING_TAGS_MISSING",
|
|
259
|
-
type: "validation_error",
|
|
260
|
-
param: nativeReasoningCapable && !isNativeReasoningActive
|
|
261
|
-
? `You disabled native reasoning for this model (${modelId}). ` +
|
|
262
|
-
`To see its reasoning, you must prompt it to use <${tagName}> tags. ` +
|
|
263
|
-
`Example: "Write your step-by-step reasoning in <${tagName}> tags before answering."`
|
|
264
|
-
: `This model (${modelId}) does not support native reasoning. ` +
|
|
265
|
-
`To get reasoning, you must prompt it to use <${tagName}> tags. ` +
|
|
266
|
-
`Example: "Write your step-by-step reasoning in <${tagName}> tags before answering."`,
|
|
267
|
-
},
|
|
268
|
-
object: "error",
|
|
269
|
-
partialResponse: {
|
|
270
|
-
id: result.id,
|
|
271
|
-
provider: result.provider,
|
|
272
|
-
model: result.model,
|
|
273
|
-
created: result.created,
|
|
274
|
-
choices: result.choices,
|
|
275
|
-
usage: result.usage
|
|
276
|
-
}
|
|
277
|
-
};
|
|
278
|
-
}
|
|
279
|
-
// If enforce: false or native reasoning is active, do nothing
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
// Post-process for structured output auto-parsing
|
|
284
|
-
// Parse JSON response when structuredOutput is enabled and autoParse is not disabled
|
|
285
|
-
const structuredOutputSettings = internalRequest.settings.structuredOutput;
|
|
286
|
-
if (result.object === 'chat.completion' && structuredOutputSettings &&
|
|
287
|
-
structuredOutputSettings.enabled !== false && structuredOutputSettings.autoParse !== false) {
|
|
288
|
-
for (const choice of result.choices) {
|
|
289
|
-
if (choice.message?.content) {
|
|
290
|
-
try {
|
|
291
|
-
choice.parsedContent = JSON.parse(choice.message.content);
|
|
292
|
-
}
|
|
293
|
-
catch (e) {
|
|
294
|
-
choice.parseError = `JSON parse failed: ${e instanceof Error ? e.message : String(e)}`;
|
|
295
|
-
this.logger.warn(`Failed to parse structured output for choice ${choice.index}: ${choice.parseError}`);
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
}
|
|
124
|
+
const processedResult = this.postProcessResponse(result, prepared);
|
|
125
|
+
if (processedResult.object === "chat.completion") {
|
|
126
|
+
this.logger.info(`LLM request completed successfully for model: ${prepared.modelId}`);
|
|
299
127
|
}
|
|
300
|
-
|
|
301
|
-
return result;
|
|
128
|
+
return processedResult;
|
|
302
129
|
}
|
|
303
130
|
catch (error) {
|
|
304
131
|
this.logger.error("Error in LLMService.sendMessage:", error);
|
|
305
132
|
return {
|
|
306
|
-
provider: providerId,
|
|
307
|
-
model: modelId,
|
|
133
|
+
provider: prepared.providerId,
|
|
134
|
+
model: prepared.modelId,
|
|
308
135
|
error: {
|
|
309
136
|
message: error instanceof Error
|
|
310
137
|
? error.message
|
|
@@ -334,6 +161,319 @@ class LLMService {
|
|
|
334
161
|
};
|
|
335
162
|
}
|
|
336
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* Streams a chat message from an LLM provider.
|
|
166
|
+
*
|
|
167
|
+
* The final `complete` event contains the same normalized response shape as
|
|
168
|
+
* sendMessage(). Validation/API key failures and unsupported streaming adapters
|
|
169
|
+
* are yielded as a single `error` event.
|
|
170
|
+
*/
|
|
171
|
+
async *streamMessage(request, callOptions) {
|
|
172
|
+
this.logger.info(`LLMService.streamMessage called with presetId: ${request.presetId}, provider: ${request.providerId}, model: ${request.modelId}`);
|
|
173
|
+
try {
|
|
174
|
+
const preparedResult = await this.prepareRequest(request, callOptions);
|
|
175
|
+
if ("error" in preparedResult) {
|
|
176
|
+
yield { type: "error", error: preparedResult.error };
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const prepared = preparedResult.prepared;
|
|
180
|
+
if (!prepared.clientAdapter.streamMessage) {
|
|
181
|
+
yield {
|
|
182
|
+
type: "error",
|
|
183
|
+
error: {
|
|
184
|
+
provider: prepared.providerId,
|
|
185
|
+
model: prepared.modelId,
|
|
186
|
+
error: {
|
|
187
|
+
message: `Streaming is not supported for provider '${prepared.providerId}'.`,
|
|
188
|
+
code: "PROVIDER_ERROR",
|
|
189
|
+
type: "unsupported_feature",
|
|
190
|
+
},
|
|
191
|
+
object: "error",
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
for await (const event of prepared.clientAdapter.streamMessage(prepared.internalRequest, prepared.apiKey, prepared.adapterOptions)) {
|
|
198
|
+
if (event.type === "complete") {
|
|
199
|
+
const processedResult = this.postProcessResponse(event.response, prepared);
|
|
200
|
+
if (processedResult.object === "error") {
|
|
201
|
+
yield { type: "error", error: processedResult };
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
yield { type: "complete", response: processedResult };
|
|
205
|
+
}
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
yield event;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
this.logger.error("Error in LLMService.streamMessage:", error);
|
|
213
|
+
yield {
|
|
214
|
+
type: "error",
|
|
215
|
+
error: {
|
|
216
|
+
provider: prepared.providerId,
|
|
217
|
+
model: prepared.modelId,
|
|
218
|
+
error: {
|
|
219
|
+
message: error instanceof Error
|
|
220
|
+
? error.message
|
|
221
|
+
: "An unknown error occurred during message streaming.",
|
|
222
|
+
code: "PROVIDER_ERROR",
|
|
223
|
+
type: "server_error",
|
|
224
|
+
providerError: error,
|
|
225
|
+
},
|
|
226
|
+
object: "error",
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
this.logger.error("Error in LLMService.streamMessage (outer):", error);
|
|
233
|
+
yield {
|
|
234
|
+
type: "error",
|
|
235
|
+
error: {
|
|
236
|
+
provider: request.providerId || request.presetId || 'unknown',
|
|
237
|
+
model: request.modelId || request.presetId || 'unknown',
|
|
238
|
+
error: {
|
|
239
|
+
message: error instanceof Error
|
|
240
|
+
? error.message
|
|
241
|
+
: "An unknown error occurred.",
|
|
242
|
+
code: "UNEXPECTED_ERROR",
|
|
243
|
+
type: "server_error",
|
|
244
|
+
providerError: error,
|
|
245
|
+
},
|
|
246
|
+
object: "error",
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
async prepareRequest(request, callOptions) {
|
|
252
|
+
// Resolve model information from preset or direct IDs
|
|
253
|
+
const resolved = await this.modelResolver.resolve(request);
|
|
254
|
+
if (resolved.error) {
|
|
255
|
+
return { error: resolved.error };
|
|
256
|
+
}
|
|
257
|
+
const { providerId, modelId, modelInfo, settings: resolvedSettings } = resolved;
|
|
258
|
+
// Create a proper LLMChatRequest with resolved values
|
|
259
|
+
const resolvedRequest = {
|
|
260
|
+
...request,
|
|
261
|
+
providerId: providerId,
|
|
262
|
+
modelId: modelId,
|
|
263
|
+
};
|
|
264
|
+
// Validate basic request structure
|
|
265
|
+
const structureValidationResult = this.requestValidator.validateRequestStructure(resolvedRequest);
|
|
266
|
+
if (structureValidationResult) {
|
|
267
|
+
return { error: structureValidationResult };
|
|
268
|
+
}
|
|
269
|
+
// Validate settings if provided
|
|
270
|
+
const combinedSettings = { ...resolvedSettings, ...request.settings };
|
|
271
|
+
if (combinedSettings) {
|
|
272
|
+
const settingsValidation = this.requestValidator.validateSettings(combinedSettings, providerId, modelId);
|
|
273
|
+
if (settingsValidation) {
|
|
274
|
+
return { error: settingsValidation };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// Apply model-specific defaults and merge with user settings
|
|
278
|
+
const finalSettings = this.settingsManager.mergeSettingsForModel(modelId, providerId, combinedSettings, modelInfo);
|
|
279
|
+
// Validate reasoning settings for model capabilities
|
|
280
|
+
const reasoningValidation = this.requestValidator.validateReasoningSettings(modelInfo, finalSettings.reasoning, resolvedRequest);
|
|
281
|
+
if (reasoningValidation) {
|
|
282
|
+
return { error: reasoningValidation };
|
|
283
|
+
}
|
|
284
|
+
// Validate structured output settings for model capabilities
|
|
285
|
+
const structuredOutputValidation = this.requestValidator.validateStructuredOutputSettings(modelInfo, finalSettings.structuredOutput, resolvedRequest);
|
|
286
|
+
if (structuredOutputValidation) {
|
|
287
|
+
return { error: structuredOutputValidation };
|
|
288
|
+
}
|
|
289
|
+
// Get provider info for parameter filtering
|
|
290
|
+
const providerInfo = (0, config_1.getProviderById)(providerId);
|
|
291
|
+
if (!providerInfo) {
|
|
292
|
+
return {
|
|
293
|
+
error: {
|
|
294
|
+
provider: providerId,
|
|
295
|
+
model: modelId,
|
|
296
|
+
error: {
|
|
297
|
+
message: `Provider information not found: ${providerId}`,
|
|
298
|
+
code: "PROVIDER_ERROR",
|
|
299
|
+
type: "server_error",
|
|
300
|
+
},
|
|
301
|
+
object: "error",
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
// Filter out unsupported parameters
|
|
306
|
+
const filteredSettings = this.settingsManager.filterUnsupportedParameters(finalSettings, modelInfo, providerInfo);
|
|
307
|
+
const internalRequest = {
|
|
308
|
+
...resolvedRequest,
|
|
309
|
+
settings: filteredSettings,
|
|
310
|
+
};
|
|
311
|
+
this.logger.debug(`Processing LLM request with (potentially filtered) settings:`, {
|
|
312
|
+
provider: providerId,
|
|
313
|
+
model: modelId,
|
|
314
|
+
settings: filteredSettings,
|
|
315
|
+
messageCount: resolvedRequest.messages.length,
|
|
316
|
+
});
|
|
317
|
+
// Get client adapter
|
|
318
|
+
const clientAdapter = this.adapterRegistry.getAdapter(providerId);
|
|
319
|
+
try {
|
|
320
|
+
const apiKey = await this.getApiKey(providerId);
|
|
321
|
+
if (!apiKey) {
|
|
322
|
+
return {
|
|
323
|
+
error: {
|
|
324
|
+
provider: providerId,
|
|
325
|
+
model: modelId,
|
|
326
|
+
error: {
|
|
327
|
+
message: `API key for provider '${providerId}' could not be retrieved. Ensure your ApiKeyProvider is configured correctly.`,
|
|
328
|
+
code: "API_KEY_ERROR",
|
|
329
|
+
type: "authentication_error",
|
|
330
|
+
},
|
|
331
|
+
object: "error",
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
// Validate API key format if adapter supports it
|
|
336
|
+
if (clientAdapter.validateApiKey && !clientAdapter.validateApiKey(apiKey)) {
|
|
337
|
+
return {
|
|
338
|
+
error: {
|
|
339
|
+
provider: providerId,
|
|
340
|
+
model: modelId,
|
|
341
|
+
error: {
|
|
342
|
+
message: `Invalid API key format for provider '${providerId}'. Please check your API key.`,
|
|
343
|
+
code: "INVALID_API_KEY",
|
|
344
|
+
type: "authentication_error",
|
|
345
|
+
},
|
|
346
|
+
object: "error",
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
const adapterOptions = {
|
|
351
|
+
...(callOptions?.signal && { signal: callOptions.signal }),
|
|
352
|
+
...((callOptions?.timeoutMs ?? this.defaultTimeoutMs) !== undefined && {
|
|
353
|
+
timeoutMs: callOptions?.timeoutMs ?? this.defaultTimeoutMs,
|
|
354
|
+
}),
|
|
355
|
+
};
|
|
356
|
+
return {
|
|
357
|
+
prepared: {
|
|
358
|
+
providerId: providerId,
|
|
359
|
+
modelId: modelId,
|
|
360
|
+
modelInfo: modelInfo,
|
|
361
|
+
resolvedRequest,
|
|
362
|
+
internalRequest,
|
|
363
|
+
clientAdapter,
|
|
364
|
+
apiKey,
|
|
365
|
+
adapterOptions,
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
this.logger.error("Error preparing LLM request:", error);
|
|
371
|
+
return {
|
|
372
|
+
error: {
|
|
373
|
+
provider: providerId,
|
|
374
|
+
model: modelId,
|
|
375
|
+
error: {
|
|
376
|
+
message: error instanceof Error
|
|
377
|
+
? error.message
|
|
378
|
+
: "An unknown error occurred during request preparation.",
|
|
379
|
+
code: "PROVIDER_ERROR",
|
|
380
|
+
type: "server_error",
|
|
381
|
+
providerError: error,
|
|
382
|
+
},
|
|
383
|
+
object: "error",
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
postProcessResponse(result, prepared) {
|
|
389
|
+
if (result.object === "error") {
|
|
390
|
+
return result;
|
|
391
|
+
}
|
|
392
|
+
// Post-process for thinking tag fallback
|
|
393
|
+
// This feature extracts reasoning from XML tags when native reasoning is not active.
|
|
394
|
+
// It's a fallback mechanism for models without native reasoning or when native is disabled.
|
|
395
|
+
const fallbackSettings = prepared.internalRequest.settings.thinkingTagFallback;
|
|
396
|
+
if (fallbackSettings && fallbackSettings.enabled !== false) {
|
|
397
|
+
const tagName = fallbackSettings.tagName || 'thinking';
|
|
398
|
+
// Check if native reasoning is active for this request
|
|
399
|
+
const isNativeReasoningActive = prepared.modelInfo.reasoning?.supported === true &&
|
|
400
|
+
(prepared.internalRequest.settings.reasoning?.enabled === true ||
|
|
401
|
+
(prepared.modelInfo.reasoning?.enabledByDefault === true &&
|
|
402
|
+
prepared.internalRequest.settings.reasoning?.enabled !== false) ||
|
|
403
|
+
prepared.modelInfo.reasoning?.canDisable === false);
|
|
404
|
+
// Process the response - extract thinking tags if present
|
|
405
|
+
const choice = result.choices[0];
|
|
406
|
+
if (choice?.message?.content) {
|
|
407
|
+
const { extracted, remaining } = (0, parser_1.extractInitialTaggedContent)(choice.message.content, tagName);
|
|
408
|
+
if (extracted !== null) {
|
|
409
|
+
// Success: thinking tag found
|
|
410
|
+
this.logger.debug(`Extracted <${tagName}> block from response.`);
|
|
411
|
+
// Handle the edge case: append to existing reasoning if present (e.g., native reasoning + thinking tags)
|
|
412
|
+
const existingReasoning = choice.reasoning || '';
|
|
413
|
+
if (existingReasoning) {
|
|
414
|
+
// Use a neutral markdown header that works for any consumer (human or AI)
|
|
415
|
+
choice.reasoning = `${existingReasoning}\n\n#### Additional Reasoning\n\n${extracted}`;
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
// No existing reasoning, just use the extracted content directly
|
|
419
|
+
choice.reasoning = extracted;
|
|
420
|
+
}
|
|
421
|
+
choice.message.content = remaining;
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
// Tag was not found
|
|
425
|
+
// Enforce only if: (1) enforce: true AND (2) native reasoning is NOT active
|
|
426
|
+
if (fallbackSettings.enforce === true && !isNativeReasoningActive) {
|
|
427
|
+
const nativeReasoningCapable = prepared.modelInfo.reasoning?.supported === true;
|
|
428
|
+
return {
|
|
429
|
+
provider: prepared.providerId,
|
|
430
|
+
model: prepared.modelId,
|
|
431
|
+
error: {
|
|
432
|
+
message: `Model response missing required <${tagName}> tags.`,
|
|
433
|
+
code: "THINKING_TAGS_MISSING",
|
|
434
|
+
type: "validation_error",
|
|
435
|
+
param: nativeReasoningCapable && !isNativeReasoningActive
|
|
436
|
+
? `You disabled native reasoning for this model (${prepared.modelId}). ` +
|
|
437
|
+
`To see its reasoning, you must prompt it to use <${tagName}> tags. ` +
|
|
438
|
+
`Example: "Write your step-by-step reasoning in <${tagName}> tags before answering."`
|
|
439
|
+
: `This model (${prepared.modelId}) does not support native reasoning. ` +
|
|
440
|
+
`To get reasoning, you must prompt it to use <${tagName}> tags. ` +
|
|
441
|
+
`Example: "Write your step-by-step reasoning in <${tagName}> tags before answering."`,
|
|
442
|
+
},
|
|
443
|
+
object: "error",
|
|
444
|
+
partialResponse: {
|
|
445
|
+
id: result.id,
|
|
446
|
+
provider: result.provider,
|
|
447
|
+
model: result.model,
|
|
448
|
+
created: result.created,
|
|
449
|
+
choices: result.choices,
|
|
450
|
+
usage: result.usage
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
// If enforce: false or native reasoning is active, do nothing
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
// Post-process for structured output auto-parsing
|
|
459
|
+
// Parse JSON response when structuredOutput is enabled and autoParse is not disabled
|
|
460
|
+
const structuredOutputSettings = prepared.internalRequest.settings.structuredOutput;
|
|
461
|
+
if (structuredOutputSettings &&
|
|
462
|
+
structuredOutputSettings.enabled !== false && structuredOutputSettings.autoParse !== false) {
|
|
463
|
+
for (const choice of result.choices) {
|
|
464
|
+
if (choice.message?.content) {
|
|
465
|
+
try {
|
|
466
|
+
choice.parsedContent = JSON.parse(choice.message.content);
|
|
467
|
+
}
|
|
468
|
+
catch (e) {
|
|
469
|
+
choice.parseError = `JSON parse failed: ${e instanceof Error ? e.message : String(e)}`;
|
|
470
|
+
this.logger.warn(`Failed to parse structured output for choice ${choice.index}: ${choice.parseError}`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return result;
|
|
476
|
+
}
|
|
337
477
|
/**
|
|
338
478
|
* Gets all configured model presets
|
|
339
479
|
*
|