@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.
package/dist/index.mjs CHANGED
@@ -352,6 +352,36 @@ function buildRequestAbortSignal(params, defaultMs = DEFAULT_REQUEST_TIMEOUT_MS)
352
352
  return AbortSignal.timeout(resolveRequestTimeoutMs(params, defaultMs));
353
353
  }
354
354
 
355
+ // src/providers/profile-parameters.ts
356
+ function applyProfileParameters(body, params, allowlist, normalize) {
357
+ const profile = params.profileParameters;
358
+ if (!profile || typeof profile !== "object") return;
359
+ const bag = profile;
360
+ for (const key of allowlist) {
361
+ const raw = bag[key];
362
+ if (raw === void 0 || raw === null) continue;
363
+ if (typeof raw === "string" && raw === "") continue;
364
+ const value = normalize ? normalize(key, raw, params, body) : raw;
365
+ if (value === void 0) continue;
366
+ body[key] = value;
367
+ }
368
+ }
369
+
370
+ // src/providers/system-messages.ts
371
+ function collapseLeadingSystemMessages(messages) {
372
+ let runLength = 0;
373
+ while (runLength < messages.length && messages[runLength].role === "system") {
374
+ runLength++;
375
+ }
376
+ if (runLength < 2) return messages;
377
+ const run = messages.slice(0, runLength);
378
+ const merged = {
379
+ ...run[0],
380
+ content: run.map((m) => m.content ?? "").filter((c) => c.length > 0).join("\n\n")
381
+ };
382
+ return [merged, ...messages.slice(runLength)];
383
+ }
384
+
355
385
  // src/providers/openai-compatible.ts
356
386
  var DEFAULT_MAX_RETRIES = 2;
