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,316 @@
1
+ """LLMAdapter: the LLM core, as AIOS section 3.2 and Appendix A.2 describe it.
2
+
3
+ Each LLM instance is wrapped as a *core* (like a CPU core in an operating system), whatever its deployment: a cloud API
4
+ (OpenAI, Anthropic, Google, Groq, Bedrock), a local server (vLLM, Ollama) or a model loaded in this process (Hugging Face).
5
+ The adapter holds one core per configured instance and answers unified inference system calls on them:
6
+
7
+ llm_chat plain text
8
+ llm_chat_with_json_output structured (JSON) output
9
+ llm_chat_with_tool_call_output function calling
10
+
11
+ Every call returns one LLMAdapterResponse (response_message, tool_calls, finished, error, status_code). A failure is reported
12
+ in that response, never raised. With several instances the routing strategy picks one per request; with `use_context_manager`
13
+ a long generation can be interrupted at a time limit and resumed.
14
+
15
+ Function calling and structured output are native where the backend supports them and emulated (prompt plus validation)
16
+ where it does not; `WrapperCapabilities.supports_tools` and `supports_json` say which.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import logging
22
+ import os
23
+ import re
24
+ import threading
25
+ import time
26
+ from dataclasses import dataclass
27
+ from typing import Any
28
+
29
+ import requests
30
+
31
+ from pal.context_manager.components.simple_context_manager import SimpleContextManager
32
+ from pal.errors import ConfigError, ProviderError
33
+ from pal.llm_wrapper.components.adapter_types import (
34
+ ACTIONS,
35
+ LLMAdapterResponse,
36
+ LLMAdapterSyscall,
37
+ LLMConfig,
38
+ RouterStrategy,
39
+ )
40
+ from pal.llm_wrapper.components.base import LLMResponse, LLMSyscall, LLMWrapper, WrapperCapabilities
41
+ from pal.llm_wrapper.components.features import (
42
+ ToolCallError,
43
+ check_tool_names,
44
+ extract_json,
45
+ json_instruction,
46
+ normalize_tools,
47
+ parse_emulated_tool_calls,
48
+ tools_instruction,
49
+ )
50
+ from pal.llm_wrapper.utils.registry import BACKEND_ALIASES, build_wrapper
51
+ from pal.redact import redact
52
+
53
+ log = logging.getLogger(__name__)
54
+
55
+ # Environment variables each cloud backend's key is read from when none is given.
56
+ _ENV_KEYS = {
57
+ "openai": ("OPENAI_API_KEY",), "azure_openai": ("AZURE_OPENAI_API_KEY",), "anthropic": ("ANTHROPIC_API_KEY",),
58
+ "gemini": ("GEMINI_API_KEY", "GOOGLE_API_KEY"), "groq": ("GROQ_API_KEY",), "deepseek": ("DEEPSEEK_API_KEY",),
59
+ "mistral": ("MISTRAL_API_KEY",), "together": ("TOGETHER_API_KEY",), "openrouter": ("OPENROUTER_API_KEY",),
60
+ "perplexity": ("PERPLEXITY_API_KEY",),
61
+ }
62
+ # Backends that never take an API key from us: local runtimes, and Bedrock (the AWS credential chain).
63
+ _KEYLESS = {"ollama", "vllm", "lmstudio", "llamacpp", "huggingface_local", "bedrock"}
64
+ _SECRET = re.compile(r"(sk-[A-Za-z0-9_\-]{8,}|AIza[0-9A-Za-z_\-]{10,}|AKIA[0-9A-Z]{12,}|Bearer\s+[A-Za-z0-9._\-]{12,})")
65
+
66
+
67
+ @dataclass
68
+ class _Llm:
69
+ name: str
70
+ core: LLMWrapper
71
+ config: LLMConfig
72
+
73
+
74
+ class LLMAdapter:
75
+ def __init__(self, llm_configs: list[dict | LLMConfig], api_key: str | list[str | None] | None = None,
76
+ log_mode: str = "console", use_context_manager: bool = False,
77
+ strategy: RouterStrategy = RouterStrategy.Sequential):
78
+ if not llm_configs:
79
+ raise ConfigError("the LLM adapter needs at least one LLM config")
80
+ self.llm_configs = [c if isinstance(c, LLMConfig) else LLMConfig.from_dict(c) for c in llm_configs]
81
+ self.api_key = api_key
82
+ self.log_mode = log_mode
83
+ self.strategy = RouterStrategy(strategy)
84
+ self.context_manager = SimpleContextManager() if use_context_manager else None
85
+ self.llms: list[_Llm] = []
86
+ self._next = 0
87
+ self._lock = threading.Lock()
88
+ self._tls = threading.local()
89
+ self.setup_api_keys()
90
+ self.initialize_llms()
91
+
92
+ # -- setup ------------------------------------------------------------------------------------------------
93
+ def setup_api_keys(self) -> None:
94
+ """A key given in the config wins; then `api_key` (one string for all, or a list matched by position); then the
95
+ backend's usual environment variable. Local runtimes and Bedrock take no key from here."""
96
+ for i, cfg in enumerate(self.llm_configs):
97
+ if cfg.api_key:
98
+ continue
99
+ backend = BACKEND_ALIASES.get(cfg.backend, cfg.backend)
100
+ given = (self.api_key[i] if i < len(self.api_key) else None) if isinstance(self.api_key, (list, tuple)) else self.api_key
101
+ if backend in _KEYLESS:
102
+ continue
103
+ cfg.api_key = given or next((os.environ[v] for v in _ENV_KEYS.get(backend, ()) if os.environ.get(v)), None)
104
+
105
+ def initialize_llms(self) -> None:
106
+ for cfg in self.llm_configs:
107
+ self.initialize_single_llm(cfg)
108
+
109
+ def initialize_single_llm(self, config: LLMConfig) -> None:
110
+ config.backend = BACKEND_ALIASES.get(config.backend, config.backend)
111
+ try:
112
+ core = build_wrapper(config.to_core_config())
113
+ except Exception as e:
114
+ raise ConfigError(f"could not set up {config.backend}/{config.name}: {self._mask(str(e))}") from e
115
+ self.llms.append(_Llm(config.name, core, config))
116
+
117
+ # -- unified system calls ----------------------------------------------------------------------------------
118
+ def llm_chat(self, messages: list[dict], **kw: Any) -> LLMAdapterResponse:
119
+ return self.execute_llm_syscall(LLMAdapterSyscall(messages=messages, action_type="chat", **self._kw(kw)))
120
+
121
+ def llm_chat_with_json_output(self, messages: list[dict], response_format: dict | None = None,
122
+ **kw: Any) -> LLMAdapterResponse:
123
+ return self.execute_llm_syscall(LLMAdapterSyscall(
124
+ messages=messages, action_type="chat_with_json_output", message_return_type="json",
125
+ response_format=response_format, **self._kw(kw)))
126
+
127
+ def llm_chat_with_tool_call_output(self, messages: list[dict], tools: list[dict], **kw: Any) -> LLMAdapterResponse:
128
+ return self.execute_llm_syscall(LLMAdapterSyscall(
129
+ messages=messages, action_type="chat_with_tool_call_output", tools=tools, **self._kw(kw)))
130
+
131
+ @staticmethod
132
+ def _kw(kw: dict) -> dict:
133
+ allowed = {"llms", "temperature", "max_tokens", "pid", "time_limit", "model"}
134
+ bad = set(kw) - allowed
135
+ if bad:
136
+ raise TypeError(f"unexpected argument(s): {', '.join(sorted(bad))}")
137
+ return kw
138
+
139
+ # -- the call --------------------------------------------------------------------------------------------------
140
+ def execute_llm_syscall(self, llm_syscall: LLMAdapterSyscall, temperature: float = 0.0) -> LLMAdapterResponse:
141
+ """Run one request on one LLM instance. `temperature` is the fallback when neither the syscall nor the instance
142
+ sets one. Never raises: a failure comes back as an LLMAdapterResponse with `error` and `status_code`."""
143
+ started = time.perf_counter()
144
+ self._tls.usage = [0, 0]
145
+ llm: _Llm | None = None
146
+ try:
147
+ if llm_syscall.action_type not in ACTIONS:
148
+ raise ValueError(f"unknown action_type {llm_syscall.action_type!r}; use one of {', '.join(ACTIONS)}")
149
+ llm = self._select(llm_syscall)
150
+ temp = llm_syscall.temperature if llm_syscall.temperature is not None else (
151
+ llm.config.temperature if llm.config.temperature is not None else temperature)
152
+ max_tokens = llm_syscall.max_tokens or llm.config.max_new_tokens
153
+ tools = llm_syscall.tools if llm_syscall.action_type == "chat_with_tool_call_output" else None
154
+ if llm_syscall.action_type == "chat_with_tool_call_output" and not tools:
155
+ raise ValueError("chat_with_tool_call_output needs at least one tool")
156
+ mrt = "json" if llm_syscall.action_type == "chat_with_json_output" else llm_syscall.message_return_type
157
+ if (self.context_manager and llm_syscall.time_limit and llm_syscall.pid is not None and not tools
158
+ and mrt == "text" and not llm_syscall.response_format):
159
+ completed, finished = self.context_manager.generate_response_with_interruption(
160
+ llm.config.name, llm.core, llm_syscall.messages, tools, mrt, temp, max_tokens, llm_syscall.pid,
161
+ llm_syscall.time_limit, llm_syscall.response_format)
162
+ else:
163
+ completed = self.get_model_response(
164
+ llm.config.name, llm.core, llm_syscall.messages, tools, llm_syscall, llm.config.hostname, mrt,
165
+ llm_syscall.response_format, temp, max_tokens)
166
+ finished = True
167
+ resp = self.process_response(completed, finished, tools, llm.core, mrt)
168
+ except Exception as e: # noqa: BLE001 (every failure becomes a response)
169
+ resp = self.handle_completion_error(e)
170
+ resp.prompt_tokens, resp.completion_tokens = self._tls.usage
171
+ resp.latency = round(time.perf_counter() - started, 4)
172
+ if llm is not None:
173
+ resp.model, resp.backend = llm.config.name, llm.config.backend
174
+ if self.log_mode == "console":
175
+ log.info("llm call backend=%s model=%s status=%s in=%d out=%d %.2fs", resp.backend or "-", resp.model or "-",
176
+ resp.status_code, resp.prompt_tokens, resp.completion_tokens, resp.latency)
177
+ return resp
178
+
179
+ def _select(self, syscall: LLMAdapterSyscall) -> _Llm:
180
+ """RouterStrategy.Sequential: the instances in turn, one per request (among the named ones, if the call names some)."""
181
+ with self._lock:
182
+ pool = self.llms
183
+ if syscall.llms:
184
+ pool = [x for x in self.llms if x.name in syscall.llms or f"{x.config.backend}/{x.name}" in syscall.llms]
185
+ if not pool:
186
+ raise ValueError(f"no configured LLM matches {syscall.llms}")
187
+ chosen = pool[self._next % len(pool)]
188
+ self._next += 1
189
+ return chosen
190
+
191
+ def get_model_response(self, model_name: str, model: LLMWrapper, messages: list[dict], tools: list[dict] | None,
192
+ llm_syscall: LLMAdapterSyscall, api_base: str | None = None, message_return_type: str = "text",
193
+ response_format: dict | None = None, temperature: float = 1.0, max_tokens: int = 1000) -> Any:
194
+ """Ask the model. Returns text, a JSON string, or a list of tool calls: the 'completed response' that
195
+ process_response turns into the unified response. `api_base` is part of the paper's signature; the core already
196
+ holds its own endpoint."""
197
+ caps: WrapperCapabilities = model.capabilities()
198
+
199
+ def call(msgs: list[dict], **extra: Any) -> LLMResponse:
200
+ sc = LLMAdapterSyscall(messages=msgs, model=model_name or None, temperature=temperature, max_tokens=max_tokens,
201
+ **extra)
202
+ r = model.chat(sc)
203
+ self._tls.usage[0] += r.prompt_tokens or 0
204
+ self._tls.usage[1] += r.completion_tokens or 0
205
+ return r
206
+
207
+ specs = normalize_tools(tools) if tools else []
208
+ if specs:
209
+ if caps.supports_tools:
210
+ r = call(messages, tools=specs)
211
+ calls = (r.meta or {}).get("tool_calls")
212
+ else:
213
+ r = call(self._with_instruction(messages, tools_instruction(specs)))
214
+ calls = parse_emulated_tool_calls(r.text, specs)
215
+ if calls:
216
+ check_tool_names(calls, specs)
217
+ return calls
218
+ return r.text
219
+
220
+ if message_return_type == "json" or response_format:
221
+ schema = response_format if isinstance(response_format, dict) else None
222
+ msgs = messages if caps.supports_json else self._with_instruction(messages, json_instruction(schema))
223
+ r = call(msgs, response_format=(schema or {"type": "json_object"}) if caps.supports_json else None)
224
+ try:
225
+ extract_json(r.text)
226
+ except ValueError as e: # one retry that tells the model what was wrong
227
+ retry = msgs + [{"role": "assistant", "content": r.text},
228
+ {"role": "user", "content": f"That was not valid JSON ({e}). Reply again with only valid JSON."}]
229
+ r = call(retry, response_format=(schema or {"type": "json_object"}) if caps.supports_json else None)
230
+ return r.text
231
+ return call(messages).text
232
+
233
+ @staticmethod
234
+ def _with_instruction(messages: list[dict], instruction: str) -> list[dict]:
235
+ out = [dict(m) for m in messages]
236
+ if out and out[0].get("role") == "system":
237
+ out[0]["content"] = f"{out[0]['content']}\n\n{instruction}"
238
+ else:
239
+ out.insert(0, {"role": "system", "content": instruction})
240
+ return out
241
+
242
+ def process_response(self, completed_response: Any, finished: bool, tools: list[dict] | None = None,
243
+ model: Any = None, message_return_type: str | None = None) -> LLMAdapterResponse:
244
+ if isinstance(completed_response, list): # tool calls
245
+ calls = [{"name": c["name"], "parameters": c.get("parameters") or {}} for c in completed_response]
246
+ return LLMAdapterResponse(tool_calls=calls, finished=finished)
247
+ text = completed_response if isinstance(completed_response, str) else str(completed_response or "")
248
+ if message_return_type == "json":
249
+ try:
250
+ text = json.dumps(extract_json(text), ensure_ascii=False)
251
+ except ValueError as e:
252
+ return LLMAdapterResponse(error=f"the model did not return valid JSON: {e}", status_code=422, finished=True)
253
+ return LLMAdapterResponse(response_message=text, finished=finished)
254
+
255
+ def handle_completion_error(self, error: Exception) -> LLMAdapterResponse:
256
+ return LLMAdapterResponse(response_message=None, finished=True, status_code=self._status_for(error),
257
+ error=self._mask(f"{type(error).__name__}: {error}"))
258
+
259
+ @staticmethod
260
+ def _status_for(error: Exception) -> int:
261
+ if isinstance(error, requests.HTTPError) and error.response is not None:
262
+ return int(error.response.status_code)
263
+ for attr in ("status_code", "code"):
264
+ code = getattr(error, attr, None)
265
+ if isinstance(code, int) and 400 <= code <= 599:
266
+ return code
267
+ resp = getattr(error, "response", None)
268
+ if isinstance(resp, dict) and isinstance(resp.get("ResponseMetadata", {}).get("HTTPStatusCode"), int):
269
+ return resp["ResponseMetadata"]["HTTPStatusCode"]
270
+ if isinstance(error, (requests.Timeout, TimeoutError)):
271
+ return 504
272
+ if isinstance(error, requests.ConnectionError):
273
+ return 503
274
+ if isinstance(error, ToolCallError):
275
+ return 422
276
+ low = str(error).lower()
277
+ if "429" in low or "rate limit" in low or "quota" in low or "resource_exhausted" in low:
278
+ return 429
279
+ if "401" in low or "unauthorized" in low or "api key" in low:
280
+ return 401
281
+ if isinstance(error, (ValueError, TypeError, ConfigError)):
282
+ return 400
283
+ return 500
284
+
285
+ def _mask(self, text: str) -> str:
286
+ return redact(text, *(cfg.api_key for cfg in self.llm_configs))
287
+
288
+ # -- drop-in as an LLMWrapper, so the SDK and the rest of Pal can run on the adapter ---------------------------
289
+ @property
290
+ def primary(self) -> LLMWrapper:
291
+ return self.llms[0].core
292
+
293
+ @property
294
+ def config(self) -> Any:
295
+ return self.primary.config
296
+
297
+ def chat(self, syscall: LLMSyscall) -> LLMResponse:
298
+ sc = syscall if isinstance(syscall, LLMAdapterSyscall) else LLMAdapterSyscall(
299
+ messages=syscall.messages, model=syscall.model, temperature=syscall.temperature, max_tokens=syscall.max_tokens,
300
+ stream=syscall.stream, params=syscall.params)
301
+ resp = self.execute_llm_syscall(sc)
302
+ if resp.error:
303
+ raise ProviderError(resp.error)
304
+ return resp.to_core_response()
305
+
306
+ def stream_chat(self, syscall: LLMSyscall):
307
+ return self.primary.stream_chat(syscall)
308
+
309
+ def token_count(self, text: str) -> int:
310
+ return self.primary.token_count(text)
311
+
312
+ def embed(self, texts: list[str]) -> list[list[float]]:
313
+ return self.primary.embed(texts)
314
+
315
+ def capabilities(self) -> WrapperCapabilities:
316
+ return self.primary.capabilities()
@@ -0,0 +1,23 @@
1
+ from pal.llm_wrapper.components.base import (
2
+ DeploymentType,
3
+ LLMResponse,
4
+ LLMSyscall,
5
+ LLMWrapper,
6
+ UnavailableWrapper,
7
+ WrapperCapabilities,
8
+ WrapperConfig,
9
+ )
10
+ from pal.llm_wrapper.components.litellm_wrapper import LiteLLMWrapper
11
+ from pal.local_llm.components.huggingface_local import HuggingFaceLocalLLM
12
+
13
+ __all__ = [
14
+ "WrapperCapabilities",
15
+ "WrapperConfig",
16
+ "DeploymentType",
17
+ "HuggingFaceLocalLLM",
18
+ "LLMWrapper",
19
+ "LLMResponse",
20
+ "LLMSyscall",
21
+ "LiteLLMWrapper",
22
+ "UnavailableWrapper",
23
+ ]
@@ -0,0 +1,90 @@
1
+ """Types for the LLM adapter (AIOS Appendix A.2): the config of one LLM instance, the unified inference syscall, the
2
+ unified response, and the routing strategy."""
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ from typing import Any
8
+
9
+ from pal.llm_wrapper.components.base import LLMResponse, LLMSyscall, WrapperConfig
10
+
11
+ ACTIONS = ("chat", "chat_with_json_output", "chat_with_tool_call_output")
12
+
13
+
14
+ class RouterStrategy(str, Enum):
15
+ """How the adapter picks one of several LLM instances for a request."""
16
+
17
+ Sequential = "sequential" # take the instances in turn, request after request
18
+
19
+
20
+ @dataclass
21
+ class LLMConfig:
22
+ """One LLM instance: which model, on which backend, reached how."""
23
+
24
+ name: str # the model name
25
+ backend: str # openai, anthropic, gemini, groq, bedrock, huggingface_local, vllm, ollama, ...
26
+ max_new_tokens: int = 1000
27
+ temperature: float | None = None # None = leave to the call (or the adapter's default)
28
+ hostname: str | None = None # server URL for local backends, or an API base URL override
29
+ api_key: str | None = None
30
+ extra: dict = field(default_factory=dict)
31
+
32
+ @classmethod
33
+ def from_dict(cls, d: dict) -> LLMConfig:
34
+ d = dict(d)
35
+ return cls(
36
+ name=d.pop("name", None) or d.pop("model", None) or "",
37
+ backend=d.pop("backend", ""),
38
+ max_new_tokens=int(d.pop("max_new_tokens", d.pop("max_tokens", 1000))),
39
+ temperature=(float(d.pop("temperature")) if d.get("temperature") is not None else d.pop("temperature", None)),
40
+ hostname=d.pop("hostname", None) or d.pop("host", None) or d.pop("base_url", None),
41
+ api_key=d.pop("api_key", None),
42
+ extra=d)
43
+
44
+ def to_core_config(self) -> WrapperConfig:
45
+ return WrapperConfig(backend=self.backend, model=self.name, host=self.hostname, base_url=self.hostname,
46
+ api_key=self.api_key, device=self.extra.get("device"), extra=dict(self.extra))
47
+
48
+
49
+ @dataclass
50
+ class LLMAdapterSyscall(LLMSyscall):
51
+ """The unified LLM inference system call. Fields beyond a plain chat request are real attributes (not `params`),
52
+ because cores pass `params` straight into the provider's HTTP payload."""
53
+
54
+ action_type: str = "chat" # chat | chat_with_json_output | chat_with_tool_call_output
55
+ tools: list[dict] | None = None
56
+ message_return_type: str = "text" # text | json
57
+ response_format: dict | None = None # JSON schema (or {"type": "json_object"}) for structured output
58
+ llms: list[str] | None = None # restrict this call to these named LLMs
59
+ pid: Any = None # identifies the caller for interrupt and resume
60
+ time_limit: float | None = None # seconds; with a context manager, generation can be interrupted
61
+
62
+
63
+ @dataclass
64
+ class LLMAdapterResponse:
65
+ """The unified response. `response_message` is text or a JSON string; `tool_calls` is a list of
66
+ {"name": ..., "parameters": {...}}; a failure is reported here, never raised."""
67
+
68
+ response_message: str | None = None
69
+ tool_calls: list[dict] | None = None
70
+ finished: bool = False
71
+ error: str | None = None
72
+ status_code: int = 200
73
+ # not in the paper: Pal needs these to count tokens and time
74
+ prompt_tokens: int = 0
75
+ completion_tokens: int = 0
76
+ model: str = ""
77
+ backend: str = ""
78
+ latency: float = 0.0
79
+
80
+ @property
81
+ def text(self) -> str:
82
+ return self.response_message or ""
83
+
84
+ @property
85
+ def ok(self) -> bool:
86
+ return self.error is None
87
+
88
+ def to_core_response(self) -> LLMResponse:
89
+ return LLMResponse(text=self.text, prompt_tokens=self.prompt_tokens, completion_tokens=self.completion_tokens,
90
+ meta={"latency": self.latency, "tool_calls": self.tool_calls, "finished": self.finished})
@@ -0,0 +1,195 @@
1
+ """
2
+ Base abstractions for the LLM core layer (AIOS section 3.2 equivalent).
3
+
4
+ Every LLM deployment option -- a cloud API, a local server, or a fully
5
+ in-process local model -- is wrapped as one "core", the same way a CPU
6
+ core abstracts away a physical execution unit from the scheduler that
7
+ uses it. Callers never talk to a specific backend's SDK or HTTP schema
8
+ directly; they issue one of a small set of *unified system calls*
9
+ (LLMSyscall) against whichever core is currently selected, and every
10
+ core answers with the same LLMResponse shape.
11
+
12
+ This is what makes a core "a core" rather than just an adapter class:
13
+ the wrapper is not "however OllamaAdapter happens to shape its
14
+ send()", it is a fixed contract (chat / token_count / embed /
15
+ capabilities) that every deployment option must satisfy, cloud or
16
+ local, API-based or in-process.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from abc import ABC, abstractmethod
21
+ from dataclasses import dataclass, field
22
+ from enum import Enum
23
+ from typing import Any, Optional
24
+
25
+
26
+ class DeploymentType(str, Enum):
27
+ """How the wrapped LLM instance is actually reached. Mirrors the
28
+ "cloud vs local device, which hardware, which inference framework"
29
+ distinction AIOS 3.2 calls out as the reason a core abstraction is
30
+ needed in the first place."""
31
+
32
+ CLOUD_API = "cloud_api" # billed HTTP API (OpenAI, Anthropic, Gemini, ...)
33
+ LOCAL_SERVER = "local_server" # local HTTP server (Ollama, llama.cpp, vLLM, LM Studio)
34
+ LOCAL_IN_PROCESS = "local_in_process" # no server at all -- model loaded into this process
35
+
36
+
37
+ @dataclass
38
+ class WrapperConfig:
39
+ """Deployment-agnostic configuration for building any core.
40
+
41
+ Not every field is used by every core -- e.g. `device` only matters
42
+ to LOCAL_IN_PROCESS cores, `api_key` is usually unused for local
43
+ ones. Unrecognised/backend-specific settings go in `extra` instead
44
+ of forcing new top-level fields for every new backend.
45
+ """
46
+
47
+ backend: str = "ollama"
48
+ model: str = "llama3"
49
+ host: Optional[str] = None # local server base URL, e.g. http://localhost:11434
50
+ base_url: Optional[str] = None # cloud/OpenAI-compatible API base URL override
51
+ api_key: Optional[str] = None # falls back to the backend's standard env var if unset
52
+ device: Optional[str] = None # "cuda", "cpu", "mps" -- local in-process cores only
53
+ extra: dict = field(default_factory=dict)
54
+
55
+
56
+ @dataclass
57
+ class LLMSyscall:
58
+ """A single unified inference request. This is the "system call"
59
+ every core accepts, regardless of deployment option -- callers never
60
+ build a backend-specific payload themselves."""
61
+
62
+ messages: list[dict]
63
+ model: Optional[str] = None
64
+ temperature: Optional[float] = None
65
+ max_tokens: Optional[int] = None
66
+ stream: bool = False
67
+ params: dict = field(default_factory=dict) # backend-specific passthrough, used sparingly
68
+
69
+
70
+ @dataclass
71
+ class LLMResponse:
72
+ text: str
73
+ raw: Any = None
74
+ prompt_tokens: int = 0
75
+ completion_tokens: int = 0
76
+ meta: dict = field(default_factory=dict)
77
+
78
+
79
+ @dataclass
80
+ class WrapperCapabilities:
81
+ """What a given core can actually do, so callers (and the pipeline)
82
+ can branch on capability instead of on backend name."""
83
+
84
+ deployment_type: DeploymentType
85
+ supports_kv_cache: bool = False
86
+ supports_embeddings: bool = False
87
+ supports_streaming: bool = False
88
+ supports_tools: bool = False # native function calling (else the adapter emulates it with a prompt)
89
+ supports_json: bool = False # native structured (JSON) output (else the adapter emulates it and validates)
90
+ billed_per_token: bool = False # true for cloud APIs: tokens saved == dollars saved
91
+ # True only for cores with direct access to per-step token logits/
92
+ # search-tree state (in-process local models). Cloud/local-server
93
+ # cores never get raw logits back over HTTP, so this is always
94
+ # False for them -- see orchestrator/components/context_interrupt.py
95
+ # for what this gates: text-based vs logits-based snapshotting.
96
+ supports_logits_snapshot: bool = False
97
+
98
+
99
+ class LLMWrapper(ABC):
100
+ """Abstract wrapper for one LLM instance under one deployment
101
+ option. Concrete cores implement this for a specific backend
102
+ (OpenAI-compatible APIs, Anthropic, Gemini, Ollama, an in-process
103
+ local model, ...) but every one of them is driven through the same
104
+ three system calls below.
105
+
106
+ Cores that report capabilities().supports_logits_snapshot=True are
107
+ expected to additionally implement three duck-typed methods used by
108
+ the logits-based context interrupt mechanism (see
109
+ orchestrator/components/context_interrupt.py):
110
+ `init_beam_search(syscall, beam_width)`, `beam_search_step(state,
111
+ max_new_tokens)`, and `decode_beam_search(state)`. These aren't
112
+ declared as abstract methods here because they require direct
113
+ logits access and a concrete search-tree state representation --
114
+ something only an in-process local core (HuggingFaceLocalLLM) can
115
+ provide; every cloud/local-server core simply never sets that
116
+ capability flag and is never asked for them.
117
+ """
118
+
119
+ name: str = "base"
120
+
121
+ def __init__(self, config: WrapperConfig):
122
+ self.config = config
123
+
124
+ # -- unified system calls -------------------------------------------------
125
+
126
+ @abstractmethod
127
+ def chat(self, syscall: LLMSyscall) -> LLMResponse:
128
+ """SYS_LLM_CHAT: run one inference request."""
129
+ ...
130
+
131
+ def stream_chat(self, syscall: LLMSyscall):
132
+ """SYS_LLM_CHAT_STREAM: same request, but yields text chunks as
133
+ they're generated instead of blocking for the full response.
134
+
135
+ This is what the scheduler's Round Robin strategy times against
136
+ for its context-interrupt mechanism (orchestrator/components/
137
+ context_interrupt.py) -- RR needs to be able to stop a generation
138
+ partway through a time slice, not just measure how long a
139
+ blocking call took.
140
+
141
+ Default fallback: cores that don't override this yield the
142
+ entire response as one chunk once chat() returns. That's
143
+ functionally correct (RR still works, just can't interrupt
144
+ mid-generation for that core -- the interrupt check only gets
145
+ one opportunity, at the end) but doesn't give RR the granularity
146
+ real token-level streaming does. Cores that expose a real
147
+ streaming endpoint (Ollama, the OpenAI-compatible schema)
148
+ override this with true incremental yielding.
149
+ """
150
+ yield self.chat(syscall).text
151
+
152
+ @abstractmethod
153
+ def token_count(self, text: str) -> int:
154
+ """SYS_LLM_TOKEN_COUNT: count tokens the way this core's model
155
+ actually tokenizes, falling back to a heuristic estimate when
156
+ the backend has no tokenizer endpoint available."""
157
+ ...
158
+
159
+ def embed(self, texts: list[str]) -> list[list[float]]:
160
+ """SYS_LLM_EMBED: optional. Cores that don't support embeddings
161
+ raise NotImplementedError rather than silently returning
162
+ zero-vectors, so callers can detect the gap via capabilities()
163
+ instead of getting a wrong answer."""
164
+ raise NotImplementedError(f"{self.name} core does not support embeddings")
165
+
166
+ # -- capability / KV-cache introspection ----------------------------------
167
+
168
+ @abstractmethod
169
+ def capabilities(self) -> WrapperCapabilities:
170
+ ...
171
+
172
+ def supports_kv_cache(self) -> bool:
173
+ return self.capabilities().supports_kv_cache
174
+
175
+ def reset_kv_cache(self) -> None:
176
+ pass
177
+
178
+
179
+ class UnavailableWrapper(LLMWrapper):
180
+ """Stands in for a core that could not be set up (for example LiteLLM is not installed). Every call raises, so the callers' own
181
+ "the local model failed" paths run: routing falls back to the API model and the input savers send your message untouched."""
182
+
183
+ name = "unavailable"
184
+
185
+ def __init__(self, why: str):
186
+ self.why = why
187
+
188
+ def chat(self, syscall: LLMSyscall) -> LLMResponse:
189
+ raise ConnectionError(f"the local model is unavailable: {self.why}")
190
+
191
+ def token_count(self, text: str) -> int:
192
+ return max(1, len(text) // 4)
193
+
194
+ def capabilities(self) -> WrapperCapabilities:
195
+ return WrapperCapabilities(deployment_type=DeploymentType.LOCAL_SERVER)