casual-llm 0.4.1__tar.gz → 0.4.3__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 (40) hide show
  1. {casual_llm-0.4.1/src/casual_llm.egg-info → casual_llm-0.4.3}/PKG-INFO +191 -9
  2. {casual_llm-0.4.1 → casual_llm-0.4.3}/README.md +190 -8
  3. {casual_llm-0.4.1 → casual_llm-0.4.3}/pyproject.toml +1 -1
  4. {casual_llm-0.4.1 → casual_llm-0.4.3}/src/casual_llm/__init__.py +1 -1
  5. {casual_llm-0.4.1 → casual_llm-0.4.3}/src/casual_llm/tools.py +8 -2
  6. {casual_llm-0.4.1 → casual_llm-0.4.3/src/casual_llm.egg-info}/PKG-INFO +191 -9
  7. {casual_llm-0.4.1 → casual_llm-0.4.3}/tests/test_tools.py +64 -0
  8. {casual_llm-0.4.1 → casual_llm-0.4.3}/LICENSE +0 -0
  9. {casual_llm-0.4.1 → casual_llm-0.4.3}/setup.cfg +0 -0
  10. {casual_llm-0.4.1 → casual_llm-0.4.3}/src/casual_llm/config.py +0 -0
  11. {casual_llm-0.4.1 → casual_llm-0.4.3}/src/casual_llm/message_converters/__init__.py +0 -0
  12. {casual_llm-0.4.1 → casual_llm-0.4.3}/src/casual_llm/message_converters/anthropic.py +0 -0
  13. {casual_llm-0.4.1 → casual_llm-0.4.3}/src/casual_llm/message_converters/ollama.py +0 -0
  14. {casual_llm-0.4.1 → casual_llm-0.4.3}/src/casual_llm/message_converters/openai.py +0 -0
  15. {casual_llm-0.4.1 → casual_llm-0.4.3}/src/casual_llm/messages.py +0 -0
  16. {casual_llm-0.4.1 → casual_llm-0.4.3}/src/casual_llm/providers/__init__.py +0 -0
  17. {casual_llm-0.4.1 → casual_llm-0.4.3}/src/casual_llm/providers/anthropic.py +0 -0
  18. {casual_llm-0.4.1 → casual_llm-0.4.3}/src/casual_llm/providers/base.py +0 -0
  19. {casual_llm-0.4.1 → casual_llm-0.4.3}/src/casual_llm/providers/ollama.py +0 -0
  20. {casual_llm-0.4.1 → casual_llm-0.4.3}/src/casual_llm/providers/openai.py +0 -0
  21. {casual_llm-0.4.1 → casual_llm-0.4.3}/src/casual_llm/py.typed +0 -0
  22. {casual_llm-0.4.1 → casual_llm-0.4.3}/src/casual_llm/tool_converters/__init__.py +0 -0
  23. {casual_llm-0.4.1 → casual_llm-0.4.3}/src/casual_llm/tool_converters/anthropic.py +0 -0
  24. {casual_llm-0.4.1 → casual_llm-0.4.3}/src/casual_llm/tool_converters/ollama.py +0 -0
  25. {casual_llm-0.4.1 → casual_llm-0.4.3}/src/casual_llm/tool_converters/openai.py +0 -0
  26. {casual_llm-0.4.1 → casual_llm-0.4.3}/src/casual_llm/usage.py +0 -0
  27. {casual_llm-0.4.1 → casual_llm-0.4.3}/src/casual_llm/utils/__init__.py +0 -0
  28. {casual_llm-0.4.1 → casual_llm-0.4.3}/src/casual_llm/utils/image.py +0 -0
  29. {casual_llm-0.4.1 → casual_llm-0.4.3}/src/casual_llm.egg-info/SOURCES.txt +0 -0
  30. {casual_llm-0.4.1 → casual_llm-0.4.3}/src/casual_llm.egg-info/dependency_links.txt +0 -0
  31. {casual_llm-0.4.1 → casual_llm-0.4.3}/src/casual_llm.egg-info/requires.txt +0 -0
  32. {casual_llm-0.4.1 → casual_llm-0.4.3}/src/casual_llm.egg-info/top_level.txt +0 -0
  33. {casual_llm-0.4.1 → casual_llm-0.4.3}/tests/test_anthropic_provider.py +0 -0
  34. {casual_llm-0.4.1 → casual_llm-0.4.3}/tests/test_backward_compatibility.py +0 -0
  35. {casual_llm-0.4.1 → casual_llm-0.4.3}/tests/test_image_utils.py +0 -0
  36. {casual_llm-0.4.1 → casual_llm-0.4.3}/tests/test_messages.py +0 -0
  37. {casual_llm-0.4.1 → casual_llm-0.4.3}/tests/test_providers.py +0 -0
  38. {casual_llm-0.4.1 → casual_llm-0.4.3}/tests/test_vision_integration.py +0 -0
  39. {casual_llm-0.4.1 → casual_llm-0.4.3}/tests/test_vision_ollama.py +0 -0
  40. {casual_llm-0.4.1 → casual_llm-0.4.3}/tests/test_vision_openai.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: casual-llm
3
- Version: 0.4.1
3
+ Version: 0.4.3
4
4
  Summary: Lightweight LLM provider abstraction with standardized message models
5
5
  Author-email: Alex Stansfield <alex@casualgenius.com>
6
6
  License: MIT
