pal-code 0.1.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 (282) hide show
  1. pal/__init__.py +29 -0
  2. pal/__main__.py +3 -0
  3. pal/access_manager/__init__.py +16 -0
  4. pal/access_manager/components/__init__.py +14 -0
  5. pal/access_manager/components/access_control.py +41 -0
  6. pal/access_manager/components/base.py +39 -0
  7. pal/access_manager/components/intervention.py +31 -0
  8. pal/access_manager/components/privilege_registry.py +30 -0
  9. pal/access_manager/pipeline.py +68 -0
  10. pal/access_manager/utils/__init__.py +2 -0
  11. pal/agent_constraints/__init__.py +3 -0
  12. pal/agent_constraints/components/AGENT_OUTPUT.md +13 -0
  13. pal/agent_constraints/components/__init__.py +3 -0
  14. pal/agent_constraints/components/constraints.py +22 -0
  15. pal/agent_constraints/pipeline.py +22 -0
  16. pal/agent_constraints/utils/__init__.py +2 -0
  17. pal/agents/__init__.py +12 -0
  18. pal/agents/coding.py +14 -0
  19. pal/agents/docs.py +15 -0
  20. pal/chat/__init__.py +4 -0
  21. pal/chat/demo.py +231 -0
  22. pal/chat/providers.py +23 -0
  23. pal/chat/service.py +591 -0
  24. pal/chat/services/__init__.py +26 -0
  25. pal/chat/services/base.py +142 -0
  26. pal/chat/services/data.py +142 -0
  27. pal/chat/services/media.py +123 -0
  28. pal/chat/services/web.py +60 -0
  29. pal/chat/sessions.py +108 -0
  30. pal/chat/tools.py +446 -0
  31. pal/chat/uploads.py +81 -0
  32. pal/cli.py +502 -0
  33. pal/compression/__init__.py +12 -0
  34. pal/compression/components/__init__.py +64 -0
  35. pal/compression/components/adverbs.py +20 -0
  36. pal/compression/components/hedges.py +54 -0
  37. pal/compression/components/interjections.py +20 -0
  38. pal/compression/components/minifier.py +56 -0
  39. pal/compression/components/output_compressor.py +13 -0
  40. pal/compression/components/pleonasms.py +54 -0
  41. pal/compression/components/punctuations.py +36 -0
  42. pal/compression/components/stopwords.py +62 -0
  43. pal/compression/history.py +346 -0
  44. pal/compression/pipeline.py +58 -0
  45. pal/compression/utils/__init__.py +30 -0
  46. pal/compression/utils/negations.py +18 -0
  47. pal/compression/utils/nltk_setup.py +20 -0
  48. pal/compression/utils/pronouns.py +27 -0
  49. pal/compression/utils/proselint_setup.py +9 -0
  50. pal/compression/utils/spacy_setup.py +22 -0
  51. pal/config/default.yaml +163 -0
  52. pal/config.py +504 -0
  53. pal/context_manager/__init__.py +7 -0
  54. pal/context_manager/chat_context.py +117 -0
  55. pal/context_manager/components/__init__.py +5 -0
  56. pal/context_manager/components/assembler.py +75 -0
  57. pal/context_manager/components/context_cache.py +45 -0
  58. pal/context_manager/components/kv_bridge.py +16 -0
  59. pal/context_manager/components/pager.py +48 -0
  60. pal/context_manager/components/simple_context_manager.py +56 -0
  61. pal/context_manager/components/turns.py +88 -0
  62. pal/context_manager/pipeline.py +27 -0
  63. pal/context_manager/utils/__init__.py +18 -0
  64. pal/context_manager/utils/hashing.py +5 -0
  65. pal/context_manager/utils/relevance.py +59 -0
  66. pal/context_manager/utils/storage.py +37 -0
  67. pal/documents/__init__.py +4 -0
  68. pal/documents/components/__init__.py +0 -0
  69. pal/documents/components/chunker.py +79 -0
  70. pal/documents/components/index.py +155 -0
  71. pal/documents/utils/__init__.py +1 -0
  72. pal/errors.py +40 -0
  73. pal/input_converters/__init__.py +8 -0
  74. pal/input_converters/components/__init__.py +0 -0
  75. pal/input_converters/components/base.py +38 -0
  76. pal/input_converters/components/pandoc_converter.py +87 -0
  77. pal/input_converters/components/pdf_converter.py +46 -0
  78. pal/input_converters/components/pptx_converter.py +52 -0
  79. pal/input_converters/components/xlsx_converter.py +38 -0
  80. pal/input_converters/pipeline.py +72 -0
  81. pal/input_converters/utils/__init__.py +1 -0
  82. pal/input_converters/workspace.py +56 -0
  83. pal/input_savers/INPUT_RULES.md +2 -0
  84. pal/input_savers/__init__.py +5 -0
  85. pal/input_savers/components/__init__.py +0 -0
  86. pal/input_savers/components/base.py +40 -0
  87. pal/input_savers/components/methods.py +41 -0
  88. pal/input_savers/components/verifier.py +39 -0
  89. pal/input_savers/pipeline.py +154 -0
  90. pal/input_savers/utils/__init__.py +0 -0
  91. pal/input_savers/utils/guards.py +94 -0
  92. pal/knowledge_graph/__init__.py +10 -0
  93. pal/knowledge_graph/components/__init__.py +0 -0
  94. pal/knowledge_graph/components/base.py +50 -0
  95. pal/knowledge_graph/components/builder.py +54 -0
  96. pal/knowledge_graph/components/retriever.py +100 -0
  97. pal/knowledge_graph/components/workspace.py +116 -0
  98. pal/knowledge_graph/pipeline.py +215 -0
  99. pal/knowledge_graph/utils/__init__.py +1 -0
  100. pal/latency_tracker/__init__.py +3 -0
  101. pal/latency_tracker/pipeline.py +86 -0
  102. pal/llm_scheduler/__init__.py +11 -0
  103. pal/llm_scheduler/api.py +137 -0
  104. pal/llm_scheduler/code.py +28 -0
  105. pal/llm_scheduler/core.py +92 -0
  106. pal/llm_scheduler/local.py +215 -0
  107. pal/llm_scheduler/pipeline.py +42 -0
  108. pal/llm_scheduler/types.py +92 -0
  109. pal/llm_wrapper/__init__.py +34 -0
  110. pal/llm_wrapper/adapter.py +316 -0
  111. pal/llm_wrapper/components/__init__.py +23 -0
  112. pal/llm_wrapper/components/adapter_types.py +90 -0
  113. pal/llm_wrapper/components/base.py +195 -0
  114. pal/llm_wrapper/components/features.py +104 -0
  115. pal/llm_wrapper/components/litellm_wrapper.py +215 -0
  116. pal/llm_wrapper/components/openrouter_wrapper.py +114 -0
  117. pal/llm_wrapper/modes.py +73 -0
  118. pal/llm_wrapper/pipeline.py +61 -0
  119. pal/llm_wrapper/utils/__init__.py +4 -0
  120. pal/llm_wrapper/utils/registry.py +49 -0
  121. pal/llm_wrapper/utils/token_counting.py +10 -0
  122. pal/local_llm/__init__.py +5 -0
  123. pal/local_llm/components/__init__.py +0 -0
  124. pal/local_llm/components/huggingface_local.py +217 -0
  125. pal/local_llm/components/local_llm.py +60 -0
  126. pal/logging_setup.py +18 -0
  127. pal/memory/__init__.py +7 -0
  128. pal/memory/components/__init__.py +6 -0
  129. pal/memory/components/lru_k.py +56 -0
  130. pal/memory/components/manager.py +98 -0
  131. pal/memory/components/note.py +54 -0
  132. pal/memory/components/storage_manager.py +60 -0
  133. pal/memory/pipeline.py +122 -0
  134. pal/memory/utils/__init__.py +3 -0
  135. pal/memory/utils/keyword_overlap.py +3 -0
  136. pal/memory_tracker/__init__.py +3 -0
  137. pal/memory_tracker/components/__init__.py +0 -0
  138. pal/memory_tracker/components/process.py +56 -0
  139. pal/memory_tracker/pipeline.py +44 -0
  140. pal/metrics/__init__.py +4 -0
  141. pal/metrics/components/__init__.py +4 -0
  142. pal/metrics/components/collector.py +140 -0
  143. pal/metrics/components/exporter.py +28 -0
  144. pal/metrics/pipeline.py +49 -0
  145. pal/metrics/utils/__init__.py +2 -0
  146. pal/notes_manager/__init__.py +5 -0
  147. pal/notes_manager/components/__init__.py +0 -0
  148. pal/notes_manager/components/privacy.py +14 -0
  149. pal/notes_manager/components/recall.py +31 -0
  150. pal/notes_manager/components/tidy.py +28 -0
  151. pal/notes_manager/pipeline.py +138 -0
  152. pal/orchestrator/__init__.py +26 -0
  153. pal/orchestrator/components/__init__.py +26 -0
  154. pal/orchestrator/components/agent.py +105 -0
  155. pal/orchestrator/components/cgroup_manager.py +209 -0
  156. pal/orchestrator/components/context_interrupt.py +220 -0
  157. pal/orchestrator/components/limits.py +9 -0
  158. pal/orchestrator/components/scheduler.py +69 -0
  159. pal/orchestrator/components/syscall_scheduler.py +200 -0
  160. pal/orchestrator/pipeline.py +228 -0
  161. pal/orchestrator/utils/__init__.py +4 -0
  162. pal/orchestrator/utils/device.py +164 -0
  163. pal/orchestrator/utils/protocol.py +32 -0
  164. pal/os_kernel/__init__.py +5 -0
  165. pal/os_kernel/filesystem.py +80 -0
  166. pal/os_kernel/network.py +146 -0
  167. pal/os_kernel/pipeline.py +175 -0
  168. pal/os_kernel/sysinfo.py +36 -0
  169. pal/paths.py +59 -0
  170. pal/pipeline.py +177 -0
  171. pal/py.typed +0 -0
  172. pal/redact.py +19 -0
  173. pal/sdk/__init__.py +33 -0
  174. pal/sdk/adapters.py +43 -0
  175. pal/sdk/agent.py +99 -0
  176. pal/sdk/components/__init__.py +14 -0
  177. pal/sdk/components/autogen_adapter.py +65 -0
  178. pal/sdk/components/autogen_client.py +98 -0
  179. pal/sdk/components/framework_adapter_base.py +69 -0
  180. pal/sdk/components/metagpt_adapter.py +46 -0
  181. pal/sdk/components/open_interpreter_adapter.py +47 -0
  182. pal/sdk/components/tool_catalog.py +44 -0
  183. pal/sdk/os_api.py +84 -0
  184. pal/sdk/patterns.py +129 -0
  185. pal/sdk/pipeline.py +240 -0
  186. pal/sdk/queries.py +68 -0
  187. pal/sdk/savers.py +88 -0
  188. pal/sdk/utils/__init__.py +2 -0
  189. pal/storage_manager/__init__.py +17 -0
  190. pal/storage_manager/components/__init__.py +19 -0
  191. pal/storage_manager/components/base.py +50 -0
  192. pal/storage_manager/components/local_file_backend.py +84 -0
  193. pal/storage_manager/components/storage_queue.py +91 -0
  194. pal/storage_manager/components/vector_store_backend.py +122 -0
  195. pal/storage_manager/pipeline.py +153 -0
  196. pal/storage_manager/utils/__init__.py +4 -0
  197. pal/storage_manager/utils/memory_adapter.py +60 -0
  198. pal/storage_manager/utils/sdk.py +93 -0
  199. pal/studio/__init__.py +0 -0
  200. pal/studio/builder.py +166 -0
  201. pal/studio/catalog.py +90 -0
  202. pal/studio/checks.py +129 -0
  203. pal/studio/clients.py +6 -0
  204. pal/studio/editor.py +137 -0
  205. pal/studio/files.py +6 -0
  206. pal/studio/models.py +6 -0
  207. pal/studio/plan.py +149 -0
  208. pal/studio/planner.py +205 -0
  209. pal/studio/policy.py +69 -0
  210. pal/studio/routes.py +254 -0
  211. pal/studio/runs.py +210 -0
  212. pal/task_router/__init__.py +11 -0
  213. pal/task_router/components/__init__.py +0 -0
  214. pal/task_router/components/answerer.py +37 -0
  215. pal/task_router/components/base.py +34 -0
  216. pal/task_router/components/classifier.py +40 -0
  217. pal/task_router/components/gates.py +23 -0
  218. pal/task_router/pipeline.py +61 -0
  219. pal/task_router/utils/__init__.py +1 -0
  220. pal/terminal/__init__.py +1 -0
  221. pal/terminal/backend.py +141 -0
  222. pal/terminal/bench.py +173 -0
  223. pal/terminal/cli.py +826 -0
  224. pal/terminal/clients.py +170 -0
  225. pal/terminal/commands.py +401 -0
  226. pal/terminal/decide.py +242 -0
  227. pal/terminal/files.py +55 -0
  228. pal/terminal/flow_cli.py +439 -0
  229. pal/terminal/hardware.py +199 -0
  230. pal/terminal/history.py +88 -0
  231. pal/terminal/keys.py +83 -0
  232. pal/terminal/local_models.py +261 -0
  233. pal/terminal/logo.py +117 -0
  234. pal/terminal/models.py +99 -0
  235. pal/terminal/policy.py +54 -0
  236. pal/terminal/project.py +413 -0
  237. pal/terminal/review.py +65 -0
  238. pal/terminal/security.py +239 -0
  239. pal/terminal/session.py +503 -0
  240. pal/terminal/setup.py +354 -0
  241. pal/terminal/syntax.py +98 -0
  242. pal/terminal/theme.py +37 -0
  243. pal/terminal/ui.py +647 -0
  244. pal/terminal/voice.py +137 -0
  245. pal/terminal/workflow.py +624 -0
  246. pal/token_tracker/__init__.py +3 -0
  247. pal/token_tracker/pipeline.py +54 -0
  248. pal/tokens.py +21 -0
  249. pal/tool_catalog.py +107 -0
  250. pal/tool_manager/__init__.py +17 -0
  251. pal/tool_manager/builtin.py +82 -0
  252. pal/tool_manager/components/__init__.py +19 -0
  253. pal/tool_manager/components/base.py +53 -0
  254. pal/tool_manager/components/conflict_resolver.py +27 -0
  255. pal/tool_manager/components/param_validator.py +41 -0
  256. pal/tool_manager/components/tool_call_queue.py +111 -0
  257. pal/tool_manager/components/tool_registry.py +60 -0
  258. pal/tool_manager/pipeline.py +80 -0
  259. pal/tool_manager/utils/__init__.py +3 -0
  260. pal/web/__init__.py +3 -0
  261. pal/web/app.py +276 -0
  262. pal/web/static/editor.js +219 -0
  263. pal/web/static/fonts/LICENSE-Inter.txt +3 -0
  264. pal/web/static/fonts/inter-400.woff2 +0 -0
  265. pal/web/static/fonts/inter-500.woff2 +0 -0
  266. pal/web/static/fonts/inter-600.woff2 +0 -0
  267. pal/web/static/fonts/inter-700.woff2 +0 -0
  268. pal/web/static/inter.css +5 -0
  269. pal/web/static/pal-logo-256.png +0 -0
  270. pal/web/static/pal-logo-32.png +0 -0
  271. pal/web/static/pal-logo-64.png +0 -0
  272. pal/web/static/studio.css +306 -0
  273. pal/web/static/studio.js +533 -0
  274. pal/web/static/voice.js +76 -0
  275. pal/web/templates/index.html +368 -0
  276. pal/web/templates/studio.html +120 -0
  277. pal_code-0.1.0.dist-info/METADATA +379 -0
  278. pal_code-0.1.0.dist-info/RECORD +282 -0
  279. pal_code-0.1.0.dist-info/WHEEL +5 -0
  280. pal_code-0.1.0.dist-info/entry_points.txt +2 -0
  281. pal_code-0.1.0.dist-info/licenses/LICENSE +21 -0
  282. pal_code-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,104 @@
