@quilltap/plugin-utils 2.3.0 → 2.5.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/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  All notable changes to @quilltap/plugin-utils will be documented in this file.
4
4
 
5
+ ## [2.5.0] - 2026-08-26
6
+
7
+ ### Added
8
+
9
+ - `OpenAICompatibleProvider` gained a `protected buildRequestBody(params, stream)` — the one Chat Completions body build both `sendMessage` and `streamMessage` now call, replacing two byte-identical copies of the message mapping and body literal. Behavior-preserving: the streaming keys (`stream`, `stream_options`) keep their historical position between `stop` and `user`, so the serialized body is unchanged. Subclasses that keep the base send/stream loops get a single override point for reshaping the body.
10
+
11
+ ## [2.4.0] - 2026-08-19
12
+
13
+ ### Added
14
+
15
+ - `collapseLeadingSystemMessages(messages)` — folds a run of consecutive leading `system` messages into one, joining the contents with a blank line. Arrays with fewer than two leading system messages come back untouched (same reference).
16
+ - `OpenAICompatibleProvider` gained `acceptsRepeatedSystemMessages` (default `true`, so every existing subclass is byte-identical on the wire) and applies it through `applySystemMessagePolicy` in both the streaming and non-streaming body builds. A subclass pointing at a local runtime sets it `false`: the model's own chat template is what runs there, and the Qwen family — plus several Llama- and Gemma-derived templates — `raise_exception` on any system message after index 0, rejecting the whole request. Quilltap emits up to three leading system blocks so its cache breakpoints survive, so every non-opening turn against such a model failed outright. (Quilltap bug 82.)
17
+
5
18
  ## [2.3.0] - 2026-08-15
6
19
 
7
20
  ### Added
package/README.md CHANGED
@@ -150,6 +150,37 @@ This gives you a complete `LLMProvider` implementation with:
150
150
  npm install openai
