Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
80 changes: 78 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,56 @@ 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,
): 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 +213,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 +226,8 @@ export class LLMAdapters {
}

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

provider(): string {
return "anthropic";
}
Expand Down Expand Up @@ -231,6 +283,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 +349,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 +378,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 +390,8 @@ export class BasetenMistralLLMAdapter
}

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

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