Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
78 changes: 78 additions & 0 deletions examples/ts-cjs/openaiResponsesApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import Freeplay, { getCallInfo } 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 messages = formattedPrompt.allMessages(completion.output);

await fpClient.recordings.create({
projectId,
allMessages: messages,
inputs: inputVariables,
promptVersionInfo: formattedPrompt.promptInfo,
callInfo: getCallInfo(formattedPrompt.promptInfo, start, end, {
promptTokens: completion.usage.input_tokens,
completionTokens: completion.usage.output_tokens,
}),
toolSchema: formattedPrompt.toolSchema,
});

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
81 changes: 79 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,57 @@ export type LLMMessage = string | ProviderMessage[];

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

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,
flavorName: string,
): 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 ${flavorName} flavor. Please update your prompt template in Freeplay to use a flavor that supports the '${role}' role.`,
);
});
}

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

toLLMSyntax(messages: ProviderMessage[]): LLMFormat;
Expand All @@ -165,6 +214,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 +227,8 @@ export class LLMAdapters {
}

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

provider(): string {
return "anthropic";
}
Expand Down Expand Up @@ -231,6 +284,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 +350,20 @@ export class OpenAILLMAdapter implements ILLMAdapter<ProviderMessage[]> {
}
}

export class OpenAIResponsesAdapter extends OpenAILLMAdapter {
roleSupport = OPENAI_RESPONSES_ROLE_SUPPORT;

toLLMSyntax(messages: ProviderMessage[]): ProviderMessage[] {
const formatted = super.toLLMSyntax(messages);
return formatted
.filter((message) => message.role !== "system")
.map((message) => ({ type: "message", ...message }));
}
}

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

provider(): string {
return "sagemaker";
}
Expand All @@ -311,6 +379,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 +391,8 @@ export class BasetenMistralLLMAdapter
}

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

provider(): string {
return "bedrock";
}
Expand All @@ -331,6 +403,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 +481,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 +577,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