prompture 0.0.38.dev2__py3-none-any.whl → 0.0.42__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 (46) hide show
  1. prompture/__init__.py +12 -1
  2. prompture/_version.py +2 -2
  3. prompture/agent.py +11 -11
  4. prompture/async_agent.py +11 -11
  5. prompture/async_conversation.py +9 -0
  6. prompture/async_core.py +16 -0
  7. prompture/async_driver.py +39 -0
  8. prompture/async_groups.py +63 -0
  9. prompture/conversation.py +9 -0
  10. prompture/core.py +16 -0
  11. prompture/cost_mixin.py +62 -0
  12. prompture/discovery.py +108 -43
  13. prompture/driver.py +39 -0
  14. prompture/drivers/__init__.py +39 -0
  15. prompture/drivers/async_azure_driver.py +7 -6
  16. prompture/drivers/async_claude_driver.py +177 -8
  17. prompture/drivers/async_google_driver.py +10 -0
  18. prompture/drivers/async_grok_driver.py +4 -4
  19. prompture/drivers/async_groq_driver.py +4 -4
  20. prompture/drivers/async_modelscope_driver.py +286 -0
  21. prompture/drivers/async_moonshot_driver.py +312 -0
  22. prompture/drivers/async_openai_driver.py +158 -6
  23. prompture/drivers/async_openrouter_driver.py +196 -7
  24. prompture/drivers/async_registry.py +30 -0
  25. prompture/drivers/async_zai_driver.py +303 -0
  26. prompture/drivers/azure_driver.py +6 -5
  27. prompture/drivers/claude_driver.py +10 -0
  28. prompture/drivers/google_driver.py +10 -0
  29. prompture/drivers/grok_driver.py +4 -4
  30. prompture/drivers/groq_driver.py +4 -4
  31. prompture/drivers/modelscope_driver.py +303 -0
  32. prompture/drivers/moonshot_driver.py +342 -0
  33. prompture/drivers/openai_driver.py +22 -12
  34. prompture/drivers/openrouter_driver.py +248 -44
  35. prompture/drivers/zai_driver.py +318 -0
  36. prompture/groups.py +42 -0
  37. prompture/ledger.py +252 -0
  38. prompture/model_rates.py +114 -2
  39. prompture/settings.py +16 -1
  40. {prompture-0.0.38.dev2.dist-info → prompture-0.0.42.dist-info}/METADATA +1 -1
  41. prompture-0.0.42.dist-info/RECORD +84 -0
  42. prompture-0.0.38.dev2.dist-info/RECORD +0 -77
  43. {prompture-0.0.38.dev2.dist-info → prompture-0.0.42.dist-info}/WHEEL +0 -0
  44. {prompture-0.0.38.dev2.dist-info → prompture-0.0.42.dist-info}/entry_points.txt +0 -0
  45. {prompture-0.0.38.dev2.dist-info → prompture-0.0.42.dist-info}/licenses/LICENSE +0 -0
  46. {prompture-0.0.38.dev2.dist-info → prompture-0.0.42.dist-info}/top_level.txt +0 -0
@@ -12,7 +12,7 @@ try:
12
12
  except Exception:
13
13
  OpenAI = None
14
14
 
15
- from ..cost_mixin import CostMixin
15
+ from ..cost_mixin import CostMixin, prepare_strict_schema
16
16
  from ..driver import Driver
17
17
 
18
18
 
@@ -93,10 +93,17 @@ class OpenAIDriver(CostMixin, Driver):
93
93
 
94
94
  model = options.get("model", self.model)
95
95
 
96
- # Lookup model-specific config
97
- model_info = self.MODEL_PRICING.get(model, {})
98
- tokens_param = model_info.get("tokens_param", "max_tokens")
99
- supports_temperature = model_info.get("supports_temperature", True)
96
+ # Lookup model-specific config (live models.dev data + hardcoded fallback)
97
+ model_config = self._get_model_config("openai", model)
98
+ tokens_param = model_config["tokens_param"]
99
+ supports_temperature = model_config["supports_temperature"]
100
+
101
+ # Validate capabilities against models.dev metadata
102
+ self._validate_model_capabilities(
103
+ "openai",
104
+ model,
105
+ using_json_schema=bool(options.get("json_schema")),
106
+ )
100
107
 
