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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

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

## [0.5.10]

### Added

- **`openai_responses` flavor**: New adapter for the OpenAI Responses API.
- **`developer` role support**: Messages with `role: "developer"` are now supported. Each adapter coerces the role appropriately for its provider — e.g. mapped to `system` for providers that don't support it natively, preserved as-is for OpenAI flavors.


## [0.5.9]

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ test-ci: type-check lint
# Example usage: make run-example
# This will run examples/example.py
run-%:
source .env; uv run python examples/$*.py
source .env; REQUESTS_CA_BUNDLE="$$(mkcert -CAROOT)/rootCA.pem" uv run python examples/$*.py

# Start interactive REPL with Freeplay client initialized
# By default connects to production (app.freeplay.ai)
Expand Down
55 changes: 31 additions & 24 deletions examples/openai_responses_api.py
Original file line number Diff line number Diff line change
@@ -1,59 +1,71 @@
import os
import time
from typing import Any, Dict

from openai import OpenAI

from freeplay import Freeplay, RecordPayload, CallInfo
from freeplay.resources.recordings import UsageTokens
from openai import OpenAI
from openai.types.responses import WebSearchToolParam

fp_client = Freeplay(
freeplay_api_key=os.environ["FREEPLAY_API_KEY"],
api_base=f"{os.environ['FREEPLAY_API_URL']}/api",
)
openai_client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY"))

input_variables = {
"question": "search the internet and tell me about Freeplay's latest funding round"
}
input_variables = {"location": "San Francisco"}

project_id = os.environ["FREEPLAY_PROJECT_ID"]

formatted_prompt = fp_client.prompts.get_formatted(
project_id=project_id,
template_name="witty-question",
template_name="my-openai-prompt",
environment="latest",
variables=input_variables,
)

print(f"Instructions (system): {formatted_prompt.system_content}")
print(f"Input messages: {formatted_prompt.llm_prompt}")
print(f"Tool schema: {formatted_prompt.tool_schema}")
print(f"Output schema: {formatted_prompt.formatted_output_schema}")

# Build the Responses API call
response_params: Dict[str, Any] = {
**formatted_prompt.prompt_info.model_parameters,
}
if formatted_prompt.system_content:
response_params["instructions"] = formatted_prompt.system_content
if formatted_prompt.tool_schema:
response_params["tools"] = formatted_prompt.tool_schema
if formatted_prompt.formatted_output_schema:
response_params["text"] = {
"format": {
"type": "json_schema",
"strict": True,
"schema": formatted_prompt.formatted_output_schema,
"name": "COTReasoning",
}
}

start = time.time()
completion = openai_client.responses.create(
input=formatted_prompt.llm_prompt,
model=formatted_prompt.prompt_info.model,
include=["code_interpreter_call.outputs"],
tools=[WebSearchToolParam(type="web_search_preview")],
# TODO: Tool schema from prompt can't be used -- format has changed from chat completions API...
# FIX => format tool schema for responses API. Likely need a new flavor for the Openai Responses API
# tools=formatted_prompt.tool_schema,
**formatted_prompt.prompt_info.model_parameters,
**response_params,
)
end = time.time()
print("Completion: %s" % completion)

session = fp_client.sessions.create()
# TODO: Rough edge: requires constructing a message format from text. This would drop tool calls, etc.
# Fix => We could update our record payload to accept these messages/tool calls, etc.
out_msg = {"role": "assistant", "content": completion.output_text}
messages = formatted_prompt.all_messages(completion.output)

messages = formatted_prompt.all_messages(out_msg)
print(f"All messages: {messages}")
call_info = CallInfo.from_prompt_info(
formatted_prompt.prompt_info,
start,
end,
UsageTokens(completion.usage.input_tokens, completion.usage.output_tokens),
api_style="batch",
)
print(f"Messages: {messages}")
record_response = fp_client.recordings.create(
RecordPayload(
project_id=project_id,
Expand All @@ -66,9 +78,4 @@
)
)

