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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ Notable additions, fixes, or breaking changes to the Freeplay SDK.

### Added

- **`gemini_api_chat` flavor**: New flavor for the Gemini API (`google-generativeai` SDK). Returns plain-dict tool schemas compatible with `google.genai`, while `gemini_chat` continues to return `vertexai.generative_models.Tool` objects for Vertex AI users.
- **Gemini message parts passthrough**: History messages already in Gemini format (with `parts`, e.g., function calls and function responses) are now passed through without re-wrapping. Role `"assistant"` is automatically translated to `"model"`.

- Interactive REPL for development and testing:
- `make repl` - Production mode (connects to app.freeplay.ai with SSL verification enabled)
- `make repl-local` - Local development mode (connects to localhost:8000 with SSL verification disabled)
Expand Down
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.6"
version = "0.5.7"
description = ""
authors = [
{name = "Freeplay Engineering", email = "support@freeplay.ai"},
Expand Down
40 changes: 0 additions & 40 deletions scripts/type-baseline/pyright-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,46 +72,6 @@
"rule": "reportUnnecessaryIsInstance",
"severity": "error"
},
{
"file": "src/freeplay/resources/adapters.py",
"line": 205,
"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": 215,
"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": 222,
"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": 224,
"character": 15,
"message": "Return type, \"list[Unknown]\", is partially unknown",
"rule": "reportUnknownVariableType",
"severity": "error"
},
{
"file": "src/freeplay/resources/adapters.py",
"line": 239,
"character": 13,
"message": "Unnecessary isinstance call; \"MediaContentUrl\" is always an instance of \"MediaContentUrl\"",
"rule": "reportUnnecessaryIsInstance",
"severity": "error"
},
{
"file": "tests/__init__.py",
"line": 10,
Expand Down
21 changes: 14 additions & 7 deletions src/freeplay/resources/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,13 +196,20 @@ def to_llm_syntax(
if len(messages) < 1:
raise ValueError("Must have at least one message to format")

gemini_messages = []
gemini_messages: List[Dict[str, Any]] = []

for message in messages:
if message["role"] == "system":
continue

if "has_media" in message and message["has_media"]:
# Already in Gemini format (e.g., history from previous turns
# with function calls, function responses, or multi-part content)
if "parts" in message:
msg_copy: Dict[str, Any] = copy.deepcopy(message)
if msg_copy.get("role") == "assistant":
msg_copy["role"] = "model"
gemini_messages.append(msg_copy)
elif "has_media" in message and message["has_media"]:
gemini_messages.append(
{
"role": self.__translate_role(message["role"]),
Expand All @@ -220,7 +227,8 @@ def to_llm_syntax(
}
)
else:
gemini_messages.append(copy.deepcopy(message))
fallback: Dict[str, Any] = copy.deepcopy(message)
gemini_messages.append(fallback)

return gemini_messages

Expand All @@ -237,12 +245,11 @@ def __map_content(
"mime_type": content.content_type,
}
}
elif isinstance(content, MediaContentUrl):
else:
# MediaContentUrl -- Gemini does not support image URLs
raise ValueError(
"Message contains an image URL, but image URLs are not supported by Gemini"
)
else:
raise ValueError(f"Unexpected content type {type(content)}")

@staticmethod
def __translate_role(role: str) -> str:
Expand Down Expand Up @@ -336,7 +343,7 @@ def adaptor_for_flavor(flavor_name: str) -> LLMAdapter:
return AnthropicAdapter()
elif flavor_name == "llama_3_chat":
return Llama3Adapter()
elif flavor_name == "gemini_chat":
elif flavor_name in ["gemini_chat", "gemini_api_chat"]:
return GeminiAdapter()
elif flavor_name == "amazon_bedrock_converse":
return BedrockConverseAdapter()
Expand Down
11 changes: 11 additions & 0 deletions src/freeplay/resources/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,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 == "gemini_api_chat":
function_declarations = [
{
"name": schema.name,
"description": schema.description,
"parameters": schema.parameters,
}
for schema in tool_schema
]
return [{"functionDeclarations": function_declarations}]

raise UnsupportedToolSchemaError()

Expand Down Expand Up @@ -600,6 +610,7 @@ def __flavor_to_provider(flavor: str) -> str:
"anthropic_chat": "anthropic",
"openai_chat": "openai",
"gemini_chat": "vertex",
"gemini_api_chat": "gemini",
}
provider = flavor_provider.get(flavor)
if not provider:
Expand Down
192 changes: 192 additions & 0 deletions tests/test_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
AnthropicAdapter,
GeminiAdapter,
BedrockConverseAdapter,
MissingFlavorError,
TextContent,
MediaContentUrl,
MediaContentBase64,
adaptor_for_flavor,
)


Expand Down Expand Up @@ -338,3 +340,193 @@ def test_bedrock_converse(self) -> None:
}
},
)

# ------------------------------------------------------------------
# Gemini parts passthrough (history with function calls / responses)
# ------------------------------------------------------------------

