entari-plugin-hyw 3.2.104__py3-none-any.whl → 3.2.106__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.

Potentially problematic release.


This version of entari-plugin-hyw might be problematic. Click here for more details.

@@ -20,19 +20,17 @@ class HYWConfig:
20
20
  extra_body: Optional[Dict[str, Any]] = None
21
21
  temperature: float = 0.4
22
22
  max_turns: int = 10
23
+ icon: str = "openai" # logo for primary model
24
+ vision_icon: Optional[str] = None # logo for vision model (falls back to icon when absent)
23
25
  enable_browser_fallback: bool = False
24
26
  vision_system_prompt: Optional[str] = None
25
27
  intruct_system_prompt: Optional[str] = None
26
28
  agent_system_prompt: Optional[str] = None
27
29
  playwright_mcp_command: str = "npx"
28
30
  playwright_mcp_args: Optional[List[str]] = None
29
- # Billing configuration (price per million tokens)
30
- # Main model pricing - if not set, billing is disabled
31
31
  input_price: Optional[float] = None # $ per 1M input tokens
32
32
  output_price: Optional[float] = None # $ per 1M output tokens
33
- # Vision model pricing overrides (defaults to main model pricing if not set)
34
33
  vision_input_price: Optional[float] = None
35
34
  vision_output_price: Optional[float] = None
36
- # Instruct model pricing overrides (defaults to main model pricing if not set)
37
35
  intruct_input_price: Optional[float] = None
38
36
  intruct_output_price: Optional[float] = None
@@ -111,6 +111,10 @@ class ProcessingPipeline:
111
111
 
112
112
  # Vision stage
113
113
  vision_text = ""
114
+ vision_start = time.time()
115
+ vision_time = 0
116
+ vision_cost = 0.0
117
+ vision_usage = {}
114
118
  if images:
