rosetta-ai 1.5.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:
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,6 +245,7 @@ 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
  }
245
251
  /**
@@ -298,7 +304,8 @@ function storeMetadata(existingMetadata, extraFields, knownFields) {
298
304
  * @returns The entity with metadata applied according to the mode
299
305
  */
300
306
  function applyMetadataMode(entity, metadata, mode, useCamelCase = true) {
301
- if (!metadata)
307
+ // Return early if no metadata or empty metadata object
308
+ if (!metadata || Object.keys(metadata).length === 0)
302
309
  return entity;
303
310
  switch (mode) {
304
311
  case "strip":
@@ -1567,6 +1574,8 @@ const KnownFieldsSchema = zod.z
1567
1574
  isRefusal: zod.z.boolean().optional(),
1568
1575
  /** Original type when mapping to a different GenAI type (for lossy conversions) */
1569
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(),
1570
1579
  })
1571
1580
  .passthrough();
1572
1581
  /**
@@ -1731,18 +1740,41 @@ const GenAISpecification = {
1731
1740
  }
1732
1741
  const parsedSystem = GenAISystemSchema.optional().parse(system);
1733
1742
  if (parsedSystem && parsedSystem.length > 0) {
1734
- 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
+ }
1735
1762
  }
1736
1763
  return { messages: parsedMessages };
1737
1764
  },
1738
1765
  fromGenAI({ messages, providerMetadata }) {
1739
1766
  const system = [];
1740
1767
  const filtered = [];
1741
- 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];
1742
1771
  // Apply metadata mode to message and its parts (handles _partsMetadata restoration)
1743
1772
  const transformed = applyMetadataModeToGenAIMessage(message, providerMetadata);
1744
1773
  if (message.role === "system") {
1745
- system.push(...transformed.parts);
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
+ }
1746
1778
  }
1747
1779
  else {
1748
1780
  filtered.push(transformed);
@@ -1774,6 +1806,48 @@ function applyMetadataModeToGenAIMessage(message, mode) {
1774
1806
  const baseWithParts = { ...baseMessage, parts: transformedParts };
1775
1807
  return applyMetadataMode(baseWithParts, msgMetadata, mode, false);
1776
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
+ }
1777
1851
 
1778
1852
  /**
1779
1853
  * Google Gemini API Schemas