def test_gemini_parts_passthrough(self) -> None:
"""Messages with 'parts' key are passed through without re-wrapping."""
messages: List[Dict[str, Any]] = [
{
"role": "user",
"parts": [{"text": "What is the weather?"}],
},
{
"role": "model",
"parts": [
{
"functionCall": {
"name": "get_weather",
"args": {"location": "Seattle"},
}
}
],
},
{
"role": "user",
"parts": [
{
"functionResponse": {
"name": "get_weather",
"response": {"temperature": "72°F"},
}
}
],
},
{
"role": "model",
"parts": [{"text": "It's 72°F in Seattle."}],
},
]

result = GeminiAdapter().to_llm_syntax(messages)
assert isinstance(result, list)

self.assertEqual(len(result), 4)
# All messages should be preserved as-is
self.assertEqual(result[0]["role"], "user")
self.assertEqual(result[0]["parts"], [{"text": "What is the weather?"}])

self.assertEqual(result[1]["role"], "model")
self.assertIn("functionCall", result[1]["parts"][0])

self.assertEqual(result[2]["role"], "user")
self.assertIn("functionResponse", result[2]["parts"][0])

self.assertEqual(result[3]["role"], "model")
self.assertEqual(result[3]["parts"], [{"text": "It's 72°F in Seattle."}])

def test_gemini_parts_passthrough_translates_assistant_to_model(self) -> None:
"""Parts messages with role 'assistant' are translated to 'model'."""
messages: List[Dict[str, Any]] = [
{
"role": "assistant",
"parts": [
{
"functionCall": {
"name": "search",
"args": {"query": "test"},
}
}
],
},
]

result = GeminiAdapter().to_llm_syntax(messages)
assert isinstance(result, list)

self.assertEqual(len(result), 1)
self.assertEqual(result[0]["role"], "model")
self.assertIn("functionCall", result[0]["parts"][0])

def test_gemini_parts_passthrough_does_not_mutate_original(self) -> None:
"""Parts passthrough deep-copies, so original messages are not mutated."""
original: Dict[str, Any] = {
"role": "assistant",
"parts": [{"text": "original"}],
}
messages: List[Dict[str, Any]] = [original]

result = GeminiAdapter().to_llm_syntax(messages)
assert isinstance(result, list)

self.assertEqual(result[0]["role"], "model")
self.assertEqual(original["role"], "assistant")

def test_gemini_mixed_content_and_parts(self) -> None:
"""Handles a mix of standard content and pre-formatted parts messages."""
messages: List[Dict[str, Any]] = [
{"role": "system", "content": "System instructions"},
{"role": "user", "content": "Hello"},
{
"role": "model",
"parts": [{"functionCall": {"name": "greet", "args": {}}}],
},
{
"role": "user",
"parts": [
{
"functionResponse": {
"name": "greet",
"response": {"greeting": "Hi!"},
}
}
],
},
{"role": "assistant", "content": "Done"},
]

result = GeminiAdapter().to_llm_syntax(messages)
assert isinstance(result, list)

self.assertEqual(len(result), 4) # system is skipped
self.assertEqual(result[0], {"role": "user", "parts": [{"text": "Hello"}]})
self.assertEqual(result[1]["role"], "model")
self.assertIn("functionCall", result[1]["parts"][0])
self.assertEqual(result[2]["role"], "user")
self.assertIn("functionResponse", result[2]["parts"][0])
self.assertEqual(result[3], {"role": "model", "parts": [{"text": "Done"}]})

def test_gemini_media_in_history_parts(self) -> None:
"""History messages with inlineData parts are passed through correctly."""
messages: List[Dict[str, Any]] = [
{
"role": "user",
"parts": [
{"text": "What's in this image?"},
{
"inlineData": {
"mimeType": "image/png",
"data": "iVBORw0KGgoAAAANSUhEUg==",
}
},
],
},
{"role": "model", "parts": [{"text": "I see a cat."}]},
{"role": "user", "content": "Can you describe it in more detail?"},
]

result = GeminiAdapter().to_llm_syntax(messages)
assert isinstance(result, list)

self.assertEqual(len(result), 3)

# First message: media parts passed through unchanged
self.assertEqual(result[0]["role"], "user")
self.assertEqual(len(result[0]["parts"]), 2)
self.assertEqual(result[0]["parts"][0], {"text": "What's in this image?"})
self.assertIn("inlineData", result[0]["parts"][1])
self.assertEqual(result[0]["parts"][1]["inlineData"]["mimeType"], "image/png")
self.assertEqual(
result[0]["parts"][1]["inlineData"]["data"], "iVBORw0KGgoAAAANSUhEUg=="
)

# Second message: text parts passed through
self.assertEqual(result[1]["role"], "model")
self.assertEqual(result[1]["parts"], [{"text": "I see a cat."}])

# Third message: standard content converted to Gemini format
self.assertEqual(
result[2],
{
"role": "user",
"parts": [{"text": "Can you describe it in more detail?"}],
},
)

# ------------------------------------------------------------------
# adaptor_for_flavor() registry tests
# ------------------------------------------------------------------

def test_adaptor_for_gemini_chat(self) -> None:
adapter = adaptor_for_flavor("gemini_chat")
self.assertIsInstance(adapter, GeminiAdapter)

def test_adaptor_for_gemini_api_chat(self) -> None:
adapter = adaptor_for_flavor("gemini_api_chat")
self.assertIsInstance(adapter, GeminiAdapter)

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