pari-cli 0.1.4__tar.gz → 0.1.6__tar.gz
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.
- {pari_cli-0.1.4 → pari_cli-0.1.6}/PKG-INFO +3 -3
- {pari_cli-0.1.4 → pari_cli-0.1.6}/README.md +1 -1
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/__init__.py +1 -1
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/agent/loop.py +2 -1
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/agent/runtime.py +53 -10
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/agent/subagent.py +1 -2
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/auth/account.py +22 -24
- pari_cli-0.1.6/pari/auth/credential.py +128 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/auth/interactive.py +51 -21
- pari_cli-0.1.6/pari/auth/login.py +149 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/auth/menu.py +17 -12
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/auth/providers.py +15 -0
- pari_cli-0.1.6/pari/auth/secret_field.py +50 -0
- pari_cli-0.1.6/pari/auth/store.py +62 -0
- pari_cli-0.1.6/pari/auth/verify.py +55 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/cli.py +14 -14
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/eval/judge.py +2 -1
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/eval/logging.py +2 -1
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/eval/metrics.py +2 -1
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/eval/runner.py +2 -1
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/fs/__init__.py +2 -1
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/fs/atomic.py +21 -2
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/intelligence/repomap.py +1 -14
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/providers/__init__.py +16 -6
- pari_cli-0.1.6/pari/providers/anthropic.py +83 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/providers/base.py +2 -30
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/providers/deepseek.py +1 -0
- pari_cli-0.1.6/pari/providers/exceptions/__init__.py +26 -0
- pari_cli-0.1.6/pari/providers/exceptions/classifier.py +60 -0
- pari_cli-0.1.6/pari/providers/exceptions/mapping.py +90 -0
- pari_cli-0.1.6/pari/providers/exceptions/types.py +40 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/providers/grok.py +1 -0
- pari_cli-0.1.6/pari/providers/http.py +99 -0
- pari_cli-0.1.6/pari/providers/message.py +31 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/providers/mock.py +15 -13
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/providers/ollama.py +7 -2
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/providers/openai.py +1 -0
- pari_cli-0.1.6/pari/providers/openai_compat.py +79 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/providers/openrouter.py +1 -0
- pari_cli-0.1.6/pari/providers/response.py +33 -0
- pari_cli-0.1.6/pari/providers/schemas/__init__.py +42 -0
- pari_cli-0.1.6/pari/providers/schemas/anthropic.py +207 -0
- pari_cli-0.1.6/pari/providers/schemas/openai.py +259 -0
- pari_cli-0.1.6/pari/providers/schemas/types.py +17 -0
- pari_cli-0.1.6/pari/providers/stream.py +214 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/routing/router.py +10 -2
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/sandbox/base.py +77 -16
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/sandbox/bwrap.py +3 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/sandbox/e2b.py +4 -3
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/sandbox/process.py +3 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/sandbox/seatbelt.py +4 -4
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/tools/bash.py +4 -1
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/tools/fs.py +81 -17
- pari_cli-0.1.6/pari/tui/activity.py +230 -0
- pari_cli-0.1.6/pari/tui/app.py +494 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/tui/completer.py +27 -0
- pari_cli-0.1.6/pari/tui/errors.py +22 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/tui/keys.py +34 -3
- pari_cli-0.1.6/pari/tui/transcript.py +44 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari_cli.egg-info/PKG-INFO +3 -3
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari_cli.egg-info/SOURCES.txt +18 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pyproject.toml +2 -2
- pari_cli-0.1.4/pari/auth/login.py +0 -207
- pari_cli-0.1.4/pari/auth/store.py +0 -43
- pari_cli-0.1.4/pari/providers/anthropic.py +0 -167
- pari_cli-0.1.4/pari/providers/openai_compat.py +0 -157
- pari_cli-0.1.4/pari/tui/app.py +0 -238
- {pari_cli-0.1.4 → pari_cli-0.1.6}/LICENSE +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/__main__.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/agent/__init__.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/agent/guards.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/agent/lint.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/agent/reflection.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/auth/__init__.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/config.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/context/__init__.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/context/cache.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/context/chunks.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/context/compaction.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/context/epoch.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/context/warmer.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/edit/__init__.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/edit/cascade.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/edit/diff.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/edit/dmp.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/edit/exact.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/edit/git_osr.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/edit/indenter.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/eval/__init__.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/fs/path.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/fs/queue.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/intelligence/__init__.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/intelligence/ast.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/intelligence/cache.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/intelligence/ppr.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/intelligence/search.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/output/__init__.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/output/parser.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/output/spooler.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/output/truncation.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/routing/__init__.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/routing/anti_verbosity.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/routing/classifier.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/sandbox/__init__.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/sandbox/dispatcher.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/session/__init__.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/session/checkpoint.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/session/dag.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/session/events.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/session/logger.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/session/undo.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/tools/__init__.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/tools/base.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/tools/output_reader.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/tools/registry.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/tools/search.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/tui/__init__.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/tui/commands.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/tui/diff_view.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/tui/prompt.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/tui/slash.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/tui/status.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari/tui/style.py +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari_cli.egg-info/dependency_links.txt +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari_cli.egg-info/entry_points.txt +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari_cli.egg-info/requires.txt +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/pari_cli.egg-info/top_level.txt +0 -0
- {pari_cli-0.1.4 → pari_cli-0.1.6}/setup.cfg +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pari-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: A resilient, sandbox-first terminal coding agent engineered for high pass rates and low token burn.
|
|
5
5
|
Author: PARI Contributors
|
|
6
6
|
License: MIT
|
|
7
7
|
Project-URL: Homepage, https://pari-cli.in
|
|
8
|
-
Project-URL: Documentation, https://pari-cli.in
|
|
8
|
+
Project-URL: Documentation, https://pari-cli.in/docs
|
|
9
9
|
Project-URL: Repository, https://github.com/MdTanwer/pari
|
|
10
10
|
Keywords: ai,coding-agent,terminal,llm,developer-tools
|
|
11
11
|
Classifier: Development Status :: 3 - Alpha
|
|
@@ -67,7 +67,7 @@ The exact same frontier reasoning model (Claude 3.7 Sonnet, GPT-4o) demonstrates
|
|
|
67
67
|
|
|
68
68
|
## 🚀 Installation & Quick Start
|
|
69
69
|
|
|
70
|
-
Official Website & Documentation: **[https://pari-cli.in](https://pari-cli.in)**
|
|
70
|
+
Official Website & Documentation: **[https://pari-cli.in/docs](https://pari-cli.in/docs)**
|
|
71
71
|
|
|
72
72
|
Choose your platform's installation command:
|
|
73
73
|
|
|
@@ -24,7 +24,7 @@ The exact same frontier reasoning model (Claude 3.7 Sonnet, GPT-4o) demonstrates
|
|
|
24
24
|
|
|
25
25
|
## 🚀 Installation & Quick Start
|
|
26
26
|
|
|
27
|
-
Official Website & Documentation: **[https://pari-cli.in](https://pari-cli.in)**
|
|
27
|
+
Official Website & Documentation: **[https://pari-cli.in/docs](https://pari-cli.in/docs)**
|
|
28
28
|
|
|
29
29
|
Choose your platform's installation command:
|
|
30
30
|
|
|
@@ -28,7 +28,8 @@ from pari.context.chunks import ContextChunk, ContextTier, assemble_context
|
|
|
28
28
|
from pari.context.compaction import compact_context
|
|
29
29
|
from pari.edit.diff import generate_unified_diff
|
|
30
30
|
from pari.fs.path import resolve_safe_path
|
|
31
|
-
from pari.providers.
|
|
31
|
+
from pari.providers.message import ToolCall
|
|
32
|
+
from pari.providers.response import ModelResponse
|
|
32
33
|
from pari.session.logger import SessionLogger
|
|
33
34
|
from pari.session.undo import GuardedUndoManager
|
|
34
35
|
|
|
@@ -24,7 +24,10 @@ from pari.agent.loop import AgentExecutionLoop, TurnResult
|
|
|
24
24
|
from pari.agent.reflection import ReflectionManager
|
|
25
25
|
from pari.intelligence.repomap import RepoMapGenerator
|
|
26
26
|
from pari.providers import get_provider
|
|
27
|
-
from pari.providers.base import LLMProvider
|
|
27
|
+
from pari.providers.base import LLMProvider
|
|
28
|
+
from pari.providers.exceptions import ProviderError
|
|
29
|
+
from pari.providers.message import ProviderMessage, ToolCall
|
|
30
|
+
from pari.providers.response import ModelResponse
|
|
28
31
|
from pari.routing.router import DualModelRouter, router_for
|
|
29
32
|
from pari.session.events import SessionEventType
|
|
30
33
|
from pari.session.logger import SessionLogger
|
|
@@ -133,15 +136,33 @@ class AgentRuntime:
|
|
|
133
136
|
self.pinned_files.append(clean_rel)
|
|
134
137
|
return True
|
|
135
138
|
|
|
136
|
-
async def continue_turn(
|
|
139
|
+
async def continue_turn(
|
|
140
|
+
self,
|
|
141
|
+
on_tool_call: Callable[[str, dict[str, Any]], None] | None = None,
|
|
142
|
+
on_diff: Callable[[str, str], None] | None = None,
|
|
143
|
+
on_tool_result: Callable[[ToolCall, str], None] | None = None,
|
|
144
|
+
on_step_text: Callable[[str], None] | None = None,
|
|
145
|
+
on_delta: Callable[[str], None] | None = None,
|
|
146
|
+
) -> str:
|
|
137
147
|
"""Run another block of turns after the user chooses Continue."""
|
|
138
|
-
return await self.execute_turn(
|
|
148
|
+
return await self.execute_turn(
|
|
149
|
+
"Continue.",
|
|
150
|
+
on_tool_call=on_tool_call,
|
|
151
|
+
on_diff=on_diff,
|
|
152
|
+
on_tool_result=on_tool_result,
|
|
153
|
+
on_step_text=on_step_text,
|
|
154
|
+
on_delta=on_delta,
|
|
155
|
+
max_turns=TURN_EXTENSION,
|
|
156
|
+
)
|
|
139
157
|
|
|
140
158
|
async def execute_turn(
|
|
141
159
|
self,
|
|
142
160
|
prompt: str,
|
|
143
161
|
on_tool_call: Callable[[str, dict[str, Any]], None] | None = None,
|
|
144
162
|
on_diff: Callable[[str, str], None] | None = None,
|
|
163
|
+
on_tool_result: Callable[[ToolCall, str], None] | None = None,
|
|
164
|
+
on_step_text: Callable[[str], None] | None = None,
|
|
165
|
+
on_delta: Callable[[str], None] | None = None,
|
|
145
166
|
*,
|
|
146
167
|
max_turns: int | None = None,
|
|
147
168
|
) -> str:
|
|
@@ -168,6 +189,7 @@ class AgentRuntime:
|
|
|
168
189
|
schemas,
|
|
169
190
|
prompt=prompt,
|
|
170
191
|
active_files=self.pinned_files,
|
|
192
|
+
on_delta=on_delta,
|
|
171
193
|
)
|
|
172
194
|
# Drop tool calls that were cut off so the next step cannot rerun them.
|
|
173
195
|
calls = [] if is_length_stop(resp.stop_reason) else list(resp.tool_calls)
|
|
@@ -175,6 +197,16 @@ class AgentRuntime:
|
|
|
175
197
|
if not call.id:
|
|
176
198
|
call.id = f"call_{index}"
|
|
177
199
|
self._tool_followup = bool(calls)
|
|
200
|
+
# Show the note that introduces this tool batch. The final answer
|
|
201
|
+
# has no tool calls and is printed once, after the turn returns.
|
|
202
|
+
# Deltas already painted this text. Printing it again would repeat it.
|
|
203
|
+
if (
|
|
204
|
+
calls
|
|
205
|
+
and resp.text.strip()
|
|
206
|
+
and on_step_text is not None
|
|
207
|
+
and on_delta is None
|
|
208
|
+
):
|
|
209
|
+
on_step_text(resp.text)
|
|
178
210
|
self.messages.append(
|
|
179
211
|
ProviderMessage(
|
|
180
212
|
role="assistant",
|
|
@@ -188,6 +220,8 @@ class AgentRuntime:
|
|
|
188
220
|
self.messages.append(
|
|
189
221
|
ProviderMessage(role="tool", content=text, tool_call_id=call.id)
|
|
190
222
|
)
|
|
223
|
+
if on_tool_result is not None:
|
|
224
|
+
on_tool_result(call, text)
|
|
191
225
|
|
|
192
226
|
dag = self.workspace / ".pari" / "sessions" / f"{self.session_id}.jsonl"
|
|
193
227
|
|
|
@@ -206,13 +240,22 @@ class AgentRuntime:
|
|
|
206
240
|
on_diff=on_diff,
|
|
207
241
|
on_warning=remember_warning,
|
|
208
242
|
)
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
243
|
+
# A failed model call must not leave the prompt sitting on the transcript.
|
|
244
|
+
# The next message would otherwise resend a turn the provider never answered.
|
|
245
|
+
sent = len(self.messages)
|
|
246
|
+
try:
|
|
247
|
+
result = await loop.run_turn(
|
|
248
|
+
prompt,
|
|
249
|
+
step,
|
|
250
|
+
lambda response: is_length_stop(response.stop_reason),
|
|
251
|
+
active_files=self._pinned_contents(),
|
|
252
|
+
repo_map=self._repo_map(),
|
|
253
|
+
)
|
|
254
|
+
except ProviderError:
|
|
255
|
+
self._tool_followup = False
|
|
256
|
+
if len(self.messages) == sent + 1 and self.messages[-1].role == "user":
|
|
257
|
+
self.messages.pop()
|
|
258
|
+
raise
|
|
216
259
|
turn_record = {
|
|
217
260
|
"event": "turn_complete",
|
|
218
261
|
"session_id": self.session_id,
|
|
@@ -35,9 +35,8 @@ class FiberSubagentPool:
|
|
|
35
35
|
self.max_concurrency = max_concurrency
|
|
36
36
|
self._slots = asyncio.Semaphore(max_concurrency)
|
|
37
37
|
|
|
38
|
-
async def run_task(self,
|
|
38
|
+
async def run_task(self, _task_id: str, coro: Coroutine) -> Any:
|
|
39
39
|
"""Run one research task, waiting if the pool is already full."""
|
|
40
|
-
_ = task_id
|
|
41
40
|
async with self._slots:
|
|
42
41
|
return await coro
|
|
43
42
|
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
"""Account sign-in for providers that have a browser or device login.
|
|
2
2
|
|
|
3
3
|
OpenAI, Anthropic, and OpenRouter open a browser and accept the redirect URL
|
|
4
|
-
if the local callback does not finish. Grok uses a device code.
|
|
5
|
-
|
|
6
|
-
provider.
|
|
4
|
+
if the local callback does not finish. Grok uses a device code. A provider
|
|
5
|
+
with ``account=False`` has no account login. Radius is not a provider.
|
|
7
6
|
"""
|
|
8
7
|
|
|
9
8
|
import base64
|
|
10
9
|
import errno
|
|
11
10
|
import hashlib
|
|
11
|
+
import html
|
|
12
12
|
import json
|
|
13
13
|
import re
|
|
14
14
|
import secrets
|
|
@@ -22,7 +22,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
22
22
|
from typing import Protocol
|
|
23
23
|
|
|
24
24
|
from pari.auth.login import sign_in
|
|
25
|
-
from pari.auth.providers import ProviderInfo, get_info
|
|
25
|
+
from pari.auth.providers import ProviderInfo, get_info, no_account_message
|
|
26
26
|
from pari.auth.store import AuthStore
|
|
27
27
|
|
|
28
28
|
|
|
@@ -35,6 +35,8 @@ class AccountUI(Protocol):
|
|
|
35
35
|
|
|
36
36
|
def read_line(self, prompt: str) -> str | None: ...
|
|
37
37
|
|
|
38
|
+
def read_secret(self, prompt: str) -> str | None: ...
|
|
39
|
+
|
|
38
40
|
def show_auth_url(
|
|
39
41
|
self,
|
|
40
42
|
url: str,
|
|
@@ -47,6 +49,8 @@ class AccountUI(Protocol):
|
|
|
47
49
|
|
|
48
50
|
def sleep(self, seconds: float) -> bool: ...
|
|
49
51
|
|
|
52
|
+
def progress(self, message: str) -> None: ...
|
|
53
|
+
|
|
50
54
|
def show_error(self, message: str) -> None: ...
|
|
51
55
|
|
|
52
56
|
|
|
@@ -68,6 +72,7 @@ _ANTHROPIC_SCOPES = (
|
|
|
68
72
|
"org:create_api_key user:profile user:inference "
|
|
69
73
|
"user:sessions:claude_code user:mcp_servers user:file_upload"
|
|
70
74
|
)
|
|
75
|
+
_CALLBACK_PATH = "/callback"
|
|
71
76
|
_ANTHROPIC_COPY_REDIRECT = "https://platform.claude.com/oauth/code/callback"
|
|
72
77
|
|
|
73
78
|
_OPENROUTER_AUTHORIZE = "https://openrouter.ai/auth"
|
|
@@ -89,11 +94,8 @@ _BROWSER = (
|
|
|
89
94
|
def sign_in_account(provider_id: str, ui: AccountUI, store: AuthStore) -> str:
|
|
90
95
|
"""Run that provider's account login and store the credential."""
|
|
91
96
|
info = get_info(provider_id)
|
|
92
|
-
if info.
|
|
93
|
-
raise ValueError(
|
|
94
|
-
"DeepSeek has no account sign-in. "
|
|
95
|
-
"Use pari-cli login deepseek --api-key <key>."
|
|
96
|
-
)
|
|
97
|
+
if not info.account:
|
|
98
|
+
raise ValueError(no_account_message(info))
|
|
97
99
|
if info.id == "openai":
|
|
98
100
|
cred = _login_openai(ui, store)
|
|
99
101
|
elif info.id == "anthropic":
|
|
@@ -176,9 +178,7 @@ def _login_openai(ui: AccountUI, store: AuthStore) -> dict:
|
|
|
176
178
|
code, client_id = _code_from(server, pasted, _OPENAI_REDIRECT, state)
|
|
177
179
|
if not client_id:
|
|
178
180
|
raise ValueError("Paste the full callback URL from the browser.")
|
|
179
|
-
|
|
180
|
-
if ui_progress:
|
|
181
|
-
ui_progress("Exchanging authorization code for tokens...")
|
|
181
|
+
ui.progress("Exchanging authorization code for tokens...")
|
|
182
182
|
return _exchange_openai(code, verifier, client_id)
|
|
183
183
|
finally:
|
|
184
184
|
server.close()
|
|
@@ -196,7 +196,7 @@ def _login_anthropic(ui: AccountUI) -> dict:
|
|
|
196
196
|
return _anthropic_copy_code(ui, verifier, challenge)
|
|
197
197
|
server = _anthropic_server(verifier)
|
|
198
198
|
redirect = (
|
|
199
|
-
server.redirect_uri if server else
|
|
199
|
+
server.redirect_uri if server else f"http://localhost:53692{_CALLBACK_PATH}"
|
|
200
200
|
)
|
|
201
201
|
try:
|
|
202
202
|
url = _anthropic_url(challenge, verifier, redirect)
|
|
@@ -512,20 +512,18 @@ def _anthropic_url(challenge: str, state: str, redirect: str) -> str:
|
|
|
512
512
|
|
|
513
513
|
def _anthropic_server(state: str) -> "CallbackServer | None":
|
|
514
514
|
try:
|
|
515
|
-
return _listen(
|
|
515
|
+
return _listen(
|
|
516
|
+
"127.0.0.1", 53692, _CALLBACK_PATH, "localhost", state, "Anthropic"
|
|
517
|
+
)
|
|
516
518
|
except OSError as exc:
|
|
517
519
|
if exc.errno != errno.EADDRINUSE:
|
|
518
520
|
return None
|
|
519
521
|
try:
|
|
520
|
-
return _listen("127.0.0.1", 0,
|
|
522
|
+
return _listen("127.0.0.1", 0, _CALLBACK_PATH, "localhost", state, "Anthropic")
|
|
521
523
|
except OSError:
|
|
522
524
|
return None
|
|
523
525
|
|
|
524
526
|
|
|
525
|
-
def _callback_path() -> str:
|
|
526
|
-
return "/callback"
|
|
527
|
-
|
|
528
|
-
|
|
529
527
|
class CallbackServer:
|
|
530
528
|
"""Loopback redirect. OpenAI's port stays 1455 because the redirect is fixed."""
|
|
531
529
|
|
|
@@ -620,7 +618,8 @@ def _listen(
|
|
|
620
618
|
|
|
621
619
|
def _send(request: BaseHTTPRequestHandler, status: int, message: str) -> None:
|
|
622
620
|
body = (
|
|
623
|
-
|
|
621
|
+
"<!doctype html><meta charset=utf-8><title>PARI</title>"
|
|
622
|
+
f"<p>{html.escape(message)}</p>"
|
|
624
623
|
).encode()
|
|
625
624
|
request.send_response(status)
|
|
626
625
|
request.send_header("Content-Type", "text/html; charset=utf-8")
|
|
@@ -654,6 +653,9 @@ def _request(url: str, data: bytes, content_type: str) -> tuple[int, dict]:
|
|
|
654
653
|
return response.status, _json_body(response.read())
|
|
655
654
|
except urllib.error.HTTPError as exc:
|
|
656
655
|
return exc.code, _json_body(exc.read())
|
|
656
|
+
except (urllib.error.URLError, TimeoutError):
|
|
657
|
+
host = urllib.parse.urlparse(url).netloc
|
|
658
|
+
raise RuntimeError(f"Could not reach {host}. Check your connection.") from None
|
|
657
659
|
|
|
658
660
|
|
|
659
661
|
def _json_body(raw: bytes) -> dict:
|
|
@@ -725,7 +727,3 @@ def _http_error(action: str, status: int, body: dict) -> str:
|
|
|
725
727
|
detail = detail.get("message")
|
|
726
728
|
suffix = f": {detail}" if isinstance(detail, str) and detail else ""
|
|
727
729
|
return f"{action} failed (HTTP {status}){suffix}."
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
def _html(text: str) -> str:
|
|
731
|
-
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Validated input for ``sign_in``."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ValidationError, field_validator, model_validator
|
|
7
|
+
|
|
8
|
+
from pari.auth.providers import PROVIDERS, ProviderInfo, get_info, no_account_message
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_KEY_CHARS = re.compile(r"[A-Za-z0-9_-]+")
|
|
12
|
+
_MIN_API_KEY = 20
|
|
13
|
+
MAX_API_KEY_LENGTH = 256
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SignInInput(BaseModel):
|
|
17
|
+
"""One sign-in request. Fields are checked in declaration order."""
|
|
18
|
+
|
|
19
|
+
method: Literal["api_key", "account"]
|
|
20
|
+
provider_id: str
|
|
21
|
+
secret: str
|
|
22
|
+
refresh: str | None = None
|
|
23
|
+
client_id: str | None = None
|
|
24
|
+
expires: int | None = None
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def info(self) -> ProviderInfo:
|
|
28
|
+
return get_info(self.provider_id)
|
|
29
|
+
|
|
30
|
+
@field_validator("method", mode="before")
|
|
31
|
+
@classmethod
|
|
32
|
+
def _method(cls, value: object) -> object:
|
|
33
|
+
if value not in ("api_key", "account"):
|
|
34
|
+
raise ValueError("Sign in with an API key or an account token.")
|
|
35
|
+
return value
|
|
36
|
+
|
|
37
|
+
@field_validator("provider_id")
|
|
38
|
+
@classmethod
|
|
39
|
+
def _provider(cls, value: str) -> str:
|
|
40
|
+
return get_info(value).id
|
|
41
|
+
|
|
42
|
+
@field_validator("secret")
|
|
43
|
+
@classmethod
|
|
44
|
+
def _secret(cls, value: str) -> str:
|
|
45
|
+
token = value.strip()
|
|
46
|
+
if not token:
|
|
47
|
+
raise ValueError("The credential is empty.")
|
|
48
|
+
return token
|
|
49
|
+
|
|
50
|
+
@field_validator("refresh", "client_id", mode="before")
|
|
51
|
+
@classmethod
|
|
52
|
+
def _optional_text(cls, value: object) -> str | None:
|
|
53
|
+
if isinstance(value, str) and value.strip():
|
|
54
|
+
return value.strip()
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
@field_validator("expires", mode="before")
|
|
58
|
+
@classmethod
|
|
59
|
+
def _optional_int(cls, value: object) -> int | None:
|
|
60
|
+
return value if isinstance(value, int) else None
|
|
61
|
+
|
|
62
|
+
@model_validator(mode="after")
|
|
63
|
+
def _check_credential(self) -> "SignInInput":
|
|
64
|
+
if self.method == "account" and not self.info.account:
|
|
65
|
+
raise ValueError(no_account_message(self.info))
|
|
66
|
+
if self.method == "api_key":
|
|
67
|
+
_check_api_key(self.info, self.secret)
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parse_sign_in(
|
|
72
|
+
provider_id: str,
|
|
73
|
+
secret: str,
|
|
74
|
+
method: str,
|
|
75
|
+
extra: dict | None = None,
|
|
76
|
+
) -> SignInInput:
|
|
77
|
+
"""Validate the input. Raises ``ValueError`` with a message to show as is."""
|
|
78
|
+
extra = extra or {}
|
|
79
|
+
try:
|
|
80
|
+
return SignInInput.model_validate(
|
|
81
|
+
{
|
|
82
|
+
"method": method,
|
|
83
|
+
"provider_id": provider_id,
|
|
84
|
+
"secret": secret,
|
|
85
|
+
"refresh": extra.get("refresh"),
|
|
86
|
+
"client_id": extra.get("client_id"),
|
|
87
|
+
"expires": extra.get("expires"),
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
except ValidationError as exc:
|
|
91
|
+
raise ValueError(_message(exc)) from None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _message(exc: ValidationError) -> str:
|
|
95
|
+
"""The first error as a sentence. Rule failures carry their own text."""
|
|
96
|
+
error = exc.errors()[0]
|
|
97
|
+
if error["type"] == "value_error":
|
|
98
|
+
return str(error["ctx"]["error"])
|
|
99
|
+
return f"{error['loc'][0]}: {error['msg']}"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _check_api_key(info: ProviderInfo, token: str) -> None:
|
|
103
|
+
if any(char.isspace() for char in token):
|
|
104
|
+
raise ValueError(f"{info.label} API keys cannot contain spaces.")
|
|
105
|
+
_check_prefix(info, token)
|
|
106
|
+
if not _KEY_CHARS.fullmatch(token):
|
|
107
|
+
raise ValueError(
|
|
108
|
+
f"That {info.label} API key uses characters that are not allowed."
|
|
109
|
+
)
|
|
110
|
+
if len(token) < _MIN_API_KEY:
|
|
111
|
+
raise ValueError(f"That {info.label} API key is too short.")
|
|
112
|
+
if len(token) > MAX_API_KEY_LENGTH:
|
|
113
|
+
raise ValueError(f"That {info.label} API key is too long.")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _check_prefix(info: ProviderInfo, token: str) -> None:
|
|
117
|
+
"""The most specific prefix wins, so ``sk-ant-`` is Anthropic and not OpenAI."""
|
|
118
|
+
matches = [item for item in PROVIDERS if token.startswith(item.key_prefix)]
|
|
119
|
+
if not matches:
|
|
120
|
+
raise ValueError(f"{info.label} API keys start with {info.key_prefix}.")
|
|
121
|
+
longest = max(len(item.key_prefix) for item in matches)
|
|
122
|
+
owners = [item for item in matches if len(item.key_prefix) == longest]
|
|
123
|
+
if info not in owners:
|
|
124
|
+
names = " or ".join(item.label for item in owners)
|
|
125
|
+
raise ValueError(
|
|
126
|
+
f"That key belongs to {names}. "
|
|
127
|
+
f"{info.label} keys start with {info.key_prefix}."
|
|
128
|
+
)
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
import base64
|
|
4
4
|
import ctypes
|
|
5
|
-
import getpass
|
|
6
5
|
import os
|
|
7
6
|
import select
|
|
8
7
|
import sys
|
|
@@ -22,7 +21,7 @@ except ImportError:
|
|
|
22
21
|
tty = None
|
|
23
22
|
|
|
24
23
|
from pari.auth.account import AccountUI, LoginCancelled, sign_in_account
|
|
25
|
-
from pari.auth.login import sign_in, sign_out, status_text
|
|
24
|
+
from pari.auth.login import KeyVerifier, sign_in, sign_out, status_text
|
|
26
25
|
from pari.auth.menu import (
|
|
27
26
|
API_KEY_TITLE,
|
|
28
27
|
LOGOUT_TITLE,
|
|
@@ -34,8 +33,10 @@ from pari.auth.menu import (
|
|
|
34
33
|
provider_rows,
|
|
35
34
|
signed_in_rows,
|
|
36
35
|
)
|
|
37
|
-
from pari.auth.providers import ProviderInfo, get_info
|
|
36
|
+
from pari.auth.providers import ProviderInfo, get_info, no_account_message
|
|
37
|
+
from pari.auth.secret_field import SecretField
|
|
38
38
|
from pari.auth.store import AuthStore
|
|
39
|
+
from pari.auth.verify import verify_api_key
|
|
39
40
|
|
|
40
41
|
|
|
41
42
|
_ACCENT = "\033[38;2;255;121;198m"
|
|
@@ -44,12 +45,15 @@ _WARN = "\033[38;2;230;180;80m"
|
|
|
44
45
|
_OK = "\033[38;2;80;200;120m"
|
|
45
46
|
_BOLD = "\033[1m"
|
|
46
47
|
_RESET = "\033[0m"
|
|
48
|
+
_PASTE_ON = "\033[?2004h"
|
|
49
|
+
_PASTE_OFF = "\033[?2004l"
|
|
47
50
|
|
|
48
51
|
|
|
49
52
|
def run_login(
|
|
50
53
|
ui: AccountUI,
|
|
51
54
|
store: AuthStore | None = None,
|
|
52
55
|
provider_id: str | None = None,
|
|
56
|
+
verify: KeyVerifier | None = None,
|
|
53
57
|
) -> str:
|
|
54
58
|
"""Walk the menus and return the success sentence. Empty means cancelled."""
|
|
55
59
|
auth = store or AuthStore()
|
|
@@ -62,7 +66,7 @@ def run_login(
|
|
|
62
66
|
return ""
|
|
63
67
|
kind = "account" if method == METHOD_ACCOUNT else "api_key"
|
|
64
68
|
if provider_id:
|
|
65
|
-
message = _finish(kind, provider_id, ui, auth)
|
|
69
|
+
message = _finish(kind, provider_id, ui, auth, verify)
|
|
66
70
|
if message is None:
|
|
67
71
|
continue
|
|
68
72
|
return message
|
|
@@ -70,7 +74,7 @@ def run_login(
|
|
|
70
74
|
chosen = ui.choose_provider(kind, provider_rows(kind, auth))
|
|
71
75
|
if not chosen:
|
|
72
76
|
break
|
|
73
|
-
message = _finish(kind, chosen, ui, auth)
|
|
77
|
+
message = _finish(kind, chosen, ui, auth, verify)
|
|
74
78
|
if message is None:
|
|
75
79
|
continue
|
|
76
80
|
return message
|
|
@@ -121,7 +125,7 @@ def login_tty(provider_id: str | None = None) -> int:
|
|
|
121
125
|
return 0
|
|
122
126
|
ui = TerminalLogin()
|
|
123
127
|
try:
|
|
124
|
-
message = run_login(ui, provider_id=provider_id)
|
|
128
|
+
message = run_login(ui, provider_id=provider_id, verify=verify_api_key)
|
|
125
129
|
except KeyboardInterrupt:
|
|
126
130
|
return 0
|
|
127
131
|
except (ValueError, RuntimeError) as exc:
|
|
@@ -134,31 +138,39 @@ def login_tty(provider_id: str | None = None) -> int:
|
|
|
134
138
|
return 0
|
|
135
139
|
|
|
136
140
|
|
|
137
|
-
def _finish(
|
|
141
|
+
def _finish(
|
|
142
|
+
kind: str,
|
|
143
|
+
provider_id: str,
|
|
144
|
+
ui: AccountUI,
|
|
145
|
+
store: AuthStore,
|
|
146
|
+
verify: KeyVerifier | None,
|
|
147
|
+
) -> str | None:
|
|
138
148
|
info = get_info(provider_id)
|
|
139
149
|
try:
|
|
140
150
|
if kind == "api_key":
|
|
141
|
-
return _read_api_key(ui, info, store)
|
|
142
|
-
if info.
|
|
143
|
-
ui.show_error(
|
|
144
|
-
"DeepSeek has no account sign-in. "
|
|
145
|
-
"Use pari-cli login deepseek --api-key <key>."
|
|
146
|
-
)
|
|
151
|
+
return _read_api_key(ui, info, store, verify)
|
|
152
|
+
if not info.account:
|
|
153
|
+
ui.show_error(no_account_message(info))
|
|
147
154
|
return None
|
|
148
155
|
return sign_in_account(info.id, ui, store)
|
|
149
156
|
except LoginCancelled:
|
|
150
157
|
return None
|
|
151
158
|
|
|
152
159
|
|
|
153
|
-
def _read_api_key(
|
|
154
|
-
|
|
160
|
+
def _read_api_key(
|
|
161
|
+
ui: AccountUI,
|
|
162
|
+
info: ProviderInfo,
|
|
163
|
+
store: AuthStore,
|
|
164
|
+
verify: KeyVerifier | None,
|
|
165
|
+
) -> str:
|
|
166
|
+
"""Ask until the provider accepts the key, or the user cancels."""
|
|
155
167
|
prompt = f"Enter your {info.label} API key:"
|
|
156
168
|
while True:
|
|
157
169
|
secret = ui.read_secret(prompt)
|
|
158
170
|
if secret is None:
|
|
159
171
|
raise LoginCancelled()
|
|
160
172
|
try:
|
|
161
|
-
return sign_in(info.id, secret, "api_key", store)
|
|
173
|
+
return sign_in(info.id, secret, "api_key", store, verify=verify)
|
|
162
174
|
except ValueError as exc:
|
|
163
175
|
ui.show_error(str(exc))
|
|
164
176
|
|
|
@@ -188,14 +200,25 @@ class TerminalLogin:
|
|
|
188
200
|
return self._run(ProviderMenu(rows, search=search, title=title))
|
|
189
201
|
|
|
190
202
|
def read_secret(self, prompt: str) -> str | None:
|
|
203
|
+
"""Read a key shown as ``x`` per character. None means cancelled."""
|
|
191
204
|
self._leave()
|
|
205
|
+
# The alternate screen restores the cursor onto the previous prompt.
|
|
206
|
+
sys.stdout.write("\n" + _PASTE_ON)
|
|
207
|
+
field = SecretField()
|
|
192
208
|
try:
|
|
193
|
-
|
|
194
|
-
|
|
209
|
+
self._enter_raw()
|
|
210
|
+
while True:
|
|
211
|
+
sys.stdout.write(f"\r\033[K{prompt} {field.masked}")
|
|
212
|
+
sys.stdout.flush()
|
|
213
|
+
result = field.on_key(self._key(None))
|
|
214
|
+
if result == "cancel":
|
|
215
|
+
return None
|
|
216
|
+
if result == "enter":
|
|
217
|
+
return field.value
|
|
218
|
+
finally:
|
|
219
|
+
sys.stdout.write(_PASTE_OFF + "\n")
|
|
195
220
|
sys.stdout.flush()
|
|
196
|
-
|
|
197
|
-
except (KeyboardInterrupt, EOFError):
|
|
198
|
-
return None
|
|
221
|
+
self._leave()
|
|
199
222
|
|
|
200
223
|
def show_error(self, message: str) -> None:
|
|
201
224
|
self._leave()
|
|
@@ -293,6 +316,9 @@ class TerminalLogin:
|
|
|
293
316
|
sys.stdout.write("\033[?1049h\033[?25l")
|
|
294
317
|
sys.stdout.flush()
|
|
295
318
|
self._alt = True
|
|
319
|
+
self._enter_raw()
|
|
320
|
+
|
|
321
|
+
def _enter_raw(self) -> None:
|
|
296
322
|
if self._raw:
|
|
297
323
|
return
|
|
298
324
|
self._fd = sys.stdin.fileno()
|
|
@@ -506,6 +532,10 @@ def _normalize(raw: bytes) -> str:
|
|
|
506
532
|
return "cancel"
|
|
507
533
|
if raw == b"\x18":
|
|
508
534
|
return "copy"
|
|
535
|
+
if raw == b"\x1b[200~":
|
|
536
|
+
return "paste_start"
|
|
537
|
+
if raw == b"\x1b[201~":
|
|
538
|
+
return "paste_end"
|
|
509
539
|
if raw == b"\x1b":
|
|
510
540
|
return "cancel"
|
|
511
541
|
if raw.startswith(b"\x1b[") or (raw.startswith(b"\x1bO") and len(raw) >= 3):
|