genai-lite 0.8.3 → 0.9.1

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.
Files changed (43) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +210 -207
  3. package/dist/config/llm-presets.json +24 -0
  4. package/dist/index.d.ts +4 -2
  5. package/dist/index.js +6 -1
  6. package/dist/llm/LLMService.d.ts +30 -1
  7. package/dist/llm/LLMService.js +42 -4
  8. package/dist/llm/clients/AnthropicClientAdapter.d.ts +2 -2
  9. package/dist/llm/clients/AnthropicClientAdapter.js +17 -3
  10. package/dist/llm/clients/GeminiClientAdapter.d.ts +2 -2
  11. package/dist/llm/clients/GeminiClientAdapter.js +14 -3
  12. package/dist/llm/clients/LlamaCppClientAdapter.d.ts +4 -4
  13. package/dist/llm/clients/LlamaCppClientAdapter.js +118 -15
  14. package/dist/llm/clients/MistralClientAdapter.d.ts +2 -2
  15. package/dist/llm/clients/MistralClientAdapter.js +15 -4
  16. package/dist/llm/clients/MockClientAdapter.d.ts +2 -2
  17. package/dist/llm/clients/MockClientAdapter.js +36 -19
  18. package/dist/llm/clients/OpenAIClientAdapter.d.ts +2 -2
  19. package/dist/llm/clients/OpenAIClientAdapter.js +26 -3
  20. package/dist/llm/clients/OpenRouterClientAdapter.d.ts +2 -2
  21. package/dist/llm/clients/OpenRouterClientAdapter.js +81 -10
  22. package/dist/llm/clients/types.d.ts +18 -1
  23. package/dist/llm/clients/types.js +4 -0
  24. package/dist/llm/config.d.ts +9 -2
  25. package/dist/llm/config.js +695 -33
  26. package/dist/llm/services/ModelResolver.d.ts +6 -0
  27. package/dist/llm/services/ModelResolver.js +57 -17
  28. package/dist/llm/services/SettingsManager.d.ts +1 -1
  29. package/dist/llm/services/SettingsManager.js +92 -4
  30. package/dist/llm/types.d.ts +127 -0
  31. package/dist/prompting/index.d.ts +1 -1
  32. package/dist/prompting/index.js +2 -1
  33. package/dist/prompting/parser.d.ts +23 -0
  34. package/dist/prompting/parser.js +33 -0
  35. package/dist/shared/adapters/errorUtils.d.ts +15 -0
  36. package/dist/shared/adapters/errorUtils.js +69 -1
  37. package/dist/shared/adapters/logprobsUtils.d.ts +13 -0
  38. package/dist/shared/adapters/logprobsUtils.js +33 -0
  39. package/dist/shared/services/withRetry.d.ts +50 -0
  40. package/dist/shared/services/withRetry.js +78 -0
  41. package/package.json +59 -59
  42. package/src/config/image-presets.json +212 -212
  43. package/src/config/llm-presets.json +623 -599
@@ -18,6 +18,8 @@ const AdapterRegistry_1 = require("../shared/services/AdapterRegistry");
18
18
  const RequestValidator_1 = require("./services/RequestValidator");
19
19
  const SettingsManager_1 = require("./services/SettingsManager");
20
20
  const ModelResolver_1 = require("./services/ModelResolver");
21
+ const withRetry_1 = require("../shared/services/withRetry");
22
+ const types_1 = require("./clients/types");
21
23
  /**
22
24
  * Main process service for LLM operations
23
25
  *
@@ -32,6 +34,8 @@ const ModelResolver_1 = require("./services/ModelResolver");
32
34
  class LLMService {
33
35
  constructor(getApiKey, options = {}) {
34
36
  this.getApiKey = getApiKey;
37
+ this.retryOptions = options.retry;
38
+ this.defaultTimeoutMs = options.timeoutMs;
35
39
  // Initialize logger - custom logger takes precedence over logLevel
36
40
  this.logger = options.logger ?? (0, defaultLogger_1.createDefaultLogger)(options.logLevel);
37
41
  // Initialize services with logger
@@ -78,7 +82,7 @@ class LLMService {
78
82
  * @param request - The LLM chat request
79
83
  * @returns Promise resolving to either success or failure response
80
84
  */