151
151
  ```
152
152
 
153
+ ### Profile Parameters
154
+
155
+ A connection profile's `parameters` blob is free-form JSON, handed to the
156
+ provider as `LLMParams.profileParameters`. A provider that never reads it drops
157
+ every setting the user saved, in silence — so forwarding is opt-in but explicit,
158
+ and always allow-listed:
159
+
160
+ ```typescript
161
+ import { applyProfileParameters } from '@quilltap/plugin-utils';
162
+
163
+ const MY_ALLOWLIST = ['top_k', 'min_p', 'reasoning_effort'] as const;
164
+
165
+ applyProfileParameters(body, params, MY_ALLOWLIST, (key, value, callParams, target) => {
166
+ // Return the value to send, or `undefined` to omit the key.
167
+ // Write into `target` to send it under a different key entirely.
168
+ return value;
169
+ });
170
+ ```
171
+
172
+ `undefined`, `null` and the empty string omit the key — the empty string is what
173
+ the schema-driven profile editor stores for "use the model default". Never
174
+ spread the blob: `model`, `messages`, `stream`, `stream_options` and `tools`
175
+ must stay unreachable from a profile.
176
+
177
+ Subclasses of `OpenAICompatibleProvider` get this by declaring
178
+ `profileParamAllowlist` (empty by default, so a subclass that declares nothing
179
+ is byte-identical on the wire) and optionally overriding `normalizeProfileParam`;
180
+ providers that implement `TextProvider` directly call the function themselves.
181
+ The keys must match whatever the plugin's `getProviderOptionsSchema()` renders,
182
+ or a field the editor draws goes nowhere.
183
+
153
184
  ### System Prompt Plugin Utilities
154
185
 
155
186
  Create system prompt plugins that provide character prompt templates from `.md` files:
package/dist/index.d.mts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { AnthropicToolDefinition, CharacterPluginDataEntry, CharacterPluginDataMap, GoogleToolDefinition, LogContext, LogLevel, OpenAIToolDefinition, PluginLogger, ToolCall, ToolCallRequest, ToolFormatOptions, ToolResult, UniversalTool, createConsoleLogger, createNoopLogger } from '@quilltap/plugin-types';
2
2
  export { T as ToolCallFormat, a as ToolConvertTarget, b as applyDescriptionLimit, c as convertFromAnthropicFormat, d as convertFromGoogleFormat, e as convertOpenAIToAnthropicFormat, f as convertOpenAIToGoogleFormat, g as convertToAnthropicFormat, h as convertToGoogleFormat, i as convertToolTo, j as convertToolsTo, k as detectToolCallFormat, l as hasToolCalls, p as parseAnthropicToolCalls, m as parseGoogleToolCalls, n as parseOpenAIToolCalls, o as parseToolCalls } from './converters-BPYo8xdH.mjs';
3
3
  export { PluginLoggerWithChild, __clearCoreLoggerFactory, __injectCoreLoggerFactory, createPluginLogger, getLogLevelFromEnv, hasCoreLogger } from './logging/index.mjs';
4
- export { DEFAULT_REQUEST_TIMEOUT_MS, OpenAICompatibleProvider, OpenAICompatibleProviderConfig, ProfileParamNormalizer, applyProfileParameters, buildRequestAbortSignal, buildSdkClientOptions, buildSdkRequestOptions, resolveRequestTimeoutMs } from './providers/index.mjs';
4
+ export { DEFAULT_REQUEST_TIMEOUT_MS, OpenAICompatibleProvider, OpenAICompatibleProviderConfig, ProfileParamNormalizer, applyProfileParameters, buildRequestAbortSignal, buildSdkClientOptions, buildSdkRequestOptions, collapseLeadingSystemMessages, resolveRequestTimeoutMs } from './providers/index.mjs';
5
5
  export { CreateSystemPromptPluginOptions, createSystemPromptPlugin, validateSystemPromptPlugin } from './system-prompts/index.mjs';
6
6
  export { isVMEnvironment, resolveHostGateway, rewriteLocalhostUrl } from './host-rewrite.mjs';
7
7
  import 'openai';
@@ -124,6 +124,6 @@ declare function getQuilltapUserAgent(): string;
124
124
  * Version of the plugin-utils package.
125
125
  * Can be used at runtime to check compatibility.
126
126
  */
127
- declare const PLUGIN_UTILS_VERSION = "2.3.0";
127
+ declare const PLUGIN_UTILS_VERSION = "2.5.0";
128
128
 
129
129
  export { BUILTIN_TOOL_NAMES, PLUGIN_UTILS_VERSION, __clearQuilltapVersion, __injectQuilltapVersion, getBuiltinToolNames, getQuilltapUserAgent, getQuilltapVersion };
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { AnthropicToolDefinition, CharacterPluginDataEntry, CharacterPluginDataMap, GoogleToolDefinition, LogContext, LogLevel, OpenAIToolDefinition, PluginLogger, ToolCall, ToolCallRequest, ToolFormatOptions, ToolResult, UniversalTool, createConsoleLogger, createNoopLogger } from '@quilltap/plugin-types';
2
2
  export { T as ToolCallFormat, a as ToolConvertTarget, b as applyDescriptionLimit, c as convertFromAnthropicFormat, d as convertFromGoogleFormat, e as convertOpenAIToAnthropicFormat, f as convertOpenAIToGoogleFormat, g as convertToAnthropicFormat, h as convertToGoogleFormat, i as convertToolTo, j as convertToolsTo, k as detectToolCallFormat, l as hasToolCalls, p as parseAnthropicToolCalls, m as parseGoogleToolCalls, n as parseOpenAIToolCalls, o as parseToolCalls } from './converters-BPYo8xdH.js';
3
3
  export { PluginLoggerWithChild, __clearCoreLoggerFactory, __injectCoreLoggerFactory, createPluginLogger, getLogLevelFromEnv, hasCoreLogger } from './logging/index.js';
4
- export { DEFAULT_REQUEST_TIMEOUT_MS, OpenAICompatibleProvider, OpenAICompatibleProviderConfig, ProfileParamNormalizer, applyProfileParameters, buildRequestAbortSignal, buildSdkClientOptions, buildSdkRequestOptions, resolveRequestTimeoutMs } from './providers/index.js';
4
+ export { DEFAULT_REQUEST_TIMEOUT_MS, OpenAICompatibleProvider, OpenAICompatibleProviderConfig, ProfileParamNormalizer, applyProfileParameters, buildRequestAbortSignal, buildSdkClientOptions, buildSdkRequestOptions, collapseLeadingSystemMessages, resolveRequestTimeoutMs } from './providers/index.js';
5
5
  export { CreateSystemPromptPluginOptions, createSystemPromptPlugin, validateSystemPromptPlugin } from './system-prompts/index.js';
6
6
  export { isVMEnvironment, resolveHostGateway, rewriteLocalhostUrl } from './host-rewrite.js';
7
7
  import 'openai';
@@ -124,6 +124,6 @@ declare function getQuilltapUserAgent(): string;
124
124
  * Version of the plugin-utils package.
125
125
  * Can be used at runtime to check compatibility.
126
126
  */
127
- declare const PLUGIN_UTILS_VERSION = "2.3.0";
127
+ declare const PLUGIN_UTILS_VERSION = "2.5.0";
128
128
 
129
129
  export { BUILTIN_TOOL_NAMES, PLUGIN_UTILS_VERSION, __clearQuilltapVersion, __injectQuilltapVersion, getBuiltinToolNames, getQuilltapUserAgent, getQuilltapVersion };
package/dist/index.js CHANGED
@@ -43,6 +43,7 @@ __export(src_exports, {
43
43
  buildRequestAbortSignal: () => buildRequestAbortSignal,
44
44
  buildSdkClientOptions: () => buildSdkClientOptions,
45
45
  buildSdkRequestOptions: () => buildSdkRequestOptions,
46
+ collapseLeadingSystemMessages: () => collapseLeadingSystemMessages,
46
47
  convertFromAnthropicFormat: () => convertFromAnthropicFormat,
47
48
  convertFromGoogleFormat: () => convertFromGoogleFormat,
48
49
  convertOpenAIToAnthropicFormat: () => convertOpenAIToAnthropicFormat,
@@ -443,6 +444,21 @@ function applyProfileParameters(body, params, allowlist, normalize) {
443
444
  }
444
445
  }
445
446
 
447
+ // src/providers/system-messages.ts
448
+ function collapseLeadingSystemMessages(messages) {
449
+ let runLength = 0;
450
+ while (runLength < messages.length && messages[runLength].role === "system") {
451
+ runLength++;
452
+ }
453
+ if (runLength < 2) return messages;
454
+ const run = messages.slice(0, runLength);
455
+ const merged = {
456
+ ...run[0],
457
+ content: run.map((m) => m.content ?? "").filter((c) => c.length > 0).join("\n\n")
458
+ };
459
+ return [merged, ...messages.slice(runLength)];
460
+ }
461
+
446
462
  // src/providers/openai-compatible.ts
447
463
  var DEFAULT_MAX_RETRIES = 2;
448
464
  var OpenAICompatibleProvider = class {
@@ -458,6 +474,18 @@ var OpenAICompatibleProvider = class {
458
474
  this.supportedMimeTypes = [];
459
475
  /** Web search is not supported */
460
476
  this.supportsWebSearch = false;
477
+ /**
478
+ * Whether the endpoint tolerates more than one leading `system` message.
479
+ *
480
+ * True for every hosted service — and so the default, which keeps the
481
+ * outbound bytes of OpenRouter, Z.AI and every other subclass exactly as they
482
+ * were. A subclass that points at a local runtime applying the *model's* chat
483
+ * template sets this false: several template families reject a system message
484
+ * anywhere but index 0, and Quilltap's context builder deliberately emits up
485
+ * to three of them so its cache breakpoints survive. See Bug 82 and
486
+ * {@link collapseLeadingSystemMessages}.
487
+ */
488
+ this.acceptsRepeatedSystemMessages = true;
461
489
  /**
462
490
  * Profile-parameter keys this provider forwards from
463
491
  * `LLMParams.profileParameters` onto the request body.
@@ -485,6 +513,14 @@ var OpenAICompatibleProvider = class {
485
513
  }
486
514
  this.logger = createPluginLogger(`${this.providerName}Provider`);
487
515
  }
516
+ /**
517
+ * Apply {@link acceptsRepeatedSystemMessages} to a mapped wire-message array.
518
+ * Called by both body builds so the streaming and non-streaming shapes cannot
519
+ * drift apart.
520
+ */
521
+ applySystemMessagePolicy(messages) {
522
+ return this.acceptsRepeatedSystemMessages ? messages : collapseLeadingSystemMessages(messages);
523
+ }
488
524
  /**
489
525
  * Collects attachment failures for messages with attachments.
490
526
  * Since attachments are not supported, all attachments are marked as failed.
@@ -603,17 +639,22 @@ var OpenAICompatibleProvider = class {
603
639
  return out;
604
640
  }
605
641
  /**
606
- * Sends a message and returns the complete response.
642
+ * Build the Chat Completions request body — the ONE build both
643
+ * {@link sendMessage} and {@link streamMessage} call, so the streaming and
644
+ * non-streaming shapes cannot drift apart. Maps Quilltap messages to the
645
+ * OpenAI wire format (tool results and assistant tool calls included),
646
+ * applies {@link applySystemMessagePolicy}, assembles the body literal, and
647
+ * finishes with {@link applyProfileParams}.
607
648
  *
608
- * @param params - LLM parameters including messages, model, and settings
609
- * @param apiKey - API key for authentication
610
- * @returns Complete LLM response with content and usage statistics
649
+ * Subclasses that keep the base class's send/stream loops can override this
650
+ * to reshape the body in one place.
651
+ *
652
+ * @param params - LLM parameters for the request
653
+ * @param stream - Whether the body is for a streaming request; adds
654
+ * `stream: true` and `stream_options: { include_usage: true }`
611
655
  */
612
- async sendMessage(params, apiKey) {
613
- this.validateApiKeyRequirement(apiKey);
614
- const attachmentResults = this.collectAttachmentFailures(params);
615
- const client = this.createClient(apiKey);
616
- const messages = params.messages.filter((m) => {
656
+ buildRequestBody(params, stream) {
657
+ const mappedMessages = params.messages.filter((m) => {
617
658
  if (m.role === "tool" && !m.toolCallId) return false;
618
659
  return true;
619
660
  }).map((m) => {
@@ -640,6 +681,7 @@ var OpenAICompatibleProvider = class {
640
681
  content: m.content
641
682
  };
642
683
  });
684
+ const messages = this.applySystemMessagePolicy(mappedMessages);
643
685
  const body = {
644
686
  model: params.model,
645
687
  messages,
@@ -647,12 +689,30 @@ var OpenAICompatibleProvider = class {
647
689
  max_tokens: params.maxTokens ?? 4096,
648
690
  top_p: params.topP ?? 1,
649
691
  stop: params.stop,
692
+ // Spread here — not appended after the literal — so the streaming keys
693
+ // keep their historical position between `stop` and `user` and the
694
+ // serialized body stays byte-identical.
695
+ ...stream ? { stream: true, stream_options: { include_usage: true } } : {},
650
696
  ...params.cacheKey ? { user: params.cacheKey } : {},
651
697
  // Tools arrive only once the runtime gate (the profile's "Allow tool
652
698
  // use") has passed, so no separate guard is needed here.
653
699
  ...params.tools && params.tools.length > 0 ? { tools: params.tools, tool_choice: params.toolChoice ?? "auto" } : {}
654
700
  };
655
701
  this.applyProfileParams(body, params);
702
+ return body;
703
+ }
704
+ /**
705
+ * Sends a message and returns the complete response.
706
+ *
707
+ * @param params - LLM parameters including messages, model, and settings
708
+ * @param apiKey - API key for authentication
709
+ * @returns Complete LLM response with content and usage statistics
710
+ */
711
+ async sendMessage(params, apiKey) {
712
+ this.validateApiKeyRequirement(apiKey);
713
+ const attachmentResults = this.collectAttachmentFailures(params);
714
+ const client = this.createClient(apiKey);
715
+ const body = this.buildRequestBody(params, false);
656
716
  try {
657
717
  const response = await client.chat.completions.create(
658
718
  body,
@@ -692,46 +752,7 @@ var OpenAICompatibleProvider = class {
692
752
  this.validateApiKeyRequirement(apiKey);
693
753
  const attachmentResults = this.collectAttachmentFailures(params);
694
754
  const client = this.createClient(apiKey);
695
- const messages = params.messages.filter((m) => {
696
- if (m.role === "tool" && !m.toolCallId) return false;
697
- return true;
698
- }).map((m) => {
699
- if (m.role === "tool" && m.toolCallId) {
700
- return {
701
- role: "tool",
702
- tool_call_id: m.toolCallId,
703
- content: m.content
704
- };
705
- }
706
- if (m.role === "assistant" && m.toolCalls && m.toolCalls.length > 0) {
707
- return {
708
- role: "assistant",
709
- content: m.content || null,
710
- tool_calls: m.toolCalls.map((tc) => ({
711
- id: tc.id,
712
- type: tc.type,
713
- function: tc.function
714
- }))
715
- };
716
- }
717
- return {
718
- role: m.role,
719
- content: m.content
720
- };
721
- });
722
- const body = {
723
- model: params.model,
724
- messages,
725
- temperature: params.temperature ?? 0.7,
726
- max_tokens: params.maxTokens ?? 4096,
727
- top_p: params.topP ?? 1,
728
- stop: params.stop,
729
- stream: true,
730
- stream_options: { include_usage: true },
731
- ...params.cacheKey ? { user: params.cacheKey } : {},
732
- ...params.tools && params.tools.length > 0 ? { tools: params.tools, tool_choice: params.toolChoice ?? "auto" } : {}
733
- };
734
- this.applyProfileParams(body, params);
755
+ const body = this.buildRequestBody(params, true);
735
756
  try {
736
757
  const stream = await client.chat.completions.create(
737
758
  body,
@@ -1100,7 +1121,7 @@ function rewriteLocalhostUrl(url) {
1100
1121
  }
1101
1122
 
1102
1123
  // src/version.generated.ts
1103
- var PLUGIN_UTILS_VERSION = "2.3.0";
1124
+ var PLUGIN_UTILS_VERSION = "2.5.0";
1104
1125
  // Annotate the CommonJS export names for ESM import in node:
1105
1126
  0 && (module.exports = {
1106
1127
  BUILTIN_TOOL_NAMES,
@@ -1116,6 +1137,7 @@ var PLUGIN_UTILS_VERSION = "2.3.0";
1116
1137
  buildRequestAbortSignal,
1117
1138
  buildSdkClientOptions,
1118
1139
  buildSdkRequestOptions,
1140
+ collapseLeadingSystemMessages,
1119
1141
  convertFromAnthropicFormat,
1120
1142
  convertFromGoogleFormat,
1121
1143
  convertOpenAIToAnthropicFormat,