Skip to content

Commit 9088e94

Browse files
Merge pull request #17 from freeplayai/apandey/openai-responses-adapter
2 parents da24790 + 74bee68 commit 9088e94

11 files changed

Lines changed: 364 additions & 48 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

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

5+
## [0.5.10]
6+
7+
### Added
8+
9+
- **`openai_responses` flavor**: New adapter for the OpenAI Responses API.
10+
- **`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.
11+
12+
513
## [0.5.9]
614

715
### Fixed

‎Makefile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ test-ci: type-check lint
2727
# Example usage: make run-example
2828
# This will run examples/example.py
2929
run-%:
30-
source .env; uv run python examples/$*.py
30+
source .env; REQUESTS_CA_BUNDLE="$$(mkcert -CAROOT)/rootCA.pem" uv run python examples/$*.py
3131

3232
# Start interactive REPL with Freeplay client initialized
3333
# By default connects to production (app.freeplay.ai)

‎examples/openai_responses_api.py‎

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,71 @@
11
import os
22
import time
3+
from typing import Any, Dict
4+
5+
from openai import OpenAI
36

47
from freeplay import Freeplay, RecordPayload, CallInfo
58
from freeplay.resources.recordings import UsageTokens
6-
from openai import OpenAI
7-
from openai.types.responses import WebSearchToolParam
89

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

15-
input_variables = {
16-
"question": "search the internet and tell me about Freeplay's latest funding round"
17-
}
16+
input_variables = {"location": "San Francisco"}
1817

1918
project_id = os.environ["FREEPLAY_PROJECT_ID"]
2019

2120
formatted_prompt = fp_client.prompts.get_formatted(
2221
project_id=project_id,
23-
template_name="witty-question",
22+
template_name="my-openai-prompt",
2423
environment="latest",
2524
variables=input_variables,
2625
)
2726

27+
print(f"Instructions (system): {formatted_prompt.system_content}")
28+
print(f"Input messages: {formatted_prompt.llm_prompt}")
29+
print(f"Tool schema: {formatted_prompt.tool_schema}")
30+
print(f"Output schema: {formatted_prompt.formatted_output_schema}")
31+
32+
# Build the Responses API call
33+
response_params: Dict[str, Any] = {
34+
**formatted_prompt.prompt_info.model_parameters,
35+
}
36+
if formatted_prompt.system_content:
37+
response_params["instructions"] = formatted_prompt.system_content
38+
if formatted_prompt.tool_schema:
39+
response_params["tools"] = formatted_prompt.tool_schema
40+
if formatted_prompt.formatted_output_schema:
41+
response_params["text"] = {
42+
"format": {
43+
"type": "json_schema",
44+
"strict": True,
45+
"schema": formatted_prompt.formatted_output_schema,
46+
"name": "COTReasoning",
47+
}
48+
}
49+
2850
start = time.time()
2951
completion = openai_client.responses.create(
3052
input=formatted_prompt.llm_prompt,
3153
model=formatted_prompt.prompt_info.model,
32-
include=["code_interpreter_call.outputs"],
33-
tools=[WebSearchToolParam(type="web_search_preview")],
34-
# TODO: Tool schema from prompt can't be used -- format has changed from chat completions API...
35-
# FIX => format tool schema for responses API. Likely need a new flavor for the Openai Responses API
36-
# tools=formatted_prompt.tool_schema,
37-
**formatted_prompt.prompt_info.model_parameters,
54+
**response_params,
3855
)
3956
end = time.time()
4057
print("Completion: %s" % completion)
4158

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

47-
messages = formatted_prompt.all_messages(out_msg)
48-
print(f"All messages: {messages}")
4962
call_info = CallInfo.from_prompt_info(
5063
formatted_prompt.prompt_info,
5164
start,
5265
end,
5366
UsageTokens(completion.usage.input_tokens, completion.usage.output_tokens),
5467
api_style="batch",
5568
)
56-
print(f"Messages: {messages}")
5769
record_response = fp_client.recordings.create(
5870
RecordPayload(
5971
project_id=project_id,
@@ -66,9 +78,4 @@
6678
)
6779
)
6880

69-
print(f"Sending customer feedback for completion id: {record_response.completion_id}")
70-
fp_client.customer_feedback.update(
71-
project_id,
72-
record_response.completion_id,
73-
{"is_it_good": "nah", "count_of_interactions": 123},
74-
)
81+
print(f"Record response: {record_response.completion_id}")

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "freeplay"
3-
version = "0.5.9"
3+
version = "0.5.10"
44
description = ""
55
authors = [
66
{name = "Freeplay Engineering", email = "support@freeplay.ai"},

‎scripts/type-baseline/pyright-baseline.json‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,71 +2,71 @@
22
"generalDiagnostics": [
33
{
44
"file": "src/freeplay/resources/adapters.py",
5-
"line": 40,
5+
"line": 87,
66
"character": 9,
77
"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]]\"",
88
"rule": "reportReturnType",
99
"severity": "error"
1010
},
1111
{
1212
"file": "src/freeplay/resources/adapters.py",
13-
"line": 62,
13+
"line": 113,
1414
"character": 16,
1515
"message": "Type of \"append\" is partially unknown\n\u00a0\u00a0Type of \"append\" is \"(object: Unknown, /) -> None\"",
1616
"rule": "reportUnknownMemberType",
1717
"severity": "error"
1818
},
1919
{
2020
"file": "src/freeplay/resources/adapters.py",
21-
"line": 72,
21+
"line": 123,
2222
"character": 16,
2323
"message": "Type of \"append\" is partially unknown\n\u00a0\u00a0Type of \"append\" is \"(object: Unknown, /) -> None\"",
2424
"rule": "reportUnknownMemberType",
2525
"severity": "error"
2626
},
2727
{
2828
"file": "src/freeplay/resources/adapters.py",
29-
"line": 74,
29+
"line": 125,
3030
"character": 15,
3131
"message": "Return type, \"list[Unknown]\", is partially unknown",
3232
"rule": "reportUnknownVariableType",
3333
"severity": "error"
3434
},
3535
{
3636
"file": "src/freeplay/resources/adapters.py",
37-
"line": 95,
37+
"line": 146,
3838
"character": 13,
3939
"message": "Unnecessary isinstance call; \"MediaContentUrl\" is always an instance of \"MediaContentUrl\"",
4040
"rule": "reportUnnecessaryIsInstance",
4141
"severity": "error"
4242
},
4343
{
4444
"file": "src/freeplay/resources/adapters.py",
45-
"line": 115,
45+
"line": 168,
4646
"character": 16,
4747
"message": "Type of \"append\" is partially unknown\n\u00a0\u00a0Type of \"append\" is \"(object: Unknown, /) -> None\"",
4848
"rule": "reportUnknownMemberType",
4949
"severity": "error"
5050
},
5151
{
5252
"file": "src/freeplay/resources/adapters.py",
53-
"line": 125,
53+
"line": 178,
5454
"character": 16,
5555
"message": "Type of \"append\" is partially unknown\n\u00a0\u00a0Type of \"append\" is \"(object: Unknown, /) -> None\"",
5656
"rule": "reportUnknownMemberType",
5757
"severity": "error"
5858
},
5959
{
6060
"file": "src/freeplay/resources/adapters.py",
61-
"line": 127,
61+
"line": 180,
6262
"character": 15,
6363
"message": "Return type, \"list[Unknown]\", is partially unknown",
6464
"rule": "reportUnknownVariableType",
6565
"severity": "error"
6666
},
6767
{
6868
"file": "src/freeplay/resources/adapters.py",
69-
"line": 137,
69+
"line": 190,
7070
"character": 13,
7171
"message": "Unnecessary isinstance call; \"MediaContentUrl\" is always an instance of \"MediaContentUrl\"",
7272
"rule": "reportUnnecessaryIsInstance",

‎src/freeplay/resources/adapters.py‎

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import copy
22
from dataclasses import dataclass
3-
from typing import Any, Dict, List, Protocol, Union
3+
from typing import Any, ClassVar, Dict, FrozenSet, List, Protocol, Set, Union
44

5-
from freeplay.errors import FreeplayConfigurationError
5+
from freeplay.errors import FreeplayConfigurationError, log_freeplay_client_warning
66
from freeplay.support import MediaType
77

88

@@ -34,7 +34,54 @@ def __init__(self, flavor_name: str):
3434
)
3535

3636

37+
@dataclass
38+
class RoleSupport:
39+
supported: FrozenSet[str]
40+
coerce_map: Dict[str, str]
41+
42+
def __post_init__(self) -> None:
43+
for source, target in self.coerce_map.items():
44+
if target not in self.supported:
45+
raise ValueError(
46+
"coerce_map target '%s' (from '%s') is not in supported roles"
47+
% (target, source)
48+
)
49+
50+
51+
_DEFAULT_ROLE_SUPPORT = RoleSupport(
52+
supported=frozenset({"system", "user", "assistant"}),
53+
coerce_map={"developer": "system"},
54+
)
55+
56+
57+
def prepare_messages(
58+
flavor_name: str,
59+
role_support: RoleSupport,
60+
messages: List[Dict[str, Any]],
61+
) -> List[Dict[str, Any]]:
62+
"""Coerce or reject message roles based on adapter's RoleSupport declaration."""
63+
result: List[Dict[str, Any]] = []
64+
coerced_roles: Set[str] = set()
65+
for m in messages:
66+
role = m.get("role", "")
67+
if role in role_support.supported:
68+
result.append(m)
69+
elif role in role_support.coerce_map:
70+
coerced_roles.add(role)
71+
result.append({**m, "role": role_support.coerce_map[role]})
72+
else:
73+
raise ValueError("role '%s' is not supported by %s" % (role, flavor_name))
74+
for role in sorted(coerced_roles):
75+
log_freeplay_client_warning(
76+
"%s role is not supported by %s; coercing to %s"
77+
% (role, flavor_name, role_support.coerce_map[role])
78+
)
79+
return result
80+
81+
3782
class LLMAdapter(Protocol):
83+
role_support: ClassVar[RoleSupport]
84+
3885
# This method must handle BOTH prompt template messages and provider specific messages.
3986
def to_llm_syntax(
4087
self, messages: List[Dict[str, Any]]
@@ -43,6 +90,8 @@ def to_llm_syntax(
4390

4491

4592
class PassthroughAdapter(LLMAdapter):
93+
role_support = _DEFAULT_ROLE_SUPPORT
94+
4695
def to_llm_syntax(
4796
self, messages: List[Dict[str, Any]]
4897
) -> Union[str, List[Dict[str, Any]]]:
@@ -51,6 +100,8 @@ def to_llm_syntax(
51100

52101

53102
class AnthropicAdapter(LLMAdapter):
103+
role_support = _DEFAULT_ROLE_SUPPORT
104+
54105
def to_llm_syntax(
55106
self, messages: List[Dict[str, Any]]
56107
) -> Union[str, List[Dict[str, Any]]]:
@@ -106,6 +157,8 @@ def __map_content(
106157

107158

108159
class OpenAIAdapter(LLMAdapter):
160+
role_support = _DEFAULT_ROLE_SUPPORT
161+
109162
def to_llm_syntax(
110163
self, messages: List[Dict[str, Any]]
111164
) -> Union[str, List[Dict[str, Any]]]:
@@ -175,6 +228,8 @@ def __format_base64_content(content: MediaContentBase64) -> Dict[str, Any]:
175228

176229

177230
class Llama3Adapter(LLMAdapter):
231+
role_support = _DEFAULT_ROLE_SUPPORT
232+
178233
def to_llm_syntax(
179234
self, messages: List[Dict[str, Any]]
180235
) -> Union[str, List[Dict[str, Any]]]:
@@ -190,6 +245,11 @@ def to_llm_syntax(
190245

191246

192247
class GeminiAdapter(LLMAdapter):
248+
role_support = RoleSupport(
249+
supported=frozenset({"system", "user", "assistant", "model"}),
250+
coerce_map={"developer": "system"},
251+
)
252+
193253
def to_llm_syntax(
194254
self, messages: List[Dict[str, Any]]
195255
) -> Union[str, List[Dict[str, Any]]]:
@@ -261,7 +321,23 @@ def __translate_role(role: str) -> str:
261321
raise ValueError(f"Gemini formatting found unexpected role {role}")
262322

263323

324+
class OpenAIResponsesAdapter(OpenAIAdapter):
325+
role_support = RoleSupport(
326+
supported=frozenset({"system", "user", "assistant", "developer"}),
327+
coerce_map={},
328+
)
329+
330+
def to_llm_syntax(
331+
self, messages: List[Dict[str, Any]]
332+
) -> Union[str, List[Dict[str, Any]]]:
333+
formatted = super().to_llm_syntax(messages)
334+
assert isinstance(formatted, list)
335+
return [{"type": "message", **m} for m in formatted if m["role"] != "system"]
336+
337+
264338
class BedrockConverseAdapter(LLMAdapter):
339+
role_support = _DEFAULT_ROLE_SUPPORT
340+
265341
def to_llm_syntax(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
266342
converse_messages: List[Dict[str, Any]] = []
267343
for message in messages:
@@ -339,6 +415,8 @@ def adaptor_for_flavor(flavor_name: str) -> LLMAdapter:
339415
return PassthroughAdapter()
340416
elif flavor_name in ["azure_openai_chat", "openai_chat"]:
341417
return OpenAIAdapter()
418+
elif flavor_name == "openai_responses":
419+
return OpenAIResponsesAdapter()
342420
elif flavor_name == "anthropic_chat":
343421
return AnthropicAdapter()
344422
elif flavor_name == "llama_3_chat":

0 commit comments

Comments
 (0)