casual-llm 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

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/__init__.py CHANGED
@@ -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.3.0"
11
11
 
12
12
  # Model configuration
13
13
  from casual_llm.config import ModelConfig, Provider
@@ -29,6 +29,10 @@ from casual_llm.messages import (
29
29
  ToolResultMessage,
30
30
  AssistantToolCall,
31
31
  AssistantToolCallFunction,
32
+ StreamChunk,
33
+ # Multimodal content types
34
+ TextContent,
35
+ ImageContent,
32
36
  )
33
37
 
34
38
  # Tool models
@@ -71,6 +75,10 @@ __all__ = [
71
75
  "ToolResultMessage",
72
76
  "AssistantToolCall",
73
77
  "AssistantToolCallFunction",
78
+ "StreamChunk",
79
+ # Multimodal content types
80
+ "TextContent",
81
+ "ImageContent",
74
82
  # Tools
75
83
  "Tool",
76
84
  "ToolParameter",
@@ -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}")
casual_llm/messages.py CHANGED
@@ -10,6 +10,37 @@ from typing import Literal, TypeAlias
10
10
  from pydantic import BaseModel
11
11
 
12
12
 
13
+ class TextContent(BaseModel):
14
+ """Text content block for multimodal messages."""
15
+
16
+ type: Literal["text"] = "text"
17
+ text: str
18
+
19
+
20
+ class ImageContent(BaseModel):
21
+ """Image content block for multimodal messages.
22
+
23
+ Supports both URL strings and base64-encoded image data.
24
+
25
+ Examples:
26
+ # URL source
27
+ ImageContent(type="image", source="https://example.com/image.jpg")
28
+
29
+ # Base64 source (dict format)
30
+ ImageContent(
31
+ type="image",
32
+ source={"type": "base64", "data": "...base64..."},
33
+ media_type="image/png"
34
+ )
35
+ """
36
+
37
+ type: Literal["image"] = "image"
38
+ source: str | dict[str, str]
39
+ """URL string or dict with {type: "base64", data: "..."} format."""
40
+ media_type: str = "image/jpeg"
41
+ """MIME type of the image (e.g., image/jpeg, image/png, image/gif, image/webp)."""
42
+
43
+
13
44
  class AssistantToolCallFunction(BaseModel):
14
45
  """Function call within an assistant tool call."""
15
46
 
@@ -50,10 +81,30 @@ class ToolResultMessage(BaseModel):
50
81
 
51
82
 
52
83
  class UserMessage(BaseModel):
53
- """Message from the user."""
84
+ """Message from the user.
85
+
86
+ Supports both simple text content and multimodal content (text + images).
87
+
88
+ Examples:
89
+ # Simple text content
90
+ UserMessage(content="Hello, world!")
91
+
92
+ # Multimodal content
93
+ UserMessage(content=[
94
+ TextContent(type="text", text="What's in this image?"),
95
+ ImageContent(type="image", source="https://example.com/image.jpg")
96
+ ])
97
+ """
54
98
 
55
99
  role: Literal["user"] = "user"
56
- content: str | None
100
+ content: str | list[TextContent | ImageContent] | None
101
+
102
+
103
+ class StreamChunk(BaseModel):
104
+ """A chunk of streamed response content from an LLM provider."""
105
+
106
+ content: str
107
+ finish_reason: str | None = None
57
108
 
58
109
 
59
110
  ChatMessage: TypeAlias = AssistantMessage | SystemMessage | ToolResultMessage | UserMessage
@@ -7,11 +7,11 @@ using standard OpenAI-compatible message formats.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- from typing import Protocol, Literal
10
+ from typing import Protocol, Literal, AsyncIterator
11
11
 
12
12
  from pydantic import BaseModel
13
13
 
14
- from casual_llm.messages import ChatMessage, AssistantMessage
14
+ from casual_llm.messages import ChatMessage, AssistantMessage, StreamChunk
15
15
  from casual_llm.tools import Tool
