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.
Files changed (174) hide show
  1. git_repokit-0.9.10.dist-info/METADATA +422 -0
  2. git_repokit-0.9.10.dist-info/RECORD +174 -0
  3. git_repokit-0.9.10.dist-info/WHEEL +5 -0
  4. git_repokit-0.9.10.dist-info/entry_points.txt +2 -0
  5. git_repokit-0.9.10.dist-info/licenses/LICENSE +9 -0
  6. git_repokit-0.9.10.dist-info/top_level.txt +1 -0
  7. repokit/__init__.py +8 -0
  8. repokit/__main__.py +12 -0
  9. repokit/__version__.py +4 -0
  10. repokit/_vendor/__init__.py +0 -0
  11. repokit/_vendor/ailib/__init__.py +1 -0
  12. repokit/_vendor/ailib/analyzer.py +177 -0
  13. repokit/_vendor/ailib/backend.py +95 -0
  14. repokit/_vendor/ailib/cache.py +97 -0
  15. repokit/_vendor/ailib/parsers.py +52 -0
  16. repokit/_vendor/ailib/transports/__init__.py +16 -0
  17. repokit/_vendor/ailib/transports/cli.py +312 -0
  18. repokit/_vendor/ailib/transports/openai_compat.py +383 -0
  19. repokit/_vendor/ailib/transports/prompt_file.py +41 -0
  20. repokit/_vendor/ailib/types.py +224 -0
  21. repokit/auth_integration.py +244 -0
  22. repokit/badges.py +183 -0
  23. repokit/branch_utils.py +364 -0
  24. repokit/cli.py +2997 -0
  25. repokit/common_kit.py +202 -0
  26. repokit/config.py +686 -0
  27. repokit/defaults.py +220 -0
  28. repokit/directory_analyzer.py +1244 -0
  29. repokit/directory_profiles.py +423 -0
  30. repokit/git_config.py +370 -0
  31. repokit/github_create.py +142 -0
  32. repokit/github_setup.py +248 -0
  33. repokit/guardrails.py +707 -0
  34. repokit/history_cleaner.py +449 -0
  35. repokit/history_protection.py +434 -0
  36. repokit/hooks/pre-commit-guardrails +122 -0
  37. repokit/hooks/pre-commit-template +28 -0
  38. repokit/layers.py +510 -0
  39. repokit/licenses/0bsd.txt +14 -0
  40. repokit/licenses/LICENSES.md +27 -0
  41. repokit/licenses/__init__.py +142 -0
  42. repokit/licenses/agpl-3.0.txt +661 -0
  43. repokit/licenses/all-rights-reserved.txt +10 -0
  44. repokit/licenses/apache-2.0.txt +201 -0
  45. repokit/licenses/bsd-2-clause.txt +24 -0
  46. repokit/licenses/bsd-3-clause.txt +28 -0
  47. repokit/licenses/bsl-1.0.txt +23 -0
  48. repokit/licenses/cc-by-4.0.txt +395 -0
  49. repokit/licenses/cc-by-sa-4.0.txt +427 -0
  50. repokit/licenses/cc0-1.0.txt +121 -0
  51. repokit/licenses/epl-2.0.txt +277 -0
  52. repokit/licenses/gpl-2.0.txt +339 -0
  53. repokit/licenses/gpl-3.0.txt +674 -0
  54. repokit/licenses/isc.txt +15 -0
  55. repokit/licenses/lgpl-2.1.txt +504 -0
  56. repokit/licenses/lgpl-3.0.txt +165 -0
  57. repokit/licenses/mit.txt +21 -0
  58. repokit/licenses/mpl-2.0.txt +373 -0
  59. repokit/licenses/unlicense.txt +24 -0
  60. repokit/readme_blocks.py +112 -0
  61. repokit/remote_integration.py +627 -0
  62. repokit/repo_manager.py +2796 -0
  63. repokit/template_engine.py +391 -0
  64. repokit/templates/ai/claude/CLAUDE-nested.md.template +25 -0
  65. repokit/templates/ai/claude/CLAUDE.md.template +98 -0
  66. repokit/templates/ai/claude/instructions/step1_context_rebuilder.md +33 -0
  67. repokit/templates/ai/claude/instructions/step2_dev_workflow_process.md +67 -0
  68. repokit/templates/ai/claude/instructions/step3_context_bridge.md +52 -0
  69. repokit/templates/common/CHANGELOG.md.template +15 -0
  70. repokit/templates/common/CONTRIBUTING.md.template +31 -0
  71. repokit/templates/common/README.md.template +30 -0
  72. repokit/templates/common/gitattributes.template +17 -0
  73. repokit/templates/common/gitignore.template +141 -0
  74. repokit/templates/extras/FUNDING.yml.template +13 -0
  75. repokit/templates/extras/ROADMAP.md.template +17 -0
  76. repokit/templates/extras/dependabot.yml.template +22 -0
  77. repokit/templates/extras/one-offs-README.md.template +28 -0
  78. repokit/templates/extras/platform-support.md.template +11 -0
  79. repokit/templates/extras/pull_request_template.md.template +25 -0
  80. repokit/templates/extras/release-template.md.template +34 -0
  81. repokit/templates/extras/settings.json.template +11 -0
  82. repokit/templates/extras/stale.yml.template +14 -0
  83. repokit/templates/github/CODEOWNERS.template +11 -0
  84. repokit/templates/github/ISSUE_TEMPLATE/bug-report.md.template +29 -0
  85. repokit/templates/github/ISSUE_TEMPLATE/feature-request.md.template +19 -0
  86. repokit/templates/github/issues/notes.md.template +18 -0
  87. repokit/templates/github/issues/roadmap.md.template +15 -0
  88. repokit/templates/hooks/pre-commit.template +64 -0
  89. repokit/templates/layers/archetypes/any/claude-plugin/.claude-plugin/marketplace.json +20 -0
  90. repokit/templates/layers/archetypes/any/claude-plugin/plugin/.claude-plugin/plugin.json +15 -0
  91. repokit/templates/layers/archetypes/any/claude-plugin/plugin/README.md +28 -0
  92. repokit/templates/layers/archetypes/c-cpp/cmake-app/.github/workflows/ci-c-cpp.yml +24 -0
  93. repokit/templates/layers/archetypes/c-cpp/cmake-app/.repokit-common.toml.rkappend +4 -0
  94. repokit/templates/layers/archetypes/c-cpp/cmake-app/.vscode/launch.json +29 -0
  95. repokit/templates/layers/archetypes/c-cpp/cmake-app/.vscode/tasks.json +26 -0
  96. repokit/templates/layers/archetypes/c-cpp/cmake-app/CMakeLists.txt +47 -0
  97. repokit/templates/layers/archetypes/c-cpp/cmake-app/CMakePresets.json +23 -0
  98. repokit/templates/layers/archetypes/c-cpp/cmake-app/cmake/GenerateStamp.cmake +45 -0
  99. repokit/templates/layers/archetypes/c-cpp/cmake-app/src/main.cpp +27 -0
  100. repokit/templates/layers/archetypes/c-cpp/cmake-app/tests/test_version.cpp +20 -0
  101. repokit/templates/layers/archetypes/c-cpp/cmake-app/vcpkg.json +5 -0
  102. repokit/templates/layers/archetypes/c-cpp/minimal/.vscode/launch.json +27 -0
  103. repokit/templates/layers/archetypes/c-cpp/minimal/Makefile +10 -0
  104. repokit/templates/layers/archetypes/c-cpp/minimal/build.bat +7 -0
  105. repokit/templates/layers/archetypes/c-cpp/minimal/src/__RK_SNAKE__.c +26 -0
  106. repokit/templates/layers/archetypes/c-cpp/msbuild/.vscode/launch.json +17 -0
  107. repokit/templates/layers/archetypes/c-cpp/msbuild/.vscode/tasks.json +20 -0
  108. repokit/templates/layers/archetypes/c-cpp/msbuild/__RK_PROJECT__.sln +19 -0
  109. repokit/templates/layers/archetypes/c-cpp/msbuild/__RK_PROJECT__.vcxproj +41 -0
  110. repokit/templates/layers/archetypes/c-cpp/msbuild/src/main.cpp +26 -0
  111. repokit/templates/layers/archetypes/js-ts/claude-plugin/.repokit-common.toml +28 -0
  112. repokit/templates/layers/archetypes/js-ts/claude-plugin/.vscode/launch.json +34 -0
  113. repokit/templates/layers/archetypes/js-ts/claude-plugin/plugin/hooks/hooks.json +15 -0
  114. repokit/templates/layers/archetypes/js-ts/claude-plugin/plugin/hooks/scripts/session-start.mjs +23 -0
  115. repokit/templates/layers/archetypes/js-ts/claude-plugin/plugin/tests/manifest.test.js +30 -0
  116. repokit/templates/layers/archetypes/js-ts/node/.repokit-common.toml.rkappend +6 -0
  117. repokit/templates/layers/archetypes/js-ts/node/package.json +13 -0
  118. repokit/templates/layers/archetypes/js-ts/node-package/.github/workflows/release.yml +39 -0
  119. repokit/templates/layers/archetypes/js-ts/node-package/package.json +32 -0
  120. repokit/templates/layers/archetypes/js-ts/node-package/src/index.ts +13 -0
  121. repokit/templates/layers/archetypes/js-ts/node-package/tests/index.test.ts +11 -0
  122. repokit/templates/layers/archetypes/js-ts/script/.vscode/launch.json +34 -0
  123. repokit/templates/layers/archetypes/js-ts/script/__RK_PROJECT__.js +37 -0
  124. repokit/templates/layers/archetypes/js-ts/script/tests/version.test.js +26 -0
  125. repokit/templates/layers/archetypes/js-ts/ts/package.json +18 -0
  126. repokit/templates/layers/archetypes/js-ts/ts/tsconfig.json +15 -0
  127. repokit/templates/layers/archetypes/python/claude-plugin/.github/workflows/ci-python.yml +44 -0
  128. repokit/templates/layers/archetypes/python/claude-plugin/.repokit-common.toml +21 -0
  129. repokit/templates/layers/archetypes/python/claude-plugin/.vscode/launch.json +33 -0
  130. repokit/templates/layers/archetypes/python/claude-plugin/VERSION +1 -0
  131. repokit/templates/layers/archetypes/python/claude-plugin/plugin/hooks/hooks.json +15 -0
  132. repokit/templates/layers/archetypes/python/claude-plugin/plugin/hooks/scripts/run-hook.mjs +42 -0
  133. repokit/templates/layers/archetypes/python/claude-plugin/plugin/hooks/scripts/session_start.py +31 -0
  134. repokit/templates/layers/archetypes/python/claude-plugin/plugin/tests/test_manifest.py +42 -0
  135. repokit/templates/layers/archetypes/python/pypi/.github/workflows/release.yml +63 -0
  136. repokit/templates/layers/archetypes/python/pypi/.vscode/launch.json +43 -0
  137. repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/__init__.py +5 -0
  138. repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/__main__.py +6 -0
  139. repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/_version.py +96 -0
  140. repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/cli.py +18 -0
  141. repokit/templates/layers/archetypes/python/pypi/pyproject.toml +71 -0
  142. repokit/templates/layers/archetypes/python/pypi/tests/conftest.py +11 -0
  143. repokit/templates/layers/archetypes/python/pypi/tests/test_version.py +50 -0
  144. repokit/templates/layers/archetypes/python/script/.github/workflows/ci-python.yml +43 -0
  145. repokit/templates/layers/archetypes/python/script/.repokit-common.toml +14 -0
  146. repokit/templates/layers/archetypes/python/script/.vscode/launch.json +33 -0
  147. repokit/templates/layers/archetypes/python/script/VERSION +1 -0
  148. repokit/templates/layers/archetypes/python/script/__RK_PROJECT__.py +40 -0
  149. repokit/templates/layers/archetypes/python/script/tests/test_version.py +25 -0
  150. repokit/templates/layers/archetypes/python/setuppy/requirements.txt +8 -0
  151. repokit/templates/layers/archetypes/python/setuppy/setup.py +23 -0
  152. repokit/templates/layers/languages/c-cpp/.gitignore.rkappend +20 -0
  153. repokit/templates/layers/languages/c-cpp/.repokit-common.toml +7 -0
  154. repokit/templates/layers/languages/c-cpp/include/__RK_SNAKE__/version.h +20 -0
  155. repokit/templates/layers/languages/c-cpp/src/app.rc +25 -0
  156. repokit/templates/layers/languages/js-ts/.editorconfig +15 -0
  157. repokit/templates/layers/languages/js-ts/.github/workflows/ci-js-ts.yml +41 -0
  158. repokit/templates/layers/languages/js-ts/.gitignore.rkappend +13 -0
  159. repokit/templates/layers/languages/js-ts/.repokit-common.toml +19 -0
  160. repokit/templates/layers/languages/js-ts/.vscode/launch.json +24 -0
  161. repokit/templates/layers/languages/js-ts/VERSION +1 -0
  162. repokit/templates/layers/languages/python/.github/workflows/ci-python.yml +76 -0
  163. repokit/templates/layers/languages/python/.gitignore.rkappend +15 -0
  164. repokit/templates/layers/languages/python/.vscode/launch.json +33 -0
  165. repokit/templates/layers/manifest.toml +302 -0
  166. repokit/templates/vault/CLAUDE.md +29 -0
  167. repokit/templates/vault/claude/_maps/MOC-project.md +15 -0
  168. repokit/templates/vault/claude/_oracle/backlinks.md +3 -0
  169. repokit/templates/vault/claude/_oracle/concepts.md +12 -0
  170. repokit/templates/vault/claude/_oracle/manifest.md +10 -0
  171. repokit/templates/vault/claude/_todo/ACTIVE-WORK.md +18 -0
  172. repokit/templates/vault/claude/_todo/OPEN-LOOPS.md +14 -0
  173. repokit/utils.py +316 -0
  174. 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 = ""