pcode 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.
- pcode/__init__.py +7 -0
- pcode/_job_supervisor.py +65 -0
- pcode/agent.py +592 -0
- pcode/anthropic_oauth.py +469 -0
- pcode/app.py +3165 -0
- pcode/appearance.py +181 -0
- pcode/aside.py +541 -0
- pcode/aside_guard.py +39 -0
- pcode/aside_ui.py +683 -0
- pcode/auth.py +113 -0
- pcode/block.py +39 -0
- pcode/browser.py +393 -0
- pcode/cache_diagnostics.py +336 -0
- pcode/cache_settings.py +58 -0
- pcode/cache_warnings.py +139 -0
- pcode/claude_sdk/__init__.py +96 -0
- pcode/claude_sdk/cli.py +52 -0
- pcode/claude_sdk/errors.py +61 -0
- pcode/claude_sdk/messages.py +154 -0
- pcode/claude_sdk/model.py +179 -0
- pcode/claude_sdk/resume.py +114 -0
- pcode/claude_sdk/session.py +547 -0
- pcode/claude_sdk/session_pool.py +251 -0
- pcode/claude_sdk/workspace.py +34 -0
- pcode/cli.py +73 -0
- pcode/clipboard.py +76 -0
- pcode/code_mode.py +43 -0
- pcode/codex_login.py +189 -0
- pcode/command_transcript.py +71 -0
- pcode/commands.py +145 -0
- pcode/compaction.py +457 -0
- pcode/completion.py +263 -0
- pcode/config.py +223 -0
- pcode/context_breakdown.py +178 -0
- pcode/context_usage.py +52 -0
- pcode/controller.py +3496 -0
- pcode/conversation_ids.py +27 -0
- pcode/conversation_tree.py +160 -0
- pcode/copy_ui.py +172 -0
- pcode/delegation.py +236 -0
- pcode/delta.py +295 -0
- pcode/diagnostics.py +226 -0
- pcode/edit_preview.py +158 -0
- pcode/edit_transcript.py +287 -0
- pcode/edit_ui.py +850 -0
- pcode/edits.py +162 -0
- pcode/email_remote/__init__.py +14 -0
- pcode/email_remote/cli.py +133 -0
- pcode/email_remote/listener.py +695 -0
- pcode/email_remote/mailbox.py +336 -0
- pcode/email_remote/outbound.py +75 -0
- pcode/email_remote/parsing.py +226 -0
- pcode/email_remote/render.py +175 -0
- pcode/email_remote/state.py +79 -0
- pcode/error_report.py +38 -0
- pcode/ext.py +602 -0
- pcode/extension_guide.md +311 -0
- pcode/extensions/browser.py +284 -0
- pcode/extensions/ponytail.py +217 -0
- pcode/extensions/sandbox.py +138 -0
- pcode/extensions/session_history.py +170 -0
- pcode/extensions/web_research.py +89 -0
- pcode/file_refs.py +265 -0
- pcode/filesystem.py +138 -0
- pcode/frame.py +149 -0
- pcode/gateway.py +465 -0
- pcode/git_diff.py +497 -0
- pcode/history.py +589 -0
- pcode/history_cursor.py +147 -0
- pcode/history_embeddings.py +102 -0
- pcode/host.py +941 -0
- pcode/host_protocol.py +291 -0
- pcode/host_ui.py +225 -0
- pcode/input_keys.py +35 -0
- pcode/inspection.py +289 -0
- pcode/inspector_ui.py +448 -0
- pcode/isolated_delegation.py +425 -0
- pcode/job_notices.py +54 -0
- pcode/jobs.py +584 -0
- pcode/jobs_ui.py +265 -0
- pcode/keymap.py +215 -0
- pcode/layout_speed.py +103 -0
- pcode/links.py +90 -0
- pcode/links_ui.py +200 -0
- pcode/live.py +1301 -0
- pcode/llm_proxy.py +44 -0
- pcode/logins.py +233 -0
- pcode/mcp.py +683 -0
- pcode/mcp_notice.py +91 -0
- pcode/mcp_oauth.py +381 -0
- pcode/meridian.py +233 -0
- pcode/meridian_process.py +337 -0
- pcode/meridian_reminders.py +107 -0
- pcode/meridian_setup.py +356 -0
- pcode/model_metadata.py +451 -0
- pcode/model_ui.py +168 -0
- pcode/models.py +209 -0
- pcode/native_results.py +119 -0
- pcode/oauth_pages.py +66 -0
- pcode/output_limits.py +41 -0
- pcode/paste.py +45 -0
- pcode/path_completion.py +66 -0
- pcode/plan_preview.py +131 -0
- pcode/plan_sizes.py +35 -0
- pcode/planning.py +161 -0
- pcode/popup_ui.py +873 -0
- pcode/preferences.py +1176 -0
- pcode/prefix_keys.py +490 -0
- pcode/profile_benchmark.py +332 -0
- pcode/profiling.py +566 -0
- pcode/project_trust.py +94 -0
- pcode/prompt_keys.py +286 -0
- pcode/remote.py +717 -0
- pcode/remote_print.py +125 -0
- pcode/remote_profile.py +237 -0
- pcode/replay_benchmark.py +319 -0
- pcode/repo_context.py +158 -0
- pcode/retries.py +28 -0
- pcode/rpc.py +280 -0
- pcode/runtime.py +244 -0
- pcode/sandbox.py +454 -0
- pcode/session_naming.py +82 -0
- pcode/session_ui.py +550 -0
- pcode/sessions.py +892 -0
- pcode/shell.py +113 -0
- pcode/shell_mode.py +172 -0
- pcode/shell_tools.py +459 -0
- pcode/skills.py +161 -0
- pcode/stall_log.py +187 -0
- pcode/startup_input.py +96 -0
- pcode/steering.py +27 -0
- pcode/stream_display.py +188 -0
- pcode/stream_events.py +437 -0
- pcode/strict_tools.py +130 -0
- pcode/syntax.py +43 -0
- pcode/syntax_colors.py +206 -0
- pcode/task_prompt.py +28 -0
- pcode/task_worktrees.py +482 -0
- pcode/terminal_notify.py +383 -0
- pcode/theme.py +135 -0
- pcode/theme_gallery.py +88 -0
- pcode/thinking_markdown.py +41 -0
- pcode/token_accounting.py +67 -0
- pcode/tool_display.py +784 -0
- pcode/tool_output_limits.py +108 -0
- pcode/tool_panel.py +377 -0
- pcode/transcript_log.py +127 -0
- pcode/transcript_notice.py +103 -0
- pcode/tree_ui.py +288 -0
- pcode/turn.py +44 -0
- pcode/ui.py +3745 -0
- pcode/usage.py +293 -0
- pcode/word_wrap.py +107 -0
- pcode/worker_ui.py +235 -0
- pcode/workers.py +136 -0
- pcode/workspace.py +57 -0
- pcode/workspace_filesystem.py +72 -0
- pcode/worktree.py +650 -0
- pcode-0.1.0.dist-info/METADATA +145 -0
- pcode-0.1.0.dist-info/RECORD +163 -0
- pcode-0.1.0.dist-info/WHEEL +4 -0
- pcode-0.1.0.dist-info/entry_points.txt +3 -0
- pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
pcode/model_metadata.py
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""Context and output metadata, not pricing. Refreshes never run during rendering.
|
|
2
|
+
|
|
3
|
+
Public Models.dev metadata is cached on disk. Authenticated metadata is cached
|
|
4
|
+
per model instance only: one account/proxy must not inherit another's limits.
|
|
5
|
+
Failures retain last-known values; unknown serving routes stay unknown.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import asdict, dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from tempfile import NamedTemporaryFile
|
|
16
|
+
from typing import Any
|
|
17
|
+
from urllib.parse import quote, urlsplit
|
|
18
|
+
from weakref import finalize
|
|
19
|
+
|
|
20
|
+
import httpx2
|
|
21
|
+
from pydantic_ai.models import Model
|
|
22
|
+
|
|
23
|
+
CATALOG_URL = "https://models.dev/api.json"
|
|
24
|
+
CATALOG_TTL = 24 * 60 * 60
|
|
25
|
+
NATIVE_TTL = 60 * 60
|
|
26
|
+
RETRY_DELAY = 60
|
|
27
|
+
REQUEST_TIMEOUT = 3
|
|
28
|
+
# Protocol version verified against OpenAI's models endpoint implementation.
|
|
29
|
+
# This is NOT the pcode version; the backend uses it to select its catalog schema.
|
|
30
|
+
CODEX_CATALOG_VERSION = "0.154.0"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def codex_catalog_version() -> str:
|
|
34
|
+
"""Use the newest verified/catalog client version, not pcode's own version.
|
|
35
|
+
|
|
36
|
+
The backend filters out newer models for older clients. The local Codex cache
|
|
37
|
+
is useful for its protocol version only: never borrow its account's limits,
|
|
38
|
+
instructions, credentials, or model availability. No CLI subprocess needed.
|
|
39
|
+
"""
|
|
40
|
+
version = CODEX_CATALOG_VERSION
|
|
41
|
+
home = Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex").expanduser()
|
|
42
|
+
try:
|
|
43
|
+
with (home / "models_cache.json").open() as file:
|
|
44
|
+
raw = file.read(8_000_001)
|
|
45
|
+
if len(raw) > 8_000_000:
|
|
46
|
+
return version
|
|
47
|
+
data = json.loads(raw)
|
|
48
|
+
candidate = data.get("client_version") if isinstance(data, dict) else None
|
|
49
|
+
if isinstance(candidate, str) and re.fullmatch(r"\d{1,3}\.\d{1,4}\.\d{1,4}", candidate):
|
|
50
|
+
if tuple(map(int, candidate.split("."))) > tuple(map(int, version.split("."))):
|
|
51
|
+
version = candidate
|
|
52
|
+
except (OSError, ValueError):
|
|
53
|
+
pass
|
|
54
|
+
return version
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ContextWindowError(ValueError):
|
|
58
|
+
"""Invalid explicit working-window configuration (safe to display)."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def positive_int(value) -> int | None:
|
|
62
|
+
return value if type(value) is int and 0 < value <= 100_000_000 else None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class ModelLimits:
|
|
67
|
+
context: int | None = None
|
|
68
|
+
input: int | None = None
|
|
69
|
+
output: int | None = None
|
|
70
|
+
maximum: int | None = None
|
|
71
|
+
source: str = ""
|
|
72
|
+
fetched_at: float = 0
|
|
73
|
+
|
|
74
|
+
def working_window(self, override: int | None = None) -> int | None:
|
|
75
|
+
# Input ceilings are distinct from the combined input+output context.
|
|
76
|
+
# Codex's default window may be smaller than its opt-in maximum.
|
|
77
|
+
if override is not None:
|
|
78
|
+
limits = [override, self.maximum or self.context, self.input]
|
|
79
|
+
else:
|
|
80
|
+
limits = [self.context, self.input]
|
|
81
|
+
known = [limit for limit in limits if limit is not None]
|
|
82
|
+
return min(known) if known else None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def configured_window() -> int | None:
|
|
86
|
+
value = os.environ.get("PCODE_CONTEXT_WINDOW", "").strip()
|
|
87
|
+
if not value:
|
|
88
|
+
return None
|
|
89
|
+
try:
|
|
90
|
+
window = int(value)
|
|
91
|
+
except ValueError:
|
|
92
|
+
raise ContextWindowError("PCODE_CONTEXT_WINDOW must be a positive token count.") from None
|
|
93
|
+
if window < 4096:
|
|
94
|
+
raise ContextWindowError("PCODE_CONTEXT_WINDOW must be at least 4096 tokens.")
|
|
95
|
+
return window
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parse_catalog(data, fetched_at: float) -> dict[str, ModelLimits]:
|
|
99
|
+
result = {}
|
|
100
|
+
if not isinstance(data, dict):
|
|
101
|
+
return result
|
|
102
|
+
for provider, entry in data.items():
|
|
103
|
+
if not isinstance(provider, str) or not isinstance(entry, dict):
|
|
104
|
+
continue
|
|
105
|
+
models = entry.get("models")
|
|
106
|
+
if not isinstance(models, dict):
|
|
107
|
+
continue
|
|
108
|
+
for name, model in models.items():
|
|
109
|
+
if not isinstance(name, str) or not isinstance(model, dict):
|
|
110
|
+
continue
|
|
111
|
+
limits = model.get("limit")
|
|
112
|
+
if not isinstance(limits, dict):
|
|
113
|
+
continue
|
|
114
|
+
context = positive_int(limits.get("context"))
|
|
115
|
+
input_limit = positive_int(limits.get("input"))
|
|
116
|
+
if context or input_limit:
|
|
117
|
+
result[f"{provider}:{name}"] = ModelLimits(
|
|
118
|
+
context=context,
|
|
119
|
+
input=input_limit,
|
|
120
|
+
output=positive_int(limits.get("output")),
|
|
121
|
+
source="models.dev",
|
|
122
|
+
fetched_at=fetched_at,
|
|
123
|
+
)
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def parse_codex(data, name: str, fetched_at: float) -> ModelLimits | None:
|
|
128
|
+
models = data.get("models") if isinstance(data, dict) else None
|
|
129
|
+
if not isinstance(models, list):
|
|
130
|
+
return None
|
|
131
|
+
for model in models:
|
|
132
|
+
if not isinstance(model, dict) or model.get("slug") != name:
|
|
133
|
+
continue
|
|
134
|
+
context = positive_int(model.get("context_window"))
|
|
135
|
+
maximum = positive_int(model.get("max_context_window"))
|
|
136
|
+
if context:
|
|
137
|
+
return ModelLimits(
|
|
138
|
+
context=context,
|
|
139
|
+
maximum=max(context, maximum or context),
|
|
140
|
+
output=positive_int(model.get("max_output_tokens")),
|
|
141
|
+
source="codex",
|
|
142
|
+
fetched_at=fetched_at,
|
|
143
|
+
)
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def parse_meridian(data, name: str, fetched_at: float) -> ModelLimits | None:
|
|
148
|
+
"""Meridian's account-aware /v1/models list (not Anthropic model detail)."""
|
|
149
|
+
models = data.get("data") if isinstance(data, dict) else None
|
|
150
|
+
if not isinstance(models, list):
|
|
151
|
+
return None
|
|
152
|
+
for model in models:
|
|
153
|
+
if not isinstance(model, dict) or model.get("id") != name:
|
|
154
|
+
continue
|
|
155
|
+
context = positive_int(model.get("context_window"))
|
|
156
|
+
if context:
|
|
157
|
+
return ModelLimits(context=context, source="meridian", fetched_at=fetched_at)
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def parse_anthropic(data, fetched_at: float) -> ModelLimits | None:
|
|
162
|
+
if not isinstance(data, dict):
|
|
163
|
+
return None
|
|
164
|
+
input_limit = positive_int(data.get("max_input_tokens"))
|
|
165
|
+
output_limit = positive_int(data.get("max_tokens"))
|
|
166
|
+
if input_limit or output_limit:
|
|
167
|
+
return ModelLimits(
|
|
168
|
+
input=input_limit,
|
|
169
|
+
output=output_limit,
|
|
170
|
+
source="anthropic",
|
|
171
|
+
fetched_at=fetched_at,
|
|
172
|
+
)
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def catalog_routes(data) -> dict[str, str]:
|
|
177
|
+
if not isinstance(data, dict):
|
|
178
|
+
return {}
|
|
179
|
+
return {
|
|
180
|
+
name: entry["api"]
|
|
181
|
+
for name, entry in data.items()
|
|
182
|
+
if isinstance(name, str)
|
|
183
|
+
and isinstance(entry, dict)
|
|
184
|
+
and isinstance(entry.get("api"), str)
|
|
185
|
+
and entry["api"].startswith("https://")
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def catalog_key(model: str | Model, routes: dict[str, str] | None = None) -> str | None:
|
|
190
|
+
"""Exact provider/model match, never guess from a model's family name."""
|
|
191
|
+
identity = model if isinstance(model, str) else model.model_id
|
|
192
|
+
provider, sep, name = identity.partition(":")
|
|
193
|
+
if not sep:
|
|
194
|
+
return None
|
|
195
|
+
if provider == "claude":
|
|
196
|
+
# Claude Code serves Anthropic's models under their own names.
|
|
197
|
+
return f"anthropic:{name}"
|
|
198
|
+
if not isinstance(model, str):
|
|
199
|
+
endpoint = model.provider
|
|
200
|
+
if endpoint is None:
|
|
201
|
+
return None
|
|
202
|
+
# Some catalogs omit `api` for native SDK providers. These are verified
|
|
203
|
+
# canonical endpoints, not model limits. Other routes come from the feed.
|
|
204
|
+
canonical = {
|
|
205
|
+
"anthropic": "https://api.anthropic.com",
|
|
206
|
+
"openai": "https://api.openai.com/v1",
|
|
207
|
+
"openai-codex": "https://chatgpt.com/backend-api/codex",
|
|
208
|
+
}.get(endpoint.name) or (routes or {}).get(endpoint.name)
|
|
209
|
+
if canonical is None:
|
|
210
|
+
return None
|
|
211
|
+
# Use the client's actual URL, not an SDK default that may hide an
|
|
212
|
+
# injected client/proxy. Require scheme, port and path to match too.
|
|
213
|
+
actual = str(getattr(endpoint.client, "base_url", ""))
|
|
214
|
+
try:
|
|
215
|
+
url, expected = urlsplit(actual), urlsplit(canonical)
|
|
216
|
+
if (
|
|
217
|
+
(url.scheme, url.netloc, url.path.rstrip("/"))
|
|
218
|
+
!= (expected.scheme, expected.netloc, expected.path.rstrip("/"))
|
|
219
|
+
or url.query
|
|
220
|
+
or url.fragment
|
|
221
|
+
):
|
|
222
|
+
return None
|
|
223
|
+
except ValueError:
|
|
224
|
+
return None
|
|
225
|
+
provider = endpoint.name
|
|
226
|
+
# OAuth subscription access is not the ordinary Anthropic API catalog.
|
|
227
|
+
if getattr(model, "_subscription_oauth", False):
|
|
228
|
+
return None
|
|
229
|
+
provider = {"openai-responses": "openai", "openai-chat": "openai"}.get(provider, provider)
|
|
230
|
+
# In particular, openai-codex NEVER maps to openai.
|
|
231
|
+
return f"{provider}:{name}"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@dataclass
|
|
235
|
+
class NativeState:
|
|
236
|
+
limits: ModelLimits | None = None
|
|
237
|
+
attempted_at: float = 0
|
|
238
|
+
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class ContextCatalog:
|
|
242
|
+
def __init__(self, cache_path: Path | None = None):
|
|
243
|
+
self.cache_path = cache_path
|
|
244
|
+
self.public: dict[str, ModelLimits] = {}
|
|
245
|
+
self.routes: dict[str, str] = {}
|
|
246
|
+
self._states: dict[int, NativeState] = {}
|
|
247
|
+
self._public_lock = asyncio.Lock()
|
|
248
|
+
self._public_attempt = 0.0
|
|
249
|
+
self._public_updated = 0.0
|
|
250
|
+
self._loaded = False
|
|
251
|
+
|
|
252
|
+
def state(self, model: Model) -> NativeState:
|
|
253
|
+
# Pydantic models are unhashable. Key by identity with weak cleanup, not
|
|
254
|
+
# by model name: credentials and endpoints belong to this exact instance.
|
|
255
|
+
key = id(model)
|
|
256
|
+
if key not in self._states:
|
|
257
|
+
self._states[key] = NativeState()
|
|
258
|
+
finalize(model, self._states.pop, key, None)
|
|
259
|
+
return self._states[key]
|
|
260
|
+
|
|
261
|
+
def _path(self) -> Path:
|
|
262
|
+
if self.cache_path is not None:
|
|
263
|
+
return self.cache_path
|
|
264
|
+
root = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache")
|
|
265
|
+
return root / "pcode" / "model-context-v1.json"
|
|
266
|
+
|
|
267
|
+
def _load(self) -> None:
|
|
268
|
+
try:
|
|
269
|
+
data = json.loads(self._path().read_text())
|
|
270
|
+
if data.get("version") != 1:
|
|
271
|
+
return
|
|
272
|
+
updated = data["fetched_at"]
|
|
273
|
+
if type(updated) not in (int, float) or not 0 < updated <= time.time():
|
|
274
|
+
return
|
|
275
|
+
entries = data["models"]
|
|
276
|
+
if not isinstance(entries, dict):
|
|
277
|
+
return
|
|
278
|
+
parsed = {}
|
|
279
|
+
for key, row in entries.items():
|
|
280
|
+
if not isinstance(key, str) or not isinstance(row, dict):
|
|
281
|
+
continue
|
|
282
|
+
limits = ModelLimits(
|
|
283
|
+
context=positive_int(row.get("context")),
|
|
284
|
+
input=positive_int(row.get("input")),
|
|
285
|
+
output=positive_int(row.get("output")),
|
|
286
|
+
source="models.dev",
|
|
287
|
+
fetched_at=updated,
|
|
288
|
+
)
|
|
289
|
+
if limits.working_window():
|
|
290
|
+
parsed[key] = limits
|
|
291
|
+
if parsed:
|
|
292
|
+
self.public = parsed
|
|
293
|
+
routes = data.get("routes", {})
|
|
294
|
+
if isinstance(routes, dict):
|
|
295
|
+
self.routes = {
|
|
296
|
+
key: value
|
|
297
|
+
for key, value in routes.items()
|
|
298
|
+
if isinstance(key, str)
|
|
299
|
+
and isinstance(value, str)
|
|
300
|
+
and value.startswith("https://")
|
|
301
|
+
}
|
|
302
|
+
self._public_updated = updated
|
|
303
|
+
except (OSError, ValueError, TypeError, KeyError, AttributeError):
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
def _save(self) -> None:
|
|
307
|
+
path = self._path()
|
|
308
|
+
temporary = None
|
|
309
|
+
try:
|
|
310
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
311
|
+
with NamedTemporaryFile(mode="w", dir=path.parent, delete=False) as file:
|
|
312
|
+
temporary = Path(file.name)
|
|
313
|
+
json.dump(
|
|
314
|
+
{
|
|
315
|
+
"version": 1,
|
|
316
|
+
"fetched_at": self._public_updated,
|
|
317
|
+
"models": {key: asdict(value) for key, value in self.public.items()},
|
|
318
|
+
"routes": self.routes,
|
|
319
|
+
},
|
|
320
|
+
file,
|
|
321
|
+
)
|
|
322
|
+
temporary.replace(path)
|
|
323
|
+
except OSError:
|
|
324
|
+
pass
|
|
325
|
+
finally:
|
|
326
|
+
if temporary is not None:
|
|
327
|
+
try:
|
|
328
|
+
temporary.unlink(missing_ok=True)
|
|
329
|
+
except OSError:
|
|
330
|
+
pass
|
|
331
|
+
|
|
332
|
+
def limits(self, model: str | Model) -> ModelLimits | None:
|
|
333
|
+
if not isinstance(model, str) and (native := self.state(model).limits) is not None:
|
|
334
|
+
return native
|
|
335
|
+
key = catalog_key(model, self.routes)
|
|
336
|
+
return self.public.get(key) if key else None
|
|
337
|
+
|
|
338
|
+
def window(self, model: str | Model) -> int | None:
|
|
339
|
+
override = configured_window()
|
|
340
|
+
limits = self.limits(model)
|
|
341
|
+
return limits.working_window(override) if limits else override
|
|
342
|
+
|
|
343
|
+
async def refresh_public(self) -> None:
|
|
344
|
+
async with self._public_lock:
|
|
345
|
+
if not self._loaded:
|
|
346
|
+
await asyncio.to_thread(self._load)
|
|
347
|
+
self._loaded = True
|
|
348
|
+
now = time.time()
|
|
349
|
+
if now - self._public_updated < CATALOG_TTL:
|
|
350
|
+
return
|
|
351
|
+
if now - self._public_attempt < RETRY_DELAY:
|
|
352
|
+
return
|
|
353
|
+
self._public_attempt = now
|
|
354
|
+
try:
|
|
355
|
+
# Dedicated anonymous client: never send provider auth to models.dev.
|
|
356
|
+
async with asyncio.timeout(REQUEST_TIMEOUT):
|
|
357
|
+
async with httpx2.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
|
|
358
|
+
response = await client.get(CATALOG_URL)
|
|
359
|
+
response.raise_for_status()
|
|
360
|
+
data = response.json()
|
|
361
|
+
parsed = parse_catalog(data, now)
|
|
362
|
+
if parsed:
|
|
363
|
+
self.public = parsed
|
|
364
|
+
self.routes = catalog_routes(data)
|
|
365
|
+
self._public_updated = now
|
|
366
|
+
await asyncio.to_thread(self._save)
|
|
367
|
+
except Exception:
|
|
368
|
+
# Metadata is optional. Do not expose bodies, credentials, or login
|
|
369
|
+
# failures in the UI; the actual model request owns auth errors.
|
|
370
|
+
pass
|
|
371
|
+
|
|
372
|
+
async def refresh_native(self, model: Model) -> None:
|
|
373
|
+
provider = model.provider
|
|
374
|
+
if provider is None or provider.name not in {"openai-codex", "anthropic", "meridian"}:
|
|
375
|
+
return
|
|
376
|
+
state = self.state(model)
|
|
377
|
+
async with state.lock:
|
|
378
|
+
now = time.time()
|
|
379
|
+
previous = self.state(model).limits
|
|
380
|
+
if previous and now - previous.fetched_at < NATIVE_TTL:
|
|
381
|
+
return
|
|
382
|
+
if now - state.attempted_at < RETRY_DELAY:
|
|
383
|
+
return
|
|
384
|
+
state.attempted_at = now
|
|
385
|
+
try:
|
|
386
|
+
# The SDK client carries the real base URL, proxy, credential
|
|
387
|
+
# rotation and 401 replay. No parallel auth implementation here.
|
|
388
|
+
async with asyncio.timeout(REQUEST_TIMEOUT), model:
|
|
389
|
+
client = provider.client
|
|
390
|
+
options = {"max_retries": 0, "timeout": REQUEST_TIMEOUT}
|
|
391
|
+
if provider.name == "meridian":
|
|
392
|
+
data = await client.get(
|
|
393
|
+
"/v1/models", cast_to=dict[str, Any], options=options
|
|
394
|
+
)
|
|
395
|
+
limits = parse_meridian(data, model.model_name, now)
|
|
396
|
+
elif provider.name == "openai-codex":
|
|
397
|
+
version = await asyncio.to_thread(codex_catalog_version)
|
|
398
|
+
data = await client.get(
|
|
399
|
+
"/models",
|
|
400
|
+
cast_to=dict[str, Any],
|
|
401
|
+
options={
|
|
402
|
+
**options,
|
|
403
|
+
"params": {"client_version": version},
|
|
404
|
+
},
|
|
405
|
+
)
|
|
406
|
+
limits = parse_codex(data, model.model_name, now)
|
|
407
|
+
else:
|
|
408
|
+
headers = {}
|
|
409
|
+
if getattr(model, "_subscription_oauth", False):
|
|
410
|
+
from pcode.auth import OAUTH_BETAS, oauth_user_agent
|
|
411
|
+
|
|
412
|
+
headers = {
|
|
413
|
+
"anthropic-beta": ",".join(sorted(OAUTH_BETAS)),
|
|
414
|
+
"User-Agent": oauth_user_agent(),
|
|
415
|
+
"x-app": "cli",
|
|
416
|
+
}
|
|
417
|
+
# Raw JSON works with installed SDKs predating these fields.
|
|
418
|
+
data = await client.get(
|
|
419
|
+
f"/v1/models/{quote(model.model_name, safe='')}",
|
|
420
|
+
cast_to=dict[str, Any],
|
|
421
|
+
options={**options, "headers": headers},
|
|
422
|
+
)
|
|
423
|
+
limits = parse_anthropic(data, now)
|
|
424
|
+
if limits is not None:
|
|
425
|
+
state.limits = limits
|
|
426
|
+
except Exception:
|
|
427
|
+
pass
|
|
428
|
+
|
|
429
|
+
async def refresh(self, model: str | Model | None) -> None:
|
|
430
|
+
if model is None:
|
|
431
|
+
return
|
|
432
|
+
if isinstance(model, str):
|
|
433
|
+
# Native state belongs to a retained model instance, never a
|
|
434
|
+
# temporary inferred model or a shared provider:name auth cache.
|
|
435
|
+
await self.refresh_public()
|
|
436
|
+
return
|
|
437
|
+
if model.provider is None:
|
|
438
|
+
return
|
|
439
|
+
await asyncio.gather(self.refresh_public(), self.refresh_native(model))
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
catalog = ContextCatalog()
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def context_window(model: str | Model) -> int | None:
|
|
446
|
+
"""Synchronous, memory-only lookup shared by the UI and compaction."""
|
|
447
|
+
return catalog.window(model)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
async def refresh_context(model: str | Model | None) -> None:
|
|
451
|
+
await catalog.refresh(model)
|
pcode/model_ui.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Searchable, temporary model picker; never owns the main editor's buffer."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from prompt_toolkit.application import Application
|
|
6
|
+
from prompt_toolkit.data_structures import Point
|
|
7
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
8
|
+
from prompt_toolkit.layout import HSplit, Layout, Window
|
|
9
|
+
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
10
|
+
from prompt_toolkit.layout.dimension import Dimension
|
|
11
|
+
from prompt_toolkit.widgets import Label, TextArea
|
|
12
|
+
|
|
13
|
+
from pcode.frame import Dialog
|
|
14
|
+
from pcode.models import PROVIDERS
|
|
15
|
+
from pcode.popup_ui import fuzzy_match, popup_container, popup_style
|
|
16
|
+
from pcode.prefix_keys import PrefixKeys
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def matches_model(term: str, model: str) -> bool:
|
|
20
|
+
"""Match the id or the provider's display name plus model, e.g. anth + opus."""
|
|
21
|
+
provider, _, name = model.partition(":")
|
|
22
|
+
return any(
|
|
23
|
+
fuzzy_match(term, text)
|
|
24
|
+
for text in (model.casefold(), f"{PROVIDERS.get(provider, provider)} {name}".casefold())
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ModelPicker:
|
|
29
|
+
def __init__(self, models, providers, *, current=None, input=None, output=None, style=None):
|
|
30
|
+
self.models = list(models)
|
|
31
|
+
self.providers = set(providers)
|
|
32
|
+
self.current = current
|
|
33
|
+
self.selected = 0
|
|
34
|
+
self.matches = list(models)
|
|
35
|
+
self.search = TextArea(height=1, prompt="Filter: ", multiline=False)
|
|
36
|
+
self.search.buffer.on_text_changed += self.filter
|
|
37
|
+
rows = FormattedTextControl(
|
|
38
|
+
self.fragments, get_cursor_position=lambda: Point(x=0, y=self.selected)
|
|
39
|
+
)
|
|
40
|
+
self.rows = Window(
|
|
41
|
+
rows,
|
|
42
|
+
height=Dimension(min=3, max=14),
|
|
43
|
+
dont_extend_height=True,
|
|
44
|
+
wrap_lines=False,
|
|
45
|
+
always_hide_cursor=True,
|
|
46
|
+
)
|
|
47
|
+
keys = KeyBindings()
|
|
48
|
+
shortcuts = self.shortcuts = PrefixKeys()
|
|
49
|
+
shortcuts.set_help(
|
|
50
|
+
lambda: [
|
|
51
|
+
("Type", "Filter models or enter provider:model-id"),
|
|
52
|
+
("↑/↓ / Ctrl+P/N", "Select model"),
|
|
53
|
+
("PgUp/PgDn", "Page"),
|
|
54
|
+
("Ctrl+U/D", "Half page"),
|
|
55
|
+
("Enter", "Apply selected model"),
|
|
56
|
+
("Esc / Ctrl+C / Ctrl+L", "Cancel"),
|
|
57
|
+
]
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
@keys.add("up", eager=True)
|
|
61
|
+
@keys.add("c-p", eager=True)
|
|
62
|
+
def previous(event):
|
|
63
|
+
self.move(-1)
|
|
64
|
+
|
|
65
|
+
@keys.add("down", eager=True)
|
|
66
|
+
@keys.add("c-n", eager=True)
|
|
67
|
+
def next_model(event):
|
|
68
|
+
self.move(1)
|
|
69
|
+
|
|
70
|
+
# The same paging keys as every other popup, moving the selection.
|
|
71
|
+
@keys.add("pageup", eager=True)
|
|
72
|
+
def previous_page(event):
|
|
73
|
+
self.move(-self.page())
|
|
74
|
+
|
|
75
|
+
@keys.add("pagedown", eager=True)
|
|
76
|
+
def next_page(event):
|
|
77
|
+
self.move(self.page())
|
|
78
|
+
|
|
79
|
+
@keys.add("c-u", eager=True)
|
|
80
|
+
def previous_half(event):
|
|
81
|
+
self.move(-max(1, (self.page() + 1) // 2))
|
|
82
|
+
|
|
83
|
+
@keys.add("c-d", eager=True)
|
|
84
|
+
def next_half(event):
|
|
85
|
+
self.move(max(1, (self.page() + 1) // 2))
|
|
86
|
+
|
|
87
|
+
@keys.add("enter", eager=True)
|
|
88
|
+
def accept(event):
|
|
89
|
+
if self.matches:
|
|
90
|
+
event.app.exit(result=self.matches[self.selected])
|
|
91
|
+
|
|
92
|
+
@keys.add("escape", eager=True)
|
|
93
|
+
@keys.add("c-c")
|
|
94
|
+
@keys.add("c-l")
|
|
95
|
+
def cancel(event):
|
|
96
|
+
event.app.exit(result=None)
|
|
97
|
+
|
|
98
|
+
dialog = Dialog(
|
|
99
|
+
title="Choose model",
|
|
100
|
+
body=HSplit(
|
|
101
|
+
[
|
|
102
|
+
self.search,
|
|
103
|
+
self.rows,
|
|
104
|
+
Label(
|
|
105
|
+
"Type provider:model-id for a custom model.",
|
|
106
|
+
dont_extend_height=True,
|
|
107
|
+
),
|
|
108
|
+
Label(shortcuts.summary, dont_extend_height=True),
|
|
109
|
+
],
|
|
110
|
+
padding=1,
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
self.app = Application(
|
|
114
|
+
layout=Layout(popup_container(dialog, shortcuts), focused_element=self.search),
|
|
115
|
+
key_bindings=shortcuts.key_bindings(keys),
|
|
116
|
+
full_screen=True,
|
|
117
|
+
input=input,
|
|
118
|
+
output=output,
|
|
119
|
+
style=popup_style(style),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def move(self, rows: int) -> None:
|
|
123
|
+
self.selected = max(0, min(len(self.matches) - 1, self.selected + rows))
|
|
124
|
+
|
|
125
|
+
def page(self) -> int:
|
|
126
|
+
"""Rows actually visible, falling back before the first render."""
|
|
127
|
+
info = self.rows.render_info
|
|
128
|
+
return max(1, info.window_height - 1) if info is not None else 10
|
|
129
|
+
|
|
130
|
+
def filter(self, buffer):
|
|
131
|
+
query = buffer.text.strip()
|
|
132
|
+
terms = query.casefold().split()
|
|
133
|
+
self.matches = [name for name in self.models if all(matches_model(t, name) for t in terms)]
|
|
134
|
+
provider, _, name = query.partition(":")
|
|
135
|
+
# Any supported provider, not only configured ones: the key may be
|
|
136
|
+
# exported after launch, and infer_model reports a missing key clearly.
|
|
137
|
+
if (
|
|
138
|
+
provider in PROVIDERS
|
|
139
|
+
and name
|
|
140
|
+
and re.fullmatch(r"[A-Za-z0-9_.:/-]+", name)
|
|
141
|
+
and query not in self.matches
|
|
142
|
+
):
|
|
143
|
+
self.matches.insert(0, query)
|
|
144
|
+
self.selected = 0
|
|
145
|
+
|
|
146
|
+
def fragments(self):
|
|
147
|
+
if not self.matches:
|
|
148
|
+
return [("class:plan", "No matches. Try another filter or provider:model-id.")]
|
|
149
|
+
result = []
|
|
150
|
+
for index, name in enumerate(self.matches):
|
|
151
|
+
provider, _, model = name.partition(":")
|
|
152
|
+
marker = " · current" if name == self.current else ""
|
|
153
|
+
if name not in self.models:
|
|
154
|
+
marker += " · custom"
|
|
155
|
+
style = "class:selected" if index == self.selected else ""
|
|
156
|
+
result.append(
|
|
157
|
+
(
|
|
158
|
+
style,
|
|
159
|
+
f"{'›' if index == self.selected else ' '} "
|
|
160
|
+
f"{PROVIDERS.get(provider, provider)} · {model}{marker}",
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
if index < len(self.matches) - 1:
|
|
164
|
+
result.append(("", "\n"))
|
|
165
|
+
return result
|
|
166
|
+
|
|
167
|
+
async def run(self):
|
|
168
|
+
return await self.app.run_async()
|