@@ -43,13 +43,15 @@ Part of the [casual-*](https://github.com/AlexStansfield/casual-mcp) ecosystem o
43
43
  ## Features
44
44
 
45
45
  - 🎯 **Protocol-based** - Uses `typing.Protocol`, no inheritance required
46
- - 🔌 **Provider-agnostic** - Works with OpenAI, Ollama, or your custom provider
47
- - 📦 **Lightweight** - Minimal dependencies (pydantic, ollama)
46
+ - 🔌 **Multi-provider** - Works with OpenAI, Anthropic (Claude), Ollama, or your custom provider
47
+ - 📦 **Lightweight** - Minimal dependencies (pydantic, ollama, httpx)
48
48
  - 🔄 **Async-first** - Built for modern async Python
49
49
  - 🛡️ **Type-safe** - Full type hints with py.typed marker
50
50
  - 📊 **OpenAI-compatible** - Standard message format used across the industry
51
51
  - 🔧 **Tool calling** - First-class support for function/tool calling
52
52
  - 📈 **Usage tracking** - Track token usage for cost monitoring
53
+ - 🖼️ **Vision support** - Send images to vision-capable models
54
+ - ⚡ **Streaming** - Stream responses in real-time with `AsyncIterator`
53
55
 
54
56
  ## Installation
55
57
 
@@ -60,11 +62,18 @@ uv add casual-llm
60
62
  # With OpenAI support
61
63
  uv add casual-llm[openai]
62
64
 
65
+ # With Anthropic (Claude) support
66
+ uv add casual-llm[anthropic]
67
+
68
+ # With all providers
69
+ uv add casual-llm[openai,anthropic]
70
+
63
71
  # Development dependencies
64
72
  uv add casual-llm[dev]
65
73
 
66
74
  # Or using pip
67
75
  pip install casual-llm
76
+ pip install casual-llm[openai,anthropic]
68
77
  ```
69
78
 
70
79
  ## Quick Start
@@ -122,6 +131,32 @@ if usage:
122
131
  print(f"Total tokens: {usage.total_tokens}")
123
132
  ```
124
133
 
134
+ ### Using Anthropic (Claude)
135
+
136
+ ```python
137
+ from casual_llm import create_provider, ModelConfig, Provider, UserMessage
138
+
139
+ # Create Anthropic provider
140
+ config = ModelConfig(
141
+ name="claude-3-5-sonnet-20241022",
142
+ provider=Provider.ANTHROPIC,
143
+ api_key="sk-ant-...", # or set ANTHROPIC_API_KEY env var
144
+ temperature=0.7
145
+ )
146
+
147
+ provider = create_provider(config)
148
+
149
+ # Generate response
150
+ messages = [UserMessage(content="Explain quantum computing in one sentence.")]
151
+ response = await provider.chat(messages, response_format="text")
152
+ print(response.content)
153
+
154
+ # Check token usage
155
+ usage = provider.get_usage()
156
+ if usage:
157
+ print(f"Total tokens: {usage.total_tokens}")
158
+ ```
159
+
125
160
  ### Using OpenAI-Compatible APIs (OpenRouter, LM Studio, etc.)
126
161
 
127
162
  ```python
@@ -136,6 +171,107 @@ config = ModelConfig(
136
171
  provider = create_provider(config)
137
172
  ```
138
173
 
174
+ ### Vision Support
175
+
176
+ Send images to vision-capable models (GPT-4o, Claude 3.5 Sonnet, llava):
177
+
178
+ ```python
179
+ from casual_llm import (
180
+ create_provider,
181
+ ModelConfig,
182
+ Provider,
183
+ UserMessage,
184
+ TextContent,
185
+ ImageContent,
186
+ )
187
+
188
+ # Works with OpenAI, Anthropic, and Ollama
189
+ config = ModelConfig(
190
+ name="gpt-4o", # or "claude-3-5-sonnet-20241022" or "llava"
191
+ provider=Provider.OPENAI,
192
+ api_key="sk-...",
193
+ )
194
+
195
+ provider = create_provider(config)
196
+
197
+ # Send an image URL
198
+ messages = [
199
+ UserMessage(
200
+ content=[
201
+ TextContent(text="What's in this image?"),
202
+ ImageContent(source="https://example.com/image.jpg"),
203
+ ]
204
+ )
205
+ ]
206
+
207
+ response = await provider.chat(messages)
208
+ print(response.content) # "I see a cat sitting on a windowsill..."
209
+
210
+ # Or send a base64-encoded image
211
+ import base64
212
+
213
+ with open("image.jpg", "rb") as f:
214
+ image_data = base64.b64encode(f.read()).decode("ascii")
215
+
216
+ messages = [
217
+ UserMessage(
218
+ content=[
219
+ TextContent(text="Describe this image"),
220
+ ImageContent(
221
+ source={"type": "base64", "data": image_data},
222
+ media_type="image/jpeg",
223
+ ),
224
+ ]
225
+ )
226
+ ]
227
+
228
+ response = await provider.chat(messages)
229
+ ```
230
+
231
+ ### Streaming Responses
232
+
233
+ Stream responses in real-time for better UX:
234
+
235
+ ```python
236
+ from casual_llm import create_provider, ModelConfig, Provider, UserMessage
237
+
238
+ config = ModelConfig(
239
+ name="gpt-4o", # Works with all providers
240
+ provider=Provider.OPENAI,
241
+ api_key="sk-...",
242
+ )
243
+
244
+ provider = create_provider(config)
245
+
246
+ messages = [UserMessage(content="Write a short poem about coding.")]
247
+
248
+ # Stream the response
249
+ async for chunk in provider.stream(messages):
250
+ if chunk.content:
251
+ print(chunk.content, end="", flush=True)
252
+
253
+ print() # New line after streaming
254
+
255
+ # Check usage after streaming
256
+ usage = provider.get_usage()
257
+ if usage:
258
+ print(f"\nTokens used: {usage.total_tokens}")
259
+ ```
260
+
261
+ ## Examples
262
+
263
+ Looking for more examples? Check out the [`examples/`](examples) directory for comprehensive demonstrations of all features:
264
+
265
+ - **[`basic_ollama.py`](examples/basic_ollama.py)** - Get started with Ollama (local LLMs)
266
+ - **[`basic_openai.py`](examples/basic_openai.py)** - Use OpenAI API and compatible services
267
+ - **[`basic_anthropic.py`](examples/basic_anthropic.py)** - Work with Claude models
268
+ - **[`vision_example.py`](examples/vision_example.py)** - Send images to vision-capable models
269
+ - **[`stream_example.py`](examples/stream_example.py)** - Stream responses in real-time
270
+ - **[`tool_calling.py`](examples/tool_calling.py)** - Complete tool/function calling workflow
271
+ - **[`message_formatting.py`](examples/message_formatting.py)** - All message types and structures
272
+
273
+ See the **[Examples README](examples/README.md)** for detailed descriptions, requirements, and usage instructions for each example.
274
+
139
275
  ## Message Models
140
276
 
141
277
  casual-llm provides OpenAI-compatible message models that work with any provider:
@@ -148,14 +284,24 @@ from casual_llm import (
148
284
  ToolResultMessage,
149
285
  AssistantToolCall,
150
286
  ChatMessage, # Type alias for any message type
287
+ TextContent, # For multimodal messages
288
+ ImageContent, # For vision support
151
289
  )
152
290
 
153
291
  # System message (sets behavior)
154
292
  system_msg = SystemMessage(content="You are a helpful assistant.")
155
293
 
156
- # User message
294
+ # User message (simple text)
157
295
  user_msg = UserMessage(content="Hello!")
158
296
 
297
+ # User message (multimodal - text + image)
298
+ vision_msg = UserMessage(
299
+ content=[
300
+ TextContent(text="What's in this image?"),
301
+ ImageContent(source="https://example.com/image.jpg"),
302
+ ]
303
+ )
304
+
159
305
  # Assistant message (with optional tool calls)
160
306
  assistant_msg = AssistantMessage(
161
307
  content="I'll help you with that.",
@@ -186,7 +332,15 @@ messages: list[ChatMessage] = [system_msg, user_msg, assistant_msg, tool_msg]
186
332
  Implement the `LLMProvider` protocol to add your own provider:
187
333
 
188
334
  ```python
189
- from casual_llm import LLMProvider, ChatMessage, AssistantMessage, Tool, Usage
335
+ from typing import Literal, AsyncIterator
336
+ from casual_llm import (
337
+ LLMProvider,
338
+ ChatMessage,
339
+ AssistantMessage,
340
+ StreamChunk,
341
+ Tool,
342
+ Usage,
343
+ )
190
344
 
191
345
  class MyCustomProvider:
192
346
  """Custom LLM provider implementation."""
@@ -194,13 +348,26 @@ class MyCustomProvider:
194
348
  async def chat(
195
349
  self,
196
350
  messages: list[ChatMessage],
197
- response_format: Literal["json", "text"] = "text",
351
+ response_format: Literal["json", "text"] | type[BaseModel] = "text",
198
352
  max_tokens: int | None = None,
199
353
  tools: list[Tool] | None = None,
354
+ temperature: float | None = None,
200
355
  ) -> AssistantMessage:
201
356
  # Your implementation here
202
357
  ...
203
358
 
359
+ async def stream(
360
+ self,
361
+ messages: list[ChatMessage],
362
+ response_format: Literal["json", "text"] | type[BaseModel] = "text",
363
+ max_tokens: int | None = None,
364
+ tools: list[Tool] | None = None,
365
+ temperature: float | None = None,
366
+ ) -> AsyncIterator[StreamChunk]:
367
+ # Your streaming implementation here
368
+ ...
369
+ yield StreamChunk(content="chunk", finish_reason=None)
370
+
204
371
  def get_usage(self) -> Usage | None:
205
372
  """Return token usage from last call."""
206
373
  return self._last_usage
@@ -246,10 +413,13 @@ Both OpenAI and Ollama providers support usage tracking.
246
413
 
247
414
  | Feature | casual-llm | LangChain | litellm |
248
415
  |---------|-----------|-----------|---------|
249
- | **Dependencies** | 2 (pydantic, ollama) | 100+ | 50+ |
416
+ | **Dependencies** | 3 (pydantic, ollama, httpx) | 100+ | 50+ |
250
417
  | **Protocol-based** | ✅ | ❌ | ❌ |
251
418
  | **Type-safe** | ✅ Full typing | Partial | Partial |
252
419
  | **Message models** | ✅ Included | ❌ Separate | ❌ |
420
+ | **Vision support** | ✅ All providers | ✅ | ✅ |
421
+ | **Streaming** | ✅ All providers | ✅ | ✅ |
422
+ | **Providers** | OpenAI, Anthropic, Ollama | Many | Many |
253
423
  | **Learning curve** | ⚡ Minutes | 📚 Hours | 📖 Medium |
254
424
  | **OpenAI compatible** | ✅ | ✅ | ✅ |
255
425
 
@@ -282,11 +452,21 @@ class LLMProvider(Protocol):
282
452
  async def chat(
283
453
  self,
284
454
  messages: list[ChatMessage],
285
- response_format: Literal["json", "text"] = "text",
455
+ response_format: Literal["json", "text"] | type[BaseModel] = "text",
286
456
  max_tokens: int | None = None,
287
457
  tools: list[Tool] | None = None,
458
+ temperature: float | None = None,
288
459
  ) -> AssistantMessage: ...
289
460
 
461
+ async def stream(
462
+ self,
463
+ messages: list[ChatMessage],
464
+ response_format: Literal["json", "text"] | type[BaseModel] = "text",
465
+ max_tokens: int | None = None,
466
+ tools: list[Tool] | None = None,
467
+ temperature: float | None = None,
468
+ ) -> AsyncIterator[StreamChunk]: ...
469
+
290
470
  def get_usage(self) -> Usage | None: ...
291
471
  ```
292
472
 
@@ -321,11 +501,13 @@ class Usage(BaseModel):
321
501
 
322
502
  All message models are Pydantic `BaseModel` instances with full validation:
323
503
 
324
- - `UserMessage(content: str | None)`
504
+ - `UserMessage(content: str | list[TextContent | ImageContent] | None)` - Supports simple text or multimodal content
325
505
  - `AssistantMessage(content: str | None, tool_calls: list[AssistantToolCall] | None = None)`
326
506
  - `SystemMessage(content: str)`
327
507
  - `ToolResultMessage(name: str, tool_call_id: str, content: str)`
328
508
  - `ChatMessage` - Type alias for any message type
509
+ - `TextContent(text: str)` - Text block for multimodal messages
510
+ - `ImageContent(source: str | dict, media_type: str | None = None)` - Image block for vision support
329
511
 
330
512
  ## Contributing
331
513
 
@@ -11,13 +11,15 @@ Part of the [casual-*](https://github.com/AlexStansfield/casual-mcp) ecosystem o
11
11
  ## Features
12
12
 
13
13
  - 🎯 **Protocol-based** - Uses `typing.Protocol`, no inheritance required
14
- - 🔌 **Provider-agnostic** - Works with OpenAI, Ollama, or your custom provider
15
- - 📦 **Lightweight** - Minimal dependencies (pydantic, ollama)
14
+ - 🔌 **Multi-provider** - Works with OpenAI, Anthropic (Claude), Ollama, or your custom provider
15
+ - 📦 **Lightweight** - Minimal dependencies (pydantic, ollama, httpx)
16
16
  - 🔄 **Async-first** - Built for modern async Python
17
17
  - 🛡️ **Type-safe** - Full type hints with py.typed marker
18
18
  - 📊 **OpenAI-compatible** - Standard message format used across the industry
19
19
  - 🔧 **Tool calling** - First-class support for function/tool calling
20
20
  - 📈 **Usage tracking** - Track token usage for cost monitoring
21
+ - 🖼️ **Vision support** - Send images to vision-capable models
22
+ - ⚡ **Streaming** - Stream responses in real-time with `AsyncIterator`
21
23
 
22
24
  ## Installation
23
25
 
@@ -28,11 +30,18 @@ uv add casual-llm
28
30
  # With OpenAI support
29
31
  uv add casual-llm[openai]
30
32
 
33
+ # With Anthropic (Claude) support
34
+ uv add casual-llm[anthropic]
35
+
36
+ # With all providers
37
+ uv add casual-llm[openai,anthropic]
38
+
31
39
  # Development dependencies
32
40
  uv add casual-llm[dev]
33
41
 
34
42
  # Or using pip
35
43
  pip install casual-llm
44
+ pip install casual-llm[openai,anthropic]
36
45
  ```
37
46
 
38
47
  ## Quick Start
@@ -90,6 +99,32 @@ if usage:
90
99
  print(f"Total tokens: {usage.total_tokens}")
91
100
  ```
92
101
 
102
+ ### Using Anthropic (Claude)
103
+
104
+ ```python
105
+ from casual_llm import create_provider, ModelConfig, Provider, UserMessage
106
+
107
+ # Create Anthropic provider
108
+ config = ModelConfig(
109
+ name="claude-3-5-sonnet-20241022",
110
+ provider=Provider.ANTHROPIC,
111
+ api_key="sk-ant-...", # or set ANTHROPIC_API_KEY env var
112
+ temperature=0.7
113
+ )
114
+
115
+ provider = create_provider(config)
116
+
117
+ # Generate response
118
+ messages = [UserMessage(content="Explain quantum computing in one sentence.")]
119
+ response = await provider.chat(messages, response_format="text")
120
+ print(response.content)
121
+
122
+ # Check token usage
123
+ usage = provider.get_usage()
124
+ if usage:
125
+ print(f"Total tokens: {usage.total_tokens}")
126
+ ```
127
+
93
128
  ### Using OpenAI-Compatible APIs (OpenRouter, LM Studio, etc.)
94
129
 
95
130
  ```python
@@ -104,6 +139,107 @@ config = ModelConfig(
104
139
  provider = create_provider(config)
105
140
  ```
106
141
 
142
+ ### Vision Support
143
+
144
+ Send images to vision-capable models (GPT-4o, Claude 3.5 Sonnet, llava):
145
+
146
+ ```python
147
+ from casual_llm import (
148
+ create_provider,
149
+ ModelConfig,
150
+ Provider,
151
+ UserMessage,
152
+ TextContent,
153
+ ImageContent,
154
+ )
155
+
156
+ # Works with OpenAI, Anthropic, and Ollama
157
+ config = ModelConfig(
158
+ name="gpt-4o", # or "claude-3-5-sonnet-20241022" or "llava"
159
+ provider=Provider.OPENAI,
160
+ api_key="sk-...",
161
+ )
162
+
163
+ provider = create_provider(config)
164
+
165
+ # Send an image URL
166
+ messages = [
167
+ UserMessage(
168
+ content=[
169
+ TextContent(text="What's in this image?"),
170
+ ImageContent(source="https://example.com/image.jpg"),
171
+ ]
172
+ )
173
+ ]
174
+
175
+ response = await provider.chat(messages)
176
+ print(response.content) # "I see a cat sitting on a windowsill..."
177
+
178
+ # Or send a base64-encoded image
179
+ import base64
180
+
181
+ with open("image.jpg", "rb") as f:
182
+ image_data = base64.b64encode(f.read()).decode("ascii")
183
+
184
+ messages = [
185
+ UserMessage(
186
+ content=[
187
+ TextContent(text="Describe this image"),
188
+ ImageContent(
189
+ source={"type": "base64", "data": image_data},
190
+ media_type="image/jpeg",
191
+ ),
192
+ ]
193
+ )
194
+ ]
195
+
196
+ response = await provider.chat(messages)
197
+ ```
198
+
199
+ ### Streaming Responses
200
+
201
+ Stream responses in real-time for better UX:
202
+
203
+ ```python
204
+ from casual_llm import create_provider, ModelConfig, Provider, UserMessage
205
+
206
+ config = ModelConfig(
207
+ name="gpt-4o", # Works with all providers
208
+ provider=Provider.OPENAI,
209
+ api_key="sk-...",
210
+ )
211
+
212
+ provider = create_provider(config)
213
+
214
+ messages = [UserMessage(content="Write a short poem about coding.")]
215
+
216
+ # Stream the response
217
+ async for chunk in provider.stream(messages):
218
+ if chunk.content:
219
+ print(chunk.content, end="", flush=True)
220
+
221
+ print() # New line after streaming
222
+
223
+ # Check usage after streaming
224
+ usage = provider.get_usage()
225
+ if usage:
226
+ print(f"\nTokens used: {usage.total_tokens}")
227
+ ```
228
+
229
+ ## Examples
230
+
231
+ Looking for more examples? Check out the [`examples/`](examples) directory for comprehensive demonstrations of all features:
232
+
233
+ - **[`basic_ollama.py`](examples/basic_ollama.py)** - Get started with Ollama (local LLMs)
234
+ - **[`basic_openai.py`](examples/basic_openai.py)** - Use OpenAI API and compatible services
235
+ - **[`basic_anthropic.py`](examples/basic_anthropic.py)** - Work with Claude models
236
+ - **[`vision_example.py`](examples/vision_example.py)** - Send images to vision-capable models
237
+ - **[`stream_example.py`](examples/stream_example.py)** - Stream responses in real-time
238
+ - **[`tool_calling.py`](examples/tool_calling.py)** - Complete tool/function calling workflow
239
+ - **[`message_formatting.py`](examples/message_formatting.py)** - All message types and structures
240
+
241
+ See the **[Examples README](examples/README.md)** for detailed descriptions, requirements, and usage instructions for each example.
242
+
107
243
  ## Message Models
108
244
 
109
245
  casual-llm provides OpenAI-compatible message models that work with any provider:
@@ -116,14 +252,24 @@ from casual_llm import (
116
252
  ToolResultMessage,
117
253
  AssistantToolCall,
118
254
  ChatMessage, # Type alias for any message type
255
+ TextContent, # For multimodal messages
256
+ ImageContent, # For vision support
119
257
  )
120
258
 
121
259
  # System message (sets behavior)
122
260
  system_msg = SystemMessage(content="You are a helpful assistant.")
123
261
 
124
- # User message
262
+ # User message (simple text)
125
263
  user_msg = UserMessage(content="Hello!")
126
264
 
265
+ # User message (multimodal - text + image)
266
+ vision_msg = UserMessage(
267
+ content=[
268
+ TextContent(text="What's in this image?"),
269
+ ImageContent(source="https://example.com/image.jpg"),
270
+ ]
271
+ )
272
+
127
273
  # Assistant message (with optional tool calls)
128
274
  assistant_msg = AssistantMessage(
129
275
  content="I'll help you with that.",
@@ -154,7 +300,15 @@ messages: list[ChatMessage] = [system_msg, user_msg, assistant_msg, tool_msg]
154
300
  Implement the `LLMProvider` protocol to add your own provider:
155
301
 
156
302
  ```python
157
- from casual_llm import LLMProvider, ChatMessage, AssistantMessage, Tool, Usage
303
+ from typing import Literal, AsyncIterator
304
+ from casual_llm import (
305
+ LLMProvider,
306
+ ChatMessage,
307
+ AssistantMessage,
308
+ StreamChunk,
309
+ Tool,
310
+ Usage,
311
+ )
158
312
 
159
313
  class MyCustomProvider:
160
314
  """Custom LLM provider implementation."""
@@ -162,13 +316,26 @@ class MyCustomProvider:
162
316
  async def chat(
163
317
  self,
164
318
  messages: list[ChatMessage],
165
- response_format: Literal["json", "text"] = "text",
319
+ response_format: Literal["json", "text"] | type[BaseModel] = "text",
166
320
  max_tokens: int | None = None,
167
321
  tools: list[Tool] | None = None,
322
+ temperature: float | None = None,
168
323
  ) -> AssistantMessage:
169
324
  # Your implementation here
170
325
  ...
171
326
 
327
+ async def stream(
328
+ self,
329
+ messages: list[ChatMessage],
330
+ response_format: Literal["json", "text"] | type[BaseModel] = "text",
331
+ max_tokens: int | None = None,
332
+ tools: list[Tool] | None = None,
333
+ temperature: float | None = None,
334
+ ) -> AsyncIterator[StreamChunk]:
335
+ # Your streaming implementation here
336
+ ...
337
+ yield StreamChunk(content="chunk", finish_reason=None)
338
+
172
339
  def get_usage(self) -> Usage | None:
173
340
  """Return token usage from last call."""
174
341
  return self._last_usage
@@ -214,10 +381,13 @@ Both OpenAI and Ollama providers support usage tracking.
214
381
 
215
382
  | Feature | casual-llm | LangChain | litellm |
216
383
  |---------|-----------|-----------|---------|
217
- | **Dependencies** | 2 (pydantic, ollama) | 100+ | 50+ |
384
+ | **Dependencies** | 3 (pydantic, ollama, httpx) | 100+ | 50+ |
218
385
  | **Protocol-based** | ✅ | ❌ | ❌ |
219
386
  | **Type-safe** | ✅ Full typing | Partial | Partial |
220
387
  | **Message models** | ✅ Included | ❌ Separate | ❌ |
388
+ | **Vision support** | ✅ All providers | ✅ | ✅ |
389
+ | **Streaming** | ✅ All providers | ✅ | ✅ |
390
+ | **Providers** | OpenAI, Anthropic, Ollama | Many | Many |
221
391
  | **Learning curve** | ⚡ Minutes | 📚 Hours | 📖 Medium |
222
392
  | **OpenAI compatible** | ✅ | ✅ | ✅ |
223
393
 
@@ -250,11 +420,21 @@ class LLMProvider(Protocol):
250
420
  async def chat(
251
421
  self,
252
422
  messages: list[ChatMessage],
253
- response_format: Literal["json", "text"] = "text",
423
+ response_format: Literal["json", "text"] | type[BaseModel] = "text",
254
424
  max_tokens: int | None = None,
255
425
  tools: list[Tool] | None = None,
426
+ temperature: float | None = None,
256
427
  ) -> AssistantMessage: ...
257
428
 
429
+ async def stream(
430
+ self,
431
+ messages: list[ChatMessage],
432
+ response_format: Literal["json", "text"] | type[BaseModel] = "text",
433
+ max_tokens: int | None = None,
434
+ tools: list[Tool] | None = None,
435
+ temperature: float | None = None,
436
+ ) -> AsyncIterator[StreamChunk]: ...
437
+
258
438
  def get_usage(self) -> Usage | None: ...
259
439
  ```
260
440
 
@@ -289,11 +469,13 @@ class Usage(BaseModel):
289
469
 
290
470
  All message models are Pydantic `BaseModel` instances with full validation:
291
471
 
292
- - `UserMessage(content: str | None)`
472
+ - `UserMessage(content: str | list[TextContent | ImageContent] | None)` - Supports simple text or multimodal content
293
473
  - `AssistantMessage(content: str | None, tool_calls: list[AssistantToolCall] | None = None)`
294
474
  - `SystemMessage(content: str)`
295
475
  - `ToolResultMessage(name: str, tool_call_id: str, content: str)`
296
476
  - `ChatMessage` - Type alias for any message type
477
+ - `TextContent(text: str)` - Text block for multimodal messages
478
+ - `ImageContent(source: str | dict, media_type: str | None = None)` - Image block for vision support
297
479
 
298
480
  ## Contributing
299
481
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "casual-llm"
3
- version = "0.4.1"
3
+ version = "0.4.3"
4
4
  description = "Lightweight LLM provider abstraction with standardized message models"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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.4.1"
10
+ __version__ = "0.4.3"
11
11
 
12
12
  # Model configuration
13
13
  from casual_llm.config import ModelConfig, Provider
@@ -7,6 +7,7 @@ Provides unified tool models compatible with Ollama, OpenAI, and MCP.
7
7
  from __future__ import annotations
8
8
 
9
9
  from typing import Any
10
+
10
11
  from pydantic import BaseModel, Field
11
12
 
12
13
 
@@ -14,10 +15,13 @@ class ToolParameter(BaseModel):
14
15
  """
15
16
  A single tool parameter definition following JSON Schema.
16
17
 
17
- Supports common JSON Schema fields for describing function parameters.
18
+ Supports common JSON Schema fields for describing function parameters,
19
+ including union types via anyOf/oneOf schemas.
18
20
  """
19
21
 
20
- type: str = Field(..., description="JSON Schema type (string, number, object, array, etc.)")
22
+ type: str | None = Field(
23
+ None, description="JSON Schema type (string, number, object, array, etc.)"
24
+ )
21
25
  description: str | None = Field(None, description="Parameter description")
22
26
  enum: list[Any] | None = Field(None, description="Allowed values for enum types")
23
27
  items: dict[str, Any] | None = Field(None, description="Array item schema")
@@ -26,6 +30,8 @@ class ToolParameter(BaseModel):
26
30
  )
27
31
  required: list[str] | None = Field(None, description="Required properties for nested objects")
28
32
  default: Any | None = Field(None, description="Default value")
33
+ anyOf: list[dict[str, Any]] | None = Field(None, description="Union type schemas")
34
+ oneOf: list[dict[str, Any]] | None = Field(None, description="Exclusive union type schemas")
29
35
 
30
36
  model_config = {"extra": "allow"} # Allow additional JSON Schema fields
31
37
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: casual-llm
3
- Version: 0.4.1
3
+ Version: 0.4.3
4
4
  Summary: Lightweight LLM provider abstraction with standardized message models
5
5
  Author-email: Alex Stansfield <alex@casualgenius.com>
6
6
  License: MIT
@@ -43,13 +43,15 @@ Part of the [casual-*](https://github.com/AlexStansfield/casual-mcp) ecosystem o
43
43
  ## Features
44
44
 
45
45
  - 🎯 **Protocol-based** - Uses `typing.Protocol`, no inheritance required
46
- - 🔌 **Provider-agnostic** - Works with OpenAI, Ollama, or your custom provider
47
- - 📦 **Lightweight** - Minimal dependencies (pydantic, ollama)
46
+ - 🔌 **Multi-provider** - Works with OpenAI, Anthropic (Claude), Ollama, or your custom provider
47
+ - 📦 **Lightweight** - Minimal dependencies (pydantic, ollama, httpx)
48
48
  - 🔄 **Async-first** - Built for modern async Python
49
49
  - 🛡️ **Type-safe** - Full type hints with py.typed marker
50
50
  - 📊 **OpenAI-compatible** - Standard message format used across the industry
51
51
  - 🔧 **Tool calling** - First-class support for function/tool calling
52
52
  - 📈 **Usage tracking** - Track token usage for cost monitoring
53
+ - 🖼️ **Vision support** - Send images to vision-capable models
54
+ - ⚡ **Streaming** - Stream responses in real-time with `AsyncIterator`
53
55
 
54
56
  ## Installation
55
57
 
@@ -60,11 +62,18 @@ uv add casual-llm
60
62
  # With OpenAI support
61
63
  uv add casual-llm[openai]
62
64
 
65
+ # With Anthropic (Claude) support
66
+ uv add casual-llm[anthropic]
67
+
68
+ # With all providers
69
+ uv add casual-llm[openai,anthropic]
70
+
63
71
  # Development dependencies
64
72
  uv add casual-llm[dev]
65
73
 
66
74
  # Or using pip
67
75
  pip install casual-llm
76
+ pip install casual-llm[openai,anthropic]
68
77
  ```
69
78
 
70
79
  ## Quick Start
@@ -122,6 +131,32 @@ if usage:
122
131
  print(f"Total tokens: {usage.total_tokens}")
123
132
  ```
124
133
 
134
+ ### Using Anthropic (Claude)
135
+
136
+ ```python
137
+ from casual_llm import create_provider, ModelConfig, Provider, UserMessage
138
+
139
+ # Create Anthropic provider
140
+ config = ModelConfig(
141
+ name="claude-3-5-sonnet-20241022",
142
+ provider=Provider.ANTHROPIC,
143
+ api_key="sk-ant-...", # or set ANTHROPIC_API_KEY env var
144
+ temperature=0.7
145
+ )
146
+
147
+ provider = create_provider(config)
148
+
149
+ # Generate response
150
+ messages = [UserMessage(content="Explain quantum computing in one sentence.")]
151
+ response = await provider.chat(messages, response_format="text")
152
+ print(response.content)
153
+
154
+ # Check token usage
155
+ usage = provider.get_usage()
156
+ if usage:
157
+ print(f"Total tokens: {usage.total_tokens}")
158
+ ```
159
+
125
160
  ### Using OpenAI-Compatible APIs (OpenRouter, LM Studio, etc.)
126
161
 
127
162
  ```python
@@ -136,6 +171,107 @@ config = ModelConfig(
136
171
  provider = create_provider(config)
137
172
  ```
138
173
 
174
+ ### Vision Support
175
+
176
+ Send images to vision-capable models (GPT-4o, Claude 3.5 Sonnet, llava):
177
+
178
+ ```python
179
+ from casual_llm import (
180
+ create_provider,
181
+ ModelConfig,
182
+ Provider,
183
+ UserMessage,
184
+ TextContent,
185
+ ImageContent,
186
+ )
187
+
188
+ # Works with OpenAI, Anthropic, and Ollama
189
+ config = ModelConfig(
190
+ name="gpt-4o", # or "claude-3-5-sonnet-20241022" or "llava"
191
+ provider=Provider.OPENAI,
192
+ api_key="sk-...",
193
+ )
194
+
195
+ provider = create_provider(config)
196
+
197
+ # Send an image URL
198
+ messages = [
199
+ UserMessage(
200
+ content=[
201
+ TextContent(text="What's in this image?"),
202
+ ImageContent(source="https://example.com/image.jpg"),
203
+ ]
204
+ )
205
+ ]
206
+
207
+ response = await provider.chat(messages)
208
+ print(response.content) # "I see a cat sitting on a windowsill..."
209
+
210
+ # Or send a base64-encoded image
211
+ import base64
212
+
213
+ with open("image.jpg", "rb") as f:
214
+ image_data = base64.b64encode(f.read()).decode("ascii")
215
+
216
+ messages = [
217
+ UserMessage(
218
+ content=[
219
+ TextContent(text="Describe this image"),
220
+ ImageContent(
221
+ source={"type": "base64", "data": image_data},
222
+ media_type="image/jpeg",
223
+ ),
224
+ ]
225
+ )
226
+ ]
227
+
228
+ response = await provider.chat(messages)
229
+ ```
230
+
231
+ ### Streaming Responses
232
+
233
+ Stream responses in real-time for better UX:
234
+
235
+ ```python
236
+ from casual_llm import create_provider, ModelConfig, Provider, UserMessage
237
+
238
+ config = ModelConfig(
239
+ name="gpt-4o", # Works with all providers
240
+ provider=Provider.OPENAI,
241
+ api_key="sk-...",
242
+ )
243
+
244
+ provider = create_provider(config)
245
+
246
+ messages = [UserMessage(content="Write a short poem about coding.")]
247
+
248
+ # Stream the response
249
+ async for chunk in provider.stream(messages):
250
+ if chunk.content:
251
+ print(chunk.content, end="", flush=True)
252
+
253
+ print() # New line after streaming
254
+
255
+ # Check usage after streaming
256
+ usage = provider.get_usage()
257
+ if usage:
258
+ print(f"\nTokens used: {usage.total_tokens}")
259
+ ```
260
+
261
+ ## Examples
262
+
263
+ Looking for more examples? Check out the [`examples/`](examples) directory for comprehensive demonstrations of all features:
264
+
265
+ - **[`basic_ollama.py`](examples/basic_ollama.py)** - Get started with Ollama (local LLMs)
266
+ - **[`basic_openai.py`](examples/basic_openai.py)** - Use OpenAI API and compatible services
267
+ - **[`basic_anthropic.py`](examples/basic_anthropic.py)** - Work with Claude models
268
+ - **[`vision_example.py`](examples/vision_example.py)** - Send images to vision-capable models
269
+ - **[`stream_example.py`](examples/stream_example.py)** - Stream responses in real-time
270
+ - **[`tool_calling.py`](examples/tool_calling.py)** - Complete tool/function calling workflow
271
+ - **[`message_formatting.py`](examples/message_formatting.py)** - All message types and structures
272
+
273
+ See the **[Examples README](examples/README.md)** for detailed descriptions, requirements, and usage instructions for each example.
274
+
139
275
  ## Message Models
140
276
 
141
277
  casual-llm provides OpenAI-compatible message models that work with any provider:
@@ -148,14 +284,24 @@ from casual_llm import (
148
284
  ToolResultMessage,
149
285
  AssistantToolCall,
150
286
  ChatMessage, # Type alias for any message type
287
+ TextContent, # For multimodal messages
288
+ ImageContent, # For vision support
151
289
  )
152
290
 
153
291
  # System message (sets behavior)
154
292
  system_msg = SystemMessage(content="You are a helpful assistant.")
155
293
 
156
- # User message
294
+ # User message (simple text)
157
295
  user_msg = UserMessage(content="Hello!")
158
296
 
297
+ # User message (multimodal - text + image)
298
+ vision_msg = UserMessage(
299
+ content=[
300
+ TextContent(text="What's in this image?"),
301
+ ImageContent(source="https://example.com/image.jpg"),
302
+ ]
303
+ )
304
+
159
305
  # Assistant message (with optional tool calls)
160
306
  assistant_msg = AssistantMessage(
161
307
  content="I'll help you with that.",
@@ -186,7 +332,15 @@ messages: list[ChatMessage] = [system_msg, user_msg, assistant_msg, tool_msg]
186
332
  Implement the `LLMProvider` protocol to add your own provider:
187
333
 
188
334
  ```python
189
- from casual_llm import LLMProvider, ChatMessage, AssistantMessage, Tool, Usage
335
+ from typing import Literal, AsyncIterator
336
+ from casual_llm import (
337
+ LLMProvider,
338
+ ChatMessage,
339
+ AssistantMessage,
340
+ StreamChunk,
341
+ Tool,
342
+ Usage,
343
+ )
190
344
 
191
345
  class MyCustomProvider:
192
346
  """Custom LLM provider implementation."""
@@ -194,13 +348,26 @@ class MyCustomProvider:
194
348
  async def chat(
195
349
  self,
196
350
  messages: list[ChatMessage],
197
- response_format: Literal["json", "text"] = "text",
351
+ response_format: Literal["json", "text"] | type[BaseModel] = "text",
198
352
  max_tokens: int | None = None,
199
353
  tools: list[Tool] | None = None,
354
+ temperature: float | None = None,
200
355
  ) -> AssistantMessage:
201
356
  # Your implementation here
202
357
  ...
203
358
 
359
+ async def stream(
360
+ self,
361
+ messages: list[ChatMessage],
362
+ response_format: Literal["json", "text"] | type[BaseModel] = "text",
363
+ max_tokens: int | None = None,
364
+ tools: list[Tool] | None = None,
365
+ temperature: float | None = None,
366
+ ) -> AsyncIterator[StreamChunk]:
367
+ # Your streaming implementation here
368
+ ...
369
+ yield StreamChunk(content="chunk", finish_reason=None)
370
+
204
371
  def get_usage(self) -> Usage | None:
205
372
  """Return token usage from last call."""
206
373
  return self._last_usage
@@ -246,10 +413,13 @@ Both OpenAI and Ollama providers support usage tracking.
246
413
 
247
414
  | Feature | casual-llm | LangChain | litellm |
248
415
  |---------|-----------|-----------|---------|
249
- | **Dependencies** | 2 (pydantic, ollama) | 100+ | 50+ |
416
+ | **Dependencies** | 3 (pydantic, ollama, httpx) | 100+ | 50+ |
250
417
  | **Protocol-based** | ✅ | ❌ | ❌ |
251
418
  | **Type-safe** | ✅ Full typing | Partial | Partial |
252
419
  | **Message models** | ✅ Included | ❌ Separate | ❌ |
420
+ | **Vision support** | ✅ All providers | ✅ | ✅ |
421
+ | **Streaming** | ✅ All providers | ✅ | ✅ |
422
+ | **Providers** | OpenAI, Anthropic, Ollama | Many | Many |
253
423
  | **Learning curve** | ⚡ Minutes | 📚 Hours | 📖 Medium |
254
424
  | **OpenAI compatible** | ✅ | ✅ | ✅ |
255
425
 
@@ -282,11 +452,21 @@ class LLMProvider(Protocol):
282
452
  async def chat(
283
453
  self,
284
454
  messages: list[ChatMessage],
285
- response_format: Literal["json", "text"] = "text",
455
+ response_format: Literal["json", "text"] | type[BaseModel] = "text",
286
456
  max_tokens: int | None = None,
287
457
  tools: list[Tool] | None = None,
458
+ temperature: float | None = None,
288
459
  ) -> AssistantMessage: ...
289
460
 
461
+ async def stream(
462
+ self,
463
+ messages: list[ChatMessage],
464
+ response_format: Literal["json", "text"] | type[BaseModel] = "text",
465
+ max_tokens: int | None = None,
466
+ tools: list[Tool] | None = None,
467
+ temperature: float | None = None,
468
+ ) -> AsyncIterator[StreamChunk]: ...
469
+
290
470
  def get_usage(self) -> Usage | None: ...
291
471
  ```
292
472
 
@@ -321,11 +501,13 @@ class Usage(BaseModel):
321
501
 
322
502
  All message models are Pydantic `BaseModel` instances with full validation:
323
503
 
324
- - `UserMessage(content: str | None)`
504
+ - `UserMessage(content: str | list[TextContent | ImageContent] | None)` - Supports simple text or multimodal content
325
505
  - `AssistantMessage(content: str | None, tool_calls: list[AssistantToolCall] | None = None)`
326
506
  - `SystemMessage(content: str)`
327
507
  - `ToolResultMessage(name: str, tool_call_id: str, content: str)`
328
508
  - `ChatMessage` - Type alias for any message type
509
+ - `TextContent(text: str)` - Text block for multimodal messages
510
+ - `ImageContent(source: str | dict, media_type: str | None = None)` - Image block for vision support
329
511
 
330
512
  ## Contributing
331
513
 
@@ -71,6 +71,35 @@ class TestToolParameter:
71
71
  assert dumped["maxLength"] == 100
72
72
  assert dumped["pattern"] == "^[a-z]+$"
73
73
 
74
+ def test_anyof_parameter(self):
75
+ """Test parameter with anyOf union type."""
76
+ param = ToolParameter(
77
+ anyOf=[{"type": "string"}, {"type": "null"}],
78
+ description="Optional string",
79
+ )
80
+ assert param.type is None
81
+ assert param.anyOf is not None
82
+
83
+ def test_oneof_parameter(self):
84
+ """Test parameter with oneOf exclusive union type."""
85
+ param = ToolParameter(
86
+ oneOf=[{"type": "string"}, {"type": "number"}],
87
+ description="String or number",
88
+ )
89
+ assert param.type is None
90
+ assert param.oneOf is not None
91
+
92
+ def test_anyof_serialization(self):
93
+ """Test that anyOf is properly serialized."""
94
+ param = ToolParameter(
95
+ anyOf=[{"type": "string"}, {"type": "null"}],
96
+ description="Optional",
97
+ )
98
+ dumped = param.model_dump(exclude_none=True)
99
+ assert "anyOf" in dumped
100
+ assert len(dumped["anyOf"]) == 2
101
+ assert "type" not in dumped # type is None, excluded
102
+
74
103
 
75
104
  class TestTool:
76
105
  """Tests for Tool model."""
@@ -188,6 +217,41 @@ class TestTool:
188
217
  assert reconstructed.required == original_tool.required
189
218
  assert len(reconstructed.parameters) == len(original_tool.parameters)
190
219
 
220
+ def test_input_schema_with_anyof(self):
221
+ """Test that input_schema preserves anyOf without injecting type."""
222
+ tool = Tool(
223
+ name="test",
224
+ description="Test",
225
+ parameters={
226
+ "optional_field": ToolParameter(
227
+ anyOf=[{"type": "string"}, {"type": "null"}],
228
+ description="An optional string",
229
+ )
230
+ },
231
+ )
232
+ schema = tool.input_schema
233
+ # anyOf should be preserved as-is
234
+ assert "anyOf" in schema["properties"]["optional_field"]
235
+ # type should NOT be injected (anyOf is valid JSON Schema)
236
+ assert "type" not in schema["properties"]["optional_field"]
237
+
238
+ def test_input_schema_with_oneof(self):
239
+ """Test that input_schema preserves oneOf without injecting type."""
240
+ tool = Tool(
241
+ name="test",
242
+ description="Test",
243
+ parameters={
244
+ "mixed_field": ToolParameter(
245
+ oneOf=[{"type": "number"}, {"type": "string"}],
246
+ )
247
+ },
248
+ )
249
+ schema = tool.input_schema
250
+ # oneOf should be preserved as-is
251
+ assert "oneOf" in schema["properties"]["mixed_field"]
252
+ # type should NOT be injected
253
+ assert "type" not in schema["properties"]["mixed_field"]
254
+
191
255
 
192
256
  class TestOllamaConverters:
193
257
  """Tests for Ollama format converters."""
File without changes
File without changes