symbolicai 1.5.0__py3-none-any.whl → 1.7.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.
Files changed (52) hide show
  1. symai/__init__.py +21 -71
  2. symai/backend/base.py +0 -26
  3. symai/backend/engines/drawing/engine_gemini_image.py +101 -0
  4. symai/backend/engines/embedding/engine_openai.py +11 -8
  5. symai/backend/engines/neurosymbolic/__init__.py +8 -0
  6. symai/backend/engines/neurosymbolic/engine_anthropic_claudeX_chat.py +1 -0
  7. symai/backend/engines/neurosymbolic/engine_anthropic_claudeX_reasoning.py +48 -1
  8. symai/backend/engines/neurosymbolic/engine_cerebras.py +1 -0
  9. symai/backend/engines/neurosymbolic/engine_google_geminiX_reasoning.py +14 -1
  10. symai/backend/engines/neurosymbolic/engine_openrouter.py +294 -0
  11. symai/backend/mixin/__init__.py +4 -0
  12. symai/backend/mixin/anthropic.py +37 -16
  13. symai/backend/mixin/openrouter.py +2 -0
  14. symai/components.py +203 -13
  15. symai/extended/interfaces/nanobanana.py +23 -0
  16. symai/interfaces.py +2 -0
  17. symai/ops/primitives.py +0 -18
  18. symai/shellsv.py +2 -7
  19. symai/strategy.py +44 -4
  20. {symbolicai-1.5.0.dist-info → symbolicai-1.7.0.dist-info}/METADATA +3 -10
  21. {symbolicai-1.5.0.dist-info → symbolicai-1.7.0.dist-info}/RECORD +25 -48
  22. {symbolicai-1.5.0.dist-info → symbolicai-1.7.0.dist-info}/WHEEL +1 -1
  23. symai/backend/driver/webclient.py +0 -217
  24. symai/backend/engines/crawler/engine_selenium.py +0 -94
  25. symai/backend/engines/drawing/engine_dall_e.py +0 -131
  26. symai/backend/engines/embedding/engine_plugin_embeddings.py +0 -12
  27. symai/backend/engines/experiments/engine_bard_wrapper.py +0 -131
  28. symai/backend/engines/experiments/engine_gptfinetuner.py +0 -32
  29. symai/backend/engines/experiments/engine_llamacpp_completion.py +0 -142
  30. symai/backend/engines/neurosymbolic/engine_openai_gptX_completion.py +0 -277
  31. symai/collect/__init__.py +0 -8
  32. symai/collect/dynamic.py +0 -117
  33. symai/collect/pipeline.py +0 -156
  34. symai/collect/stats.py +0 -434
  35. symai/extended/crawler.py +0 -21
  36. symai/extended/interfaces/selenium.py +0 -18
  37. symai/extended/interfaces/vectordb.py +0 -21
  38. symai/extended/personas/__init__.py +0 -3
  39. symai/extended/personas/builder.py +0 -105
  40. symai/extended/personas/dialogue.py +0 -126
  41. symai/extended/personas/persona.py +0 -154
  42. symai/extended/personas/research/__init__.py +0 -1
  43. symai/extended/personas/research/yann_lecun.py +0 -62
  44. symai/extended/personas/sales/__init__.py +0 -1
  45. symai/extended/personas/sales/erik_james.py +0 -62
  46. symai/extended/personas/student/__init__.py +0 -1
  47. symai/extended/personas/student/max_tenner.py +0 -51
  48. symai/extended/strategies/__init__.py +0 -1
  49. symai/extended/strategies/cot.py +0 -40
  50. {symbolicai-1.5.0.dist-info → symbolicai-1.7.0.dist-info}/entry_points.txt +0 -0
  51. {symbolicai-1.5.0.dist-info → symbolicai-1.7.0.dist-info}/licenses/LICENSE +0 -0
  52. {symbolicai-1.5.0.dist-info → symbolicai-1.7.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,294 @@
1
+ import json
2
+ import logging
3
+ from copy import deepcopy
4
+
5
+ import openai
6
+
7
+ from ....components import SelfPrompt
8
+ from ....core_ext import retry
9
+ from ....utils import UserMessage
10
+ from ...base import Engine
11
+ from ...settings import SYMAI_CONFIG
12
+
13
+ logging.getLogger("openai").setLevel(logging.ERROR)
14
+ logging.getLogger("requests").setLevel(logging.ERROR)
15
+ logging.getLogger("urllib").setLevel(logging.ERROR)
16
+ logging.getLogger("httpx").setLevel(logging.ERROR)
17
+ logging.getLogger("httpcore").setLevel(logging.ERROR)
18
+
19
+
20
+ _NON_VERBOSE_OUTPUT = (
21
+ "<META_INSTRUCTION/>\n"
22
+ "You do not output anything else, like verbose preambles or post explanation, such as "
23
+ '"Sure, let me...", "Hope that was helpful...", "Yes, I can help you with that...", etc. '
24
+ "Consider well formatted output, e.g. for sentences use punctuation, spaces etc. or for code use "
25
+ "indentation, etc. Never add meta instructions information to your output!\n\n"
26
+ )
27
+
28
+
29
+ class OpenRouterEngine(Engine):
30
+ def __init__(self, api_key: str | None = None, model: str | None = None):
31
+ super().__init__()
32
+ self.config = deepcopy(SYMAI_CONFIG)
33
+ # In case we use EngineRepository.register to inject the api_key and model => dynamically change the engine at runtime
34
+ if api_key is not None and model is not None:
35
+ self.config["NEUROSYMBOLIC_ENGINE_API_KEY"] = api_key
36
+ self.config["NEUROSYMBOLIC_ENGINE_MODEL"] = model
37
+ if self.id() != "neurosymbolic":
38
+ return # do not initialize if not neurosymbolic; avoids conflict with llama.cpp check in EngineRepository.register_from_package
39
+ openai.api_key = self.config["NEUROSYMBOLIC_ENGINE_API_KEY"]
40
+ self.model = self.config["NEUROSYMBOLIC_ENGINE_MODEL"]
41
+ self.seed = None
42
+ self.name = self.__class__.__name__
43
+ self._last_prompt_tokens = None
44
+ self._last_messages = None
45
+
46
+ try:
47
+ self.client = openai.OpenAI(
48
+ api_key=openai.api_key, base_url="https://openrouter.ai/api/v1"
49
+ )
50
+ except Exception as exc:
51
+ UserMessage(
52
+ f"Failed to initialize OpenRouter client. Please check your OpenAI library version. Caused by: {exc}",
53
+ raise_with=ValueError,
54
+ )
55
+
56
+ def id(self) -> str:
57
+ model_name = self.config.get("NEUROSYMBOLIC_ENGINE_MODEL")
58
+ if model_name and model_name.startswith("openrouter"):
59
+ return "neurosymbolic"
60
+ return super().id()
61
+
62
+ def command(self, *args, **kwargs):
63
+ super().command(*args, **kwargs)
64
+ if "NEUROSYMBOLIC_ENGINE_API_KEY" in kwargs:
65
+ openai.api_key = kwargs["NEUROSYMBOLIC_ENGINE_API_KEY"]
66
+ if "NEUROSYMBOLIC_ENGINE_MODEL" in kwargs:
67
+ self.model = kwargs["NEUROSYMBOLIC_ENGINE_MODEL"]
68
+ if "seed" in kwargs:
69
+ self.seed = kwargs["seed"]
70
+
71
+ def compute_required_tokens(self, messages):
72
+ if self._last_prompt_tokens is not None and self._last_messages == messages:
73
+ return self._last_prompt_tokens
74
+ UserMessage(
75
+ "Token counting not implemented for this engine.", raise_with=NotImplementedError
76
+ )
77
+ return 0
78
+
79
+ def compute_remaining_tokens(self, _prompts: list) -> int:
80
+ UserMessage(
81
+ "Token counting not implemented for this engine.", raise_with=NotImplementedError
82
+ )
83
+
84
+ def _handle_prefix(self, model_name: str) -> str:
85
+ """Handle prefix for model name."""
86
+ return model_name.replace("openrouter:", "")
87
+
88
+ def _extract_thinking_content(self, output: list[str]) -> tuple[str | None, list[str]]:
89
+ """Extract thinking content from textual output using <think>...</think> tags if present."""
90
+ if not output or not output[0]:
91
+ return None, output
92
+
93
+ content = output[0]
94
+ start = content.find("<think>")
95
+ if start == -1:
96
+ return None, output
97
+
98
+ end = content.find("</think>", start + 7)
99
+ if end == -1:
100
+ return None, output
101
+
102
+ thinking_content = content[start + 7 : end].strip() or None
103
+ cleaned_content = (content[:start] + content[end + 8 :]).strip()
104
+ cleaned_output = [cleaned_content, *output[1:]]
105
+
106
+ return thinking_content, cleaned_output
107
+
108
+ # cumulative wait time is < 30s
109
+ @retry(tries=8, delay=0.5, backoff=1.5, max_delay=5, jitter=(0, 0.5))
110
+ def forward(self, argument):
111
+ kwargs = argument.kwargs
112
+ messages = argument.prop.prepared_input
113
+ payload = self._prepare_request_payload(messages, argument)
114
+ except_remedy = kwargs.get("except_remedy")
115
+
116
+ try:
117
+ res = self.client.chat.completions.create(**payload)
118
+ except Exception as exc:
119
+ if openai.api_key is None or openai.api_key == "":
120
+ msg = (
121
+ "OpenRouter API key is not set. Please set it in the config file or "
122
+ "pass it as an argument to the command method."
123
+ )
124
+ UserMessage(msg)
125
+ if (
126
+ self.config["NEUROSYMBOLIC_ENGINE_API_KEY"] is None
127
+ or self.config["NEUROSYMBOLIC_ENGINE_API_KEY"] == ""
128
+ ):
129
+ UserMessage(msg, raise_with=ValueError)
130
+ openai.api_key = self.config["NEUROSYMBOLIC_ENGINE_API_KEY"]
131
+
132
+ callback = self.client.chat.completions.create
133
+ kwargs["model"] = (
134
+ self._handle_prefix(kwargs["model"])
135
+ if "model" in kwargs
136
+ else self._handle_prefix(self.model)
137
+ )
138
+
139
+ if except_remedy is not None:
140
+ res = except_remedy(self, exc, callback, argument)
141
+ else:
142
+ UserMessage(f"Error during generation. Caused by: {exc}", raise_with=ValueError)
143
+
144
+ prompt_tokens = getattr(res.usage, "prompt_tokens", None)
145
+ if prompt_tokens is None:
146
+ prompt_tokens = getattr(res.usage, "input_tokens", None)
147
+ self._last_prompt_tokens = prompt_tokens
148
+ self._last_messages = messages
149
+
150
+ metadata = {"raw_output": res}
151
+ if payload.get("tools"):
152
+ metadata = self._process_function_calls(res, metadata)
153
+
154
+ output = [r.message.content for r in res.choices]
155
+ thinking, output = self._extract_thinking_content(output)
156
+ if thinking:
157
+ metadata["thinking"] = thinking
158
+
159
+ return output, metadata
160
+
161
+ def _prepare_raw_input(self, argument):
162
+ if not argument.prop.processed_input:
163
+ UserMessage(
164
+ "Need to provide a prompt instruction to the engine if raw_input is enabled.",
165
+ raise_with=ValueError,
166
+ )
167
+ value = argument.prop.processed_input
168
+ if not isinstance(value, list):
169
+ if not isinstance(value, dict):
170
+ value = {"role": "user", "content": str(value)}
171
+ value = [value]
172
+ return value
173
+
174
+ def prepare(self, argument):
175
+ if argument.prop.raw_input:
176
+ argument.prop.prepared_input = self._prepare_raw_input(argument)
177
+ return
178
+ self._validate_response_format(argument)
179
+
180
+ system = self._build_system_message(argument)
181
+ user_content = self._build_user_content(argument)
182
+ user_prompt = {"role": "user", "content": user_content}
183
+ system, user_prompt = self._apply_self_prompt_if_needed(argument, system, user_prompt)
184
+
185
+ argument.prop.prepared_input = [
186
+ {"role": "system", "content": system},
187
+ user_prompt,
188
+ ]
189
+
190
+ def _validate_response_format(self, argument) -> None:
191
+ if argument.prop.response_format:
192
+ response_format = argument.prop.response_format
193
+ assert response_format.get("type") is not None, (
194
+ 'Expected format `{ "type": "json_object" }`! We are using the OpenAI compatible '
195
+ "API for OpenRouter."
196
+ )
197
+
198
+ def _build_system_message(self, argument) -> str:
199
+ system: str = ""
200
+ if argument.prop.suppress_verbose_output:
201
+ system += _NON_VERBOSE_OUTPUT
202
+ if system:
203
+ system = f"{system}\n"
204
+
205
+ ref = argument.prop.instance
206
+ static_ctxt, dyn_ctxt = ref.global_context
207
+ if len(static_ctxt) > 0:
208
+ system += f"<STATIC CONTEXT/>\n{static_ctxt}\n\n"
209
+
210
+ if len(dyn_ctxt) > 0:
211
+ system += f"<DYNAMIC CONTEXT/>\n{dyn_ctxt}\n\n"
212
+
213
+ if argument.prop.payload:
214
+ system += f"<ADDITIONAL CONTEXT/>\n{argument.prop.payload!s}\n\n"
215
+
216
+ examples = argument.prop.examples
217
+ if examples and len(examples) > 0:
218
+ system += f"<EXAMPLES/>\n{examples!s}\n\n"
219
+
220
+ if argument.prop.prompt is not None and len(argument.prop.prompt) > 0:
221
+ val = str(argument.prop.prompt)
222
+ system += f"<INSTRUCTION/>\n{val}\n\n"
223
+
224
+ if argument.prop.template_suffix:
225
+ system += (
226
+ " You will only generate content for the placeholder "
227
+ f"`{argument.prop.template_suffix!s}` following the instructions and the provided context "
228
+ "information.\n\n"
229
+ )
230
+
231
+ return system
232
+
233
+ def _build_user_content(self, argument) -> str:
234
+ return str(argument.prop.processed_input)
235
+
236
+ def _apply_self_prompt_if_needed(self, argument, system, user_prompt):
237
+ if argument.prop.instance._kwargs.get("self_prompt", False) or argument.prop.self_prompt:
238
+ self_prompter = SelfPrompt()
239
+ res = self_prompter({"user": user_prompt["content"], "system": system})
240
+ if res is None:
241
+ UserMessage("Self-prompting failed!", raise_with=ValueError)
242
+ return res["system"], {"role": "user", "content": res["user"]}
243
+ return system, user_prompt
244
+
245
+ def _process_function_calls(self, res, metadata):
246
+ hit = False
247
+ if (
248
+ hasattr(res, "choices")
249
+ and res.choices
250
+ and hasattr(res.choices[0], "message")
251
+ and res.choices[0].message
252
+ and hasattr(res.choices[0].message, "tool_calls")
253
+ and res.choices[0].message.tool_calls
254
+ ):
255
+ for tool_call in res.choices[0].message.tool_calls:
256
+ if hasattr(tool_call, "function") and tool_call.function:
257
+ if hit:
258
+ UserMessage(
259
+ "Multiple function calls detected in the response but only the first one will be processed."
260
+ )
261
+ break
262
+ try:
263
+ args_dict = json.loads(tool_call.function.arguments)
264
+ except json.JSONDecodeError:
265
+ args_dict = {}
266
+ metadata["function_call"] = {
267
+ "name": tool_call.function.name,
268
+ "arguments": args_dict,
269
+ }
270
+ hit = True
271
+ return metadata
272
+
273
+ # TODO: requires updates for reasoning
274
+ def _prepare_request_payload(self, messages, argument):
275
+ kwargs = argument.kwargs
276
+ max_tokens = kwargs.get("max_tokens")
277
+ if max_tokens is None:
278
+ max_tokens = kwargs.get("max_completion_tokens")
279
+ return {
280
+ "messages": messages,
281
+ "model": self._handle_prefix(kwargs.get("model", self.model)),
282
+ "seed": kwargs.get("seed", self.seed),
283
+ "max_tokens": max_tokens,
284
+ "stop": kwargs.get("stop"),
285
+ "temperature": kwargs.get("temperature", 1),
286
+ "frequency_penalty": kwargs.get("frequency_penalty", 0),
287
+ "presence_penalty": kwargs.get("presence_penalty", 0),
288
+ "top_p": kwargs.get("top_p", 1),
289
+ "n": kwargs.get("n", 1),
290
+ "tools": kwargs.get("tools"),
291
+ "tool_choice": kwargs.get("tool_choice"),
292
+ "response_format": kwargs.get("response_format"),
293
+ "stream": kwargs.get("stream", False),
294
+ }
@@ -11,6 +11,8 @@ from .groq import SUPPORTED_REASONING_MODELS as GROQ_REASONING_MODELS
11
11
  from .openai import SUPPORTED_CHAT_MODELS as OPENAI_CHAT_MODELS
12
12
  from .openai import SUPPORTED_REASONING_MODELS as OPENAI_REASONING_MODELS
13
13
  from .openai import SUPPORTED_RESPONSES_MODELS as OPENAI_RESPONSES_MODELS
14
+ from .openrouter import SUPPORTED_CHAT_MODELS as OPENROUTER_CHAT_MODELS
15
+ from .openrouter import SUPPORTED_REASONING_MODELS as OPENROUTER_REASONING_MODELS
14
16
 
15
17
  __all__ = [
16
18
  "ANTHROPIC_CHAT_MODELS",
@@ -26,4 +28,6 @@ __all__ = [
26
28
  "OPENAI_CHAT_MODELS",
27
29
  "OPENAI_REASONING_MODELS",
28
30
  "OPENAI_RESPONSES_MODELS",
31
+ "OPENROUTER_CHAT_MODELS",
32
+ "OPENROUTER_REASONING_MODELS",
29
33
  ]
@@ -10,6 +10,7 @@ SUPPORTED_CHAT_MODELS = [
10
10
  "claude-3-haiku-20240307",
11
11
  ]
12
12
  SUPPORTED_REASONING_MODELS = [
13
+ "claude-opus-4-6",
13
14
  "claude-opus-4-5",
14
15
  "claude-opus-4-1",
15
16
  "claude-opus-4-0",
@@ -19,29 +20,49 @@ SUPPORTED_REASONING_MODELS = [
19
20
  "claude-sonnet-4-5",
20
21
  ]
21
22
 
23
+ LONG_CONTEXT_1M_TOKENS = 1_000_000
24
+ LONG_CONTEXT_1M_BETA_HEADER = "context-1m-2025-08-07"
25
+ LONG_CONTEXT_1M_MODELS = {
26
+ "claude-opus-4-6",
27
+ "claude-sonnet-4-5",
28
+ }
29
+
22
30
 
23
31
  class AnthropicMixin:
24
- def api_max_context_tokens(self):
32
+ def supports_long_context_1m(self, model: str) -> bool:
33
+ return model in LONG_CONTEXT_1M_MODELS
34
+
35
+ def long_context_beta_header(self) -> str:
36
+ return LONG_CONTEXT_1M_BETA_HEADER
37
+
38
+ def api_max_context_tokens(self, long_context_1m: bool = False, model: str | None = None):
39
+ selected_model = self.model if model is None else model
40
+ if long_context_1m and self.supports_long_context_1m(selected_model):
41
+ return LONG_CONTEXT_1M_TOKENS
25
42
  if (
26
- self.model == "claude-opus-4-5"
27
- or self.model == "claude-opus-4-1"
28
- or self.model == "claude-opus-4-0"
29
- or self.model == "claude-sonnet-4-0"
30
- or self.model == "claude-3-7-sonnet-latest"
31
- or self.model == "claude-haiku-4-5"
32
- or self.model == "claude-sonnet-4-5"
33
- or self.model == "claude-3-5-sonnet-latest"
34
- or self.model == "claude-3-5-sonnet-20241022"
35
- or self.model == "claude-3-5-sonnet-20240620"
36
- or self.model == "claude-3-opus-latest"
37
- or self.model == "claude-3-opus-20240229"
38
- or self.model == "claude-3-sonnet-20240229"
39
- or self.model == "claude-3-haiku-20240307"
43
+ selected_model == "claude-opus-4-6"
44
+ or selected_model == "claude-opus-4-5"
45
+ or selected_model == "claude-opus-4-1"
46
+ or selected_model == "claude-opus-4-0"
47
+ or selected_model == "claude-sonnet-4-0"
48
+ or selected_model == "claude-3-7-sonnet-latest"
49
+ or selected_model == "claude-haiku-4-5"
50
+ or selected_model == "claude-sonnet-4-5"
51
+ or selected_model == "claude-3-5-sonnet-latest"
52
+ or selected_model == "claude-3-5-sonnet-20241022"
53
+ or selected_model == "claude-3-5-sonnet-20240620"
54
+ or selected_model == "claude-3-opus-latest"
55
+ or selected_model == "claude-3-opus-20240229"
56
+ or selected_model == "claude-3-sonnet-20240229"
57
+ or selected_model == "claude-3-haiku-20240307"
40
58
  ):
41
59
  return 200_000
42
60
  return None
43
61
 
44
62
  def api_max_response_tokens(self):
63
+ if self.model == "claude-opus-4-6":
64
+ return 128_000
65
+
45
66
  if (
46
67
  self.model == "claude-opus-4-5"
47
68
  or self.model == "claude-sonnet-4-0"
@@ -61,7 +82,7 @@ class AnthropicMixin:
61
82
  if (
62
83
  self.model == "claude-3-5-sonnet-20240620"
63
84
  or self.model == "claude-3-opus-latest"
64
- or self.model == "clade-3-opus-20240229"
85
+ or self.model == "claude-3-opus-20240229"
65
86
  or self.model == "claude-3-sonnet-20240229"
66
87
  or self.model == "claude-3-haiku-20240307"
67
88
  ):
@@ -0,0 +1,2 @@
1
+ SUPPORTED_CHAT_MODELS = ["openrouter:moonshotai/kimi-k2.5"]
2
+ SUPPORTED_REASONING_MODELS = []
symai/components.py CHANGED
@@ -1229,6 +1229,7 @@ class MetadataTracker(Expression):
1229
1229
  and frame.f_code.co_name == "forward"
1230
1230
  and "self" in frame.f_locals
1231
1231
  and isinstance(frame.f_locals["self"], Engine)
1232
+ and arg is not None # Ensure arg is not None to avoid unpacking error on exceptions
1232
1233
  ):
1233
1234
  _, metadata = arg # arg contains return value on 'return' event
1234
1235
  engine_name = frame.f_locals["self"].__class__.__name__
@@ -1350,6 +1351,116 @@ class MetadataTracker(Expression):
1350
1351
  token_details[(engine_name, model_name)]["completion_breakdown"][
1351
1352
  "reasoning_tokens"
1352
1353
  ] += 0
1354
+ elif engine_name in ("ClaudeXChatEngine", "ClaudeXReasoningEngine"):
1355
+ raw_output = metadata["raw_output"]
1356
+ usage = self._extract_claude_usage(raw_output)
1357
+ if usage is None:
1358
+ # Skip if we can't extract usage (shouldn't happen normally)
1359
+ logger.warning(f"Could not extract usage from {engine_name} response.")
1360
+ token_details[(engine_name, model_name)]["usage"]["total_calls"] += 1
1361
+ token_details[(engine_name, model_name)]["prompt_breakdown"][
1362
+ "cached_tokens"
1363
+ ] += 0
1364
+ token_details[(engine_name, model_name)]["completion_breakdown"][
1365
+ "reasoning_tokens"
1366
+ ] += 0
1367
+ continue
1368
+ input_tokens = getattr(usage, "input_tokens", 0) or 0
1369
+ output_tokens = getattr(usage, "output_tokens", 0) or 0
1370
+ token_details[(engine_name, model_name)]["usage"]["prompt_tokens"] += (
1371
+ input_tokens
1372
+ )
1373
+ token_details[(engine_name, model_name)]["usage"]["completion_tokens"] += (
1374
+ output_tokens
1375
+ )
1376
+ # Calculate total tokens
1377
+ total = input_tokens + output_tokens
1378
+ token_details[(engine_name, model_name)]["usage"]["total_tokens"] += total
1379
+ token_details[(engine_name, model_name)]["usage"]["total_calls"] += 1
1380
+ # Track cache tokens if available
1381
+ cache_creation = getattr(usage, "cache_creation_input_tokens", 0) or 0
1382
+ cache_read = getattr(usage, "cache_read_input_tokens", 0) or 0
1383
+ token_details[(engine_name, model_name)]["prompt_breakdown"][
1384
+ "cache_creation_tokens"
1385
+ ] += cache_creation
1386
+ token_details[(engine_name, model_name)]["prompt_breakdown"][
1387
+ "cache_read_tokens"
1388
+ ] += cache_read
1389
+ # For backward compatibility, also track as cached_tokens
1390
+ token_details[(engine_name, model_name)]["prompt_breakdown"][
1391
+ "cached_tokens"
1392
+ ] += cache_read
1393
+ # Track reasoning/thinking tokens for ClaudeXReasoningEngine
1394
+ if engine_name == "ClaudeXReasoningEngine":
1395
+ thinking_output = metadata.get("thinking", "")
1396
+ # Store thinking content if available
1397
+ if thinking_output:
1398
+ if "thinking_content" not in token_details[(engine_name, model_name)]:
1399
+ token_details[(engine_name, model_name)]["thinking_content"] = []
1400
+ token_details[(engine_name, model_name)]["thinking_content"].append(
1401
+ thinking_output
1402
+ )
1403
+ # Note: Anthropic doesn't break down reasoning tokens separately in usage,
1404
+ # but extended thinking is included in output_tokens
1405
+ token_details[(engine_name, model_name)]["completion_breakdown"][
1406
+ "reasoning_tokens"
1407
+ ] += 0
1408
+ elif engine_name == "GeminiXReasoningEngine":
1409
+ usage = metadata["raw_output"].usage_metadata
1410
+ token_details[(engine_name, model_name)]["usage"]["prompt_tokens"] += (
1411
+ usage.prompt_token_count
1412
+ )
1413
+ token_details[(engine_name, model_name)]["usage"]["completion_tokens"] += (
1414
+ usage.candidates_token_count
1415
+ )
1416
+ token_details[(engine_name, model_name)]["usage"]["total_tokens"] += (
1417
+ usage.total_token_count
1418
+ )
1419
+ token_details[(engine_name, model_name)]["usage"]["total_calls"] += 1
1420
+ # Track cache tokens if available
1421
+ cache_read = getattr(usage, "cached_content_token_count", 0) or 0
1422
+ token_details[(engine_name, model_name)]["prompt_breakdown"][
1423
+ "cached_tokens"
1424
+ ] += cache_read
1425
+ # Track thinking content if available
1426
+ thinking_output = metadata.get("thinking", "")
1427
+ if thinking_output:
1428
+ if "thinking_content" not in token_details[(engine_name, model_name)]:
1429
+ token_details[(engine_name, model_name)]["thinking_content"] = []
1430
+ token_details[(engine_name, model_name)]["thinking_content"].append(
1431
+ thinking_output
1432
+ )
1433
+ # Note: Gemini reasoning tokens are part of candidates_token_count
1434
+ token_details[(engine_name, model_name)]["completion_breakdown"][
1435
+ "reasoning_tokens"
1436
+ ] += 0
1437
+ elif engine_name == "DeepSeekXReasoningEngine":
1438
+ usage = metadata["raw_output"].usage
1439
+ token_details[(engine_name, model_name)]["usage"]["completion_tokens"] += (
1440
+ usage.completion_tokens
1441
+ )
1442
+ token_details[(engine_name, model_name)]["usage"]["prompt_tokens"] += (
1443
+ usage.prompt_tokens
1444
+ )
1445
+ token_details[(engine_name, model_name)]["usage"]["total_tokens"] += (
1446
+ usage.total_tokens
1447
+ )
1448
+ token_details[(engine_name, model_name)]["usage"]["total_calls"] += 1
1449
+ # Track thinking content if available
1450
+ thinking_output = metadata.get("thinking", "")
1451
+ if thinking_output:
1452
+ if "thinking_content" not in token_details[(engine_name, model_name)]:
1453
+ token_details[(engine_name, model_name)]["thinking_content"] = []
1454
+ token_details[(engine_name, model_name)]["thinking_content"].append(
1455
+ thinking_output
1456
+ )
1457
+ # Note: DeepSeek reasoning tokens might be in completion_tokens_details
1458
+ reasoning_tokens = 0
1459
+ if hasattr(usage, "completion_tokens_details") and usage.completion_tokens_details:
1460
+ reasoning_tokens = getattr(usage.completion_tokens_details, "reasoning_tokens", 0) or 0
1461
+ token_details[(engine_name, model_name)]["completion_breakdown"][
1462
+ "reasoning_tokens"
1463
+ ] += reasoning_tokens
1353
1464
  else:
1354
1465
  logger.warning(f"Tracking {engine_name} is not supported.")
1355
1466
  continue
@@ -1361,8 +1472,60 @@ class MetadataTracker(Expression):
1361
1472
  # Convert to normal dict
1362
1473
  return {**token_details}
1363
1474
 
1475
+ def _extract_claude_usage(self, raw_output):
1476
+ """Extract usage information from Claude response (handles both streaming and non-streaming).
1477
+
1478
+ For non-streaming responses, raw_output is a Message object with a .usage attribute.
1479
+ For streaming responses, raw_output is a list of stream events. Usage info is in:
1480
+ - RawMessageStartEvent.message.usage (input_tokens)
1481
+ - RawMessageDeltaEvent.usage (output_tokens)
1482
+ """
1483
+ # Non-streaming: raw_output is a Message with .usage
1484
+ if hasattr(raw_output, "usage"):
1485
+ return raw_output.usage
1486
+
1487
+ # Streaming: raw_output is a list of events
1488
+ if isinstance(raw_output, list):
1489
+ # Accumulate usage from stream events
1490
+ input_tokens = 0
1491
+ output_tokens = 0
1492
+ cache_creation = 0
1493
+ cache_read = 0
1494
+
1495
+ for event in raw_output:
1496
+ # RawMessageStartEvent contains initial usage with input_tokens
1497
+ if hasattr(event, "message") and hasattr(event.message, "usage"):
1498
+ msg_usage = event.message.usage
1499
+ input_tokens += getattr(msg_usage, "input_tokens", 0) or 0
1500
+ cache_creation += getattr(msg_usage, "cache_creation_input_tokens", 0) or 0
1501
+ cache_read += getattr(msg_usage, "cache_read_input_tokens", 0) or 0
1502
+ # RawMessageDeltaEvent contains usage with output_tokens
1503
+ elif hasattr(event, "usage") and event.usage is not None:
1504
+ evt_usage = event.usage
1505
+ output_tokens += getattr(evt_usage, "output_tokens", 0) or 0
1506
+
1507
+ # Create a simple object-like dict to hold usage (using Box for attribute access)
1508
+ return Box({
1509
+ "input_tokens": input_tokens,
1510
+ "output_tokens": output_tokens,
1511
+ "cache_creation_input_tokens": cache_creation,
1512
+ "cache_read_input_tokens": cache_read,
1513
+ })
1514
+
1515
+ return None
1516
+
1364
1517
  def _can_accumulate_engine(self, engine_name: str) -> bool:
1365
- supported_engines = ("GPTXChatEngine", "GPTXReasoningEngine", "GPTXSearchEngine")
1518
+ supported_engines = (
1519
+ "GPTXChatEngine",
1520
+ "GPTXReasoningEngine",
1521
+ "GPTXSearchEngine",
1522
+ "ClaudeXChatEngine",
1523
+ "ClaudeXReasoningEngine",
1524
+ "GeminiXReasoningEngine",
1525
+ "DeepSeekXReasoningEngine",
1526
+ "GroqEngine",
1527
+ "CerebrasEngine",
1528
+ )
1366
1529
  return engine_name in supported_engines
1367
1530
 
1368
1531
  def _track_parallel_usage_items(self, token_details, engine_name, metadata):
@@ -1388,21 +1551,48 @@ class MetadataTracker(Expression):
1388
1551
 
1389
1552
  metadata_raw_output = metadata["raw_output"]
1390
1553
  accumulated_raw_output = accumulated["raw_output"]
1391
- if not hasattr(metadata_raw_output, "usage") or not hasattr(
1392
- accumulated_raw_output, "usage"
1393
- ):
1394
- return
1395
1554
 
1396
- current_usage = metadata_raw_output.usage
1397
- accumulated_usage = accumulated_raw_output.usage
1555
+ # Handle both OpenAI/Anthropic-style (usage) and Gemini-style (usage_metadata)
1556
+ current_usage = getattr(metadata_raw_output, "usage", None) or getattr(
1557
+ metadata_raw_output, "usage_metadata", None
1558
+ )
1559
+ accumulated_usage = getattr(accumulated_raw_output, "usage", None) or getattr(
1560
+ accumulated_raw_output, "usage_metadata", None
1561
+ )
1398
1562
 
1399
- for attr in ["completion_tokens", "prompt_tokens", "total_tokens"]:
1563
+ if not current_usage or not accumulated_usage:
1564
+ return
1565
+
1566
+ # Handle both OpenAI-style (completion_tokens, prompt_tokens),
1567
+ # Anthropic-style (output_tokens, input_tokens),
1568
+ # and Gemini-style (candidates_token_count, prompt_token_count) fields
1569
+ token_attrs = [
1570
+ "completion_tokens",
1571
+ "prompt_tokens",
1572
+ "total_tokens",
1573
+ "input_tokens",
1574
+ "output_tokens",
1575
+ "candidates_token_count",
1576
+ "prompt_token_count",
1577
+ "total_token_count",
1578
+ ]
1579
+ for attr in token_attrs:
1400
1580
  if hasattr(current_usage, attr) and hasattr(accumulated_usage, attr):
1401
- setattr(
1402
- accumulated_usage,
1403
- attr,
1404
- getattr(accumulated_usage, attr) + getattr(current_usage, attr),
1405
- )
1581
+ current_val = getattr(current_usage, attr) or 0
1582
+ accumulated_val = getattr(accumulated_usage, attr) or 0
1583
+ setattr(accumulated_usage, attr, accumulated_val + current_val)
1584
+
1585
+ # Handle Anthropic cache tokens and Gemini cached tokens
1586
+ cache_attrs = [
1587
+ "cache_creation_input_tokens",
1588
+ "cache_read_input_tokens",
1589
+ "cached_content_token_count",
1590
+ ]
1591
+ for attr in cache_attrs:
1592
+ if hasattr(current_usage, attr) and hasattr(accumulated_usage, attr):
1593
+ current_val = getattr(current_usage, attr) or 0
1594
+ accumulated_val = getattr(accumulated_usage, attr) or 0
1595
+ setattr(accumulated_usage, attr, accumulated_val + current_val)
1406
1596
 
1407
1597
  for detail_attr in ["completion_tokens_details", "prompt_tokens_details"]:
1408
1598
  if not hasattr(current_usage, detail_attr) or not hasattr(
@@ -0,0 +1,23 @@
1
+ from ... import core
2
+ from ...backend.engines.drawing.engine_gemini_image import GeminiImageResult
3
+ from ...symbol import Expression
4
+
5
+
6
+ class nanobanana(Expression):
7
+ def __init__(self, *args, **kwargs):
8
+ super().__init__(*args, **kwargs)
9
+ self.name = self.__class__.__name__
10
+
11
+ def __call__(
12
+ self,
13
+ prompt: str | None = None,
14
+ operation: str = "create",
15
+ **kwargs,
16
+ ) -> list:
17
+ prompt = self._to_symbol(prompt)
18
+
19
+ @core.draw(operation=operation, **kwargs)
20
+ def _func(_) -> GeminiImageResult:
21
+ pass
22
+
23
+ return _func(prompt).value