rosetta-ai 1.4.0 โ†’ 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,6 +15,7 @@ Rosetta converts messages between different LLM providers using [**GenAI**](http
15
15
  - ๐Ÿ“ Full TypeScript support with strict types
16
16
  - โœ… Runtime validation with Zod schemas
17
17
  - ๐Ÿ’พ Preserve provider-specific metadata for lossless round-trips
18
+ - ๐Ÿ“Œ System message order preservation - system messages retain their original position in conversation when translating between providers
18
19
  - ๐ŸŒ Works in Node.js and browsers
19
20
  - ๐ŸŒณ Tree-shakeable ESM build
20
21
 
@@ -292,6 +293,8 @@ if (result.error) {
292
293
  - **fromGenAI** = Can translate *to* this provider from GenAI (target)
293
294
  - **Separated System** = Provider separates system instructions from messages (use the `system` option if needed)
294
295
 
296
+ **System message order preservation**: When translating to a provider that separates system instructions (like GenAI), system messages are extracted from the conversation and returned in the `system` field. Rosetta preserves the original position of each system message so that when translating back to a provider with inline system messages (like Promptl or Vercel AI), the system messages are re-inserted at their original positions in the conversation.
297
+
295
298
  ### Universal Compatibility
296
299
 
297
300
  The **Compat** provider is a universal fallback that handles messages from *any* LLM providerโ€”even ones not explicitly supported. When you call `translate()` without specifying a source provider, Rosetta tries to match against known provider schemas. If none match, it automatically falls back to Compat, which:
@@ -368,6 +371,10 @@ const message: GenAIMessage = {
368
371
  toolName: "get_weather", // Tool name (GenAI schema doesn't include it)
369
372
  isError: true, // Error indicator
370
373
  },
374
+ // Parts metadata - collapsed part-level metadata (for providers with string-only content)
375
+ _partsMetadata: {
376
+ _promptlSourceMap: [...], // Part metadata moved to message level
377
+ },
371
378
  // Extra fields - any other provider-specific data
372
379
  annotations: [...],
373
380
  },
@@ -375,6 +382,8 @@ const message: GenAIMessage = {
375
382
  };
376
383
  ```
377
384
 
385
+ **Note on `_partsMetadata`**: Some providers require string content for certain message types (e.g., VercelAI system messages). When translating to these providers, part-level metadata is collected and stored in `_partsMetadata` at the message level. When translating back to a provider that supports structured content, this metadata is automatically restored to the first content part. **Important**: In passthrough mode, if the target provider doesn't support structured content (like VercelAI system messages), part-level metadata stored in `_partsMetadata` will be lost. Use **preserve** mode if you need to retain this metadata through round-trips.
386
+
378
387
  #### Provider Metadata Mode
379
388
 
380
389
  The `providerMetadata` option controls how metadata (extra fields) is handled in the output.
package/dist/index.cjs CHANGED
@@ -221,10 +221,15 @@ function extractExtraFields(obj, knownKeys) {
221
221
  * Reads metadata from an entity, checking both casings (snake_case and camelCase).
222
222
  * This is necessary because messages may have been previously translated by Rosetta
223
223
  * with different target providers (GenAI uses snake_case, VercelAI uses camelCase).
224
+ * Returns undefined if metadata is not present or is an empty object.
224
225
  */
225
226
  function readMetadata(entity) {
226
227
  // biome-ignore lint/complexity/useLiteralKeys: required for index signature access
227
- return (entity["_provider_metadata"] ?? entity["_providerMetadata"]);
228
+ const metadata = (entity["_provider_metadata"] ?? entity["_providerMetadata"]);
229
+ // Return undefined for empty objects to prevent empty metadata from propagating
230
+ if (!metadata || Object.keys(metadata).length === 0)
231
+ return undefined;
232
+ return metadata;
228
233
  }
229
234
  /**
230
235
  * Extracts known fields from metadata, checking both casings.
@@ -240,8 +245,23 @@ function getKnownFields(metadata) {
240
245
  isError: known?.isError,
241
246
  isRefusal: known?.isRefusal,
242
247
  originalType: known?.originalType,
248
+ messageIndex: known?.messageIndex,
243
249
  };
244
250
  }
251
+ /**
252
+ * Extracts parts metadata from message metadata, checking both casings.
253
+ * Parts metadata is used to preserve part-level metadata when converting to providers
254
+ * that require string content (e.g., VercelAI system messages).
255
+ *
256
+ * @param metadata - The message metadata to extract from
257
+ * @returns The parts metadata object, or undefined if not present
258
+ */
259
+ function getPartsMetadata(metadata) {
260
+ if (!metadata)
261
+ return undefined;
262
+ // biome-ignore lint/complexity/useLiteralKeys: required for index signature access
263
+ return (metadata["_parts_metadata"] ?? metadata["_partsMetadata"]);
264
+ }
245
265
  /**
246
266
  * Stores metadata on a GenAI entity, merging with any existing metadata.
247
267
  * This is used in toGenAI to build the _provider_metadata field.
@@ -284,7 +304,8 @@ function storeMetadata(existingMetadata, extraFields, knownFields) {
284
304
  * @returns The entity with metadata applied according to the mode
285
305
  */
286
306
  function applyMetadataMode(entity, metadata, mode, useCamelCase = true) {
287
- if (!metadata)
307
+ // Return early if no metadata or empty metadata object
308
+ if (!metadata || Object.keys(metadata).length === 0)
288
309
  return entity;
289
310
  switch (mode) {
290
311
  case "strip":
@@ -294,20 +315,30 @@ function applyMetadataMode(entity, metadata, mode, useCamelCase = true) {
294
315
  // Add metadata field with target provider's casing
295
316
  const metadataKey = useCamelCase ? "_providerMetadata" : "_provider_metadata";
296
317
  const knownFieldsKey = useCamelCase ? "_knownFields" : "_known_fields";
297
- // Normalize the known fields key in the metadata
298
- const { _known_fields, _knownFields, ...rest } = metadata;
318
+ const partsMetadataKey = useCamelCase ? "_partsMetadata" : "_parts_metadata";
319
+ // Normalize the known fields and parts metadata keys in the metadata
320
+ const { _known_fields, _knownFields, _parts_metadata, _partsMetadata, ...rest } = metadata;
299
321
  const knownFields = _known_fields ?? _knownFields;
322
+ const partsMetadata = _parts_metadata ?? _partsMetadata;
300
323
  const hasKnownFields = knownFields && Object.keys(knownFields).length > 0;
301
- const normalizedMetadata = hasKnownFields ? { ...rest, [knownFieldsKey]: knownFields } : rest;
324
+ const hasPartsMetadata = partsMetadata && Object.keys(partsMetadata).length > 0;
325
+ const normalizedMetadata = { ...rest };
326
+ if (hasKnownFields) {
327
+ normalizedMetadata[knownFieldsKey] = knownFields;
328
+ }
329
+ if (hasPartsMetadata) {
330
+ normalizedMetadata[partsMetadataKey] = partsMetadata;
331
+ }
302
332
  // Only add metadata field if there's something to store
303
333
  if (Object.keys(normalizedMetadata).length === 0)
304
334
  return entity;
305
335
  return { ...entity, [metadataKey]: normalizedMetadata };
306
336
  }
307
337
  case "passthrough": {
308
- // Spread all fields EXCEPT known fields (either casing)
309
- const { _known_fields, _knownFields, ...extraFields } = metadata;
310
- // Only spread if there are extra fields
338
+ // Spread all extra fields EXCEPT known fields and parts metadata (either casing)
339
+ // _partsMetadata should be restored to parts by the provider's fromGenAI, not spread at entity level
340
+ const { _known_fields, _knownFields, _parts_metadata, _partsMetadata, ...extraFields } = metadata;
341
+ // Only spread if there are extra fields to add
311
342
  if (Object.keys(extraFields).length === 0)
312
343
  return entity;
313
344
  return { ...entity, ...extraFields };
@@ -1543,12 +1574,27 @@ const KnownFieldsSchema = zod.z
1543
1574
  isRefusal: zod.z.boolean().optional(),
1544
1575
  /** Original type when mapping to a different GenAI type (for lossy conversions) */
1545
1576
  originalType: zod.z.string().optional(),
1577
+ /** Original message index for system parts extracted into a separate system array */
1578
+ messageIndex: zod.z.number().optional(),
1579
+ })
1580
+ .passthrough();
1581
+ /**
1582
+ * Schema for collapsed part-level metadata (used when target doesn't support structured content).
1583
+ * Has the same structure as part metadata: known fields + extra fields.
1584
+ */
1585
+ const PartsMetadataSchema = zod.z
1586
+ .object({
1587
+ /** Known fields from collapsed parts */
1588
+ _known_fields: KnownFieldsSchema.optional(),
1589
+ /** Also check camelCase version */
1590
+ _knownFields: KnownFieldsSchema.optional(),
1546
1591
  })
1547
1592
  .passthrough();
1548
1593
  /**
1549
1594
  * Provider metadata schema for preserving provider-specific data.
1550
1595
  * This is a flat structure with:
1551
1596
  * - `_known_fields`: Internal fields used for building correct translations
1597
+ * - `_parts_metadata`: Collapsed part-level metadata (when target uses string content)
1552
1598
  * - All other fields: Extra data from the source provider, passed through
1553
1599
  *
1554
1600
  * The metadata is stored at `_provider_metadata` on GenAI entities.
@@ -1561,6 +1607,10 @@ const ProviderMetadataSchema = zod.z
1561
1607
  _known_fields: KnownFieldsSchema.optional(),
1562
1608
  /** Also check camelCase version (from VercelAI/Promptl targets) */
1563
1609
  _knownFields: KnownFieldsSchema.optional(),
1610
+ /** Collapsed part-level metadata (when target uses string content) */
1611
+ _parts_metadata: PartsMetadataSchema.optional(),
1612
+ /** Also check camelCase version (from VercelAI/Promptl targets) */
1613
+ _partsMetadata: PartsMetadataSchema.optional(),
1564
1614
  })
1565
1615
  .passthrough();
1566
1616
  /** Role of the entity that created the message. */
@@ -1690,22 +1740,44 @@ const GenAISpecification = {
1690
1740
  }
1691
1741
  const parsedSystem = GenAISystemSchema.optional().parse(system);
1692
1742
  if (parsedSystem && parsedSystem.length > 0) {
1693
- parsedMessages.unshift({ role: "system", parts: parsedSystem });
1743
+ const partsByIndex = groupPartsByMessageIndex(parsedSystem);
1744
+ if (partsByIndex) {
1745
+ // Insert system messages at their original positions (lowest index first).
1746
+ // Ascending order is correct because messageIndex refers to the position in the full
1747
+ // conversation (including system messages). As we insert left-to-right, each insertion
1748
+ // naturally shifts subsequent positions to account for the added messages.
1749
+ const sortedIndices = [...partsByIndex.keys()].sort((a, b) => a - b);
1750
+ for (const index of sortedIndices) {
1751
+ // biome-ignore lint/style/noNonNullAssertion: key comes from the map
1752
+ const parts = partsByIndex.get(index);
1753
+ // Clamp to valid range: -1 (no index) and 0 both go to position 0
1754
+ const insertAt = index < 0 ? 0 : Math.min(index, parsedMessages.length);
1755
+ parsedMessages.splice(insertAt, 0, { role: "system", parts });
1756
+ }
1757
+ }
1758
+ else {
1759
+ // No parts have messageIndex - fall back to prepending all as one system message
1760
+ parsedMessages.unshift({ role: "system", parts: parsedSystem });
1761
+ }
1694
1762
  }
1695
1763
  return { messages: parsedMessages };
1696
1764
  },
1697
1765
  fromGenAI({ messages, providerMetadata }) {
1698
1766
  const system = [];
1699
1767
  const filtered = [];
1700
- for (const message of messages) {
1768
+ for (let i = 0; i < messages.length; i++) {
1769
+ // biome-ignore lint/style/noNonNullAssertion: index is within bounds
1770
+ const message = messages[i];
1771
+ // Apply metadata mode to message and its parts (handles _partsMetadata restoration)
1772
+ const transformed = applyMetadataModeToGenAIMessage(message, providerMetadata);
1701
1773
  if (message.role === "system") {
1702
- // Apply metadata mode to system parts
1703
- const transformedParts = message.parts.map((part) => applyMetadataModeToGenAI(part, providerMetadata));
1704
- system.push(...transformedParts);
1774
+ // Store the original message index on each part so the position can be reconstructed
1775
+ for (const part of transformed.parts) {
1776
+ system.push(storeMessageIndexOnPart(part, i));
1777
+ }
1705
1778
  }
1706
1779
  else {
1707
- // Apply metadata mode to message and its parts
1708
- filtered.push(applyMetadataModeToGenAIMessage(message, providerMetadata));
1780
+ filtered.push(transformed);
1709
1781
  }
1710
1782
  }
1711
1783
  return { messages: filtered, system: system.length > 0 ? system : undefined };
@@ -1722,11 +1794,60 @@ function applyMetadataModeToGenAI(part, mode) {
1722
1794
  function applyMetadataModeToGenAIMessage(message, mode) {
1723
1795
  const msgMetadata = readMetadata(message);
1724
1796
  const transformedParts = message.parts.map((part) => applyMetadataModeToGenAI(part, mode));
1797
+ // Extract _partsMetadata from message metadata and apply to first part
1798
+ // This restores part-level metadata that was merged when converting to string content
1799
+ const partsMetadata = getPartsMetadata(msgMetadata);
1800
+ if (partsMetadata && transformedParts.length > 0) {
1801
+ // biome-ignore lint/style/noNonNullAssertion: length check guarantees element exists
1802
+ transformedParts[0] = applyMetadataMode(transformedParts[0], partsMetadata, mode, false);
1803
+ }
1725
1804
  // Remove existing metadata keys before applying mode
1726
1805
  const { _provider_metadata, _providerMetadata, parts, ...baseMessage } = message;
1727
1806
  const baseWithParts = { ...baseMessage, parts: transformedParts };
1728
1807
  return applyMetadataMode(baseWithParts, msgMetadata, mode, false);
1729
1808
  }
1809
+ /** Store the original message index on a system part via _provider_metadata._known_fields. */
1810
+ function storeMessageIndexOnPart(part, index) {
1811
+ const existingMetadata = readMetadata(part);
1812
+ const metadata = storeMetadata(existingMetadata ?? {}, {}, { messageIndex: index });
1813
+ if (!metadata)
1814
+ return part;
1815
+ return { ...part, _provider_metadata: metadata };
1816
+ }
1817
+ /**
1818
+ * Groups system parts by their messageIndex known field.
1819
+ * Returns a Map from index to parts array, or null if no parts have a messageIndex.
1820
+ */
1821
+ function groupPartsByMessageIndex(parts) {
1822
+ let hasAnyIndex = false;
1823
+ const grouped = new Map();
1824
+ for (const part of parts) {
1825
+ const metadata = readMetadata(part);
1826
+ const known = getKnownFields(metadata);
1827
+ if (known.messageIndex !== undefined) {
1828
+ hasAnyIndex = true;
1829
+ const idx = known.messageIndex;
1830
+ const existing = grouped.get(idx);
1831
+ if (existing) {
1832
+ existing.push(part);
1833
+ }
1834
+ else {
1835
+ grouped.set(idx, [part]);
1836
+ }
1837
+ }
1838
+ else {
1839
+ // Parts without an index go into a group keyed by -1 (will be prepended)
1840
+ const existing = grouped.get(-1);
1841
+ if (existing) {
1842
+ existing.push(part);
1843
+ }
1844
+ else {
1845
+ grouped.set(-1, [part]);
1846
+ }
1847
+ }
1848
+ }
1849
+ return hasAnyIndex ? grouped : null;
1850
+ }
1730
1851
 
1731
1852
  /**
1732
1853
  * Google Gemini API Schemas
@@ -3684,6 +3805,15 @@ function genAIMessageToPromptl(message, toolCallNameMap, mode) {
3684
3805
  toolMessages.push(createToolMessageFromPart(part, toolCallNameMap, mode));
3685
3806
  }
3686
3807
  }
3808
+ // Apply _partsMetadata to the first tool message's content if present
3809
+ const partsMetadata = getPartsMetadata(msgMetadata);
3810
+ if (partsMetadata && toolMessages.length > 0) {
3811
+ const firstMsg = toolMessages[0];
3812
+ if (firstMsg.content.length > 0) {
3813
+ // biome-ignore lint/style/noNonNullAssertion: length check guarantees element exists
3814
+ firstMsg.content[0] = applyMetadataMode(firstMsg.content[0], partsMetadata, mode, true);
3815
+ }
3816
+ }
3687
3817
  return toolMessages.length > 0 ? toolMessages : [];
3688
3818
  }
3689
3819
  // Handle assistant role - check if any parts are tool_call_response
@@ -3707,10 +3837,27 @@ function genAIMessageToPromptl(message, toolCallNameMap, mode) {
3707
3837
  }
3708
3838
  }
3709
3839
  if (content.length > 0) {
3840
+ // Extract _partsMetadata from message metadata and apply to first content part
3841
+ const partsMetadata = getPartsMetadata(msgMetadata);
3842
+ if (partsMetadata) {
3843
+ // biome-ignore lint/style/noNonNullAssertion: length check guarantees element exists
3844
+ content[0] = applyMetadataMode(content[0], partsMetadata, mode, true);
3845
+ }
3710
3846
  const assistantMsg = { role: "assistant", content };
3711
3847
  result.unshift(applyMetadataMode(assistantMsg, msgMetadata, mode, true));
3712
3848
  }
3713
3849
  }
3850
+ else {
3851
+ // No other parts - apply _partsMetadata to the first tool message's content
3852
+ const partsMetadata = getPartsMetadata(msgMetadata);
3853
+ if (partsMetadata && result.length > 0) {
3854
+ const firstMsg = result[0];
3855
+ if (firstMsg.content.length > 0) {
3856
+ // biome-ignore lint/style/noNonNullAssertion: length check guarantees element exists
3857
+ firstMsg.content[0] = applyMetadataMode(firstMsg.content[0], partsMetadata, mode, true);
3858
+ }
3859
+ }
3860
+ }
3714
3861
  return result;
3715
3862
  }
3716
3863
  }
@@ -3722,6 +3869,13 @@ function genAIMessageToPromptl(message, toolCallNameMap, mode) {
3722
3869
  content.push(converted);
3723
3870
  }
3724
3871
  }
3872
+ // Extract _partsMetadata from message metadata and apply to first content part
3873
+ // This restores part-level metadata that was merged when converting to string content
3874
+ const partsMetadata = getPartsMetadata(msgMetadata);
3875
+ if (partsMetadata && content.length > 0) {
3876
+ // biome-ignore lint/style/noNonNullAssertion: length check guarantees element exists
3877
+ content[0] = applyMetadataMode(content[0], partsMetadata, mode, true);
3878
+ }
3725
3879
  // Map role - Promptl only accepts specific roles
3726
3880
  let role;
3727
3881
  if (message.role === "system") {
@@ -4472,6 +4626,14 @@ function genAIMessageToVercelAI(message, toolCallNameMap, mode) {
4472
4626
  const msgMetadata = readMetadata(message);
4473
4627
  // Helper to apply metadata mode to a message
4474
4628
  const applyMode = (msg) => applyMetadataMode(msg, msgMetadata, mode, true);
4629
+ // Helper to check if any part has metadata that should be preserved
4630
+ const hasPartMetadata = () => message.parts.some((p) => {
4631
+ const meta = readMetadata(p);
4632
+ return meta && Object.keys(meta).length > 0;
4633
+ });
4634
+ // Helper to determine if we should collapse single text part to string
4635
+ // Only collapse if mode is "strip" OR no parts have metadata
4636
+ const shouldCollapseToString = () => mode === "strip" || !hasPartMetadata();
4475
4637
  // Handle system messages
4476
4638
  if (message.role === "system") {
4477
4639
  // Extract text content
@@ -4479,8 +4641,21 @@ function genAIMessageToVercelAI(message, toolCallNameMap, mode) {
4479
4641
  .filter((p) => p.type === "text")
4480
4642
  .map((p) => p.content)
4481
4643
  .join("\n");
4644
+ // System messages must use string content, so collect part metadata into _partsMetadata
4645
+ let combinedMeta = msgMetadata ? { ...msgMetadata } : undefined;
4646
+ let partsMetadata;
4647
+ for (const part of message.parts.filter((p) => p.type === "text")) {
4648
+ const partMeta = readMetadata(part);
4649
+ if (partMeta && Object.keys(partMeta).length > 0) {
4650
+ partsMetadata = { ...partsMetadata, ...partMeta };
4651
+ }
4652
+ }
4653
+ if (partsMetadata) {
4654
+ combinedMeta = { ...combinedMeta, _partsMetadata: partsMetadata };
4655
+ }
4656
+ const applyModeCombined = (msg) => applyMetadataMode(msg, combinedMeta, mode, true);
4482
4657
  return [
4483
- applyMode({
4658
+ applyModeCombined({
4484
4659
  role: "system",
4485
4660
  content: textContent,
4486
4661
  }),
@@ -4498,6 +4673,12 @@ function genAIMessageToVercelAI(message, toolCallNameMap, mode) {
4498
4673
  if (toolParts.length === 0) {
4499
4674
  return [];
4500
4675
  }
4676
+ // Apply _partsMetadata to the first tool part if present
4677
+ const partsMetadata = getPartsMetadata(msgMetadata);
4678
+ if (partsMetadata && toolParts.length > 0) {
4679
+ // biome-ignore lint/style/noNonNullAssertion: length check guarantees element exists
4680
+ toolParts[0] = applyMetadataMode(toolParts[0], partsMetadata, mode, true);
4681
+ }
4501
4682
  return [
4502
4683
  applyMode({
4503
4684
  role: "tool",
@@ -4514,8 +4695,10 @@ function genAIMessageToVercelAI(message, toolCallNameMap, mode) {
4514
4695
  userParts.push(converted);
4515
4696
  }
4516
4697
  }
4517
- // If only one text part, use string content
4518
- if (userParts.length === 1 && userParts[0]?.type === "text") {
4698
+ // Check for _partsMetadata - if present, we need array content to apply it
4699
+ const partsMetadata = getPartsMetadata(msgMetadata);
4700
+ // If only one text part and no metadata to preserve (including _partsMetadata), use string content
4701
+ if (userParts.length === 1 && userParts[0]?.type === "text" && shouldCollapseToString() && !partsMetadata) {
4519
4702
  return [
4520
4703
  applyMode({
4521
4704
  role: "user",
@@ -4523,6 +4706,11 @@ function genAIMessageToVercelAI(message, toolCallNameMap, mode) {
4523
4706
  }),
4524
4707
  ];
4525
4708
  }
4709
+ // Apply _partsMetadata to the first user part if present
4710
+ if (partsMetadata && userParts.length > 0) {
4711
+ // biome-ignore lint/style/noNonNullAssertion: length check guarantees element exists
4712
+ userParts[0] = applyMetadataMode(userParts[0], partsMetadata, mode, true);
4713
+ }
4526
4714
  return [
4527
4715
  applyMode({
4528
4716
  role: "user",
@@ -4539,8 +4727,13 @@ function genAIMessageToVercelAI(message, toolCallNameMap, mode) {
4539
4727
  assistantParts.push(converted);
4540
4728
  }
4541
4729
  }
4542
- // If only one text part, use string content
4543
- if (assistantParts.length === 1 && assistantParts[0]?.type === "text") {
4730
+ // Check for _partsMetadata - if present, we need array content to apply it
4731
+ const partsMetadata = getPartsMetadata(msgMetadata);
4732
+ // If only one text part and no metadata to preserve (including _partsMetadata), use string content
4733
+ if (assistantParts.length === 1 &&
4734
+ assistantParts[0]?.type === "text" &&
4735
+ shouldCollapseToString() &&
4736
+ !partsMetadata) {
4544
4737
  return [
4545
4738
  applyMode({
4546
4739
  role: "assistant",
@@ -4548,6 +4741,11 @@ function genAIMessageToVercelAI(message, toolCallNameMap, mode) {
4548
4741
  }),
4549
4742
  ];
4550
4743
  }
4744
+ // Apply _partsMetadata to the first assistant part if present
4745
+ if (partsMetadata && assistantParts.length > 0) {
4746
+ // biome-ignore lint/style/noNonNullAssertion: length check guarantees element exists
4747
+ assistantParts[0] = applyMetadataMode(assistantParts[0], partsMetadata, mode, true);
4748
+ }
4551
4749
  return [
4552
4750
  applyMode({
4553
4751
  role: "assistant",
@@ -4560,6 +4758,12 @@ function genAIMessageToVercelAI(message, toolCallNameMap, mode) {
4560
4758
  .filter((p) => p.type === "text")
4561
4759
  .map((p) => ({ type: "text", text: p.content }));
4562
4760
  if (textParts.length > 0) {
4761
+ // Apply _partsMetadata to the first text part if present
4762
+ const partsMetadata = getPartsMetadata(msgMetadata);
4763
+ if (partsMetadata) {
4764
+ // biome-ignore lint/style/noNonNullAssertion: length check guarantees element exists
4765
+ textParts[0] = applyMetadataMode(textParts[0], partsMetadata, mode, true);
4766
+ }
4563
4767
  return [
4564
4768
  applyMode({
4565
4769
  role: "user",