@quilltap/plugin-utils 2.2.20 → 2.4.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.
@@ -122,6 +122,27 @@ declare class OpenAICompatibleProvider implements TextProvider {
122
122
  protected readonly maxRetries: number;
123
123
  /** Logger instance */
124
124
  protected readonly logger: PluginLogger;
125
+ /**
126
+ * Whether the endpoint tolerates more than one leading `system` message.
127
+ *
128
+ * True for every hosted service — and so the default, which keeps the
129
+ * outbound bytes of OpenRouter, Z.AI and every other subclass exactly as they
130
+ * were. A subclass that points at a local runtime applying the *model's* chat
131
+ * template sets this false: several template families reject a system message
132
+ * anywhere but index 0, and Quilltap's context builder deliberately emits up
133
+ * to three of them so its cache breakpoints survive. See Bug 82 and
134
+ * {@link collapseLeadingSystemMessages}.
135
+ */
136
+ protected readonly acceptsRepeatedSystemMessages: boolean;
137
+ /**
138
+ * Apply {@link acceptsRepeatedSystemMessages} to a mapped wire-message array.
139
+ * Called by both body builds so the streaming and non-streaming shapes cannot
140
+ * drift apart.
141
+ */
142
+ protected applySystemMessagePolicy<T extends {
143
+ role: string;
144
+ content?: string | null;
145
+ }>(messages: T[]): T[];
125
146
  /**
126
147
  * Creates a new OpenAI-compatible provider instance.
127
148
  *
@@ -178,6 +199,44 @@ declare class OpenAICompatibleProvider implements TextProvider {
178
199
  timeout: number;
179
200
  maxRetries: number;
180
201
  } | undefined;
202
+ /**
203
+ * Profile-parameter keys this provider forwards from
204
+ * `LLMParams.profileParameters` onto the request body.
205
+ *
206
+ * **Empty by default**, so a subclass that declares nothing sends exactly the
207
+ * bytes it sent before this mechanism existed. Subclasses opt in by
208
+ * overriding with their own allow-list; `model`, `messages`, `stream`,
209
+ * `stream_options` and `tools` must never appear in one.
210
+ */
211
+ protected readonly profileParamAllowlist: readonly string[];
212
+ /**
213
+ * Per-key hook for allow-listed profile parameters. The default forwards the
214
+ * stored value verbatim.
215
+ *
216
+ * Override to reshape a value (the editor stores flat strings where some APIs
217
+ * want objects), to gate a key on the model, or — by writing into `body` and
218
+ * returning `undefined` — to send it under a different key entirely.
219
+ */
220
+ protected normalizeProfileParam(_key: string, value: unknown, _params: LLMParams, _body: Record<string, unknown>): unknown | undefined;
221
+ /**
222
+ * Apply {@link profileParamAllowlist} to a request body under construction.
223
+ * Called by both body builds so the streaming and non-streaming shapes cannot
224
+ * drift apart.
225
+ */
226
+ protected applyProfileParams(body: Record<string, unknown>, params: LLMParams): void;
227
+ /**
228
+ * Normalize an OpenAI-shaped `tool_calls` array into Quilltap's `ToolCall`
229
+ * shape, tolerating servers that hand back already-parsed argument objects
230
+ * where the OpenAI wire format specifies a JSON string.
231
+ */
232
+ protected normalizeToolCalls(toolCalls: readonly unknown[] | undefined | null): {
233
+ id: string;
234
+ type: 'function';
235
+ function: {
236
+ name: string;
237
+ arguments: string;
238
+ };
239
+ }[];
181
240
  /**
182
241
  * Sends a message and returns the complete response.
183
242
  *
@@ -210,6 +269,106 @@ declare class OpenAICompatibleProvider implements TextProvider {
210
269
  getAvailableModels(apiKey: string): Promise<string[]>;
211
270
  }
212
271
 
272
+ /**
273
+ * Profile-parameter passthrough
274
+ *
275
+ * A connection profile's `parameters` blob is free-form JSON: the host stores
276
+ * whatever the profile editor (or a hand-edited profile) puts there and hands
277
+ * it back to the provider as `LLMParams.profileParameters` at call time. What
278
+ * reaches the wire is entirely the provider's business — and a provider that
279
+ * never reads the bag drops every setting in silence, which is exactly the
280
+ * defect Bug 71 records.
281
+ *
282
+ * This is the one mechanism every provider uses to forward those keys.
283
+ *
284
+ * **An exported function rather than a base-class method.** Only one plugin in
285
+ * the repository extends `OpenAICompatibleProvider` (DeepSeek); Z.AI and
286
+ * OpenRouter implement `TextProvider` directly, and Ollama speaks its own
287
+ * REST shape. A `protected` method would have reached one of them, so the
288
+ * mechanism is a free function usable by inheritance *and* composition, and
289
+ * the base class simply calls it.
290
+ *
291
+ * @packageDocumentation
292
+ */
293
+
294
+ /**
295
+ * Per-key hook applied to an allow-listed profile parameter on its way into
296
+ * the request body.
297
+ *
298
+ * Return the value to send, or `undefined` to omit the key entirely.
299
+ *
300
+ * The `body` is passed so a normalizer can place a value somewhere other than
301
+ * its own top-level key — the OpenAI-compatible plugin folds `reasoning_effort`
302
+ * into `chat_template_kwargs` this way, then returns `undefined` so no flat
303
+ * `reasoning_effort` key is sent. Normalizers that only reshape a value can
304
+ * ignore the argument.
305
+ */
306
+ type ProfileParamNormalizer = (key: string, value: unknown, params: LLMParams, body: Record<string, unknown>) => unknown | undefined;
307
+ /**
308
+ * Copy allow-listed keys from `params.profileParameters` onto a request body.
309
+ *
310
+ * Rules, all of which predate this helper (they were hand-rolled identically in
311
+ * the DeepSeek and Z.AI providers before it existed):
312
+ *
313
+ * - **Allow-list, never spread.** `model`, `messages`, `stream`,
314
+ * `stream_options` and `tools` must be unreachable from a profile — a
315
+ * misconfigured bag cannot be allowed to retarget the request.
316
+ * - **`undefined` and `null` skip.** So does the empty string, which is what
317
+ * the schema-driven profile editor stores for "omit this and use the model
318
+ * default".
319
+ * - **The allow-list is ordered.** Keys are applied in the order given, so a
320
+ * normalizer that merges into an earlier key (`chat_template_kwargs`) sees
321
+ * whatever the profile set for it.
322
+ *
323
+ * @param body - Request body under construction; mutated in place
324
+ * @param params - The call's LLM parameters, carrying `profileParameters`
325
+ * @param allowlist - Keys this provider is willing to forward
326
+ * @param normalize - Optional per-key hook (see {@link ProfileParamNormalizer})
327
+ */
328
+ declare function applyProfileParameters(body: Record<string, unknown>, params: LLMParams, allowlist: readonly string[], normalize?: ProfileParamNormalizer): void;
329
+
330
+ /**
331
+ * Leading-system-message collapse.
332
+ *
333
+ * Quilltap's context builder deliberately emits the head of a turn as up to
334
+ * three consecutive `system` messages — the cacheable persona prefix, the
335
+ * static identity reinforcement, and the compressed-history summary — so that a
336
+ * cache breakpoint on the first is not invalidated by churn in the others.
337
+ * Hosted providers accept that happily.
338
+ *
339
+ * A local runtime does not answer for itself: it applies the *model's own* chat
340
+ * template, and several families (Qwen most notably, plus Llama- and
341
+ * Gemma-derived templates) `raise_exception` on any system message after index
342
+ * 0. The whole request is then rejected before a token is generated, so the
343
+ * opening greeting — one system message — works and every turn after it dies.
344
+ *
345
+ * The repair belongs here, at request-build time, rather than in the context
346
+ * assembly: the blocks stay separate for every provider that wants them
347
+ * separate, and the providers that cannot take them fold the run on the way
348
+ * out. See Bug 82.
349
+ *
350
+ * @packageDocumentation
351
+ */
352
+ /** The shape this helper needs; wire messages carry more, and keep it. */
353
+ interface RoleAndContent {
354
+ role: string;
355
+ content?: string | null;
356
+ }
357
+ /**
358
+ * Fold a run of consecutive leading `system` messages into a single one,
359
+ * joining their contents with a blank line.
360
+ *
361
+ * Only the *leading* run is touched — a system message that appears later is
362
+ * left where it is, because moving or merging it would change what the model is
363
+ * told and when. Arrays with fewer than two leading system messages are
364
+ * returned unchanged (the same array reference), so a provider that opts in
365
+ * still sends byte-identical requests everywhere the problem does not arise.
366
+ *
367
+ * @param messages - Wire-format messages, already mapped for the endpoint
368
+ * @returns The messages with their leading system run collapsed
369
+ */
370
+ declare function collapseLeadingSystemMessages<T extends RoleAndContent>(messages: T[]): T[];
371
+
213
372
  /**
214
373
  * Per-request wall-clock budgets.
215
374
  *
@@ -291,4 +450,4 @@ declare function buildSdkClientOptions(params: Pick<LLMParams, 'requestTimeoutMs
291
450
  */
292
451
  declare function buildRequestAbortSignal(params: Pick<LLMParams, 'requestTimeoutMs'>, defaultMs?: number): AbortSignal;
293
452
 
294
- export { DEFAULT_REQUEST_TIMEOUT_MS, OpenAICompatibleProvider, type OpenAICompatibleProviderConfig, buildRequestAbortSignal, buildSdkClientOptions, buildSdkRequestOptions, resolveRequestTimeoutMs };
453
+ export { DEFAULT_REQUEST_TIMEOUT_MS, OpenAICompatibleProvider, type OpenAICompatibleProviderConfig, type ProfileParamNormalizer, applyProfileParameters, buildRequestAbortSignal, buildSdkClientOptions, buildSdkRequestOptions, collapseLeadingSystemMessages, resolveRequestTimeoutMs };
@@ -122,6 +122,27 @@ declare class OpenAICompatibleProvider implements TextProvider {
122
122
  protected readonly maxRetries: number;
123
123
  /** Logger instance */
124
124
  protected readonly logger: PluginLogger;
125
+ /**
126
+ * Whether the endpoint tolerates more than one leading `system` message.
127
+ *
128
+ * True for every hosted service — and so the default, which keeps the
129
+ * outbound bytes of OpenRouter, Z.AI and every other subclass exactly as they
130
+ * were. A subclass that points at a local runtime applying the *model's* chat
131
+ * template sets this false: several template families reject a system message
132
+ * anywhere but index 0, and Quilltap's context builder deliberately emits up
133
+ * to three of them so its cache breakpoints survive. See Bug 82 and
134
+ * {@link collapseLeadingSystemMessages}.
135
+ */
136
+ protected readonly acceptsRepeatedSystemMessages: boolean;
137
+ /**
138
+ * Apply {@link acceptsRepeatedSystemMessages} to a mapped wire-message array.
139
+ * Called by both body builds so the streaming and non-streaming shapes cannot
140
+ * drift apart.
141
+ */
142
+ protected applySystemMessagePolicy<T extends {
143
+ role: string;
144
+ content?: string | null;
145
+ }>(messages: T[]): T[];
125
146
  /**
126
147
  * Creates a new OpenAI-compatible provider instance.
127
148
  *
@@ -178,6 +199,44 @@ declare class OpenAICompatibleProvider implements TextProvider {
178
199
  timeout: number;
179
200
  maxRetries: number;
180
201
  } | undefined;
202
+ /**
203
+ * Profile-parameter keys this provider forwards from
204
+ * `LLMParams.profileParameters` onto the request body.
205
+ *
206
+ * **Empty by default**, so a subclass that declares nothing sends exactly the
207
+ * bytes it sent before this mechanism existed. Subclasses opt in by
208
+ * overriding with their own allow-list; `model`, `messages`, `stream`,
209
+ * `stream_options` and `tools` must never appear in one.
210
+ */
211
+ protected readonly profileParamAllowlist: readonly string[];
212
+ /**
213
+ * Per-key hook for allow-listed profile parameters. The default forwards the
214
+ * stored value verbatim.
215
+ *
216
+ * Override to reshape a value (the editor stores flat strings where some APIs
217
+ * want objects), to gate a key on the model, or — by writing into `body` and
218
+ * returning `undefined` — to send it under a different key entirely.
219
+ */
220
+ protected normalizeProfileParam(_key: string, value: unknown, _params: LLMParams, _body: Record<string, unknown>): unknown | undefined;
221
+ /**
222
+ * Apply {@link profileParamAllowlist} to a request body under construction.
223
+ * Called by both body builds so the streaming and non-streaming shapes cannot
224
+ * drift apart.
225
+ */
226
+ protected applyProfileParams(body: Record<string, unknown>, params: LLMParams): void;
227
+ /**
228
+ * Normalize an OpenAI-shaped `tool_calls` array into Quilltap's `ToolCall`
229
+ * shape, tolerating servers that hand back already-parsed argument objects
230
+ * where the OpenAI wire format specifies a JSON string.
231
+ */
232
+ protected normalizeToolCalls(toolCalls: readonly unknown[] | undefined | null): {
233
+ id: string;
234
+ type: 'function';
235
+ function: {
236
+ name: string;
237
+ arguments: string;
238
+ };
239
+ }[];
181
240
  /**
182
241
  * Sends a message and returns the complete response.
183
242
  *
@@ -210,6 +269,106 @@ declare class OpenAICompatibleProvider implements TextProvider {
210
269
  getAvailableModels(apiKey: string): Promise<string[]>;
211
270
  }
212
271
 
272
+ /**
273
+ * Profile-parameter passthrough
274
+ *
275
+ * A connection profile's `parameters` blob is free-form JSON: the host stores
276
+ * whatever the profile editor (or a hand-edited profile) puts there and hands
277
+ * it back to the provider as `LLMParams.profileParameters` at call time. What
278
+ * reaches the wire is entirely the provider's business — and a provider that
279
+ * never reads the bag drops every setting in silence, which is exactly the
280
+ * defect Bug 71 records.
281
+ *
282
+ * This is the one mechanism every provider uses to forward those keys.
283
+ *
284
+ * **An exported function rather than a base-class method.** Only one plugin in
285
+ * the repository extends `OpenAICompatibleProvider` (DeepSeek); Z.AI and
286
+ * OpenRouter implement `TextProvider` directly, and Ollama speaks its own
287
+ * REST shape. A `protected` method would have reached one of them, so the
288
+ * mechanism is a free function usable by inheritance *and* composition, and
289
+ * the base class simply calls it.
290
+ *
291
+ * @packageDocumentation
292
+ */
293
+
294
+ /**
295
+ * Per-key hook applied to an allow-listed profile parameter on its way into
296
+ * the request body.
297
+ *
298
+ * Return the value to send, or `undefined` to omit the key entirely.
299
+ *
300
+ * The `body` is passed so a normalizer can place a value somewhere other than
301
+ * its own top-level key — the OpenAI-compatible plugin folds `reasoning_effort`
302
+ * into `chat_template_kwargs` this way, then returns `undefined` so no flat
303
+ * `reasoning_effort` key is sent. Normalizers that only reshape a value can
304
+ * ignore the argument.
305
+ */
306
+ type ProfileParamNormalizer = (key: string, value: unknown, params: LLMParams, body: Record<string, unknown>) => unknown | undefined;
307
+ /**
308
+ * Copy allow-listed keys from `params.profileParameters` onto a request body.
309
+ *
310
+ * Rules, all of which predate this helper (they were hand-rolled identically in
311
+ * the DeepSeek and Z.AI providers before it existed):
312
+ *
313
+ * - **Allow-list, never spread.** `model`, `messages`, `stream`,
314
+ * `stream_options` and `tools` must be unreachable from a profile — a
315
+ * misconfigured bag cannot be allowed to retarget the request.
316
+ * - **`undefined` and `null` skip.** So does the empty string, which is what
317
+ * the schema-driven profile editor stores for "omit this and use the model
318
+ * default".
319
+ * - **The allow-list is ordered.** Keys are applied in the order given, so a
320
+ * normalizer that merges into an earlier key (`chat_template_kwargs`) sees
321
+ * whatever the profile set for it.
322
+ *
323
+ * @param body - Request body under construction; mutated in place
324
+ * @param params - The call's LLM parameters, carrying `profileParameters`
325
+ * @param allowlist - Keys this provider is willing to forward
326
+ * @param normalize - Optional per-key hook (see {@link ProfileParamNormalizer})
327
+ */
328
+ declare function applyProfileParameters(body: Record<string, unknown>, params: LLMParams, allowlist: readonly string[], normalize?: ProfileParamNormalizer): void;
329
+
330
+ /**
331
+ * Leading-system-message collapse.
332
+ *
333
+ * Quilltap's context builder deliberately emits the head of a turn as up to
334
+ * three consecutive `system` messages — the cacheable persona prefix, the
335
+ * static identity reinforcement, and the compressed-history summary — so that a
336
+ * cache breakpoint on the first is not invalidated by churn in the others.
337
+ * Hosted providers accept that happily.
338
+ *
339
+ * A local runtime does not answer for itself: it applies the *model's own* chat
340
+ * template, and several families (Qwen most notably, plus Llama- and
341
+ * Gemma-derived templates) `raise_exception` on any system message after index
342
+ * 0. The whole request is then rejected before a token is generated, so the
343
+ * opening greeting — one system message — works and every turn after it dies.
344
+ *
345
+ * The repair belongs here, at request-build time, rather than in the context
346
+ * assembly: the blocks stay separate for every provider that wants them
347
+ * separate, and the providers that cannot take them fold the run on the way
348
+ * out. See Bug 82.
349
+ *
350
+ * @packageDocumentation
351
+ */
352
+ /** The shape this helper needs; wire messages carry more, and keep it. */
353
+ interface RoleAndContent {
354
+ role: string;
355
+ content?: string | null;
356
+ }
357
+ /**
358
+ * Fold a run of consecutive leading `system` messages into a single one,
359
+ * joining their contents with a blank line.
360
+ *
361
+ * Only the *leading* run is touched — a system message that appears later is
362
+ * left where it is, because moving or merging it would change what the model is
363
+ * told and when. Arrays with fewer than two leading system messages are
364
+ * returned unchanged (the same array reference), so a provider that opts in
365
+ * still sends byte-identical requests everywhere the problem does not arise.
366
+ *
367
+ * @param messages - Wire-format messages, already mapped for the endpoint
368
+ * @returns The messages with their leading system run collapsed
369
+ */
370
+ declare function collapseLeadingSystemMessages<T extends RoleAndContent>(messages: T[]): T[];
371
+
213
372
  /**
214
373
  * Per-request wall-clock budgets.
215
374
  *
@@ -291,4 +450,4 @@ declare function buildSdkClientOptions(params: Pick<LLMParams, 'requestTimeoutMs
291
450
  */
292
451
  declare function buildRequestAbortSignal(params: Pick<LLMParams, 'requestTimeoutMs'>, defaultMs?: number): AbortSignal;
293
452
 
294
- export { DEFAULT_REQUEST_TIMEOUT_MS, OpenAICompatibleProvider, type OpenAICompatibleProviderConfig, buildRequestAbortSignal, buildSdkClientOptions, buildSdkRequestOptions, resolveRequestTimeoutMs };
453
+ export { DEFAULT_REQUEST_TIMEOUT_MS, OpenAICompatibleProvider, type OpenAICompatibleProviderConfig, type ProfileParamNormalizer, applyProfileParameters, buildRequestAbortSignal, buildSdkClientOptions, buildSdkRequestOptions, collapseLeadingSystemMessages, resolveRequestTimeoutMs };
@@ -32,9 +32,11 @@ var providers_exports = {};
32
32
  __export(providers_exports, {
33
33
  DEFAULT_REQUEST_TIMEOUT_MS: () => DEFAULT_REQUEST_TIMEOUT_MS,
34
34
  OpenAICompatibleProvider: () => OpenAICompatibleProvider,
35
+ applyProfileParameters: () => applyProfileParameters,
35
36
  buildRequestAbortSignal: () => buildRequestAbortSignal,
36
37
  buildSdkClientOptions: () => buildSdkClientOptions,
37
38
  buildSdkRequestOptions: () => buildSdkRequestOptions,
39
+ collapseLeadingSystemMessages: () => collapseLeadingSystemMessages,
38
40
  resolveRequestTimeoutMs: () => resolveRequestTimeoutMs
39
41
  });
40
42
  module.exports = __toCommonJS(providers_exports);
@@ -131,6 +133,36 @@ function buildRequestAbortSignal(params, defaultMs = DEFAULT_REQUEST_TIMEOUT_MS)
131
133
  return AbortSignal.timeout(resolveRequestTimeoutMs(params, defaultMs));
132
134
  }
133
135
 
136
+ // src/providers/profile-parameters.ts
137
+ function applyProfileParameters(body, params, allowlist, normalize) {
138
+ const profile = params.profileParameters;
139
+ if (!profile || typeof profile !== "object") return;
140
+ const bag = profile;
141
+ for (const key of allowlist) {
142
+ const raw = bag[key];
143
+ if (raw === void 0 || raw === null) continue;
144
+ if (typeof raw === "string" && raw === "") continue;
145
+ const value = normalize ? normalize(key, raw, params, body) : raw;
146
+ if (value === void 0) continue;
147
+ body[key] = value;
148
+ }
149
+ }
150
+
151
+ // src/providers/system-messages.ts
152
+ function collapseLeadingSystemMessages(messages) {
153
+ let runLength = 0;
154
+ while (runLength < messages.length && messages[runLength].role === "system") {
155
+ runLength++;
156
+ }
157
+ if (runLength < 2) return messages;
158
+ const run = messages.slice(0, runLength);
159
+ const merged = {
160
+ ...run[0],
161
+ content: run.map((m) => m.content ?? "").filter((c) => c.length > 0).join("\n\n")
162
+ };
163
+ return [merged, ...messages.slice(runLength)];
164
+ }
165
+
134
166
  // src/providers/openai-compatible.ts
135
167
  var DEFAULT_MAX_RETRIES = 2;
136
168
  var OpenAICompatibleProvider = class {
@@ -146,6 +178,28 @@ var OpenAICompatibleProvider = class {
146
178
  this.supportedMimeTypes = [];
147
179
  /** Web search is not supported */
148
180
  this.supportsWebSearch = false;
181
+ /**
182
+ * Whether the endpoint tolerates more than one leading `system` message.
183
+ *
184
+ * True for every hosted service — and so the default, which keeps the
185
+ * outbound bytes of OpenRouter, Z.AI and every other subclass exactly as they
186
+ * were. A subclass that points at a local runtime applying the *model's* chat
187
+ * template sets this false: several template families reject a system message
188
+ * anywhere but index 0, and Quilltap's context builder deliberately emits up
189
+ * to three of them so its cache breakpoints survive. See Bug 82 and
190
+ * {@link collapseLeadingSystemMessages}.
191
+ */
192
+ this.acceptsRepeatedSystemMessages = true;
193
+ /**
194
+ * Profile-parameter keys this provider forwards from
195
+ * `LLMParams.profileParameters` onto the request body.
196
+ *
197
+ * **Empty by default**, so a subclass that declares nothing sends exactly the
198
+ * bytes it sent before this mechanism existed. Subclasses opt in by
199
+ * overriding with their own allow-list; `model`, `messages`, `stream`,
200
+ * `stream_options` and `tools` must never appear in one.
201
+ */
202
+ this.profileParamAllowlist = [];
149
203
  if (typeof config === "string") {
150
204
  this.baseUrl = config;
151
205
  this.providerName = "OpenAICompatible";
@@ -163,6 +217,14 @@ var OpenAICompatibleProvider = class {
163
217
  }
164
218
  this.logger = createPluginLogger(`${this.providerName}Provider`);
165
219
  }
220
+ /**
221
+ * Apply {@link acceptsRepeatedSystemMessages} to a mapped wire-message array.
222
+ * Called by both body builds so the streaming and non-streaming shapes cannot
223
+ * drift apart.
224
+ */
225
+ applySystemMessagePolicy(messages) {
226
+ return this.acceptsRepeatedSystemMessages ? messages : collapseLeadingSystemMessages(messages);
227
+ }
166
228
  /**
167
229
  * Collects attachment failures for messages with attachments.
168
230
  * Since attachments are not supported, all attachments are marked as failed.
@@ -233,6 +295,53 @@ var OpenAICompatibleProvider = class {
233
295
  buildRequestOptions(params) {
234
296
  return buildSdkRequestOptions(params);
235
297
  }
298
+ /**
299
+ * Per-key hook for allow-listed profile parameters. The default forwards the
300
+ * stored value verbatim.
301
+ *
302
+ * Override to reshape a value (the editor stores flat strings where some APIs
303
+ * want objects), to gate a key on the model, or — by writing into `body` and
304
+ * returning `undefined` — to send it under a different key entirely.
305
+ */
306
+ normalizeProfileParam(_key, value, _params, _body) {
307
+ return value;
308
+ }
309
+ /**
310
+ * Apply {@link profileParamAllowlist} to a request body under construction.
311
+ * Called by both body builds so the streaming and non-streaming shapes cannot
312
+ * drift apart.
313
+ */
314
+ applyProfileParams(body, params) {
315
+ applyProfileParameters(
316
+ body,
317
+ params,
318
+ this.profileParamAllowlist,
319
+ (key, value, callParams, target) => this.normalizeProfileParam(key, value, callParams, target)
320
+ );
321
+ }
322
+ /**
323
+ * Normalize an OpenAI-shaped `tool_calls` array into Quilltap's `ToolCall`
324
+ * shape, tolerating servers that hand back already-parsed argument objects
325
+ * where the OpenAI wire format specifies a JSON string.
326
+ */
327
+ normalizeToolCalls(toolCalls) {
328
+ if (!toolCalls || toolCalls.length === 0) return [];
329
+ const out = [];
330
+ for (const raw of toolCalls) {
331
+ const tc = raw;
332
+ if (!tc?.function?.name) continue;
333
+ const args = tc.function.arguments;
334
+ out.push({
335
+ id: tc.id ?? "",
336
+ type: "function",
337
+ function: {
338
+ name: tc.function.name,
339
+ arguments: typeof args === "string" ? args : JSON.stringify(args ?? {})
340
+ }
341
+ });
342
+ }
343
+ return out;
344
+ }
236
345
  /**
237
346
  * Sends a message and returns the complete response.
238
347
  *
@@ -244,7 +353,7 @@ var OpenAICompatibleProvider = class {
244
353
  this.validateApiKeyRequirement(apiKey);
245
354
  const attachmentResults = this.collectAttachmentFailures(params);
246
355
  const client = this.createClient(apiKey);
247
- const messages = params.messages.filter((m) => {
356
+ const mappedMessages = params.messages.filter((m) => {
248
357
  if (m.role === "tool" && !m.toolCallId) return false;
249
358
  return true;
250
359
  }).map((m) => {
@@ -271,17 +380,27 @@ var OpenAICompatibleProvider = class {
271
380
  content: m.content
272
381
  };
273
382
  });
383
+ const messages = this.applySystemMessagePolicy(mappedMessages);
384
+ const body = {
385
+ model: params.model,
386
+ messages,
387
+ temperature: params.temperature ?? 0.7,
388
+ max_tokens: params.maxTokens ?? 4096,
389
+ top_p: params.topP ?? 1,
390
+ stop: params.stop,
391
+ ...params.cacheKey ? { user: params.cacheKey } : {},
392
+ // Tools arrive only once the runtime gate (the profile's "Allow tool
393
+ // use") has passed, so no separate guard is needed here.
394
+ ...params.tools && params.tools.length > 0 ? { tools: params.tools, tool_choice: params.toolChoice ?? "auto" } : {}
395
+ };
396
+ this.applyProfileParams(body, params);
274
397
  try {
275
- const response = await client.chat.completions.create({
276
- model: params.model,
277
- messages,
278
- temperature: params.temperature ?? 0.7,
279
- max_tokens: params.maxTokens ?? 4096,
280
- top_p: params.topP ?? 1,
281
- stop: params.stop,
282
- ...params.cacheKey ? { user: params.cacheKey } : {}
283
- }, this.buildRequestOptions(params));
398
+ const response = await client.chat.completions.create(
399
+ body,
400
+ this.buildRequestOptions(params)
401
+ );
284
402
  const choice = response.choices[0];
403
+ const toolCalls = this.normalizeToolCalls(choice.message.tool_calls);
285
404
  return {
286
405
  content: choice.message.content ?? "",
287
406
  finishReason: choice.finish_reason,
@@ -291,7 +410,8 @@ var OpenAICompatibleProvider = class {
291
410
  totalTokens: response.usage?.total_tokens ?? 0
292
411
  },
293
412
  raw: response,
294
- attachmentResults
413
+ attachmentResults,
414
+ ...toolCalls.length > 0 ? { toolCalls } : {}
295
415
  };
296
416
  } catch (error) {
297
417
  this.logger.error(
@@ -313,7 +433,7 @@ var OpenAICompatibleProvider = class {
313
433
  this.validateApiKeyRequirement(apiKey);
314
434
  const attachmentResults = this.collectAttachmentFailures(params);
315
435
  const client = this.createClient(apiKey);
316
- const messages = params.messages.filter((m) => {
436
+ const mappedMessages = params.messages.filter((m) => {
317
437
  if (m.role === "tool" && !m.toolCallId) return false;
318
438
  return true;
319
439
  }).map((m) => {
@@ -340,23 +460,33 @@ var OpenAICompatibleProvider = class {
340
460
  content: m.content
341
461
  };
342
462
  });
463
+ const messages = this.applySystemMessagePolicy(mappedMessages);
464
+ const body = {
465
+ model: params.model,
466
+ messages,
467
+ temperature: params.temperature ?? 0.7,
468
+ max_tokens: params.maxTokens ?? 4096,
469
+ top_p: params.topP ?? 1,
470
+ stop: params.stop,
471
+ stream: true,
472
+ stream_options: { include_usage: true },
473
+ ...params.cacheKey ? { user: params.cacheKey } : {},
474
+ ...params.tools && params.tools.length > 0 ? { tools: params.tools, tool_choice: params.toolChoice ?? "auto" } : {}
475
+ };
476
+ this.applyProfileParams(body, params);
343
477
  try {
344
- const stream = await client.chat.completions.create({
345
- model: params.model,
346
- messages,
347
- temperature: params.temperature ?? 0.7,
348
- max_tokens: params.maxTokens ?? 4096,
349
- top_p: params.topP ?? 1,
350
- stop: params.stop,
351
- stream: true,
352
- stream_options: { include_usage: true },
353
- ...params.cacheKey ? { user: params.cacheKey } : {}
354
- }, this.buildRequestOptions(params));
478
+ const stream = await client.chat.completions.create(
479
+ body,
480
+ this.buildRequestOptions(params)
481
+ );
355
482
  let chunkCount = 0;
356
483
  let accumulatedUsage = null;
484
+ const toolCallAccumulator = /* @__PURE__ */ new Map();
485
+ let finishReason = null;
357
486
  for await (const chunk of stream) {
358
487
  chunkCount++;
359
- const content = chunk.choices[0]?.delta?.content;
488
+ const choice = chunk.choices[0];
489
+ const content = choice?.delta?.content;
360
490
  const hasUsage = chunk.usage;
361
491
  if (hasUsage) {
362
492
  accumulatedUsage = {
@@ -365,6 +495,17 @@ var OpenAICompatibleProvider = class {
365
495
  total_tokens: chunk.usage?.total_tokens
366
496
  };
367
497
  }
498
+ if (choice?.delta?.tool_calls) {
499
+ for (const tcDelta of choice.delta.tool_calls) {
500
+ const idx = tcDelta.index ?? 0;
501
+ const existing = toolCallAccumulator.get(idx) ?? { id: "", name: "", arguments: "" };
502
+ if (tcDelta.id) existing.id = tcDelta.id;
503
+ if (tcDelta.function?.name) existing.name = tcDelta.function.name;
504
+ if (tcDelta.function?.arguments) existing.arguments += tcDelta.function.arguments;
505
+ toolCallAccumulator.set(idx, existing);
506
+ }
507
+ }
508
+ if (choice?.finish_reason) finishReason = choice.finish_reason;
368
509
  if (content) {
369
510
  yield {
370
511
  content,
@@ -372,6 +513,11 @@ var OpenAICompatibleProvider = class {
372
513
  };
373
514
  }
374
515
  }
516
+ const toolCalls = Array.from(toolCallAccumulator.values()).filter((tc) => tc.name).map((tc) => ({
517
+ id: tc.id,
518
+ type: "function",
519
+ function: { name: tc.name, arguments: tc.arguments }
520
+ }));
375
521
  yield {
376
522
  content: "",
377
523
  done: true,
@@ -380,7 +526,22 @@ var OpenAICompatibleProvider = class {
380
526
  completionTokens: accumulatedUsage.completion_tokens ?? 0,
381
527
  totalTokens: accumulatedUsage.total_tokens ?? 0
382
528
  } : void 0,
383
- attachmentResults
529
+ attachmentResults,
530
+ // Only synthesized when the stream actually carried tool calls: the
531
+ // host's tool detection reads `rawResponse`, and an empty one on every
532
+ // ordinary turn would be noise in the logs for no gain.
533
+ ...toolCalls.length > 0 ? {
534
+ toolCalls,
535
+ rawResponse: {
536
+ choices: [
537
+ {
538
+ index: 0,
539
+ message: { role: "assistant", content: "", tool_calls: toolCalls },
540
+ finish_reason: finishReason
541
+ }
542
+ ]
543
+ }
544
+ } : {}
384
545
  };
385
546
  } catch (error) {
386
547
  this.logger.error(
@@ -446,9 +607,11 @@ var OpenAICompatibleProvider = class {
446
607
  0 && (module.exports = {
447
608
  DEFAULT_REQUEST_TIMEOUT_MS,
448
609
  OpenAICompatibleProvider,
610
+ applyProfileParameters,
449
611
  buildRequestAbortSignal,
450
612
  buildSdkClientOptions,
451
613
  buildSdkRequestOptions,
614
+ collapseLeadingSystemMessages,
452
615
  resolveRequestTimeoutMs
453
616
  });
454
617
  //# sourceMappingURL=index.js.map