@wendellresearch-ai/benchwarmers 0.2.0 → 0.2.2

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 (103) hide show
  1. package/AGENTS.md +152 -147
  2. package/BENCHMARKS.md +262 -262
  3. package/GATEWAY.md +415 -272
  4. package/NOTICE +28 -28
  5. package/PRIVACY.md +46 -46
  6. package/README.md +408 -314
  7. package/SECURITY.md +83 -83
  8. package/TERMS.md +22 -22
  9. package/USAGE.md +186 -163
  10. package/VERSION +1 -1
  11. package/bin/benchwarmers.js +16 -74
  12. package/cli.py +674 -0
  13. package/examples/config/gateway.json +5 -5
  14. package/examples/config/model-roles.json +11 -11
  15. package/gateway.py +939 -755
  16. package/launcher.py +120 -0
  17. package/mcp_server.py +266 -266
  18. package/metrics/alerts.py +116 -116
  19. package/metrics/aliases.py +41 -41
  20. package/metrics/bench_collections.py +141 -141
  21. package/metrics/catalog.py +304 -295
  22. package/metrics/client.py +804 -394
  23. package/metrics/cliproxy.py +191 -191
  24. package/metrics/connect.py +691 -691
  25. package/metrics/cost.py +171 -152
  26. package/metrics/decide.py +227 -227
  27. package/metrics/discover.py +318 -318
  28. package/metrics/endpoints.py +436 -436
  29. package/metrics/formats.py +1021 -0
  30. package/metrics/guard.py +647 -592
  31. package/metrics/learn.py +234 -221
  32. package/metrics/ledger.py +148 -0
  33. package/metrics/litellm_hub.py +206 -206
  34. package/metrics/model_roles.py +94 -84
  35. package/metrics/offload.py +166 -166
  36. package/metrics/owui.py +205 -205
  37. package/metrics/pacing.py +248 -0
  38. package/metrics/paths.py +60 -60
  39. package/metrics/quota.py +383 -0
  40. package/metrics/route.py +581 -579
  41. package/metrics/scheduler.py +275 -275
  42. package/metrics/sources.py +167 -167
  43. package/metrics/wrong.py +144 -144
  44. package/package.json +69 -65
  45. package/perf/README.md +89 -89
  46. package/perf/run.py +284 -261
  47. package/scripts/auto_retire.py +367 -367
  48. package/scripts/check.py +113 -106
  49. package/scripts/check_deprecations.py +378 -378
  50. package/scripts/check_themes.py +103 -103
  51. package/scripts/init.py +124 -124
  52. package/scripts/list_served.py +78 -78
  53. package/scripts/notice.py +78 -78
  54. package/scripts/onboard.py +410 -331
  55. package/scripts/provider_tags.py +129 -129
  56. package/scripts/reprefix.py +136 -136
  57. package/scripts/sync_config.py +105 -105
  58. package/scripts/test_auth.py +255 -255
  59. package/scripts/test_cli.py +189 -0
  60. package/scripts/test_client.py +277 -233
  61. package/scripts/test_connect.py +222 -222
  62. package/scripts/test_first_run.py +180 -180
  63. package/scripts/test_formats.py +549 -0
  64. package/scripts/test_hubs.py +328 -328
  65. package/scripts/test_launcher.py +67 -0
  66. package/scripts/test_llm_log.py +85 -81
  67. package/scripts/test_mcp.py +178 -178
  68. package/scripts/test_merged.py +215 -214
  69. package/scripts/test_no_owui.py +291 -291
  70. package/scripts/test_owui.py +90 -90
  71. package/scripts/test_quota.py +334 -0
  72. package/scripts/test_serve_edit.py +453 -438
  73. package/scripts/test_server_json.py +79 -0
  74. package/scripts/test_verdict.py +102 -98
  75. package/scripts/test_vision.py +231 -228
  76. package/serve.py +3972 -3764
  77. package/suite/mine.py +154 -154
  78. package/suite/pace.py +145 -145
  79. package/suite/run.py +1046 -1042
  80. package/suite/seed.py +421 -421
  81. package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
  82. package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
  83. package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
  84. package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
  85. package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
  86. package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
  87. package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
  88. package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
  89. package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
  90. package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
  91. package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
  92. package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
  93. package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
  94. package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
  95. package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
  96. package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
  97. package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
  98. package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
  99. package/ui/bench.css +430 -425
  100. package/ui/bench.js +1757 -1576
  101. package/ui/themes/dark.css +35 -35
  102. package/ui/themes/hardwood.css +52 -52
  103. package/ui/themes/rainforest.css +32 -32
