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.
- pal/__init__.py +29 -0
- pal/__main__.py +3 -0
- pal/access_manager/__init__.py +16 -0
- pal/access_manager/components/__init__.py +14 -0
- pal/access_manager/components/access_control.py +41 -0
- pal/access_manager/components/base.py +39 -0
- pal/access_manager/components/intervention.py +31 -0
- pal/access_manager/components/privilege_registry.py +30 -0
- pal/access_manager/pipeline.py +68 -0
- pal/access_manager/utils/__init__.py +2 -0
- pal/agent_constraints/__init__.py +3 -0
- pal/agent_constraints/components/AGENT_OUTPUT.md +13 -0
- pal/agent_constraints/components/__init__.py +3 -0
- pal/agent_constraints/components/constraints.py +22 -0
- pal/agent_constraints/pipeline.py +22 -0
- pal/agent_constraints/utils/__init__.py +2 -0
- pal/agents/__init__.py +12 -0
- pal/agents/coding.py +14 -0
- pal/agents/docs.py +15 -0
- pal/chat/__init__.py +4 -0
- pal/chat/demo.py +231 -0
- pal/chat/providers.py +23 -0
- pal/chat/service.py +591 -0
- pal/chat/services/__init__.py +26 -0
- pal/chat/services/base.py +142 -0
- pal/chat/services/data.py +142 -0
- pal/chat/services/media.py +123 -0
- pal/chat/services/web.py +60 -0
- pal/chat/sessions.py +108 -0
- pal/chat/tools.py +446 -0
- pal/chat/uploads.py +81 -0
- pal/cli.py +502 -0
- pal/compression/__init__.py +12 -0
- pal/compression/components/__init__.py +64 -0
- pal/compression/components/adverbs.py +20 -0
- pal/compression/components/hedges.py +54 -0
- pal/compression/components/interjections.py +20 -0
- pal/compression/components/minifier.py +56 -0
- pal/compression/components/output_compressor.py +13 -0
- pal/compression/components/pleonasms.py +54 -0
- pal/compression/components/punctuations.py +36 -0
- pal/compression/components/stopwords.py +62 -0
- pal/compression/history.py +346 -0
- pal/compression/pipeline.py +58 -0
- pal/compression/utils/__init__.py +30 -0
- pal/compression/utils/negations.py +18 -0
- pal/compression/utils/nltk_setup.py +20 -0
- pal/compression/utils/pronouns.py +27 -0
- pal/compression/utils/proselint_setup.py +9 -0
- pal/compression/utils/spacy_setup.py +22 -0
- pal/config/default.yaml +163 -0
- pal/config.py +504 -0
- pal/context_manager/__init__.py +7 -0
- pal/context_manager/chat_context.py +117 -0
- pal/context_manager/components/__init__.py +5 -0
- pal/context_manager/components/assembler.py +75 -0
- pal/context_manager/components/context_cache.py +45 -0
- pal/context_manager/components/kv_bridge.py +16 -0
- pal/context_manager/components/pager.py +48 -0
- pal/context_manager/components/simple_context_manager.py +56 -0
- pal/context_manager/components/turns.py +88 -0
- pal/context_manager/pipeline.py +27 -0
- pal/context_manager/utils/__init__.py +18 -0
- pal/context_manager/utils/hashing.py +5 -0
- pal/context_manager/utils/relevance.py +59 -0
- pal/context_manager/utils/storage.py +37 -0
- pal/documents/__init__.py +4 -0
- pal/documents/components/__init__.py +0 -0
- pal/documents/components/chunker.py +79 -0
- pal/documents/components/index.py +155 -0
- pal/documents/utils/__init__.py +1 -0
- pal/errors.py +40 -0
- pal/input_converters/__init__.py +8 -0
- pal/input_converters/components/__init__.py +0 -0
- pal/input_converters/components/base.py +38 -0
- pal/input_converters/components/pandoc_converter.py +87 -0
- pal/input_converters/components/pdf_converter.py +46 -0
- pal/input_converters/components/pptx_converter.py +52 -0
- pal/input_converters/components/xlsx_converter.py +38 -0
- pal/input_converters/pipeline.py +72 -0
- pal/input_converters/utils/__init__.py +1 -0
- pal/input_converters/workspace.py +56 -0
- pal/input_savers/INPUT_RULES.md +2 -0
- pal/input_savers/__init__.py +5 -0
- pal/input_savers/components/__init__.py +0 -0
- pal/input_savers/components/base.py +40 -0
- pal/input_savers/components/methods.py +41 -0
- pal/input_savers/components/verifier.py +39 -0
- pal/input_savers/pipeline.py +154 -0
- pal/input_savers/utils/__init__.py +0 -0
- pal/input_savers/utils/guards.py +94 -0
- pal/knowledge_graph/__init__.py +10 -0
- pal/knowledge_graph/components/__init__.py +0 -0
- pal/knowledge_graph/components/base.py +50 -0
- pal/knowledge_graph/components/builder.py +54 -0
- pal/knowledge_graph/components/retriever.py +100 -0
- pal/knowledge_graph/components/workspace.py +116 -0
- pal/knowledge_graph/pipeline.py +215 -0
- pal/knowledge_graph/utils/__init__.py +1 -0
- pal/latency_tracker/__init__.py +3 -0
- pal/latency_tracker/pipeline.py +86 -0
- pal/llm_scheduler/__init__.py +11 -0
- pal/llm_scheduler/api.py +137 -0
- pal/llm_scheduler/code.py +28 -0
- pal/llm_scheduler/core.py +92 -0
- pal/llm_scheduler/local.py +215 -0
- pal/llm_scheduler/pipeline.py +42 -0
- pal/llm_scheduler/types.py +92 -0
- pal/llm_wrapper/__init__.py +34 -0
- pal/llm_wrapper/adapter.py +316 -0
- pal/llm_wrapper/components/__init__.py +23 -0
- pal/llm_wrapper/components/adapter_types.py +90 -0
- pal/llm_wrapper/components/base.py +195 -0
- pal/llm_wrapper/components/features.py +104 -0
- pal/llm_wrapper/components/litellm_wrapper.py +215 -0
- pal/llm_wrapper/components/openrouter_wrapper.py +114 -0
- pal/llm_wrapper/modes.py +73 -0
- pal/llm_wrapper/pipeline.py +61 -0
- pal/llm_wrapper/utils/__init__.py +4 -0
- pal/llm_wrapper/utils/registry.py +49 -0
- pal/llm_wrapper/utils/token_counting.py +10 -0
- pal/local_llm/__init__.py +5 -0
- pal/local_llm/components/__init__.py +0 -0
- pal/local_llm/components/huggingface_local.py +217 -0
- pal/local_llm/components/local_llm.py +60 -0
- pal/logging_setup.py +18 -0
- pal/memory/__init__.py +7 -0
- pal/memory/components/__init__.py +6 -0
- pal/memory/components/lru_k.py +56 -0
- pal/memory/components/manager.py +98 -0
- pal/memory/components/note.py +54 -0
- pal/memory/components/storage_manager.py +60 -0
- pal/memory/pipeline.py +122 -0
- pal/memory/utils/__init__.py +3 -0
- pal/memory/utils/keyword_overlap.py +3 -0
- pal/memory_tracker/__init__.py +3 -0
- pal/memory_tracker/components/__init__.py +0 -0
- pal/memory_tracker/components/process.py +56 -0
- pal/memory_tracker/pipeline.py +44 -0
- pal/metrics/__init__.py +4 -0
- pal/metrics/components/__init__.py +4 -0
- pal/metrics/components/collector.py +140 -0
- pal/metrics/components/exporter.py +28 -0
- pal/metrics/pipeline.py +49 -0
- pal/metrics/utils/__init__.py +2 -0
- pal/notes_manager/__init__.py +5 -0
- pal/notes_manager/components/__init__.py +0 -0
- pal/notes_manager/components/privacy.py +14 -0
- pal/notes_manager/components/recall.py +31 -0
- pal/notes_manager/components/tidy.py +28 -0
- pal/notes_manager/pipeline.py +138 -0
- pal/orchestrator/__init__.py +26 -0
- pal/orchestrator/components/__init__.py +26 -0
- pal/orchestrator/components/agent.py +105 -0
- pal/orchestrator/components/cgroup_manager.py +209 -0
- pal/orchestrator/components/context_interrupt.py +220 -0
- pal/orchestrator/components/limits.py +9 -0
- pal/orchestrator/components/scheduler.py +69 -0
- pal/orchestrator/components/syscall_scheduler.py +200 -0
- pal/orchestrator/pipeline.py +228 -0
- pal/orchestrator/utils/__init__.py +4 -0
- pal/orchestrator/utils/device.py +164 -0
- pal/orchestrator/utils/protocol.py +32 -0
- pal/os_kernel/__init__.py +5 -0
- pal/os_kernel/filesystem.py +80 -0
- pal/os_kernel/network.py +146 -0
- pal/os_kernel/pipeline.py +175 -0
- pal/os_kernel/sysinfo.py +36 -0
- pal/paths.py +59 -0
- pal/pipeline.py +177 -0
- pal/py.typed +0 -0
- pal/redact.py +19 -0
- pal/sdk/__init__.py +33 -0
- pal/sdk/adapters.py +43 -0
- pal/sdk/agent.py +99 -0
- pal/sdk/components/__init__.py +14 -0
- pal/sdk/components/autogen_adapter.py +65 -0
- pal/sdk/components/autogen_client.py +98 -0
- pal/sdk/components/framework_adapter_base.py +69 -0
- pal/sdk/components/metagpt_adapter.py +46 -0
- pal/sdk/components/open_interpreter_adapter.py +47 -0
- pal/sdk/components/tool_catalog.py +44 -0
- pal/sdk/os_api.py +84 -0
- pal/sdk/patterns.py +129 -0
- pal/sdk/pipeline.py +240 -0
- pal/sdk/queries.py +68 -0
- pal/sdk/savers.py +88 -0
- pal/sdk/utils/__init__.py +2 -0
- pal/storage_manager/__init__.py +17 -0
- pal/storage_manager/components/__init__.py +19 -0
- pal/storage_manager/components/base.py +50 -0
- pal/storage_manager/components/local_file_backend.py +84 -0
- pal/storage_manager/components/storage_queue.py +91 -0
- pal/storage_manager/components/vector_store_backend.py +122 -0
- pal/storage_manager/pipeline.py +153 -0
- pal/storage_manager/utils/__init__.py +4 -0
- pal/storage_manager/utils/memory_adapter.py +60 -0
- pal/storage_manager/utils/sdk.py +93 -0
- pal/studio/__init__.py +0 -0
- pal/studio/builder.py +166 -0
- pal/studio/catalog.py +90 -0
- pal/studio/checks.py +129 -0
- pal/studio/clients.py +6 -0
- pal/studio/editor.py +137 -0
- pal/studio/files.py +6 -0
- pal/studio/models.py +6 -0
- pal/studio/plan.py +149 -0
- pal/studio/planner.py +205 -0
- pal/studio/policy.py +69 -0
- pal/studio/routes.py +254 -0
- pal/studio/runs.py +210 -0
- pal/task_router/__init__.py +11 -0
- pal/task_router/components/__init__.py +0 -0
- pal/task_router/components/answerer.py +37 -0
- pal/task_router/components/base.py +34 -0
- pal/task_router/components/classifier.py +40 -0
- pal/task_router/components/gates.py +23 -0
- pal/task_router/pipeline.py +61 -0
- pal/task_router/utils/__init__.py +1 -0
- pal/terminal/__init__.py +1 -0
- pal/terminal/backend.py +141 -0
- pal/terminal/bench.py +173 -0
- pal/terminal/cli.py +826 -0
- pal/terminal/clients.py +170 -0
- pal/terminal/commands.py +401 -0
- pal/terminal/decide.py +242 -0
- pal/terminal/files.py +55 -0
- pal/terminal/flow_cli.py +439 -0
- pal/terminal/hardware.py +199 -0
- pal/terminal/history.py +88 -0
- pal/terminal/keys.py +83 -0
- pal/terminal/local_models.py +261 -0
- pal/terminal/logo.py +117 -0
- pal/terminal/models.py +99 -0
- pal/terminal/policy.py +54 -0
- pal/terminal/project.py +413 -0
- pal/terminal/review.py +65 -0
- pal/terminal/security.py +239 -0
- pal/terminal/session.py +503 -0
- pal/terminal/setup.py +354 -0
- pal/terminal/syntax.py +98 -0
- pal/terminal/theme.py +37 -0
- pal/terminal/ui.py +647 -0
- pal/terminal/voice.py +137 -0
- pal/terminal/workflow.py +624 -0
- pal/token_tracker/__init__.py +3 -0
- pal/token_tracker/pipeline.py +54 -0
- pal/tokens.py +21 -0
- pal/tool_catalog.py +107 -0
- pal/tool_manager/__init__.py +17 -0
- pal/tool_manager/builtin.py +82 -0
- pal/tool_manager/components/__init__.py +19 -0
- pal/tool_manager/components/base.py +53 -0
- pal/tool_manager/components/conflict_resolver.py +27 -0
- pal/tool_manager/components/param_validator.py +41 -0
- pal/tool_manager/components/tool_call_queue.py +111 -0
- pal/tool_manager/components/tool_registry.py +60 -0
- pal/tool_manager/pipeline.py +80 -0
- pal/tool_manager/utils/__init__.py +3 -0
- pal/web/__init__.py +3 -0
- pal/web/app.py +276 -0
- pal/web/static/editor.js +219 -0
- pal/web/static/fonts/LICENSE-Inter.txt +3 -0
- pal/web/static/fonts/inter-400.woff2 +0 -0
- pal/web/static/fonts/inter-500.woff2 +0 -0
- pal/web/static/fonts/inter-600.woff2 +0 -0
- pal/web/static/fonts/inter-700.woff2 +0 -0
- pal/web/static/inter.css +5 -0
- pal/web/static/pal-logo-256.png +0 -0
- pal/web/static/pal-logo-32.png +0 -0
- pal/web/static/pal-logo-64.png +0 -0
- pal/web/static/studio.css +306 -0
- pal/web/static/studio.js +533 -0
- pal/web/static/voice.js +76 -0
- pal/web/templates/index.html +368 -0
- pal/web/templates/studio.html +120 -0
- pal_code-0.1.0.dist-info/METADATA +379 -0
- pal_code-0.1.0.dist-info/RECORD +282 -0
- pal_code-0.1.0.dist-info/WHEEL +5 -0
- pal_code-0.1.0.dist-info/entry_points.txt +2 -0
- pal_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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)
|