npcpy 1.3.16__tar.gz → 1.3.18__tar.gz

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 (93) hide show
  1. {npcpy-1.3.16/npcpy.egg-info → npcpy-1.3.18}/PKG-INFO +1 -1
  2. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/data/web.py +0 -1
  3. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/gen/response.py +160 -2
  4. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/memory/command_history.py +14 -5
  5. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/ml_funcs.py +61 -16
  6. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/npc_array.py +149 -1
  7. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/npc_compiler.py +23 -12
  8. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/npc_sysenv.py +183 -8
  9. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/serve.py +846 -73
  10. {npcpy-1.3.16 → npcpy-1.3.18/npcpy.egg-info}/PKG-INFO +1 -1
  11. {npcpy-1.3.16 → npcpy-1.3.18}/setup.py +1 -1
  12. {npcpy-1.3.16 → npcpy-1.3.18}/tests/test_ml_funcs.py +58 -16
  13. {npcpy-1.3.16 → npcpy-1.3.18}/tests/test_npc_array.py +111 -0
  14. {npcpy-1.3.16 → npcpy-1.3.18}/tests/test_npc_compiler.py +208 -0
  15. {npcpy-1.3.16 → npcpy-1.3.18}/tests/test_npc_sysenv.py +66 -0
  16. {npcpy-1.3.16 → npcpy-1.3.18}/LICENSE +0 -0
  17. {npcpy-1.3.16 → npcpy-1.3.18}/MANIFEST.in +0 -0
  18. {npcpy-1.3.16 → npcpy-1.3.18}/README.md +0 -0
  19. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/__init__.py +0 -0
  20. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/build_funcs.py +0 -0
  21. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/data/__init__.py +0 -0
  22. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/data/audio.py +0 -0
  23. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/data/data_models.py +0 -0
  24. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/data/image.py +0 -0
  25. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/data/load.py +0 -0
  26. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/data/text.py +0 -0
  27. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/data/video.py +0 -0
  28. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/ft/__init__.py +0 -0
  29. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/ft/diff.py +0 -0
  30. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/ft/ge.py +0 -0
  31. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/ft/memory_trainer.py +0 -0
  32. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/ft/model_ensembler.py +0 -0
  33. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/ft/rl.py +0 -0
  34. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/ft/sft.py +0 -0
  35. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/ft/usft.py +0 -0
  36. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/gen/__init__.py +0 -0
  37. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/gen/audio_gen.py +0 -0
  38. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/gen/embeddings.py +0 -0
  39. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/gen/image_gen.py +0 -0
  40. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/gen/ocr.py +0 -0
  41. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/gen/video_gen.py +0 -0
  42. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/gen/world_gen.py +0 -0
  43. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/llm_funcs.py +0 -0
  44. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/main.py +0 -0
  45. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/memory/__init__.py +0 -0
  46. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/memory/kg_vis.py +0 -0
  47. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/memory/knowledge_graph.py +0 -0
  48. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/memory/memory_processor.py +0 -0
  49. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/memory/search.py +0 -0
  50. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/mix/__init__.py +0 -0
  51. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/mix/debate.py +0 -0
  52. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/npcs.py +0 -0
  53. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/sql/__init__.py +0 -0
  54. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/sql/ai_function_tools.py +0 -0
  55. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/sql/database_ai_adapters.py +0 -0
  56. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/sql/database_ai_functions.py +0 -0
  57. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/sql/model_runner.py +0 -0
  58. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/sql/npcsql.py +0 -0
  59. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/sql/sql_model_compiler.py +0 -0
  60. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/tools.py +0 -0
  61. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/work/__init__.py +0 -0
  62. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/work/browser.py +0 -0
  63. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/work/desktop.py +0 -0
  64. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/work/plan.py +0 -0
  65. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy/work/trigger.py +0 -0
  66. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy.egg-info/SOURCES.txt +0 -0
  67. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy.egg-info/dependency_links.txt +0 -0
  68. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy.egg-info/requires.txt +0 -0
  69. {npcpy-1.3.16 → npcpy-1.3.18}/npcpy.egg-info/top_level.txt +0 -0
  70. {npcpy-1.3.16 → npcpy-1.3.18}/setup.cfg +0 -0
  71. {npcpy-1.3.16 → npcpy-1.3.18}/tests/test_audio.py +0 -0
  72. {npcpy-1.3.16 → npcpy-1.3.18}/tests/test_browser.py +0 -0
  73. {npcpy-1.3.16 → npcpy-1.3.18}/tests/test_build_funcs.py +0 -0
  74. {npcpy-1.3.16 → npcpy-1.3.18}/tests/test_command_history.py +0 -0
  75. {npcpy-1.3.16 → npcpy-1.3.18}/tests/test_data_models.py +0 -0
  76. {npcpy-1.3.16 → npcpy-1.3.18}/tests/test_diff.py +0 -0
  77. {npcpy-1.3.16 → npcpy-1.3.18}/tests/test_documentation_examples.py +0 -0
  78. {npcpy-1.3.16 → npcpy-1.3.18}/tests/test_genetic_evolver.py +0 -0
  79. {npcpy-1.3.16 → npcpy-1.3.18}/tests/test_image.py +0 -0
  80. {npcpy-1.3.16 → npcpy-1.3.18}/tests/test_llm_funcs.py +0 -0
  81. {npcpy-1.3.16 → npcpy-1.3.18}/tests/test_load.py +0 -0
  82. {npcpy-1.3.16 → npcpy-1.3.18}/tests/test_memory_processor.py +0 -0
  83. {npcpy-1.3.16 → npcpy-1.3.18}/tests/test_model_runner.py +0 -0
  84. {npcpy-1.3.16 → npcpy-1.3.18}/tests/test_npcsql.py +0 -0
  85. {npcpy-1.3.16 → npcpy-1.3.18}/tests/test_response.py +0 -0
  86. {npcpy-1.3.16 → npcpy-1.3.18}/tests/test_serve.py +0 -0
  87. {npcpy-1.3.16 → npcpy-1.3.18}/tests/test_sql_adapters.py +0 -0
  88. {npcpy-1.3.16 → npcpy-1.3.18}/tests/test_sql_compiler.py +0 -0
  89. {npcpy-1.3.16 → npcpy-1.3.18}/tests/test_sql_functions.py +0 -0
  90. {npcpy-1.3.16 → npcpy-1.3.18}/tests/test_text.py +0 -0
  91. {npcpy-1.3.16 → npcpy-1.3.18}/tests/test_tools.py +0 -0
  92. {npcpy-1.3.16 → npcpy-1.3.18}/tests/test_video.py +0 -0
  93. {npcpy-1.3.16 → npcpy-1.3.18}/tests/test_web.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: npcpy
