Skip to content

Commit 899a3fa

Browse files
committed
Add support for custom file upload limits and auto image conversion settings
1 parent 7e062b5 commit 899a3fa

4 files changed

Lines changed: 110 additions & 5 deletions

File tree

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@ Configure additional providers and models in [llms.json](llms/llms.json)
1010

1111
## Features
1212

13-
- **Lightweight**: Single [llms.py](https://github.com/ServiceStack/llms/blob/main/llms/main.py) Python file with single `aiohttp` dependency
13+
- **Lightweight**: Single [llms.py](https://github.com/ServiceStack/llms/blob/main/llms/main.py) Python file with single `aiohttp` dependency (Pillow optional)
1414
- **Multi-Provider Support**: OpenRouter, Ollama, Anthropic, Google, OpenAI, Grok, Groq, Qwen, Z.ai, Mistral
1515
- **OpenAI-Compatible API**: Works with any client that supports OpenAI's chat completion API
1616
- **Built-in Analytics**: Built-in analytics UI to visualize costs, requests, and token usage
1717
- **Configuration Management**: Easy provider enable/disable and configuration management
1818
- **CLI Interface**: Simple command-line interface for quick interactions
1919
- **Server Mode**: Run an OpenAI-compatible HTTP server at `http://localhost:{PORT}/v1/chat/completions`
2020
- **Image Support**: Process images through vision-capable models
21+
- Auto resizes and converts to webp if exceeds configured limits
2122
- **Audio Support**: Process audio through audio-capable models
2223
- **Custom Chat Templates**: Configurable chat completion request templates for different modalities
2324
- **Auto-Discovery**: Automatically discover available Ollama models
@@ -223,6 +224,8 @@ The configuration file [llms.json](llms/llms.json) is saved to `~/.llms/llms.jso
223224
- `audio`: Default chat completion request template for audio prompts
224225
- `file`: Default chat completion request template for file prompts
225226
- `check`: Check request template for testing provider connectivity
227+
- `limits`: Override Request size limits
228+
- `convert`: Max image size and length limits and auto conversion settings
226229

227230
### Providers
228231

@@ -1191,7 +1194,7 @@ This shows:
11911194
- `llms/main.py` - Main script with CLI and server functionality
11921195
- `llms/llms.json` - Default configuration file
11931196
- `llms/ui.json` - UI configuration file
1194-
- `requirements.txt` - Python dependencies (aiohttp)
1197+
- `requirements.txt` - Python dependencies, required: `aiohttp`, optional: `Pillow`
11951198

11961199
### Provider Classes
11971200

llms/llms.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,15 @@
105105
"stream": false
106106
}
107107
},
108+
"limits": {
109+
"client_max_size": 20971520
110+
},
111+
"convert": {
112+
"image": {
113+
"max_size": "1536x1024",
114+
"max_length": 1572864
115+
}
116+
},
108117
"providers": {
109118
"openrouter_free": {
110119
"enabled": true,

llms/main.py

Lines changed: 95 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import site
1717
import secrets
1818
import re
19+
from io import BytesIO
1920
from urllib.parse import parse_qs, urlencode
2021

2122
import aiohttp
@@ -24,6 +25,12 @@
2425
from pathlib import Path
2526
from importlib import resources # Py≥3.9 (pip install importlib_resources for 3.7/3.8)
2627

28+
try:
29+
from PIL import Image
30+
HAS_PIL = True
31+
except ImportError:
32+
HAS_PIL = False
33+
2734
VERSION = "2.0.27"
2835
_ROOT = None
2936
g_config_path = None
@@ -201,6 +208,77 @@ def price_to_string(price: float | int | str | None) -> str | None:
201208
except (ValueError, TypeError):
202209
return None
203210

211+
def convert_image_if_needed(image_bytes, mimetype='image/png'):
212+
"""
213+
Convert and resize image to WebP if it exceeds configured limits.
214+
215+
Args:
216+
image_bytes: Raw image bytes
217+
mimetype: Original image MIME type
218+
219+
Returns:
220+
tuple: (converted_bytes, new_mimetype) or (original_bytes, original_mimetype) if no conversion needed
221+
"""
222+
if not HAS_PIL:
223+
return image_bytes, mimetype
224+
225+
# Get conversion config
226+
convert_config = g_config.get('convert', {}).get('image', {}) if g_config else {}
227+
if not convert_config:
228+
return image_bytes, mimetype
229+
230+
max_size_str = convert_config.get('max_size', '1536x1024')
231+
max_length = convert_config.get('max_length', 1.5*1024*1024) # 1.5MB
232+
233+
try:
234+
# Parse max_size (e.g., "1536x1024")
235+
max_width, max_height = map(int, max_size_str.split('x'))
236+
237+
# Open image
238+
with Image.open(BytesIO(image_bytes)) as img:
239+
original_width, original_height = img.size
240+
241+
# Check if image exceeds limits
242+
needs_resize = original_width > max_width or original_height > max_height
243+
244+
# Check if base64 length would exceed max_length (in KB)
245+
# Base64 encoding increases size by ~33%, so check raw bytes * 1.33 / 1024
246+
estimated_kb = (len(image_bytes) * 1.33) / 1024
247+
needs_conversion = estimated_kb > max_length
248+
249+
if not needs_resize and not needs_conversion:
250+
return image_bytes, mimetype
251+
252+
# Convert RGBA to RGB if necessary (WebP doesn't support transparency in RGB mode)
253+
if img.mode in ('RGBA', 'LA', 'P'):
254+
# Create a white background
255+
background = Image.new('RGB', img.size, (255, 255, 255))
256+
if img.mode == 'P':
257+
img = img.convert('RGBA')
258+
background.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None)
259+
img = background
260+
elif img.mode != 'RGB':
261+
img = img.convert('RGB')
262+
263+
# Resize if needed (preserve aspect ratio)
264+
if needs_resize:
265+
img.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
266+
_log(f"Resized image from {original_width}x{original_height} to {img.size[0]}x{img.size[1]}")
267+
268+
# Convert to WebP
269+
output = BytesIO()
270+
img.save(output, format='WEBP', quality=85, method=6)
271+
converted_bytes = output.getvalue()
272+
273+
_log(f"Converted image to WebP: {len(image_bytes)} bytes -> {len(converted_bytes)} bytes ({len(converted_bytes)*100//len(image_bytes)}%)")
274+
275+
return converted_bytes, 'image/webp'
276+
277+
except Exception as e:
278+
_log(f"Error converting image: {e}")
279+
# Return original if conversion fails
280+
return image_bytes, mimetype
281+
204282
async def process_chat(chat):
205283
if not chat:
206284
raise Exception("No chat provided")
@@ -231,19 +309,31 @@ async def process_chat(chat):
231309
mimetype = get_file_mime_type(get_filename(url))
232310
if 'Content-Type' in response.headers:
233311
mimetype = response.headers['Content-Type']
312+
# convert/resize image if needed
313+
content, mimetype = convert_image_if_needed(content, mimetype)
234314
# convert to data uri
235315
image_url['url'] = f"data:{mimetype};base64,{base64.b64encode(content).decode('utf-8')}"
236316
elif is_file_path(url):
237317
_log(f"Reading image: {url}")
238318
with open(url, "rb") as f:
239319
content = f.read()
240-
ext = os.path.splitext(url)[1].lower().lstrip('.') if '.' in url else 'png'
241320
# get mimetype from file extension
242321
mimetype = get_file_mime_type(get_filename(url))
322+
# convert/resize image if needed
323+
content, mimetype = convert_image_if_needed(content, mimetype)
243324
# convert to data uri
244325
image_url['url'] = f"data:{mimetype};base64,{base64.b64encode(content).decode('utf-8')}"
245326
elif url.startswith('data:'):
246-
pass
327+
# Extract existing data URI and process it
328+
if ';base64,' in url:
329+
prefix = url.split(';base64,')[0]
330+
mimetype = prefix.split(':')[1] if ':' in prefix else 'image/png'
331+
base64_data = url.split(';base64,')[1]
332+
content = base64.b64decode(base64_data)
333+
# convert/resize image if needed
334+
content, mimetype = convert_image_if_needed(content, mimetype)
335+
# update data uri with potentially converted image
336+
image_url['url'] = f"data:{mimetype};base64,{base64.b64encode(content).decode('utf-8')}"
247337
else:
248338
raise Exception(f"Invalid image: {url}")
249339
elif item['type'] == 'input_audio' and 'input_audio' in item:
@@ -1481,7 +1571,9 @@ def main():
14811571

14821572
_log("Authentication enabled - GitHub OAuth configured")
14831573

1484-
app = web.Application()
1574+
client_max_size = g_config.get('limits', {}).get('client_max_size', 20*1024*1024) # 20MB max request size (to handle base64 encoding overhead)
1575+
_log(f"client_max_size set to {client_max_size} bytes ({client_max_size/1024/1024:.1f}MB)")
1576+
app = web.Application(client_max_size=client_max_size)
14851577

14861578
# Authentication middleware helper
14871579
def check_auth(request):

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
aiohttp
2+
Pillow

0 commit comments

Comments
 (0)