Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
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
48 changes: 24 additions & 24 deletions examples/openai_responses_api.py
Original file line number Diff line number Diff line change
@@ -1,59 +1,64 @@
import os
import time
from typing import Any

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"] = formatted_prompt.formatted_output_schema

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The output schema param is really called text?


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,
Comment thread
callingmedic911 marked this conversation as resolved.
Outdated
)
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 +71,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}")
10 changes: 10 additions & 0 deletions src/freeplay/resources/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,14 @@ def __translate_role(role: str) -> str:
raise ValueError(f"Gemini formatting found unexpected role {role}")


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


class BedrockConverseAdapter(LLMAdapter):
def to_llm_syntax(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
converse_messages: List[Dict[str, Any]] = []
Expand Down Expand Up @@ -339,6 +347,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
37 changes: 32 additions & 5 deletions src/freeplay/resources/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import logging
import warnings
from abc import ABC, abstractmethod
from dataclasses import asdict, dataclass
from dataclasses import asdict, dataclass, replace
from pathlib import Path
from typing import (
Any,
Expand Down Expand Up @@ -172,8 +172,15 @@ def formatted_output_schema(self) -> Any:
return self._formatted_output_schema

def all_messages(self, new_message: ProviderMessage) -> List[Dict[str, Any]]:
converted_message = convert_provider_message_to_dict(new_message)
return self._messages + [converted_message]
converted = convert_provider_message_to_dict(new_message)
# Use adapter-formatted messages when available (proper provider
# format, media mapped, system handling per-flavor). Fall back to
# raw messages for string-format adapters (e.g. Llama) where
# _llm_prompt is None.
input_messages: List[Dict[str, Any]] = list(self._llm_prompt or self._messages)
Comment thread
callingmedic911 marked this conversation as resolved.
Outdated
if isinstance(converted, list):
return input_messages + converted
return input_messages + [converted]
Comment thread
callingmedic911 marked this conversation as resolved.

Comment thread
coderabbitai[bot] marked this conversation as resolved.

class BoundPrompt:
Expand Down Expand Up @@ -236,6 +243,16 @@ def __format_tool_schema(flavor_name: str, tool_schema: List[ToolSchema]) -> Any
return [Tool(function_declarations=function_declarations)]
except ImportError:
raise VertexAIToolSchemaError()
elif flavor_name == "openai_responses":
return [
{
"type": "function",
"name": t.name,
"description": t.description,
"parameters": t.parameters,
}
for t in tool_schema
]
elif flavor_name == "gemini_api_chat":
function_declarations = [
{
Expand All @@ -256,6 +273,9 @@ def __format_output_schema(
# For OpenAI and Azure OpenAI, the normalized format is compatible with the API format
if flavor_name in ["openai_chat", "azure_openai_chat"]:
return output_schema
elif flavor_name == "openai_responses":
inner = output_schema.get("json_schema", {})
return {"format": {"type": "json_schema", **inner}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

鈿狅笍 Potential issue | 馃煛 Minor

Add a guard for missing json_schema before formatting.

When json_schema is absent, this returns an incomplete structure and fails later in a less actionable place.

馃挕 Proposed fix
         elif flavor_name == "openai_responses":
-            inner = output_schema.get("json_schema", {})
+            inner = output_schema.get("json_schema")
+            if not inner:
+                raise FreeplayConfigurationError(
+                    "Missing json_schema in output_schema for openai_responses flavor."
+                )
             return {"format": {"type": "json_schema", **inner}}
馃 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/freeplay/resources/prompts.py` around lines 275 - 277, Guard the
"openai_responses" branch against a missing json_schema: before building inner =
output_schema.get("json_schema", {}), check that "json_schema" exists and is
non-empty on output_schema (referencing the flavor_name == "openai_responses"
branch, the output_schema variable, and inner), and if it's missing produce a
clear failure (e.g., raise a ValueError with a descriptive message including
flavor_name/output_schema) or return an explicit fallback instead of returning
an incomplete {"format": {"type": "json_schema", **inner}} structure.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm not sure this handling is consistent with the other flavors -- they don't provide the top level json_schema annotation.. It is a bit different than how we handle tools.

# Add other flavors as necessary - currently only OpenAI-compatible models support output schema
raise UnsupportedOutputSchema()

Expand All @@ -274,17 +294,23 @@ def format(self, flavor_name: Optional[str] = None) -> FormattedPrompt:
else None
)

effective_prompt_info = (
replace(self.prompt_info, flavor_name=final_flavor)
if final_flavor != self.prompt_info.flavor_name
else self.prompt_info
)

if isinstance(formatted_prompt, str):
return FormattedPrompt(
prompt_info=self.prompt_info,
prompt_info=effective_prompt_info,
messages=self.messages,
formatted_prompt_text=formatted_prompt,
tool_schema=formatted_tool_schema,
formatted_output_schema=formatted_output_schema,
)
else:
return FormattedPrompt(
prompt_info=self.prompt_info,
prompt_info=effective_prompt_info,
messages=self.messages,
formatted_prompt=formatted_prompt,
tool_schema=formatted_tool_schema,
Expand Down Expand Up @@ -609,6 +635,7 @@ def __flavor_to_provider(flavor: str) -> str:
"azure_openai_chat": "azure",
"anthropic_chat": "anthropic",
"openai_chat": "openai",
"openai_responses": "openai",
"gemini_chat": "vertex",
"gemini_api_chat": "gemini",
}
Expand Down
68 changes: 68 additions & 0 deletions tests/test_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from freeplay.resources.adapters import (
OpenAIAdapter,
OpenAIResponsesAdapter,
AnthropicAdapter,
GeminiAdapter,
BedrockConverseAdapter,
Expand Down Expand Up @@ -527,6 +528,73 @@ def test_adaptor_for_gemini_api_chat(self) -> None:
adapter = adaptor_for_flavor("gemini_api_chat")
self.assertIsInstance(adapter, GeminiAdapter)

def test_openai_responses_strips_system(self) -> None:
messages: List[Dict[str, Any]] = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there!"},
]

formatted = OpenAIResponsesAdapter().to_llm_syntax(messages)

self.assertEqual(
formatted,
[
{"type": "message", "role": "user", "content": "Hello"},
{"type": "message", "role": "assistant", "content": "Hi there!"},
],
)

def test_openai_responses_media(self) -> None:
messages: List[Dict[str, Any]] = [
{"role": "system", "content": "You are a helpful assistant."},
{
"role": "user",
"has_media": True,
"content": [
TextContent("Take a look at these images!"),
MediaContentUrl(
type="image",
url="https://localhost/image.png",
slot_name="image1",
),
MediaContentBase64(
type="image",
content_type="image/png",
data="some-data",
slot_name="image2",
),
],
},
]

formatted = OpenAIResponsesAdapter().to_llm_syntax(messages)

self.assertEqual(
formatted,
[
{
"type": "message",
"role": "user",
"content": [
{"type": "text", "text": "Take a look at these images!"},
{
"type": "image_url",
"image_url": {"url": "https://localhost/image.png"},
},
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,some-data"},
},
],
},
],
)

def test_adaptor_for_openai_responses(self) -> None:
adapter = adaptor_for_flavor("openai_responses")
self.assertIsInstance(adapter, OpenAIResponsesAdapter)

def test_adaptor_for_unknown_flavor_raises(self) -> None:
with self.assertRaises(MissingFlavorError):
adaptor_for_flavor("nonexistent_flavor")
Loading