kiwimatecoder 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.
- kiwimatecoder/__init__.py +8 -0
- kiwimatecoder/__main__.py +12 -0
- kiwimatecoder/acp/__init__.py +13 -0
- kiwimatecoder/acp/protocol.py +115 -0
- kiwimatecoder/acp/server.py +823 -0
- kiwimatecoder/acp/stdio.py +32 -0
- kiwimatecoder/agent.py +1162 -0
- kiwimatecoder/ai.py +68 -0
- kiwimatecoder/audit.py +65 -0
- kiwimatecoder/browser.py +261 -0
- kiwimatecoder/catalog.py +445 -0
- kiwimatecoder/checkpoints.py +103 -0
- kiwimatecoder/client.py +820 -0
- kiwimatecoder/clipboard.py +99 -0
- kiwimatecoder/commands.py +6757 -0
- kiwimatecoder/config.py +5335 -0
- kiwimatecoder/diagnostics.py +295 -0
- kiwimatecoder/documents.py +299 -0
- kiwimatecoder/evals/__init__.py +39 -0
- kiwimatecoder/evals/cases.py +230 -0
- kiwimatecoder/evals/render.py +46 -0
- kiwimatecoder/evals/runner.py +319 -0
- kiwimatecoder/events.py +95 -0
- kiwimatecoder/forge.py +172 -0
- kiwimatecoder/git.py +164 -0
- kiwimatecoder/headless.py +255 -0
- kiwimatecoder/hooks.py +170 -0
- kiwimatecoder/hunks.py +260 -0
- kiwimatecoder/i18n.py +368 -0
- kiwimatecoder/images.py +195 -0
- kiwimatecoder/index/__init__.py +14 -0
- kiwimatecoder/index/builder.py +376 -0
- kiwimatecoder/index/embeddings.py +151 -0
- kiwimatecoder/index/search.py +247 -0
- kiwimatecoder/index/store.py +251 -0
- kiwimatecoder/instructions.py +83 -0
- kiwimatecoder/jobs.py +594 -0
- kiwimatecoder/lsp/__init__.py +56 -0
- kiwimatecoder/lsp/client.py +548 -0
- kiwimatecoder/lsp/manager.py +353 -0
- kiwimatecoder/lsp/protocol.py +79 -0
- kiwimatecoder/main.py +4258 -0
- kiwimatecoder/mcp/__init__.py +45 -0
- kiwimatecoder/mcp/client.py +385 -0
- kiwimatecoder/mcp/errors.py +7 -0
- kiwimatecoder/mcp/manager.py +253 -0
- kiwimatecoder/mcp/protocol.py +132 -0
- kiwimatecoder/media.py +904 -0
- kiwimatecoder/memory.py +141 -0
- kiwimatecoder/network.py +112 -0
- kiwimatecoder/notify.py +139 -0
- kiwimatecoder/permissions.py +171 -0
- kiwimatecoder/pickers.py +417 -0
- kiwimatecoder/plugins.py +245 -0
- kiwimatecoder/pricing.py +167 -0
- kiwimatecoder/prompts.py +213 -0
- kiwimatecoder/providers.py +342 -0
- kiwimatecoder/redaction.py +66 -0
- kiwimatecoder/remote.py +294 -0
- kiwimatecoder/repl.py +1388 -0
- kiwimatecoder/routing.py +65 -0
- kiwimatecoder/sandbox.py +169 -0
- kiwimatecoder/sdk.py +146 -0
- kiwimatecoder/session.py +574 -0
- kiwimatecoder/share.py +386 -0
- kiwimatecoder/shell.py +660 -0
- kiwimatecoder/skills.py +138 -0
- kiwimatecoder/sync.py +513 -0
- kiwimatecoder/team.py +249 -0
- kiwimatecoder/telemetry.py +392 -0
- kiwimatecoder/templates.py +108 -0
- kiwimatecoder/tools/__init__.py +190 -0
- kiwimatecoder/tools/ask.py +62 -0
- kiwimatecoder/tools/base.py +69 -0
- kiwimatecoder/tools/browser.py +261 -0
- kiwimatecoder/tools/edit_file.py +143 -0
- kiwimatecoder/tools/forge.py +207 -0
- kiwimatecoder/tools/git.py +158 -0
- kiwimatecoder/tools/image.py +300 -0
- kiwimatecoder/tools/io.py +346 -0
- kiwimatecoder/tools/list_dir.py +67 -0
- kiwimatecoder/tools/lsp.py +254 -0
- kiwimatecoder/tools/memory.py +115 -0
- kiwimatecoder/tools/paths.py +234 -0
- kiwimatecoder/tools/read_file.py +113 -0
- kiwimatecoder/tools/registry.py +91 -0
- kiwimatecoder/tools/run_bash.py +189 -0
- kiwimatecoder/tools/search.py +224 -0
- kiwimatecoder/tools/selection.py +108 -0
- kiwimatecoder/tools/shell.py +158 -0
- kiwimatecoder/tools/skill.py +46 -0
- kiwimatecoder/tools/task.py +61 -0
- kiwimatecoder/tools/todo.py +77 -0
- kiwimatecoder/tools/web.py +146 -0
- kiwimatecoder/tools/write_file.py +83 -0
- kiwimatecoder/ui.py +189 -0
- kiwimatecoder/updater.py +502 -0
- kiwimatecoder/web.py +499 -0
- kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
- kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
- kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
- kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
- kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
- kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
kiwimatecoder/catalog.py
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""Live model catalogs fetched from provider APIs.
|
|
2
|
+
|
|
3
|
+
Model ids drift fast: providers ship new ones every few weeks and retire old
|
|
4
|
+
ones without warning. The curated tuples in :mod:`kiwimatecoder.providers` are
|
|
5
|
+
only a shipped-at-build-time starting point, so this module asks each provider
|
|
6
|
+
what it actually serves today via ``GET {base_url}/models`` and turns the answer
|
|
7
|
+
into the list ``/model`` offers.
|
|
8
|
+
|
|
9
|
+
Two rules shape the result:
|
|
10
|
+
|
|
11
|
+
* **New models are added.** Anything the provider lists is offered, ordered
|
|
12
|
+
newest-first so freshly released ids surface at the top.
|
|
13
|
+
* **Deprecated models are removed.** Only ids present in the live listing
|
|
14
|
+
survive, so a retired model disappears from ``/model`` on the next refresh
|
|
15
|
+
even if it is still hard-coded in the curated catalog.
|
|
16
|
+
|
|
17
|
+
Listings are filtered to models this CLI can actually drive: text chat models
|
|
18
|
+
that support tool calling. Embedding, audio, image, and moderation endpoints are
|
|
19
|
+
dropped by name, and provider metadata (OpenRouter's ``supported_parameters`` /
|
|
20
|
+
``architecture``, Mistral's ``capabilities``) is honored when present.
|
|
21
|
+
|
|
22
|
+
Nothing here touches disk; :mod:`kiwimatecoder.config` owns the cache that keeps
|
|
23
|
+
``/model`` from hitting the network on every invocation.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
from collections.abc import Iterable, Sequence
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from datetime import datetime
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
import httpx
|
|
35
|
+
|
|
36
|
+
from kiwimatecoder import network
|
|
37
|
+
from kiwimatecoder.providers import ProviderConfig
|
|
38
|
+
|
|
39
|
+
# A catalog older than this is refetched the next time the models are shown.
|
|
40
|
+
CATALOG_TTL_SECONDS = 24 * 60 * 60
|
|
41
|
+
# After a failed fetch, don't retry automatically for this long (an explicit
|
|
42
|
+
# `/model refresh` always retries). Keeps `/model` snappy when offline.
|
|
43
|
+
FAILURE_BACKOFF_SECONDS = 30 * 60
|
|
44
|
+
# Model listings are small; a short timeout keeps a hung provider from blocking
|
|
45
|
+
# the selector.
|
|
46
|
+
FETCH_TIMEOUT = 8.0
|
|
47
|
+
# Server-detection probes (setup wizard) should feel instant.
|
|
48
|
+
PROBE_TIMEOUT = 0.75
|
|
49
|
+
# Gateways such as OpenRouter serve hundreds of models. Offer the newest slice
|
|
50
|
+
# in the selector — any id can still be set by name with `/model <id>`.
|
|
51
|
+
MAX_CATALOG_MODELS = 60
|
|
52
|
+
ANTHROPIC_VERSION = "2023-06-01"
|
|
53
|
+
|
|
54
|
+
# Substrings that mark an id as something other than a text chat model. Matched
|
|
55
|
+
# case-insensitively against the model id.
|
|
56
|
+
NON_CHAT_MARKERS = (
|
|
57
|
+
"embed",
|
|
58
|
+
"rerank",
|
|
59
|
+
"moderation",
|
|
60
|
+
"guard",
|
|
61
|
+
"whisper",
|
|
62
|
+
"audio",
|
|
63
|
+
"speech",
|
|
64
|
+
"transcribe",
|
|
65
|
+
"voice",
|
|
66
|
+
"tts",
|
|
67
|
+
"dall-e",
|
|
68
|
+
"image",
|
|
69
|
+
"imagen",
|
|
70
|
+
"video",
|
|
71
|
+
"veo",
|
|
72
|
+
"sora",
|
|
73
|
+
"diffusion",
|
|
74
|
+
"flux",
|
|
75
|
+
"clip",
|
|
76
|
+
"ocr",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class CatalogFetchError(RuntimeError):
|
|
81
|
+
"""Raised when a provider's model listing cannot be retrieved or parsed."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class RemoteModel:
|
|
86
|
+
"""One model as reported by a provider's listing endpoint."""
|
|
87
|
+
|
|
88
|
+
id: str
|
|
89
|
+
created: float = 0.0 # epoch seconds; 0 when the provider reports no date
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class ModelCatalog:
|
|
94
|
+
"""The model list offered for a provider, plus how it was obtained.
|
|
95
|
+
|
|
96
|
+
``source`` is ``"live"`` (just fetched), ``"cache"`` (a previous fetch), or
|
|
97
|
+
``"curated"`` (the tuple shipped in the registry). ``added`` and ``removed``
|
|
98
|
+
are only populated for a live fetch and describe the change against the
|
|
99
|
+
previously known catalog — ``removed`` is the list of deprecated ids that
|
|
100
|
+
just dropped out of ``/model``.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
provider_id: str
|
|
104
|
+
models: list[str]
|
|
105
|
+
source: str
|
|
106
|
+
fetched_at: float | None = None
|
|
107
|
+
added: list[str] = field(default_factory=list)
|
|
108
|
+
removed: list[str] = field(default_factory=list)
|
|
109
|
+
error: str | None = None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def models_url(provider: ProviderConfig) -> str:
|
|
113
|
+
"""Return the listing endpoint for ``provider`` (with any api-version)."""
|
|
114
|
+
return provider.versioned_url(f"{provider.base_url.rstrip('/')}/models")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def request_headers(provider: ProviderConfig, api_key: str | None) -> dict[str, str]:
|
|
118
|
+
"""Build auth headers for a listing request.
|
|
119
|
+
|
|
120
|
+
Anthropic's native API wants ``x-api-key`` plus a version header; every
|
|
121
|
+
OpenAI-compatible provider uses its configured ``key_header``/``key_prefix``
|
|
122
|
+
(``Authorization: Bearer`` by default, ``api-key`` for Azure). Provider
|
|
123
|
+
``extra_headers`` are applied last so a custom provider can override
|
|
124
|
+
anything.
|
|
125
|
+
"""
|
|
126
|
+
headers = {"Accept": "application/json"}
|
|
127
|
+
if provider.compat == "anthropic":
|
|
128
|
+
headers["anthropic-version"] = ANTHROPIC_VERSION
|
|
129
|
+
if api_key:
|
|
130
|
+
headers["x-api-key"] = api_key
|
|
131
|
+
elif api_key:
|
|
132
|
+
headers[provider.key_header] = f"{provider.key_prefix}{api_key}"
|
|
133
|
+
headers.update(provider.extra_headers)
|
|
134
|
+
return headers
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def request_params(provider: ProviderConfig) -> dict[str, str | int]:
|
|
138
|
+
"""Return query params for the listing request."""
|
|
139
|
+
if provider.compat == "anthropic":
|
|
140
|
+
# Anthropic paginates at 20 by default; 1000 is the documented maximum
|
|
141
|
+
# and comfortably covers the whole catalog in one request.
|
|
142
|
+
return {"limit": 1000}
|
|
143
|
+
return {}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _parse_timestamp(value: object) -> float:
|
|
147
|
+
"""Coerce a provider timestamp (epoch seconds or ISO 8601) to a float."""
|
|
148
|
+
if isinstance(value, bool):
|
|
149
|
+
return 0.0
|
|
150
|
+
if isinstance(value, (int, float)):
|
|
151
|
+
return float(value)
|
|
152
|
+
if isinstance(value, str) and value.strip():
|
|
153
|
+
try:
|
|
154
|
+
return datetime.fromisoformat(value.strip().replace("Z", "+00:00")).timestamp()
|
|
155
|
+
except ValueError:
|
|
156
|
+
return 0.0
|
|
157
|
+
return 0.0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def normalize_model_id(raw: object) -> str:
|
|
161
|
+
"""Normalize a listed id to the form the chat endpoint expects.
|
|
162
|
+
|
|
163
|
+
Google's OpenAI-compatible listing returns ``models/gemini-...`` while its
|
|
164
|
+
chat endpoint expects the bare id, so the prefix is stripped.
|
|
165
|
+
"""
|
|
166
|
+
model_id = str(raw or "").strip()
|
|
167
|
+
if model_id.startswith("models/"):
|
|
168
|
+
model_id = model_id[len("models/") :]
|
|
169
|
+
return model_id
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# Name fragments that mark an id as a model of the given media kind; used only
|
|
173
|
+
# when the provider's listing has no modality metadata.
|
|
174
|
+
MEDIA_NAME_MARKERS: dict[str, tuple[str, ...]] = {
|
|
175
|
+
"image": ("image", "imagen", "dall-e", "flux", "diffusion"),
|
|
176
|
+
"video": ("video", "veo", "sora"),
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def is_chat_model(
|
|
181
|
+
entry: dict[str, Any], model_id: str, *, require_tools: bool = True
|
|
182
|
+
) -> bool:
|
|
183
|
+
"""Return whether a listed model is a text chat model this CLI can drive.
|
|
184
|
+
|
|
185
|
+
Provider metadata wins when present; otherwise the id is matched against
|
|
186
|
+
:data:`NON_CHAT_MARKERS`. Tool calling is required — the agent loop is built
|
|
187
|
+
on it — so a provider that reports ``supported_parameters`` without
|
|
188
|
+
``tools`` (OpenRouter does this for every entry) is filtered out, unless
|
|
189
|
+
``require_tools`` is off for a chat-only provider.
|
|
190
|
+
"""
|
|
191
|
+
lowered = model_id.lower()
|
|
192
|
+
if not lowered or any(marker in lowered for marker in NON_CHAT_MARKERS):
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
capabilities = entry.get("capabilities")
|
|
196
|
+
if isinstance(capabilities, dict):
|
|
197
|
+
chat = capabilities.get("completion_chat")
|
|
198
|
+
if chat is False:
|
|
199
|
+
return False
|
|
200
|
+
if require_tools and capabilities.get("function_calling") is False:
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
architecture = entry.get("architecture")
|
|
204
|
+
if isinstance(architecture, dict):
|
|
205
|
+
outputs = architecture.get("output_modalities")
|
|
206
|
+
if isinstance(outputs, list) and outputs and "text" not in outputs:
|
|
207
|
+
return False
|
|
208
|
+
inputs = architecture.get("input_modalities")
|
|
209
|
+
if isinstance(inputs, list) and inputs and "text" not in inputs:
|
|
210
|
+
return False
|
|
211
|
+
|
|
212
|
+
supported = entry.get("supported_parameters")
|
|
213
|
+
if (
|
|
214
|
+
require_tools
|
|
215
|
+
and isinstance(supported, list)
|
|
216
|
+
and supported
|
|
217
|
+
and "tools" not in supported
|
|
218
|
+
):
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
return True
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def is_media_model(entry: dict[str, Any], model_id: str, kind: str) -> bool:
|
|
225
|
+
"""Return whether a listed model produces ``kind`` ("image" or "video").
|
|
226
|
+
|
|
227
|
+
``architecture.output_modalities`` wins when the provider reports it
|
|
228
|
+
(OpenRouter does); otherwise the id is matched against
|
|
229
|
+
:data:`MEDIA_NAME_MARKERS`.
|
|
230
|
+
"""
|
|
231
|
+
lowered = model_id.lower()
|
|
232
|
+
if not lowered:
|
|
233
|
+
return False
|
|
234
|
+
architecture = entry.get("architecture")
|
|
235
|
+
if isinstance(architecture, dict):
|
|
236
|
+
outputs = architecture.get("output_modalities")
|
|
237
|
+
if isinstance(outputs, list) and outputs:
|
|
238
|
+
return kind in outputs
|
|
239
|
+
return any(marker in lowered for marker in MEDIA_NAME_MARKERS[kind])
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def parse_models_response(
|
|
243
|
+
payload: object, *, require_tools: bool = True, kind: str = "chat"
|
|
244
|
+
) -> list[RemoteModel]:
|
|
245
|
+
"""Extract usable models of ``kind`` (chat by default) from a provider's listing payload.
|
|
246
|
+
|
|
247
|
+
Accepts the OpenAI shape (``{"data": [...]}``), a bare list, or a
|
|
248
|
+
``{"models": [...]}`` variant, and tolerates entries that are plain strings.
|
|
249
|
+
Unusable entries are skipped rather than raising, so one odd record cannot
|
|
250
|
+
break the whole catalog. ``require_tools`` is passed to
|
|
251
|
+
:func:`is_chat_model`.
|
|
252
|
+
"""
|
|
253
|
+
if isinstance(payload, dict):
|
|
254
|
+
entries = payload.get("data")
|
|
255
|
+
if not isinstance(entries, list):
|
|
256
|
+
entries = payload.get("models")
|
|
257
|
+
else:
|
|
258
|
+
entries = payload
|
|
259
|
+
if not isinstance(entries, list):
|
|
260
|
+
return []
|
|
261
|
+
|
|
262
|
+
models: dict[str, RemoteModel] = {}
|
|
263
|
+
for raw_entry in entries:
|
|
264
|
+
if isinstance(raw_entry, str):
|
|
265
|
+
entry: dict[str, Any] = {"id": raw_entry}
|
|
266
|
+
elif isinstance(raw_entry, dict):
|
|
267
|
+
entry = raw_entry
|
|
268
|
+
else:
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
model_id = normalize_model_id(entry.get("id") or entry.get("name"))
|
|
272
|
+
if not model_id or model_id in models:
|
|
273
|
+
continue
|
|
274
|
+
if kind == "chat":
|
|
275
|
+
usable = is_chat_model(entry, model_id, require_tools=require_tools)
|
|
276
|
+
else:
|
|
277
|
+
usable = is_media_model(entry, model_id, kind)
|
|
278
|
+
if not usable:
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
created = _parse_timestamp(
|
|
282
|
+
entry.get("created")
|
|
283
|
+
if entry.get("created") is not None
|
|
284
|
+
else entry.get("created_at")
|
|
285
|
+
)
|
|
286
|
+
models[model_id] = RemoteModel(id=model_id, created=created)
|
|
287
|
+
|
|
288
|
+
return list(models.values())
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def fetch_models(
|
|
292
|
+
provider: ProviderConfig,
|
|
293
|
+
api_key: str | None = None,
|
|
294
|
+
*,
|
|
295
|
+
timeout: float = FETCH_TIMEOUT,
|
|
296
|
+
transport: httpx.BaseTransport | None = None,
|
|
297
|
+
kind: str = "chat",
|
|
298
|
+
) -> list[RemoteModel]:
|
|
299
|
+
"""Fetch and parse ``provider``'s live model listing.
|
|
300
|
+
|
|
301
|
+
Raises :class:`CatalogFetchError` for any transport, status, payload, or
|
|
302
|
+
"nothing usable came back" failure so callers have a single thing to catch.
|
|
303
|
+
``transport`` is an injection point for tests.
|
|
304
|
+
"""
|
|
305
|
+
if not provider.is_local and network.offline_enabled():
|
|
306
|
+
raise CatalogFetchError(
|
|
307
|
+
f"{provider.name}: {network.offline_message('model catalog fetch')}"
|
|
308
|
+
)
|
|
309
|
+
url = models_url(provider)
|
|
310
|
+
try:
|
|
311
|
+
with httpx.Client(
|
|
312
|
+
timeout=timeout,
|
|
313
|
+
transport=transport,
|
|
314
|
+
follow_redirects=True,
|
|
315
|
+
**network.current_options(),
|
|
316
|
+
) as client:
|
|
317
|
+
response = client.get(
|
|
318
|
+
url,
|
|
319
|
+
headers=request_headers(provider, api_key),
|
|
320
|
+
# ``params={}`` would drop an api-version already in the URL.
|
|
321
|
+
params=request_params(provider) or None,
|
|
322
|
+
)
|
|
323
|
+
except httpx.HTTPError as exc:
|
|
324
|
+
raise CatalogFetchError(f"{provider.name}: {exc}") from exc
|
|
325
|
+
|
|
326
|
+
if response.status_code != 200:
|
|
327
|
+
body = response.text[:200].replace("\n", " ").strip()
|
|
328
|
+
raise CatalogFetchError(
|
|
329
|
+
f"{provider.name} returned HTTP {response.status_code} for {url}"
|
|
330
|
+
+ (f": {body}" if body else "")
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
payload = response.json()
|
|
335
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
336
|
+
raise CatalogFetchError(
|
|
337
|
+
f"{provider.name} returned a non-JSON model list from {url}"
|
|
338
|
+
) from exc
|
|
339
|
+
|
|
340
|
+
models = parse_models_response(
|
|
341
|
+
payload, require_tools=provider.supports_tools, kind=kind
|
|
342
|
+
)
|
|
343
|
+
if not models:
|
|
344
|
+
raise CatalogFetchError(f"{provider.name} listed no usable {kind} models")
|
|
345
|
+
return models
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def probe(
|
|
349
|
+
provider: ProviderConfig,
|
|
350
|
+
*,
|
|
351
|
+
api_key: str | None = None,
|
|
352
|
+
timeout: float = PROBE_TIMEOUT,
|
|
353
|
+
transport: httpx.BaseTransport | None = None,
|
|
354
|
+
) -> bool:
|
|
355
|
+
"""Return whether ``provider``'s server answers a listing request right now.
|
|
356
|
+
|
|
357
|
+
Used to annotate local providers in the setup wizard; never raises. Any
|
|
358
|
+
200 counts as "running", even when no models are loaded yet — that is a
|
|
359
|
+
server the user can still pull models into. A 401 also counts as running
|
|
360
|
+
for providers that enforce auth (Unsloth): the server is up, only the key
|
|
361
|
+
is missing — and it may not exist yet when the wizard first probes.
|
|
362
|
+
|
|
363
|
+
With offline mode on, cloud providers are reported as not running without a
|
|
364
|
+
request; local servers are still probed so the setup wizard keeps working.
|
|
365
|
+
"""
|
|
366
|
+
if not provider.is_local and network.offline_enabled():
|
|
367
|
+
return False
|
|
368
|
+
try:
|
|
369
|
+
with httpx.Client(
|
|
370
|
+
timeout=timeout,
|
|
371
|
+
transport=transport,
|
|
372
|
+
follow_redirects=True,
|
|
373
|
+
**network.current_options(),
|
|
374
|
+
) as client:
|
|
375
|
+
response = client.get(
|
|
376
|
+
models_url(provider),
|
|
377
|
+
headers=request_headers(provider, api_key),
|
|
378
|
+
params=request_params(provider) or None,
|
|
379
|
+
)
|
|
380
|
+
except httpx.HTTPError:
|
|
381
|
+
return False
|
|
382
|
+
if response.status_code == 200:
|
|
383
|
+
return True
|
|
384
|
+
return response.status_code == 401 and provider.requires_key
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def search_models(models: Sequence[str], query: str) -> list[str]:
|
|
388
|
+
"""Filter ``models`` to ids whose name matches every token of ``query``.
|
|
389
|
+
|
|
390
|
+
Matching is a case-insensitive substring test per whitespace-separated
|
|
391
|
+
token, combined with AND — ``search claude sonnet`` matches
|
|
392
|
+
``anthropic/claude-sonnet-5``. An empty or blank query returns everything,
|
|
393
|
+
so callers can treat it as "no filter".
|
|
394
|
+
"""
|
|
395
|
+
tokens = [token for token in query.lower().split() if token]
|
|
396
|
+
if not tokens:
|
|
397
|
+
return list(models)
|
|
398
|
+
return [
|
|
399
|
+
model_id
|
|
400
|
+
for model_id in models
|
|
401
|
+
if all(token in model_id.lower() for token in tokens)
|
|
402
|
+
]
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def summarize_ids(model_ids: Sequence[str], limit: int = 8) -> str:
|
|
406
|
+
"""Join model ids for display, trimming long lists to ``limit`` entries.
|
|
407
|
+
|
|
408
|
+
A first refresh can add dozens of models at once; printing all of them
|
|
409
|
+
buries the part the user cares about.
|
|
410
|
+
"""
|
|
411
|
+
shown = ", ".join(model_ids[:limit])
|
|
412
|
+
extra = len(model_ids) - limit
|
|
413
|
+
return f"{shown}, +{extra} more" if extra > 0 else shown
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def merge_catalog(
|
|
417
|
+
provider: ProviderConfig,
|
|
418
|
+
remote: Iterable[RemoteModel],
|
|
419
|
+
*,
|
|
420
|
+
keep: Sequence[str] = (),
|
|
421
|
+
limit: int | None = MAX_CATALOG_MODELS,
|
|
422
|
+
) -> list[str]:
|
|
423
|
+
"""Turn a live listing into the catalog ``/model`` should offer.
|
|
424
|
+
|
|
425
|
+
Caller-supplied ``keep`` ids such as the session's current model come
|
|
426
|
+
first, then the rest newest-first. There is no default model to pin: the
|
|
427
|
+
user chooses. Only ids the provider still lists survive — that is how
|
|
428
|
+
deprecated models leave the catalog — so ``keep`` can pin a model but never
|
|
429
|
+
resurrect a retired one.
|
|
430
|
+
"""
|
|
431
|
+
ordered = [model.id for model in sorted(remote, key=lambda m: -m.created)]
|
|
432
|
+
live = set(ordered)
|
|
433
|
+
|
|
434
|
+
pinned = list(
|
|
435
|
+
dict.fromkeys(
|
|
436
|
+
model
|
|
437
|
+
for model in keep
|
|
438
|
+
if model and model in live
|
|
439
|
+
)
|
|
440
|
+
)
|
|
441
|
+
rest = [model for model in ordered if model not in set(pinned)]
|
|
442
|
+
catalog = pinned + rest
|
|
443
|
+
if limit is not None and limit > 0:
|
|
444
|
+
catalog = catalog[:limit]
|
|
445
|
+
return catalog
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Per-tool file snapshots backing ``/undo`` and ``/rewind``.
|
|
2
|
+
|
|
3
|
+
Before a mutating tool runs, the target files are copied into a per-session
|
|
4
|
+
temporary directory. A checkpoint records where each pre-change snapshot lives
|
|
5
|
+
(``None`` means the file did not exist yet, so undo removes it). Restoring is
|
|
6
|
+
best-effort and never raises on a single unreadable file.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import datetime
|
|
12
|
+
import shutil
|
|
13
|
+
import tempfile
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _resolve(relative: str, workspace_root: Path) -> Path | None:
|
|
19
|
+
"""Resolve inside the workspace without a circular import at module load."""
|
|
20
|
+
from kiwimatecoder.tools.paths import PathError, resolve_in_workspace
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
return resolve_in_workspace(relative, workspace_root)
|
|
24
|
+
except PathError:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Checkpoint:
|
|
30
|
+
"""A snapshot of files captured before one mutating tool call."""
|
|
31
|
+
|
|
32
|
+
id: int
|
|
33
|
+
label: str
|
|
34
|
+
created_at: str
|
|
35
|
+
# Workspace-relative path -> snapshot file path, or None when absent.
|
|
36
|
+
files: dict[str, str | None] = field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def paths(self) -> list[str]:
|
|
40
|
+
return list(self.files)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CheckpointStore:
|
|
44
|
+
"""Captures and restores file snapshots for one session."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, snapshot_root: Path | None = None) -> None:
|
|
47
|
+
self._root = snapshot_root or Path(
|
|
48
|
+
tempfile.mkdtemp(prefix="kiwimatecoder-checkpoints-")
|
|
49
|
+
)
|
|
50
|
+
self._counter = 0
|
|
51
|
+
|
|
52
|
+
def capture(
|
|
53
|
+
self, workspace_root: Path, paths: list[str], label: str
|
|
54
|
+
) -> Checkpoint:
|
|
55
|
+
"""Snapshot ``paths`` as they are right now and return the checkpoint."""
|
|
56
|
+
self._counter += 1
|
|
57
|
+
files: dict[str, str | None] = {}
|
|
58
|
+
for raw in paths:
|
|
59
|
+
relative = str(raw).strip()
|
|
60
|
+
if not relative or relative in files:
|
|
61
|
+
continue
|
|
62
|
+
target = _resolve(relative, workspace_root)
|
|
63
|
+
if target is None:
|
|
64
|
+
continue
|
|
65
|
+
if target.is_file():
|
|
66
|
+
dest = self._root / str(self._counter) / f"{len(files)}.snap"
|
|
67
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
try:
|
|
69
|
+
shutil.copyfile(target, dest)
|
|
70
|
+
except OSError:
|
|
71
|
+
continue
|
|
72
|
+
files[relative] = str(dest)
|
|
73
|
+
else:
|
|
74
|
+
files[relative] = None
|
|
75
|
+
return Checkpoint(
|
|
76
|
+
id=self._counter,
|
|
77
|
+
label=label,
|
|
78
|
+
created_at=datetime.datetime.now().isoformat(),
|
|
79
|
+
files=files,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def restore(self, workspace_root: Path, checkpoint: Checkpoint) -> list[str]:
|
|
83
|
+
"""Restore the checkpoint's files; returns the paths actually changed."""
|
|
84
|
+
restored: list[str] = []
|
|
85
|
+
for relative, snapshot in checkpoint.files.items():
|
|
86
|
+
target = _resolve(relative, workspace_root)
|
|
87
|
+
if target is None:
|
|
88
|
+
continue
|
|
89
|
+
if snapshot is None:
|
|
90
|
+
try:
|
|
91
|
+
if target.exists():
|
|
92
|
+
target.unlink()
|
|
93
|
+
restored.append(relative)
|
|
94
|
+
except OSError:
|
|
95
|
+
continue
|
|
96
|
+
else:
|
|
97
|
+
try:
|
|
98
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
shutil.copyfile(snapshot, target)
|
|
100
|
+
restored.append(relative)
|
|
101
|
+
except OSError:
|
|
102
|
+
continue
|
|
103
|
+
return restored
|