16
16
  from casual_llm.usage import Usage
17
17
 
@@ -77,6 +77,52 @@ class LLMProvider(Protocol):
77
77
  """
78
78
  ...
79
79
 
80
+ def stream(
81
+ self,
82
+ messages: list[ChatMessage],
83
+ response_format: Literal["json", "text"] | type[BaseModel] = "text",
84
+ max_tokens: int | None = None,
85
+ tools: list[Tool] | None = None,
86
+ temperature: float | None = None,
87
+ ) -> AsyncIterator[StreamChunk]:
88
+ """
89
+ Stream a chat response from the LLM.
90
+
91
+ This method yields response chunks in real-time as they are generated,
92
+ enabling progressive display in chat interfaces.
93
+
94
+ Args:
95
+ messages: List of ChatMessage (UserMessage, AssistantMessage, SystemMessage, etc.)
96
+ response_format: Expected response format. Can be "json", "text", or a Pydantic
97
+ BaseModel class for JSON Schema-based structured output. When a Pydantic model
98
+ is provided, the LLM will be instructed to return JSON matching the schema.
99
+ max_tokens: Maximum tokens to generate (optional)
100
+ tools: List of tools available for the LLM to call (optional, may not work
101
+ with all providers during streaming)
102
+ temperature: Temperature for this request (optional, overrides instance temperature)
103
+
104
+ Yields:
105
+ StreamChunk objects containing content fragments as tokens are generated.
106
+ Each chunk has a `content` attribute with the text fragment.
107
+
108
+ Raises:
109
+ Provider-specific exceptions (httpx.HTTPError, openai.OpenAIError, etc.)
110
+
111
+ Examples:
112
+ >>> from casual_llm import UserMessage
113
+ >>>
114
+ >>> # Stream response and print tokens as they arrive
115
+ >>> async for chunk in provider.stream([UserMessage(content="Tell me a story")]):
116
+ ... print(chunk.content, end="", flush=True)
117
+ >>>
118
+ >>> # Collect full response from stream
119
+ >>> chunks = []
120
+ >>> async for chunk in provider.stream([UserMessage(content="Hello")]):
121
+ ... chunks.append(chunk.content)
122
+ >>> full_response = "".join(chunks)
123
+ """
124
+ ...
125
+
80
126
  def get_usage(self) -> Usage | None:
81
127
  """
82
128
  Get token usage statistics from the last chat() call.
@@ -5,11 +5,11 @@ Ollama LLM provider using the official ollama library.
5
5
  from __future__ import annotations
6
6
 
7
7
  import logging
8
- from typing import Any, Literal
8
+ from typing import Any, AsyncIterator, Literal
9
9
  from ollama import AsyncClient
10
10
  from pydantic import BaseModel
11
11
 
12
- from casual_llm.messages import ChatMessage, AssistantMessage
12
+ from casual_llm.messages import ChatMessage, AssistantMessage, StreamChunk
13
13
  from casual_llm.tools import Tool
14
14
  from casual_llm.usage import Usage
15
15
  from casual_llm.tool_converters import tools_to_ollama
@@ -42,7 +42,8 @@ class OllamaProvider:
42
42
  Args:
43
43
  model: Model name (e.g., "qwen2.5:7b-instruct")
44
44
  host: Ollama server URL (e.g., "http://localhost:11434")
45
- temperature: Temperature for generation (0.0-1.0, optional - uses Ollama default if not set)
45
+ temperature: Temperature for generation (0.0-1.0, optional - uses Ollama
46
+ default if not set)
46
47
  timeout: HTTP request timeout in seconds
47
48
  """
48
49
  self.model = model
@@ -108,8 +109,8 @@ class OllamaProvider:
108
109
  ... response_format=PersonInfo # Pass the class, not an instance
109
110
  ... )
110
111
  """
