Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@

# Changelog

Notable additions, fixes, or breaking changes to the Freeplay SDK.

## [0.6.0] - 2026-03-09

### Added

- **OpenAI Responses API support**: New `openai_responses` flavor with `OpenAIResponsesAdapter` that formats messages for the OpenAI Responses API (`{type: "message", role, content}`), strips system messages (use `instructions` parameter instead), and formats tool schemas in the flat Responses API style (`{type, name, description, parameters}`).
- **`developer` role support**: New role coercion system via `prepareMessages()`. The `developer` role passes through natively for `openai_responses`, is coerced to `system` (with a warning) for `openai_chat`, and throws for unsupported flavors.

## [0.5.5] - 2026-02-10

### Added
Expand Down Expand Up @@ -60,6 +66,7 @@ Notable additions, fixes, or breaking changes to the Freeplay SDK.
```

**Notes:**

- Backend automatically normalizes all tool schema formats (OpenAI, Anthropic, GenAI/Vertex)
- No breaking changes to the API - tool schemas are still passed the same way
- This approach is consistent with how we handle messages from different providers
Expand Down
91 changes: 91 additions & 0 deletions examples/ts-cjs/openaiResponsesApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import Freeplay, { getCallInfo, getSessionInfo } from "freeplay";
import OpenAI from "openai";

async function main() {
const fpClient = new Freeplay({
freeplayApiKey: process.env["FREEPLAY_API_KEY"],
baseUrl: `${process.env["FREEPLAY_API_URL"]}/api`,
});

const openaiClient = new OpenAI({
apiKey: process.env["OPENAI_API_KEY"],
});

const inputVariables = { location: "San Francisco" };
const projectId = process.env["FREEPLAY_PROJECT_ID"]!;

const formattedPrompt = await fpClient.prompts.getFormatted({
projectId,
templateName: "my-openai-prompt",
environment: "latest",
variables: inputVariables,
});
Comment thread
callingmedic911 marked this conversation as resolved.

console.log("Instructions (system):", formattedPrompt.systemContent);
console.log("Input messages:", formattedPrompt.llmPrompt);
console.log("Tool schema:", formattedPrompt.toolSchema);
console.log("Output schema:", formattedPrompt.outputSchema);

// Build the Responses API call parameters
const responseParams: Record<string, any> = {
...formattedPrompt.promptInfo.modelParameters,
};
if (formattedPrompt.systemContent) {
responseParams.instructions = formattedPrompt.systemContent;
}
if (formattedPrompt.toolSchema) {
responseParams.tools = formattedPrompt.toolSchema;
}
if (formattedPrompt.outputSchema) {
responseParams.text = {
format: {
type: "json_schema",
strict: true,
schema: formattedPrompt.outputSchema,
name: "COTReasoning",
},
};
}

const start = new Date();
const completion = await openaiClient.responses.create({
input: formattedPrompt.llmPrompt as OpenAI.Responses.ResponseInput,
model: formattedPrompt.promptInfo.model,
...responseParams,
});
const end = new Date();

console.log("Completion:", completion);

// Record to Freeplay
const session = fpClient.sessions.create();
const messages = formattedPrompt.allMessages(
completion.output as any,
);

await fpClient.recordings.create({
projectId,
allMessages: messages,
sessionInfo: getSessionInfo(session),
inputs: inputVariables,
promptVersionInfo: formattedPrompt.promptInfo,
callInfo: getCallInfo(
formattedPrompt.promptInfo,
start,
end,
{
promptTokens: completion.usage.input_tokens,
completionTokens: completion.usage.output_tokens,
},
"batch",
),
toolSchema: formattedPrompt.toolSchema,
responseInfo: {
isComplete: true,
},
});

console.log("Recording created successfully");
}

main().catch(console.error);
10 changes: 6 additions & 4 deletions examples/ts-cjs/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion examples/ts-cjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"example": "source ../../.env && ts-node testRunExampleOpenAI.ts",
"test-run": "source ../../.env && ts-node testRunWithAgentDataset.ts",
"vertex": "source ../../.env && ts-node vertexGeminiTools.ts",
"nested-traces": "source ../../.env && ts-node traceWithParentId.ts"
"nested-traces": "source ../../.env && ts-node traceWithParentId.ts",
"openai-responses": "source ../../.env && ts-node openaiResponsesApi.ts"
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "freeplay",
"version": "0.5.5",
"version": "0.6.0",
"description": "Node.js/Typescript SDK for Freeplay AI",
"type": "module",
"typesVersions": {
Expand Down
95 changes: 93 additions & 2 deletions src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export type MediaSlot = {
};

export type StrictChatMessage = {
role: "system" | "assistant" | "user";
role: "system" | "assistant" | "user" | "developer";
content: string;
kind?: string;
};
Expand Down Expand Up @@ -141,8 +141,58 @@ export type LLMMessage = string | ProviderMessage[];

export type CustomMetadata = Record<string, string | number | boolean>;

// -- Role support infrastructure --

export type RoleSupport = {
supported: ReadonlySet<string>;
coerceMap: Readonly<Record<string, string>>;
};

const DEFAULT_ROLE_SUPPORT: RoleSupport = {
supported: new Set(["system", "user", "assistant"]),
coerceMap: {},
};

const OPENAI_ROLE_SUPPORT: RoleSupport = {
supported: new Set(["system", "user", "assistant", "tool"]),
coerceMap: { developer: "system" },
};

const OPENAI_RESPONSES_ROLE_SUPPORT: RoleSupport = {
supported: new Set(["system", "user", "assistant", "developer", "tool"]),
coerceMap: {},
};
Comment thread
callingmedic911 marked this conversation as resolved.

const GEMINI_ROLE_SUPPORT: RoleSupport = {
supported: new Set(["system", "user", "assistant", "model"]),
coerceMap: {},
};

export function prepareMessages(
messages: ProviderMessage[],
roleSupport: RoleSupport,
): ProviderMessage[] {
return messages.map((message) => {
const role = message.role;
if (roleSupport.supported.has(role)) {
return message;
}
const coerced = roleSupport.coerceMap[role];
if (coerced) {
console.warn(
`Role '${role}' is not natively supported by this flavor. Coercing to '${coerced}'.`,
);
return { ...message, role: coerced };
}
throw new FreeplayConfigurationError(
`Role '${role}' is not supported by this flavor.`,
Comment thread
callingmedic911 marked this conversation as resolved.
Outdated
);
});
}

// Thin requirements of a "Flavor".
interface ILLMAdapter<LLMFormat> {
roleSupport: RoleSupport;
provider(): string;

toLLMSyntax(messages: ProviderMessage[]): LLMFormat;
Expand All @@ -165,6 +215,8 @@ export class LLMAdapters {
return new GeminiLLMAdapter();
case "gemini_api_chat":
return new GeminiApiLLMAdapter();
case "openai_responses":
return new OpenAIResponsesAdapter();
case "amazon_bedrock_converse":
return new BedrockConverseAdapter();
default:
Expand All @@ -176,6 +228,8 @@ export class LLMAdapters {
}

export class AnthropicLLMAdapter implements ILLMAdapter<ProviderMessage[]> {
roleSupport = DEFAULT_ROLE_SUPPORT;

provider(): string {
return "anthropic";
}
Expand Down Expand Up @@ -231,6 +285,8 @@ export class AnthropicLLMAdapter implements ILLMAdapter<ProviderMessage[]> {
}

export class OpenAILLMAdapter implements ILLMAdapter<ProviderMessage[]> {
roleSupport = OPENAI_ROLE_SUPPORT;

provider(): string {
return "openai";
}
Expand Down Expand Up @@ -295,7 +351,33 @@ export class OpenAILLMAdapter implements ILLMAdapter<ProviderMessage[]> {
}
}

export class OpenAIResponsesAdapter extends OpenAILLMAdapter {
roleSupport = OPENAI_RESPONSES_ROLE_SUPPORT;

toLLMSyntax(messages: ProviderMessage[]): ProviderMessage[] {
// Strip system messages (they go to the `instructions` parameter in Responses API)
const nonSystemMessages = messages.filter(
(message) => message.role !== "system",
);

// Run parent's media formatting, then wrap each message in Responses API format
const formatted = super.toLLMSyntax(nonSystemMessages);
return formatted.map((message) => {
// Pass through messages that are already in Responses API format (e.g. from history)
if ("type" in message) {
return message;
}
return {
...message,
type: "message",
};
});
}
}

export class Llama3LLMAdapter implements ILLMAdapter<string> {
roleSupport = DEFAULT_ROLE_SUPPORT;

provider(): string {
return "sagemaker";
}
Expand All @@ -311,6 +393,8 @@ export class Llama3LLMAdapter implements ILLMAdapter<string> {
export class BasetenMistralLLMAdapter
implements ILLMAdapter<ProviderMessage[]>
{
roleSupport = DEFAULT_ROLE_SUPPORT;

provider(): string {
return "baseten";
}
Expand All @@ -321,6 +405,8 @@ export class BasetenMistralLLMAdapter
}

export class MistralLLMAdapter implements ILLMAdapter<ProviderMessage[]> {
roleSupport = DEFAULT_ROLE_SUPPORT;

provider(): string {
return "bedrock";
}
Expand All @@ -331,6 +417,8 @@ export class MistralLLMAdapter implements ILLMAdapter<ProviderMessage[]> {
}

export class GeminiLLMAdapter implements ILLMAdapter<GeminiChatMessage[]> {
roleSupport = GEMINI_ROLE_SUPPORT;

provider(): string {
return "vertex";
}
Expand Down Expand Up @@ -407,6 +495,8 @@ export class GeminiApiLLMAdapter extends GeminiLLMAdapter {
}

export class BedrockConverseAdapter implements ILLMAdapter<ProviderMessage[]> {
roleSupport = DEFAULT_ROLE_SUPPORT;

provider(): string {
return "bedrock";
}
Expand Down Expand Up @@ -501,7 +591,8 @@ export type FlavorSpecifier =
| "baseten_mistral_chat"
| "mistral_chat"
| "gemini_chat"
| "gemini_api_chat";
| "gemini_api_chat"
| "openai_responses";
export type Provider =
| "openai"
| "azure_openai"
Expand Down
Loading