@@ -1,206 +1,206 @@
1
- """The LiteLLM hub adapter: a model added as a new deployment on a connection LiteLLM already has.
2
-
3
- python metrics/litellm_hub.py <connection> <model id> # what would change, changes nothing
4
-
5
- Named litellm_hub, not litellm: metrics/ is on sys.path, and a module called
6
- litellm would stand in for the real package in anything that imports it.
7
-
8
- LiteLLM serves each model as a deployment: a public `model_name` and the
9
- `litellm_params` that reach the upstream (`groq/<id>`, an `api_base`, a key).
10
- A model is added with POST /model/new, which works only in LiteLLM's database
11
- mode (DATABASE_URL set, and STORE_MODEL_IN_DB=True or general_settings
12
- store_model_in_db), and only with the master key or an admin's: the
13
- endpoint's admin key (endpoints.admin_key, falling back to the endpoint's
14
- own key, which is often the master key).
15
-
16
- A CONNECTION is an upstream: the host of a deployment's `api_base` when it
17
- sets one (an `openai/...` deployment pointed at any OpenAI-compatible API),
18
- else its provider (`groq` for `groq/llama-...`). The new deployment copies
19
- one sibling's connection-level params (COPIED), so it reaches the same
20
- upstream the same way. LiteLLM never hands an `api_key` back, so a sibling's
21
- key travels only as a stored credential (`litellm_credential_name`); a
22
- sibling without one is copied without a key, and LiteLLM then uses the
23
- provider's environment variable (GROQ_API_KEY), as that sibling does unless it
24
- carries a key of its own. Onboarding's probe says which.
25
- ponytail: one connection per upstream; two accounts on one provider count as
26
- one, and the sibling with a credential is the one copied.
27
-
28
- Checked against LiteLLM 1.106.1's source (2026-10-21):
29
- proxy/management_endpoints/model_management_endpoints.py (POST /model/new:
30
- no database refuses with "No DB Connected", database mode off with
31
- "STORE_MODEL_IN_DB", both before anything is written; the reply is the stored
32
- row, which may carry the encrypted key, so nothing here prints it),
33
- proxy/proxy_server.py (GET /model/info: `data`, each with `model_name`,
34
- `litellm_params` without `api_key` and other secrets masked with '*',
35
- `model_info.db_model`), proxy/health_endpoints (GET /health/readiness/details:
36
- `db` and `litellm_version`; releases before it said both on /health/readiness).
37
- """
38
-
39
- from __future__ import annotations
40
-
41
- import json
42
- import pathlib
43
- import sys
44
- import time
45
- import urllib.error
46
- import urllib.parse
47
- import urllib.request
48
-
49
- ROOT = pathlib.Path(__file__).resolve().parent.parent
50
- sys.path.insert(0, str(ROOT / "metrics"))
51
- import endpoints # noqa: E402
52
-
53
- # What this hub takes (RELEASE-PLAN section 2, "Adding a model to a hub"); "" where it has none.
54
- SUPPORTS = {"allow_list": "deployments, as one more",
55
- "tags": "a label in model_info (LiteLLM's own tags drive routing, so not those)", "logo": "",
56
- "access": "open to every key without a model list (access groups, keys and teams stay LiteLLM's)",
57
- "link": "/ui/models-and-endpoints?model=<deployment id>"}
58
- CHECKED = ("1.106",)
59
- # A sibling's params that say where the upstream is and how to reach it, not
60
- # which model it is or what it costs.
61
- COPIED = ("api_base", "api_version", "custom_llm_provider", "litellm_credential_name", "organization",
62
- "extra_headers", "timeout", "stream_timeout", "max_retries")
63
- NO_DB = ("LiteLLM runs without a database, and adds a model only in its database mode: give it"
64
- " DATABASE_URL (Postgres) and STORE_MODEL_IN_DB=True, restart it, then add again")
65
-
66
-
67
- def endpoint() -> str:
68
- """The LiteLLM hub's endpoint name; ValueError when there is none."""
69
- name = endpoints.find_hub("litellm")
70
- if not name:
71
- raise ValueError("no endpoint is a LiteLLM hub; add it on the Endpoints page as kind hub, LiteLLM")
72
- return name
73
-
74
-
75
- def _call(path: str, body: dict | None = None):
76
- name = endpoint()
77
- root = endpoints.base(name).removesuffix("/v1")
78
- req = urllib.request.Request(root + path, data=None if body is None else json.dumps(body).encode(),
79
- method="GET" if body is None else "POST",
80
- headers={"Authorization": f"Bearer {endpoints.admin_key(name)}",
81
- "Content-Type": "application/json", "Accept": "application/json"})
82
- with urllib.request.urlopen(req, timeout=15 if body is None else 60) as r:
83
- return json.loads(r.read() or b"null")
84
-
85
-
86
- def _said(exc: urllib.error.HTTPError) -> str:
87
- """LiteLLM's message from its {"error": {"message": ...}} reply, trimmed."""
88
- text = exc.read().decode("utf-8", "replace")
89
- try:
90
- err = json.loads(text).get("error")
91
- text = str(err.get("message") if isinstance(err, dict) else err or text)
92
- except (ValueError, AttributeError):
93
- pass
94
- return text[:300]
95
-
96
-
97
- def _upstream(d: dict) -> tuple[str, str, str | None]:
98
- """A deployment's (connection, base URL or "", the provider prefix a new
99
- model id takes, None when its model string is passed through as is)."""
100
- p = d.get("litellm_params") or {}
101
- model, base = str(p.get("model") or ""), str(p.get("api_base") or "").rstrip("/")
102
- prefix = None if p.get("custom_llm_provider") or "/" not in model else model.split("/", 1)[0]
103
- name = urllib.parse.urlsplit(base).hostname if base else p.get("custom_llm_provider") or prefix or "openai"
104
- return name or base, base, prefix
105
-
106
-
107
- def _deployments() -> list[dict]:
108
- return (_call("/model/info") or {}).get("data") or []
109
-
110
-
111
- def upstreams() -> dict[str, tuple[str, str]]:
112
- """Connection -> (base URL or "", the prefix served ids take: none on LiteLLM).
113
- What Discover matches a provider against (discover.hub_routes)."""
114
- return {n: (b, "") for n, b, _p in map(_upstream, _deployments())}
115
-
116
-
117
- def _ready() -> dict:
118
- """The readiness details (any valid key), or the public probe on a release before them."""
119
- try:
120
- return _call("/health/readiness/details") or {}
121
- except urllib.error.HTTPError as exc:
122
- if exc.code != 404:
123
- raise
124
- return _call("/health/readiness") or {}
125
-
126
-
127
- def plan(connection: str, model_id: str) -> dict:
128
- """What adding would send. Writes nothing; ValueError when it cannot be done here."""
129
- ready = _ready()
130
- release, db = str(ready.get("litellm_version") or ""), str(ready.get("db") or "")
131
- if db == "Not connected":
132
- raise ValueError(NO_DB)
133
- if db and db != "connected":
134
- raise ValueError(f"LiteLLM's database is {db}; adding a model needs it reachable")
135
- deps = _deployments()
136
- mine = [d for d in deps if _upstream(d)[0] == connection]
137
- if not mine:
138
- raise ValueError(f"no LiteLLM deployment is on a connection called {connection!r} (a provider such as"
139
- " groq, or the host of an api_base); add one model there in LiteLLM first")
140
- # One name, two upstreams: LiteLLM would spread the calls across both.
141
- same = [d for d in deps if d.get("model_name") == model_id]
142
- other = next((_upstream(d)[0] for d in same if _upstream(d)[0] != connection), None)
143
- if other:
144
- raise ValueError(f"LiteLLM already serves {model_id} from {other}; a second deployment under that"
145
- " name would share its calls with it")
146
- sib = next((d for d in mine if (d.get("litellm_params") or {}).get("litellm_credential_name")), mine[0])
147
- p, (_n, base, prefix) = sib.get("litellm_params") or {}, _upstream(sib)
148
- # Masked values ('*') are LiteLLM hiding a secret; sent back they would
149
- # replace it. A dict holding one (extra_headers) is left out whole, and said.
150
- given = [k for k in COPIED if p.get(k) not in (None, "", {})]
151
- params = {k: p[k] for k in given if "**" not in json.dumps(p[k])}
152
- params["model"] = f"{prefix}/{model_id}" if prefix else model_id
153
- cred = params.get("litellm_credential_name")
154
- return {"connection": connection, "base_url": base, "model_id": model_id, "served": model_id,
155
- "deployment": "exists" if same else "create", "copy_from": sib.get("model_name"),
156
- "litellm_params": params, "model_info": {"tags": [connection]},
157
- "not_copied": [k for k in given if k not in params],
158
- "key": f"the stored credential {cred}" if cred else "none copied (LiteLLM's environment variable for it)",
159
- "database_mode": True if any((d.get("model_info") or {}).get("db_model") for d in deps)
160
- else "not shown until the add, which writes nothing if it is off",
161
- "version": release, "version_note": endpoints.version_note("litellm", release, CHECKED)}
162
-
163
-
164
- def add(connection: str, model_id: str) -> tuple[str, list[str]]:
165
- """Do it. (served id, what was done)."""
166
- p = plan(connection, model_id)
167
- done = [p["version_note"]] if p["version_note"] else []
168
- if p["deployment"] == "exists":
169
- return p["served"], done + [f"LiteLLM already has a {model_id} deployment on {connection}; nothing added"]
170
- body = {"model_name": p["served"], "litellm_params": p["litellm_params"], "model_info": p["model_info"]}
171
- try:
172
- made = _call("/model/new", body) or {}
173
- except urllib.error.HTTPError as exc:
174
- said = _said(exc)
175
- if "STORE_MODEL_IN_DB" in said:
176
- raise ValueError("LiteLLM has a database but keeps its models in config.yaml: set STORE_MODEL_IN_DB=True"
177
- " (or general_settings: store_model_in_db: true), restart it, then add again;"
178
- " nothing was added") from None
179
- if "No DB Connected" in said:
180
- raise ValueError(NO_DB) from None
181
- if exc.code in (401, 403):
182
- raise ValueError(f"LiteLLM refused the key for /model/new ({said}): it takes the master key or an"
183
- " admin's; store it as this endpoint's admin key") from None
184
- raise ValueError(f"LiteLLM refused /model/new: {exc.code} {said}") from None
185
- done.append(f"created a {model_id} deployment on the {connection} connection (LiteLLM id"
186
- f" {made.get('model_id', '?')}), reaching it as {p['copy_from']} does, key: {p['key']};"
187
- f" labelled {connection}" + (f"; not copied, as LiteLLM masks them: {', '.join(p['not_copied'])}"
188
- if p["not_copied"] else ""))
189
- for _ in range(10): # LiteLLM reloads its router from the database
190
- if p["served"] in endpoints.models(endpoint()):
191
- break
192
- time.sleep(2)
193
- else:
194
- done.append(f"LiteLLM does not list {p['served']} yet; onboarding will say if it never does")
195
- return p["served"], done
196
-
197
-
198
- def main() -> None:
199
- sys.stdout.reconfigure(encoding="utf-8", errors="replace")
200
- if len(sys.argv) != 3:
201
- sys.exit(__doc__)
202
- print(json.dumps(plan(sys.argv[1], sys.argv[2]), indent=2))
203
-
204
-
205
- if __name__ == "__main__":
206
- main()
1
+ """The LiteLLM hub adapter: a model added as a new deployment on a connection LiteLLM already has.
2
+
3
+ python metrics/litellm_hub.py <connection> <model id> # what would change, changes nothing
4
+
5
+ Named litellm_hub, not litellm: metrics/ is on sys.path, and a module called
6
+ litellm would stand in for the real package in anything that imports it.
7
+
8
+ LiteLLM serves each model as a deployment: a public `model_name` and the
9
+ `litellm_params` that reach the upstream (`groq/<id>`, an `api_base`, a key).
10
+ A model is added with POST /model/new, which works only in LiteLLM's database
11
+ mode (DATABASE_URL set, and STORE_MODEL_IN_DB=True or general_settings
12
+ store_model_in_db), and only with the master key or an admin's: the
13
+ endpoint's admin key (endpoints.admin_key, falling back to the endpoint's
14
+ own key, which is often the master key).
15
+
16
+ A CONNECTION is an upstream: the host of a deployment's `api_base` when it
17
+ sets one (an `openai/...` deployment pointed at any OpenAI-compatible API),
18
+ else its provider (`groq` for `groq/llama-...`). The new deployment copies
19
+ one sibling's connection-level params (COPIED), so it reaches the same
20
+ upstream the same way. LiteLLM never hands an `api_key` back, so a sibling's
21
+ key travels only as a stored credential (`litellm_credential_name`); a
22
+ sibling without one is copied without a key, and LiteLLM then uses the
23
+ provider's environment variable (GROQ_API_KEY), as that sibling does unless it
24
+ carries a key of its own. Onboarding's probe says which.
25
+ ponytail: one connection per upstream; two accounts on one provider count as
26
+ one, and the sibling with a credential is the one copied.
27
+
28
+ Checked against LiteLLM 1.106.1's source (2026-10-21):
29
+ proxy/management_endpoints/model_management_endpoints.py (POST /model/new:
30
+ no database refuses with "No DB Connected", database mode off with
31
+ "STORE_MODEL_IN_DB", both before anything is written; the reply is the stored
32
+ row, which may carry the encrypted key, so nothing here prints it),
33
+ proxy/proxy_server.py (GET /model/info: `data`, each with `model_name`,
34
+ `litellm_params` without `api_key` and other secrets masked with '*',
35
+ `model_info.db_model`), proxy/health_endpoints (GET /health/readiness/details:
36
+ `db` and `litellm_version`; releases before it said both on /health/readiness).
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import json
42
+ import pathlib
43
+ import sys
44
+ import time
45
+ import urllib.error
46
+ import urllib.parse
47
+ import urllib.request
48
+
49
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
50
+ sys.path.insert(0, str(ROOT / "metrics"))
51
+ import endpoints # noqa: E402
52
+
53
+ # What this hub takes (RELEASE-PLAN section 2, "Adding a model to a hub"); "" where it has none.
54
+ SUPPORTS = {"allow_list": "deployments, as one more",
55
+ "tags": "a label in model_info (LiteLLM's own tags drive routing, so not those)", "logo": "",
56
+ "access": "open to every key without a model list (access groups, keys and teams stay LiteLLM's)",
57
+ "link": "/ui/models-and-endpoints?model=<deployment id>"}
58
+ CHECKED = ("1.106",)
59
+ # A sibling's params that say where the upstream is and how to reach it, not
60
+ # which model it is or what it costs.
61
+ COPIED = ("api_base", "api_version", "custom_llm_provider", "litellm_credential_name", "organization",
62
+ "extra_headers", "timeout", "stream_timeout", "max_retries")
63
+ NO_DB = ("LiteLLM runs without a database, and adds a model only in its database mode: give it"
64
+ " DATABASE_URL (Postgres) and STORE_MODEL_IN_DB=True, restart it, then add again")
65
+
66
+
67
+ def endpoint() -> str:
68
+ """The LiteLLM hub's endpoint name; ValueError when there is none."""
69
+ name = endpoints.find_hub("litellm")
70
+ if not name:
71
+ raise ValueError("no endpoint is a LiteLLM hub; add it on the Endpoints page as kind hub, LiteLLM")
72
+ return name
73
+
74
+
75
+ def _call(path: str, body: dict | None = None):
76
+ name = endpoint()
77
+ root = endpoints.base(name).removesuffix("/v1")
78
+ req = urllib.request.Request(root + path, data=None if body is None else json.dumps(body).encode(),
79
+ method="GET" if body is None else "POST",
80
+ headers={"Authorization": f"Bearer {endpoints.admin_key(name)}",
81
+ "Content-Type": "application/json", "Accept": "application/json"})
82
+ with urllib.request.urlopen(req, timeout=15 if body is None else 60) as r:
83
+ return json.loads(r.read() or b"null")
84
+
85
+
86
+ def _said(exc: urllib.error.HTTPError) -> str:
87
+ """LiteLLM's message from its {"error": {"message": ...}} reply, trimmed."""
88
+ text = exc.read().decode("utf-8", "replace")
89
+ try:
90
+ err = json.loads(text).get("error")
91
+ text = str(err.get("message") if isinstance(err, dict) else err or text)
92
+ except (ValueError, AttributeError):
93
+ pass
94
+ return text[:300]
95
+
96
+
97
+ def _upstream(d: dict) -> tuple[str, str, str | None]:
98
+ """A deployment's (connection, base URL or "", the provider prefix a new
99
+ model id takes, None when its model string is passed through as is)."""
100
+ p = d.get("litellm_params") or {}
101
+ model, base = str(p.get("model") or ""), str(p.get("api_base") or "").rstrip("/")
102
+ prefix = None if p.get("custom_llm_provider") or "/" not in model else model.split("/", 1)[0]
103
+ name = urllib.parse.urlsplit(base).hostname if base else p.get("custom_llm_provider") or prefix or "openai"
104
+ return name or base, base, prefix
105
+
106
+
107
+ def _deployments() -> list[dict]:
108
+ return (_call("/model/info") or {}).get("data") or []
109
+
110
+
111
+ def upstreams() -> dict[str, tuple[str, str]]:
112
+ """Connection -> (base URL or "", the prefix served ids take: none on LiteLLM).
113
+ What Discover matches a provider against (discover.hub_routes)."""
114
+ return {n: (b, "") for n, b, _p in map(_upstream, _deployments())}
115
+
116
+
117
+ def _ready() -> dict:
118
+ """The readiness details (any valid key), or the public probe on a release before them."""
119
+ try:
120
+ return _call("/health/readiness/details") or {}
121
+ except urllib.error.HTTPError as exc:
122
+ if exc.code != 404:
123
+ raise
124
+ return _call("/health/readiness") or {}
125
+
126
+
127
+ def plan(connection: str, model_id: str) -> dict:
128
+ """What adding would send. Writes nothing; ValueError when it cannot be done here."""
129
+ ready = _ready()
130
+ release, db = str(ready.get("litellm_version") or ""), str(ready.get("db") or "")
131
+ if db == "Not connected":
132
+ raise ValueError(NO_DB)
133
+ if db and db != "connected":
134
+ raise ValueError(f"LiteLLM's database is {db}; adding a model needs it reachable")
135
+ deps = _deployments()
136
+ mine = [d for d in deps if _upstream(d)[0] == connection]
137
+ if not mine:
138
+ raise ValueError(f"no LiteLLM deployment is on a connection called {connection!r} (a provider such as"
139
+ " groq, or the host of an api_base); add one model there in LiteLLM first")
140
+ # One name, two upstreams: LiteLLM would spread the calls across both.
141
+ same = [d for d in deps if d.get("model_name") == model_id]
142
+ other = next((_upstream(d)[0] for d in same if _upstream(d)[0] != connection), None)
143
+ if other:
144
+ raise ValueError(f"LiteLLM already serves {model_id} from {other}; a second deployment under that"
145
+ " name would share its calls with it")
146
+ sib = next((d for d in mine if (d.get("litellm_params") or {}).get("litellm_credential_name")), mine[0])
147
+ p, (_n, base, prefix) = sib.get("litellm_params") or {}, _upstream(sib)
148
+ # Masked values ('*') are LiteLLM hiding a secret; sent back they would
149
+ # replace it. A dict holding one (extra_headers) is left out whole, and said.
150
+ given = [k for k in COPIED if p.get(k) not in (None, "", {})]
151
+ params = {k: p[k] for k in given if "**" not in json.dumps(p[k])}
152
+ params["model"] = f"{prefix}/{model_id}" if prefix else model_id
153
+ cred = params.get("litellm_credential_name")
154
+ return {"connection": connection, "base_url": base, "model_id": model_id, "served": model_id,
155
+ "deployment": "exists" if same else "create", "copy_from": sib.get("model_name"),
156
+ "litellm_params": params, "model_info": {"tags": [connection]},
157
+ "not_copied": [k for k in given if k not in params],
158
+ "key": f"the stored credential {cred}" if cred else "none copied (LiteLLM's environment variable for it)",
159
+ "database_mode": True if any((d.get("model_info") or {}).get("db_model") for d in deps)
160
+ else "not shown until the add, which writes nothing if it is off",
161
+ "version": release, "version_note": endpoints.version_note("litellm", release, CHECKED)}
162
+
163
+
164
+ def add(connection: str, model_id: str) -> tuple[str, list[str]]:
165
+ """Do it. (served id, what was done)."""
166
+ p = plan(connection, model_id)
167
+ done = [p["version_note"]] if p["version_note"] else []
168
+ if p["deployment"] == "exists":
169
+ return p["served"], done + [f"LiteLLM already has a {model_id} deployment on {connection}; nothing added"]
170
+ body = {"model_name": p["served"], "litellm_params": p["litellm_params"], "model_info": p["model_info"]}
171
+ try:
172
+ made = _call("/model/new", body) or {}
173
+ except urllib.error.HTTPError as exc:
174
+ said = _said(exc)
175
+ if "STORE_MODEL_IN_DB" in said:
176
+ raise ValueError("LiteLLM has a database but keeps its models in config.yaml: set STORE_MODEL_IN_DB=True"
177
+ " (or general_settings: store_model_in_db: true), restart it, then add again;"
178
+ " nothing was added") from None
179
+ if "No DB Connected" in said:
180
+ raise ValueError(NO_DB) from None
181
+ if exc.code in (401, 403):
182
+ raise ValueError(f"LiteLLM refused the key for /model/new ({said}): it takes the master key or an"
183
+ " admin's; store it as this endpoint's admin key") from None
184
+ raise ValueError(f"LiteLLM refused /model/new: {exc.code} {said}") from None
185
+ done.append(f"created a {model_id} deployment on the {connection} connection (LiteLLM id"
186
+ f" {made.get('model_id', '?')}), reaching it as {p['copy_from']} does, key: {p['key']};"
187
+ f" labelled {connection}" + (f"; not copied, as LiteLLM masks them: {', '.join(p['not_copied'])}"
188
+ if p["not_copied"] else ""))
189
+ for _ in range(10): # LiteLLM reloads its router from the database
190
+ if p["served"] in endpoints.models(endpoint()):
191
+ break
192
+ time.sleep(2)
193
+ else:
194
+ done.append(f"LiteLLM does not list {p['served']} yet; onboarding will say if it never does")
195
+ return p["served"], done
196
+
197
+
198
+ def main() -> None:
199
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
200
+ if len(sys.argv) != 3:
201
+ sys.exit(__doc__)
202
+ print(json.dumps(plan(sys.argv[1], sys.argv[2]), indent=2))
203
+
204
+
205
+ if __name__ == "__main__":
206
+ main()
@@ -1,84 +1,94 @@
1
- """What a model is FOR, so the router does not seat a code-completion model to
2
- review a design, a small instruct model to judge a large packet, or an image
3
- model to enumerate call sites.
4
-
5
- `config/model-roles.json` in the data folder gives each alias a ROLE under
6
- `models` (an alias not listed is `general`) and lists `ad_hoc` aliases. Those
7
- two are the install's own. The roles themselves and the task SHAPES that admit
8
- them are the product's, so they are defined here and every install gets them;
9
- until the split (RELEASE-PLAN Phase 1) all four lived in one personal file.
10
-
11
- roles: general reasoning / do-it-all: eligible for every shape
12
- code trained for code completion and edits; sharp on "find every
13
- place X happens" and "is this function wrong", weak on
14
- process, prose and design judgement
15
- light small, fast instruct models (nano/3b/12b/30b-a3b class): short
16
- bounded tasks (classify, extract, summarise, enumerate over a
17
- small file); not review or design over a large packet
18
- image image generation; never a reviewer
19
- decision typed answers with probabilities through a Decisions API, not
20
- chat; no shape admits them (config/decision-models.json)
21
-
22
- shapes: today's task, `route.py --shape <name>`; general when none is given.
23
- `quick` is a short, bounded task where a fast small model is right.
24
- `vision` is a question about images: the gateway gives it to a
25
- request that carries any, unless its collection sets a shape, and
26
- whatever the shape fills explorer seats only with models the catalog
27
- says take images (gateway.py). A role is what a model is for, not
28
- what it can read, so vision admits the roles that answer questions.
29
-
30
- ad_hoc: aliases NEVER chosen automatically: not by the router's seats or
31
- reserve, not by a benchmark sweep or `--next` rotation, not by the
32
- daily perf run. Orthogonal to role: a role says what a model is FOR,
33
- this says what it COSTS. They answer only when named (`--with`,
34
- `llm -m`, a benchmark's `--model`).
35
-
36
- A role never lowers a score: rank is decided by evidence when seats are
37
- picked; a role only decides which shapes a model is eligible for.
38
- """
39
-
40
- from __future__ import annotations
41
-
42
- import locked
43
- from paths import DATA # the install's data folder (metrics/paths.py)
44
-
45
- FILE = DATA / "config" / "model-roles.json"
46
-
47
- ROLES = {
48
- "general": "reasoning / do-it-all — eligible for every task shape",
49
- "code": "code completion and edits — eligible for review, code and enumeration;"
50
- " weak on design, process and prose",
51
- "light": "small, fast instruct model — eligible for quick and enumeration;"
52
- " not for review or design over a large packet",
53
- "image": "image generation — never a reviewer",
54
- "decision": "typed choice / score / yes-no with probabilities, through a Decisions API"
55
- " — no chat, never a seat; see config/decision-models.json",
56
- }
57
- SHAPES = {
58
- "general": ["general"],
59
- "design": ["general"],
60
- "prose": ["general"],
61
- "review": ["general", "code"],
62
- "code": ["general", "code"],
63
- "enumeration": ["general", "code", "light"],
64
- "quick": ["general", "light"],
65
- "vision": ["general", "light"],
66
- "image": ["image"],
67
- }
68
-
69
-
70
- def load() -> dict:
71
- """{roles, shapes, models, ad_hoc}; {} parts when the file is missing.
72
-
73
- The data file's own `roles` and `shapes` win over the shipped ones, name by
74
- name. Files from before the split hold all four keys, with wording their
75
- owner may have tuned, and an upgrade must not silently replace it; a role
76
- or shape the product adds later still arrives, because only the names a
77
- file defines are overridden.
78
- ponytail: a file can redefine a shipped shape but not remove one; add a
79
- null-means-remove rule if anyone needs it."""
80
- data = locked.read_json(FILE)
81
- return {"roles": {**ROLES, **(data.get("roles") or {})},
82
- "shapes": {**SHAPES, **(data.get("shapes") or {})},
83
- "models": data.get("models") or {},
84
- "ad_hoc": data.get("ad_hoc") or []}
1
+ """What a model is FOR, so the router does not seat a code-completion model to
2
+ review a design, a small instruct model to judge a large packet, or an image
3
+ model to enumerate call sites.
4
+
5
+ `config/model-roles.json` in the data folder gives each alias a ROLE under
6
+ `models` (an alias not listed is `general`) and lists `ad_hoc` aliases. Those
7
+ two are the install's own. The roles themselves and the task SHAPES that admit
8
+ them are the product's, so they are defined here and every install gets them;
9
+ until the split (RELEASE-PLAN Phase 1) all four lived in one personal file.
10
+
11
+ roles: general reasoning / do-it-all: eligible for every shape
12
+ code trained for code completion and edits; sharp on "find every
13
+ place X happens" and "is this function wrong", weak on
14
+ process, prose and design judgement
15
+ light small, fast instruct models (nano/3b/12b/30b-a3b class): short
16
+ bounded tasks (classify, extract, summarise, enumerate over a
17
+ small file); not review or design over a large packet
18
+ image image generation; never a reviewer
19
+ vision a specialist at reading images (OCR, documents, screenshots,
20
+ charts), weak at text-only work: admitted by the vision shape
21
+ alone, and a collection member with this role sits only when
22
+ the request carries images (gateway.py). A model that reads
23
+ images AND does everything else stays general.
24
+ decision typed answers with probabilities through a Decisions API, not
25
+ chat; no shape admits them (config/decision-models.json)
26
+
27
+ shapes: today's task, `route.py --shape <name>`; general when none is given.
28
+ `quick` is a short, bounded task where a fast small model is right.
29
+ `vision` is a question about images: the gateway gives it to a
30
+ request that carries any, unless its collection sets a shape, and
31
+ whatever the shape fills explorer seats only with models the catalog
32
+ says take images (gateway.py). A role is what a model is for, not
33
+ what it can read, so vision admits the roles that answer questions.
34
+
35
+ ad_hoc: aliases NEVER chosen automatically: not by the router's seats or
36
+ reserve, not by a benchmark sweep or `--next` rotation, not by the
37
+ daily perf run. Orthogonal to role: a role says what a model is FOR,
38
+ this says what it COSTS. They answer only when named: `--with` or
39
+ the gateway's `with`, a collection that lists them as a member or
40
+ merger (every request through it spends their quota), `llm -m`, a
41
+ benchmark's `--model`. `bench onboard --quick --ad-hoc` (or Quick + ad
42
+ hoc in the picker) onboards one this way from the start.
43
+
44
+ A role never lowers a score: rank is decided by evidence when seats are
45
+ picked; a role only decides which shapes a model is eligible for.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import locked
51
+ from paths import DATA # the install's data folder (metrics/paths.py)
52
+
53
+ FILE = DATA / "config" / "model-roles.json"
54
+
55
+ ROLES = {
56
+ "general": "reasoning / do-it-all — eligible for every task shape",
57
+ "code": "code completion and edits — eligible for review, code and enumeration;"
58
+ " weak on design, process and prose",
59
+ "light": "small, fast instruct model — eligible for quick and enumeration;"
60
+ " not for review or design over a large packet",
61
+ "image": "image generation — never a reviewer",
62
+ "vision": "reads images (OCR, documents, screenshots) — sits only for requests that carry"
63
+ " images; never a text reviewer",
64
+ "decision": "typed choice / score / yes-no with probabilities, through a Decisions API"
65
+ " — no chat, never a seat; see config/decision-models.json",
66
+ }
67
+ SHAPES = {
68
+ "general": ["general"],
69
+ "design": ["general"],
70
+ "prose": ["general"],
71
+ "review": ["general", "code"],
72
+ "code": ["general", "code"],
73
+ "enumeration": ["general", "code", "light"],
74
+ "quick": ["general", "light"],
75
+ "vision": ["general", "light", "vision"],
76
+ "image": ["image"],
77
+ }
78
+
79
+
80
+ def load() -> dict:
81
+ """{roles, shapes, models, ad_hoc}; {} parts when the file is missing.
82
+
83
+ The data file's own `roles` and `shapes` win over the shipped ones, name by
84
+ name. Files from before the split hold all four keys, with wording their
85
+ owner may have tuned, and an upgrade must not silently replace it; a role
86
+ or shape the product adds later still arrives, because only the names a
87
+ file defines are overridden.
88
+ ponytail: a file can redefine a shipped shape but not remove one; add a
89
+ null-means-remove rule if anyone needs it."""
90
+ data = locked.read_json(FILE)
91
+ return {"roles": {**ROLES, **(data.get("roles") or {})},
92
+ "shapes": {**SHAPES, **(data.get("shapes") or {})},
93
+ "models": data.get("models") or {},
94
+ "ad_hoc": data.get("ad_hoc") or []}