111
- # Convert messages to Ollama format using converter
112
- chat_messages = convert_messages_to_ollama(messages)
112
+ # Convert messages to Ollama format using converter (async for image support)
113
+ chat_messages = await convert_messages_to_ollama(messages)
113
114
  logger.debug(f"Converted {len(messages)} messages to Ollama format")
114
115
 
115
116
  # Use provided temperature or fall back to instance temperature
@@ -173,3 +174,90 @@ class OllamaProvider:
173
174
  content = response_message.content.strip() if response_message.content else ""
174
175
  logger.debug(f"Generated {len(content)} characters")
175
176
  return AssistantMessage(content=content, tool_calls=tool_calls)
177
+
178
+ async def stream(
179
+ self,
180
+ messages: list[ChatMessage],
181
+ response_format: Literal["json", "text"] | type[BaseModel] = "text",
182
+ max_tokens: int | None = None,
183
+ tools: list[Tool] | None = None,
184
+ temperature: float | None = None,
185
+ ) -> AsyncIterator[StreamChunk]:
186
+ """
187
+ Stream a chat response from Ollama.
188
+
189
+ This method yields response chunks in real-time as they are generated,
190
+ enabling progressive display in chat interfaces.
191
+
192
+ Args:
193
+ messages: Conversation messages (ChatMessage format)
194
+ response_format: "json" for JSON output, "text" for plain text, or a Pydantic
195
+ BaseModel class for JSON Schema-based structured output. When a Pydantic
196
+ model is provided, the LLM will be instructed to return JSON matching the
197
+ schema.
198
+ max_tokens: Maximum tokens to generate (optional)
199
+ tools: List of tools available for the LLM to call (optional, may not work
200
+ with all streaming scenarios)
201
+ temperature: Temperature for this request (optional, overrides instance temperature)
202
+
203
+ Yields:
204
+ StreamChunk objects containing content fragments as tokens are generated.
205
+
206
+ Raises:
207
+ ResponseError: If the request could not be fulfilled
208
+ RequestError: If the request was invalid
209
+
210
+ Examples:
211
+ >>> async for chunk in provider.stream([UserMessage(content="Hello")]):
212
+ ... print(chunk.content, end="", flush=True)
213
+ """
214
+ # Convert messages to Ollama format using converter (async for image support)
215
+ chat_messages = await convert_messages_to_ollama(messages)
216
+ logger.debug(f"Converted {len(messages)} messages to Ollama format for streaming")
217
+
218
+ # Use provided temperature or fall back to instance temperature
219
+ temp = temperature if temperature is not None else self.temperature
220
+
221
+ # Build options
222
+ options: dict[str, Any] = {}
223
+ if temp is not None:
224
+ options["temperature"] = temp
225
+ if max_tokens:
226
+ options["num_predict"] = max_tokens
227
+
228
+ # Build request kwargs
229
+ request_kwargs: dict[str, Any] = {
230
+ "model": self.model,
231
+ "messages": chat_messages,
232
+ "stream": True,
233
+ "options": options,
234
+ }
235
+
236
+ # Handle response_format: "json", "text", or Pydantic model class
237
+ if response_format == "json":
238
+ request_kwargs["format"] = "json"
239
+ elif isinstance(response_format, type) and issubclass(response_format, BaseModel):
240
+ # Extract JSON Schema from Pydantic model and pass directly to format
241
+ schema = response_format.model_json_schema()
242
+ request_kwargs["format"] = schema
243
+ logger.debug(f"Using JSON Schema from Pydantic model: {response_format.__name__}")
244
+ # "text" is the default - no format parameter needed
245
+
246
+ # Add tools if provided
247
+ if tools:
248
+ converted_tools = tools_to_ollama(tools)
249
+ request_kwargs["tools"] = converted_tools
250
+ logger.debug(f"Added {len(converted_tools)} tools to streaming request")
251
+
252
+ logger.debug(f"Starting stream with model {self.model}")
253
+ stream = await self.client.chat(**request_kwargs)
254
+
255
+ async for chunk in stream:
256
+ # Extract content from the message if present
257
+ if chunk.message and chunk.message.content:
258
+ content = chunk.message.content
259
+ # Ollama uses 'done' field to indicate completion
260
+ finish_reason = "stop" if getattr(chunk, "done", False) else None
261
+ yield StreamChunk(content=content, finish_reason=finish_reason)
262
+
263
+ logger.debug("Stream completed")
@@ -5,11 +5,11 @@ OpenAI LLM provider (compatible with OpenAI API and compatible services).
5
5
  from __future__ import annotations