101
108
  # Defaults
102
109
  opts = {"temperature": 1.0, "max_tokens": 512, **options}
@@ -118,12 +125,13 @@ class OpenAIDriver(CostMixin, Driver):
118
125
  if options.get("json_mode"):
119
126
  json_schema = options.get("json_schema")
120
127
  if json_schema:
128
+ schema_copy = prepare_strict_schema(json_schema)
121
129
  kwargs["response_format"] = {
122
130
  "type": "json_schema",
123
131
  "json_schema": {
124
132
  "name": "extraction",
125
133
  "strict": True,
126
- "schema": json_schema,
134
+ "schema": schema_copy,
127
135
  },
128
136
  }
129
137
  else:
@@ -168,9 +176,11 @@ class OpenAIDriver(CostMixin, Driver):
168
176
  raise RuntimeError("openai package (>=1.0.0) is not installed")
169
177
 
170
178
  model = options.get("model", self.model)
171
- model_info = self.MODEL_PRICING.get(model, {})
172
- tokens_param = model_info.get("tokens_param", "max_tokens")
173
- supports_temperature = model_info.get("supports_temperature", True)
179
+ model_config = self._get_model_config("openai", model)
180
+ tokens_param = model_config["tokens_param"]
181
+ supports_temperature = model_config["supports_temperature"]
182
+
183
+ self._validate_model_capabilities("openai", model, using_tool_use=True)
174
184
 
175
185
  opts = {"temperature": 1.0, "max_tokens": 512, **options}
176
186
 
@@ -239,9 +249,9 @@ class OpenAIDriver(CostMixin, Driver):
239
249
  raise RuntimeError("openai package (>=1.0.0) is not installed")
240
250
 
241
251
  model = options.get("model", self.model)
242
- model_info = self.MODEL_PRICING.get(model, {})
243
- tokens_param = model_info.get("tokens_param", "max_tokens")
244
- supports_temperature = model_info.get("supports_temperature", True)
252
+ model_config = self._get_model_config("openai", model)
253
+ tokens_param = model_config["tokens_param"]
254
+ supports_temperature = model_config["supports_temperature"]
245
255
 
246
256
  opts = {"temperature": 1.0, "max_tokens": 512, **options}
247
257
 
@@ -2,54 +2,66 @@
2
2
  Requires the `requests` package. Uses OPENROUTER_API_KEY env var.
3
3
  """
4
4
 
5
+ import contextlib
6
+ import json
5
7
  import os
8
+ from collections.abc import Iterator
6
9
  from typing import Any
7
10
 
8
11
  import requests
9
12
 
10
- from ..cost_mixin import CostMixin
13
+ from ..cost_mixin import CostMixin, prepare_strict_schema
11
14
  from ..driver import Driver
12
15
 
13
16
 
14
17
  class OpenRouterDriver(CostMixin, Driver):
15
18
  supports_json_mode = True
19
+ supports_json_schema = True
20
+ supports_tool_use = True
21
+ supports_streaming = True
16
22
  supports_vision = True
17
23
 
18
24
  # Approximate pricing per 1K tokens based on OpenRouter's pricing
19
25
  # https://openrouter.ai/docs#pricing
20
26
  MODEL_PRICING = {
21
- "openai/gpt-3.5-turbo": {
22
- "prompt": 0.0015,
23
- "completion": 0.002,
27
+ "openai/gpt-4o": {
28
+ "prompt": 0.005,
29
+ "completion": 0.015,
24
30
  "tokens_param": "max_tokens",
25
31
  "supports_temperature": True,
26
32
  },
27
- "anthropic/claude-2": {
28
- "prompt": 0.008,
29
- "completion": 0.024,
33
+ "openai/gpt-4o-mini": {
34
+ "prompt": 0.00015,
35
+ "completion": 0.0006,
30
36
  "tokens_param": "max_tokens",
31
37
  "supports_temperature": True,
32
38
  },
33
- "google/palm-2-chat-bison": {
34
- "prompt": 0.0005,
35
- "completion": 0.0005,
39
+ "anthropic/claude-sonnet-4-20250514": {
40
+ "prompt": 0.003,
41
+ "completion": 0.015,
36
42
  "tokens_param": "max_tokens",
37
43
  "supports_temperature": True,
38
44
  },
39
- "meta-llama/llama-2-70b-chat": {
40
- "prompt": 0.0007,
41
- "completion": 0.0007,
45
+ "google/gemini-2.0-flash-001": {
46
+ "prompt": 0.0001,
47
+ "completion": 0.0004,
48
+ "tokens_param": "max_tokens",
49
+ "supports_temperature": True,
50
+ },
51
+ "meta-llama/llama-3.1-70b-instruct": {
52
+ "prompt": 0.0004,
53
+ "completion": 0.0004,
42
54
  "tokens_param": "max_tokens",
43
55
  "supports_temperature": True,
44
56
  },
45
57
  }
46
58
 
47
- def __init__(self, api_key: str | None = None, model: str = "openai/gpt-3.5-turbo"):
59
+ def __init__(self, api_key: str | None = None, model: str = "openai/gpt-4o-mini"):
48
60
  """Initialize OpenRouter driver.
