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,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)
|
pal/llm_wrapper/modes.py
ADDED
|
@@ -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,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
|