llms-py 3.0.0b1__py3-none-any.whl → 3.0.0b3__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.
Files changed (63) hide show
  1. llms/__pycache__/__init__.cpython-312.pyc +0 -0
  2. llms/__pycache__/__init__.cpython-313.pyc +0 -0
  3. llms/__pycache__/__init__.cpython-314.pyc +0 -0
  4. llms/__pycache__/__main__.cpython-312.pyc +0 -0
  5. llms/__pycache__/__main__.cpython-314.pyc +0 -0
  6. llms/__pycache__/llms.cpython-312.pyc +0 -0
  7. llms/__pycache__/main.cpython-312.pyc +0 -0
  8. llms/__pycache__/main.cpython-313.pyc +0 -0
  9. llms/__pycache__/main.cpython-314.pyc +0 -0
  10. llms/__pycache__/plugins.cpython-314.pyc +0 -0
  11. llms/index.html +27 -57
  12. llms/llms.json +48 -15
  13. llms/main.py +923 -624
  14. llms/providers/__pycache__/anthropic.cpython-314.pyc +0 -0
  15. llms/providers/__pycache__/chutes.cpython-314.pyc +0 -0
  16. llms/providers/__pycache__/google.cpython-314.pyc +0 -0
  17. llms/providers/__pycache__/nvidia.cpython-314.pyc +0 -0
  18. llms/providers/__pycache__/openai.cpython-314.pyc +0 -0
  19. llms/providers/__pycache__/openrouter.cpython-314.pyc +0 -0
  20. llms/providers/anthropic.py +189 -0
  21. llms/providers/chutes.py +152 -0
  22. llms/providers/google.py +306 -0
  23. llms/providers/nvidia.py +107 -0
  24. llms/providers/openai.py +159 -0
  25. llms/providers/openrouter.py +70 -0
  26. llms/providers-extra.json +356 -0
  27. llms/providers.json +1 -1
  28. llms/ui/App.mjs +150 -57
  29. llms/ui/ai.mjs +84 -50
  30. llms/ui/app.css +1 -4963
  31. llms/ui/ctx.mjs +196 -0
  32. llms/ui/index.mjs +117 -0
  33. llms/ui/lib/charts.mjs +9 -13
  34. llms/ui/markdown.mjs +6 -0
  35. llms/ui/{Analytics.mjs → modules/analytics.mjs} +76 -64
  36. llms/ui/{Main.mjs → modules/chat/ChatBody.mjs} +91 -179
  37. llms/ui/{SettingsDialog.mjs → modules/chat/SettingsDialog.mjs} +8 -8
  38. llms/ui/{ChatPrompt.mjs → modules/chat/index.mjs} +281 -96
  39. llms/ui/modules/layout.mjs +267 -0
  40. llms/ui/modules/model-selector.mjs +851 -0
  41. llms/ui/{Recents.mjs → modules/threads/Recents.mjs} +10 -11
  42. llms/ui/{Sidebar.mjs → modules/threads/index.mjs} +48 -45
  43. llms/ui/{threadStore.mjs → modules/threads/threadStore.mjs} +21 -7
  44. llms/ui/tailwind.input.css +441 -79
  45. llms/ui/utils.mjs +83 -123
  46. {llms_py-3.0.0b1.dist-info → llms_py-3.0.0b3.dist-info}/METADATA +1 -1
  47. llms_py-3.0.0b3.dist-info/RECORD +65 -0
  48. llms/ui/Avatar.mjs +0 -85
  49. llms/ui/Brand.mjs +0 -52
  50. llms/ui/ModelSelector.mjs +0 -693
  51. llms/ui/OAuthSignIn.mjs +0 -92
  52. llms/ui/ProviderIcon.mjs +0 -36
  53. llms/ui/ProviderStatus.mjs +0 -105
  54. llms/ui/SignIn.mjs +0 -64
  55. llms/ui/SystemPromptEditor.mjs +0 -31
  56. llms/ui/SystemPromptSelector.mjs +0 -56
  57. llms/ui/Welcome.mjs +0 -8
  58. llms/ui.json +0 -1069
  59. llms_py-3.0.0b1.dist-info/RECORD +0 -49
  60. {llms_py-3.0.0b1.dist-info → llms_py-3.0.0b3.dist-info}/WHEEL +0 -0
  61. {llms_py-3.0.0b1.dist-info → llms_py-3.0.0b3.dist-info}/entry_points.txt +0 -0
  62. {llms_py-3.0.0b1.dist-info → llms_py-3.0.0b3.dist-info}/licenses/LICENSE +0 -0
  63. {llms_py-3.0.0b1.dist-info → llms_py-3.0.0b3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,189 @@
1
+ import json
2
+ import time
3
+
4
+ import aiohttp
5
+
6
+
7
+ def install(ctx):
8
+ from llms.main import OpenAiCompatible
9
+
10
+ class AnthropicProvider(OpenAiCompatible):
11
+ sdk = "@ai-sdk/anthropic"
12
+
13
+ def __init__(self, **kwargs):
14
+ if "api" not in kwargs:
15
+ kwargs["api"] = "https://api.anthropic.com/v1"
16
+ super().__init__(**kwargs)
17
+
18
+ # Anthropic uses x-api-key header instead of Authorization
19
+ if self.api_key:
20
+ self.headers = self.headers.copy()
21
+ if "Authorization" in self.headers:
22
+ del self.headers["Authorization"]
23
+ self.headers["x-api-key"] = self.api_key
24
+
25
+ if "anthropic-version" not in self.headers:
26
+ self.headers = self.headers.copy()
27
+ self.headers["anthropic-version"] = "2023-06-01"
28
+ self.chat_url = f"{self.api}/messages"
29
+
30
+ async def chat(self, chat):
31
+ chat["model"] = self.provider_model(chat["model"]) or chat["model"]
32
+
33
+ chat = await self.process_chat(chat, provider_id=self.id)
34
+
35
+ # Transform OpenAI format to Anthropic format
36
+ anthropic_request = {
37
+ "model": chat["model"],
38
+ "messages": [],
39
+ }
40
+
41
+ # Extract system message (Anthropic uses top-level 'system' parameter)
42
+ system_messages = []
43
+ for message in chat.get("messages", []):
44
+ if message.get("role") == "system":
45
+ content = message.get("content", "")
46
+ if isinstance(content, str):
47
+ system_messages.append(content)
48
+ elif isinstance(content, list):
49
+ for item in content:
50
+ if item.get("type") == "text":
51
+ system_messages.append(item.get("text", ""))
52
+
53
+ if system_messages:
54
+ anthropic_request["system"] = "\n".join(system_messages)
55
+
56
+ # Transform messages (exclude system messages)
57
+ for message in chat.get("messages", []):
58
+ if message.get("role") == "system":
59
+ continue
60
+
61
+ anthropic_message = {"role": message.get("role"), "content": []}
62
+
63
+ content = message.get("content", "")
64
+ if isinstance(content, str):
65
+ anthropic_message["content"] = content
66
+ elif isinstance(content, list):
67
+ for item in content:
68
+ if item.get("type") == "text":
69
+ anthropic_message["content"].append({"type": "text", "text": item.get("text", "")})
70
+ elif item.get("type") == "image_url" and "image_url" in item:
71
+ # Transform OpenAI image_url format to Anthropic format
72
+ image_url = item["image_url"].get("url", "")
73
+ if image_url.startswith("data:"):
74
+ # Extract media type and base64 data
75
+ parts = image_url.split(";base64,", 1)
76
+ if len(parts) == 2:
77
+ media_type = parts[0].replace("data:", "")
78
+ base64_data = parts[1]
79
+ anthropic_message["content"].append(
80
+ {
81
+ "type": "image",
82
+ "source": {"type": "base64", "media_type": media_type, "data": base64_data},
83
+ }
84
+ )
85
+
86
+ anthropic_request["messages"].append(anthropic_message)
87
+
88
+ # Handle max_tokens (required by Anthropic, uses max_tokens not max_completion_tokens)
89
+ if "max_completion_tokens" in chat:
90
+ anthropic_request["max_tokens"] = chat["max_completion_tokens"]
91
+ elif "max_tokens" in chat:
92
+ anthropic_request["max_tokens"] = chat["max_tokens"]
93
+ else:
94
+ # Anthropic requires max_tokens, set a default
95
+ anthropic_request["max_tokens"] = 4096
96
+
97
+ # Copy other supported parameters
98
+ if "temperature" in chat:
99
+ anthropic_request["temperature"] = chat["temperature"]
100
+ if "top_p" in chat:
101
+ anthropic_request["top_p"] = chat["top_p"]
102
+ if "top_k" in chat:
103
+ anthropic_request["top_k"] = chat["top_k"]
104
+ if "stop" in chat:
105
+ anthropic_request["stop_sequences"] = chat["stop"] if isinstance(chat["stop"], list) else [chat["stop"]]
106
+ if "stream" in chat:
107
+ anthropic_request["stream"] = chat["stream"]
108
+ if "tools" in chat:
109
+ anthropic_request["tools"] = chat["tools"]
110
+ if "tool_choice" in chat:
111
+ anthropic_request["tool_choice"] = chat["tool_choice"]
112
+
113
+ ctx.log(f"POST {self.chat_url}")
114
+ ctx.log(json.dumps(anthropic_request, indent=2))
115
+
116
+ async with aiohttp.ClientSession() as session:
117
+ started_at = time.time()
118
+ async with session.post(
119
+ self.chat_url,
120
+ headers=self.headers,
121
+ data=json.dumps(anthropic_request),
122
+ timeout=aiohttp.ClientTimeout(total=120),
123
+ ) as response:
124
+ return ctx.log_json(self.to_response(await self.response_json(response), chat, started_at))
125
+
126
+ def to_response(self, response, chat, started_at):
127
+ """Convert Anthropic response format to OpenAI-compatible format."""
128
+ # Transform Anthropic response to OpenAI format
129
+ ret = {
130
+ "id": response.get("id", ""),
131
+ "object": "chat.completion",
132
+ "created": int(started_at),
133
+ "model": response.get("model", ""),
134
+ "choices": [],
135
+ "usage": {},
136
+ }
137
+
138
+ # Transform content blocks to message content
139
+ content_parts = []
140
+ thinking_parts = []
141
+
142
+ for block in response.get("content", []):
143
+ if block.get("type") == "text":
144
+ content_parts.append(block.get("text", ""))
145
+ elif block.get("type") == "thinking":
146
+ # Store thinking blocks separately (some models include reasoning)
147
+ thinking_parts.append(block.get("thinking", ""))
148
+
149
+ # Combine all text content
150
+ message_content = "\n".join(content_parts) if content_parts else ""
151
+
152
+ # Create the choice object
153
+ choice = {
154
+ "index": 0,
155
+ "message": {"role": "assistant", "content": message_content},
156
+ "finish_reason": response.get("stop_reason", "stop"),
157
+ }
158
+
159
+ # Add thinking as metadata if present
160
+ if thinking_parts:
161
+ choice["message"]["thinking"] = "\n".join(thinking_parts)
162
+
163
+ ret["choices"].append(choice)
164
+
165
+ # Transform usage
166
+ if "usage" in response:
167
+ usage = response["usage"]
168
+ ret["usage"] = {
169
+ "prompt_tokens": usage.get("input_tokens", 0),
170
+ "completion_tokens": usage.get("output_tokens", 0),
171
+ "total_tokens": usage.get("input_tokens", 0) + usage.get("output_tokens", 0),
172
+ }
173
+
174
+ # Add metadata
175
+ if "metadata" not in ret:
176
+ ret["metadata"] = {}
177
+ ret["metadata"]["duration"] = int((time.time() - started_at) * 1000)
178
+
179
+ if chat is not None and "model" in chat:
180
+ cost = self.model_cost(chat["model"])
181
+ if cost and "input" in cost and "output" in cost:
182
+ ret["metadata"]["pricing"] = f"{cost['input']}/{cost['output']}"
183
+
184
+ return ret
185
+
186
+ ctx.add_provider(AnthropicProvider)
187
+
188
+
189
+ __install__ = install
@@ -0,0 +1,152 @@
1
+ import json
2
+ import mimetypes
3
+ import time
4
+
5
+ import aiohttp
6
+
7
+
8
+ def install(ctx):
9
+ from llms.main import GeneratorBase
10
+
11
+ class ChutesImage(GeneratorBase):
12
+ sdk = "chutes/image"
13
+
14
+ def __init__(self, **kwargs):
15
+ super().__init__(**kwargs)
16
+ self.width = int(kwargs.get("width", 1024))
17
+ self.height = int(kwargs.get("height", 1024))
18
+ self.cfg_scale = float(kwargs.get("cfg_scale", 7.5))
19
+ self.steps = int(kwargs.get("steps", 50))
20
+ self.negative_prompt = kwargs.get("negative_prompt", "blur, distortion, low quality")
21
+ self.gen_url = kwargs.get("api", "https://image.chutes.ai/generate")
22
+ self.model_resolutions = {
23
+ "chutes-hidream": {
24
+ "1:1": "1024x1024",
25
+ "9:16": "768x1360",
26
+ "16:9": "1360x768",
27
+ "3:4": "880x1168",
28
+ "4:3": "1168x880",
29
+ "2:3": "832x1248",
30
+ "3:2": "1248x832",
31
+ }
32
+ }
33
+ self.model_sizes = ["chutes-hunyuan-image-3"]
34
+ self.model_negative_prompt = [
35
+ "chroma",
36
+ "qwen-image-edit-2509",
37
+ "JuggernautXL-Ragnarok",
38
+ "JuggernautXL",
39
+ "Animij",
40
+ "iLustMix",
41
+ ]
42
+
43
+ async def chat(self, chat, provider=None):
44
+ headers = {"Authorization": f"Bearer {self.api_key}"}
45
+ if provider is not None:
46
+ headers["Authorization"] = f"Bearer {provider.api_key}"
47
+ chat["model"] = provider.provider_model(chat["model"]) or chat["model"]
48
+
49
+ aspect_ratio = "1:1"
50
+ if "messages" in chat and len(chat["messages"]) > 0:
51
+ aspect_ratio = chat["messages"][0].get("aspect_ratio", "1:1")
52
+ cfg_scale = self.cfg_scale
53
+ if chat["model"] == "chutes-z-image-turbo":
54
+ cfg_scale = min(self.cfg_scale, 5)
55
+ payload = {
56
+ "model": chat["model"],
57
+ "prompt": ctx.last_user_prompt(chat),
58
+ "guidance_scale": cfg_scale,
59
+ "width": self.width,
60
+ "height": self.height,
61
+ "num_inference_steps": self.steps,
62
+ }
63
+ if chat["model"] in self.model_negative_prompt:
64
+ payload["negative_prompt"] = self.negative_prompt
65
+
66
+ image_config = chat.get("image_config", {})
67
+ aspect_ratio = image_config.get("aspect_ratio")
68
+ if aspect_ratio:
69
+ dimension = ctx.app.aspect_ratios.get(aspect_ratio)
70
+ if dimension:
71
+ width, height = dimension.split("×")
72
+ payload["width"] = int(width)
73
+ payload["height"] = int(height)
74
+
75
+ if chat["model"] in self.model_resolutions:
76
+ # if models use resolution, remove width and height
77
+ del payload["width"]
78
+ del payload["height"]
79
+ resolution = self.model_resolutions[chat["model"]][aspect_ratio]
80
+ payload["resolution"] = resolution
81
+ elif chat["model"] in self.model_sizes:
82
+ del payload["width"]
83
+ del payload["height"]
84
+ payload["size"] = aspect_ratio
85
+
86
+ gen_url = self.gen_url
87
+ if chat["model"].startswith("chutes-"):
88
+ model = payload["model"]
89
+ gen_url = f"https://{model}.chutes.ai/generate"
90
+ del payload["model"]
91
+
92
+ ctx.log(f"POST {gen_url}")
93
+ ctx.log(json.dumps(payload, indent=2))
94
+ async with aiohttp.ClientSession() as session, session.post(
95
+ gen_url, headers=headers, json=payload
96
+ ) as response:
97
+ if response.status < 300:
98
+ image_data = await response.read()
99
+ content_type = response.headers.get("Content-Type")
100
+ if content_type:
101
+ ext = mimetypes.guess_extension(content_type)
102
+ if ext:
103
+ ext = ext.lstrip(".") # remove leading dot
104
+ if not ext:
105
+ ext = "png"
106
+
107
+ relative_url, info = ctx.save_image_to_cache(
108
+ image_data,
109
+ f"{chat['model']}.{ext}",
110
+ {
111
+ "model": chat["model"],
112
+ "prompt": ctx.last_user_prompt(chat),
113
+ "width": self.width,
114
+ "height": self.height,
115
+ "cfg_scale": self.cfg_scale,
116
+ "steps": self.steps,
117
+ },
118
+ )
119
+ return {
120
+ "choices": [
121
+ {
122
+ "message": {
123
+ "role": "assistant",
124
+ "content": self.default_content,
125
+ "images": [
126
+ {
127
+ "type": "image_url",
128
+ "image_url": {
129
+ "url": relative_url,
130
+ },
131
+ }
132
+ ],
133
+ }
134
+ }
135
+ ],
136
+ "created": int(time.time()),
137
+ }
138
+ else:
139
+ text = await response.text()
140
+ try:
141
+ data = json.loads(text)
142
+ ctx.log(data)
143
+ if "detail" in data:
144
+ raise Exception(data["detail"])
145
+ except json.JSONDecodeError:
146
+ pass
147
+ raise Exception(f"Failed to generate image {response.status}")
148
+
149
+ ctx.add_provider(ChutesImage)
150
+
151
+
152
+ __install__ = install
@@ -0,0 +1,306 @@
1
+ import json
2
+ import time
3
+
4
+ import aiohttp
5
+
6
+ # class GoogleOpenAiProvider(OpenAiCompatible):
7
+ # sdk = "google-openai-compatible"
8
+
9
+ # def __init__(self, api_key, **kwargs):
10
+ # super().__init__(api="https://generativelanguage.googleapis.com", api_key=api_key, **kwargs)
11
+ # self.chat_url = "https://generativelanguage.googleapis.com/v1beta/chat/completions"
12
+
13
+
14
+ def install(ctx):
15
+ from llms.main import OpenAiCompatible
16
+
17
+ def gemini_chat_summary(gemini_chat):
18
+ """Summarize Gemini chat completion request for logging. Replace inline_data with size of content only"""
19
+ clone = json.loads(json.dumps(gemini_chat))
20
+ for content in clone["contents"]:
21
+ for part in content["parts"]:
22
+ if "inline_data" in part:
23
+ data = part["inline_data"]["data"]
24
+ part["inline_data"]["data"] = f"({len(data)})"
25
+ return json.dumps(clone, indent=2)
26
+
27
+ def gemini_response_summary(obj):
28
+ to = {}
29
+ for k, v in obj.items():
30
+ if k == "candidates":
31
+ candidates = []
32
+ for candidate in v:
33
+ c = {}
34
+ for ck, cv in candidate.items():
35
+ if ck == "content":
36
+ content = {}
37
+ for content_k, content_v in cv.items():
38
+ if content_k == "parts":
39
+ parts = []
40
+ for part in content_v:
41
+ p = {}
42
+ for pk, pv in part.items():
43
+ if pk == "inlineData":
44
+ p[pk] = {
45
+ "mimeType": pv.get("mimeType"),
46
+ "data": f"({len(pv.get('data'))})",
47
+ }
48
+ else:
49
+ p[pk] = pv
50
+ parts.append(p)
51
+ content[content_k] = parts
52
+ else:
53
+ content[content_k] = content_v
54
+ c[ck] = content
55
+ else:
56
+ c[ck] = cv
57
+ candidates.append(c)
58
+ to[k] = candidates
59
+ else:
60
+ to[k] = v
61
+ return to
62
+
63
+ class GoogleProvider(OpenAiCompatible):
64
+ sdk = "@ai-sdk/google"
65
+
66
+ def __init__(self, **kwargs):
67
+ new_kwargs = {"api": "https://generativelanguage.googleapis.com", **kwargs}
68
+ super().__init__(**new_kwargs)
69
+ self.safety_settings = kwargs.get("safety_settings")
70
+ self.thinking_config = kwargs.get("thinking_config")
71
+ self.tools = kwargs.get("tools")
72
+ self.curl = kwargs.get("curl")
73
+ self.headers = kwargs.get("headers", {"Content-Type": "application/json"})
74
+ # Google fails when using Authorization header, use query string param instead
75
+ if "Authorization" in self.headers:
76
+ del self.headers["Authorization"]
77
+
78
+ async def chat(self, chat):
79
+ chat["model"] = self.provider_model(chat["model"]) or chat["model"]
80
+
81
+ chat = await self.process_chat(chat)
82
+ generation_config = {}
83
+
84
+ # Filter out system messages and convert to proper Gemini format
85
+ contents = []
86
+ system_prompt = None
87
+
88
+ async with aiohttp.ClientSession() as session:
89
+ for message in chat["messages"]:
90
+ if message["role"] == "system":
91
+ content = message["content"]
92
+ if isinstance(content, list):
93
+ for item in content:
94
+ if "text" in item:
95
+ system_prompt = item["text"]
96
+ break
97
+ elif isinstance(content, str):
98
+ system_prompt = content
99
+ elif "content" in message:
100
+ if isinstance(message["content"], list):
101
+ parts = []
102
+ for item in message["content"]:
103
+ if "type" in item:
104
+ if item["type"] == "image_url" and "image_url" in item:
105
+ image_url = item["image_url"]
106
+ if "url" not in image_url:
107
+ continue
108
+ url = image_url["url"]
109
+ if not url.startswith("data:"):
110
+ raise Exception("Image was not downloaded: " + url)
111
+ # Extract mime type from data uri
112
+ mimetype = url.split(";", 1)[0].split(":", 1)[1] if ";" in url else "image/png"
113
+ base64_data = url.split(",", 1)[1]
114
+ parts.append({"inline_data": {"mime_type": mimetype, "data": base64_data}})
115
+ elif item["type"] == "input_audio" and "input_audio" in item:
116
+ input_audio = item["input_audio"]
117
+ if "data" not in input_audio:
118
+ continue
119
+ data = input_audio["data"]
120
+ format = input_audio["format"]
121
+ mimetype = f"audio/{format}"
122
+ parts.append({"inline_data": {"mime_type": mimetype, "data": data}})
123
+ elif item["type"] == "file" and "file" in item:
124
+ file = item["file"]
125
+ if "file_data" not in file:
126
+ continue
127
+ data = file["file_data"]
128
+ if not data.startswith("data:"):
129
+ raise (Exception("File was not downloaded: " + data))
130
+ # Extract mime type from data uri
131
+ mimetype = (
132
+ data.split(";", 1)[0].split(":", 1)[1]
133
+ if ";" in data
134
+ else "application/octet-stream"
135
+ )
136
+ base64_data = data.split(",", 1)[1]
137
+ parts.append({"inline_data": {"mime_type": mimetype, "data": base64_data}})
138
+ if "text" in item:
139
+ text = item["text"]
140
+ parts.append({"text": text})
141
+ if len(parts) > 0:
142
+ contents.append(
143
+ {
144
+ "role": message["role"]
145
+ if "role" in message and message["role"] == "user"
146
+ else "model",
147
+ "parts": parts,
148
+ }
149
+ )
150
+ else:
151
+ content = message["content"]
152
+ contents.append(
153
+ {
154
+ "role": message["role"]
155
+ if "role" in message and message["role"] == "user"
156
+ else "model",
157
+ "parts": [{"text": content}],
158
+ }
159
+ )
160
+
161
+ gemini_chat = {
162
+ "contents": contents,
163
+ }
164
+
165
+ if self.safety_settings:
166
+ gemini_chat["safetySettings"] = self.safety_settings
167
+
168
+ # Add system instruction if present
169
+ if system_prompt is not None:
170
+ gemini_chat["systemInstruction"] = {"parts": [{"text": system_prompt}]}
171
+
172
+ if "max_completion_tokens" in chat:
173
+ generation_config["maxOutputTokens"] = chat["max_completion_tokens"]
174
+ if "stop" in chat:
175
+ generation_config["stopSequences"] = [chat["stop"]]
176
+ if "temperature" in chat:
177
+ generation_config["temperature"] = chat["temperature"]
178
+ if "top_p" in chat:
179
+ generation_config["topP"] = chat["top_p"]
180
+ if "top_logprobs" in chat:
181
+ generation_config["topK"] = chat["top_logprobs"]
182
+
183
+ if "thinkingConfig" in chat:
184
+ generation_config["thinkingConfig"] = chat["thinkingConfig"]
185
+ elif self.thinking_config:
186
+ generation_config["thinkingConfig"] = self.thinking_config
187
+
188
+ if len(generation_config) > 0:
189
+ gemini_chat["generationConfig"] = generation_config
190
+
191
+ if "tools" in chat:
192
+ gemini_chat["tools"] = chat["tools"]
193
+ elif self.tools:
194
+ gemini_chat["tools"] = self.tools.copy()
195
+
196
+ if "modalities" in chat:
197
+ generation_config["responseModalities"] = [modality.upper() for modality in chat["modalities"]]
198
+ if "image_config" in chat:
199
+ # delete thinkingConfig
200
+ del generation_config["thinkingConfig"]
201
+ config_map = {
202
+ "aspect_ratio": "aspectRatio",
203
+ "image_size": "imageSize",
204
+ }
205
+ generation_config["imageConfig"] = {
206
+ config_map[k]: v for k, v in chat["image_config"].items() if k in config_map
207
+ }
208
+
209
+ started_at = int(time.time() * 1000)
210
+ gemini_chat_url = f"https://generativelanguage.googleapis.com/v1beta/models/{chat['model']}:generateContent?key={self.api_key}"
211
+
212
+ ctx.log(f"POST {gemini_chat_url}")
213
+ ctx.log(gemini_chat_summary(gemini_chat))
214
+ started_at = time.time()
215
+
216
+ if ctx.MOCK and "modalities" in chat:
217
+ print("Mocking Google Gemini Image")
218
+ with open(f"{ctx.MOCK_DIR}/gemini-image.json") as f:
219
+ obj = json.load(f)
220
+ else:
221
+ async with session.post(
222
+ gemini_chat_url,
223
+ headers=self.headers,
224
+ data=json.dumps(gemini_chat),
225
+ timeout=aiohttp.ClientTimeout(total=120),
226
+ ) as res:
227
+ obj = await self.response_json(res)
228
+
229
+ if "error" in obj:
230
+ ctx.log(f"Error: {obj['error']}")
231
+ raise Exception(obj["error"]["message"])
232
+
233
+ if ctx.debug:
234
+ ctx.dbg(json.dumps(gemini_response_summary(obj), indent=2))
235
+
236
+ response = {
237
+ "id": f"chatcmpl-{started_at}",
238
+ "created": started_at,
239
+ "model": obj.get("modelVersion", chat["model"]),
240
+ }
241
+ choices = []
242
+ for i, candidate in enumerate(obj["candidates"]):
243
+ role = "assistant"
244
+ if "content" in candidate and "role" in candidate["content"]:
245
+ role = "assistant" if candidate["content"]["role"] == "model" else candidate["content"]["role"]
246
+
247
+ # Safely extract content from all text parts
248
+ content = ""
249
+ reasoning = ""
250
+ images = []
251
+ if "content" in candidate and "parts" in candidate["content"]:
252
+ text_parts = []
253
+ reasoning_parts = []
254
+ for part in candidate["content"]["parts"]:
255
+ if "text" in part:
256
+ if "thought" in part and part["thought"]:
257
+ reasoning_parts.append(part["text"])
258
+ else:
259
+ text_parts.append(part["text"])
260
+ if "inlineData" in part:
261
+ inline_data = part["inlineData"]
262
+ mime_type = inline_data.get("mimeType", "image/png")
263
+ ext = mime_type.split("/")[1]
264
+ base64_data = inline_data["data"]
265
+ filename = f"{chat['model'].split('/')[-1]}-{len(images)}.{ext}"
266
+ relative_url, info = ctx.save_image_to_cache(base64_data, filename, {})
267
+ images.append(
268
+ {
269
+ "type": "image_url",
270
+ "index": len(images),
271
+ "image_url": {
272
+ "url": relative_url,
273
+ },
274
+ }
275
+ )
276
+ content = " ".join(text_parts)
277
+ reasoning = " ".join(reasoning_parts)
278
+
279
+ choice = {
280
+ "index": i,
281
+ "finish_reason": candidate.get("finishReason", "stop"),
282
+ "message": {
283
+ "role": role,
284
+ "content": content,
285
+ },
286
+ }
287
+ if reasoning:
288
+ choice["message"]["reasoning"] = reasoning
289
+ if len(images) > 0:
290
+ choice["message"]["images"] = images
291
+ choices.append(choice)
292
+ response["choices"] = choices
293
+ if "usageMetadata" in obj:
294
+ usage = obj["usageMetadata"]
295
+ response["usage"] = {
296
+ "completion_tokens": usage["candidatesTokenCount"],
297
+ "total_tokens": usage["totalTokenCount"],
298
+ "prompt_tokens": usage["promptTokenCount"],
299
+ }
300
+
301
+ return ctx.log_json(self.to_response(response, chat, started_at))
302
+
303
+ ctx.add_provider(GoogleProvider)
304
+
305
+
306
+ __install__ = install