81
- async sendMessage(request) {
85
+ async sendMessage(request, callOptions) {
82
86
  this.logger.info(`LLMService.sendMessage called with presetId: ${request.presetId}, provider: ${request.providerId}, model: ${request.modelId}`);
83
87
  try {
84
88
  // Resolve model information from preset or direct IDs
@@ -107,7 +111,7 @@ class LLMService {
107
111
  }
108
112
  }
109
113
  // Apply model-specific defaults and merge with user settings
110
- const finalSettings = this.settingsManager.mergeSettingsForModel(modelId, providerId, combinedSettings);
114
+ const finalSettings = this.settingsManager.mergeSettingsForModel(modelId, providerId, combinedSettings, modelInfo);
111
115
  // Validate reasoning settings for model capabilities
112
116
  const reasoningValidation = this.requestValidator.validateReasoningSettings(modelInfo, finalSettings.reasoning, resolvedRequest);
113
117
  if (reasoningValidation) {
@@ -175,7 +179,41 @@ class LLMService {
175
179
  };
176
180
  }
177
181
  this.logger.info(`Making LLM request with ${clientAdapter.constructor.name} for provider: ${providerId}`);
178
- const result = await clientAdapter.sendMessage(internalRequest, apiKey);
182
+ // Unified retry layer: adapters never throw, so retry decisions are made on
183
+ // RETURNED failure responses. SDK-internal retries are disabled (maxRetries: 0
184
+ // 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
+ const retryOnTimeout = this.retryOptions?.retryOnTimeout ?? true;
192
+ const retryableCodes = new Set([
193
+ types_1.ADAPTER_ERROR_CODES.RATE_LIMIT_EXCEEDED,
194
+ types_1.ADAPTER_ERROR_CODES.NETWORK_ERROR,
195
+ ...(retryOnTimeout ? [types_1.ADAPTER_ERROR_CODES.REQUEST_TIMEOUT] : []),
196
+ ]);
197
+ const result = await (0, withRetry_1.withRetry)(() => clientAdapter.sendMessage(internalRequest, apiKey, adapterOptions), (res) => {
198
+ if (res.object !== 'error') {
199
+ return { retry: false };
200
+ }
201
+ const code = String(res.error.code);
202
+ const status = res.error.status;
203
+ const retryable = retryableCodes.has(code) ||
204
+ (code === types_1.ADAPTER_ERROR_CODES.PROVIDER_ERROR &&
205
+ typeof status === 'number' &&
206
+ (status === 408 || status === 409 || status >= 500));
207
+ return { retry: retryable, retryAfterMs: res.error.retryAfterMs };
208
+ }, {
209
+ ...this.retryOptions,
210
+ ...(callOptions?.maxRetries !== undefined && {
211
+ maxRetries: callOptions.maxRetries,
212
+ }),
213
+ signal: callOptions?.signal,
214
+ logger: this.logger,
215
+ label: `${providerId}/${modelId}`,
216
+ });
179
217
  // Post-process for thinking tag fallback
180
218
  // This feature extracts reasoning from XML tags when native reasoning is not active.
181
219
  // It's a fallback mechanism for models without native reasoning or when native is disabled.
@@ -375,7 +413,7 @@ class LLMService {
375
413
  else {
376
414
  const { providerId, modelId, modelInfo, settings } = resolved;
377
415
  // Merge settings with model defaults
378
- const mergedSettings = this.settingsManager.mergeSettingsForModel(modelId, providerId, settings || {});
416
+ const mergedSettings = this.settingsManager.mergeSettingsForModel(modelId, providerId, settings || {}, modelInfo);
379
417
  // Calculate native reasoning status
380
418
  const nativeReasoningActive = !!(modelInfo.reasoning?.supported &&
381
419
  (mergedSettings.reasoning?.enabled === true ||
@@ -1,5 +1,5 @@
1
1
  import type { LLMResponse, LLMFailureResponse } from "../types";
2
- import type { ILLMClientAdapter, InternalLLMChatRequest } from "./types";
2
+ import type { ILLMClientAdapter, InternalLLMChatRequest, AdapterRequestOptions } from "./types";
3
3
  import type { Logger } from "../../logging/types";
4
4
  /**
5
5
  * Client adapter for Anthropic API integration
@@ -32,7 +32,7 @@ export declare class AnthropicClientAdapter implements ILLMClientAdapter {
32
32
  * @param apiKey - The decrypted Anthropic API key
33
33
  * @returns Promise resolving to success or failure response
34
34
  */
35
- sendMessage(request: InternalLLMChatRequest, apiKey: string): Promise<LLMResponse | LLMFailureResponse>;
35
+ sendMessage(request: InternalLLMChatRequest, apiKey: string, options?: AdapterRequestOptions): Promise<LLMResponse | LLMFailureResponse>;
36
36
  /**
37
37
  * Validates Anthropic API key format
38
38
  *
@@ -40,7 +40,7 @@ class AnthropicClientAdapter {
40
40
  * @param apiKey - The decrypted Anthropic API key
41
41
  * @returns Promise resolving to success or failure response
42
42
  */
43
- async sendMessage(request, apiKey) {
43
+ async sendMessage(request, apiKey, options) {
44
44
  try {
45
45
  // Check if structured output is requested - need beta API
46
46
  const useStructuredOutput = request.settings.structuredOutput?.schema &&
@@ -49,7 +49,13 @@ class AnthropicClientAdapter {
49
49
  const anthropic = new sdk_1.default({
50
50
  apiKey,
51
51
  ...(this.baseURL && { baseURL: this.baseURL }),
52
+ maxRetries: 0, // retries are owned by the unified LLMService retry layer
52
53
  });
54
+ // Per-request transport options (abort signal, timeout)
55
+ const requestTransportOptions = {
56
+ ...(options?.signal && { signal: options.signal }),
57
+ ...(options?.timeoutMs !== undefined && { timeout: options.timeoutMs }),
58
+ };
53
59
  // Format messages for Anthropic API (Claude has specific requirements)
54
60
  const { messages, systemMessage } = this.formatMessagesForAnthropic(request);
55
61
  // Prepare API call parameters
@@ -59,6 +65,9 @@ class AnthropicClientAdapter {
59
65
  max_tokens: request.settings.maxTokens,
60
66
  temperature: request.settings.temperature,
61
67
  top_p: request.settings.topP,
68
+ ...(request.settings.topK !== undefined && {
69
+ top_k: request.settings.topK,
70
+ }),
62
71
  ...(systemMessage && { system: systemMessage }),
63
72
  ...(request.settings.stopSequences.length > 0 && {
64
73
  stop_sequences: request.settings.stopSequences,
@@ -135,10 +144,14 @@ class AnthropicClientAdapter {
135
144
  headers: {
136
145
  'anthropic-beta': 'structured-outputs-2025-11-13',
137
146
  },
147
+ ...requestTransportOptions,
138
148
  });
139
149
  }
140
150
  else {
141
- completion = await anthropic.messages.create(messageParams);
151
+ completion =
152
+ Object.keys(requestTransportOptions).length > 0
153
+ ? await anthropic.messages.create(messageParams, requestTransportOptions)
154
+ : await anthropic.messages.create(messageParams);
142
155
  }
143
156
  this.logger.info(`Anthropic API call successful, response ID: ${completion.id}`);
144
157
  // Convert to standardized response format
@@ -387,7 +400,7 @@ class AnthropicClientAdapter {
387
400
  createErrorResponse(error, request) {
388
401
  // Use shared error mapping utility for common error patterns
389
402
  const initialProviderMessage = error instanceof sdk_1.default.APIError ? error.message : undefined;
390
- let { errorCode, errorMessage, errorType, status } = (0, errorUtils_1.getCommonMappedErrorDetails)(error, initialProviderMessage);
403
+ let { errorCode, errorMessage, errorType, status, retryAfterMs } = (0, errorUtils_1.getCommonMappedErrorDetails)(error, initialProviderMessage);
391
404
  // Apply Anthropic-specific refinements for 400 errors based on message content
392
405
  if (error instanceof sdk_1.default.APIError && status === 400) {
393
406
  if (error.message.toLowerCase().includes("context length") ||
@@ -409,6 +422,7 @@ class AnthropicClientAdapter {
409
422
  code: errorCode,
410
423
  type: errorType,
411
424
  ...(status && { status }),
425
+ ...(retryAfterMs !== undefined && { retryAfterMs }),
412
426
  providerError: error,
413
427
  },
414
428
  object: "error",
@@ -1,5 +1,5 @@
1
1
  import type { LLMResponse, LLMFailureResponse } from "../types";
2
- import type { ILLMClientAdapter, InternalLLMChatRequest } from "./types";
2
+ import type { ILLMClientAdapter, InternalLLMChatRequest, AdapterRequestOptions } from "./types";
3
3
  import type { Logger } from "../../logging/types";
4
4
  /**
5
5
  * Client adapter for Google Gemini API integration
@@ -32,7 +32,7 @@ export declare class GeminiClientAdapter implements ILLMClientAdapter {
32
32
  * @param apiKey - The decrypted Gemini API key
33
33
  * @returns Promise resolving to success or failure response
34
34
  */
35
- sendMessage(request: InternalLLMChatRequest, apiKey: string): Promise<LLMResponse | LLMFailureResponse>;
35
+ sendMessage(request: InternalLLMChatRequest, apiKey: string, options?: AdapterRequestOptions): Promise<LLMResponse | LLMFailureResponse>;
36
36
  /**
37
37
  * Validates Gemini API key format
38
38
  *
@@ -37,9 +37,9 @@ class GeminiClientAdapter {
37
37
  * @param apiKey - The decrypted Gemini API key
38
38
  * @returns Promise resolving to success or failure response
39
39
  */
40
- async sendMessage(request, apiKey) {
40
+ async sendMessage(request, apiKey, options) {
41
41
  try {
42
- // Initialize Gemini client
42
+ // Initialize Gemini client (note: @google/genai performs no automatic retries)
43
43
  const genAI = new genai_1.GoogleGenAI({ apiKey });
44
44
  // Format the request for Gemini API
45
45
  const { contents, generationConfig, safetySettings, systemInstruction } = this.formatInternalRequestToGemini(request);
@@ -60,6 +60,10 @@ class GeminiClientAdapter {
60
60
  ...generationConfig,
61
61
  safetySettings: safetySettings,
62
62
  ...(systemInstruction && { systemInstruction: systemInstruction }),
63
+ ...(options?.signal && { abortSignal: options.signal }),
64
+ ...(options?.timeoutMs !== undefined && {
65
+ httpOptions: { timeout: options.timeoutMs },
66
+ }),
63
67
  },
64
68
  });
65
69
  this.logger.info(`Gemini API call successful, processing response`);
@@ -153,6 +157,12 @@ class GeminiClientAdapter {
153
157
  maxOutputTokens: request.settings.maxTokens,
154
158
  temperature: request.settings.temperature,
155
159
  ...(request.settings.topP && { topP: request.settings.topP }),
160
+ ...(request.settings.topK !== undefined && {
161
+ topK: request.settings.topK,
162
+ }),
163
+ ...(request.settings.seed !== undefined && {
164
+ seed: request.settings.seed,
165
+ }),
156
166
  ...(request.settings.stopSequences &&
157
167
  request.settings.stopSequences.length > 0 && {
158
168
  stopSequences: request.settings.stopSequences,
@@ -307,7 +317,7 @@ class GeminiClientAdapter {
307
317
  createErrorResponse(error, request) {
308
318
  // Use shared error mapping utility for common error patterns
309
319
  const initialProviderMessage = error?.message;
310
- let { errorCode, errorMessage, errorType, status } = (0, errorUtils_1.getCommonMappedErrorDetails)(error, initialProviderMessage);
320
+ let { errorCode, errorMessage, errorType, status, retryAfterMs } = (0, errorUtils_1.getCommonMappedErrorDetails)(error, initialProviderMessage);
311
321
  // Apply Gemini-specific refinements for certain error types
312
322
  if (error && error.message) {
313
323
  const message = error.message.toLowerCase();
@@ -337,6 +347,7 @@ class GeminiClientAdapter {
337
347
  code: errorCode,
338
348
  type: errorType,
339
349
  ...(status && { status }),
350
+ ...(retryAfterMs !== undefined && { retryAfterMs }),
340
351
  providerError: error,
341
352
  },
342
353
  object: "error",
@@ -1,12 +1,12 @@
1
1
  import type { LLMResponse, LLMFailureResponse, ModelInfo } from "../types";
2
- import type { ILLMClientAdapter, InternalLLMChatRequest } from "./types";
2
+ import type { ILLMClientAdapter, InternalLLMChatRequest, AdapterRequestOptions } from "./types";
3
3
  import { LlamaCppServerClient } from "./LlamaCppServerClient";
4
4
  import type { Logger } from "../../logging/types";
5
5
  /**
6
6
  * Configuration options for LlamaCppClientAdapter
7
7
  */
8
8
  export interface LlamaCppClientConfig {
9
- /** Base URL of the llama.cpp server (default: http://localhost:8080) */
9
+ /** Base URL of the llama.cpp server (default: http://127.0.0.1:8080) */
10
10
  baseURL?: string;
11
11
  /** Whether to check server health before sending requests (default: false) */
12
12
  checkHealth?: boolean;
@@ -33,7 +33,7 @@ export interface LlamaCppClientConfig {
33
33
  * ```typescript
34
34
  * // Create adapter for local server
35
35
  * const adapter = new LlamaCppClientAdapter({
36
- * baseURL: 'http://localhost:8080',
36
+ * baseURL: 'http://127.0.0.1:8080',
37
37
  * checkHealth: true
38
38
  * });
39
39
  *
@@ -84,7 +84,7 @@ export declare class LlamaCppClientAdapter implements ILLMClientAdapter {
84
84
  * @param apiKey - Not used for llama.cpp (local server), but kept for interface compatibility
85
85
  * @returns Promise resolving to success or failure response
86
86
  */
87
- sendMessage(request: InternalLLMChatRequest, apiKey: string): Promise<LLMResponse | LLMFailureResponse>;
87
+ sendMessage(request: InternalLLMChatRequest, apiKey: string, options?: AdapterRequestOptions): Promise<LLMResponse | LLMFailureResponse>;
88
88
  /**
89
89
  * Validates API key format
90
90
  *
@@ -12,6 +12,8 @@ const errorUtils_1 = require("../../shared/adapters/errorUtils");
12
12
  const systemMessageUtils_1 = require("../../shared/adapters/systemMessageUtils");
13
13
  const LlamaCppServerClient_1 = require("./LlamaCppServerClient");
14
14
  const config_1 = require("../config");
15
+ const parser_1 = require("../../prompting/parser");
16
+ const logprobsUtils_1 = require("../../shared/adapters/logprobsUtils");
15
17
  const defaultLogger_1 = require("../../logging/defaultLogger");
16
18
  /**
17
19
  * Client adapter for llama.cpp server integration
@@ -33,7 +35,7 @@ const defaultLogger_1 = require("../../logging/defaultLogger");
33
35
  * ```typescript
34
36
  * // Create adapter for local server
35
37
  * const adapter = new LlamaCppClientAdapter({
36
- * baseURL: 'http://localhost:8080',
38
+ * baseURL: 'http://127.0.0.1:8080',
37
39
  * checkHealth: true
38
40
  * });
39
41
  *
@@ -57,7 +59,10 @@ class LlamaCppClientAdapter {
57
59
  constructor(config) {
58
60
  this.cachedModelCapabilities = null;
59
61
  this.detectionAttempted = false;
60
- this.baseURL = config?.baseURL || 'http://localhost:8080';
62
+ // 127.0.0.1 rather than localhost: on Windows, localhost resolves to IPv6 (::1)
63
+ // first, and when llama-server listens on IPv4 only, every fresh connection waits
64
+ // ~2s for the IPv6 attempt to time out (~9x per-request slowdown measured).
65
+ this.baseURL = config?.baseURL || 'http://127.0.0.1:8080';
61
66
  this.checkHealth = config?.checkHealth || false;
62
67
  this.serverClient = new LlamaCppServerClient_1.LlamaCppServerClient(this.baseURL);
63
68
  this.logger = config?.logger ?? (0, defaultLogger_1.createDefaultLogger)();
@@ -125,7 +130,7 @@ class LlamaCppClientAdapter {
125
130
  * @param apiKey - Not used for llama.cpp (local server), but kept for interface compatibility
126
131
  * @returns Promise resolving to success or failure response
127
132
  */
128
- async sendMessage(request, apiKey) {
133
+ async sendMessage(request, apiKey, options) {
129
134
  try {
130
135
  // Optional health check before making request
131
136
  if (this.checkHealth) {
@@ -153,6 +158,7 @@ class LlamaCppClientAdapter {
153
158
  const openai = new openai_1.default({
154
159
  apiKey: apiKey || 'not-needed',
155
160
  baseURL: `${this.baseURL}/v1`,
161
+ maxRetries: 0, // retries are owned by the unified LLMService retry layer
156
162
  });
157
163
  // Format messages for OpenAI-compatible API
158
164
  const messages = this.formatMessages(request);
@@ -172,6 +178,20 @@ class LlamaCppClientAdapter {
172
178
  ...(request.settings.presencePenalty !== 0 && {
173
179
  presence_penalty: request.settings.presencePenalty,
174
180
  }),
181
+ ...(request.settings.seed !== undefined && {
182
+ seed: request.settings.seed,
183
+ }),
184
+ // llama.cpp-native sampling params (not in the OpenAI SDK types, but the
185
+ // SDK sends extra body fields as-is and llama-server reads them top-level)
186
+ ...(request.settings.topK !== undefined && {
187
+ top_k: request.settings.topK,
188
+ }),
189
+ ...(request.settings.minP !== undefined && {
190
+ min_p: request.settings.minP,
191
+ }),
192
+ ...(request.settings.repeatPenalty !== undefined && {
193
+ repeat_penalty: request.settings.repeatPenalty,
194
+ }),
175
195
  };
176
196
  // Handle structured output configuration for llama.cpp
177
197
  // llama.cpp uses response_format with type: 'json_object' and optional schema
@@ -182,6 +202,56 @@ class LlamaCppClientAdapter {
182
202
  schema: so.schema,
183
203
  };
184
204
  }
205
+ // Reasoning toggle for detected hybrid GGUF models (Qwen 3.x, Gemma 4, ...).
206
+ // The chat template's thinking flag is sent explicitly — false unless reasoning
207
+ // was requested — so hybrid models don't silently burn thinking tokens.
208
+ // Requires llama-server running with --jinja; servers without it ignore the kwarg.
209
+ const detectedCaps = await this.getModelCapabilities();
210
+ const localReasoning = detectedCaps?.localReasoning;
211
+ const derivedKwargs = {};
212
+ if (localReasoning?.toggleKwarg) {
213
+ derivedKwargs[localReasoning.toggleKwarg] =
214
+ request.settings.reasoning?.enabled === true;
215
+ }
216
+ // User-supplied chat-template kwargs (escape hatch) win over derived ones
217
+ const chatTemplateKwargs = {
218
+ ...derivedKwargs,
219
+ ...(request.settings.llamacpp?.chatTemplateKwargs || {}),
220
+ };
221
+ if (Object.keys(chatTemplateKwargs).length > 0) {
222
+ // llama-server rejects assistant prefill together with enable_thinking=true
223
+ // (HTTP 400: "Assistant response prefill is incompatible with enable_thinking.")
224
+ // Fail fast with a clear message instead of surfacing the raw server error.
225
+ const thinkingKwarg = localReasoning?.toggleKwarg ?? 'enable_thinking';
226
+ const effectiveThinking = chatTemplateKwargs[thinkingKwarg] === true;
227
+ const lastMessage = messages[messages.length - 1];
228
+ if (effectiveThinking && lastMessage?.role === 'assistant') {
229
+ return {
230
+ provider: request.providerId,
231
+ model: request.modelId,
232
+ error: {
233
+ message: 'llama.cpp does not support assistant prefill (a trailing assistant message) ' +
234
+ 'while thinking is enabled. Remove the trailing assistant message or disable reasoning.',
235
+ code: types_1.ADAPTER_ERROR_CODES.PROVIDER_ERROR,
236
+ type: 'invalid_request_error',
237
+ },
238
+ object: 'error',
239
+ };
240
+ }
241
+ completionParams.chat_template_kwargs = chatTemplateKwargs;
242
+ }
243
+ // GBNF grammar for constrained decoding (validated as mutually exclusive
244
+ // with structuredOutput upstream)
245
+ if (request.settings.llamacpp?.grammar) {
246
+ completionParams.grammar = request.settings.llamacpp.grammar;
247
+ }
248
+ // Per-token log probabilities (OpenAI-compatible request/response shape)
249
+ if (request.settings.logprobs === true) {
250
+ completionParams.logprobs = true;
251
+ if (request.settings.topLogprobs !== undefined) {
252
+ completionParams.top_logprobs = request.settings.topLogprobs;
253
+ }
254
+ }
185
255
  this.logger.debug(`llama.cpp API parameters:`, {
186
256
  baseURL: this.baseURL,
187
257
  model: completionParams.model,
@@ -191,11 +261,17 @@ class LlamaCppClientAdapter {
191
261
  });
192
262
  this.logger.info(`Making llama.cpp API call for model: ${request.modelId}`);
193
263
  // Make the API call
194
- const completion = await openai.chat.completions.create(completionParams);
264
+ const transportOptions = {
265
+ ...(options?.signal && { signal: options.signal }),
266
+ ...(options?.timeoutMs !== undefined && { timeout: options.timeoutMs }),
267
+ };
268
+ const completion = Object.keys(transportOptions).length > 0
269
+ ? await openai.chat.completions.create(completionParams, transportOptions)
270
+ : await openai.chat.completions.create(completionParams);
195
271
  // Type guard to ensure we have a non-streaming response
196
272
  if ('id' in completion && 'choices' in completion) {
197
273
  this.logger.info(`llama.cpp API call successful, response ID: ${completion.id}`);
198
- return this.createSuccessResponse(completion, request);
274
+ return this.createSuccessResponse(completion, request, detectedCaps);
199
275
  }
200
276
  else {
201
277
  throw new Error('Unexpected streaming response from llama.cpp server');
@@ -309,36 +385,62 @@ class LlamaCppClientAdapter {
309
385
  * @param request - Original request for context
310
386
  * @returns Standardized LLM response
311
387
  */
312
- createSuccessResponse(completion, request) {
388
+ createSuccessResponse(completion, request, detectedCaps) {
313
389
  const choice = completion.choices[0];
314
390
  if (!choice || !choice.message) {
315
391
  throw new Error("No valid choices in llama.cpp completion response");
316
392
  }
317
- // Extract reasoning content if available
318
- // llama.cpp returns reasoning in reasoning_content field when using --reasoning-format
319
- let reasoning;
320
- if (choice.message.reasoning_content) {
321
- reasoning = choice.message.reasoning_content;
322
- }
393
+ const local = detectedCaps?.localReasoning;
394
+ // Whether thinking is active for this request: explicitly requested, or the
395
+ // detected model reasons unconditionally (always-on models like GPT-OSS,
396
+ // Thinking-2507 checkpoints, DeepSeek R1).
397
+ const reasoningActive = request.settings.reasoning?.enabled === true ||
398
+ detectedCaps?.reasoning?.enabledByDefault === true ||
399
+ detectedCaps?.reasoning?.canDisable === false;
400
+ const excludeReasoning = request.settings.reasoning?.exclude === true;
323
401
  return {
324
402
  id: completion.id,
325
403
  provider: request.providerId,
326
404
  model: completion.model || request.modelId,
327
405
  created: completion.created,
328
406
  choices: completion.choices.map((c) => {
407
+ let content = c.message.content || "";
408
+ // Strip the template-injected "nothink" prefix (some chat templates leak an
409
+ // empty think block into content when thinking is disabled). Exact match.
410
+ if (local?.nothinkPrefix && content.startsWith(local.nothinkPrefix)) {
411
+ content = content.slice(local.nothinkPrefix.length);
412
+ }
329
413
  const mappedChoice = {
330
414
  message: {
331
415
  role: "assistant",
332
- content: c.message.content || "",
416
+ content,
333
417
  },
334
418
  finish_reason: c.finish_reason,
335
419
  index: c.index,
336
420
  };
337
- // Include reasoning if available and not excluded
421
+ // Two-tier reasoning extraction:
422
+ // 1. Prefer the server-separated reasoning_content field (populated when
423
+ // llama-server's --reasoning-format handling recognizes the template).
424
+ // 2. Fall back to marker extraction from content — reasoning_content
425
+ // population is model/template-dependent, not guaranteed.
338
426
  const messageReasoning = c.message.reasoning_content;
339
- if (messageReasoning && request.settings.reasoning && !request.settings.reasoning.exclude) {
427
+ if (messageReasoning && request.settings.reasoning && !excludeReasoning) {
340
428
  mappedChoice.reasoning = messageReasoning;
341
429
  }
430
+ else if (!messageReasoning && reasoningActive && local?.markers) {
431
+ const { content: cleaned, extracted } = (0, parser_1.extractMarkerDelimitedContent)(content, local.markers[0], local.markers[1]);
432
+ if (extracted) {
433
+ mappedChoice.message.content = cleaned;
434
+ if (!excludeReasoning) {
435
+ mappedChoice.reasoning = extracted;
436
+ }
437
+ }
438
+ }
439
+ // Per-token log probabilities (OpenAI-compatible shape)
440
+ const logprobs = (0, logprobsUtils_1.mapOpenAIChatLogprobs)(c.logprobs);
441
+ if (logprobs) {
442
+ mappedChoice.logprobs = logprobs;
443
+ }
342
444
  return mappedChoice;
343
445
  }),
344
446
  usage: completion.usage
@@ -389,6 +491,7 @@ class LlamaCppClientAdapter {
389
491
  code: mappedError.errorCode,
390
492
  type: mappedError.errorType,
391
493
  ...(mappedError.status && { status: mappedError.status }),
494
+ ...(mappedError.retryAfterMs !== undefined && { retryAfterMs: mappedError.retryAfterMs }),
392
495
  providerError: error,
393
496
  },
394
497
  object: "error",
@@ -1,5 +1,5 @@
1
1
  import type { LLMResponse, LLMFailureResponse } from "../types";
2
- import type { ILLMClientAdapter, InternalLLMChatRequest } from "./types";
2
+ import type { ILLMClientAdapter, InternalLLMChatRequest, AdapterRequestOptions } from "./types";
3
3
  import type { Logger } from "../../logging/types";
4
4
  /**
5
5
  * Configuration options for MistralClientAdapter
@@ -52,7 +52,7 @@ export declare class MistralClientAdapter implements ILLMClientAdapter {
52
52
  * @param apiKey - The Mistral API key
53
53
  * @returns Promise resolving to success or failure response
54
54
  */
55
- sendMessage(request: InternalLLMChatRequest, apiKey: string): Promise<LLMResponse | LLMFailureResponse>;
55
+ sendMessage(request: InternalLLMChatRequest, apiKey: string, options?: AdapterRequestOptions): Promise<LLMResponse | LLMFailureResponse>;
56
56
  /**
57
57
  * Validates Mistral API key format
58
58
  *
@@ -51,9 +51,10 @@ class MistralClientAdapter {
51
51
  * @param apiKey - The Mistral API key
52
52
  * @returns Promise resolving to success or failure response
53
53
  */
54
- async sendMessage(request, apiKey) {
54
+ async sendMessage(request, apiKey, options) {
55
55
  try {
56
- // Initialize Mistral client
56
+ // Initialize Mistral client (Speakeasy SDK retry default is "none" — retries
57
+ // are owned by the unified LLMService retry layer)
57
58
  const mistral = new mistralai_1.Mistral({
58
59
  apiKey,
59
60
  serverURL: this.baseURL !== 'https://api.mistral.ai' ? this.baseURL : undefined,
@@ -78,6 +79,9 @@ class MistralClientAdapter {
78
79
  ...(request.settings.stopSequences.length > 0 && {
79
80
  stop: request.settings.stopSequences,
80
81
  }),
82
+ ...(request.settings.seed !== undefined && {
83
+ randomSeed: request.settings.seed,
84
+ }),
81
85
  // Note: Mistral does not support frequency_penalty or presence_penalty
82
86
  };
83
87
  // Handle structured output configuration for Mistral
@@ -87,8 +91,14 @@ class MistralClientAdapter {
87
91
  this.logger.warn(`Mistral does not support JSON schema validation. ` +
88
92
  `Using json_object mode - schema validation will be client-side only.`);
89
93
  }
90
- // Make the API call
91
- const completion = await mistral.chat.complete(requestOptions);
94
+ // Make the API call (per-request transport options: timeout + abort signal)
95
+ const transportOptions = {
96
+ ...(options?.timeoutMs !== undefined && { timeoutMs: options.timeoutMs }),
97
+ ...(options?.signal && { fetchOptions: { signal: options.signal } }),
98
+ };
99
+ const completion = Object.keys(transportOptions).length > 0
100
+ ? await mistral.chat.complete(requestOptions, transportOptions)
101
+ : await mistral.chat.complete(requestOptions);
92
102
  if (completion && completion.choices && completion.choices.length > 0) {
93
103
  this.logger.info(`Mistral API call successful, response ID: ${completion.id}`);
94
104
  return this.createSuccessResponse(completion, request);
@@ -239,6 +249,7 @@ class MistralClientAdapter {
239
249
  code: mappedError.errorCode,
240
250
  type: mappedError.errorType,
241
251
  ...(mappedError.status && { status: mappedError.status }),
252
+ ...(mappedError.retryAfterMs !== undefined && { retryAfterMs: mappedError.retryAfterMs }),
242
253
  providerError: error,
243
254
  },
244
255
  object: "error",
@@ -1,5 +1,5 @@
1
1
  import type { LLMResponse, LLMFailureResponse, ApiProviderId } from "../types";
2
- import type { ILLMClientAdapter, InternalLLMChatRequest } from "./types";
2
+ import type { ILLMClientAdapter, InternalLLMChatRequest, AdapterRequestOptions } from "./types";
3
3
  /**
4
4
  * Mock client adapter for testing LLM functionality
5
5
  *
@@ -20,7 +20,7 @@ export declare class MockClientAdapter implements ILLMClientAdapter {
20
20
  * @param apiKey - The API key (ignored for mock)
21
21
  * @returns Promise resolving to mock response
22
22
  */
23
- sendMessage(request: InternalLLMChatRequest, apiKey: string): Promise<LLMResponse | LLMFailureResponse>;
23
+ sendMessage(request: InternalLLMChatRequest, apiKey: string, options?: AdapterRequestOptions): Promise<LLMResponse | LLMFailureResponse>;
24
24
  /**
25
25
  * Validates API key format (always returns true for mock)
26
26
  */