3
- Version: 1.3.16
3
+ Version: 1.3.18
4
4
  Summary: npcpy is the premier open-source library for integrating LLMs and Agents into python systems.
5
5
  Home-page: https://github.com/NPC-Worldwide/npcpy
6
6
  Author: Christopher Agostino
@@ -66,7 +66,6 @@ def search_perplexity(
66
66
  "top_p": top_p,
67
67
  "return_images": False,
68
68
  "return_related_questions": False,
69
- "search_recency_filter": "month",
70
69
  "top_k": 0,
71
70
  "stream": False,
72
71
  "presence_penalty": 0,
@@ -588,6 +588,148 @@ def get_ollama_response(
588
588
  import time
589
589
 
590
590
 
591
+ def get_lora_response(
592
+ prompt: str = None,
593
+ model: str = None,
594
+ tools: list = None,
595
+ tool_map: Dict = None,
596
+ format: str = None,
597
+ messages: List[Dict[str, str]] = None,
598
+ stream: bool = False,
599
+ auto_process_tool_calls: bool = False,
600
+ **kwargs,
601
+ ) -> Dict[str, Any]:
602
+ """
603
+ Generate response using a LoRA adapter on top of a base model.
604
+ The adapter path should contain adapter_config.json with base_model_name_or_path.
605
+ """
606
+ print(f"🎯 get_lora_response called with model={model}, prompt={prompt[:50] if prompt else 'None'}...")
607
+
608
+ result = {
609
+ "response": None,
610
+ "messages": messages.copy() if messages else [],
611
+ "raw_response": None,
612
+ "tool_calls": [],
613
+ "tool_results": []
614
+ }
615
+
616
+ try:
617
+ import torch
618
+ from transformers import AutoTokenizer, AutoModelForCausalLM
619
+ from peft import PeftModel
620
+ print("🎯 Successfully imported torch, transformers, peft")
621
+ except ImportError as e:
622
+ print(f"🎯 Import error: {e}")
623
+ return {
624
+ "response": "",
625
+ "messages": messages or [],
626
+ "error": f"Missing dependencies for LoRA. Install with: pip install transformers peft torch. Error: {e}"
627
+ }
628
+
629
+ adapter_path = os.path.expanduser(model)
630
+ adapter_config_path = os.path.join(adapter_path, 'adapter_config.json')
631
+
632
+ if not os.path.exists(adapter_config_path):
633
+ return {
634
+ "response": "",
635
+ "messages": messages or [],
636
+ "error": f"No adapter_config.json found at {adapter_path}"
637
+ }
638
+
639
+ # Read base model from adapter config
640
+ try:
641
+ with open(adapter_config_path, 'r') as f:
642
+ adapter_config = json.load(f)
643
+ base_model_id = adapter_config.get('base_model_name_or_path')
644
+ if not base_model_id:
645
+ return {
646
+ "response": "",
647
+ "messages": messages or [],
648
+ "error": "adapter_config.json missing base_model_name_or_path"
649
+ }
650
+ except Exception as e:
651
+ return {
652
+ "response": "",
653
+ "messages": messages or [],
654
+ "error": f"Failed to read adapter config: {e}"
655
+ }
656
+
657
+ if prompt:
658
+ if result['messages'] and result['messages'][-1]["role"] == "user":
659
+ result['messages'][-1]["content"] = prompt
660
+ else:
661
+ result['messages'].append({"role": "user", "content": prompt})
662
+
663
+ if format == "json":
664
+ json_instruction = """If you are returning a json object, begin directly with the opening {.
665
+ Do not include any additional markdown formatting or leading ```json tags in your response."""
666
+ if result["messages"] and result["messages"][-1]["role"] == "user":
667
+ result["messages"][-1]["content"] += "\n" + json_instruction
668
+
669
+ try:
670
+ logger.info(f"Loading base model: {base_model_id}")
671
+ tokenizer = AutoTokenizer.from_pretrained(base_model_id, trust_remote_code=True)
672
+ base_model = AutoModelForCausalLM.from_pretrained(
673
+ base_model_id,
674
+ torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32,
675
+ device_map="auto" if torch.cuda.is_available() else None,
676
+ trust_remote_code=True
677
+ )
678
+
679
+ if tokenizer.pad_token is None:
680
+ tokenizer.pad_token = tokenizer.eos_token
681
+
682
+ logger.info(f"Loading LoRA adapter: {adapter_path}")
683
+ model_with_adapter = PeftModel.from_pretrained(base_model, adapter_path)
684
+
685
+ # Apply chat template
686
+ chat_text = tokenizer.apply_chat_template(
687
+ result["messages"],
688
+ tokenize=False,
689
+ add_generation_prompt=True
690
+ )
691
+ device = next(model_with_adapter.parameters()).device
692
+ inputs = tokenizer(chat_text, return_tensors="pt", padding=True, truncation=True)
693
+ inputs = {k: v.to(device) for k, v in inputs.items()}
694
+
695
+ max_new_tokens = kwargs.get("max_tokens", 512)
696
+ temperature = kwargs.get("temperature", 0.7)
697
+
698
+ with torch.no_grad():
699
+ outputs = model_with_adapter.generate(
700
+ **inputs,
701
+ max_new_tokens=max_new_tokens,
702
+ temperature=temperature,
703
+ do_sample=True,
704
+ pad_token_id=tokenizer.eos_token_id,
705
+ )
706
+
707
+ response_content = tokenizer.decode(
708
+ outputs[0][inputs['input_ids'].shape[1]:],
709
+ skip_special_tokens=True
710
+ ).strip()
711
+
712
+ result["response"] = response_content
713
+ result["raw_response"] = response_content
714
+ result["messages"].append({"role": "assistant", "content": response_content})
715
+
716
+ if format == "json":
717
+ try:
718
+ if response_content.startswith("```json"):
719
+ response_content = response_content.replace("```json", "").replace("```", "").strip()
720
+ parsed_response = json.loads(response_content)
721
+ result["response"] = parsed_response
722
+ except json.JSONDecodeError:
723
+ result["error"] = f"Invalid JSON response: {response_content}"
724
+
725
+ except Exception as e:
726
+ logger.error(f"LoRA inference error: {e}")
727
+ result["error"] = f"LoRA inference error: {str(e)}"
728
+ result["response"] = ""
729
+
730
+ return result
731
+
732
+
591
733
  def get_llamacpp_response(
592
734
  prompt: str = None,
593
735
  model: str = None,
@@ -730,7 +872,7 @@ def get_litellm_response(
730
872
  auto_process_tool_calls=auto_process_tool_calls,
731
873
  **kwargs
732
874
  )
733
- elif provider=='transformers':
875
+ elif provider == 'transformers':
734
876
  return get_transformers_response(
735
877
  prompt,
736
878
  model,
@@ -745,8 +887,24 @@ def get_litellm_response(
745
887
  attachments=attachments,
746
888
  auto_process_tool_calls=auto_process_tool_calls,
747
889
  **kwargs
748
-
749
890
  )
891
+ elif provider == 'lora':
892
+ print(f"🔧 LoRA provider detected, calling get_lora_response with model: {model}")
893
+ result = get_lora_response(
894
+ prompt=prompt,
895
+ model=model,
896
+ tools=tools,
897
+ tool_map=tool_map,
898
+ format=format,
899
+ messages=messages,
900
+ stream=stream,
901
+ auto_process_tool_calls=auto_process_tool_calls,
902
+ **kwargs
903
+ )
904
+ print(f"🔧 LoRA response: {result.get('response', 'NO RESPONSE')[:200] if result.get('response') else 'EMPTY'}")
905
+ if result.get('error'):
906
+ print(f"🔧 LoRA error: {result.get('error')}")
907
+ return result
750
908
  elif provider == 'llamacpp':
751
909
  return get_llamacpp_response(
752
910
  prompt,
@@ -611,7 +611,9 @@ class CommandHistory:
611
611
  Column('reasoning_content', Text), # For thinking tokens / chain of thought
612
612
  Column('tool_calls', Text), # JSON array of tool calls made by assistant
613
613
  Column('tool_results', Text), # JSON array of tool call results
614
- Column('parent_message_id', String(50)) # Links assistant response to parent user message for broadcast grouping
614
+ Column('parent_message_id', String(50)), # Links assistant response to parent user message for broadcast grouping
615
+ Column('device_id', String(255)), # UUID of the device that created this message
616
+ Column('device_name', String(255)) # Human-readable device name
615
617
  )
616
618
 
617
619
  Table('message_attachments', metadata,
@@ -867,6 +869,8 @@ class CommandHistory:
867
869
  tool_calls=None,
868
870
  tool_results=None,
869
871
  parent_message_id=None,
872
+ device_id=None,
873
+ device_name=None,
870
874
  ):
871
875
  if isinstance(content, (dict, list)):
872
876
  content = json.dumps(content, cls=CustomJSONEncoder)
@@ -882,14 +886,15 @@ class CommandHistory:
882
886
 
883
887
  stmt = """
884
888
  INSERT INTO conversation_history
885
- (message_id, timestamp, role, content, conversation_id, directory_path, model, provider, npc, team, reasoning_content, tool_calls, tool_results, parent_message_id)
886
- VALUES (:message_id, :timestamp, :role, :content, :conversation_id, :directory_path, :model, :provider, :npc, :team, :reasoning_content, :tool_calls, :tool_results, :parent_message_id)
889
+ (message_id, timestamp, role, content, conversation_id, directory_path, model, provider, npc, team, reasoning_content, tool_calls, tool_results, parent_message_id, device_id, device_name)
890
+ VALUES (:message_id, :timestamp, :role, :content, :conversation_id, :directory_path, :model, :provider, :npc, :team, :reasoning_content, :tool_calls, :tool_results, :parent_message_id, :device_id, :device_name)
887
891
  """
888
892
  params = {
889
893
  "message_id": message_id, "timestamp": timestamp, "role": role, "content": content,
890
894
  "conversation_id": conversation_id, "directory_path": normalized_directory_path, "model": model,
891
895
  "provider": provider, "npc": npc, "team": team, "reasoning_content": reasoning_content,
892
- "tool_calls": tool_calls, "tool_results": tool_results, "parent_message_id": parent_message_id
896
+ "tool_calls": tool_calls, "tool_results": tool_results, "parent_message_id": parent_message_id,
897
+ "device_id": device_id, "device_name": device_name
893
898
  }
894
899
  with self.engine.begin() as conn:
895
900
  conn.execute(text(stmt), params)
@@ -1461,6 +1466,8 @@ def save_conversation_message(
1461
1466
  tool_results: List[Dict] = None,
1462
1467
  parent_message_id: str = None,
1463
1468
  skip_if_exists: bool = True,
1469
+ device_id: str = None,
1470
+ device_name: str = None,
1464
1471
  ):
1465
1472
  """
1466
1473
  Saves a conversation message linked to a conversation ID with optional attachments.
@@ -1495,7 +1502,9 @@ def save_conversation_message(
1495
1502
  reasoning_content=reasoning_content,
1496
1503
  tool_calls=tool_calls,
1497
1504
  tool_results=tool_results,
1498
- parent_message_id=parent_message_id)
1505
+ parent_message_id=parent_message_id,
1506
+ device_id=device_id,
1507
+ device_name=device_name)
1499
1508
  def retrieve_last_conversation(
1500
1509
  command_history: CommandHistory, conversation_id: str
1501
1510
  ) -> str:
@@ -16,7 +16,6 @@ Same interface pattern as llm_funcs:
16
16
  from __future__ import annotations
17
17
  import copy
18
18
  import itertools
19
- import pickle
20
19
  from concurrent.futures import ThreadPoolExecutor, as_completed
21
20
  from dataclasses import dataclass, field
22
21
  from typing import Any, Callable, Dict, List, Optional, Tuple, Union
@@ -708,21 +707,67 @@ def cross_validate(
708
707
 
709
708
  # ==================== Utility Functions ====================
710
709
 
711
- def serialize_model(model: Any, path: str = None) -> bytes:
712
- """Serialize model to bytes or file"""
713
- data = pickle.dumps(model)
714
- if path:
715
- with open(path, 'wb') as f:
716
- f.write(data)
717
- return data
718
-
719
-
720
- def deserialize_model(data: Union[bytes, str]) -> Any:
721
- """Deserialize model from bytes or file path"""
722
- if isinstance(data, str):
723
- with open(data, 'rb') as f:
724
- data = f.read()
725
- return pickle.loads(data)
710
+ def serialize_model(model: Any, path: str, format: str = "joblib") -> None:
711
+ """
712
+ Serialize model to file using safe formats (no pickle).
713
+
714
+ Args:
715
+ model: The model to serialize
716
+ path: File path to write to (required)
717
+ format: Serialization format - "joblib" (default) or "safetensors"
718
+
719
+ Raises:
720
+ ImportError: If required library is not installed
721
+ ValueError: If format is not supported for the model type
722
+ """
723
+ if format == "safetensors":
724
+ from safetensors.torch import save_file
725
+ if hasattr(model, 'state_dict'):
726
+ save_file(model.state_dict(), path)
727
+ else:
728
+ raise ValueError("safetensors format requires model with state_dict (PyTorch)")
729
+ elif format == "joblib":
730
+ import joblib
731
+ joblib.dump(model, path)
732
+ else:
733
+ raise ValueError(f"Unsupported format: {format}. Use 'joblib' or 'safetensors'.")
734
+
735
+
736
+ def deserialize_model(path: str, format: str = "auto") -> Any:
737
+ """
738
+ Deserialize model from file using safe formats (no pickle).
739
+
740
+ Args:
741
+ path: File path to load from
742
+ format: "auto" (detect from extension), "joblib", or "safetensors"
743
+
744
+ Returns:
745
+ The deserialized model
746
+
747
+ Raises:
748
+ ImportError: If required library is not installed
749
+ ValueError: If format cannot be determined
750
+ """
751
+ # Auto-detect format from extension
752
+ if format == "auto":
753
+ if path.endswith('.safetensors'):
754
+ format = "safetensors"
755
+ elif path.endswith('.joblib'):
756
+ format = "joblib"
757
+ else:
758
+ raise ValueError(
759
+ f"Cannot auto-detect format for {path}. "
760
+ "Use .joblib or .safetensors extension, or specify format explicitly."
761
+ )
762
+
763
+ if format == "safetensors":
764
+ from safetensors.torch import load_file
765
+ return load_file(path)
766
+ elif format == "joblib":
767
+ import joblib
768
+ return joblib.load(path)
769
+ else:
770
+ raise ValueError(f"Unsupported format: {format}. Use 'joblib' or 'safetensors'.")
726
771
 
727
772
 
728
773
  def get_model_params(model: Any) -> Dict[str, Any]:
@@ -20,7 +20,6 @@ Example:
20
20
  from __future__ import annotations
21
21
  import copy
22
22
  import itertools
23
- import pickle
24
23
  from concurrent.futures import ThreadPoolExecutor, as_completed
25
24
  from dataclasses import dataclass, field
26
25
  from typing import (
@@ -47,6 +46,7 @@ class OpType(Enum):
47
46
  REDUCE = "reduce"
48
47
  CHAIN = "chain"
49
48
  EVOLVE = "evolve"
49
+ JINX = "jinx" # Execute a Jinx workflow across models
50
50
 
51
51
 
52
52
  @dataclass
@@ -328,6 +328,61 @@ class NPCArray:
328
328
 
329
329
  return cls(specs)
330
330
 
331
+ @classmethod
332
+ def from_matrix(
333
+ cls,
334
+ matrix: List[Dict[str, Any]]
335
+ ) -> 'NPCArray':
336
+ """
337
+ Create NPCArray from a matrix of model configurations.
338
+
339
+ This is particularly useful for defining model arrays in Jinx templates
340
+ where you want explicit control over each model configuration.
341
+
342
+ Args:
343
+ matrix: List of model configuration dicts. Each dict should have:
344
+ - 'model': model name/reference (required)
345
+ - 'provider': provider name (optional)
346
+ - 'type': model type - 'llm', 'npc', 'sklearn', 'torch' (default: 'llm')
347
+ - Any additional config parameters
348
+
349
+ Example:
350
+ >>> # In a Jinx template, define a matrix of models:
351
+ >>> matrix = [
352
+ ... {'model': 'gpt-4', 'provider': 'openai', 'temperature': 0.7},
353
+ ... {'model': 'claude-3-opus', 'provider': 'anthropic', 'temperature': 0.5},
354
+ ... {'model': 'llama3.2', 'provider': 'ollama', 'temperature': 0.8},
355
+ ... ]
356
+ >>> arr = NPCArray.from_matrix(matrix)
357
+
358
+ >>> # Mixed model types:
359
+ >>> matrix = [
360
+ ... {'model': 'gpt-4', 'type': 'llm', 'provider': 'openai'},
361
+ ... {'model': my_npc, 'type': 'npc'},
362
+ ... {'model': sklearn_model, 'type': 'sklearn'},
363
+ ... ]
364
+ """
365
+ specs = []
366
+ for config in matrix:
367
+ model_type = config.get('type', 'llm')
368
+ model_ref = config.get('model')
369
+ provider = config.get('provider')
370
+
371
+ # Extract config params (everything except type, model, provider)
372
+ extra_config = {
373
+ k: v for k, v in config.items()
374
+ if k not in ('type', 'model', 'provider')
375
+ }
376
+
377
+ specs.append(ModelSpec(
378
+ model_type=model_type,
379
+ model_ref=model_ref,
380
+ provider=provider,
381
+ config=extra_config
382
+ ))
383
+
384
+ return cls(specs)
385
+
331
386
  # ==================== Properties ====================
332
387
 
333
388
  @property
@@ -490,6 +545,43 @@ class NPCArray:
490
545
 
491
546
  return NPCArray(self._specs, new_node)
492
547
 
548
+ def jinx(
549
+ self,
550
+ jinx_name: str,
551
+ inputs: Optional[Dict[str, Any]] = None,
552
+ **kwargs
553
+ ) -> 'LazyResult':
554
+ """
555
+ Execute a Jinx workflow across all models in the array.
556
+
557
+ Each model in the array will be used as the 'npc' context for the jinx,
558
+ allowing you to run the same workflow template with different models.
559
+
560
+ Args:
561
+ jinx_name: Name of the jinx workflow to execute (e.g., 'analyze', 'summarize')
562
+ inputs: Input values for the jinx template variables
563
+ **kwargs: Additional execution parameters
564
+
565
+ Returns:
566
+ LazyResult with workflow outputs from each model
567
+
568
+ Example:
569
+ >>> models = NPCArray.from_llms(['gpt-4', 'claude-3'])
570
+ >>> results = models.jinx('analyze', inputs={'topic': 'AI safety'}).collect()
571
+ """
572
+ new_node = GraphNode(
573
+ op_type=OpType.JINX,
574
+ params={
575
+ "jinx_name": jinx_name,
576
+ "inputs": inputs or {},
577
+ **kwargs
578
+ },
579
+ parents=[self._graph],
580
+ shape=(len(self._specs),)
581
+ )
582
+
583
+ return LazyResult(self._specs, new_node)
584
+
493
585
 
494
586
  class LazyResult:
495
587
  """
@@ -792,6 +884,7 @@ class GraphExecutor:
792
884
  OpType.REDUCE: self._exec_reduce,
793
885
  OpType.CHAIN: self._exec_chain,
794
886
  OpType.EVOLVE: self._exec_evolve,
887
+ OpType.JINX: self._exec_jinx,
795
888
  }
796
889
 
797
890
  handler = handlers.get(node.op_type)
@@ -1136,6 +1229,61 @@ class GraphExecutor:
1136
1229
  metadata={"operation": "evolve", "generation": 1}
1137
1230
  )
1138
1231
 
1232
+ def _exec_jinx(self, node, specs, prompts, parents) -> ResponseTensor:
1233
+ """Execute a Jinx workflow across models"""
1234
+ from npcpy.npc_compiler import NPC, Jinx
1235
+
1236
+ jinx_name = node.params.get("jinx_name")
1237
+ inputs = node.params.get("inputs", {})
1238
+ extra_kwargs = {k: v for k, v in node.params.items()
1239
+ if k not in ("jinx_name", "inputs")}
1240
+
1241
+ results = []
1242
+
1243
+ def run_jinx_single(spec: ModelSpec) -> str:
1244
+ """Run jinx for a single model spec"""
1245
+ try:
1246
+ if spec.model_type == "npc":
1247
+ # Use the NPC directly
1248
+ npc = spec.model_ref
1249
+ else:
1250
+ # Create a temporary NPC with the model
1251
+ npc = NPC(
1252
+ name=f"array_npc_{spec.model_ref}",
1253
+ model=spec.model_ref,
1254
+ provider=spec.provider
1255
+ )
1256
+
1257
+ # Execute the jinx
1258
+ result = npc.execute_jinx(
1259
+ jinx_name=jinx_name,
1260
+ input_values=inputs,
1261
+ **extra_kwargs
1262
+ )
1263
+ return result.get("output", str(result))
1264
+ except Exception as e:
1265
+ return f"Error: {e}"
1266
+
1267
+ if self.parallel and len(specs) > 1:
1268
+ with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
1269
+ futures = {executor.submit(run_jinx_single, spec): i
1270
+ for i, spec in enumerate(specs)}
1271
+ results = [None] * len(specs)
1272
+ for future in as_completed(futures):
1273
+ idx = futures[future]
1274
+ try:
1275
+ results[idx] = future.result()
1276
+ except Exception as e:
1277
+ results[idx] = f"Error: {e}"
1278
+ else:
1279
+ results = [run_jinx_single(spec) for spec in specs]
1280
+
1281
+ return ResponseTensor(
1282
+ data=np.array(results, dtype=object),
1283
+ model_specs=specs,
1284
+ metadata={"operation": "jinx", "jinx_name": jinx_name, **inputs}
1285
+ )
1286
+
1139
1287
 
1140
1288
  def _compute_response_variance(responses: List[str]) -> float:
1141
1289
  """Compute semantic variance across responses"""
@@ -52,6 +52,7 @@ import fnmatch
52
52
  import subprocess
53
53
  from typing import Any, Dict, List, Optional, Union, Callable, Tuple
54
54
  from jinja2 import Environment, FileSystemLoader, Template, Undefined, DictLoader
55
+ from jinja2.sandbox import SandboxedEnvironment
55
56
  from sqlalchemy import create_engine, text
56
57
  import npcpy as npy
57
58
  from npcpy.tools import auto_tools
@@ -231,7 +232,8 @@ def load_yaml_file(file_path):
231
232
 
232
233
  # First pass: render Jinja2 templates to produce valid YAML
233
234
  # This allows {% if %} and other control structures to work
234
- jinja_env = Environment(undefined=SilentUndefined)
235
+ # Use SandboxedEnvironment to prevent template injection attacks
236
+ jinja_env = SandboxedEnvironment(undefined=SilentUndefined)
235
237
  # Configure tojson filter to handle SilentUndefined
236
238
  jinja_env.policies['json.dumps_function'] = _json_dumps_with_undefined
237
239
  template = jinja_env.from_string(content)
@@ -694,7 +696,8 @@ class Jinx:
694
696
  jinja_env: Optional[Environment] = None):
695
697
 
696
698
  if jinja_env is None:
697
- jinja_env = Environment(
699
+ # Use SandboxedEnvironment to prevent template injection attacks
700
+ jinja_env = SandboxedEnvironment(
698
701
  loader=DictLoader({}),
699
702
  undefined=SilentUndefined,
700
703
  )
@@ -771,21 +774,24 @@ class Jinx:
771
774
 
772
775
  self._log_debug(f"DEBUG: Executing step '{step_name}' with rendered code: {rendered_code}")
773
776
 
777
+ # Import NPCArray for array operations in jinx
778
+ from npcpy.npc_array import NPCArray, infer_matrix, ensemble_vote
779
+
774
780
  exec_globals = {
775
781
  "__builtins__": __builtins__,
776
782
  "npc": active_npc,
777
783
  "context": context, # Pass context by reference
778
- "math": math,
779
- "random": random,
784
+ "math": math,
785
+ "random": random,
780
786
  "datetime": datetime,
781
787
  "Image": Image,
782
788
  "pd": pd,
783
789
  "plt": plt,
784
- "sys": sys,
790
+ "sys": sys,
785
791
  "subprocess": subprocess,
786
792
  "np": np,
787
793
  "os": os,
788
- 're': re,
794
+ 're': re,
789
795
  "json": json,
790
796
  "Path": pathlib.Path,
791
797
  "fnmatch": fnmatch,
@@ -793,6 +799,10 @@ class Jinx:
793
799
  "subprocess": subprocess,
794
800
  "get_llm_response": npy.llm_funcs.get_llm_response,
795
801
  "CommandHistory": CommandHistory,
802
+ # NPCArray support for compute graph operations in jinx
803
+ "NPCArray": NPCArray,
804
+ "infer_matrix": infer_matrix,
805
+ "ensemble_vote": ensemble_vote,
796
806
  }
797
807
 
798
808
  if extra_globals:
@@ -1261,7 +1271,8 @@ class NPC:
1261
1271
  dirs.append(self.jinxs_directory)
1262
1272
 
1263
1273
  # This jinja_env is for the *second pass* (runtime variable resolution in Jinx.execute)
1264
- self.jinja_env = Environment(
1274
+ # Use SandboxedEnvironment to prevent template injection attacks
1275
+ self.jinja_env = SandboxedEnvironment(
1265
1276
  loader=FileSystemLoader([
1266
1277
  os.path.expanduser(d) for d in dirs
1267
1278
  ]),
@@ -1389,13 +1400,13 @@ class NPC:
1389
1400
 
1390
1401
  combined_raw_jinxs_dict = {j.jinx_name: j for j in all_available_raw_jinxs}
1391
1402
 
1392
- npc_first_pass_jinja_env = Environment(undefined=SilentUndefined)
1393
-
1403
+ npc_first_pass_jinja_env = SandboxedEnvironment(undefined=SilentUndefined)
1404
+
1394
1405
  jinx_macro_globals = {}
1395
1406
  for raw_jinx in combined_raw_jinxs_dict.values():
1396
1407
  def create_jinx_callable(jinx_obj_in_closure):
1397
1408
  def callable_jinx(**kwargs):
1398
- temp_jinja_env = Environment(undefined=SilentUndefined)
1409
+ temp_jinja_env = SandboxedEnvironment(undefined=SilentUndefined)
1399
1410
  rendered_target_steps = []
1400
1411
  for target_step in jinx_obj_in_closure._raw_steps:
1401
1412
  temp_rendered_step = {}
@@ -2506,7 +2517,7 @@ class Team:
2506
2517
  self._raw_jinxs_list: List['Jinx'] = [] # Temporary storage for raw Team-level Jinx objects
2507
2518
  self.jinx_tool_catalog: Dict[str, Dict[str, Any]] = {} # Jinx-derived tool defs ready for MCP/LLM
2508
2519
 
2509
- self.jinja_env_for_first_pass = Environment(undefined=SilentUndefined) # Env for macro expansion
2520
+ self.jinja_env_for_first_pass = SandboxedEnvironment(undefined=SilentUndefined) # Env for macro expansion
2510
2521
 
2511
2522
  self.db_conn = db_conn
2512
2523
  self.team_path = os.path.expanduser(team_path) if team_path else None
@@ -2700,7 +2711,7 @@ class Team:
2700
2711
  def callable_jinx(**kwargs):
2701
2712
  # This callable will be invoked by the Jinja renderer during the first pass.
2702
2713
  # It needs to render the target Jinx's *raw* steps with the provided kwargs.
2703
- temp_jinja_env = Environment(undefined=SilentUndefined)
2714
+ temp_jinja_env = SandboxedEnvironment(undefined=SilentUndefined)
2704
2715
 
2705
2716
  rendered_target_steps = []
2706
2717
  for target_step in jinx_obj_in_closure._raw_steps: