llms-py 3.0.0b2__py3-none-any.whl → 3.0.0b4__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.
- llms/__pycache__/main.cpython-314.pyc +0 -0
- llms/index.html +2 -1
- llms/llms.json +50 -17
- llms/main.py +484 -544
- llms/providers/__pycache__/anthropic.cpython-314.pyc +0 -0
- llms/providers/__pycache__/chutes.cpython-314.pyc +0 -0
- llms/providers/__pycache__/google.cpython-314.pyc +0 -0
- llms/providers/__pycache__/nvidia.cpython-314.pyc +0 -0
- llms/providers/__pycache__/openai.cpython-314.pyc +0 -0
- llms/providers/__pycache__/openrouter.cpython-314.pyc +0 -0
- llms/providers/anthropic.py +189 -0
- llms/providers/chutes.py +152 -0
- llms/providers/google.py +306 -0
- llms/providers/nvidia.py +107 -0
- llms/providers/openai.py +159 -0
- llms/providers/openrouter.py +70 -0
- llms/providers-extra.json +356 -0
- llms/providers.json +1 -1
- llms/ui/App.mjs +132 -60
- llms/ui/ai.mjs +76 -10
- llms/ui/app.css +65 -28
- llms/ui/ctx.mjs +196 -0
- llms/ui/index.mjs +75 -171
- llms/ui/lib/charts.mjs +9 -13
- llms/ui/markdown.mjs +6 -0
- llms/ui/{Analytics.mjs → modules/analytics.mjs} +76 -64
- llms/ui/{Main.mjs → modules/chat/ChatBody.mjs} +59 -135
- llms/ui/{SettingsDialog.mjs → modules/chat/SettingsDialog.mjs} +8 -8
- llms/ui/{ChatPrompt.mjs → modules/chat/index.mjs} +242 -46
- llms/ui/modules/layout.mjs +267 -0
- llms/ui/modules/model-selector.mjs +851 -0
- llms/ui/{Recents.mjs → modules/threads/Recents.mjs} +0 -2
- llms/ui/{Sidebar.mjs → modules/threads/index.mjs} +46 -44
- llms/ui/{threadStore.mjs → modules/threads/threadStore.mjs} +10 -7
- llms/ui/utils.mjs +82 -123
- {llms_py-3.0.0b2.dist-info → llms_py-3.0.0b4.dist-info}/METADATA +1 -1
- llms_py-3.0.0b4.dist-info/RECORD +65 -0
- llms/ui/Avatar.mjs +0 -86
- llms/ui/Brand.mjs +0 -52
- llms/ui/OAuthSignIn.mjs +0 -61
- llms/ui/ProviderIcon.mjs +0 -36
- llms/ui/ProviderStatus.mjs +0 -104
- llms/ui/SignIn.mjs +0 -65
- llms/ui/Welcome.mjs +0 -8
- llms/ui/model-selector.mjs +0 -686
- llms/ui.json +0 -1069
- llms_py-3.0.0b2.dist-info/RECORD +0 -58
- {llms_py-3.0.0b2.dist-info → llms_py-3.0.0b4.dist-info}/WHEEL +0 -0
- {llms_py-3.0.0b2.dist-info → llms_py-3.0.0b4.dist-info}/entry_points.txt +0 -0
- {llms_py-3.0.0b2.dist-info → llms_py-3.0.0b4.dist-info}/licenses/LICENSE +0 -0
- {llms_py-3.0.0b2.dist-info → llms_py-3.0.0b4.dist-info}/top_level.txt +0 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
llms/providers/chutes.py
ADDED
|
@@ -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
|
llms/providers/google.py
ADDED
|
@@ -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
|