deepparallel 0.2.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.
- deepparallel/__init__.py +3 -0
- deepparallel/agent.py +286 -0
- deepparallel/backend.py +302 -0
- deepparallel/branding.py +211 -0
- deepparallel/cli.py +569 -0
- deepparallel/config.py +158 -0
- deepparallel/fusion.py +225 -0
- deepparallel/licensing.py +108 -0
- deepparallel/registry.json +13 -0
- deepparallel/renderer.py +222 -0
- deepparallel/system_prompt.txt +4 -0
- deepparallel/tools/__init__.py +27 -0
- deepparallel/tools/codeast.py +171 -0
- deepparallel/tools/edit.py +29 -0
- deepparallel/tools/files.py +74 -0
- deepparallel/tools/registry.py +149 -0
- deepparallel/tools/sandbox.py +110 -0
- deepparallel/tools/search.py +38 -0
- deepparallel/tools/shell.py +38 -0
- deepparallel/tools/vision.py +54 -0
- deepparallel/tools/web.py +76 -0
- deepparallel-0.2.0.dist-info/METADATA +128 -0
- deepparallel-0.2.0.dist-info/RECORD +26 -0
- deepparallel-0.2.0.dist-info/WHEEL +5 -0
- deepparallel-0.2.0.dist-info/entry_points.txt +3 -0
- deepparallel-0.2.0.dist-info/top_level.txt +1 -0
deepparallel/config.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Runtime configuration and model-spec loading.
|
|
2
|
+
|
|
3
|
+
Pure resolution from os.environ and packaged data files. No network calls,
|
|
4
|
+
no printing, and no .env loading here (cli.py loads .env once at startup).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
_PKG_DIR = Path(__file__).parent
|
|
15
|
+
REGISTRY_PATH = _PKG_DIR / "registry.json"
|
|
16
|
+
SYSTEM_PROMPT_PATH = _PKG_DIR / "system_prompt.txt"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Settings:
|
|
21
|
+
backend: str
|
|
22
|
+
azure_endpoint: str | None
|
|
23
|
+
azure_api_key: str | None
|
|
24
|
+
deployment: str
|
|
25
|
+
api_version: str
|
|
26
|
+
foundry_base_url: str | None
|
|
27
|
+
foundry_api_key: str | None
|
|
28
|
+
foundry_model: str
|
|
29
|
+
temperature: float
|
|
30
|
+
max_tokens: int
|
|
31
|
+
show_thinking: bool
|
|
32
|
+
tools_enabled: bool
|
|
33
|
+
auto_approve: bool
|
|
34
|
+
max_steps: int
|
|
35
|
+
shell_timeout: int
|
|
36
|
+
fusion_mode: str
|
|
37
|
+
reasoner_deployment: str
|
|
38
|
+
parallel_deployments: tuple[str, ...]
|
|
39
|
+
judge_deployment: str
|
|
40
|
+
guardian_enabled: bool
|
|
41
|
+
guardian_deployment: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class ModelSpec:
|
|
46
|
+
name: str
|
|
47
|
+
label: str
|
|
48
|
+
deployment: str
|
|
49
|
+
base_family: str
|
|
50
|
+
context_window: int
|
|
51
|
+
license: str
|
|
52
|
+
license_url: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _bool_env(name: str, default: bool = False) -> bool:
|
|
56
|
+
raw = os.environ.get(name)
|
|
57
|
+
if raw is None:
|
|
58
|
+
return default
|
|
59
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _float_env(name: str, default: float) -> float:
|
|
63
|
+
raw = os.environ.get(name)
|
|
64
|
+
if raw is None:
|
|
65
|
+
return default
|
|
66
|
+
try:
|
|
67
|
+
return float(raw)
|
|
68
|
+
except ValueError:
|
|
69
|
+
return default
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _list_env(name: str, default: list[str]) -> tuple[str, ...]:
|
|
73
|
+
raw = os.environ.get(name)
|
|
74
|
+
if raw is None:
|
|
75
|
+
return tuple(default)
|
|
76
|
+
items = [s.strip() for s in raw.split(",") if s.strip()]
|
|
77
|
+
return tuple(items) if items else tuple(default)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _int_env(name: str, default: int) -> int:
|
|
81
|
+
raw = os.environ.get(name)
|
|
82
|
+
if raw is None:
|
|
83
|
+
return default
|
|
84
|
+
try:
|
|
85
|
+
return int(raw)
|
|
86
|
+
except ValueError:
|
|
87
|
+
return default
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def resolve_settings() -> Settings:
|
|
91
|
+
backend = os.environ.get("DEEPPARALLEL_BACKEND", "azure").strip().lower()
|
|
92
|
+
if backend not in {"azure", "foundry"}:
|
|
93
|
+
backend = "azure"
|
|
94
|
+
return Settings(
|
|
95
|
+
backend=backend,
|
|
96
|
+
azure_endpoint=os.environ.get("AZURE_CORE_ENDPOINT"),
|
|
97
|
+
azure_api_key=os.environ.get("AZURE_CORE_API_KEY"),
|
|
98
|
+
deployment=os.environ.get("DEEPPARALLEL_DEPLOYMENT", "DeepSeek-V4-Pro"),
|
|
99
|
+
api_version=os.environ.get("DEEPPARALLEL_API_VERSION", "2024-08-01-preview"),
|
|
100
|
+
foundry_base_url=os.environ.get("FOUNDRY_BASE_URL"),
|
|
101
|
+
foundry_api_key=os.environ.get("FOUNDRY_API_KEY"),
|
|
102
|
+
foundry_model=os.environ.get("DEEPPARALLEL_FOUNDRY_MODEL", "DeepSeek-V3-1"),
|
|
103
|
+
temperature=_float_env("DEEPPARALLEL_TEMPERATURE", 0.4),
|
|
104
|
+
max_tokens=_int_env("DEEPPARALLEL_MAX_TOKENS", 2048),
|
|
105
|
+
show_thinking=_bool_env("DEEPPARALLEL_THINK", False),
|
|
106
|
+
tools_enabled=_bool_env("DEEPPARALLEL_TOOLS", True),
|
|
107
|
+
auto_approve=_bool_env("DEEPPARALLEL_AUTO_APPROVE", False),
|
|
108
|
+
max_steps=_int_env("DEEPPARALLEL_MAX_STEPS", 12),
|
|
109
|
+
shell_timeout=_int_env("DEEPPARALLEL_SHELL_TIMEOUT", 120),
|
|
110
|
+
fusion_mode=(os.environ.get("DEEPPARALLEL_FUSION", "off").strip().lower() or "off"),
|
|
111
|
+
reasoner_deployment=os.environ.get("DEEPPARALLEL_REASONER_DEPLOYMENT", "DeepSeek-R1-0528"),
|
|
112
|
+
parallel_deployments=_list_env(
|
|
113
|
+
"DEEPPARALLEL_PARALLEL_MODELS",
|
|
114
|
+
["DeepSeek-V4-Pro", "DeepSeek-R1-0528", "DeepSeek-V4-Flash"],
|
|
115
|
+
),
|
|
116
|
+
judge_deployment=os.environ.get(
|
|
117
|
+
"DEEPPARALLEL_JUDGE_DEPLOYMENT",
|
|
118
|
+
os.environ.get("DEEPPARALLEL_DEPLOYMENT", "DeepSeek-V4-Pro"),
|
|
119
|
+
),
|
|
120
|
+
guardian_enabled=_bool_env("DEEPPARALLEL_GUARDIAN", True),
|
|
121
|
+
# A fast, independent reviewer by default (NOT the reasoner: a reasoning
|
|
122
|
+
# model spends a small token budget on hidden thinking and returns no
|
|
123
|
+
# verdict). Differs from the primary author model for a real 2nd opinion.
|
|
124
|
+
guardian_deployment=os.environ.get("DEEPPARALLEL_GUARDIAN_DEPLOYMENT", "DeepSeek-V4-Flash"),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def missing_required(settings: Settings) -> list[str]:
|
|
129
|
+
"""Names of required env vars unset for the active backend, in display order."""
|
|
130
|
+
missing: list[str] = []
|
|
131
|
+
if settings.backend == "azure":
|
|
132
|
+
if not settings.azure_endpoint:
|
|
133
|
+
missing.append("AZURE_CORE_ENDPOINT")
|
|
134
|
+
if not settings.azure_api_key:
|
|
135
|
+
missing.append("AZURE_CORE_API_KEY")
|
|
136
|
+
else:
|
|
137
|
+
if not settings.foundry_base_url:
|
|
138
|
+
missing.append("FOUNDRY_BASE_URL")
|
|
139
|
+
if not settings.foundry_api_key:
|
|
140
|
+
missing.append("FOUNDRY_API_KEY")
|
|
141
|
+
return missing
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def load_model_spec() -> ModelSpec:
|
|
145
|
+
entry = json.loads(REGISTRY_PATH.read_text())["models"][0]
|
|
146
|
+
return ModelSpec(
|
|
147
|
+
name=entry["name"],
|
|
148
|
+
label=entry["label"],
|
|
149
|
+
deployment=entry["deployment"],
|
|
150
|
+
base_family=entry["base_family"],
|
|
151
|
+
context_window=entry["context_window"],
|
|
152
|
+
license=entry["license"],
|
|
153
|
+
license_url=entry["license_url"],
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def load_system_prompt() -> str:
|
|
158
|
+
return SYSTEM_PROMPT_PATH.read_text().strip()
|
deepparallel/fusion.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Fusion backends: stack DeepParallel with other models for stronger output.
|
|
2
|
+
|
|
3
|
+
All strategies compose deepparallel's own `Backend` instances (secondary backends
|
|
4
|
+
pointed at other deployments), so there is no foundry dependency. Modes:
|
|
5
|
+
- ReasonAnswerBackend: a reasoner produces a thinking trace, injected into the
|
|
6
|
+
primary's answer call.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import difflib
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
13
|
+
from typing import Callable, Iterator
|
|
14
|
+
|
|
15
|
+
_REASON_NOTE = (
|
|
16
|
+
"\n\nA separate reasoning model analyzed this request. "
|
|
17
|
+
"Consider its reasoning, then give your own best answer:\n"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _augment(messages: list[dict], reasoning: str) -> list[dict]:
|
|
22
|
+
if not reasoning:
|
|
23
|
+
return messages
|
|
24
|
+
aug = [dict(m) for m in messages]
|
|
25
|
+
note = _REASON_NOTE + reasoning
|
|
26
|
+
if aug and aug[0].get("role") == "system":
|
|
27
|
+
aug[0] = {**aug[0], "content": (aug[0].get("content") or "") + note}
|
|
28
|
+
else:
|
|
29
|
+
aug.insert(0, {"role": "system", "content": note.strip()})
|
|
30
|
+
return aug
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ReasonAnswerBackend:
|
|
34
|
+
"""Run the reasoner first, inject its trace, then answer with the primary."""
|
|
35
|
+
|
|
36
|
+
label = "DeepParallel (fusion: reason)"
|
|
37
|
+
|
|
38
|
+
def __init__(self, primary, reasoner):
|
|
39
|
+
self._primary = primary
|
|
40
|
+
self._reasoner = reasoner
|
|
41
|
+
|
|
42
|
+
def check(self) -> tuple[bool, str]:
|
|
43
|
+
return self._primary.check()
|
|
44
|
+
|
|
45
|
+
def _reason(self, messages, temperature, max_tokens) -> str:
|
|
46
|
+
try:
|
|
47
|
+
msg = self._reasoner.chat(messages, [], temperature, max_tokens)
|
|
48
|
+
except Exception: # noqa: BLE001 - reasoning is best-effort; fall back
|
|
49
|
+
return ""
|
|
50
|
+
return (msg.get("reasoning_content") or msg.get("content") or "").strip()
|
|
51
|
+
|
|
52
|
+
def chat(self, messages, tools, temperature, max_tokens) -> dict:
|
|
53
|
+
reasoning = self._reason(messages, temperature, max_tokens)
|
|
54
|
+
return self._primary.chat(_augment(messages, reasoning), tools, temperature, max_tokens)
|
|
55
|
+
|
|
56
|
+
def stream_chat(self, messages, temperature, max_tokens) -> Iterator[tuple[str, str]]:
|
|
57
|
+
reasoning = self._reason(messages, temperature, max_tokens)
|
|
58
|
+
yield from self._primary.stream_chat(_augment(messages, reasoning), temperature, max_tokens)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_DOUBT = (
|
|
62
|
+
"not sure",
|
|
63
|
+
"uncertain",
|
|
64
|
+
"not certain",
|
|
65
|
+
"unclear",
|
|
66
|
+
"cannot determine",
|
|
67
|
+
"can't determine",
|
|
68
|
+
"don't have enough",
|
|
69
|
+
"do not have enough",
|
|
70
|
+
"more information",
|
|
71
|
+
"insufficient information",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class EscalationBackend:
|
|
76
|
+
"""Answer with the primary; only escalate to the reasoner when the answer
|
|
77
|
+
signals uncertainty. Cheapest fusion mode on average."""
|
|
78
|
+
|
|
79
|
+
label = "DeepParallel (fusion: escalate)"
|
|
80
|
+
|
|
81
|
+
def __init__(self, primary, reasoner):
|
|
82
|
+
self._primary = primary
|
|
83
|
+
self._reasoner = reasoner
|
|
84
|
+
|
|
85
|
+
def check(self) -> tuple[bool, str]:
|
|
86
|
+
return self._primary.check()
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def _uncertain(content: str) -> bool:
|
|
90
|
+
low = content.lower()
|
|
91
|
+
return any(phrase in low for phrase in _DOUBT)
|
|
92
|
+
|
|
93
|
+
def chat(self, messages, tools, temperature, max_tokens) -> dict:
|
|
94
|
+
msg = self._primary.chat(messages, tools, temperature, max_tokens)
|
|
95
|
+
if msg.get("tool_calls"):
|
|
96
|
+
return msg # mid-loop tool turn; never escalate
|
|
97
|
+
if self._uncertain(msg.get("content") or ""):
|
|
98
|
+
return ReasonAnswerBackend(self._primary, self._reasoner).chat(
|
|
99
|
+
messages, tools, temperature, max_tokens
|
|
100
|
+
)
|
|
101
|
+
return msg
|
|
102
|
+
|
|
103
|
+
def stream_chat(self, messages, temperature, max_tokens) -> Iterator[tuple[str, str]]:
|
|
104
|
+
yield from self._primary.stream_chat(messages, temperature, max_tokens)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _last_user(messages: list[dict]) -> str:
|
|
108
|
+
for m in reversed(messages):
|
|
109
|
+
if m.get("role") == "user":
|
|
110
|
+
return m.get("content") or ""
|
|
111
|
+
return ""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _chain_answer(backend, messages, temperature, max_tokens) -> str:
|
|
115
|
+
try:
|
|
116
|
+
msg = backend.chat(messages, [], temperature, max_tokens)
|
|
117
|
+
except Exception as e: # noqa: BLE001 - one chain failing must not sink the run
|
|
118
|
+
return f"[error: {type(e).__name__}: {e}]"
|
|
119
|
+
return (msg.get("content") or msg.get("reasoning_content") or "").strip()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _fallback(results: list[dict]) -> str:
|
|
123
|
+
valid = [r["content"] for r in results if not r["content"].startswith("[error")]
|
|
124
|
+
return max(valid, key=len) if valid else "All reasoning chains failed."
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _judge(messages, results, judge, temperature, max_tokens) -> str:
|
|
128
|
+
question = _last_user(messages)
|
|
129
|
+
blocks = "\n\n".join(
|
|
130
|
+
f"Candidate {i + 1} ({r['name']}):\n{r['content']}" for i, r in enumerate(results)
|
|
131
|
+
)
|
|
132
|
+
judge_messages = [
|
|
133
|
+
{
|
|
134
|
+
"role": "system",
|
|
135
|
+
"content": (
|
|
136
|
+
"You are a careful judge. Synthesize the single best, correct answer "
|
|
137
|
+
"from the candidate answers below. Resolve disagreements with sound "
|
|
138
|
+
"reasoning. Output only the final answer."
|
|
139
|
+
),
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
"role": "user",
|
|
143
|
+
"content": f"Question:\n{question}\n\nCandidate answers:\n{blocks}\n\nFinal answer:",
|
|
144
|
+
},
|
|
145
|
+
]
|
|
146
|
+
try:
|
|
147
|
+
msg = judge.chat(judge_messages, [], temperature, max_tokens)
|
|
148
|
+
except Exception: # noqa: BLE001 - degrade gracefully if the judge fails
|
|
149
|
+
return _fallback(results)
|
|
150
|
+
return (msg.get("content") or "").strip() or _fallback(results)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def deep_query(
|
|
154
|
+
messages: list[dict],
|
|
155
|
+
chains: list[tuple[str, object]],
|
|
156
|
+
judge,
|
|
157
|
+
temperature: float,
|
|
158
|
+
max_tokens: int,
|
|
159
|
+
on_event: Callable[[str, str], None] | None = None,
|
|
160
|
+
) -> dict:
|
|
161
|
+
"""Fan a prompt out to several model chains in parallel, then synthesize a
|
|
162
|
+
final answer with a judge. Heavy and slow; for one-shot --deep runs."""
|
|
163
|
+
order = {name: i for i, (name, _) in enumerate(chains)}
|
|
164
|
+
results: list[dict] = []
|
|
165
|
+
with ThreadPoolExecutor(max_workers=min(8, max(1, len(chains)))) as pool:
|
|
166
|
+
futures = {
|
|
167
|
+
pool.submit(_chain_answer, backend, messages, temperature, max_tokens): name
|
|
168
|
+
for name, backend in chains
|
|
169
|
+
}
|
|
170
|
+
for future in as_completed(futures):
|
|
171
|
+
name = futures[future]
|
|
172
|
+
content = future.result()
|
|
173
|
+
results.append({"name": name, "content": content})
|
|
174
|
+
if on_event:
|
|
175
|
+
on_event(name, content)
|
|
176
|
+
results.sort(key=lambda r: order.get(r["name"], 0))
|
|
177
|
+
answer = _judge(messages, results, judge, temperature, max_tokens)
|
|
178
|
+
return {"answer": answer, "chains": results, "agreement": _agreement(results)}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _agreement(results: list[dict]) -> dict:
|
|
182
|
+
"""Cross-model agreement (NOT correctness) from pairwise answer similarity.
|
|
183
|
+
|
|
184
|
+
Zero extra model cost; uses difflib over the candidate answers.
|
|
185
|
+
"""
|
|
186
|
+
answers = [r["content"] for r in results if not r["content"].startswith("[error")]
|
|
187
|
+
if len(answers) < 2:
|
|
188
|
+
return {"level": "n/a", "score": 1.0 if answers else 0.0}
|
|
189
|
+
ratios = [
|
|
190
|
+
difflib.SequenceMatcher(None, answers[i], answers[j]).ratio()
|
|
191
|
+
for i in range(len(answers))
|
|
192
|
+
for j in range(i + 1, len(answers))
|
|
193
|
+
]
|
|
194
|
+
score = round(sum(ratios) / len(ratios), 2)
|
|
195
|
+
level = "high" if score >= 0.6 else "medium" if score >= 0.3 else "low"
|
|
196
|
+
return {"level": level, "score": score}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def dual_query(
|
|
200
|
+
messages: list[dict],
|
|
201
|
+
left: tuple[str, object],
|
|
202
|
+
right: tuple[str, object],
|
|
203
|
+
temperature: float,
|
|
204
|
+
max_tokens: int,
|
|
205
|
+
judge=None,
|
|
206
|
+
) -> dict:
|
|
207
|
+
"""Run two models on the same prompt (in parallel) for side-by-side
|
|
208
|
+
comparison, optionally synthesizing a merged answer with a judge."""
|
|
209
|
+
pairs = [left, right]
|
|
210
|
+
answers: dict[str, str] = {}
|
|
211
|
+
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
212
|
+
futures = {
|
|
213
|
+
pool.submit(_chain_answer, backend, messages, temperature, max_tokens): name
|
|
214
|
+
for name, backend in pairs
|
|
215
|
+
}
|
|
216
|
+
for future in as_completed(futures):
|
|
217
|
+
answers[futures[future]] = future.result()
|
|
218
|
+
results = [
|
|
219
|
+
{"name": left[0], "content": answers[left[0]]},
|
|
220
|
+
{"name": right[0], "content": answers[right[0]]},
|
|
221
|
+
]
|
|
222
|
+
synthesis = None
|
|
223
|
+
if judge is not None:
|
|
224
|
+
synthesis = _judge(messages, results, judge, temperature, max_tokens)
|
|
225
|
+
return {"left": results[0], "right": results[1], "synthesis": synthesis}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""License tiers and offline verification for DeepParallel.
|
|
2
|
+
|
|
3
|
+
A license key is a signed token `body.signature` where body is
|
|
4
|
+
url-safe-base64(JSON payload) and signature is an Ed25519 signature of the body.
|
|
5
|
+
The CLI embeds the issuer's PUBLIC key and verifies offline, so a key cannot be
|
|
6
|
+
forged without the private issuance key (held server-side, used at Stripe
|
|
7
|
+
fulfillment time).
|
|
8
|
+
|
|
9
|
+
Open-core split:
|
|
10
|
+
- FREE: the full single-model agent and all tools (bring your own key).
|
|
11
|
+
- PRO/TEAM: the fusion layer - Guardian edit-review, fusion modes, and the
|
|
12
|
+
--deep multi-model council - which is the paid value.
|
|
13
|
+
|
|
14
|
+
Enforcement here is the client-side gate; hosted inference is additionally
|
|
15
|
+
gated server-side at the gateway.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import base64
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import time
|
|
24
|
+
from enum import IntEnum
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
# Issuer public key (Ed25519, raw, base64). The matching private key is the
|
|
28
|
+
# issuance secret and is never shipped.
|
|
29
|
+
_EMBEDDED_PUBKEY = "I0YYNFwGGUZGD6h15leBEPBQ197Snik+njzxkVf/Owg="
|
|
30
|
+
|
|
31
|
+
# Paid features -> minimum tier required.
|
|
32
|
+
_FEATURE_TIER: dict[str, "Tier"] = {}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Tier(IntEnum):
|
|
36
|
+
FREE = 0
|
|
37
|
+
PRO = 1
|
|
38
|
+
TEAM = 2
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def label(self) -> str:
|
|
42
|
+
return {0: "Free", 1: "Pro", 2: "Team"}[int(self)]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _b64url_decode(s: str) -> bytes:
|
|
46
|
+
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def verify_token(token: str, pubkey_b64: str | None = None) -> dict | None:
|
|
50
|
+
"""Return the payload dict if the token is validly signed and unexpired."""
|
|
51
|
+
if not token or "." not in token:
|
|
52
|
+
return None
|
|
53
|
+
try:
|
|
54
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
55
|
+
except ImportError:
|
|
56
|
+
return None
|
|
57
|
+
body, _, sig = token.partition(".")
|
|
58
|
+
try:
|
|
59
|
+
pub = Ed25519PublicKey.from_public_bytes(_b64url_decode(pubkey_b64 or _EMBEDDED_PUBKEY))
|
|
60
|
+
pub.verify(_b64url_decode(sig), body.encode())
|
|
61
|
+
except Exception: # noqa: BLE001 - any failure (bad sig/key/encoding) = invalid
|
|
62
|
+
return None
|
|
63
|
+
try:
|
|
64
|
+
payload = json.loads(_b64url_decode(body))
|
|
65
|
+
except (ValueError, json.JSONDecodeError):
|
|
66
|
+
return None
|
|
67
|
+
exp = payload.get("exp", 0)
|
|
68
|
+
if exp and time.time() > exp:
|
|
69
|
+
return None
|
|
70
|
+
return payload
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _license_file_token() -> str | None:
|
|
74
|
+
path = Path(
|
|
75
|
+
os.environ.get("DEEPPARALLEL_LICENSE_FILE", "~/.config/deepparallel/license")
|
|
76
|
+
).expanduser()
|
|
77
|
+
try:
|
|
78
|
+
return path.read_text().strip() or None
|
|
79
|
+
except OSError:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def resolve_tier() -> Tier:
|
|
84
|
+
"""Resolve the active tier from the license env var or file. FREE on absence/failure."""
|
|
85
|
+
token = os.environ.get("DEEPPARALLEL_LICENSE") or _license_file_token()
|
|
86
|
+
if not token:
|
|
87
|
+
return Tier.FREE
|
|
88
|
+
payload = verify_token(token)
|
|
89
|
+
if not payload:
|
|
90
|
+
return Tier.FREE
|
|
91
|
+
name = str(payload.get("tier", "")).upper()
|
|
92
|
+
return getattr(Tier, name, Tier.FREE) if name in Tier.__members__ else Tier.FREE
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def tier_allows(have: Tier, need: Tier) -> bool:
|
|
96
|
+
return int(have) >= int(need)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def check_feature(feature: str, have: Tier | None = None) -> tuple[bool, str]:
|
|
100
|
+
"""Return (allowed, message). Paid features need PRO+; message nudges upgrade."""
|
|
101
|
+
have = resolve_tier() if have is None else have
|
|
102
|
+
need = _FEATURE_TIER.get(feature, Tier.PRO)
|
|
103
|
+
if tier_allows(have, need):
|
|
104
|
+
return True, ""
|
|
105
|
+
return False, (
|
|
106
|
+
f"{feature} requires DeepParallel {need.label} (you are on {have.label}). "
|
|
107
|
+
f"Upgrade at https://deepparallel.dev/pricing"
|
|
108
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"models": [
|
|
3
|
+
{
|
|
4
|
+
"name": "crowelm-deepparallel",
|
|
5
|
+
"label": "DeepParallel",
|
|
6
|
+
"deployment": "DeepSeek-V4-Pro",
|
|
7
|
+
"base_family": "CroweLM",
|
|
8
|
+
"context_window": 131072,
|
|
9
|
+
"license": "Crowe Logic Service Terms",
|
|
10
|
+
"license_url": "https://crowelogic.com"
|
|
11
|
+
}
|
|
12
|
+
]
|
|
13
|
+
}
|