6
6
 
7
7
  import logging
8
- from typing import Literal, Any
8
+ from typing import Literal, Any, AsyncIterator
9
9
  from openai import AsyncOpenAI
10
10
  from pydantic import BaseModel
11
11
 
12
- from casual_llm.messages import ChatMessage, AssistantMessage
12
+ from casual_llm.messages import ChatMessage, AssistantMessage, StreamChunk
13
13
  from casual_llm.tools import Tool
14
14
  from casual_llm.usage import Usage
15
15
  from casual_llm.tool_converters import tools_to_openai
@@ -46,7 +46,8 @@ class OpenAIProvider:
46
46
  api_key: API key (optional, can use OPENAI_API_KEY env var)
47
47
  base_url: Base URL for API (e.g., "https://openrouter.ai/api/v1")
48
48
  organization: OpenAI organization ID (optional)
49
- temperature: Temperature for generation (0.0-1.0, optional - uses OpenAI default if not set)
49
+ temperature: Temperature for generation (0.0-1.0, optional - uses OpenAI
50
+ default if not set)
50
51
  timeout: HTTP request timeout in seconds
51
52
  extra_kwargs: Additional kwargs to pass to client.chat.completions.create()
52
53
  """
@@ -191,3 +192,96 @@ class OpenAIProvider:
191
192
  content = response_message.content or ""
192
193
  logger.debug(f"Generated {len(content)} characters")
193
194
  return AssistantMessage(content=content, tool_calls=tool_calls)
195
+
196
+ async def stream(
197
+ self,
198
+ messages: list[ChatMessage],
199
+ response_format: Literal["json", "text"] | type[BaseModel] = "text",
200
+ max_tokens: int | None = None,
201
+ tools: list[Tool] | None = None,
202
+ temperature: float | None = None,
203
+ ) -> AsyncIterator[StreamChunk]:
204
+ """
205
+ Stream a chat response from OpenAI API.
206
+
207
+ This method yields response chunks in real-time as they are generated,
208
+ enabling progressive display in chat interfaces.
209
+
210
+ Args:
211
+ messages: Conversation messages (ChatMessage format)
212
+ response_format: "json" for JSON output, "text" for plain text, or a Pydantic
213
+ BaseModel class for JSON Schema-based structured output. When a Pydantic
214
+ model is provided, the LLM will be instructed to return JSON matching the
215
+ schema.
216
+ max_tokens: Maximum tokens to generate (optional)
217
+ tools: List of tools available for the LLM to call (optional, may not work
218
+ with all streaming scenarios)
219
+ temperature: Temperature for this request (optional, overrides instance temperature)
220
+
221
+ Yields:
222
+ StreamChunk objects containing content fragments as tokens are generated.
223
+
224
+ Raises:
225
+ openai.OpenAIError: If request fails
226
+
227
+ Examples:
228
+ >>> async for chunk in provider.stream([UserMessage(content="Hello")]):
229
+ ... print(chunk.content, end="", flush=True)
230
+ """
231
+ # Convert messages to OpenAI format using converter
232
+ chat_messages = convert_messages_to_openai(messages)
233
+ logger.debug(f"Converted {len(messages)} messages to OpenAI format for streaming")
234
+
235
+ # Use provided temperature or fall back to instance temperature
236
+ temp = temperature if temperature is not None else self.temperature
237
+
238
+ # Build request kwargs
239
+ request_kwargs: dict[str, Any] = {
240
+ "model": self.model,
241
+ "messages": chat_messages,
242
+ "stream": True,
243
+ }
244
+
245
+ # Only add temperature if specified
246
+ if temp is not None:
247
+ request_kwargs["temperature"] = temp
248
+
249
+ # Handle response_format: "json", "text", or Pydantic model class
250
+ if response_format == "json":
251
+ request_kwargs["response_format"] = {"type": "json_object"}
252
+ elif isinstance(response_format, type) and issubclass(response_format, BaseModel):
253
+ # Extract JSON Schema from Pydantic model
254
+ schema = response_format.model_json_schema()
255
+ request_kwargs["response_format"] = {
256
+ "type": "json_schema",
257
+ "json_schema": {
258
+ "name": response_format.__name__,
259
+ "schema": schema,
260
+ },
261
+ }
262
+ logger.debug(f"Using JSON Schema from Pydantic model: {response_format.__name__}")
263
+ # "text" is the default - no response_format needed
264
+
265
+ if max_tokens:
266
+ request_kwargs["max_tokens"] = max_tokens
267
+
268
+ # Add tools if provided
269
+ if tools:
270
+ converted_tools = tools_to_openai(tools)
271
+ request_kwargs["tools"] = converted_tools
272
+ logger.debug(f"Added {len(converted_tools)} tools to streaming request")
273
+
274
+ # Merge extra kwargs
275
+ request_kwargs.update(self.extra_kwargs)
276
+
277
+ logger.debug(f"Starting stream with model {self.model}")
278
+ stream = await self.client.chat.completions.create(**request_kwargs)
279
+
280
+ async for chunk in stream:
281
+ # Extract content from the delta if present
282
+ if chunk.choices and chunk.choices[0].delta.content:
283
+ content = chunk.choices[0].delta.content
284
+ finish_reason = chunk.choices[0].finish_reason
285
+ yield StreamChunk(content=content, finish_reason=finish_reason)
286
+
287
+ logger.debug("Stream completed")
@@ -0,0 +1,9 @@
1
+ """
2
+ Utility modules for casual-llm.
3
+ """
4
+
5
+ from casual_llm.utils.image import fetch_image_as_base64
6
+
7
+ __all__ = [
8
+ "fetch_image_as_base64",
9
+ ]
@@ -0,0 +1,162 @@
1
+ """
2
+ Image utilities for fetching and processing images.
3
+
4
+ Provides async utilities for downloading images from URLs and converting
5
+ them to base64 format for use in multimodal LLM messages.
6
+ """
7
+
8
+ import base64
9
+
10
+ try:
11
+ import httpx
12
+
13
+ HTTPX_AVAILABLE = True
14
+ except ImportError:
15
+ HTTPX_AVAILABLE = False
16
+
17
+
18
+ class ImageFetchError(Exception):
19
+ """Raised when image fetching fails."""
20
+
21
+ pass
22
+
23
+
24
+ # Default timeout for image fetching (in seconds)
25
+ DEFAULT_TIMEOUT = 30.0
26
+
27
+ # Maximum image size in bytes (10 MB)
28
+ MAX_IMAGE_SIZE = 10 * 1024 * 1024
29
+
30
+
31
+ async def fetch_image_as_base64(
32
+ url: str,
33
+ timeout: float = DEFAULT_TIMEOUT,
34
+ max_size: int = MAX_IMAGE_SIZE,
35
+ ) -> tuple[str, str]:
36
+ """Fetch an image from a URL and return it as base64-encoded data.
37
+
38
+ Downloads the image from the given URL and returns the base64-encoded
39
+ content along with the detected media type.
40
+
41
+ Args:
42
+ url: The URL of the image to fetch.
43
+ timeout: Request timeout in seconds. Defaults to 30 seconds.
44
+ max_size: Maximum allowed image size in bytes. Defaults to 10 MB.
45
+
46
+ Returns:
47
+ A tuple of (base64_data, media_type) where:
48
+ - base64_data: The raw base64-encoded image data (no data: prefix)
49
+ - media_type: The MIME type of the image (e.g., "image/jpeg")
50
+
51
+ Raises:
52
+ ImageFetchError: If the image cannot be fetched, is too large,
53
+ or if httpx is not installed.
54
+
55
+ Example:
56
+ >>> base64_data, media_type = await fetch_image_as_base64(
57
+ ... "https://example.com/image.jpg"
58
+ ... )
59
+ >>> print(media_type)
60
+ image/jpeg
61
+ """
62
+ if not HTTPX_AVAILABLE:
63
+ raise ImageFetchError(
64
+ "httpx is required for fetching images from URLs. "
65
+ "Install it with: pip install 'httpx[http2]'"
66
+ )
67
+
68
+ try:
69
+ # Use a browser-like User-Agent and HTTP/2 to avoid being blocked by sites like Wikipedia
70
+ headers = {
71
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
72
+ }
73
+ async with httpx.AsyncClient(timeout=timeout, headers=headers, http2=True) as client:
74
+ response = await client.get(url)
75
+ response.raise_for_status()
76
+
77
+ # Check content length if available
78
+ content_length = response.headers.get("content-length")
79
+ if content_length and int(content_length) > max_size:
80
+ raise ImageFetchError(
81
+ f"Image size ({int(content_length)} bytes) exceeds "
82
+ f"maximum allowed size ({max_size} bytes)"
83
+ )
84
+
85
+ # Read content and check actual size
86
+ content = response.content
87
+ if len(content) > max_size:
88
+ raise ImageFetchError(
89
+ f"Image size ({len(content)} bytes) exceeds "
90
+ f"maximum allowed size ({max_size} bytes)"
91
+ )
92
+
93
+ # Extract media type from Content-Type header
94
+ content_type = response.headers.get("content-type", "image/jpeg")
95
+ # Remove any charset or boundary info (e.g., "image/jpeg; charset=utf-8")
96
+ media_type = content_type.split(";")[0].strip()
97
+
98
+ # Validate that it looks like an image type
99
+ if not media_type.startswith("image/"):
100
+ # Default to image/jpeg if content-type doesn't indicate an image
101
+ media_type = "image/jpeg"
102
+
103
+ # Encode to base64
104
+ base64_data = base64.b64encode(content).decode("ascii")
105
+
106
+ return base64_data, media_type
107
+
108
+ except httpx.HTTPStatusError as e:
109
+ raise ImageFetchError(
110
+ f"HTTP error fetching image from {url}: {e.response.status_code}"
111
+ ) from e
112
+ except httpx.TimeoutException as e:
113
+ raise ImageFetchError(f"Timeout fetching image from {url}") from e
114
+ except httpx.RequestError as e:
115
+ raise ImageFetchError(f"Error fetching image from {url}: {e}") from e
116
+
117
+
118
+ def strip_base64_prefix(data: str) -> str:
119
+ """Strip the data URI prefix from a base64-encoded string.
120
+
121
+ Removes the 'data:<media_type>;base64,' prefix if present,
122
+ returning only the raw base64 data.
123
+
124
+ Args:
125
+ data: A base64 string, optionally with a data URI prefix.
126
+
127
+ Returns:
128
+ The raw base64 data without any prefix.
129
+
130
+ Example:
131
+ >>> strip_base64_prefix("data:image/png;base64,abc123")
132
+ 'abc123'
133
+ >>> strip_base64_prefix("abc123")
134
+ 'abc123'
135
+ """
136
+ # Check for data URI format: data:<media_type>;base64,<data>
137
+ if data.startswith("data:") and ";base64," in data:
138
+ # Split on ";base64," and return the data portion
139
+ return data.split(";base64,", 1)[1]
140
+ return data
141
+
142
+
143
+ def add_base64_prefix(base64_data: str, media_type: str = "image/png") -> str:
144
+ """Add a data URI prefix to raw base64 data.
145
+
146
+ Creates a complete data URI by prepending the appropriate prefix
147
+ to raw base64-encoded data.
148
+
149
+ Args:
150
+ base64_data: The raw base64-encoded data (without prefix).
151
+ media_type: The MIME type of the data. Defaults to "image/png".
152
+
153
+ Returns:
154
+ A complete data URI string.
155
+
156
+ Example:
157
+ >>> add_base64_prefix("abc123", "image/png")
158
+ 'data:image/png;base64,abc123'
159
+ >>> add_base64_prefix("xyz789", "image/jpeg")
160
+ 'data:image/jpeg;base64,xyz789'
161
+ """
162
+ return f"data:{media_type};base64,{base64_data}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: casual-llm
3
- Version: 0.2.0
3
+ Version: 0.3.0
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,6 +23,7 @@ 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"
28
29
  Dynamic: license-file
@@ -0,0 +1,23 @@
1
+ casual_llm/__init__.py,sha256=z6NcQ71k1nRY5bPYeBLcpyEsFJ3RIEpoxEq4xgO5lB8,2117
2
+ casual_llm/config.py,sha256=ofGJeHfbJupGSSdMZlsLGZ3RH07A26nQso-4XDMBkVA,1808
3
+ casual_llm/messages.py,sha256=x593dOc2K1Yoj_nbqPP6oewKBSlwVZTUeWKpmJM2WlA,2881
4
+ casual_llm/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ casual_llm/tools.py,sha256=AQiGhjJgbwEVd0mHP2byqJ8U4F5XgTK0P9LAmkpdCpA,4045
6
+ casual_llm/usage.py,sha256=-_Dj_do4Fgn3TVrcohG68Giy6SmXaF0p7_GxzKEK2jw,936
7
+ casual_llm/message_converters/__init__.py,sha256=5cuQLm2A7hfzZipoDXItdgJqLLME1KEYCE5dvuqyQ48,608
8
+ casual_llm/message_converters/ollama.py,sha256=PKy0jexhWHnYsuZBrU55W80RyUL5RZyTrwqWWono3Yg,8006
9
+ casual_llm/message_converters/openai.py,sha256=UMj11jCZRj4B1hlIGs_ORkrZjJFs2-ZcMzZNRV_5vxk,5706
10
+ casual_llm/providers/__init__.py,sha256=lVsN9GqU1cQjIEJEL6DbsIzPkknBDggx-_5q_yxtDAA,2525
11
+ casual_llm/providers/base.py,sha256=W7Heb0nW0RfiGkBv2qYb3jV6RfCZyvrou6Vf8X4RkLg,5483
12
+ casual_llm/providers/ollama.py,sha256=en3NuUgVMYCWlFsUT1WRwh_vdfsU4puuESyUu3EkhCE,10390
13
+ casual_llm/providers/openai.py,sha256=NSbCwGac6dOdrf3vG9BnurlJkMiZixubia0K6OWq8FY,11059
14
+ casual_llm/tool_converters/__init__.py,sha256=4FN_r7drXk_prc2EOqrXfO8DNOAZwB0w3qvqhySQnXY,635
15
+ casual_llm/tool_converters/ollama.py,sha256=jaSco33-Px_KPUIzqIQC3BqkXG8180fyMeet3b7OUdo,1926
16
+ casual_llm/tool_converters/openai.py,sha256=n4Yi3rN19CwhwevwNb_XC4RagFrS3HohkUsu5A7b6Hw,1880
17
+ casual_llm/utils/__init__.py,sha256=E-XvHlYDjMidAW--CRVJHwvNnUJkiL88G9mVq1w71o0,142
18
+ casual_llm/utils/image.py,sha256=nlE7CoPSfc0cFUrFMzyVefJ8nAN3FAHYDicAIA4YK8I,5465
19
+ casual_llm-0.3.0.dist-info/licenses/LICENSE,sha256=-PvmTd5xNNGaePz8xckUDBswdIrPWi4L6m7EsyKLmb8,1072
20
+ casual_llm-0.3.0.dist-info/METADATA,sha256=CUF-Q7L3_HFWUZ6d7r3IiusTuxAj5LkiyXDeAqcNmRM,9883
21
+ casual_llm-0.3.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
22
+ casual_llm-0.3.0.dist-info/top_level.txt,sha256=KKXzOGm04MbK756RPCswL3zeJ6Z-DvaCWy48Mch3HAo,11
23
+ casual_llm-0.3.0.dist-info/RECORD,,
@@ -1,21 +0,0 @@
1
- casual_llm/__init__.py,sha256=i8lKHDY8NWGDwozd5q52FtzfCkp5XPmxi0PwUxtVJ24,1945
2
- casual_llm/config.py,sha256=ofGJeHfbJupGSSdMZlsLGZ3RH07A26nQso-4XDMBkVA,1808
3
- casual_llm/messages.py,sha256=vLx46YzBn_P8UEplKq6vVe1oF2sJpQuJ67u2gU1asJQ,1436
4
- casual_llm/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
- casual_llm/tools.py,sha256=AQiGhjJgbwEVd0mHP2byqJ8U4F5XgTK0P9LAmkpdCpA,4045
6
- casual_llm/usage.py,sha256=-_Dj_do4Fgn3TVrcohG68Giy6SmXaF0p7_GxzKEK2jw,936
7
- casual_llm/message_converters/__init__.py,sha256=5cuQLm2A7hfzZipoDXItdgJqLLME1KEYCE5dvuqyQ48,608
8
- casual_llm/message_converters/ollama.py,sha256=Py4S22ObZcPmAAYMjyAlYMUeiGNxvahpBR0E6KTtm0g,5076
9
- casual_llm/message_converters/openai.py,sha256=4KLgiDpmlcBnG4tRH_lRhUAYvuQKcB6J_1sW6ym6C7E,4028
10
- casual_llm/providers/__init__.py,sha256=lVsN9GqU1cQjIEJEL6DbsIzPkknBDggx-_5q_yxtDAA,2525
11
- casual_llm/providers/base.py,sha256=K4aiOiiCvAQ-mXnlurmo-gaX0fRrnrFLjdR0t7TXeuQ,3383
12
- casual_llm/providers/ollama.py,sha256=fC2gmHvFn5Og9T8Ge6_e1gGEc_W15X28S4ay3TD8kL0,6492
13
- casual_llm/providers/openai.py,sha256=Fg4YhEoXWxryorFJfDdFbTTcESyY-KNzgJwWQ_iJ-P0,7098
14
- casual_llm/tool_converters/__init__.py,sha256=4FN_r7drXk_prc2EOqrXfO8DNOAZwB0w3qvqhySQnXY,635
15
- casual_llm/tool_converters/ollama.py,sha256=jaSco33-Px_KPUIzqIQC3BqkXG8180fyMeet3b7OUdo,1926
16
- casual_llm/tool_converters/openai.py,sha256=n4Yi3rN19CwhwevwNb_XC4RagFrS3HohkUsu5A7b6Hw,1880
17
- casual_llm-0.2.0.dist-info/licenses/LICENSE,sha256=-PvmTd5xNNGaePz8xckUDBswdIrPWi4L6m7EsyKLmb8,1072
18
- casual_llm-0.2.0.dist-info/METADATA,sha256=mOZxtsvp_S8C_VTm-JEycQ_zD5b-x2ruYMwYnE57F34,9847
19
- casual_llm-0.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
20
- casual_llm-0.2.0.dist-info/top_level.txt,sha256=KKXzOGm04MbK756RPCswL3zeJ6Z-DvaCWy48Mch3HAo,11
21
- casual_llm-0.2.0.dist-info/RECORD,,