casual-llm 0.2.0__tar.gz → 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {casual_llm-0.2.0/src/casual_llm.egg-info → casual_llm-0.4.1}/PKG-INFO +4 -1
  2. {casual_llm-0.2.0 → casual_llm-0.4.1}/pyproject.toml +4 -2
  3. {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm/__init__.py +22 -1
  4. {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm/config.py +1 -0
  5. {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm/message_converters/__init__.py +9 -1
  6. casual_llm-0.4.1/src/casual_llm/message_converters/anthropic.py +290 -0
  7. {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm/message_converters/ollama.py +91 -3
  8. {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm/message_converters/openai.py +57 -1
  9. casual_llm-0.4.1/src/casual_llm/messages.py +111 -0
  10. {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm/providers/__init__.py +23 -2
  11. casual_llm-0.4.1/src/casual_llm/providers/anthropic.py +319 -0
  12. {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm/providers/base.py +48 -2
  13. {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm/providers/ollama.py +93 -5
  14. {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm/providers/openai.py +97 -3
  15. {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm/tool_converters/__init__.py +8 -1
  16. casual_llm-0.4.1/src/casual_llm/tool_converters/anthropic.py +70 -0
  17. casual_llm-0.4.1/src/casual_llm/utils/__init__.py +9 -0
  18. casual_llm-0.4.1/src/casual_llm/utils/image.py +162 -0
  19. {casual_llm-0.2.0 → casual_llm-0.4.1/src/casual_llm.egg-info}/PKG-INFO +4 -1
  20. {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm.egg-info/SOURCES.txt +12 -1
  21. {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm.egg-info/requires.txt +4 -0
  22. casual_llm-0.4.1/tests/test_anthropic_provider.py +694 -0
  23. casual_llm-0.4.1/tests/test_backward_compatibility.py +429 -0
  24. casual_llm-0.4.1/tests/test_image_utils.py +394 -0
  25. {casual_llm-0.2.0 → casual_llm-0.4.1}/tests/test_providers.py +231 -3
  26. casual_llm-0.4.1/tests/test_vision_integration.py +420 -0
  27. casual_llm-0.4.1/tests/test_vision_ollama.py +615 -0
  28. casual_llm-0.4.1/tests/test_vision_openai.py +470 -0
  29. casual_llm-0.2.0/src/casual_llm/messages.py +0 -60
  30. {casual_llm-0.2.0 → casual_llm-0.4.1}/LICENSE +0 -0
  31. {casual_llm-0.2.0 → casual_llm-0.4.1}/README.md +0 -0
  32. {casual_llm-0.2.0 → casual_llm-0.4.1}/setup.cfg +0 -0
  33. {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm/py.typed +0 -0
  34. {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm/tool_converters/ollama.py +0 -0
  35. {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm/tool_converters/openai.py +0 -0
  36. {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm/tools.py +0 -0
  37. {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm/usage.py +0 -0
  38. {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm.egg-info/dependency_links.txt +0 -0
  39. {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm.egg-info/top_level.txt +0 -0
  40. {casual_llm-0.2.0 → casual_llm-0.4.1}/tests/test_messages.py +0 -0
  41. {casual_llm-0.2.0 → casual_llm-0.4.1}/tests/test_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: casual-llm
3
- Version: 0.2.0
3
+ Version: 0.4.1
4
4
  Summary: Lightweight LLM provider abstraction with standardized message models
5
5
  Author-email: Alex Stansfield <alex@casualgenius.com>
6
6
  License: MIT
@@ -23,8 +23,11 @@ Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
24
  Requires-Dist: pydantic>=2.0.0
25
25
  Requires-Dist: ollama>=0.6.1
26
+ Requires-Dist: httpx[http2]>=0.28.1
26
27
  Provides-Extra: openai
27
28
  Requires-Dist: openai>=1.0.0; extra == "openai"
29
+ Provides-Extra: anthropic
30
+ Requires-Dist: anthropic>=0.20.0; extra == "anthropic"
28
31
  Dynamic: license-file
29
32
 
30
33
  # casual-llm
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "casual-llm"
3
- version = "0.2.0"
3
+ version = "0.4.1"
4
4
  description = "Lightweight LLM provider abstraction with standardized message models"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -24,10 +24,12 @@ classifiers = [
24
24
  dependencies = [
25
25
  "pydantic>=2.0.0",
26
26
  "ollama>=0.6.1",
27
+ "httpx[http2]>=0.28.1",
27
28
  ]
28
29
 
29
30
  [project.optional-dependencies]
30
31
  openai = ["openai>=1.0.0"]
32
+ anthropic = ["anthropic>=0.20.0"]
31
33
 
32
34
  [project.urls]
33
35
  Homepage = "https://github.com/casualgenius/casual-llm"
@@ -72,4 +74,4 @@ dev = [
72
74
  "black>=23.0.0",
73
75
  "ruff>=0.1.0",
74
76
  "mypy>=1.0.0",
75
- ]
77
+ ]
@@ -7,7 +7,7 @@ A simple, protocol-based library for working with different LLM providers
7
7
  Part of the casual-* ecosystem of lightweight AI tools.
8
8
  """
9
9
 
10
- __version__ = "0.2.0"
10
+ __version__ = "0.4.1"
11
11
 
12
12
  # Model configuration
13
13
  from casual_llm.config import ModelConfig, Provider
@@ -17,6 +17,7 @@ from casual_llm.providers import (
17
17
  LLMProvider,
18
18
  OllamaProvider,
19
19
  OpenAIProvider,
20
+ AnthropicProvider,
20
21
  create_provider,
21
22
  )
22
23
 
@@ -29,6 +30,10 @@ from casual_llm.messages import (
29
30
  ToolResultMessage,
30
31
  AssistantToolCall,
31
32
  AssistantToolCallFunction,
33
+ StreamChunk,
34
+ # Multimodal content types
35
+ TextContent,
36
+ ImageContent,
32
37
  )
33
38
 
34
39
  # Tool models
@@ -43,14 +48,19 @@ from casual_llm.tool_converters import (
43
48
  tools_to_ollama,
44
49
  tool_to_openai,
45
50
  tools_to_openai,
51
+ tool_to_anthropic,
52
+ tools_to_anthropic,
46
53
  )
47
54
 
48
55
  # Message converters
49
56
  from casual_llm.message_converters import (
50
57
  convert_messages_to_openai,
51
58
  convert_messages_to_ollama,
59
+ convert_messages_to_anthropic,
52
60
  convert_tool_calls_from_openai,
53
61
  convert_tool_calls_from_ollama,
62
+ convert_tool_calls_from_anthropic,
63
+ extract_system_message,
54
64
  )
55
65
 
56
66
  __all__ = [
@@ -62,6 +72,7 @@ __all__ = [
62
72
  "Provider",
63
73
  "OllamaProvider",
64
74
  "OpenAIProvider",
75
+ "AnthropicProvider",
65
76
  "create_provider",
66
77
  # Messages
67
78
  "ChatMessage",
@@ -71,18 +82,28 @@ __all__ = [
71
82
  "ToolResultMessage",
72
83
  "AssistantToolCall",
73
84
  "AssistantToolCallFunction",
85
+ "StreamChunk",
86
+ # Multimodal content types
87
+ "TextContent",
88
+ "ImageContent",
74
89
  # Tools
75
90
  "Tool",
76
91
  "ToolParameter",
77
92
  # Usage
78
93
  "Usage",
94
+ # Tool converters
79
95
  "tool_to_ollama",
80
96
  "tools_to_ollama",
81
97
  "tool_to_openai",
82
98
  "tools_to_openai",
99
+ "tool_to_anthropic",
100
+ "tools_to_anthropic",
83
101
  # Message converters
84
102
  "convert_messages_to_openai",
85
103
  "convert_messages_to_ollama",
104
+ "convert_messages_to_anthropic",
86
105
  "convert_tool_calls_from_openai",
87
106
  "convert_tool_calls_from_ollama",
107
+ "convert_tool_calls_from_anthropic",
108
+ "extract_system_message",
88
109
  ]
@@ -14,6 +14,7 @@ class Provider(Enum):
14
14
 
15
15
  OPENAI = "openai"
16
16
  OLLAMA = "ollama"
17
+ ANTHROPIC = "anthropic"
17
18
 
18
19
 
19
20
  @dataclass
@@ -2,7 +2,7 @@
2
2
  Message converters for different LLM provider formats.
3
3
 
4
4
  This package provides converters to translate between casual-llm's unified
5
- ChatMessage format and provider-specific formats (OpenAI, Ollama).
5
+ ChatMessage format and provider-specific formats (OpenAI, Ollama, Anthropic).
6
6
  """
7
7
 
8
8
  from casual_llm.message_converters.openai import (
@@ -13,10 +13,18 @@ from casual_llm.message_converters.ollama import (
13
13
  convert_messages_to_ollama,
14
14
  convert_tool_calls_from_ollama,
15
15
  )
16
+ from casual_llm.message_converters.anthropic import (
17
+ convert_messages_to_anthropic,
18
+ convert_tool_calls_from_anthropic,
19
+ extract_system_message,
20
+ )
16
21
 
17
22
  __all__ = [
18
23
  "convert_messages_to_openai",
19
24
  "convert_messages_to_ollama",
25
+ "convert_messages_to_anthropic",
20
26
  "convert_tool_calls_from_openai",
21
27
  "convert_tool_calls_from_ollama",
28
+ "convert_tool_calls_from_anthropic",
29
+ "extract_system_message",
22
30
  ]
@@ -0,0 +1,290 @@
1
+ """
2
+ Anthropic message converters.
3
+
4
+ Converts casual-llm ChatMessage format to Anthropic API format and vice versa.
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from casual_llm.messages import (
12
+ ChatMessage,
13
+ AssistantToolCall,
14
+ AssistantToolCallFunction,
15
+ TextContent,
16
+ ImageContent,
17
+ )
18
+
19
+ if TYPE_CHECKING:
20
+ from anthropic.types import ToolUseBlock
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def _convert_image_to_anthropic(image: ImageContent) -> dict[str, Any]:
26
+ """
27
+ Convert ImageContent to Anthropic image block format.
28
+
29
+ Anthropic supports both URL and base64 images directly.
30
+
31
+ Args:
32
+ image: ImageContent with either URL or base64 source
33
+
34
+ Returns:
35
+ Dictionary in Anthropic image block format
36
+
37
+ Examples:
38
+ >>> from casual_llm import ImageContent
39
+ >>> img = ImageContent(source="https://example.com/image.jpg")
40
+ >>> block = _convert_image_to_anthropic(img)
41
+ >>> block["type"]
42
+ 'image'
43
+ """
44
+ if isinstance(image.source, str):
45
+ # URL image - Anthropic supports URLs directly
46
+ return {
47
+ "type": "image",
48
+ "source": {
49
+ "type": "url",
50
+ "url": image.source,
51
+ },
52
+ }
53
+ else:
54
+ # Base64 image
55
+ base64_data = image.source.get("data", "")
56
+ media_type = image.media_type or "image/jpeg"
57
+
58
+ return {
59
+ "type": "image",
60
+ "source": {
61
+ "type": "base64",
62
+ "media_type": media_type,
63
+ "data": base64_data,
64
+ },
65
+ }
66
+
67
+
68
+ def _convert_user_content_to_anthropic(
69
+ content: str | list[TextContent | ImageContent] | None,
70
+ ) -> list[dict[str, Any]]:
71
+ """
72
+ Convert UserMessage content to Anthropic format.
73
+
74
+ Handles both simple string content (backward compatible) and
75
+ multimodal content arrays (text + images).
76
+
77
+ Anthropic always uses content blocks (array format), even for simple text.
78
+
79
+ Args:
80
+ content: Message content (string, multimodal array, or None)
81
+
82
+ Returns:
83
+ List of content blocks in Anthropic format
84
+
85
+ Examples:
86
+ >>> content_blocks = _convert_user_content_to_anthropic("Hello")
87
+ >>> content_blocks[0]["type"]
88
+ 'text'
89
+ """
90
+ if content is None:
91
+ return [{"type": "text", "text": ""}]
92
+
93
+ if isinstance(content, str):
94
+ # Simple string content
95
+ return [{"type": "text", "text": content}]
96
+
97
+ # Multimodal content array
98
+ content_blocks: list[dict[str, Any]] = []
99
+
100
+ for item in content:
101
+ if isinstance(item, TextContent):
102
+ content_blocks.append({"type": "text", "text": item.text})
103
+ elif isinstance(item, ImageContent):
104
+ content_blocks.append(_convert_image_to_anthropic(item))
105
+
106
+ return content_blocks
107
+
108
+
109
+ def extract_system_message(messages: list[ChatMessage]) -> str | None:
110
+ """
111
+ Extract system message content from the messages list.
112
+
113
+ Anthropic requires system messages to be passed as a separate parameter,
114
+ not as part of the messages array. This function extracts the first
115
+ system message content for use with the Anthropic API.
116
+
117
+ Args:
118
+ messages: List of ChatMessage objects
119
+
120
+ Returns:
121
+ System message content string, or None if no system message present
122
+
123
+ Examples:
124
+ >>> from casual_llm import SystemMessage, UserMessage
125
+ >>> messages = [SystemMessage(content="You are helpful"), UserMessage(content="Hello")]
126
+ >>> extract_system_message(messages)
127
+ 'You are helpful'
128
+ """
129
+ for msg in messages:
130
+ if msg.role == "system":
131
+ logger.debug("Extracted system message")
132
+ return msg.content
133
+ return None
134
+
135
+
136
+ def convert_messages_to_anthropic(messages: list[ChatMessage]) -> list[dict[str, Any]]:
137
+ """
138
+ Convert casual-llm ChatMessage list to Anthropic format.
139
+
140
+ Handles all message types including tool calls and tool results.
141
+ Note: System messages are excluded - use extract_system_message() to get
142
+ the system message content for the separate `system` parameter.
143
+
144
+ Anthropic format differences:
145
+ - System messages are NOT included (passed separately)
146
+ - Tool results go in user messages with "tool_result" content type
147
+ - Content is always an array of content blocks
148
+
149
+ Args:
150
+ messages: List of ChatMessage objects
151
+
152
+ Returns:
153
+ List of dictionaries in Anthropic MessageParam format
154
+
155
+ Examples:
156
+ >>> from casual_llm import UserMessage, AssistantMessage
157
+ >>> messages = [UserMessage(content="Hello")]
158
+ >>> anthropic_msgs = convert_messages_to_anthropic(messages)
159
+ >>> anthropic_msgs[0]["role"]
160
+ 'user'
161
+ """
162
+ if not messages:
163
+ return []
164
+
165
+ logger.debug(f"Converting {len(messages)} messages to Anthropic format")
166
+
167
+ anthropic_messages: list[dict[str, Any]] = []
168
+
169
+ for msg in messages:
170
+ match msg.role:
171
+ case "assistant":
172
+ # Handle assistant messages with optional tool calls
173
+ content_blocks: list[dict[str, Any]] = []
174
+
175
+ # Add text content if present
176
+ if msg.content:
177
+ content_blocks.append({"type": "text", "text": msg.content})
178
+
179
+ # Add tool use blocks if present
180
+ if msg.tool_calls:
181
+ for tool_call in msg.tool_calls:
182
+ # Parse arguments JSON string back to dict for Anthropic
183
+ try:
184
+ input_data = json.loads(tool_call.function.arguments)
185
+ except json.JSONDecodeError:
186
+ input_data = {}
187
+ logger.warning(
188
+ f"Failed to parse tool call arguments: {tool_call.function.arguments}"
189
+ )
190
+
191
+ content_blocks.append(
192
+ {
193
+ "type": "tool_use",
194
+ "id": tool_call.id,
195
+ "name": tool_call.function.name,
196
+ "input": input_data,
197
+ }
198
+ )
199
+
200
+ anthropic_messages.append(
201
+ {
202
+ "role": "assistant",
203
+ "content": content_blocks,
204
+ }
205
+ )
206
+
207
+ case "system":
208
+ # System messages are excluded - they are passed separately
209
+ # via the `system` parameter in the API call
210
+ logger.debug("Skipping system message (handled separately)")
211
+ continue
212
+
213
+ case "tool":
214
+ # Tool results go in user messages with tool_result content type
215
+ anthropic_messages.append(
216
+ {
217
+ "role": "user",
218
+ "content": [
219
+ {
220
+ "type": "tool_result",
221
+ "tool_use_id": msg.tool_call_id,
222
+ "content": msg.content,
223
+ }
224
+ ],
225
+ }
226
+ )
227
+
228
+ case "user":
229
+ # User messages with text and/or image content
230
+ content_blocks = _convert_user_content_to_anthropic(msg.content)
231
+ anthropic_messages.append(
232
+ {
233
+ "role": "user",
234
+ "content": content_blocks,
235
+ }
236
+ )
237
+
238
+ case _:
239
+ logger.warning(f"Unknown message role: {msg.role}")
240
+
241
+ return anthropic_messages
242
+
243
+
244
+ def convert_tool_calls_from_anthropic(
245
+ response_tool_calls: list["ToolUseBlock"],
246
+ ) -> list[AssistantToolCall]:
247
+ """
248
+ Convert Anthropic ToolUseBlock to casual-llm format.
249
+
250
+ Anthropic returns tool call arguments as a dict in the `input` field,
251
+ which must be serialized to JSON string for AssistantToolCallFunction.
252
+
253
+ Args:
254
+ response_tool_calls: List of ToolUseBlock from Anthropic response
255
+
256
+ Returns:
257
+ List of AssistantToolCall objects
258
+
259
+ Examples:
260
+ >>> # Assuming response has tool_use blocks
261
+ >>> # tool_calls = convert_tool_calls_from_anthropic(tool_use_blocks)
262
+ >>> # assert len(tool_calls) > 0
263
+ pass
264
+ """
265
+ tool_calls = []
266
+
267
+ for tool in response_tool_calls:
268
+ logger.debug(f"Converting tool call: {tool.name}")
269
+
270
+ # Serialize input dict to JSON string for casual-llm format
271
+ arguments = json.dumps(tool.input) if tool.input else "{}"
272
+
273
+ tool_call = AssistantToolCall(
274
+ id=tool.id,
275
+ type="function",
276
+ function=AssistantToolCallFunction(name=tool.name, arguments=arguments),
277
+ )
278
+ tool_calls.append(tool_call)
279
+
280
+ logger.debug(f"Converted {len(tool_calls)} tool calls")
281
+ return tool_calls
282
+
283
+
284
+ __all__ = [
285
+ "convert_messages_to_anthropic",
286
+ "extract_system_message",
287
+ "convert_tool_calls_from_anthropic",
288
+ "_convert_image_to_anthropic",
289
+ "_convert_user_content_to_anthropic",
290
+ ]
@@ -13,6 +13,12 @@ from casual_llm.messages import (
13
13
  ChatMessage,
14
14
  AssistantToolCall,
15
15
  AssistantToolCallFunction,
16
+ TextContent,
17
+ ImageContent,
18
+ )
19
+ from casual_llm.utils.image import (
20
+ strip_base64_prefix,
21
+ fetch_image_as_base64,
16
22
  )
17
23
 
18
24
  if TYPE_CHECKING:
@@ -21,23 +27,101 @@ if TYPE_CHECKING:
21
27
  logger = logging.getLogger(__name__)
22
28
 
23
29
 
24
- def convert_messages_to_ollama(messages: list[ChatMessage]) -> list[dict[str, Any]]:
30
+ async def _convert_image_to_ollama(image: ImageContent) -> str:
31
+ """
32
+ Convert ImageContent to Ollama base64 format.
33
+
34
+ Ollama expects images as raw base64 strings (no data URI prefix).
35
+
36
+ For URL sources, this function fetches the image and converts to base64.
37
+
38
+ Raises:
39
+ ImageFetchError: If a URL image cannot be fetched.
40
+ """
41
+ if isinstance(image.source, str):
42
+ # Check if it's a data URI or a URL
43
+ if image.source.startswith("data:"):
44
+ # Data URI - extract base64 data
45
+ return strip_base64_prefix(image.source)
46
+ else:
47
+ # Regular URL - fetch and convert to base64
48
+ logger.debug(f"Fetching image from URL for Ollama: {image.source}")
49
+ base64_data, _ = await fetch_image_as_base64(image.source)
50
+ return base64_data
51
+ else:
52
+ # Base64 dict source - use directly
53
+ base64_data = image.source.get("data", "")
54
+ # Strip any data URI prefix that might be present
55
+ return strip_base64_prefix(base64_data)
56
+
57
+
58
+ async def _convert_user_content_to_ollama(
59
+ content: str | list[TextContent | ImageContent] | None,
60
+ ) -> tuple[str, list[str]]:
61
+ """
62
+ Convert UserMessage content to Ollama format.
63
+
64
+ Handles both simple string content (backward compatible) and
65
+ multimodal content arrays (text + images).
66
+
67
+ Ollama uses a format where text goes in "content" and images
68
+ go in a separate "images" array as raw base64 strings.
69
+
70
+ Returns:
71
+ A tuple of (text_content, images_list) where:
72
+ - text_content: Combined text from all TextContent items
73
+ - images_list: List of base64-encoded image strings
74
+
75
+ Raises:
76
+ ImageFetchError: If a URL image cannot be fetched.
77
+ """
78
+ if content is None:
79
+ return "", []
80
+
81
+ if isinstance(content, str):
82
+ # Simple string content
83
+ return content, []
84
+
85
+ # Multimodal content array
86
+ text_parts: list[str] = []
87
+ images: list[str] = []
88
+
89
+ for item in content:
90
+ if isinstance(item, TextContent):
91
+ text_parts.append(item.text)
92
+ elif isinstance(item, ImageContent):
93
+ images.append(await _convert_image_to_ollama(item))
94
+
95
+ # Join text parts with newlines
96
+ text_content = "\n".join(text_parts) if text_parts else ""
97
+
98
+ return text_content, images
99
+
100
+
101
+ async def convert_messages_to_ollama(messages: list[ChatMessage]) -> list[dict[str, Any]]:
25
102
  """
26
103
  Convert casual-llm ChatMessage list to Ollama format.
27
104
 
28
105
  Unlike OpenAI which expects tool call arguments as JSON strings,
29
106
  Ollama expects them as dictionaries. This function handles that conversion.
30
107
 
108
+ Supports multimodal messages with images. Ollama expects images as raw
109
+ base64 strings in a separate "images" array.
110
+
31
111
  Args:
32
112
  messages: List of ChatMessage objects
33
113
 
34
114
  Returns:
35
115
  List of dictionaries in Ollama message format
36
116
 
117
+ Raises:
118
+ ImageFetchError: If a URL image cannot be fetched.
119
+
37
120
  Examples:
121
+ >>> import asyncio
38
122
  >>> from casual_llm import UserMessage
39
123
  >>> messages = [UserMessage(content="Hello")]
40
- >>> ollama_msgs = convert_messages_to_ollama(messages)
124
+ >>> ollama_msgs = asyncio.run(convert_messages_to_ollama(messages))
41
125
  >>> ollama_msgs[0]["role"]
42
126
  'user'
43
127
  """
@@ -97,7 +181,11 @@ def convert_messages_to_ollama(messages: list[ChatMessage]) -> list[dict[str, An
97
181
  )
98
182
 
99
183
  case "user":
100
- ollama_messages.append({"role": "user", "content": msg.content})
184
+ text_content, images = await _convert_user_content_to_ollama(msg.content)
185
+ user_message: dict[str, Any] = {"role": "user", "content": text_content}
186
+ if images:
187
+ user_message["images"] = images
188
+ ollama_messages.append(user_message)
101
189
 
102
190
  case _:
103
191
  logger.warning(f"Unknown message role: {msg.role}")
@@ -11,6 +11,8 @@ from casual_llm.messages import (
11
11
  ChatMessage,
12
12
  AssistantToolCall,
13
13
  AssistantToolCallFunction,
14
+ TextContent,
15
+ ImageContent,
14
16
  )
15
17
 
16
18
  if TYPE_CHECKING:
@@ -19,6 +21,55 @@ if TYPE_CHECKING:
19
21
  logger = logging.getLogger(__name__)
20
22
 
21
23
 
24
+ def _convert_image_to_openai(image: ImageContent) -> dict[str, Any]:
25
+ """
26
+ Convert ImageContent to OpenAI image_url format.
27
+
28
+ OpenAI expects images in the format:
29
+ {"type": "image_url", "image_url": {"url": "..."}}
30
+
31
+ For base64 images, the URL should be a data URI:
32
+ data:image/jpeg;base64,...
33
+ """
34
+ if isinstance(image.source, str):
35
+ # URL source - use directly
36
+ image_url = image.source
37
+ else:
38
+ # Base64 dict source - construct data URI
39
+ base64_data = image.source.get("data", "")
40
+ image_url = f"data:{image.media_type};base64,{base64_data}"
41
+
42
+ return {
43
+ "type": "image_url",
44
+ "image_url": {"url": image_url},
45
+ }
46
+
47
+
48
+ def _convert_user_content_to_openai(
49
+ content: str | list[TextContent | ImageContent] | None,
50
+ ) -> str | list[dict[str, Any]] | None:
51
+ """
52
+ Convert UserMessage content to OpenAI format.
53
+
54
+ Handles both simple string content (backward compatible) and
55
+ multimodal content arrays (text + images).
56
+ """
57
+ if content is None or isinstance(content, str):
58
+ # Simple string content or None - pass through
59
+ return content
60
+
61
+ # Multimodal content array
62
+ openai_content: list[dict[str, Any]] = []
63
+
64
+ for item in content:
65
+ if isinstance(item, TextContent):
66
+ openai_content.append({"type": "text", "text": item.text})
67
+ elif isinstance(item, ImageContent):
68
+ openai_content.append(_convert_image_to_openai(item))
69
+
70
+ return openai_content
71
+
72
+
22
73
  def convert_messages_to_openai(messages: list[ChatMessage]) -> list[dict[str, Any]]:
23
74
  """
24
75
  Convert casual-llm ChatMessage list to OpenAI format.
@@ -86,7 +137,12 @@ def convert_messages_to_openai(messages: list[ChatMessage]) -> list[dict[str, An
86
137
  )
87
138
 
88
139
  case "user":
89
- openai_messages.append({"role": "user", "content": msg.content})
140
+ openai_messages.append(
141
+ {
142
+ "role": "user",
143
+ "content": _convert_user_content_to_openai(msg.content),
144
+ }
145
+ )
90
146
 
91
147
  case _:
92
148
  logger.warning(f"Unknown message role: {msg.role}")