115
119
  vision_model = (
116
120
  selected_vision_model
@@ -129,6 +133,15 @@ class ProcessingPipeline:
129
133
  # Add vision usage with vision-specific pricing
130
134
  usage_totals["input_tokens"] += vision_usage.get("input_tokens", 0)
131
135
  usage_totals["output_tokens"] += vision_usage.get("output_tokens", 0)
136
+
137
+ # Calculate Vision Cost
138
+ v_in_price = float(getattr(self.config, "vision_input_price", None) or getattr(self.config, "input_price", 0.0) or 0.0)
139
+ v_out_price = float(getattr(self.config, "vision_output_price", None) or getattr(self.config, "output_price", 0.0) or 0.0)
140
+ if v_in_price > 0 or v_out_price > 0:
141
+ vision_cost = (vision_usage.get("input_tokens", 0) / 1_000_000 * v_in_price) + (vision_usage.get("output_tokens", 0) / 1_000_000 * v_out_price)
142
+
143
+ vision_time = time.time() - vision_start
144
+
132
145
  trace["vision"] = {
133
146
  "model": vision_model,
134
147
  "base_url": getattr(self.config, "vision_base_url", None) or self.config.base_url,
@@ -137,18 +150,33 @@ class ProcessingPipeline:
137
150
  "images_count": len(images or []),
138
151
  "output": vision_text,
139
152
  "usage": vision_usage,
153
+ "time": vision_time,
154
+ "cost": vision_cost
140
155
  }
141
156
 
142
157
  # Intruct + pre-search
158
+ instruct_start = time.time()
143
159
  instruct_model = getattr(self.config, "intruct_model_name", None) or active_model
144
- instruct_text, search_payloads, intruct_trace, intruct_usage = await self._run_instruct_stage(
160
+ instruct_text, search_payloads, intruct_trace, intruct_usage, search_time = await self._run_instruct_stage(
145
161
  user_input=user_input,
146
162
  vision_text=vision_text,
147
163
  model=instruct_model,
148
164
  )
165
+ instruct_time = time.time() - instruct_start
166
+
167
+ # Calculate Instruct Cost
168
+ instruct_cost = 0.0
169
+ i_in_price = float(getattr(self.config, "intruct_input_price", None) or getattr(self.config, "input_price", 0.0) or 0.0)
170
+ i_out_price = float(getattr(self.config, "intruct_output_price", None) or getattr(self.config, "output_price", 0.0) or 0.0)
171
+ if i_in_price > 0 or i_out_price > 0:
172
+ instruct_cost = (intruct_usage.get("input_tokens", 0) / 1_000_000 * i_in_price) + (intruct_usage.get("output_tokens", 0) / 1_000_000 * i_out_price)
173
+
149
174
  # Add instruct usage
150
175
  usage_totals["input_tokens"] += intruct_usage.get("input_tokens", 0)
151
176
  usage_totals["output_tokens"] += intruct_usage.get("output_tokens", 0)
177
+
178
+ intruct_trace["time"] = instruct_time
179
+ intruct_trace["cost"] = instruct_cost
152
180
  trace["intruct"] = intruct_trace
153
181
 
154
182
  explicit_mcp_intent = "mcp" in (user_input or "").lower()
@@ -162,6 +190,7 @@ class ProcessingPipeline:
162
190
  logger.warning(f"MCP Playwright granted for this request: reason={intruct_trace.get('grant_reason')!r}")
163
191
 
164
192
  # Start agent loop
193
+ agent_start_time = time.time()
165
194
  current_history.append({"role": "user", "content": user_input or "..."})
166
195
 
167
196
  max_steps = 6
@@ -269,6 +298,22 @@ class ProcessingPipeline:
269
298
  structured = self._parse_tagged_response(final_response_content)
270
299
  final_content = structured.get("response") or final_response_content
271
300
 
301
+ agent_time = time.time() - agent_start_time
302
+
303
+ # Calculate Agent Cost (accumulated steps)
304
+ agent_cost = 0.0
305
+ a_in_price = float(getattr(self.config, "input_price", 0.0) or 0.0)
306
+ a_out_price = float(getattr(self.config, "output_price", 0.0) or 0.0)
307
+
308
+ # Agent usage is already in usage_totals, but that includes ALL stages.
309
+ # We need just Agent tokens for Agent cost.
310
+ # Agent inputs = Total inputs - Vision inputs - Instruct inputs
311
+ agent_input_tokens = usage_totals["input_tokens"] - vision_usage.get("input_tokens", 0) - intruct_usage.get("input_tokens", 0)
312
+ agent_output_tokens = usage_totals["output_tokens"] - vision_usage.get("output_tokens", 0) - intruct_usage.get("output_tokens", 0)
313
+
314
+ if a_in_price > 0 or a_out_price > 0:
315
+ agent_cost = (agent_input_tokens / 1_000_000 * a_in_price) + (agent_output_tokens / 1_000_000 * a_out_price)
316
+
272
317
  trace["agent"] = {
273
318
  "model": active_model,
274
319
  "base_url": self.config.base_url,
@@ -276,6 +321,8 @@ class ProcessingPipeline:
276
321
  "steps": agent_trace_steps,
277
322
  "final_output": final_response_content,
278
323
  "mcp_granted": grant_mcp,
324
+ "time": agent_time,
325
+ "cost": agent_cost
279
326
  }
280
327
  trace_markdown = self._render_trace_markdown(trace)
281
328
 
@@ -297,7 +344,96 @@ class ProcessingPipeline:
297
344
  input_cost = (usage_totals["input_tokens"] / 1_000_000) * input_price
298
345
  output_cost = (usage_totals["output_tokens"] / 1_000_000) * output_price
299
346
  billing_info["total_cost"] = input_cost + output_cost
300
- logger.info(f"Billing: {usage_totals['input_tokens']} in @ ${input_price}/M + {usage_totals['output_tokens']} out @ ${output_price}/M = ${billing_info['total_cost']:.6f}")
347
+ # logger.info(f"Billing: {usage_totals['input_tokens']} in @ ${input_price}/M + {usage_totals['output_tokens']} out @ ${output_price}/M = ${billing_info['total_cost']:.6f}")
348
+
349
+ # Build stages_used list for UI display
350
+ # Order: Vision (if used) -> Search (if performed) -> Agent
351
+ stages_used = []
352
+
353
+ # Helper to infer icon from model name or base_url
354
+ def infer_icon(model_name: str, base_url: str) -> str:
355
+ model_lower = (model_name or "").lower()
356
+ url_lower = (base_url or "").lower()
357
+
358
+ if "deepseek" in model_lower or "deepseek" in url_lower:
359
+ return "deepseek"
360
+ elif "claude" in model_lower or "anthropic" in url_lower:
361
+ return "anthropic"
362
+ elif "gemini" in model_lower or "google" in url_lower:
363
+ return "google"
364
+ elif "gpt" in model_lower or "openai" in url_lower:
365
+ return "openai"
366
+ elif "qwen" in model_lower:
367
+ return "qwen"
368
+ elif "openrouter" in url_lower:
369
+ return "openrouter"
370
+ return "openai" # Default fallback
371
+
372
+ # Helper to infer provider from base_url
373
+ def infer_provider(base_url: str) -> str:
374
+ url_lower = (base_url or "").lower()
375
+ if "openrouter" in url_lower:
376
+ return "OpenRouter"
377
+ elif "openai" in url_lower:
378
+ return "OpenAI"
379
+ elif "anthropic" in url_lower:
380
+ return "Anthropic"
381
+ elif "google" in url_lower:
382
+ return "Google"
383
+ elif "deepseek" in url_lower:
384
+ return "DeepSeek"
385
+ return "" # Empty string = don't show provider
386
+
387
+ if trace.get("vision"):
388
+ v = trace["vision"]
389
+ v_model = v.get("model", "")
390
+ v_base_url = v.get("base_url", "") or self.config.base_url
391
+ stages_used.append({
392
+ "name": "Vision",
393
+ "model": v_model,
394
+ "icon_config": getattr(self.config, "vision_icon", None) or infer_icon(v_model, v_base_url),
395
+ "provider": infer_provider(v_base_url),
396
+ "time": v.get("time", 0),
397
+ "cost": v.get("cost", 0.0)
398
+ })
399
+
400
+ if trace.get("intruct"):
401
+ i = trace["intruct"]
402
+ i_model = i.get("model", "")
403
+ i_base_url = i.get("base_url", "") or self.config.base_url
404
+ stages_used.append({
405
+ "name": "Instruct",
406
+ "model": i_model,
407
+ "icon_config": getattr(self.config, "intruct_icon", None) or infer_icon(i_model, i_base_url),
408
+ "provider": infer_provider(i_base_url),
409
+ "time": i.get("time", 0),
410
+ "cost": i.get("cost", 0.0)
411
+ })
412
+
413
+ # Show Search stage only when search was actually performed
414
+ if search_payloads:
415
+ # Use dedicated SearXNG metadata as requested
416
+ stages_used.append({
417
+ "name": "Search",
418
+ "model": "SearXNG",
419
+ "icon_config": "search", # Ensure mapping exists or handle specially in render
420
+ "provider": "SearXNG",
421
+ "time": search_time,
422
+ "cost": 0.0 # Search is free in this plugin
423
+ })
424
+
425
+ if trace.get("agent"):
426
+ a = trace["agent"]
427
+ a_model = a.get("model", "") or active_model
428
+ a_base_url = a.get("base_url", "") or self.config.base_url
429
+ stages_used.append({
430
+ "name": "Agent",
431
+ "model": a_model,
432
+ "icon_config": getattr(self.config, "icon", None) or infer_icon(a_model, a_base_url),
433
+ "provider": infer_provider(a_base_url),
434
+ "time": a.get("time", 0),
435
+ "cost": a.get("cost", 0.0)
436
+ })
301
437
 
302
438
  return {
303
439
  "llm_response": final_content,
@@ -308,6 +444,7 @@ class ProcessingPipeline:
308
444
  "conversation_history": current_history,
309
445
  "trace_markdown": trace_markdown,
310
446
  "billing_info": billing_info,
447
+ "stages_used": stages_used,
311
448
  }
312
449
 
313
450
  except Exception as e:
@@ -358,29 +495,53 @@ class ProcessingPipeline:
358
495
  current_step = None
359
496
 
360
497
  for line in lines:
361
- # Check if this is a tool line: [icon] tool_name
362
- tool_match = re.match(r'\[(\w+)\]\s+(.+)', line.strip())
363
- if tool_match:
364
- # Save previous step if exists
365
- if current_step:
366
- parsed["mcp_steps"].append(current_step)
498
+ line_stripped = line.strip()
499
+ if not line_stripped: continue
500
+
501
+ # New Format: "1. [icon] name: description" OR "[icon] name: description"
502
+ # Regex details:
503
+ # ^(?:(?:\d+\.|[-*])\s+)? -> Optional numbering (1. or - or *)
504
+ # \[(\w+)\] -> Icon in brackets [icon] -> group 1
505
+ # \s+ -> separating space
506
+ # ([^:]+) -> Tool Name (chars before colon) -> group 2
507
+ # : -> Colon separator
508
+ # \s*(.+) -> Description -> group 3
509
+ new_format_match = re.match(r'^(?:(?:\d+\.|[-*])\s+)?\[(\w+)\]\s+([^:]+):\s*(.+)$', line_stripped)
510
+
511
+ # Old/Flexible Format: "[icon] name" (description might be on next line)
512
+ flexible_match = re.match(r'^(?:(?:\d+\.|[-*])\s+)?\[(\w+)\]\s+(.+)$', line_stripped)
513
+
514
+ if new_format_match:
515
+ if current_step: parsed["mcp_steps"].append(current_step)
367
516
  current_step = {
368
- "icon": tool_match.group(1).lower(),
369
- "name": tool_match.group(2).strip(),
370
- "description": ""
517
+ "icon": new_format_match.group(1).lower(),
518
+ "name": new_format_match.group(2).strip(),
519
+ "description": new_format_match.group(3).strip()
371
520
  }
372
- elif line.startswith(" ") and current_step:
373
- # This is an indented description line
374
- current_step["description"] = line.strip()
375
- elif line.strip() and not line.startswith("[") and current_step is None:
376
- # Fallback: plain tool name without icon
521
+ elif flexible_match:
522
+ # Could be just "[icon] name" without description, or mixed
523
+ if current_step: parsed["mcp_steps"].append(current_step)
377
524
  current_step = {
378
- "icon": "",
379
- "name": line.strip(),
525
+ "icon": flexible_match.group(1).lower(),
526
+ "name": flexible_match.group(2).strip(),
380
527
  "description": ""
381
528
  }
529
+ elif line.startswith(" ") and current_step:
530
+ # Indented description line (continuation)
531
+ if current_step["description"]:
532
+ current_step["description"] += " " + line.strip()
533
+ else:
534
+ current_step["description"] = line.strip()
535
+ elif line_stripped and not line_stripped.startswith("[") and current_step is None:
536
+ # Plain text line without icon, treat as name if no current step
537
+ # (This handles cases where LLM forgets brackets but lists steps)
538
+ if current_step: parsed["mcp_steps"].append(current_step)
539
+ current_step = {
540
+ "icon": "default",
541
+ "name": line_stripped,
542
+ "description": ""
543
+ }
382
544
 
383
- # Don't forget the last step
384
545
  if current_step:
385
546
  parsed["mcp_steps"].append(current_step)
386
547
  remaining_text = remaining_text.replace(mcp_block_match.group(0), "").strip()
@@ -470,8 +631,8 @@ class ProcessingPipeline:
470
631
 
471
632
  async def _run_instruct_stage(
472
633
  self, user_input: str, vision_text: str, model: str
473
- ) -> Tuple[str, List[str], Dict[str, Any], Dict[str, int]]:
474
- """Returns (instruct_text, search_payloads, trace_dict, usage_dict)."""
634
+ ) -> Tuple[str, List[str], Dict[str, Any], Dict[str, int], float]:
635
+ """Returns (instruct_text, search_payloads, trace_dict, usage_dict, search_time)."""
475
636
  tools = [self.web_search_tool, self.grant_mcp_playwright_tool]
476
637
  tools_desc = "\n".join([t["function"]["name"] for t in tools])
477
638
 
@@ -511,12 +672,20 @@ class ProcessingPipeline:
511
672
  "tool_results": [],
512
673
  "output": "",
513
674
  }