357
387
  var OpenAICompatibleProvider = class {
@@ -367,6 +397,28 @@ var OpenAICompatibleProvider = class {
367
397
  this.supportedMimeTypes = [];
368
398
  /** Web search is not supported */
369
399
  this.supportsWebSearch = false;
400
+ /**
401
+ * Whether the endpoint tolerates more than one leading `system` message.
402
+ *
403
+ * True for every hosted service — and so the default, which keeps the
404
+ * outbound bytes of OpenRouter, Z.AI and every other subclass exactly as they
405
+ * were. A subclass that points at a local runtime applying the *model's* chat
406
+ * template sets this false: several template families reject a system message
407
+ * anywhere but index 0, and Quilltap's context builder deliberately emits up
408
+ * to three of them so its cache breakpoints survive. See Bug 82 and
409
+ * {@link collapseLeadingSystemMessages}.
410
+ */
411
+ this.acceptsRepeatedSystemMessages = true;
412
+ /**
413
+ * Profile-parameter keys this provider forwards from
414
+ * `LLMParams.profileParameters` onto the request body.
415
+ *
416
+ * **Empty by default**, so a subclass that declares nothing sends exactly the
417
+ * bytes it sent before this mechanism existed. Subclasses opt in by
418
+ * overriding with their own allow-list; `model`, `messages`, `stream`,
419
+ * `stream_options` and `tools` must never appear in one.
420
+ */
421
+ this.profileParamAllowlist = [];
370
422
  if (typeof config === "string") {
371
423
  this.baseUrl = config;
372
424
  this.providerName = "OpenAICompatible";
@@ -384,6 +436,14 @@ var OpenAICompatibleProvider = class {
384
436
  }
385
437
  this.logger = createPluginLogger(`${this.providerName}Provider`);
386
438
  }
439
+ /**
440
+ * Apply {@link acceptsRepeatedSystemMessages} to a mapped wire-message array.
441
+ * Called by both body builds so the streaming and non-streaming shapes cannot
442
+ * drift apart.
443
+ */
444
+ applySystemMessagePolicy(messages) {
445
+ return this.acceptsRepeatedSystemMessages ? messages : collapseLeadingSystemMessages(messages);
446
+ }
387
447
  /**
388
448
  * Collects attachment failures for messages with attachments.
389
449
  * Since attachments are not supported, all attachments are marked as failed.
@@ -454,6 +514,53 @@ var OpenAICompatibleProvider = class {
454
514
  buildRequestOptions(params) {
455
515
  return buildSdkRequestOptions(params);
456
516
  }
517
+ /**
518
+ * Per-key hook for allow-listed profile parameters. The default forwards the
519
+ * stored value verbatim.
520
+ *
521
+ * Override to reshape a value (the editor stores flat strings where some APIs
522
+ * want objects), to gate a key on the model, or — by writing into `body` and
523
+ * returning `undefined` — to send it under a different key entirely.
524
+ */
525
+ normalizeProfileParam(_key, value, _params, _body) {
526
+ return value;
527
+ }
528
+ /**
529
+ * Apply {@link profileParamAllowlist} to a request body under construction.
530
+ * Called by both body builds so the streaming and non-streaming shapes cannot
531
+ * drift apart.
532
+ */
533
+ applyProfileParams(body, params) {
534
+ applyProfileParameters(
535
+ body,
536
+ params,
537
+ this.profileParamAllowlist,
538
+ (key, value, callParams, target) => this.normalizeProfileParam(key, value, callParams, target)
539
+ );
540
+ }
541
+ /**
542
+ * Normalize an OpenAI-shaped `tool_calls` array into Quilltap's `ToolCall`
543
+ * shape, tolerating servers that hand back already-parsed argument objects
544
+ * where the OpenAI wire format specifies a JSON string.
545
+ */
546
+ normalizeToolCalls(toolCalls) {
547
+ if (!toolCalls || toolCalls.length === 0) return [];
548
+ const out = [];
549
+ for (const raw of toolCalls) {
550
+ const tc = raw;
551
+ if (!tc?.function?.name) continue;
552
+ const args = tc.function.arguments;
553
+ out.push({
554
+ id: tc.id ?? "",
555
+ type: "function",
556
+ function: {
557
+ name: tc.function.name,
558
+ arguments: typeof args === "string" ? args : JSON.stringify(args ?? {})
559
+ }
560
+ });
561
+ }
562
+ return out;
563
+ }
457
564
  /**
458
565
  * Sends a message and returns the complete response.
459
566
  *
@@ -465,7 +572,7 @@ var OpenAICompatibleProvider = class {
465
572
  this.validateApiKeyRequirement(apiKey);
466
573
  const attachmentResults = this.collectAttachmentFailures(params);
467
574
  const client = this.createClient(apiKey);
468
- const messages = params.messages.filter((m) => {
575
+ const mappedMessages = params.messages.filter((m) => {
469
576
  if (m.role === "tool" && !m.toolCallId) return false;
470
577
  return true;
471
578
  }).map((m) => {
@@ -492,17 +599,27 @@ var OpenAICompatibleProvider = class {
492
599
  content: m.content
493
600
  };
494
601
  });
602
+ const messages = this.applySystemMessagePolicy(mappedMessages);
603
+ const body = {
604
+ model: params.model,
605
+ messages,
606
+ temperature: params.temperature ?? 0.7,
607
+ max_tokens: params.maxTokens ?? 4096,
608
+ top_p: params.topP ?? 1,
609
+ stop: params.stop,
610
+ ...params.cacheKey ? { user: params.cacheKey } : {},
611
+ // Tools arrive only once the runtime gate (the profile's "Allow tool
612
+ // use") has passed, so no separate guard is needed here.
613
+ ...params.tools && params.tools.length > 0 ? { tools: params.tools, tool_choice: params.toolChoice ?? "auto" } : {}
614
+ };
615
+ this.applyProfileParams(body, params);
495
616
  try {
496
- const response = await client.chat.completions.create({
497
- model: params.model,
498
- messages,
499
- temperature: params.temperature ?? 0.7,
500
- max_tokens: params.maxTokens ?? 4096,
501
- top_p: params.topP ?? 1,
502
- stop: params.stop,
503
- ...params.cacheKey ? { user: params.cacheKey } : {}
504
- }, this.buildRequestOptions(params));
617
+ const response = await client.chat.completions.create(
618
+ body,
619
+ this.buildRequestOptions(params)
620
+ );
505
621
  const choice = response.choices[0];
622
+ const toolCalls = this.normalizeToolCalls(choice.message.tool_calls);
506
623
  return {
507
624
  content: choice.message.content ?? "",
508
625
  finishReason: choice.finish_reason,
@@ -512,7 +629,8 @@ var OpenAICompatibleProvider = class {
512
629
  totalTokens: response.usage?.total_tokens ?? 0
513
630
  },
514
631
  raw: response,
515
- attachmentResults
632
+ attachmentResults,
633
+ ...toolCalls.length > 0 ? { toolCalls } : {}
516
634
  };
517
635
  } catch (error) {
518
636
  this.logger.error(
@@ -534,7 +652,7 @@ var OpenAICompatibleProvider = class {
534
652
  this.validateApiKeyRequirement(apiKey);
535
653
  const attachmentResults = this.collectAttachmentFailures(params);
536
654
  const client = this.createClient(apiKey);
537
- const messages = params.messages.filter((m) => {
655
+ const mappedMessages = params.messages.filter((m) => {
538
656
  if (m.role === "tool" && !m.toolCallId) return false;
539
657
  return true;
540
658
  }).map((m) => {
@@ -561,23 +679,33 @@ var OpenAICompatibleProvider = class {
561
679
  content: m.content
562
680
  };
563
681
  });
682
+ const messages = this.applySystemMessagePolicy(mappedMessages);
683
+ const body = {
684
+ model: params.model,
685
+ messages,
686
+ temperature: params.temperature ?? 0.7,
687
+ max_tokens: params.maxTokens ?? 4096,
688
+ top_p: params.topP ?? 1,
689
+ stop: params.stop,
690
+ stream: true,
691
+ stream_options: { include_usage: true },
692
+ ...params.cacheKey ? { user: params.cacheKey } : {},
693
+ ...params.tools && params.tools.length > 0 ? { tools: params.tools, tool_choice: params.toolChoice ?? "auto" } : {}
694
+ };
695
+ this.applyProfileParams(body, params);
564
696
  try {
565
- const stream = await client.chat.completions.create({
566
- model: params.model,
567
- messages,
568
- temperature: params.temperature ?? 0.7,
569
- max_tokens: params.maxTokens ?? 4096,
570
- top_p: params.topP ?? 1,
571
- stop: params.stop,
572
- stream: true,
573
- stream_options: { include_usage: true },
574
- ...params.cacheKey ? { user: params.cacheKey } : {}
575
- }, this.buildRequestOptions(params));
697
+ const stream = await client.chat.completions.create(
698
+ body,
699
+ this.buildRequestOptions(params)
700
+ );
576
701
  let chunkCount = 0;
577
702
  let accumulatedUsage = null;
703
+ const toolCallAccumulator = /* @__PURE__ */ new Map();
704
+ let finishReason = null;
578
705
  for await (const chunk of stream) {
579
706
  chunkCount++;
580
- const content = chunk.choices[0]?.delta?.content;
707
+ const choice = chunk.choices[0];
708
+ const content = choice?.delta?.content;
581
709
  const hasUsage = chunk.usage;
582
710
  if (hasUsage) {
583
711
  accumulatedUsage = {
@@ -586,6 +714,17 @@ var OpenAICompatibleProvider = class {
586
714
  total_tokens: chunk.usage?.total_tokens
587
715
  };
588
716
  }
717
+ if (choice?.delta?.tool_calls) {
718
+ for (const tcDelta of choice.delta.tool_calls) {
719
+ const idx = tcDelta.index ?? 0;
720
+ const existing = toolCallAccumulator.get(idx) ?? { id: "", name: "", arguments: "" };
721
+ if (tcDelta.id) existing.id = tcDelta.id;
722
+ if (tcDelta.function?.name) existing.name = tcDelta.function.name;
723
+ if (tcDelta.function?.arguments) existing.arguments += tcDelta.function.arguments;
724
+ toolCallAccumulator.set(idx, existing);
725
+ }
726
+ }
727
+ if (choice?.finish_reason) finishReason = choice.finish_reason;
589
728
  if (content) {
590
729
  yield {
591
730
  content,
@@ -593,6 +732,11 @@ var OpenAICompatibleProvider = class {
593
732
  };
594
733
  }
595
734
  }
735
+ const toolCalls = Array.from(toolCallAccumulator.values()).filter((tc) => tc.name).map((tc) => ({
736
+ id: tc.id,
737
+ type: "function",
738
+ function: { name: tc.name, arguments: tc.arguments }
739
+ }));
596
740
  yield {
597
741
  content: "",
598
742
  done: true,
@@ -601,7 +745,22 @@ var OpenAICompatibleProvider = class {
601
745
  completionTokens: accumulatedUsage.completion_tokens ?? 0,
602
746
  totalTokens: accumulatedUsage.total_tokens ?? 0
603
747
  } : void 0,
604
- attachmentResults
748
+ attachmentResults,
749
+ // Only synthesized when the stream actually carried tool calls: the
750
+ // host's tool detection reads `rawResponse`, and an empty one on every
751
+ // ordinary turn would be noise in the logs for no gain.
752
+ ...toolCalls.length > 0 ? {
753
+ toolCalls,
754
+ rawResponse: {
755
+ choices: [
756
+ {
757
+ index: 0,
758
+ message: { role: "assistant", content: "", tool_calls: toolCalls },
759
+ finish_reason: finishReason
760
+ }
761
+ ]
762
+ }
763
+ } : {}
605
764
  };
606
765
  } catch (error) {
607
766
  this.logger.error(
@@ -902,7 +1061,7 @@ function rewriteLocalhostUrl(url) {
902
1061
  }
903
1062
 
904
1063
  // src/version.generated.ts
905
- var PLUGIN_UTILS_VERSION = "2.2.20";
1064
+ var PLUGIN_UTILS_VERSION = "2.4.0";
906
1065
  export {
907
1066
  BUILTIN_TOOL_NAMES,
908
1067
  DEFAULT_REQUEST_TIMEOUT_MS,
@@ -913,9 +1072,11 @@ export {
913
1072
  __injectCoreLoggerFactory,
914
1073
  __injectQuilltapVersion,
915
1074
  applyDescriptionLimit,
1075
+ applyProfileParameters,
916
1076
  buildRequestAbortSignal,
917
1077
  buildSdkClientOptions,
918
1078
  buildSdkRequestOptions,
1079
+ collapseLeadingSystemMessages,
919
1080
  convertFromAnthropicFormat,
920
1081
  convertFromGoogleFormat,
921
1082
  convertOpenAIToAnthropicFormat,