1
+ """Function calling and structured output, in two flavours.
2
+
3
+ Native: the backend supports it (`WrapperCapabilities.supports_tools` / `supports_json`) and the core uses the provider's own
4
+ mechanism. Emulated: for backends that do not (an in-process Hugging Face model, for one), the adapter puts the contract in
5
+ the prompt and validates what comes back. Both produce the same result shape, so callers never know which was used.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ from typing import Any
12
+
13
+
14
+ def normalize_tools(tools: list[dict] | None) -> list[dict]:
15
+ """Accept OpenAI-style {"type": "function", "function": {...}} or flat {"name", "description", "parameters"}."""
16
+ out = []
17
+ for t in tools or []:
18
+ fn = t.get("function", t) if isinstance(t, dict) else {}
19
+ name = fn.get("name")
20
+ if not name:
21
+ raise ValueError("every tool needs a name")
22
+ out.append({"name": name, "description": fn.get("description", ""),
23
+ "parameters": fn.get("parameters") or fn.get("input_schema") or {"type": "object", "properties": {}}})
24
+ return out
25
+
26
+
27
+ def is_response_format(rf: dict | None) -> bool:
28
+ """True for a provider-style response format ({"type": "json_object"} and friends); False for a JSON schema, which
29
+ also has a `type` key ({"type": "object", ...})."""
30
+ return isinstance(rf, dict) and rf.get("type") in ("json_object", "json_schema", "text")
31
+
32
+
33
+ def parse_arguments(raw: Any) -> dict:
34
+ if isinstance(raw, dict):
35
+ return raw
36
+ if isinstance(raw, str) and raw.strip():
37
+ try:
38
+ val = json.loads(raw)
39
+ return val if isinstance(val, dict) else {"value": val}
40
+ except ValueError:
41
+ return {"_raw": raw}
42
+ return {}
43
+
44
+
45
+ def extract_json(text: str) -> Any:
46
+ """Parse JSON from a model reply, tolerating ``` fences and a sentence before or after it."""
47
+ t = (text or "").strip()
48
+ fence = re.match(r"^```(?:json)?\s*(.*?)\s*```$", t, re.S | re.I)
49
+ if fence:
50
+ t = fence.group(1)
51
+ try:
52
+ return json.loads(t)
53
+ except ValueError:
54
+ pass
55
+ for open_c, close_c in (("{", "}"), ("[", "]")):
56
+ a, b = t.find(open_c), t.rfind(close_c)
57
+ if 0 <= a < b:
58
+ try:
59
+ return json.loads(t[a:b + 1])
60
+ except ValueError:
61
+ continue
62
+ raise ValueError("no valid JSON found in the reply")
63
+
64
+
65
+ def json_instruction(schema: dict | None) -> str:
66
+ base = "Reply with only a single valid JSON value. No explanation, no markdown fences."
67
+ if schema and not is_response_format(schema):
68
+ return base + " It must match this JSON schema: " + json.dumps(schema, separators=(",", ":"))
69
+ return base
70
+
71
+
72
+ def tools_instruction(tools: list[dict]) -> str:
73
+ listing = "; ".join(f'{t["name"]}: {t["description"]} parameters={json.dumps(t["parameters"], separators=(",", ":"))}'
74
+ for t in tools)
75
+ return ('You can call tools. To call one or more, reply with only JSON of the form '
76
+ '{"tool_calls": [{"name": "<tool name>", "parameters": {...}}]} and nothing else. '
77
+ 'If no tool is needed, answer normally in plain text. Tools: ' + listing)
78
+
79
+
80
+ def parse_emulated_tool_calls(text: str, tools: list[dict]) -> list[dict] | None:
81
+ """The tool calls a model asked for in the emulated format, or None when it answered in plain text."""
82
+ try:
83
+ data = extract_json(text)
84
+ except ValueError:
85
+ return None
86
+ calls = data.get("tool_calls") if isinstance(data, dict) else None
87
+ if not isinstance(calls, list) or not calls:
88
+ return None
89
+ out = []
90
+ for c in calls:
91
+ if isinstance(c, dict) and isinstance(c.get("name"), str):
92
+ out.append({"name": c["name"], "parameters": parse_arguments(c.get("parameters", c.get("arguments")))})
93
+ return out or None
94
+
95
+
96
+ class ToolCallError(ValueError):
97
+ """The model asked for a tool that was not offered."""
98
+
99
+
100
+ def check_tool_names(calls: list[dict], tools: list[dict]) -> None:
101
+ known = {t["name"] for t in tools}
102
+ for c in calls:
103
+ if c["name"] not in known:
104
+ raise ToolCallError(f"the model called an unknown tool: {c['name']!r}")
@@ -0,0 +1,215 @@
1
+ """The LiteLLM wrapper, the one wrapper for every model call: the cloud APIs (OpenAI, Anthropic, Google Gemini, Groq, DeepSeek, Mistral, Together,
2
+ OpenRouter, Perplexity, Azure OpenAI, Amazon Bedrock) AND the local runtimes (Ollama, vLLM, LM Studio, llama.cpp servers) all go
3
+ through LiteLLM's Python SDK (`litellm.completion`). The only core that does not is the in-process Hugging Face one, because
4
+ LiteLLM calls model servers and cannot load a model into this process.
5
+
6
+ How it is kept safe, because this library handles users' API keys:
7
+ * only the SDK is used, never the LiteLLM proxy server (most of its 2026 CVEs, and the March 2026 backdoor's second payload,
8
+ are in the proxy);
9
+ * the version is pinned and hash-locked (requirements/litellm.lock, pyproject extra `litellm`);
10
+ * the key is passed to each call explicitly, never put in a global;
11
+ * LiteLLM's telemetry and callbacks are off, and its price table is the copy bundled with the pinned version (no download at
12
+ import time).
13
+ A local runtime has no key to protect, so for it the point of LiteLLM is one interface, one token and error format, and one
14
+ path to test. Stopping a stream early (the scheduler's pause) closes the connection underneath LiteLLM's wrapper, which has no
15
+ close() of its own: otherwise the model server would keep generating and block the job that paused it.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import time
21
+ from collections.abc import Iterator
22
+ from typing import Any
23
+
24
+ from pal.errors import DependencyMissingError
25
+ from pal.tokens import estimate_tokens
26
+
27
+ from .base import DeploymentType, LLMResponse, LLMSyscall, LLMWrapper, WrapperCapabilities, WrapperConfig
28
+ from .features import is_response_format, normalize_tools, parse_arguments
29
+
30
+ PINNED = "1.102.1"
31
+
32
+ # our provider name -> LiteLLM's provider prefix
33
+ PREFIX = {"openai": "openai", "azure_openai": "azure", "anthropic": "anthropic", "gemini": "gemini", "groq": "groq",
34
+ "together": "together_ai", "mistral": "mistral", "deepseek": "deepseek", "openrouter": "openrouter",
35
+ "perplexity": "perplexity", "bedrock": "bedrock"}
36
+
37
+ # local runtime -> LiteLLM's provider prefix. "ollama_chat" is LiteLLM's /api/chat route (the one that handles chat turns and tools).
38
+ LOCAL_PREFIX = {"ollama": "ollama_chat", "vllm": "hosted_vllm", "lmstudio": "lm_studio", "llamacpp": "openai"}
39
+ # The OpenAI-style local servers need SOME key in the request. Without an explicit one LiteLLM would fall back to a real key in the
40
+ # environment (OPENAI_API_KEY) and send it to a local process, so a placeholder is always sent unless you configured a real one.
41
+ _PLACEHOLDER_KEY = "not-needed"
42
+ _NEEDS_PLACEHOLDER = ("vllm", "lmstudio", "llamacpp")
43
+
44
+ _PASSTHROUGH = ("top_p", "stop", "seed", "frequency_penalty", "presence_penalty")
45
+ _STREAM_HANDLES = ("streaming_response", "response_iterator", "http_response")
46
+ _module: Any = None
47
+
48
+
49
+ def _import_litellm() -> Any:
50
+ """Import LiteLLM once, with its network and logging side effects switched off first."""
51
+ global _module
52
+ if _module is not None:
53
+ return _module
54
+ os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True") # use the bundled price table: no download at import
55
+ os.environ.setdefault("LITELLM_TELEMETRY", "False")
56
+ try:
57
+ import litellm
58
+ except ImportError as e:
59
+ raise DependencyMissingError(
60
+ f"litellm is not installed. Run: pip install 'pal-code[litellm]' (pinned to litellm=={PINNED}; a hash-locked "
61
+ "install is in requirements/litellm.lock)") from e
62
+ litellm.suppress_debug_info = True
63
+ litellm.set_verbose = False
64
+ for attr in ("telemetry",):
65
+ if hasattr(litellm, attr):
66
+ setattr(litellm, attr, False)
67
+ litellm.success_callback = []
68
+ litellm.failure_callback = []
69
+ _module = litellm
70
+ return litellm
71
+
72
+
73
+ def full_model(backend: str, model: str) -> str:
74
+ prefix = PREFIX.get(backend) or LOCAL_PREFIX.get(backend)
75
+ if prefix is None or model.startswith(prefix + "/"):
76
+ return model
77
+ return f"{prefix}/{model}"
78
+
79
+
80
+ def close_stream(stream: Any) -> None:
81
+ """Stop a LiteLLM stream so the model server stops generating. LiteLLM's wrapper has no sync close(); the HTTP stream is held
82
+ by generators underneath it, and closing those closes the connection. Tolerant: anything missing or failing is skipped."""
83
+ inner = getattr(stream, "completion_stream", None)
84
+ for obj in (stream, inner):
85
+ close = getattr(obj, "close", None)
86
+ if callable(close):
87
+ try:
88
+ close()
89
+ except Exception: # noqa: BLE001 (already closed, or not closable)
90
+ pass
91
+ for name in _STREAM_HANDLES:
92
+ handle = getattr(inner, name, None)
93
+ close = getattr(handle, "close", None)
94
+ if callable(close):
95
+ try:
96
+ close()
97
+ except Exception: # noqa: BLE001
98
+ pass
99
+
100
+
101
+ class LiteLLMWrapper(LLMWrapper):
102
+ """Your own key, any provider LiteLLM knows. Two specialised kinds build on it: `OpenRouterWrapper` (one key, hundreds of models) and
103
+ `LocalLLM` (models served on this machine); they set what differs and share everything else, so the key handling lives in one place."""
104
+ name = "litellm"
105
+ IS_LOCAL = False
106
+
107
+ def __init__(self, config: WrapperConfig):
108
+ super().__init__(config)
109
+ self.ll = _import_litellm()
110
+ self.model = full_model(config.backend, config.model)
111
+ self.api_key = config.api_key
112
+ self.local = self.IS_LOCAL or config.backend in LOCAL_PREFIX
113
+ self.timeout = float(config.extra.get("timeout", 300 if self.local else 120)) # a local model's first call loads it
114
+
115
+ # -- one request ---------------------------------------------------------------------------------------------
116
+ def _kwargs(self, syscall: LLMSyscall) -> dict:
117
+ kw: dict = {"model": syscall.model if syscall.model and "/" in syscall.model else self.model,
118
+ "messages": syscall.messages, "timeout": self.timeout, "num_retries": 0, "drop_params": True}
119
+ key = self._key()
120
+ if key:
121
+ kw["api_key"] = key
122
+ base = self.config.base_url or self.config.host
123
+ if base:
124
+ kw["api_base"] = base
125
+ if syscall.temperature is not None:
126
+ kw["temperature"] = syscall.temperature
127
+ if syscall.max_tokens is not None:
128
+ kw["max_tokens"] = syscall.max_tokens
129
+ tools = normalize_tools(getattr(syscall, "tools", None))
130
+ if tools:
131
+ kw["tools"] = [{"type": "function", "function": t} for t in tools]
132
+ rf = getattr(syscall, "response_format", None)
133
+ if rf:
134
+ kw["response_format"] = rf if is_response_format(rf) else {
135
+ "type": "json_schema", "json_schema": {"name": "response", "schema": rf}}
136
+ kw.update({k: v for k, v in syscall.params.items() if k in _PASSTHROUGH})
137
+ kw.update(self.config.extra.get("litellm_params", {}))
138
+ return kw
139
+
140
+ def chat(self, syscall: LLMSyscall) -> LLMResponse:
141
+ t0 = time.time()
142
+ r = self.ll.completion(**self._kwargs(syscall))
143
+ latency = time.time() - t0
144
+ msg = r.choices[0].message
145
+ calls = [{"name": c.function.name, "parameters": parse_arguments(c.function.arguments)}
146
+ for c in (getattr(msg, "tool_calls", None) or [])]
147
+ usage = getattr(r, "usage", None)
148
+ try:
149
+ cost = float(self.ll.completion_cost(completion_response=r))
150
+ except Exception: # noqa: BLE001 (a model with no known price)
151
+ cost = None
152
+ return LLMResponse(
153
+ text=getattr(msg, "content", None) or "", raw=r,
154
+ prompt_tokens=int(getattr(usage, "prompt_tokens", 0) or 0),
155
+ completion_tokens=int(getattr(usage, "completion_tokens", 0) or 0),
156
+ meta={"latency": latency, "tool_calls": calls or None, "cost_usd": cost, "model": self.model})
157
+
158
+ def stream_chat(self, syscall: LLMSyscall) -> Iterator[str]:
159
+ stream = self.ll.completion(**self._kwargs(syscall), stream=True)
160
+ try:
161
+ for chunk in stream:
162
+ delta = chunk.choices[0].delta if chunk.choices else None
163
+ piece = getattr(delta, "content", None) if delta is not None else None
164
+ if piece:
165
+ yield piece
166
+ finally:
167
+ close_stream(stream) # runs when the consumer stops early too (the scheduler pausing a job)
168
+
169
+ # -- facts about the model -------------------------------------------------------------------------------------
170
+ def token_count(self, text: str) -> int:
171
+ try:
172
+ return int(self.ll.token_counter(model=self.model, text=text))
173
+ except Exception: # noqa: BLE001 (an unknown model has no tokenizer: estimate)
174
+ return estimate_tokens(text)
175
+
176
+ def _key(self) -> str | None:
177
+ if self.api_key:
178
+ return self.api_key
179
+ return _PLACEHOLDER_KEY if self.config.backend in _NEEDS_PLACEHOLDER else None
180
+
181
+ def embed(self, texts: list[str]) -> list[list[float]]:
182
+ kw: dict = {"model": self.model, "input": texts, "timeout": self.timeout}
183
+ key = self._key()
184
+ if key:
185
+ kw["api_key"] = key
186
+ base = self.config.base_url or self.config.host
187
+ if base:
188
+ kw["api_base"] = base
189
+ return [list(d["embedding"]) for d in self.ll.embedding(**kw).data]
190
+
191
+ def price_per_token(self) -> tuple[float, float] | None:
192
+ """(input, output) USD per token from LiteLLM's bundled price table, or None for an unknown model. A local model is free."""
193
+ if self.local:
194
+ return None
195
+ try:
196
+ info = self.ll.get_model_info(model=self.model) # provider-aware: "openrouter/gpt-4o" is not OpenAI's gpt-4o
197
+ except Exception: # noqa: BLE001 (not in the price table)
198
+ return None
199
+ if not info or info.get("input_cost_per_token") is None:
200
+ return None
201
+ return float(info["input_cost_per_token"]), float(info.get("output_cost_per_token") or 0.0)
202
+
203
+ def capabilities(self) -> WrapperCapabilities:
204
+ tools = json_mode = False
205
+ try:
206
+ tools = bool(self.ll.supports_function_calling(model=self.model))
207
+ except Exception: # noqa: BLE001
208
+ pass
209
+ try:
210
+ json_mode = "response_format" in (self.ll.get_supported_openai_params(model=self.model) or [])
211
+ except Exception: # noqa: BLE001
212
+ pass
213
+ return WrapperCapabilities(
214
+ deployment_type=DeploymentType.LOCAL_SERVER if self.local else DeploymentType.CLOUD_API, supports_embeddings=self.local,
215
+ supports_streaming=True, supports_tools=tools, supports_json=json_mode, billed_per_token=not self.local)
@@ -0,0 +1,114 @@
1
+ """The OpenRouter wrapper: one OpenRouter key reaches hundreds of models from many labs, some of them free. It is for EXPLORING models: trying a
2
+ new one costs nothing but a model name. (If you have your own key for one provider, use the LiteLLM wrapper directly: no middleman.)
3
+
4
+ It is the LiteLLM wrapper underneath, so the key is handled exactly the same way (per call, never in a global), plus:
5
+ * the app's name is sent as OpenRouter's optional attribution header;
6
+ * `models()` reads OpenRouter's public model list (no key needed) so you can browse: name, context size, price, and whether it is free.
7
+
8
+ What to know before using it: your prompts pass through OpenRouter on their way to the model's lab; free models are rate-limited (about 20 a
9
+ minute); the list changes, and a model can disappear. The list is read live and kept for an hour; if it cannot be read you get an error, never
10
+ a stale guess.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import threading
15
+ import time
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass
18
+ from typing import Any
19
+
20
+ from pal.llm_wrapper.components.litellm_wrapper import LiteLLMWrapper
21
+
22
+ CATALOGUE_URL = "https://openrouter.ai/api/v1/models"
23
+ CATALOGUE_TTL = 3600.0
24
+ APP_TITLE = "Pal"
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ModelInfo:
29
+ id: str # what you pass as the model name, e.g. "meta-llama/llama-3.3-70b-instruct:free"
30
+ name: str
31
+ context_length: int | None
32
+ prompt_per_million: float | None # USD per million input tokens; None when not stated
33
+ completion_per_million: float | None
34
+ free: bool
35
+
36
+ def label(self) -> str:
37
+ ctx = f"{self.context_length // 1000}k" if self.context_length else "?"
38
+ price = "free" if self.free else (f"${self.prompt_per_million:g}/M in" if self.prompt_per_million is not None else "price ?")
39
+ return f"{self.name} | {ctx} | {price}"
40
+
41
+
42
+ def _per_million(value: Any) -> float | None:
43
+ try:
44
+ return round(float(value) * 1_000_000, 6)
45
+ except (TypeError, ValueError):
46
+ return None
47
+
48
+
49
+ def parse_catalogue(payload: Any) -> list[ModelInfo]:
50
+ """OpenRouter's model list -> ModelInfo. Tolerant: an entry without an id is skipped, a missing or odd field becomes None."""
51
+ out: list[ModelInfo] = []
52
+ data = payload.get("data") if isinstance(payload, dict) else None
53
+ for m in data if isinstance(data, list) else []:
54
+ if not isinstance(m, dict) or not m.get("id"):
55
+ continue
56
+ pricing = m.get("pricing") or {}
57
+ prompt, completion = _per_million(pricing.get("prompt")), _per_million(pricing.get("completion"))
58
+ ctx = m.get("context_length")
59
+ out.append(ModelInfo(id=str(m["id"]), name=str(m.get("name") or m["id"]), context_length=int(ctx) if isinstance(ctx, (int, float)) else None,
60
+ prompt_per_million=prompt, completion_per_million=completion,
61
+ free=str(m["id"]).endswith(":free") or (prompt == 0.0 and completion == 0.0)))
62
+ return sorted(out, key=lambda x: x.id)
63
+
64
+
65
+ def _fetch_json(url: str, timeout: float = 10.0) -> Any:
66
+ import requests
67
+ r = requests.get(url, timeout=timeout, headers={"Accept": "application/json"})
68
+ r.raise_for_status()
69
+ return r.json()
70
+
71
+
72
+ _cache: dict = {"at": 0.0, "models": []}
73
+ _cache_lock = threading.Lock()
74
+
75
+
76
+ def fetch_models(fetch: Callable[[str], Any] | None = None, force: bool = False) -> list[ModelInfo]:
77
+ """OpenRouter's current models, from its public list (no key). Kept for an hour. Raises if it cannot be read."""
78
+ with _cache_lock:
79
+ if not force and _cache["models"] and time.time() - _cache["at"] < CATALOGUE_TTL:
80
+ return list(_cache["models"])
81
+ models = parse_catalogue((fetch or _fetch_json)(CATALOGUE_URL))
82
+ if not models:
83
+ raise RuntimeError("OpenRouter's model list came back empty or in a format this version does not understand")
84
+ with _cache_lock:
85
+ _cache.update(at=time.time(), models=models)
86
+ return list(models)
87
+
88
+
89
+ def search_models(models: list[ModelInfo], text: str = "", free_only: bool = False, limit: int = 50) -> list[ModelInfo]:
90
+ """Models whose id or name contains every word of `text` (any case), free ones only if asked, at most `limit`."""
91
+ words = [w for w in text.lower().split() if w]
92
+ hits = [m for m in models if (not free_only or m.free) and all(w in f"{m.id} {m.name}".lower() for w in words)]
93
+ return hits[:limit] if limit else hits
94
+
95
+
96
+ class OpenRouterWrapper(LiteLLMWrapper):
97
+ name = "openrouter"
98
+
99
+ def __init__(self, config):
100
+ super().__init__(config)
101
+ self.app_title = str(config.extra.get("app_title", APP_TITLE))
102
+ self.app_url = str(config.extra.get("app_url", ""))
103
+
104
+ def _kwargs(self, syscall) -> dict:
105
+ kw = super()._kwargs(syscall)
106
+ headers = {"X-Title": self.app_title}
107
+ if self.app_url:
108
+ headers["HTTP-Referer"] = self.app_url
109
+ kw["extra_headers"] = {**headers, **kw.get("extra_headers", {})}
110
+ return kw
111
+
112
+ @staticmethod
113
+ def models(fetch: Callable[[str], Any] | None = None, force: bool = False) -> list[ModelInfo]:
114
+ return fetch_models(fetch, force)
@@ -0,0 +1,73 @@
1
+ """Two ways to reach a cloud model, and which providers belong to each.
2
+
3
+ explore One OpenRouter key reaches hundreds of models, some free: the way to try new models. Your prompts pass through OpenRouter.
4
+ own_key Your own key for one provider (Anthropic, OpenAI, Gemini ...): direct, no middleman, that provider's own terms.
5
+
6
+ Both are served by the same LiteLLM-based wrapper (the OpenRouter wrapper adds the model browser); a mode is only a choice of which providers to
7
+ offer. Local models are a third thing (the Local LLM component) and are not a mode.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass
14
+
15
+ # provider -> the environment variable that holds its key (the order is the order they are offered in)
16
+ PROVIDER_KEYS = {
17
+ "anthropic": "ANTHROPIC_API_KEY",
18
+ "openai": "OPENAI_API_KEY",
19
+ "gemini": "GEMINI_API_KEY",
20
+ "groq": "GROQ_API_KEY",
21
+ "deepseek": "DEEPSEEK_API_KEY",
22
+ "mistral": "MISTRAL_API_KEY",
23
+ "together": "TOGETHER_API_KEY",
24
+ "openrouter": "OPENROUTER_API_KEY",
25
+ }
26
+ EXPLORE_PROVIDER = "openrouter"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Mode:
31
+ key: str
32
+ title: str
33
+ summary: str
34
+ providers: tuple[str, ...]
35
+
36
+
37
+ MODES: dict[str, Mode] = {
38
+ "explore": Mode("explore", "Explore models",
39
+ "One OpenRouter key reaches hundreds of models, some of them free. Your prompts pass through OpenRouter on the way to the model.",
40
+ (EXPLORE_PROVIDER,)),
41
+ "own_key": Mode("own_key", "Use my own key",
42
+ "Your own key for one provider (Anthropic, OpenAI, Gemini ...). Direct to that provider, no middleman.",
43
+ tuple(p for p in PROVIDER_KEYS if p != EXPLORE_PROVIDER)),
44
+ }
45
+
46
+
47
+ def mode_for(provider: str) -> str:
48
+ """Which mode a provider belongs to."""
49
+ if provider not in PROVIDER_KEYS:
50
+ raise KeyError(f"unknown provider {provider!r} (known: {', '.join(PROVIDER_KEYS)})")
51
+ return "explore" if provider == EXPLORE_PROVIDER else "own_key"
52
+
53
+
54
+ def providers_with_keys(env: Mapping[str, str] | None = None) -> dict[str, bool]:
55
+ """Every provider and whether its key is set in the environment."""
56
+ env = os.environ if env is None else env
57
+ return {p: bool(env.get(var)) for p, var in PROVIDER_KEYS.items()}
58
+
59
+
60
+ def default_mode(env: Mapping[str, str] | None = None) -> str:
61
+ """own_key if any provider key is set, else explore if only an OpenRouter key is, else own_key (the plain default)."""
62
+ have = providers_with_keys(env)
63
+ if any(v for p, v in have.items() if p != EXPLORE_PROVIDER):
64
+ return "own_key"
65
+ return "explore" if have[EXPLORE_PROVIDER] else "own_key"
66
+
67
+
68
+ def describe_modes(env: Mapping[str, str] | None = None) -> list[dict]:
69
+ """The modes as plain data for the web page and the command line, with which providers already have a key."""
70
+ have = providers_with_keys(env)
71
+ return [{"key": m.key, "title": m.title, "summary": m.summary,
72
+ "providers": [{"name": p, "key_env": PROVIDER_KEYS[p], "key_set": have[p]} for p in m.providers],
73
+ "ready": any(have[p] for p in m.providers)} for m in MODES.values()]
@@ -0,0 +1,61 @@
1
+ """
2
+ LLMWrapperPipeline is the single entry point callers use to talk to an LLM,
3
+ regardless of deployment option. It owns exactly one active LLMWrapper at a
4
+ time (swappable via switch()) and exposes the unified system calls --
5
+ callers never see LiteLLMWrapper vs the in-process Hugging Face core vs a local
6
+ in-process model, only syscall_chat/syscall_token_count/syscall_embed.
7
+
8
+ This replaces the old Router, which only dispatched to a fixed set of
9
+ BackendAdapter subclasses with backend-specific interfaces; the syscall
10
+ layer here is the "standardized system calls" piece AIOS 3.2 describes
11
+ and the old Router didn't actually have.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from pal.llm_wrapper.components.base import LLMResponse, LLMSyscall, WrapperCapabilities, WrapperConfig
16
+ from pal.llm_wrapper.utils.registry import available_backends, build_wrapper
17
+
18
+
19
+ class LLMWrapperPipeline:
20
+ def __init__(self, config: WrapperConfig):
21
+ self.config = config
22
+ self.core = build_wrapper(config)
23
+
24
+ def switch(self, config: WrapperConfig) -> None:
25
+ """Swap the active core -- e.g. moving from a local Ollama core
26
+ to a cloud Gemini core mid-session. Every syscall below is core-
27
+ agnostic, so nothing else needs to change when this is called."""
28
+ self.config = config
29
+ self.core = build_wrapper(config)
30
+
31
+ # -- unified system calls -------------------------------------------------
32
+
33
+ def syscall_chat(self, messages: list[dict], **kwargs) -> LLMResponse:
34
+ syscall = LLMSyscall(
35
+ messages=messages,
36
+ model=kwargs.pop("model", None),
37
+ temperature=kwargs.pop("temperature", None),
38
+ max_tokens=kwargs.pop("max_tokens", None),
39
+ stream=kwargs.pop("stream", False),
40
+ params=kwargs,
41
+ )
42
+ return self.core.chat(syscall)
43
+
44
+ def syscall_token_count(self, text: str) -> int:
45
+ return self.core.token_count(text)
46
+
47
+ def syscall_embed(self, texts: list[str]) -> list[list[float]]:
48
+ return self.core.embed(texts)
49
+
50
+ def capabilities(self) -> WrapperCapabilities:
51
+ return self.core.capabilities()
52
+
53
+ def supports_kv_cache(self) -> bool:
54
+ return self.core.supports_kv_cache()
55
+
56
+ def reset_kv_cache(self) -> None:
57
+ self.core.reset_kv_cache()
58
+
59
+ @staticmethod
60
+ def list_backends() -> list[str]:
61
+ return available_backends()
@@ -0,0 +1,4 @@
1
+ from pal.llm_wrapper.utils.registry import WRAPPER_REGISTRY, available_backends, build_wrapper
2
+ from pal.llm_wrapper.utils.token_counting import heuristic_token_count
3
+
4
+ __all__ = ["WRAPPER_REGISTRY", "available_backends", "build_wrapper", "heuristic_token_count"]
@@ -0,0 +1,49 @@
1
+ """
2
+ Backend-name -> wrapper-class registry, and the factory that builds a
3
+ concrete LLMWrapper from a WrapperConfig. This is the single place that
4
+ knows how a `backend` string maps to a deployment option -- adding a
5
+ new cloud API or local runtime means adding one line here, not
6
+ touching the pipeline or callers.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from pal.llm_wrapper.components.base import LLMWrapper, WrapperConfig
11
+ from pal.llm_wrapper.components.litellm_wrapper import LOCAL_PREFIX, LiteLLMWrapper
12
+ from pal.llm_wrapper.components.litellm_wrapper import PREFIX as LITELLM_PROVIDERS
13
+ from pal.llm_wrapper.components.openrouter_wrapper import OpenRouterWrapper
14
+ from pal.local_llm.components.huggingface_local import HuggingFaceLocalLLM
15
+ from pal.local_llm.components.local_llm import LocalLLM
16
+
17
+ # EVERY model call goes through ONE wrapper: LiteLLM's Python SDK (see litellm_wrapper.py for how it is kept safe). That covers the
18
+ # cloud APIs and the local runtimes. The single exception is the in-process Hugging Face core: LiteLLM calls model servers, it
19
+ # cannot load a model into this process.
20
+ # EVERY model call goes through ONE wrapper, LiteLLM's Python SDK (see litellm_wrapper.py for how it is kept safe). Three kinds build on it:
21
+ # LiteLLMWrapper your own key for a provider (OpenAI, Anthropic, Gemini, Groq, DeepSeek, Mistral, Together, Perplexity, Azure, Bedrock),
22
+ # or any LiteLLM model string with the backend "litellm"
23
+ # OpenRouterWrapper one OpenRouter key, hundreds of models, a model browser: for exploring
24
+ # LocalLLM models served on this machine (Ollama, vLLM, LM Studio, llama.cpp), in the Local LLM component
25
+ # The single exception is the in-process Hugging Face model, also in the Local LLM component: LiteLLM calls model servers, it cannot load a
26
+ # model into this process.
27
+ WRAPPER_REGISTRY: dict[str, type[LLMWrapper]] = {
28
+ **{name: LiteLLMWrapper for name in LITELLM_PROVIDERS},
29
+ **{name: LocalLLM for name in LOCAL_PREFIX},
30
+ "openrouter": OpenRouterWrapper,
31
+ "litellm": LiteLLMWrapper, # any LiteLLM model string, e.g. "cohere/command-r"
32
+ "huggingface_local": HuggingFaceLocalLLM,
33
+ }
34
+ # Names the paper's backend table uses, mapped to ours.
35
+ BACKEND_ALIASES = {"google": "gemini", "huggingface": "huggingface_local", "hf": "huggingface_local",
36
+ "aws_bedrock": "bedrock"}
37
+
38
+
39
+ def build_wrapper(config: WrapperConfig) -> LLMWrapper:
40
+ config.backend = BACKEND_ALIASES.get(config.backend, config.backend)
41
+ cls = WRAPPER_REGISTRY.get(config.backend)
42
+ if cls is None:
43
+ known = ", ".join(sorted(WRAPPER_REGISTRY))
44
+ raise ValueError(f"unknown backend: {config.backend!r} (known backends: {known})")
45
+ return cls(config)
46
+
47
+
48
+ def available_backends() -> list[str]:
49
+ return sorted(WRAPPER_REGISTRY)
@@ -0,0 +1,10 @@
1
+ """Shared fallback token-count heuristic, used when a core's real
2
+ tokenizer/endpoint is unavailable (offline, no matching tiktoken
3
+ encoding, network error on a pure estimation call, etc.)."""
4
+
5
+
6
+ def heuristic_token_count(text: str) -> int:
7
+ """~4 characters per token is the same rough approximation every
8
+ core already falls back to individually; centralised here so it's
9
+ defined once."""
10
+ return max(1, len(text) // 4)
@@ -0,0 +1,5 @@
1
+ """The Local LLM component: models served on this machine (Ollama, vLLM, LM Studio, llama.cpp) and the in-process Hugging Face model."""
2
+ from pal.local_llm.components.huggingface_local import HuggingFaceLocalLLM
3
+ from pal.local_llm.components.local_llm import DEFAULT_URLS, LOCAL_BACKENDS, LocalLLM
4
+
5
+ __all__ = ["DEFAULT_URLS", "LOCAL_BACKENDS", "HuggingFaceLocalLLM", "LocalLLM"]
File without changes