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.
- {casual_llm-0.2.0/src/casual_llm.egg-info → casual_llm-0.4.1}/PKG-INFO +4 -1
- {casual_llm-0.2.0 → casual_llm-0.4.1}/pyproject.toml +4 -2
- {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm/__init__.py +22 -1
- {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm/config.py +1 -0
- {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm/message_converters/__init__.py +9 -1
- casual_llm-0.4.1/src/casual_llm/message_converters/anthropic.py +290 -0
- {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm/message_converters/ollama.py +91 -3
- {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm/message_converters/openai.py +57 -1
- casual_llm-0.4.1/src/casual_llm/messages.py +111 -0
- {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm/providers/__init__.py +23 -2
- casual_llm-0.4.1/src/casual_llm/providers/anthropic.py +319 -0
- {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm/providers/base.py +48 -2
- {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm/providers/ollama.py +93 -5
- {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm/providers/openai.py +97 -3
- {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm/tool_converters/__init__.py +8 -1
- casual_llm-0.4.1/src/casual_llm/tool_converters/anthropic.py +70 -0
- casual_llm-0.4.1/src/casual_llm/utils/__init__.py +9 -0
- casual_llm-0.4.1/src/casual_llm/utils/image.py +162 -0
- {casual_llm-0.2.0 → casual_llm-0.4.1/src/casual_llm.egg-info}/PKG-INFO +4 -1
- {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm.egg-info/SOURCES.txt +12 -1
- {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm.egg-info/requires.txt +4 -0
- casual_llm-0.4.1/tests/test_anthropic_provider.py +694 -0
- casual_llm-0.4.1/tests/test_backward_compatibility.py +429 -0
- casual_llm-0.4.1/tests/test_image_utils.py +394 -0
- {casual_llm-0.2.0 → casual_llm-0.4.1}/tests/test_providers.py +231 -3
- casual_llm-0.4.1/tests/test_vision_integration.py +420 -0
- casual_llm-0.4.1/tests/test_vision_ollama.py +615 -0
- casual_llm-0.4.1/tests/test_vision_openai.py +470 -0
- casual_llm-0.2.0/src/casual_llm/messages.py +0 -60
- {casual_llm-0.2.0 → casual_llm-0.4.1}/LICENSE +0 -0
- {casual_llm-0.2.0 → casual_llm-0.4.1}/README.md +0 -0
- {casual_llm-0.2.0 → casual_llm-0.4.1}/setup.cfg +0 -0
- {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm/py.typed +0 -0
- {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm/tool_converters/ollama.py +0 -0
- {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm/tool_converters/openai.py +0 -0
- {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm/tools.py +0 -0
- {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm/usage.py +0 -0
- {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm.egg-info/dependency_links.txt +0 -0
- {casual_llm-0.2.0 → casual_llm-0.4.1}/src/casual_llm.egg-info/top_level.txt +0 -0
- {casual_llm-0.2.0 → casual_llm-0.4.1}/tests/test_messages.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
]
|
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
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}")
|