print(f"Sending customer feedback for completion id: {record_response.completion_id}")
fp_client.customer_feedback.update(
project_id,
record_response.completion_id,
{"is_it_good": "nah", "count_of_interactions": 123},
)
print(f"Record response: {record_response.completion_id}")
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "freeplay"
version = "0.5.9"
version = "0.5.10"
description = ""
authors = [
{name = "Freeplay Engineering", email = "support@freeplay.ai"},
Expand Down
18 changes: 9 additions & 9 deletions scripts/type-baseline/pyright-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,71 +2,71 @@
"generalDiagnostics": [
{
"file": "src/freeplay/resources/adapters.py",
"line": 40,
"line": 87,
"character": 9,
"message": "Function with declared return type \"str | List[Dict[str, Any]]\" must return value on all code paths\n\u00a0\u00a0Type \"None\" is not assignable to type \"str | List[Dict[str, Any]]\"\n\u00a0\u00a0\u00a0\u00a0\"None\" is not assignable to \"str\"\n\u00a0\u00a0\u00a0\u00a0\"None\" is not assignable to \"List[Dict[str, Any]]\"",
"rule": "reportReturnType",
"severity": "error"
},
{
"file": "src/freeplay/resources/adapters.py",
"line": 62,
"line": 113,
"character": 16,
"message": "Type of \"append\" is partially unknown\n\u00a0\u00a0Type of \"append\" is \"(object: Unknown, /) -> None\"",
"rule": "reportUnknownMemberType",
"severity": "error"
},
{
"file": "src/freeplay/resources/adapters.py",
"line": 72,
"line": 123,
"character": 16,
"message": "Type of \"append\" is partially unknown\n\u00a0\u00a0Type of \"append\" is \"(object: Unknown, /) -> None\"",
"rule": "reportUnknownMemberType",
"severity": "error"
},
{
"file": "src/freeplay/resources/adapters.py",
"line": 74,
"line": 125,
"character": 15,
"message": "Return type, \"list[Unknown]\", is partially unknown",
"rule": "reportUnknownVariableType",
"severity": "error"
},
{
"file": "src/freeplay/resources/adapters.py",
"line": 95,
"line": 146,
"character": 13,
"message": "Unnecessary isinstance call; \"MediaContentUrl\" is always an instance of \"MediaContentUrl\"",
"rule": "reportUnnecessaryIsInstance",
"severity": "error"
},
{
"file": "src/freeplay/resources/adapters.py",
"line": 115,
"line": 168,
"character": 16,
"message": "Type of \"append\" is partially unknown\n\u00a0\u00a0Type of \"append\" is \"(object: Unknown, /) -> None\"",
"rule": "reportUnknownMemberType",
"severity": "error"
},
{
"file": "src/freeplay/resources/adapters.py",
"line": 125,
"line": 178,
"character": 16,
"message": "Type of \"append\" is partially unknown\n\u00a0\u00a0Type of \"append\" is \"(object: Unknown, /) -> None\"",
"rule": "reportUnknownMemberType",
"severity": "error"
},
{
"file": "src/freeplay/resources/adapters.py",
"line": 127,
"line": 180,
"character": 15,
"message": "Return type, \"list[Unknown]\", is partially unknown",
"rule": "reportUnknownVariableType",
"severity": "error"
},
{
"file": "src/freeplay/resources/adapters.py",
"line": 137,
"line": 190,
"character": 13,
"message": "Unnecessary isinstance call; \"MediaContentUrl\" is always an instance of \"MediaContentUrl\"",
"rule": "reportUnnecessaryIsInstance",
Expand Down
82 changes: 80 additions & 2 deletions src/freeplay/resources/adapters.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import copy
from dataclasses import dataclass
from typing import Any, Dict, List, Protocol, Union
from typing import Any, ClassVar, Dict, FrozenSet, List, Protocol, Set, Union

from freeplay.errors import FreeplayConfigurationError
from freeplay.errors import FreeplayConfigurationError, log_freeplay_client_warning
from freeplay.support import MediaType


Expand Down Expand Up @@ -34,7 +34,54 @@ def __init__(self, flavor_name: str):
)


@dataclass
class RoleSupport:
supported: FrozenSet[str]
coerce_map: Dict[str, str]

def __post_init__(self) -> None:
for source, target in self.coerce_map.items():
if target not in self.supported:
raise ValueError(
"coerce_map target '%s' (from '%s') is not in supported roles"
% (target, source)
)


_DEFAULT_ROLE_SUPPORT = RoleSupport(
supported=frozenset({"system", "user", "assistant"}),
coerce_map={"developer": "system"},
)


def prepare_messages(
flavor_name: str,
role_support: RoleSupport,
messages: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Coerce or reject message roles based on adapter's RoleSupport declaration."""
result: List[Dict[str, Any]] = []
coerced_roles: Set[str] = set()
for m in messages:
role = m.get("role", "")
if role in role_support.supported:
result.append(m)
elif role in role_support.coerce_map:
coerced_roles.add(role)
result.append({**m, "role": role_support.coerce_map[role]})
else:
raise ValueError("role '%s' is not supported by %s" % (role, flavor_name))
for role in sorted(coerced_roles):
log_freeplay_client_warning(
"%s role is not supported by %s; coercing to %s"
% (role, flavor_name, role_support.coerce_map[role])
)
return result


class LLMAdapter(Protocol):
role_support: ClassVar[RoleSupport]

# This method must handle BOTH prompt template messages and provider specific messages.
def to_llm_syntax(
self, messages: List[Dict[str, Any]]
Expand All @@ -43,6 +90,8 @@ def to_llm_syntax(


class PassthroughAdapter(LLMAdapter):
role_support = _DEFAULT_ROLE_SUPPORT

def to_llm_syntax(
self, messages: List[Dict[str, Any]]
) -> Union[str, List[Dict[str, Any]]]:
Expand All @@ -51,6 +100,8 @@ def to_llm_syntax(


class AnthropicAdapter(LLMAdapter):
role_support = _DEFAULT_ROLE_SUPPORT

def to_llm_syntax(
self, messages: List[Dict[str, Any]]
) -> Union[str, List[Dict[str, Any]]]:
Expand Down Expand Up @@ -106,6 +157,8 @@ def __map_content(


class OpenAIAdapter(LLMAdapter):
role_support = _DEFAULT_ROLE_SUPPORT

def to_llm_syntax(
self, messages: List[Dict[str, Any]]
) -> Union[str, List[Dict[str, Any]]]:
Expand Down Expand Up @@ -175,6 +228,8 @@ def __format_base64_content(content: MediaContentBase64) -> Dict[str, Any]:


class Llama3Adapter(LLMAdapter):
role_support = _DEFAULT_ROLE_SUPPORT

def to_llm_syntax(
self, messages: List[Dict[str, Any]]
) -> Union[str, List[Dict[str, Any]]]:
Expand All @@ -190,6 +245,11 @@ def to_llm_syntax(


class GeminiAdapter(LLMAdapter):
role_support = RoleSupport(
supported=frozenset({"system", "user", "assistant", "model"}),
coerce_map={"developer": "system"},
)

def to_llm_syntax(
self, messages: List[Dict[str, Any]]
) -> Union[str, List[Dict[str, Any]]]:
Expand Down Expand Up @@ -261,7 +321,23 @@ def __translate_role(role: str) -> str:
raise ValueError(f"Gemini formatting found unexpected role {role}")


class OpenAIResponsesAdapter(OpenAIAdapter):
role_support = RoleSupport(
supported=frozenset({"system", "user", "assistant", "developer"}),
coerce_map={},
)

def to_llm_syntax(
self, messages: List[Dict[str, Any]]
) -> Union[str, List[Dict[str, Any]]]:
formatted = super().to_llm_syntax(messages)
assert isinstance(formatted, list)
return [{"type": "message", **m} for m in formatted if m["role"] != "system"]


class BedrockConverseAdapter(LLMAdapter):
role_support = _DEFAULT_ROLE_SUPPORT

def to_llm_syntax(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
converse_messages: List[Dict[str, Any]] = []
for message in messages:
Expand Down Expand Up @@ -339,6 +415,8 @@ def adaptor_for_flavor(flavor_name: str) -> LLMAdapter:
return PassthroughAdapter()
elif flavor_name in ["azure_openai_chat", "openai_chat"]:
return OpenAIAdapter()
elif flavor_name == "openai_responses":
return OpenAIResponsesAdapter()
elif flavor_name == "anthropic_chat":
return AnthropicAdapter()
elif flavor_name == "llama_3_chat":
Expand Down
Loading