49
61
 
50
62
  Args:
51
63
  api_key: OpenRouter API key. If not provided, will look for OPENROUTER_API_KEY env var
52
- model: Model to use. Defaults to openai/gpt-3.5-turbo
64
+ model: Model to use. Defaults to openai/gpt-4o-mini
53
65
  """
54
66
  self.api_key = api_key or os.getenv("OPENROUTER_API_KEY")
55
67
  if not self.api_key:
@@ -85,10 +97,17 @@ class OpenRouterDriver(CostMixin, Driver):
85
97
 
86
98
  model = options.get("model", self.model)
87
99
 
88
- # Lookup model-specific config
89
- model_info = self.MODEL_PRICING.get(model, {})
90
- tokens_param = model_info.get("tokens_param", "max_tokens")
91
- supports_temperature = model_info.get("supports_temperature", True)
100
+ # Lookup model-specific config (live models.dev data + hardcoded fallback)
101
+ model_config = self._get_model_config("openrouter", model)
102
+ tokens_param = model_config["tokens_param"]
103
+ supports_temperature = model_config["supports_temperature"]
104
+
105
+ # Validate capabilities against models.dev metadata
106
+ self._validate_model_capabilities(
107
+ "openrouter",
108
+ model,
109
+ using_json_schema=bool(options.get("json_schema")),
110
+ )
92
111
 
93
112
  # Defaults
94
113
  opts = {"temperature": 1.0, "max_tokens": 512, **options}
@@ -108,45 +127,230 @@ class OpenRouterDriver(CostMixin, Driver):
108
127
 
109
128
  # Native JSON mode support
110
129
  if options.get("json_mode"):
111
- data["response_format"] = {"type": "json_object"}
130
+ json_schema = options.get("json_schema")
131
+ if json_schema:
132
+ schema_copy = prepare_strict_schema(json_schema)
133
+ data["response_format"] = {
134
+ "type": "json_schema",
135
+ "json_schema": {
136
+ "name": "extraction",
137
+ "strict": True,
138
+ "schema": schema_copy,
139
+ },
140
+ }
141
+ else:
142
+ data["response_format"] = {"type": "json_object"}
143
+
144
+ try:
145
+ response = requests.post(
146
+ f"{self.base_url}/chat/completions",
147
+ headers=self.headers,
148
+ json=data,
149
+ timeout=120,
150
+ )
151
+ response.raise_for_status()
152
+ resp = response.json()
153
+ except requests.exceptions.HTTPError as e:
154
+ body = ""
155
+ if e.response is not None:
156
+ with contextlib.suppress(Exception):
157
+ body = e.response.text
158
+ error_msg = f"OpenRouter API request failed: {e!s}"
159
+ if body:
160
+ error_msg += f"\nResponse: {body}"
161
+ raise RuntimeError(error_msg) from e
162
+ except requests.exceptions.RequestException as e:
163
+ raise RuntimeError(f"OpenRouter API request failed: {e!s}") from e
164
+
165
+ # Extract usage info
166
+ usage = resp.get("usage", {})
167
+ prompt_tokens = usage.get("prompt_tokens", 0)
168
+ completion_tokens = usage.get("completion_tokens", 0)
169
+ total_tokens = usage.get("total_tokens", 0)
170
+
171
+ # Calculate cost via shared mixin
172
+ total_cost = self._calculate_cost("openrouter", model, prompt_tokens, completion_tokens)
173
+
174
+ # Standardized meta object
175
+ meta = {
176
+ "prompt_tokens": prompt_tokens,
177
+ "completion_tokens": completion_tokens,
178
+ "total_tokens": total_tokens,
179
+ "cost": round(total_cost, 6),
180
+ "raw_response": resp,
181
+ "model_name": model,
182
+ }
183
+
184
+ text = resp["choices"][0]["message"]["content"]
185
+ return {"text": text, "meta": meta}
186
+
187
+ # ------------------------------------------------------------------
188
+ # Tool use
189
+ # ------------------------------------------------------------------
190
+
191
+ def generate_messages_with_tools(
192
+ self,
193
+ messages: list[dict[str, Any]],
194
+ tools: list[dict[str, Any]],
195
+ options: dict[str, Any],
196
+ ) -> dict[str, Any]:
197
+ """Generate a response that may include tool calls."""
198
+ if not self.api_key:
199
+ raise RuntimeError("OpenRouter API key not found")
200
+
201
+ model = options.get("model", self.model)
202
+ model_config = self._get_model_config("openrouter", model)
203
+ tokens_param = model_config["tokens_param"]
204
+ supports_temperature = model_config["supports_temperature"]
205
+
206
+ self._validate_model_capabilities("openrouter", model, using_tool_use=True)
207
+
208
+ opts = {"temperature": 1.0, "max_tokens": 512, **options}
209
+
210
+ data: dict[str, Any] = {
211
+ "model": model,
212
+ "messages": messages,
213
+ "tools": tools,
214
+ }
215
+ data[tokens_param] = opts.get("max_tokens", 512)
216
+
217
+ if supports_temperature and "temperature" in opts:
218
+ data["temperature"] = opts["temperature"]
112
219
 
113
220
  try:
114
221
  response = requests.post(
115
222
  f"{self.base_url}/chat/completions",
116
223
  headers=self.headers,
117
224
  json=data,
225
+ timeout=120,
118
226
  )
119
227
  response.raise_for_status()
120
228
  resp = response.json()
229
+ except requests.exceptions.HTTPError as e:
230
+ error_msg = f"OpenRouter API request failed: {e!s}"
231
+ raise RuntimeError(error_msg) from e
232
+ except requests.exceptions.RequestException as e:
233
+ raise RuntimeError(f"OpenRouter API request failed: {e!s}") from e
234
+
235
+ usage = resp.get("usage", {})
236
+ prompt_tokens = usage.get("prompt_tokens", 0)
237
+ completion_tokens = usage.get("completion_tokens", 0)
238
+ total_tokens = usage.get("total_tokens", 0)
239
+ total_cost = self._calculate_cost("openrouter", model, prompt_tokens, completion_tokens)
121
240
 
122
- # Extract usage info
123
- usage = resp.get("usage", {})
124
- prompt_tokens = usage.get("prompt_tokens", 0)
125
- completion_tokens = usage.get("completion_tokens", 0)
126
- total_tokens = usage.get("total_tokens", 0)
241
+ meta = {
242
+ "prompt_tokens": prompt_tokens,
243
+ "completion_tokens": completion_tokens,
244
+ "total_tokens": total_tokens,
245
+ "cost": round(total_cost, 6),
246
+ "raw_response": resp,
247
+ "model_name": model,
248
+ }
249
+
250
+ choice = resp["choices"][0]
251
+ text = choice["message"].get("content") or ""
252
+ stop_reason = choice.get("finish_reason")
253
+
254
+ tool_calls_out: list[dict[str, Any]] = []
255
+ for tc in choice["message"].get("tool_calls", []):
256
+ try:
257
+ args = json.loads(tc["function"]["arguments"])
258
+ except (json.JSONDecodeError, TypeError):
259
+ args = {}
260
+ tool_calls_out.append({
261
+ "id": tc["id"],
262
+ "name": tc["function"]["name"],
263
+ "arguments": args,
264
+ })
265
+
266
+ return {
267
+ "text": text,
268
+ "meta": meta,
269
+ "tool_calls": tool_calls_out,
270
+ "stop_reason": stop_reason,
271
+ }
127
272
 
128
- # Calculate cost via shared mixin
129
- total_cost = self._calculate_cost("openrouter", model, prompt_tokens, completion_tokens)
273
+ # ------------------------------------------------------------------
274
+ # Streaming
275
+ # ------------------------------------------------------------------
130
276
 
131
- # Standardized meta object
132
- meta = {
277
+ def generate_messages_stream(
278
+ self,
279
+ messages: list[dict[str, Any]],
280
+ options: dict[str, Any],
281
+ ) -> Iterator[dict[str, Any]]:
282
+ """Yield response chunks via OpenRouter streaming API."""
283
+ if not self.api_key:
284
+ raise RuntimeError("OpenRouter API key not found")
285
+
286
+ model = options.get("model", self.model)
287
+ model_config = self._get_model_config("openrouter", model)
288
+ tokens_param = model_config["tokens_param"]
289
+ supports_temperature = model_config["supports_temperature"]
290
+
291
+ opts = {"temperature": 1.0, "max_tokens": 512, **options}
292
+
293
+ data: dict[str, Any] = {
294
+ "model": model,
295
+ "messages": messages,
296
+ "stream": True,
297
+ "stream_options": {"include_usage": True},
298
+ }
299
+ data[tokens_param] = opts.get("max_tokens", 512)
300
+
301
+ if supports_temperature and "temperature" in opts:
302
+ data["temperature"] = opts["temperature"]
303
+
304
+ response = requests.post(
305
+ f"{self.base_url}/chat/completions",
306
+ headers=self.headers,
307
+ json=data,
308
+ stream=True,
309
+ timeout=120,
310
+ )
311
+ response.raise_for_status()
312
+
313
+ full_text = ""
314
+ prompt_tokens = 0
315
+ completion_tokens = 0
316
+
317
+ for line in response.iter_lines(decode_unicode=True):
318
+ if not line or not line.startswith("data: "):
319
+ continue
320
+ payload = line[len("data: "):]
321
+ if payload.strip() == "[DONE]":
322
+ break
323
+ try:
324
+ chunk = json.loads(payload)
325
+ except json.JSONDecodeError:
326
+ continue
327
+
328
+ # Usage comes in the final chunk
329
+ usage = chunk.get("usage")
330
+ if usage:
331
+ prompt_tokens = usage.get("prompt_tokens", 0)
332
+ completion_tokens = usage.get("completion_tokens", 0)
333
+
334
+ choices = chunk.get("choices", [])
335
+ if choices:
336
+ delta = choices[0].get("delta", {})
337
+ content = delta.get("content", "")
338
+ if content:
339
+ full_text += content
340
+ yield {"type": "delta", "text": content}
341
+
342
+ total_tokens = prompt_tokens + completion_tokens
343
+ total_cost = self._calculate_cost("openrouter", model, prompt_tokens, completion_tokens)
344
+
345
+ yield {
346
+ "type": "done",
347
+ "text": full_text,
348
+ "meta": {
133
349
  "prompt_tokens": prompt_tokens,
134
350
  "completion_tokens": completion_tokens,
135
351
  "total_tokens": total_tokens,
136
352
  "cost": round(total_cost, 6),
137
- "raw_response": resp,
353
+ "raw_response": {},
138
354
  "model_name": model,
139
- }
140
-
141
- text = resp["choices"][0]["message"]["content"]
142
- return {"text": text, "meta": meta}
143
-
144
- except requests.exceptions.RequestException as e:
145
- error_msg = f"OpenRouter API request failed: {e!s}"
146
- if hasattr(e.response, "json"):
147
- try:
148
- error_details = e.response.json()
149
- error_msg = f"{error_msg} - {error_details.get('error', {}).get('message', '')}"
150
- except Exception:
151
- pass
152
- raise RuntimeError(error_msg) from e
355
+ },
356
+ }