675
+
676
+ search_time = 0.0
677
+
514
678
  if response.tool_calls:
515
679
  plan_dict = response.model_dump() if hasattr(response, "model_dump") else response
516
680
  history.append(plan_dict)
517
681
 
518
682
  tasks = [self._safe_route_tool(tc) for tc in response.tool_calls]
683
+
684
+ # Measure search/tool execution time
685
+ st = time.time()
519
686
  results = await asyncio.gather(*tasks)
687
+ search_time = time.time() - st
688
+
520
689
  for i, result in enumerate(results):
521
690
  tc = response.tool_calls[i]
522
691
  history.append(
@@ -537,11 +706,11 @@ class ProcessingPipeline:
537
706
  # and the grant decision; avoid wasting tokens/time.
538
707
  intruct_trace["output"] = ""
539
708
  intruct_trace["usage"] = usage
540
- return "", search_payloads, intruct_trace, usage
709
+ return "", search_payloads, intruct_trace, usage, search_time
541
710
 
542
711
  intruct_trace["output"] = (response.content or "").strip()
543
712
  intruct_trace["usage"] = usage
544
- return "", search_payloads, intruct_trace, usage
713
+ return "", search_payloads, intruct_trace, usage, 0.0
545
714
 
546
715
  def _format_search_msgs(self, search_payloads: List[str]) -> str:
547
716
  """