git-repokit 0.9.10__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.
- git_repokit-0.9.10.dist-info/METADATA +422 -0
- git_repokit-0.9.10.dist-info/RECORD +174 -0
- git_repokit-0.9.10.dist-info/WHEEL +5 -0
- git_repokit-0.9.10.dist-info/entry_points.txt +2 -0
- git_repokit-0.9.10.dist-info/licenses/LICENSE +9 -0
- git_repokit-0.9.10.dist-info/top_level.txt +1 -0
- repokit/__init__.py +8 -0
- repokit/__main__.py +12 -0
- repokit/__version__.py +4 -0
- repokit/_vendor/__init__.py +0 -0
- repokit/_vendor/ailib/__init__.py +1 -0
- repokit/_vendor/ailib/analyzer.py +177 -0
- repokit/_vendor/ailib/backend.py +95 -0
- repokit/_vendor/ailib/cache.py +97 -0
- repokit/_vendor/ailib/parsers.py +52 -0
- repokit/_vendor/ailib/transports/__init__.py +16 -0
- repokit/_vendor/ailib/transports/cli.py +312 -0
- repokit/_vendor/ailib/transports/openai_compat.py +383 -0
- repokit/_vendor/ailib/transports/prompt_file.py +41 -0
- repokit/_vendor/ailib/types.py +224 -0
- repokit/auth_integration.py +244 -0
- repokit/badges.py +183 -0
- repokit/branch_utils.py +364 -0
- repokit/cli.py +2997 -0
- repokit/common_kit.py +202 -0
- repokit/config.py +686 -0
- repokit/defaults.py +220 -0
- repokit/directory_analyzer.py +1244 -0
- repokit/directory_profiles.py +423 -0
- repokit/git_config.py +370 -0
- repokit/github_create.py +142 -0
- repokit/github_setup.py +248 -0
- repokit/guardrails.py +707 -0
- repokit/history_cleaner.py +449 -0
- repokit/history_protection.py +434 -0
- repokit/hooks/pre-commit-guardrails +122 -0
- repokit/hooks/pre-commit-template +28 -0
- repokit/layers.py +510 -0
- repokit/licenses/0bsd.txt +14 -0
- repokit/licenses/LICENSES.md +27 -0
- repokit/licenses/__init__.py +142 -0
- repokit/licenses/agpl-3.0.txt +661 -0
- repokit/licenses/all-rights-reserved.txt +10 -0
- repokit/licenses/apache-2.0.txt +201 -0
- repokit/licenses/bsd-2-clause.txt +24 -0
- repokit/licenses/bsd-3-clause.txt +28 -0
- repokit/licenses/bsl-1.0.txt +23 -0
- repokit/licenses/cc-by-4.0.txt +395 -0
- repokit/licenses/cc-by-sa-4.0.txt +427 -0
- repokit/licenses/cc0-1.0.txt +121 -0
- repokit/licenses/epl-2.0.txt +277 -0
- repokit/licenses/gpl-2.0.txt +339 -0
- repokit/licenses/gpl-3.0.txt +674 -0
- repokit/licenses/isc.txt +15 -0
- repokit/licenses/lgpl-2.1.txt +504 -0
- repokit/licenses/lgpl-3.0.txt +165 -0
- repokit/licenses/mit.txt +21 -0
- repokit/licenses/mpl-2.0.txt +373 -0
- repokit/licenses/unlicense.txt +24 -0
- repokit/readme_blocks.py +112 -0
- repokit/remote_integration.py +627 -0
- repokit/repo_manager.py +2796 -0
- repokit/template_engine.py +391 -0
- repokit/templates/ai/claude/CLAUDE-nested.md.template +25 -0
- repokit/templates/ai/claude/CLAUDE.md.template +98 -0
- repokit/templates/ai/claude/instructions/step1_context_rebuilder.md +33 -0
- repokit/templates/ai/claude/instructions/step2_dev_workflow_process.md +67 -0
- repokit/templates/ai/claude/instructions/step3_context_bridge.md +52 -0
- repokit/templates/common/CHANGELOG.md.template +15 -0
- repokit/templates/common/CONTRIBUTING.md.template +31 -0
- repokit/templates/common/README.md.template +30 -0
- repokit/templates/common/gitattributes.template +17 -0
- repokit/templates/common/gitignore.template +141 -0
- repokit/templates/extras/FUNDING.yml.template +13 -0
- repokit/templates/extras/ROADMAP.md.template +17 -0
- repokit/templates/extras/dependabot.yml.template +22 -0
- repokit/templates/extras/one-offs-README.md.template +28 -0
- repokit/templates/extras/platform-support.md.template +11 -0
- repokit/templates/extras/pull_request_template.md.template +25 -0
- repokit/templates/extras/release-template.md.template +34 -0
- repokit/templates/extras/settings.json.template +11 -0
- repokit/templates/extras/stale.yml.template +14 -0
- repokit/templates/github/CODEOWNERS.template +11 -0
- repokit/templates/github/ISSUE_TEMPLATE/bug-report.md.template +29 -0
- repokit/templates/github/ISSUE_TEMPLATE/feature-request.md.template +19 -0
- repokit/templates/github/issues/notes.md.template +18 -0
- repokit/templates/github/issues/roadmap.md.template +15 -0
- repokit/templates/hooks/pre-commit.template +64 -0
- repokit/templates/layers/archetypes/any/claude-plugin/.claude-plugin/marketplace.json +20 -0
- repokit/templates/layers/archetypes/any/claude-plugin/plugin/.claude-plugin/plugin.json +15 -0
- repokit/templates/layers/archetypes/any/claude-plugin/plugin/README.md +28 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/.github/workflows/ci-c-cpp.yml +24 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/.repokit-common.toml.rkappend +4 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/.vscode/launch.json +29 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/.vscode/tasks.json +26 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/CMakeLists.txt +47 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/CMakePresets.json +23 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/cmake/GenerateStamp.cmake +45 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/src/main.cpp +27 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/tests/test_version.cpp +20 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/vcpkg.json +5 -0
- repokit/templates/layers/archetypes/c-cpp/minimal/.vscode/launch.json +27 -0
- repokit/templates/layers/archetypes/c-cpp/minimal/Makefile +10 -0
- repokit/templates/layers/archetypes/c-cpp/minimal/build.bat +7 -0
- repokit/templates/layers/archetypes/c-cpp/minimal/src/__RK_SNAKE__.c +26 -0
- repokit/templates/layers/archetypes/c-cpp/msbuild/.vscode/launch.json +17 -0
- repokit/templates/layers/archetypes/c-cpp/msbuild/.vscode/tasks.json +20 -0
- repokit/templates/layers/archetypes/c-cpp/msbuild/__RK_PROJECT__.sln +19 -0
- repokit/templates/layers/archetypes/c-cpp/msbuild/__RK_PROJECT__.vcxproj +41 -0
- repokit/templates/layers/archetypes/c-cpp/msbuild/src/main.cpp +26 -0
- repokit/templates/layers/archetypes/js-ts/claude-plugin/.repokit-common.toml +28 -0
- repokit/templates/layers/archetypes/js-ts/claude-plugin/.vscode/launch.json +34 -0
- repokit/templates/layers/archetypes/js-ts/claude-plugin/plugin/hooks/hooks.json +15 -0
- repokit/templates/layers/archetypes/js-ts/claude-plugin/plugin/hooks/scripts/session-start.mjs +23 -0
- repokit/templates/layers/archetypes/js-ts/claude-plugin/plugin/tests/manifest.test.js +30 -0
- repokit/templates/layers/archetypes/js-ts/node/.repokit-common.toml.rkappend +6 -0
- repokit/templates/layers/archetypes/js-ts/node/package.json +13 -0
- repokit/templates/layers/archetypes/js-ts/node-package/.github/workflows/release.yml +39 -0
- repokit/templates/layers/archetypes/js-ts/node-package/package.json +32 -0
- repokit/templates/layers/archetypes/js-ts/node-package/src/index.ts +13 -0
- repokit/templates/layers/archetypes/js-ts/node-package/tests/index.test.ts +11 -0
- repokit/templates/layers/archetypes/js-ts/script/.vscode/launch.json +34 -0
- repokit/templates/layers/archetypes/js-ts/script/__RK_PROJECT__.js +37 -0
- repokit/templates/layers/archetypes/js-ts/script/tests/version.test.js +26 -0
- repokit/templates/layers/archetypes/js-ts/ts/package.json +18 -0
- repokit/templates/layers/archetypes/js-ts/ts/tsconfig.json +15 -0
- repokit/templates/layers/archetypes/python/claude-plugin/.github/workflows/ci-python.yml +44 -0
- repokit/templates/layers/archetypes/python/claude-plugin/.repokit-common.toml +21 -0
- repokit/templates/layers/archetypes/python/claude-plugin/.vscode/launch.json +33 -0
- repokit/templates/layers/archetypes/python/claude-plugin/VERSION +1 -0
- repokit/templates/layers/archetypes/python/claude-plugin/plugin/hooks/hooks.json +15 -0
- repokit/templates/layers/archetypes/python/claude-plugin/plugin/hooks/scripts/run-hook.mjs +42 -0
- repokit/templates/layers/archetypes/python/claude-plugin/plugin/hooks/scripts/session_start.py +31 -0
- repokit/templates/layers/archetypes/python/claude-plugin/plugin/tests/test_manifest.py +42 -0
- repokit/templates/layers/archetypes/python/pypi/.github/workflows/release.yml +63 -0
- repokit/templates/layers/archetypes/python/pypi/.vscode/launch.json +43 -0
- repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/__init__.py +5 -0
- repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/__main__.py +6 -0
- repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/_version.py +96 -0
- repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/cli.py +18 -0
- repokit/templates/layers/archetypes/python/pypi/pyproject.toml +71 -0
- repokit/templates/layers/archetypes/python/pypi/tests/conftest.py +11 -0
- repokit/templates/layers/archetypes/python/pypi/tests/test_version.py +50 -0
- repokit/templates/layers/archetypes/python/script/.github/workflows/ci-python.yml +43 -0
- repokit/templates/layers/archetypes/python/script/.repokit-common.toml +14 -0
- repokit/templates/layers/archetypes/python/script/.vscode/launch.json +33 -0
- repokit/templates/layers/archetypes/python/script/VERSION +1 -0
- repokit/templates/layers/archetypes/python/script/__RK_PROJECT__.py +40 -0
- repokit/templates/layers/archetypes/python/script/tests/test_version.py +25 -0
- repokit/templates/layers/archetypes/python/setuppy/requirements.txt +8 -0
- repokit/templates/layers/archetypes/python/setuppy/setup.py +23 -0
- repokit/templates/layers/languages/c-cpp/.gitignore.rkappend +20 -0
- repokit/templates/layers/languages/c-cpp/.repokit-common.toml +7 -0
- repokit/templates/layers/languages/c-cpp/include/__RK_SNAKE__/version.h +20 -0
- repokit/templates/layers/languages/c-cpp/src/app.rc +25 -0
- repokit/templates/layers/languages/js-ts/.editorconfig +15 -0
- repokit/templates/layers/languages/js-ts/.github/workflows/ci-js-ts.yml +41 -0
- repokit/templates/layers/languages/js-ts/.gitignore.rkappend +13 -0
- repokit/templates/layers/languages/js-ts/.repokit-common.toml +19 -0
- repokit/templates/layers/languages/js-ts/.vscode/launch.json +24 -0
- repokit/templates/layers/languages/js-ts/VERSION +1 -0
- repokit/templates/layers/languages/python/.github/workflows/ci-python.yml +76 -0
- repokit/templates/layers/languages/python/.gitignore.rkappend +15 -0
- repokit/templates/layers/languages/python/.vscode/launch.json +33 -0
- repokit/templates/layers/manifest.toml +302 -0
- repokit/templates/vault/CLAUDE.md +29 -0
- repokit/templates/vault/claude/_maps/MOC-project.md +15 -0
- repokit/templates/vault/claude/_oracle/backlinks.md +3 -0
- repokit/templates/vault/claude/_oracle/concepts.md +12 -0
- repokit/templates/vault/claude/_oracle/manifest.md +10 -0
- repokit/templates/vault/claude/_todo/ACTIVE-WORK.md +18 -0
- repokit/templates/vault/claude/_todo/OPEN-LOOPS.md +14 -0
- repokit/utils.py +316 -0
- repokit/vault.py +248 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""The OpenAI-compatible HTTP transport: LM Studio, Ollama and llama.cpp on
|
|
2
|
+
this machine; OpenAI, OpenRouter, Groq, Together and the Llama endpoints
|
|
3
|
+
over the wire. One adapter. Local versus remote is the spec's endpoint and
|
|
4
|
+
whether it names a credential; nothing in the code knows the difference.
|
|
5
|
+
|
|
6
|
+
Stdlib only (`urllib`): no `requests`, no `openai` package.
|
|
7
|
+
|
|
8
|
+
**Everything unusual below is a measured fact, not a precaution**, taken
|
|
9
|
+
against LM Studio on real hardware while this transport's predecessor was
|
|
10
|
+
built (2026-09-04):
|
|
11
|
+
|
|
12
|
+
* ``response_format: {"type": "json_object"}`` is answered **HTTP 400** --
|
|
13
|
+
unsupported -- while ``json_schema`` with ``strict`` works. So a strict
|
|
14
|
+
schema is the only structured-output route here. The schema itself is the
|
|
15
|
+
caller's: it arrives in the Request, and without one no shape is asked for.
|
|
16
|
+
* ``reasoning_effort: "none"`` is the only thinking-suppression that reaches
|
|
17
|
+
the model through this API; it is a preset's business (`Spec.extra`), not
|
|
18
|
+
the transport's, because a hosted provider may reject it.
|
|
19
|
+
* **HTTP 200 with empty content is possible** -- with reasoning on and a low
|
|
20
|
+
token ceiling, every token went to reasoning and ``content`` came back
|
|
21
|
+
``''``. An empty body is a failure here, with a sentence.
|
|
22
|
+
* **Asking for a model that is not loaded makes a local server load it** --
|
|
23
|
+
17.74 GB off disk in the measured case -- so the id is checked before
|
|
24
|
+
anything is sent. ``/v1/models`` lists everything DOWNLOADED, not what is
|
|
25
|
+
in memory (13 against 1, measured); load state comes from LM Studio's own
|
|
26
|
+
``/api/v0/models``, which hangs off the server ROOT, not off ``/v1``. A
|
|
27
|
+
server without that endpoint (Ollama, llama.cpp, every hosted provider)
|
|
28
|
+
answers "cannot say", a third value that must never be read as "nothing".
|
|
29
|
+
* **A client timeout does not cancel server-side work**, so there is no
|
|
30
|
+
retry in here; the caller re-runs if it wants one. An HTTP error is
|
|
31
|
+
reported with the server's own words and is not retried without the
|
|
32
|
+
schema either -- a provider that rejects ``response_format`` for a routed
|
|
33
|
+
model has said something the caller needs to hear.
|
|
34
|
+
* **Throughput is dominated by the configured context window, not by the
|
|
35
|
+
model** (~385x between an oversized and a right-sized window on the same
|
|
36
|
+
model). The probe reports the loaded window and warns above a threshold,
|
|
37
|
+
so a caller can say so BEFORE a long job.
|
|
38
|
+
|
|
39
|
+
**The credential** (2026-09-09) comes from one of three places, read in a
|
|
40
|
+
fixed order at request time: a file the spec names on purpose
|
|
41
|
+
(`credential_file`), then the environment variable it names
|
|
42
|
+
(`credential_env`), then fallback files (`credential_fallbacks`) -- a
|
|
43
|
+
tool's default key location, read only when the environment has nothing.
|
|
44
|
+
The spec holds paths and a name, never the value; the value is read here,
|
|
45
|
+
put in one header, and appears in no identity, record or sentence. A
|
|
46
|
+
missing key is a sentence naming the variable AND the files, so a person
|
|
47
|
+
knows where to put it. The file format is `NAME=value` lines (an `export `
|
|
48
|
+
prefix, surrounding quotes, `#` comments and blank lines tolerated, CRLF and
|
|
49
|
+
a BOM too); a file whose only non-comment line has no `=` is the value
|
|
50
|
+
itself. On POSIX a key file readable by group or others earns a warning.
|
|
51
|
+
"""
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import ipaddress
|
|
55
|
+
import json
|
|
56
|
+
import os
|
|
57
|
+
import stat
|
|
58
|
+
import time
|
|
59
|
+
import urllib.error
|
|
60
|
+
import urllib.request
|
|
61
|
+
from urllib.parse import urlparse
|
|
62
|
+
|
|
63
|
+
from ..types import MODEL, ON_PREM, SCHEMA, Readiness, Request, Response, Spec
|
|
64
|
+
|
|
65
|
+
#: How long to wait on a listing. If reachability takes longer than this the
|
|
66
|
+
#: server is not in a state to work with.
|
|
67
|
+
PROBE_TIMEOUT = 4
|
|
68
|
+
|
|
69
|
+
#: A key file is a line or a handful; anything larger is not one, and is
|
|
70
|
+
#: not read further.
|
|
71
|
+
KEY_FILE_CAP = 65536
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _unquote(value: str) -> str:
|
|
75
|
+
v = value.strip()
|
|
76
|
+
if len(v) >= 2 and v[0] == v[-1] and v[0] in ("'", '"'):
|
|
77
|
+
return v[1:-1]
|
|
78
|
+
return v
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _read_key_file(path: str, name: str) -> str:
|
|
82
|
+
"""The value of `name` in the key file at `path`, or "" when the file
|
|
83
|
+
does not exist, cannot be read, or has no such line.
|
|
84
|
+
|
|
85
|
+
`NAME=value` lines; `export NAME=value` too; surrounding quotes, `#`
|
|
86
|
+
comments and blank lines tolerated; a BOM and CRLF as well (`utf-8-sig`
|
|
87
|
+
and `splitlines`). When the file has exactly one non-comment line and
|
|
88
|
+
that line has no `=`, it is the value itself -- so a file that holds
|
|
89
|
+
only the token works, and a multi-line file without the name never
|
|
90
|
+
yields a guess. An empty value reads as absent."""
|
|
91
|
+
try:
|
|
92
|
+
with open(os.path.expanduser(path), encoding="utf-8-sig") as f:
|
|
93
|
+
text = f.read(KEY_FILE_CAP)
|
|
94
|
+
except (OSError, ValueError):
|
|
95
|
+
return ""
|
|
96
|
+
lines = [ln.strip() for ln in text.splitlines()]
|
|
97
|
+
lines = [ln for ln in lines if ln and not ln.startswith("#")]
|
|
98
|
+
for line in lines:
|
|
99
|
+
if line.startswith("export "):
|
|
100
|
+
line = line[len("export "):].lstrip()
|
|
101
|
+
key, sep, value = line.partition("=")
|
|
102
|
+
if sep and name and key.strip() == name:
|
|
103
|
+
return _unquote(value)
|
|
104
|
+
if len(lines) == 1 and "=" not in lines[0]:
|
|
105
|
+
return _unquote(lines[0])
|
|
106
|
+
return ""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _loose_mode(mode: int) -> bool:
|
|
110
|
+
"""Whether a file mode lets group or others read it."""
|
|
111
|
+
return bool(stat.S_IMODE(mode) & 0o077)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _permission_warning(path: str) -> str:
|
|
115
|
+
"""POSIX only: a key file readable by group or others. Windows ACLs are
|
|
116
|
+
not a mode, so nothing is said there."""
|
|
117
|
+
if os.name != "posix":
|
|
118
|
+
return ""
|
|
119
|
+
try:
|
|
120
|
+
mode = os.stat(os.path.expanduser(path)).st_mode
|
|
121
|
+
except OSError:
|
|
122
|
+
return ""
|
|
123
|
+
if _loose_mode(mode):
|
|
124
|
+
return f"{path} is readable by others (mode {stat.S_IMODE(mode):o}); chmod 600 it"
|
|
125
|
+
return ""
|
|
126
|
+
|
|
127
|
+
#: Above this many tokens the loaded window is far larger than a short
|
|
128
|
+
#: structured task needs, and on a machine whose VRAM the KV cache fills it
|
|
129
|
+
#: is the difference between seconds and minutes.
|
|
130
|
+
ROOMY_CONTEXT = 65536
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def endpoint_is_on_prem(url: str) -> bool | None:
|
|
134
|
+
"""Whether a request to `url` stays on the user's own network, decided
|
|
135
|
+
without a DNS lookup: an IP literal that is loopback, private or
|
|
136
|
+
link-local; `localhost`; a `.local` name. A public host name is not.
|
|
137
|
+
None when the URL has no host at all."""
|
|
138
|
+
host = urlparse(url).hostname
|
|
139
|
+
if not host:
|
|
140
|
+
return None
|
|
141
|
+
if host == "localhost" or host.endswith(".local"):
|
|
142
|
+
return True
|
|
143
|
+
try:
|
|
144
|
+
ip = ipaddress.ip_address(host)
|
|
145
|
+
except ValueError:
|
|
146
|
+
return False
|
|
147
|
+
return bool(ip.is_loopback or ip.is_private or ip.is_link_local)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class OpenAICompatible:
|
|
151
|
+
"""Stateless: every method takes the spec it is working for."""
|
|
152
|
+
|
|
153
|
+
# -- plumbing ----------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _credential(spec: Spec) -> tuple[str, str]:
|
|
157
|
+
"""(the bearer token, where it came from) -- ("", "") when there is
|
|
158
|
+
none. The order is fixed: the file named on purpose, then the
|
|
159
|
+
environment, then the fallback files. The source is a path or the
|
|
160
|
+
variable's name, never the value, so a probe may print it."""
|
|
161
|
+
name = spec.credential_env
|
|
162
|
+
if spec.credential_file:
|
|
163
|
+
value = _read_key_file(spec.credential_file, name)
|
|
164
|
+
if value:
|
|
165
|
+
return value, spec.credential_file
|
|
166
|
+
if name:
|
|
167
|
+
value = os.environ.get(name, "")
|
|
168
|
+
if value:
|
|
169
|
+
return value, name
|
|
170
|
+
for path in spec.credential_fallbacks:
|
|
171
|
+
value = _read_key_file(path, name)
|
|
172
|
+
if value:
|
|
173
|
+
return value, path
|
|
174
|
+
return "", ""
|
|
175
|
+
|
|
176
|
+
@staticmethod
|
|
177
|
+
def _missing_key(spec: Spec) -> str:
|
|
178
|
+
"""The sentence for a spec that names a credential and has none:
|
|
179
|
+
the variable and every file, each with why it gave nothing."""
|
|
180
|
+
who = spec.name or spec.endpoint.rstrip("/")
|
|
181
|
+
name = spec.credential_env
|
|
182
|
+
files = [p for p in (spec.credential_file, *spec.credential_fallbacks) if p]
|
|
183
|
+
if not files:
|
|
184
|
+
return f"{name} is not set in the environment -- {who} needs a key there"
|
|
185
|
+
why = []
|
|
186
|
+
for p in files:
|
|
187
|
+
if os.path.isfile(os.path.expanduser(p)):
|
|
188
|
+
why.append(f"{p} has no {name} line" if name else f"{p} is empty")
|
|
189
|
+
else:
|
|
190
|
+
why.append(f"{p} does not exist")
|
|
191
|
+
if not name:
|
|
192
|
+
return f"{' and '.join(why)} -- {who} needs a key there"
|
|
193
|
+
return (f"{name} is not set in the environment and {' and '.join(why)} -- "
|
|
194
|
+
f"{who} needs a key in one of them")
|
|
195
|
+
|
|
196
|
+
def _headers(self, spec: Spec, *, post: bool = False) -> dict[str, str]:
|
|
197
|
+
h = {"Accept": "application/json"}
|
|
198
|
+
if post:
|
|
199
|
+
h["Content-Type"] = "application/json"
|
|
200
|
+
value, _ = self._credential(spec)
|
|
201
|
+
if value:
|
|
202
|
+
h["Authorization"] = f"Bearer {value}"
|
|
203
|
+
return h
|
|
204
|
+
|
|
205
|
+
def _get(self, spec: Spec, url: str, timeout: int = PROBE_TIMEOUT):
|
|
206
|
+
req = urllib.request.Request(url, headers=self._headers(spec))
|
|
207
|
+
with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310
|
|
208
|
+
return json.loads(r.read().decode("utf-8", "replace"))
|
|
209
|
+
|
|
210
|
+
@staticmethod
|
|
211
|
+
def _root(spec: Spec) -> str:
|
|
212
|
+
ep = spec.endpoint.rstrip("/")
|
|
213
|
+
return ep[:-3] if ep.endswith("/v1") else ep
|
|
214
|
+
|
|
215
|
+
# -- the roster ----------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
def models(self, spec: Spec) -> list[str] | None:
|
|
218
|
+
"""Model ids the server reports; None when it cannot be reached (or
|
|
219
|
+
refuses the credential). Everything DOWNLOADED, not what is loaded."""
|
|
220
|
+
try:
|
|
221
|
+
body = self._get(spec, spec.endpoint.rstrip("/") + "/models")
|
|
222
|
+
except (urllib.error.URLError, OSError, ValueError, TimeoutError):
|
|
223
|
+
return None
|
|
224
|
+
data = body.get("data") if isinstance(body, dict) else None
|
|
225
|
+
if not isinstance(data, list):
|
|
226
|
+
return None
|
|
227
|
+
return [d["id"] for d in data if isinstance(d, dict) and isinstance(d.get("id"), str)]
|
|
228
|
+
|
|
229
|
+
def _native_records(self, spec: Spec) -> list | None:
|
|
230
|
+
try:
|
|
231
|
+
body = self._get(spec, self._root(spec) + "/api/v0/models")
|
|
232
|
+
except (urllib.error.URLError, OSError, ValueError, TimeoutError):
|
|
233
|
+
return None
|
|
234
|
+
data = body.get("data") if isinstance(body, dict) else None
|
|
235
|
+
return data if isinstance(data, list) else None
|
|
236
|
+
|
|
237
|
+
def loaded_models(self, spec: Spec) -> list[str] | None:
|
|
238
|
+
"""Model ids actually in memory, or **None when the server cannot
|
|
239
|
+
say**. Three-valued on purpose."""
|
|
240
|
+
data = self._native_records(spec)
|
|
241
|
+
if data is None:
|
|
242
|
+
return None
|
|
243
|
+
return [d["id"] for d in data if isinstance(d, dict) and isinstance(d.get("id"), str)
|
|
244
|
+
and d.get("state") == "loaded"]
|
|
245
|
+
|
|
246
|
+
def context_length(self, spec: Spec) -> int | None:
|
|
247
|
+
data = self._native_records(spec)
|
|
248
|
+
if data is None:
|
|
249
|
+
return None
|
|
250
|
+
for d in data:
|
|
251
|
+
if isinstance(d, dict) and d.get("state") == "loaded":
|
|
252
|
+
n = d.get("loaded_context_length") or d.get("max_context_length")
|
|
253
|
+
return n if isinstance(n, int) else None
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
def _target(self, spec: Spec) -> tuple[str, str]:
|
|
257
|
+
"""(model to ask for, why not) -- "" in the second slot means go.
|
|
258
|
+
|
|
259
|
+
The order that avoids surprising a person: the spec's model if it is
|
|
260
|
+
loaded; else the loaded one when the server can say; else the first
|
|
261
|
+
listed, which is all a server without load state can offer."""
|
|
262
|
+
ep = spec.endpoint.rstrip("/")
|
|
263
|
+
if (spec.credential_env or spec.credential_file) and not self._credential(spec)[0]:
|
|
264
|
+
return "", self._missing_key(spec)
|
|
265
|
+
listed = self.models(spec)
|
|
266
|
+
if not listed:
|
|
267
|
+
hint = f" ({spec.hint})" if spec.hint else ""
|
|
268
|
+
return "", (f"no model server at {ep} -- start the local server{hint} "
|
|
269
|
+
f"or point at another endpoint")
|
|
270
|
+
live = self.loaded_models(spec) # None = the server cannot say
|
|
271
|
+
if spec.model:
|
|
272
|
+
if live is not None and spec.model not in live:
|
|
273
|
+
where = ", ".join(live) if live else "none"
|
|
274
|
+
return "", (f"model {spec.model!r} is not loaded on {ep} (loaded: {where}) -- "
|
|
275
|
+
f"asking for it would load it from disk; load it first or name one "
|
|
276
|
+
f"that is")
|
|
277
|
+
if live is None and spec.model not in listed:
|
|
278
|
+
return "", f"model {spec.model!r} is not on {ep} -- it has: {', '.join(listed)}"
|
|
279
|
+
return spec.model, ""
|
|
280
|
+
if live:
|
|
281
|
+
return live[0], "" # the one in memory, not the first on disk
|
|
282
|
+
if live is not None: # the server can say, and says: nothing
|
|
283
|
+
return "", (f"{ep} has no model loaded -- load one (asking would load one "
|
|
284
|
+
f"from disk, which takes minutes)")
|
|
285
|
+
return listed[0], ""
|
|
286
|
+
|
|
287
|
+
# -- the protocol ---------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
def capabilities(self, spec: Spec) -> frozenset[str]:
|
|
290
|
+
caps = {SCHEMA, MODEL}
|
|
291
|
+
on_prem = spec.on_prem if spec.on_prem is not None else endpoint_is_on_prem(spec.endpoint)
|
|
292
|
+
if on_prem:
|
|
293
|
+
caps.add(ON_PREM)
|
|
294
|
+
return frozenset(caps)
|
|
295
|
+
|
|
296
|
+
def probe(self, spec: Spec) -> Readiness:
|
|
297
|
+
model, why = self._target(spec)
|
|
298
|
+
if not model:
|
|
299
|
+
return Readiness(False, why)
|
|
300
|
+
ep = spec.endpoint.rstrip("/")
|
|
301
|
+
n = self.context_length(spec)
|
|
302
|
+
reason = f"{ep} -- reachable, model {model}" + (f", {n:,}-token context" if n else "")
|
|
303
|
+
warnings = []
|
|
304
|
+
if n and n > ROOMY_CONTEXT:
|
|
305
|
+
warnings.append(f"loaded with a {n:,}-token context; a short structured task needs a "
|
|
306
|
+
f"couple of thousand, and an oversized window is the usual reason a local "
|
|
307
|
+
f"run takes minutes (reload it at 16k-32k)")
|
|
308
|
+
# A server that cannot say what is loaded has a catalogue, not a
|
|
309
|
+
# model: taking the first entry is right for a local server running
|
|
310
|
+
# one model and arbitrary for a hosted provider, where it decides
|
|
311
|
+
# both the answer and the price. Measured 2026-09-17: two runs of the
|
|
312
|
+
# golden matrix on OpenRouter were answered by a model nobody chose.
|
|
313
|
+
_on_prem = spec.on_prem if spec.on_prem is not None else endpoint_is_on_prem(spec.endpoint)
|
|
314
|
+
if not spec.model and not _on_prem:
|
|
315
|
+
warnings.append(f"no model configured, so the first this endpoint lists was used: "
|
|
316
|
+
f"{model} -- it decides the answer and the price, and the list can "
|
|
317
|
+
f"change between runs; set ai_merge_model to choose")
|
|
318
|
+
_, source = self._credential(spec)
|
|
319
|
+
if source:
|
|
320
|
+
reason += f", key from {source}" # the route, never the value
|
|
321
|
+
if source != spec.credential_env:
|
|
322
|
+
perm = _permission_warning(source)
|
|
323
|
+
if perm:
|
|
324
|
+
warnings.append(perm)
|
|
325
|
+
return Readiness(True, reason, "; ".join(warnings))
|
|
326
|
+
|
|
327
|
+
def invoke(self, spec: Spec, req: Request) -> Response:
|
|
328
|
+
model, why = self._target(spec)
|
|
329
|
+
if not model:
|
|
330
|
+
return Response("failed", error=why)
|
|
331
|
+
ep = spec.endpoint.rstrip("/")
|
|
332
|
+
body: dict = {"model": model,
|
|
333
|
+
"messages": [{"role": "user", "content": req.prompt}],
|
|
334
|
+
"stream": False}
|
|
335
|
+
honoured = [MODEL]
|
|
336
|
+
if req.temperature is not None:
|
|
337
|
+
body["temperature"] = req.temperature
|
|
338
|
+
if req.max_tokens is not None:
|
|
339
|
+
body["max_tokens"] = req.max_tokens
|
|
340
|
+
honoured.append("max_tokens")
|
|
341
|
+
body.update(dict(spec.extra))
|
|
342
|
+
if req.schema is not None:
|
|
343
|
+
body["response_format"] = {"type": "json_schema",
|
|
344
|
+
"json_schema": {"name": "answer", "strict": True,
|
|
345
|
+
"schema": req.schema}}
|
|
346
|
+
honoured.append(SCHEMA)
|
|
347
|
+
http = urllib.request.Request(ep + "/chat/completions", data=json.dumps(body).encode("utf-8"),
|
|
348
|
+
headers=self._headers(spec, post=True), method="POST")
|
|
349
|
+
started = time.monotonic()
|
|
350
|
+
# Until this transport streams (the run-while-alive design's second
|
|
351
|
+
# unit), a server answers in one piece and shows no sign of life
|
|
352
|
+
# meanwhile -- so the silence window is its ceiling when the caller
|
|
353
|
+
# set none. A zero timeout on urlopen would fail at once.
|
|
354
|
+
ceiling = req.timeout or req.idle
|
|
355
|
+
try:
|
|
356
|
+
with urllib.request.urlopen(http, timeout=ceiling) as r: # noqa: S310
|
|
357
|
+
answer = json.loads(r.read().decode("utf-8", "replace"))
|
|
358
|
+
except urllib.error.HTTPError as e:
|
|
359
|
+
detail = ""
|
|
360
|
+
try:
|
|
361
|
+
detail = e.read().decode("utf-8", "replace")[:300]
|
|
362
|
+
except Exception: # noqa: BLE001
|
|
363
|
+
pass
|
|
364
|
+
return Response("failed", error=f"{ep} answered HTTP {e.code}{': ' + detail if detail else ''}")
|
|
365
|
+
except TimeoutError:
|
|
366
|
+
return Response("failed", error=(f"no answer from {model} within {ceiling}s -- if it is "
|
|
367
|
+
f"generating slowly, the usual cause is a context window "
|
|
368
|
+
f"far larger than the prompt, not the model itself"))
|
|
369
|
+
except (urllib.error.URLError, OSError) as e:
|
|
370
|
+
return Response("failed", error=f"cannot reach {ep}: {e}")
|
|
371
|
+
except ValueError as e:
|
|
372
|
+
return Response("failed", error=f"{ep} answered something that is not JSON: {e}")
|
|
373
|
+
elapsed = time.monotonic() - started
|
|
374
|
+
choices = answer.get("choices") if isinstance(answer, dict) else None
|
|
375
|
+
if not choices:
|
|
376
|
+
return Response("failed", error=f"{ep} answered with no choices", elapsed=elapsed)
|
|
377
|
+
text = (choices[0].get("message") or {}).get("content") or ""
|
|
378
|
+
if not text.strip():
|
|
379
|
+
return Response("failed", model_used=model, elapsed=elapsed,
|
|
380
|
+
error=(f"{model} answered with empty content -- the reasoning budget may "
|
|
381
|
+
f"have consumed the reply; raise max_tokens or turn reasoning off"))
|
|
382
|
+
return Response("answered", text=text, model_used=str(answer.get("model") or model),
|
|
383
|
+
honoured=tuple(honoured), elapsed=elapsed)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""The prompt-file transport: not a model. It writes the request where a
|
|
2
|
+
person can carry it to any model by hand, and answers "deferred" -- which
|
|
3
|
+
is a status, not a failure and not a success. A caller that wants a
|
|
4
|
+
"write the prompt and stop" mode without routing through the library is
|
|
5
|
+
free to do that itself; this exists so the library's own story is complete
|
|
6
|
+
and so a consumer that DOES want it has it.
|
|
7
|
+
|
|
8
|
+
The spec's `endpoint` is the directory prompts are written to.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from ..types import ON_PREM, Readiness, Request, Response, Spec
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PromptFile:
|
|
19
|
+
def capabilities(self, spec: Spec) -> frozenset[str]:
|
|
20
|
+
return frozenset({ON_PREM}) # nothing leaves at all
|
|
21
|
+
|
|
22
|
+
def probe(self, spec: Spec) -> Readiness:
|
|
23
|
+
if not spec.endpoint:
|
|
24
|
+
return Readiness(False, "no directory configured for prompts (the spec's endpoint)")
|
|
25
|
+
return Readiness(True, f"prompts are written to {spec.endpoint}; nothing is sent")
|
|
26
|
+
|
|
27
|
+
def invoke(self, spec: Spec, req: Request) -> Response:
|
|
28
|
+
if not spec.endpoint:
|
|
29
|
+
return Response("failed", error="no directory configured for prompts (the spec's endpoint)")
|
|
30
|
+
d = Path(spec.endpoint).expanduser()
|
|
31
|
+
try:
|
|
32
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
path = d / f"prompt_{time.strftime('%Y-%m-%d_%H-%M-%S')}.md"
|
|
34
|
+
n = 1
|
|
35
|
+
while path.exists(): # two in one second
|
|
36
|
+
n += 1
|
|
37
|
+
path = d / f"prompt_{time.strftime('%Y-%m-%d_%H-%M-%S')}_{n}.md"
|
|
38
|
+
path.write_text(req.prompt, encoding="utf-8")
|
|
39
|
+
except OSError as e:
|
|
40
|
+
return Response("failed", error=f"cannot write the prompt under {d}: {e}")
|
|
41
|
+
return Response("deferred", artifact=str(path))
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""The library's value types: what a backend IS on paper, what a call SAYS,
|
|
2
|
+
and what comes BACK.
|
|
3
|
+
|
|
4
|
+
A `Spec` is everything needed to build a backend -- which transport, where,
|
|
5
|
+
which model, how to authenticate, how to invoke a CLI -- and nothing that
|
|
6
|
+
varies per call. It is immutable, so it can be shared, hashed and recorded.
|
|
7
|
+
It holds the NAME of the environment variable carrying a credential, or the
|
|
8
|
+
PATH of a file holding one, and never the credential itself: a spec is
|
|
9
|
+
exactly the object that ends up in a cache key, a provenance record and a
|
|
10
|
+
health-check line, and a secret in a frozen dataclass ends up in all three.
|
|
11
|
+
`identity()` is the part of a spec those consumers may see.
|
|
12
|
+
|
|
13
|
+
A `Request` is what varies per call and is the same for every transport: the
|
|
14
|
+
prompt, the shape the answer must take, the limits. The model is NOT here --
|
|
15
|
+
it is a fact about which backend you built, so it lives in the spec.
|
|
16
|
+
|
|
17
|
+
A `Response` says what happened with a word (answered, deferred, failed),
|
|
18
|
+
never with a boolean that has to mean three things.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import dataclasses
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from typing import Any, TextIO
|
|
27
|
+
|
|
28
|
+
STATUSES = ("answered", "deferred", "failed")
|
|
29
|
+
|
|
30
|
+
#: Capability names a transport may advertise for a given spec.
|
|
31
|
+
SCHEMA = "schema" # the answer's shape is enforced, not just requested
|
|
32
|
+
MODEL = "model" # the backend can be told which model to use
|
|
33
|
+
STREAM = "stream" # output can be echoed as it arrives
|
|
34
|
+
ON_PREM = "data_stays_on_prem" # the prompt does not leave the user's own network
|
|
35
|
+
TOOLS = "tools" # given a working directory, the backend may read and write in it
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _basename(path: str) -> str:
|
|
39
|
+
"""The last component of `path` under EITHER separator, so the same
|
|
40
|
+
logical file yields the same identity on every platform."""
|
|
41
|
+
return path.replace("\\", "/").rstrip("/").rsplit("/", 1)[-1]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Spec:
|
|
46
|
+
"""Everything needed to BUILD a backend. Immutable.
|
|
47
|
+
|
|
48
|
+
transport -- "cli", "openai_compat", "prompt-file", ... (the registry's names)
|
|
49
|
+
name -- a human label for the preset; not part of the identity
|
|
50
|
+
endpoint -- HTTP transports: the base URL, ending in /v1 for the
|
|
51
|
+
OpenAI-compatible surface; the prompt-file transport: the
|
|
52
|
+
directory prompts are written to
|
|
53
|
+
model -- the model to ask for; "" means the backend's own choice
|
|
54
|
+
credential_env -- the NAME of the environment variable holding a bearer
|
|
55
|
+
token, resolved at request time; never the value
|
|
56
|
+
credential_file -- the PATH of a file holding the token, read at request
|
|
57
|
+
time BEFORE the environment (a file named on purpose
|
|
58
|
+
wins). `NAME=value` lines, looked up by credential_env;
|
|
59
|
+
a file with a single bare line is the value itself
|
|
60
|
+
credential_fallbacks -- paths read only when the environment has
|
|
61
|
+
nothing, in order (a tool's default key location).
|
|
62
|
+
Neither field ever holds the value
|
|
63
|
+
command -- CLI transports: the argv template. Placeholders:
|
|
64
|
+
{model}, {schema} (the schema inline, one argv token),
|
|
65
|
+
{schema_file}, {prompt_file}, {output_file} (paths in the
|
|
66
|
+
child's scratch directory), {cwd} (the child's working
|
|
67
|
+
directory), {tools} (see `tools`). An empty placeholder
|
|
68
|
+
removes itself and the flag before it.
|
|
69
|
+
tools -- CLI transports: what `{tools}` becomes when the Request
|
|
70
|
+
names a working directory -- the CLI's own grant of what
|
|
71
|
+
it may touch there (claude's `--allowedTools` list,
|
|
72
|
+
codex's sandbox mode). Without a workdir the placeholder
|
|
73
|
+
and its flag vanish, so a call that names none is argv-
|
|
74
|
+
identical to a preset without a grant
|
|
75
|
+
answer -- CLI transports: where the answer is. "stdout" (default),
|
|
76
|
+
"stdout-json:<key>" (stdout is a JSON envelope; take one
|
|
77
|
+
key), "stream-json:<key>" (stdout is one JSON event per
|
|
78
|
+
line as the CLI works; the answer is one key of the last
|
|
79
|
+
event whose type is "result", and an event marked
|
|
80
|
+
is_error is a failure carrying that key's text), or
|
|
81
|
+
"file:{output_file}"
|
|
82
|
+
max_turns -- CLI transports: what `{turns}` becomes -- the CLI's own
|
|
83
|
+
budget of agentic turns (claude's `--max-turns`), the
|
|
84
|
+
cost fence for a call that is allowed to run while it is
|
|
85
|
+
alive. 0 drops the placeholder and its flag. Part of the
|
|
86
|
+
identity, because it bounds the answer
|
|
87
|
+
env_unset -- variables removed from the CHILD's environment
|
|
88
|
+
candidates -- extra executable paths tried after PATH
|
|
89
|
+
on_prem -- the preset's word on whether data stays on the user's
|
|
90
|
+
network; None lets the transport derive it from the
|
|
91
|
+
endpoint
|
|
92
|
+
hint -- appended to a not-reachable reason (e.g. where to start
|
|
93
|
+
a local server); never part of the identity
|
|
94
|
+
extra -- transport-specific request fields, as ordered pairs
|
|
95
|
+
"""
|
|
96
|
+
transport: str
|
|
97
|
+
name: str = ""
|
|
98
|
+
endpoint: str = ""
|
|
99
|
+
model: str = ""
|
|
100
|
+
credential_env: str = ""
|
|
101
|
+
credential_file: str = ""
|
|
102
|
+
credential_fallbacks: tuple[str, ...] = ()
|
|
103
|
+
command: tuple[str, ...] = ()
|
|
104
|
+
tools: str = ""
|
|
105
|
+
answer: str = "stdout"
|
|
106
|
+
env_unset: tuple[str, ...] = ()
|
|
107
|
+
candidates: tuple[str, ...] = ()
|
|
108
|
+
on_prem: bool | None = None
|
|
109
|
+
hint: str = ""
|
|
110
|
+
extra: tuple[tuple[str, Any], ...] = ()
|
|
111
|
+
max_turns: int = 0
|
|
112
|
+
|
|
113
|
+
def with_(self, **changes: Any) -> "Spec":
|
|
114
|
+
return dataclasses.replace(self, **changes)
|
|
115
|
+
|
|
116
|
+
def identity(self) -> str:
|
|
117
|
+
"""What a cache key, a record or a report may hold: the things that
|
|
118
|
+
decide WHICH model answers. Never the credential's value; not the
|
|
119
|
+
executable's resolved path (a reinstall must not flush a cache); not
|
|
120
|
+
the name, the hint, the timeout or the candidates. A key file is
|
|
121
|
+
named by its BASENAME only: the full path carries the account name,
|
|
122
|
+
records get pasted into issues, and a moved home must not flush the
|
|
123
|
+
cache either."""
|
|
124
|
+
# Labelled, and only the parts that exist: an identity is read by
|
|
125
|
+
# people in records and reports, and `cli||||...` said nothing about
|
|
126
|
+
# which fields were empty (2026-09-09; one cache invalidation).
|
|
127
|
+
parts = [self.transport]
|
|
128
|
+
if self.endpoint:
|
|
129
|
+
parts.append(f"endpoint={self.endpoint.rstrip('/')}")
|
|
130
|
+
if self.model:
|
|
131
|
+
parts.append(f"model={self.model}")
|
|
132
|
+
if self.command:
|
|
133
|
+
parts.append(f"command={' '.join(self.command)}")
|
|
134
|
+
if self.max_turns:
|
|
135
|
+
parts.append(f"turns={self.max_turns}")
|
|
136
|
+
if self.extra:
|
|
137
|
+
parts.append(f"extra={json.dumps(sorted(self.extra), sort_keys=True, default=str)}")
|
|
138
|
+
if self.credential_env:
|
|
139
|
+
parts.append(f"key:{self.credential_env}")
|
|
140
|
+
for path in (self.credential_file, *self.credential_fallbacks):
|
|
141
|
+
if path:
|
|
142
|
+
parts.append(f"keyfile:{_basename(path)}")
|
|
143
|
+
return " ".join(parts)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True)
|
|
147
|
+
class Request:
|
|
148
|
+
"""What varies per call. Identical for every transport.
|
|
149
|
+
|
|
150
|
+
max_tokens=None and temperature=None mean "not sent": a low ceiling is
|
|
151
|
+
exactly how a reasoning model comes back with empty content. `stream_to`
|
|
152
|
+
is a text sink a transport echoes output into as it arrives; it is not
|
|
153
|
+
part of the fingerprint, and neither are the two limits. `workdir` is a
|
|
154
|
+
directory the caller prepared for the backend to work IN -- a CLI runs
|
|
155
|
+
there, may read and write there under the spec's `tools` grant, and the
|
|
156
|
+
directory is left for the caller to read back; "" means the transport's
|
|
157
|
+
own throwaway scratch. Where the child ran is not what was asked, so
|
|
158
|
+
the fingerprint excludes it too.
|
|
159
|
+
|
|
160
|
+
The two limits (2026-09-18, run while alive): a call ends when the model
|
|
161
|
+
finishes, when it is demonstrably dead, or when the caller set a
|
|
162
|
+
ceiling -- never because a number guessed in advance ran out. `idle` is
|
|
163
|
+
the silence, in seconds, after which a backend that has shown no sign of
|
|
164
|
+
life is declared dead and stopped (a thinking model is observably alive:
|
|
165
|
+
its output arrives as it thinks; a dead process emits nothing).
|
|
166
|
+
`timeout` is a hard ceiling on the whole call and 0, the default, means
|
|
167
|
+
there is none; a caller sets one for a machine with nobody watching.
|
|
168
|
+
"""
|
|
169
|
+
prompt: str
|
|
170
|
+
schema: dict | None = None
|
|
171
|
+
max_tokens: int | None = None
|
|
172
|
+
timeout: int = 0
|
|
173
|
+
temperature: float | None = 0
|
|
174
|
+
stream_to: TextIO | None = None
|
|
175
|
+
workdir: str = ""
|
|
176
|
+
idle: int = 180
|
|
177
|
+
|
|
178
|
+
def fingerprint(self) -> str:
|
|
179
|
+
payload = json.dumps({"prompt": self.prompt, "schema": self.schema,
|
|
180
|
+
"max_tokens": self.max_tokens, "temperature": self.temperature},
|
|
181
|
+
sort_keys=True, default=str)
|
|
182
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True)
|
|
186
|
+
class Response:
|
|
187
|
+
"""What came back. `status` is one of `STATUSES`:
|
|
188
|
+
|
|
189
|
+
answered -- `text` holds the answer, `model_used` says who gave it,
|
|
190
|
+
`honoured` names the Request fields the transport enforced
|
|
191
|
+
deferred -- nothing was asked; `artifact` is where the prompt was written
|
|
192
|
+
for a person to carry
|
|
193
|
+
failed -- `error` is a sentence a person can act on
|
|
194
|
+
"""
|
|
195
|
+
status: str
|
|
196
|
+
text: str = ""
|
|
197
|
+
error: str = ""
|
|
198
|
+
model_used: str = ""
|
|
199
|
+
honoured: tuple[str, ...] = ()
|
|
200
|
+
artifact: str = ""
|
|
201
|
+
cached: bool = False
|
|
202
|
+
cached_at: float = 0.0
|
|
203
|
+
key: str = ""
|
|
204
|
+
elapsed: float = 0.0
|
|
205
|
+
|
|
206
|
+
def __post_init__(self) -> None:
|
|
207
|
+
if self.status not in STATUSES:
|
|
208
|
+
raise ValueError(f"status must be one of {STATUSES}, not {self.status!r}")
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def ok(self) -> bool:
|
|
212
|
+
return self.status == "answered"
|
|
213
|
+
|
|
214
|
+
def with_(self, **changes: Any) -> "Response":
|
|
215
|
+
return dataclasses.replace(self, **changes)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass(frozen=True)
|
|
219
|
+
class Readiness:
|
|
220
|
+
"""A health check's answer: yes or no, a sentence either way, and an
|
|
221
|
+
optional warning for "yes, but" (a window far larger than the job)."""
|
|
222
|
+
ok: bool
|
|
223
|
+
reason: str = ""
|
|
224
|
+
warning: str = ""
|