This document provides context and guidelines for AI agents working on this codebase.
Rosetta is a TypeScript library that translates messages between different LLM providers using a standardized intermediate format called GenAI.
- GenAI - Core format (intermediate and default)
- Promptl - Format used by the promptl-ai package
- VercelAI - Vercel AI SDK message format (source and target)
- OpenAI Completions - OpenAI Chat Completions API format (source-only)
- OpenAI Responses - OpenAI Responses API format (source-only)
- Anthropic - Anthropic Messages API format (source-only)
- Google - Google Gemini GenerateContent API format (source-only)
- Compat - Universal fallback format for unknown providers (source-only)
More providers will be added incrementally.
flowchart TB
subgraph api [src/api]
translateFn[translate function]
safeTranslateFn[safeTranslate function]
end
subgraph core [src/core]
subgraph genai [genai/]
genaiIndex[index.ts - Schemas and types]
end
subgraph infer [infer/]
inferIndex[index.ts - Provider detection]
end
end
subgraph providers [src/providers]
providerTypes[provider.ts - Enum and types]
specifications[specifications.ts - Registry]
subgraph providerFolders [Provider Folders]
genaiProvider[genai/]
promptlProvider[promptl/]
openaiCompletionsProvider[openai/completions/]
openaiResponsesProvider[openai/responses/]
anthropicProvider[anthropic/]
googleProvider[google/]
compatProvider[compat/]
end
end
translateFn --> specifications
safeTranslateFn --> translateFn
translateFn --> inferIndex
specifications --> providerFolders
inferIndex --> specifications
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Provider A │ │ Provider B │ │ Provider C │
│ Format │ │ Format │ │ Format │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────────────────────┐
│ toGenAI() (always required) │
└──────────────────────────┬───────────────────────────┘
▼
┌─────────────────┐
│ GenAI Schema │
│ (Intermediate) │
└────────┬────────┘
▼
┌──────────────────────────────────────────────────────┐
│ fromGenAI() (optional per provider) │
└──────────────────────────┬───────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Provider A │ │ Provider B │ │ Provider C │
│ Format │ │ Format │ │ Format │
└─────────────┘ └─────────────┘ └─────────────┘
src/
├── index.ts # Re-exports from ./api only
├── api/
│ ├── index.ts # Public exports
│ └── translator.ts # translate/safeTranslate functions and types
├── core/
│ ├── index.ts # Internal exports
│ ├── genai/
│ │ └── index.ts # All Zod schemas and inferred types
│ ├── infer/
│ │ └── index.ts # Provider inference logic
│ └── input/
│ └── index.ts # Input types (InputMessages, InputSystem)
├── providers/
│ ├── index.ts # Re-exports from provider.ts and specifications.ts
│ ├── provider.ts # Provider enum and types (separate to avoid circular deps)
│ ├── specifications.ts # PROVIDER_SPECIFICATIONS registry
│ ├── genai/ # GenAI provider specification
│ ├── promptl/ # Promptl provider specification
│ ├── openai/
│ │ ├── completions/ # OpenAI Chat Completions provider
│ │ └── responses/ # OpenAI Responses API provider
│ ├── anthropic/ # Anthropic Messages API provider
│ ├── google/ # Google Gemini API provider
│ ├── vercelai/ # Vercel AI SDK provider
│ └── compat/ # Universal fallback provider (source-only)
└── utils/
└── index.ts # Shared utilities
examples/ # E2E tests with real library integrations
├── genai.test.ts # GenAI provider E2E tests
├── promptl.test.ts # Promptl provider E2E tests (uses promptl-ai)
├── openai_completions.test.ts # OpenAI Completions E2E tests
├── openai_responses.test.ts # OpenAI Responses E2E tests
├── anthropic.test.ts # Anthropic E2E tests
├── google.test.ts # Google Gemini E2E tests
├── vercelai.test.ts # Vercel AI SDK E2E tests
├── vitest.config.ts # Vitest config (imports from src directly)
└── package.json # Separate package with vitest
import { translate, safeTranslate, Provider } from "rosetta-ai";
// Quick usage - auto-infer source if not provided
const { messages, system } = translate(inputMessages);
// Specify source and target
const { messages, system } = translate(inputMessages, {
from: Provider.Promptl,
to: Provider.GenAI,
});
// Specify direction for output translation
const { messages } = translate(outputMessages, {
from: Provider.GenAI,
to: Provider.Promptl,
direction: "output",
});
// Safe version (returns error instead of throwing)
const result = safeTranslate(messages);
if (result.error) {
console.error("Translation failed:", result.error.message);
} else {
console.log("Translated:", result.messages);
}
// Custom inference priority
const { messages } = translate(inputMessages, {
inferPriority: [Provider.Promptl, Provider.GenAI],
});
// Filter empty messages during translation
const { messages } = translate(inputMessages, {
filterEmptyMessages: true,
});enum Provider {
GenAI = "genai",
Promptl = "promptl",
OpenAICompletions = "openai_completions",
OpenAIResponses = "openai_responses",
Anthropic = "anthropic",
Google = "google",
VercelAI = "vercel_ai",
Compat = "compat",
}New providers are added to this enum as they are implemented.
The input types allow flexible message input while each provider validates with its own Zod schema:
import type { InputMessages, InputSystem } from "rosetta-ai";
// Messages can be a string or array of provider messages
type InputMessages = string | object[];
// System can be a string, single object, or array of parts
type InputSystem = string | object | object[];The GenAI schema is the intermediate format. Key entities:
- GenAIMessage:
{ role, parts, name?, finish_reason?, _provider_metadata? } - GenAIPart: Discriminated union by
typefield (text, blob, file, uri, reasoning, tool_call, tool_call_response, generic) - GenAISystem: Array of GenAIParts
All entities include optional _provider_metadata for preserving provider-specific data during round-trips.
All GenAI schemas and types are prefixed with "GenAI" (e.g., GenAIMessageSchema, GenAIMessage, GenAIPartSchema, GenAIPart) to follow the same naming convention as other providers.
- Language: TypeScript (strict mode, ESM)
- Schema Validation: Zod v4
- Linter/Formatter: Biome (120 char line width, 2 spaces)
- Testing: Vitest
- Bundler: Rollup (ESM + CJS dual package)
- Package Manager: pnpm
Keep modules consolidated - avoid unnecessary file splits:
-
Put schemas and types together: Define Zod schemas and their inferred types in the same file, placing the type immediately after its schema definition.
-
One index.ts per module: Each module folder should have its main logic in
index.ts. Don't create separate files liketypes.ts,schemas.ts, orlogic.tsunless the file would exceed ~500 lines. -
Provider implementation in index.ts: Each provider's specification, types, and registration should all live in the provider's
index.tsfile. -
Tests are the exception: Test files (
.test.ts,.spec.ts) should always be separate from the implementation. -
Extra files when necessary: Although we prefer consolidated files, an extra file can be created when:
- It's needed to fix circular dependencies (e.g.,
provider.tsfor shared types) - The concerns are genuinely separate (e.g.,
translator.tsfor a distinct class) - The file would otherwise exceed ~500 lines
- The provider has complex schemas that benefit from separation (e.g.,
schema.tsfor message schemas)
- It's needed to fix circular dependencies (e.g.,
Example - Good (simple provider):
providers/genai/
├── index.ts # All logic, types, and registration
└── index.test.ts # Tests only
Example - Good (complex provider):
providers/promptl/
├── index.ts # Provider specification and conversion logic
├── index.test.ts # Tests
└── schema.ts # Message and content schemas
Example - Avoid:
providers/genai/
├── index.ts
├── specification.ts # Unnecessary split
├── types.ts # Unnecessary split
└── index.test.ts
- Use Biome for formatting and linting
- Line width: 120 characters
- Use 2 spaces for indentation
- Prefer explicit types over inference for public APIs
- All schemas should be defined with Zod
- Use
// biome-ignore format:comments to preserve intentional formatting (e.g., readable conditional types) - All imports and re-exports must be at the top of the file - never put imports mid-code
- Enum string values must be snake_case - e.g.,
GenAI = "genai" - File names: Prefer single word names when possible (e.g.,
overview.ts,translator.ts). If multiple words are needed, use snake_case (e.g.,tool_call.ts) - Use
typeinstead ofinterface- always usetype Foo = { ... }instead ofinterface Foo { ... }
The project uses $package as an import alias that resolves to the src/ directory. Use this instead of relative paths with ../:
// Preferred - use the alias
import { Provider } from "$package/providers";
import { GenAIMessage } from "$package/core/genai";
// Avoid - relative paths with multiple levels
import { Provider } from "../../providers";The alias is configured in:
- TypeScript:
tsconfig.json(paths) - Vitest:
vitest.config.ts(resolve.alias) - Rollup:
rollup.config.ts(@rollup/plugin-alias)
- Use JSDoc comments for public APIs
- After completing a task, review if AGENTS.md or README.md need updates:
- AGENTS.md is for AI agents - contains architecture, directory structure, coding guidelines, and development instructions. Update when:
- Adding/removing providers or major components
- Changing directory structure or file organization
- Updating coding conventions or patterns
- Adding new development workflows or commands
- README.md is for humans - contains API usage, installation, and supported features. Update when:
- Adding/changing public API (new functions, options, types)
- Adding/removing provider support
- Changing installation or setup instructions
- Be selective: not every code change needs documentation updates. Focus on changes that affect how the package is used (README) or developed (AGENTS)
- AGENTS.md is for AI agents - contains architecture, directory structure, coding guidelines, and development instructions. Update when:
NEVER use section separator comments with equals signs, dashes, or any decorative characters:
// BAD - DO NOT USE ANY OF THESE PATTERNS:
// =============================================================================
// Some Section
// =============================================================================
// =================================
// Another Section
// =================================
// -----------------------------------
// Yet Another Section
// -----------------------------------
// ***********************************
// Still Bad
// ***********************************These are visual clutter and make the code harder to read. If you need to group related code, use:
- Simple JSDoc comments (
/** ... */) for documentation - Blank lines to separate logical groups
- Smaller, well-named files if groups become too large
Always use // TODO: comments for incomplete implementations or planned work:
// TODO: Add support for streaming responses
// TODO: Handle edge case when parts array is emptyThis makes it easy to track what's missing in the codebase by searching for TODO.
- Place tests alongside source files:
*.test.tsor*.spec.ts - Use descriptive test names that explain the behavior
- Test both success and error cases
- Test edge cases for message format conversions
This is a comprehensive guide for adding a new provider to Rosetta.
Important: Always create the schema and metadata files FIRST, before registering the provider in the enum. Otherwise the linter will complain about missing types.
When designing schemas for a new provider, follow these simplification principles:
-
Only schematize entities with GenAI mappings: Define explicit Zod schemas only for provider entities that map directly to GenAI parts (text, blob, uri, file, reasoning, tool_call, tool_call_response). This keeps schemas focused and maintainable.
-
Only define fields used for translation: Within each schema, only include fields that are actually used in the
toGenAIconversion logic. All other fields (likeid,status,annotations,logprobs,filename, etc.) will automatically flow through.passthrough()and be captured in_provider_metadataviaextractExtraFields. This keeps schemas minimal and prevents them from breaking when the provider API adds new fields. -
Unify input/output schemas when possible: If the provider has separate input and output types that map to the same GenAI entity, create a unified schema that accepts both. For example, instead of separate
InputTextPartSchemaandOutputTextPartSchema, create a singleTextPartSchemawithtype: z.enum(["input_text", "output_text"]). This reduces code duplication and simplifies the conversion logic. -
Use passthrough for specialized features: Other item types (built-in tools like web_search, file_search, computer_use, MCP, code_interpreter, etc.) don't need detailed schemas. Capture them with minimal type checking and convert to GenAI generic parts, preserving all data in
_provider_metadata. -
Focus on complete messages only: Don't schema streaming-related types (delta events, done events, in_progress events). Only schema complete message/item structures that represent the final state.
-
Keep schemas simple: A simpler schema is easier to maintain and less likely to break when the provider API adds new fields. Let
.passthrough()handle unknown fields automatically.
Example - OpenAI Responses provider:
// Unified text part schema (handles both input_text and output_text)
export const OpenAIResponsesTextPartSchema = z
.object({
type: z.enum(["input_text", "output_text"]),
text: z.string(),
// annotations, logprobs, etc. flow through .passthrough() to metadata
})
.passthrough();
// Only fields used for translation are defined:
// - message: type, role, content (not id, status)
// - function_call: type, call_id, name, arguments (not id, status)
// - reasoning: type, summary (not id, encrypted_content, status)
// Passthrough items (converted to generic parts):
// - file_search_call, web_search_call, computer_call, code_interpreter_call
// - mcp_call, mcp_list_tools, mcp_approval_request, mcp_approval_response
// - image_generation_call, local_shell_call, etc.Create src/providers/{provider_name}/ folder with:
schema.ts- Message and content Zod schemas
1.1. Create message schema (schema.ts):
import { z } from "zod";
import type { Infer } from "$package/utils";
export const NewProviderMessageSchema = z
.object({
role: z.enum(["user", "assistant", "system"]),
content: z.string(), // or z.array(...) for structured content
// Add provider-specific fields
})
.passthrough(); // Always use passthrough to preserve unknown fields
// Use Infer<T> for external SDK providers (OpenAI, Anthropic, etc.)
// This removes the index signature for compatibility with external SDK types
export type NewProviderMessage = Infer<typeof NewProviderMessageSchema>;
// For internal formats (GenAI, Promptl), use z.infer<T> instead:
// export type NewProviderMessage = z.infer<typeof NewProviderMessageSchema>;Now that the types exist, register the provider in the enum and type mappings.
2.1. Add to Provider enum (src/providers/provider.ts):
export enum Provider {
GenAI = "genai",
Promptl = "promptl",
OpenAICompletions = "openai_completions",
NewProvider = "new_provider", // snake_case value
}2.2. Update type mappings in the same file:
// ProviderMessage<P> - Maps provider to its message type
export type ProviderMessage<P extends Provider> =
P extends Provider.GenAI ? GenAIMessage :
P extends Provider.NewProvider ? NewProviderMessage :
never;
// ProviderSystem<P> - Maps provider to its system type
// Use `never` if this provider embeds system in messages (like Promptl)
export type ProviderSystem<P extends Provider> =
P extends Provider.GenAI ? GenAISystem :
P extends Provider.NewProvider ? never : // or NewProviderSystem if separated
never;Create index.ts in the provider folder:
import type { GenAIMessage, GenAIPart } from "$package/core/genai";
import { NewProviderMessageSchema, type NewProviderMessage } from "./schema";
import {
Provider,
type ProviderFromGenAIArgs,
type ProviderSpecification,
type ProviderToGenAIArgs,
} from "$package/providers/provider";
export const NewProviderSpecification = {
provider: Provider.NewProvider,
name: "New Provider",
messageSchema: NewProviderMessageSchema,
// systemSchema: NewProviderSystemSchema, // Only if separated from messages
toGenAI({ messages, direction }: ProviderToGenAIArgs) {
// Handle string input - wrap in provider-native format then fall through
// IMPORTANT: Never early-return here; the rest of the pipeline handles system, etc.
if (typeof messages === "string") {
const role = direction === "input" ? "user" : "assistant";
messages = [{ role, content: messages }];
}
// Validate with schema
const parsedMessages = NewProviderMessageSchema.array().parse(messages);
// Convert each message
const converted: GenAIMessage[] = [];
for (const message of parsedMessages) {
converted.push(convertToGenAI(message));
}
return { messages: converted };
},
// Optional: Only implement if you want this provider to be a target
fromGenAI({ messages, direction }: ProviderFromGenAIArgs) {
const converted: NewProviderMessage[] = [];
for (const message of messages) {
converted.push(convertFromGenAI(message));
}
return { messages: converted };
},
} as const satisfies ProviderSpecification<Provider.NewProvider>;Add to src/providers/specifications.ts:
import { NewProviderSpecification } from "$package/providers/new_provider";
const PROVIDER_SPECIFICATIONS = {
[Provider.GenAI]: GenAISpecification,
[Provider.NewProvider]: NewProviderSpecification,
} as const satisfies { [P in Provider]: ProviderSpecification<P> };The direction parameter indicates whether messages are being prepared for model input or processed from model output:
"input"- Messages going TO the model (user prompts, conversation history)"output"- Messages coming FROM the model (assistant responses)
Use direction for string input:
IMPORTANT: When handling string input, wrap it in the provider's native message format and assign back to messages so execution falls through to the normal conversion pipeline. Never use an early return for string handling. An early return bypasses all downstream logic (e.g., system instruction handling, schema validation), which causes bugs like system instructions being silently ignored when messages is a string.
toGenAI({ messages, system, direction }) {
// Wrap string in the provider's native format, then fall through
if (typeof messages === "string") {
const role = direction === "input" ? "user" : "assistant";
messages = [{ role, content: messages }]; // Provider-native format, NOT GenAI format
}
// String input now flows through the same pipeline as array input:
// schema validation, system instruction handling, message conversion, etc.
const parsedMessages = MessageSchema.array().parse(messages);
// ...
}For providers with non-standard roles (e.g., Google uses "model" instead of "assistant"), use the provider's role in the wrapped message. The normal conversion pipeline will map it to GenAI's role later:
// Google provider example - uses "model" role natively
if (typeof messages === "string") {
const role = direction === "input" ? "user" : "model";
messages = [{ role, parts: [{ text: messages }] }]; // Google-native format
}GenAI allows passthrough for roles, has a generic part type, and supports _provider_metadata on every entity. Use these to minimize information loss.
6.1. The Metadata Structure:
The _provider_metadata field has two parts:
_known_fields: Cross-provider semantic data (toolName,isError,isRefusal,originalType,messageIndex)- Extra fields: Provider-specific data at the root level
Use the utility functions from $package/utils:
import { storeMetadata, readMetadata, getKnownFields, applyMetadataMode } from "$package/utils";
// In toGenAI - store metadata with known fields and extra fields
function convertToGenAI(message: NewProviderMessage): GenAIMessage {
const extraFields = extractExtraFields(message, KNOWN_MESSAGE_KEYS);
const existingMetadata = readMetadata(message); // Read existing metadata if any
// Store known fields and extra fields
const metadata = storeMetadata(
existingMetadata,
extraFields,
{ toolName: message.name } // Known fields for cross-provider access
);
return {
role: message.role,
parts: [{ type: "text", content: message.content }],
...(metadata ? { _provider_metadata: metadata } : {}),
};
}6.2. Restore fields when converting back (target providers only):
Use applyMetadataMode to handle the three modes ("preserve", "passthrough", "strip"):
import { applyMetadataMode, readMetadata, getKnownFields } from "$package/utils";
import type { ProviderMetadataMode } from "$package/utils";
function convertFromGenAI(
message: GenAIMessage,
providerMetadata: ProviderMetadataMode
): NewProviderMessage {
const metadata = readMetadata(message);
const knownFields = getKnownFields(metadata);
// Build base entity using known fields for accurate translation
const base: NewProviderMessage = {
role: mapRole(message.role),
content: extractContent(message.parts),
// Use known fields for cross-provider translation
...(knownFields.toolName ? { name: knownFields.toolName } : {}),
};
// Apply metadata mode (preserve/passthrough/strip)
// useCamelCase = true for providers like VercelAI that use camelCase
return applyMetadataMode(base, metadata, providerMetadata, false);
}6.3. Use GenAI's flexibility:
- Passthrough roles: GenAI accepts
z.union([GenAIRoleSchema, z.string()]), so custom roles like "developer" pass through - Generic parts: For unsupported content types, use
{ type: "custom_type", content: "...", ...data } - Modality strings: GenAI accepts
z.union([GenAIModalitySchema, z.string()])for custom modalities
Providers handle system instructions differently:
Option A: System embedded in messages (like Promptl):
- Set
ProviderSystem<P>tonever - Don't define
systemSchema - System messages are regular messages with
role: "system"
Option B: System separated from messages (like GenAI):
- Define a
systemSchema - Handle
systemparameter intoGenAI:
toGenAI({ messages, system, direction }) {
// Handle messages (string or array)
if (typeof messages === "string") {
const role = direction === "input" ? "user" : "assistant";
messages = [{ role, parts: [{ type: "text", content: messages }] }];
}
const parsedMessages = MessageSchema.array().parse(messages);
// Handle system (string, single object, or array)
if (typeof system === "string") {
system = [{ type: "text", content: system }];
} else if (system !== undefined && !Array.isArray(system)) {
system = [system]; // Single object -> wrap in array
}
const parsedSystem = SystemSchema.optional().parse(system);
// Prepend system as first message
if (parsedSystem && parsedSystem.length > 0) {
parsedMessages.unshift({ role: "system", parts: parsedSystem });
}
return { messages: parsedMessages };
}- Extract system in
fromGenAI:
fromGenAI({ messages }) {
const system: NewProviderSystem = [];
const filtered: NewProviderMessage[] = [];
for (const message of messages) {
if (message.role === "system") {
system.push(...message.parts);
} else {
filtered.push(convertMessage(message));
}
}
return { messages: filtered, system: system.length > 0 ? system : undefined };
}System message order preservation:
When the GenAI provider's fromGenAI extracts system messages, it stores the original message index on each system part via _known_fields.messageIndex. When toGenAI receives system parts with messageIndex, it reconstructs the original positions by inserting system messages at their stored indices (clamped to valid range). If no parts have messageIndex, it falls back to prepending all system parts as a single message at position 0.
This allows round-tripping through providers that separate system from messages (like GenAI) without losing the original conversation order.
If you want auto-inference to detect this provider, add to src/core/infer/index.ts:
export const DEFAULT_INFER_PRIORITY: Provider[] = [
Provider.GenAI,
Provider.Promptl,
Provider.NewProvider, // Add here
];Create index.test.ts in the provider folder with tests covering:
9.1. String message handling:
describe("string messages", () => {
it("should convert string to user message for input direction", () => {
const result = Specification.toGenAI({ messages: "Hello", direction: "input" });
expect(result.messages[0]?.role).toBe("user");
});
it("should convert string to assistant message for output direction", () => {
const result = Specification.toGenAI({ messages: "Response", direction: "output" });
expect(result.messages[0]?.role).toBe("assistant");
});
});9.2. Content type conversions:
describe("content types", () => {
it("should convert text content");
it("should convert image URLs to uri parts");
it("should convert base64 images to blob parts");
it("should convert tool calls");
it("should convert tool responses");
});9.3. Role mapping:
describe("role mapping", () => {
it("should preserve standard roles");
it("should handle custom roles appropriately");
it("should handle unknown roles with fallback");
});9.4. Metadata preservation:
describe("metadata preservation", () => {
it("should preserve extra message fields in _provider_metadata");
it("should preserve extra content fields in part metadata");
it("should restore metadata when converting back");
});9.5. Round-trip tests (most important!):
describe("round-trip conversion", () => {
it("should preserve user message through toGenAI -> fromGenAI");
it("should preserve assistant message through round-trip");
it("should preserve tool calls through round-trip");
it("should preserve complex conversation through round-trip");
});9.6. Edge cases:
describe("edge cases", () => {
it("should handle empty messages array");
it("should handle empty content array");
it("should handle missing optional fields");
});9.7. Schema validation:
describe("schema validation", () => {
it("should validate correct messages");
it("should reject invalid messages");
});9.8. Schema passthrough tests (required):
These tests verify that unknown fields added by provider API updates are preserved during schema parsing. This is critical for forward compatibility.
describe("schema passthrough - unknown fields preserved during parsing", () => {
it("should preserve unknown fields on messages during schema parsing", () => {
const message = {
role: "user",
content: "Hello",
future_api_field: "preserved",
nested_data: { key: "value" },
};
const result = Specification.messageSchema.safeParse(message);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toHaveProperty("future_api_field", "preserved");
expect(result.data).toHaveProperty("nested_data", { key: "value" });
}
});
it("should preserve unknown fields on content parts during schema parsing");
it("should preserve unknown fields on tool calls during schema parsing");
it("should preserve unknown fields on nested objects during schema parsing");
});These tests ensure that when a provider (like OpenAI) adds new fields to their API, the library won't strip them during Zod parsing. The .passthrough() on all z.object() schemas enables this.
Create examples/{provider_name}.test.ts with vitest tests. E2E tests should include BOTH real API/library tests AND hardcoded tests.
Why both types of tests?
- Real API tests: Verify the provider works with actual library output, catching edge cases and format changes
- Hardcoded tests: Run without API keys/network, useful for CI and fast iteration
Structure your E2E tests like this:
/**
* {Provider Name} E2E Tests
*
* Tests translating {Provider} format messages to/from GenAI.
* Includes real API tests (when credentials available) and hardcoded tests.
*/
import { describe, expect, it } from "vitest";
import { ProviderSDK } from "provider-sdk"; // e.g., openai, anthropic
import { Provider, translate } from "rosetta-ai";
const hasApiKey = !!process.env.PROVIDER_API_KEY;
describe("{Provider Name} E2E", () => {
// Real API tests - skipped when no API key, with extended timeout
describe.skipIf(!hasApiKey)("real API calls", { timeout: 30000 }, () => {
const client = new ProviderSDK({ apiKey: process.env.PROVIDER_API_KEY });
it("should translate real API response", async () => {
const response = await client.chat.completions.create({
model: "model-name",
messages: [{ role: "user", content: "Hello" }],
});
const result = translate([response.choices[0].message], {
from: Provider.NewProvider,
to: Provider.GenAI,
});
expect(result.messages).toHaveLength(1);
expect(result.messages[0]?.role).toBe("assistant");
});
it("should translate tool calls from real API", async () => {
const response = await client.chat.completions.create({
model: "model-name",
messages: [{ role: "user", content: "What's the weather?" }],
tools: [{ type: "function", function: { name: "get_weather", ... } }],
tool_choice: "required",
});
const result = translate([response.choices[0].message], {
from: Provider.NewProvider,
to: Provider.GenAI,
});
expect(result.messages[0]?.parts[0]?.type).toBe("tool_call");
});
});
// Hardcoded tests - always run, no API key required
describe("hardcoded messages", () => {
it("should translate simple messages", () => {
const messages = [
{ role: "user" as const, content: "Hello" },
{ role: "assistant" as const, content: "Hi there!" },
];
const result = translate(messages, {
from: Provider.NewProvider,
to: Provider.GenAI,
});
expect(result.messages).toHaveLength(2);
});
it("should translate tool calls", () => {
const messages = [
{
role: "assistant" as const,
content: null,
tool_calls: [{ id: "call_1", type: "function", function: { name: "test", arguments: "{}" } }],
},
];
const result = translate(messages, {
from: Provider.NewProvider,
to: Provider.GenAI,
});
expect(result.messages[0]?.parts[0]?.type).toBe("tool_call");
});
it("should auto-detect provider format", () => {
const messages = [{ role: "user" as const, content: "Hello" }];
const result = translate(messages); // No 'from' specified
expect(result.messages).toBeDefined();
});
});
});Key patterns:
- Use
describe.skipIf(!hasApiKey)with extended timeout - skip API tests when credentials aren't available, and set{ timeout: 30000 }(30s) since API calls can be slow - Get API keys from environment variables - never hardcode secrets
- Add the provider SDK to
examples/package.jsonas a dependency - Test the full flow: simple messages, tool calls, multimodal content, edge cases
- Test cross-provider translation: e.g., OpenAI → Promptl via GenAI
The examples folder uses vitest and imports directly from source (no rebuild needed):
cd examples
pnpm test # Run all E2E testsAfter completing the implementation, update the documentation:
-
Update README.md - Add the new provider to the "Supported Providers" table:
- Provider name
- Status (✅ Available)
- toGenAI support (✅ or -)
- fromGenAI support (✅ or -)
-
Update AGENTS.md - Already covered by the implementation steps above:
- Supported Providers list
- Architecture diagram (if applicable)
- Directory structure
Not all providers need to be translation targets. If you only want to ingest messages from a provider (source-only):
- Don't implement
fromGenAI- only implementtoGenAI - The provider will be usable as
frombut not asto - Type system automatically enforces this via
ProviderSourceandProviderTargettypes
Example (OpenAI Completions is currently source-only):
export const OpenAICompletionsSpecification = {
provider: Provider.OpenAICompletions,
name: "OpenAI Completions",
messageSchema: OpenAICompletionsMessageSchema,
toGenAI({ messages, direction }) {
// Convert OpenAI → GenAI
return { messages: converted };
},
// No fromGenAI - this provider is source-only
} as const satisfies ProviderSpecification<Provider.OpenAICompletions>;Use, and add to, shared utilities from $package/utils:
import { extractExtraFields, isUrlString, getPartsMetadata } from "$package/utils";
// Extract fields not in known keys (for passthrough preservation)
const extra = extractExtraFields(obj, ["role", "content"]);
// Check if string is a URL (for image/file handling)
if (isUrlString(value)) {
// Convert to uri part
} else {
// Convert to blob part (base64)
}
// Read cross-provider data from root-level shared fields
const toolName = part._provider_metadata?.toolName as string | undefined;
const isError = part._provider_metadata?.isError as boolean | undefined;
// Extract collapsed part metadata from message metadata (handles both casings)
const partsMetadata = getPartsMetadata(msgMetadata);
if (partsMetadata && content.length > 0) {
// Apply parts metadata to first content part
content[0] = applyMetadataMode(content[0], partsMetadata, mode, true);
}When implementing provider conversions, keep these cross-provider concerns in mind:
1. Tool Name Preservation:
Tool names are essential for matching tool calls with tool results. When converting tool_call_response parts:
- In
toGenAI: StoretoolNamein_known_fieldsusingstoreMetadata - In
fromGenAI: ReadtoolNameviagetKnownFields - Fallback to inference: If not in known fields, try to infer from matching
tool_callparts in the conversation byid - Last resort: Use
"unknown"as a fallback
// In toGenAI for tool_call_response:
const metadata = storeMetadata(existingMeta, extraFields, { toolName: content.toolName });
// In fromGenAI for tool_call_response:
const knownFields = getKnownFields(readMetadata(part));
let toolName = knownFields.toolName;
if (!toolName && toolId && toolCallNameMap.has(toolId)) {
toolName = toolCallNameMap.get(toolId);
}
toolName = toolName ?? "unknown";2. Tool Call Deduplication:
Some providers (like Promptl) may have tool calls in BOTH the content array AND a separate toolCalls property. When converting:
- Track tool call IDs already processed from content
- Only add tool calls from the separate property if their ID isn't already present
const toolCallIdsInContent = new Set<string>();
for (const content of message.content) {
if (content.type === "tool-call" && content.toolCallId) {
toolCallIdsInContent.add(content.toolCallId);
}
parts.push(...convertContent(content));
}
if (message.toolCalls) {
for (const toolCall of message.toolCalls) {
if (!toolCallIdsInContent.has(toolCall.id)) {
parts.push(convertToolCall(toolCall));
}
}
}3. Typed Tool Result Outputs:
Some providers (like VercelAI) use typed tool result outputs with { type, value } structure:
- In
toGenAI: Extract the actual value and storeisErrorin_known_fields, storeoutputTypeas extra field - In
fromGenAI: ReadisErrorviagetKnownFields,outputTypefrom metadata
// In toGenAI for tool_call_response:
const metadata = storeMetadata(existingMeta, { outputType: "error-text" }, { isError: true });
// In fromGenAI for tool_call_response:
const knownFields = getKnownFields(readMetadata(part));
const metadata = readMetadata(part);
const isError = knownFields.isError;
const outputType = metadata.outputType as string | undefined;
if (typeof response === "string") {
output = { type: isError ? "error-text" : "text", value: response };
} else {
output = { type: isError ? "error-json" : "json", value: response };
}4. Provider-Specific Part Types:
When a source provider has part types that don't exist exactly in GenAI (e.g., redacted-reasoning):
- Always map to the closest GenAI part type - Don't use generic parts if a close equivalent exists
- Store
originalTypein_known_fieldsusingstoreMetadata - Only use generic parts when there truly is NO equivalent in GenAI
// In toGenAI - map redacted-reasoning to reasoning (closest equivalent)
case "redacted-reasoning":
const metadata = storeMetadata(existingMeta, extraFields, { originalType: "redacted-reasoning" });
return [{
type: "reasoning", // Use existing GenAI type
content: content.data,
...(metadata ? { _provider_metadata: metadata } : {}),
}];// In fromGenAI - restore original type if coming back to same provider
if (part.type === "reasoning") {
const knownFields = getKnownFields(readMetadata(part));
if (knownFields.originalType === "redacted-reasoning") {
return { type: "redacted-reasoning", data: part.content };
}
return { type: "reasoning", text: part.content };
}5. Part-Level vs Message-Level Metadata in fromGenAI:
When implementing fromGenAI, be careful to preserve part-level metadata when the target provider format allows it. A common bug is losing part metadata when collapsing content to a simpler format.
Problem scenario: Some providers optimize single-text-part messages to string content (e.g., { role: "user", content: "Hello" } instead of { role: "user", content: [{ type: "text", text: "Hello" }] }). If parts have metadata, this optimization discards it.
Solution: Only collapse to string content when mode is "strip" OR when parts have no metadata:
// Check if any part has metadata to preserve
const hasPartMetadata = () =>
message.parts.some((p) => {
const meta = readMetadata(p as unknown as Record<string, unknown>);
return meta && Object.keys(meta).length > 0;
});
// Only collapse when safe (strip mode or no metadata)
const shouldCollapseToString = () => mode === "strip" || !hasPartMetadata();
// Use the check before collapsing
if (userParts.length === 1 && userParts[0]?.type === "text" && shouldCollapseToString()) {
return [applyMode({ role: "user", content: userParts[0].text })];
}
// Otherwise keep as array to preserve part metadata
return [applyMode({ role: "user", content: userParts })];For providers that require string content (like system messages in VercelAI), collect part metadata into _partsMetadata:
// Collect part metadata into _partsMetadata (preserves everything including _known_fields)
let combinedMeta = msgMetadata ? { ...msgMetadata } : undefined;
let partsMetadata: Record<string, unknown> | undefined;
for (const part of message.parts.filter((p) => p.type === "text")) {
const partMeta = readMetadata(part as unknown as Record<string, unknown>);
if (partMeta && Object.keys(partMeta).length > 0) {
partsMetadata = { ...partsMetadata, ...partMeta };
}
}
if (partsMetadata) {
combinedMeta = { ...combinedMeta, _partsMetadata: partsMetadata };
}
// Apply combined metadata to the message
return [applyMetadataMode({ role: "system", content: textContent }, combinedMeta, mode, true)];When applyMetadataMode is called:
- In preserve mode:
_partsMetadatastays nested inside_providerMetadata(like_knownFields) - In passthrough mode:
_partsMetadatais stripped (like_knownFields), so part metadata is lost for messages that can't have structured content - In strip mode: All metadata is removed
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.
- GenAI as the canonical format: All conversions go through GenAI
- Preserve information: Use
_provider_metadatato preserve provider-specific data - Validate at boundaries: Use Zod schemas to validate input/output
- Type-safe: Leverage TypeScript's type system fully
- Zod-first types: Always infer TypeScript types from Zod schemas
- Forward-compatible schemas: Use
.passthrough()on allz.object()schemas to preserve unknown fields when provider APIs add new properties
For all provider-related types (messages, content parts, tool calls, etc.), always define the Zod schema first and infer the TypeScript type immediately after:
import { z } from "zod";
// Schema and type together, prefixed with provider name
export const GenAIMessageSchema = z.object({
role: z.enum(["user", "assistant", "system"]),
parts: z.array(GenAIPartSchema),
});
export type GenAIMessage = z.infer<typeof GenAIMessageSchema>;
// NOT in separate files!Problem: Zod's .passthrough() is required on all z.object() schemas to preserve unknown fields at runtime. However, it adds an index signature [x: string]: unknown to the inferred TypeScript type, which makes it incompatible with external SDK types (e.g., OpenAI SDK types).
// With .passthrough(), Zod infers:
// { [x: string]: unknown; type: "text"; text: string }
// This is NOT assignable to OpenAI's type:
// { type: "text"; text: string }Solution: Use the Infer utility type from $package/utils for external provider schemas. It removes the index signature while keeping .passthrough() for runtime behavior:
import { z } from "zod";
import type { Infer } from "$package/utils";
export const OpenAICompletionsTextPartSchema = z
.object({
type: z.literal("text"),
text: z.string(),
})
.passthrough();
// Use Infer<T> instead of z.infer<T> for clean types
export type OpenAICompletionsTextPart = Infer<typeof OpenAICompletionsTextPartSchema>;
// Result: { type: "text"; text: string } - compatible with OpenAI SDKWhen to use Infer vs z.infer:
| Provider Type | Use | Reason |
|---|---|---|
| External SDKs (OpenAI, Anthropic, etc.) | Infer<T> |
Types must be compatible with external SDK types |
| GenAI (internal) | z.infer<T> |
No external compatibility needed |
| Promptl (internal) | z.infer<T> |
No external compatibility needed |
Rule of thumb: If users will pass messages directly from an external SDK to translate(), use Infer. If it's an internal format, use z.infer.
For complex conditional types, use // biome-ignore format: to preserve readable formatting:
// biome-ignore format: preserve conditional type formatting for readability
export type ProviderMessage<P extends Provider> =
P extends Provider.GenAI ? GenAIMessage :
P extends Provider.Promptl ? PromptlMessage :
P extends Provider.OpenAICompletions ? OpenAICompletionsMessage :
never;Keep translation logic as simple as possible. The goal is to convert between formats accurately, not to build a complex abstraction layer.
-
Minimal metadata schemas: Use opaque passthrough (
z.object({}).passthrough()) for metadata unless you need to access specific fields programmatically. Don't over-specify what can be treated as unknown data. -
Don't over-preserve information: Only store metadata for fields that:
- Are semantically meaningful (e.g.,
isRefusaldistinguishes refusal text from normal text) - Are needed for round-trip conversion (only relevant if you have
fromGenAI) - Cannot be inferred from the GenAI structure itself
- Are semantically meaningful (e.g.,
-
Leverage GenAI's flexibility: GenAI accepts passthrough roles, generic parts, and custom modalities. Use these instead of complex metadata when possible.
-
Source-only providers are simpler: If a provider doesn't need
fromGenAI, you don't need:- Explicit metadata field definitions
- Metadata to reconstruct the original format
- Complex field mapping logic
-
Avoid redundant metadata: If information is already captured in the GenAI structure, don't duplicate it in metadata. For example, a function tool call's name and arguments are in the
tool_callpart - no need to also store them in metadata. -
Use
extractExtraFieldsandstoreMetadatacorrectly:Key principle: If you're NOT explicitly using a field in your conversion logic, DON'T put it in
knownKeys. Let it flow automatically to metadata viaextractExtraFields.Anti-pattern to avoid - excluding then re-adding:
// BAD: Adding to knownKeys then manually re-adding to extraFields const knownKeys = ["role", "content", "annotations"]; // annotations in known list const extraFields = extractExtraFields(message, knownKeys); if (message.annotations) extraFields.annotations = message.annotations; // Why exclude then re-add? // GOOD: Just don't include it in knownKeys const knownKeys = ["role", "content"]; // annotations NOT in list const extraFields = extractExtraFields(message, knownKeys); // annotations automatically included
Anti-pattern to avoid - confusing extra fields with known fields:
// BAD: Putting semantic data as extra fields instead of known fields const metadata = storeMetadata(existing, { toolName: part.name }, {}); // toolName as extra field // GOOD: Use _known_fields for cross-provider semantic data const metadata = storeMetadata(existing, extraFields, { toolName: part.name }); // toolName in known fields
Remember:
knownKeys= fields you explicitly handle in your conversion codeextraFields= provider-specific data for same-provider round-trips_known_fields(viastoreMetadata) = semantic data for cross-provider translation (toolName,isError,isRefusal,originalType,messageIndex)
- Unified GenAI Schema: System/input/output schemas merged into one
_provider_metadataon ALL Entities: Every Part and Message can store provider-specific datatoGenAIRequired,fromGenAIOptional: All providers must support ingestion, output is optional- Discriminated Union for Parts: Use
typefield for runtime discrimination - Inference Fallback: If
fromnot provided, infer from messages; if inference fails, use first from priority list - Consolidated Files: Keep related code together, avoid unnecessary file splits
- All Providers Have Specifications: Every provider in the enum has an entry in
PROVIDER_SPECIFICATIONS - Opaque Metadata by Default: Prefer
z.object({}).passthrough()for metadata schemas; only define explicit fields when needed for conversion logic - Simplicity Over Completeness: Keep translation logic minimal; don't preserve information that won't be used
- Passthrough on All Entity Schemas: Use
.passthrough()on allz.object()schemas (messages, parts, tool calls, etc.) to preserve unknown fields when providers add new properties to their APIs - Provider Isolation: Providers should NEVER know about or access other providers' metadata slots - use root-level shared fields for cross-provider data
Critical principle: Providers should only know about GenAI, never about each other. This ensures the architecture scales as new providers are added.
When translating from Provider A to Provider B via GenAI, some semantically important data (like toolName on tool results) isn't part of the GenAI schema. Without a proper solution, you might be tempted to access source-provider-specific metadata, which creates coupling.
The _provider_metadata object has two parts:
_known_fields: Cross-provider semantic data that any target provider can read- Extra fields: Provider-specific data for same-provider round-trips
// _provider_metadata structure:
{
// Known fields - ANY provider can read these via getKnownFields()
_known_fields: {
toolName: "get_weather", // Tool name for tool_call_response parts
isError: true, // Error indicator
isRefusal: false, // Refusal indicator
originalType: "custom_type", // Original type for lossy conversions
messageIndex: 2, // Original position of system message in conversation
},
// Parts metadata - collapsed part-level metadata when target doesn't support structured content
// Used by providers like VercelAI (system messages only support string content)
_partsMetadata: {
_promptlSourceMap: [...], // Part metadata collapsed to message level
custom_part_field: "value",
},
// Extra fields - provider-specific data for round-trips
custom_field: "value",
annotations: [...],
}Note on _partsMetadata: Some providers require string content for certain message types (e.g., VercelAI system messages). When converting from GenAI to such providers, part-level metadata is collected and stored in _partsMetadata at the message level. When converting back to a provider that supports structured content, this metadata is extracted and applied to the first content part. Use getPartsMetadata() to read this field (handles both camelCase and snake_case variants).
In toGenAI (source providers): Use storeMetadata to store both known and extra fields:
import { storeMetadata } from "$package/utils";
// Store known fields (for cross-provider translation) and extra fields (for round-trips)
const metadata = storeMetadata(
existingMetadata, // Any existing metadata from input
{ annotations: [...] }, // Extra fields (provider-specific)
{ toolName: "...", isError: true } // Known fields (cross-provider)
);In fromGenAI (target providers): Use getKnownFields to read cross-provider data:
import { readMetadata, getKnownFields, applyMetadataMode } from "$package/utils";
const metadata = readMetadata(part);
const knownFields = getKnownFields(metadata);
// Use known fields for accurate translation
const toolName = knownFields.toolName ?? "unknown";
const isError = knownFields.isError ?? false;
// Apply metadata mode for output
return applyMetadataMode(baseEntity, metadata, providerMetadata, useCamelCase);The providerMetadata option controls how metadata appears in output:
| Mode | Description | Use Case |
|---|---|---|
"preserve" |
Keep _provider_metadata nested in output |
Storing as GenAI format |
"passthrough" |
Spread extra fields as direct properties | Lossless round-trips |
"strip" |
Don't include metadata | Clean output |
Note: When translating between the same provider (e.g., Promptl → Promptl), providerMetadata is automatically set to "passthrough" for lossless round-trips.
# Main package (run from root)
pnpm install # Install dependencies
pnpm build # Build the package (DO NOT run from AI sandbox - freezes)
pnpm test # Run unit tests
pnpm lint # Check for lint, format and type errors
pnpm knip # Check for unused files, dependencies, and exports
pnpm format # Format code and fixable lint errors
pnpm test:examples # Run the E2E tests in examples/ (no need to cd)
# Examples / E2E tests (run from examples/)
cd examples
pnpm install # Install example dependencies
pnpm test # Run E2E tests (imports from src, no rebuild needed)Always run pnpm lint, pnpm test, and pnpm knip before considering a change complete — the same way linting and testing are required, knip must pass (no unused files, dependencies, or exports).
AI Agent Note: Do NOT run pnpm build from within the AI sandbox - it freezes. Ask the user to run pnpm build manually. However, you CAN run pnpm test (and pnpm test:examples) since they import directly from source.
The examples/ folder contains E2E tests that validate the library works correctly with real provider libraries. These are NOT just usage examples - they are vitest test suites.
Key characteristics:
- Uses real libraries: Tests use actual provider SDKs (e.g.,
openai,promptl-ai) to generate realistic messages - Imports from source: The vitest config resolves
rosetta-aito../src/index.ts, so no rebuild is needed when changing the library - Separate package: Has its own
package.jsonwith provider SDKs as dependencies - Standard vitest patterns: Uses
describe,it,expect- not custom assertion functions - Two types of tests: Real API tests (skipped without credentials) AND hardcoded tests (always run)
E2E test structure:
Each provider's E2E test file should have:
- Real API tests using
describe.skipIf(!hasApiKey)- calls the actual provider API - Hardcoded tests - uses manually constructed messages, runs without API keys
This ensures tests pass in CI (where API keys may not be available) while still validating real API behavior when credentials are present.
When to update examples:
- When adding a new provider, create
examples/{provider}.test.tswith both real and hardcoded tests - Add the provider SDK to
examples/package.json - When changing message formats or translation behavior, update relevant tests
- When fixing bugs, add regression tests to prevent recurrence
Running examples:
cd examples
pnpm install # Install provider SDKs
pnpm test # Runs all E2E tests against the sourceRunning with API keys:
cd examples
OPENAI_API_KEY=sk-... pnpm test # Runs including real API testsThis allows rapid iteration: edit source → run examples tests → see results immediately.