@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
package/metrics/owui.py CHANGED
@@ -1,205 +1,205 @@
1
- """The Open WebUI hub adapter: add a model as it is otherwise done by hand, then hand it to onboarding.
2
-
3
- python metrics/owui.py <connection> <model id> # what would change, changes nothing
4
-
5
- One of three hub adapters (litellm_hub.py, cliproxy.py), each exposing
6
- SUPPORTS, CHECKED, endpoint(), plan() and add(); serve.discover_add picks one
7
- by the endpoint's hub (endpoints.adapter).
8
-
9
- THE HAND STEPS (2026-10-18)
10
- ---------------------------
11
- 1. Edit the connection: add the model to its Model IDs list (advanced
12
- settings). Only when the list is in use; an empty list means Open WebUI
13
- already shows everything the provider serves.
14
- 2. Find the model in Admin > Models: make it public, tag it with the
15
- connection's name (`nvidia-api`, `groq`, ...), give it the provider logo.
16
-
17
- The logo, the capability switches and the default features are copied from
18
- a model already on the same connection, because every model on a connection
19
- carries the same ones. A connection is found by its tag: the Open WebUI
20
- connection (`urlIdx`) its tagged models are served from. Then
21
- `scripts/onboard.py` registers, probes and runs the suite as for any model.
22
-
23
- Admin calls with the endpoint's admin key (endpoints.admin_key: an admin's
24
- API key, which is the endpoint's own key unless one is stored apart); keys
25
- are never printed, and the config is written back exactly as read, plus one
26
- id. The endpoint is whichever is a hub of type openwebui (`endpoints.find_hub`);
27
- an install without one has no Open WebUI to add to, and `endpoint()` says so.
28
- """
29
-
30
- from __future__ import annotations
31
-
32
- import collections
33
- import json
34
- import pathlib
35
- import sys
36
- import time
37
- import urllib.request
38
-
39
- ROOT = pathlib.Path(__file__).resolve().parent.parent
40
- sys.path.insert(0, str(ROOT / "metrics"))
41
- sys.path.insert(0, str(ROOT / "suite"))
42
- import endpoints # noqa: E402
43
-
44
- PUBLIC = [{"principal_type": "user", "principal_id": "*", "permission": "read"}]
45
- COPIED = ("profile_image_url", "capabilities", "defaultFeatureIds")
46
- # What this hub takes (RELEASE-PLAN section 2, "Adding a model to a hub"); the
47
- # dashboard says only what is here. "" where the hub has no such thing.
48
- SUPPORTS = {"allow_list": "Model IDs list", "tags": "the connection's tag",
49
- "logo": "copied from the connection's other models (PNG, JPEG, GIF or WebP)",
50
- "access": "public: every signed-in user",
51
- "link": "/?settings=admin:models (v0.11+, the Models tab; up to v0.10 /admin/settings/models?id=<id>)"}
52
- CHECKED = ("0.12",) # 0.12.3, 2026-10-21: the routes below and /api/version
53
-
54
-
55
- def endpoint() -> str:
56
- """The Open WebUI hub's endpoint name. ValueError when there is none: the
57
- dashboard shows it as the reason, and nothing has been sent anywhere."""
58
- name = endpoints.find_hub("openwebui")
59
- if not name:
60
- raise ValueError("no endpoint is an Open WebUI hub; add it on the Endpoints page as kind hub, Open WebUI")
61
- return name
62
-
63
-
64
- def _call(path: str, body: dict | None = None):
65
- name = endpoint()
66
- root = endpoints.base(name).removesuffix("/api")
67
- req = urllib.request.Request(root + path, data=None if body is None else json.dumps(body).encode(),
68
- method="GET" if body is None else "POST",
69
- headers={"Authorization": f"Bearer {endpoints.admin_key(name)}",
70
- "Content-Type": "application/json", "Accept": "application/json"})
71
- with urllib.request.urlopen(req, timeout=60) as r:
72
- return json.loads(r.read() or b"null")
73
-
74
-
75
- def version() -> tuple[str, str]:
76
- """(release, endpoints.version_note's sentence). Advice only: a hub that
77
- will not say is still added to."""
78
- try:
79
- v = str((_call("/api/version") or {}).get("version") or "")
80
- except Exception: # noqa: BLE001
81
- v = ""
82
- return v, endpoints.version_note("openwebui", v, CHECKED)
83
-
84
-
85
- def connections() -> dict[str, tuple[int, str]]:
86
- """Connection name -> (Open WebUI connection index, the tag its models carry).
87
-
88
- The bench names a connection from config/provider-tags.json (`groq`,
89
- `mistral`), Open WebUI's tags differ (`groq-api`, `mistral-studio`), so
90
- the two meet at the index the bench's own registered models are served
91
- from. Open WebUI's tag names are accepted as well."""
92
- import route
93
- from run import _registered_names
94
- data = _call("/api/models").get("data", [])
95
- by_id = {m["id"]: m for m in data}
96
- tag_at: dict[int, collections.Counter] = collections.defaultdict(collections.Counter)
97
- for m in data:
98
- for t in m.get("tags") or []:
99
- if m.get("urlIdx") is not None:
100
- tag_at[m["urlIdx"]][t["name"]] += 1
101
- at: dict[str, collections.Counter] = collections.defaultdict(collections.Counter)
102
- for alias, served in _registered_names().items():
103
- m = by_id.get(served)
104
- if m and m.get("urlIdx") is not None:
105
- at[route.connection_of(alias)][m["urlIdx"]] += 1
106
- out = {}
107
- for name, c in at.items():
108
- idx = c.most_common(1)[0][0]
109
- tags = tag_at.get(idx) # .get: indexing a defaultdict would add an empty one
110
- out[name] = (idx, tags.most_common(1)[0][0] if tags else name)
111
- for idx, c in tag_at.items():
112
- tag = c.most_common(1)[0][0]
113
- out.setdefault(tag, (idx, tag))
114
- return out
115
-
116
-
117
- def plan(connection: str, model_id: str) -> dict:
118
- """What adding would change. Raises ValueError when it cannot be done here."""
119
- release, note = version()
120
- found = connections().get(connection)
121
- if found is None:
122
- raise ValueError(f"no Open WebUI connection is known as {connection!r}: none of the bench's"
123
- " models is served from it and no Open WebUI model carries that tag")
124
- idx, tag = found
125
- cfg = _call("/openai/config")
126
- if idx >= len(cfg.get("OPENAI_API_BASE_URLS") or []):
127
- raise ValueError(f"Open WebUI's connections changed while this ran; try again")
128
- conn = (cfg.get("OPENAI_API_CONFIGS") or {}).get(str(idx), {})
129
- if conn.get("enable") is False:
130
- raise ValueError(f"the {connection} connection is switched off in Open WebUI")
131
- prefix = conn.get("prefix_id") or ""
132
- served = f"{prefix}.{model_id}" if prefix else model_id
133
- # One id, two connections: Open WebUI would serve only one of them.
134
- elsewhere = next((m.get("urlIdx") for m in _call("/api/models").get("data", [])
135
- if m.get("id") == served and m.get("urlIdx") not in (None, idx)), None)
136
- if elsewhere is not None:
137
- raise ValueError(f"Open WebUI already serves {served} from connection {elsewhere}; give the"
138
- f" {connection} connection a prefix in Open WebUI so the ids differ")
139
- listed = conn.get("model_ids") or []
140
- records = {m["id"]: m for m in _call("/api/v1/models/base")}
141
- sibling = next((m for m in records.values()
142
- if any(t.get("name") == tag for t in (m.get("meta") or {}).get("tags") or [])
143
- and (m.get("meta") or {}).get("profile_image_url")), None)
144
- return {"connection": connection, "tag": tag, "index": idx, "base_url": cfg["OPENAI_API_BASE_URLS"][idx],
145
- "model_id": model_id, "served": served,
146
- "add_to_list": bool(listed) and model_id not in listed,
147
- "record": "update" if served in records else "create",
148
- "copy_from": sibling["id"] if sibling else None, "version": release, "version_note": note}
149
-
150
-
151
- def add(connection: str, model_id: str) -> tuple[str, list[str]]:
152
- """Do it. (served id, what was done)."""
153
- p = plan(connection, model_id)
154
- done = [p["version_note"]] if p["version_note"] else []
155
- if p["add_to_list"]:
156
- cfg = _call("/openai/config")
157
- # The write sends every key back as read. 0.12.3 returns them whole; a
158
- # version that masks them would have this overwrite real keys.
159
- if any(isinstance(k, str) and ch in k for k in cfg.get("OPENAI_API_KEYS", []) for ch in "*•"):
160
- raise ValueError("Open WebUI returned masked API keys; writing the config back would"
161
- " replace them, so add the model ID to the connection by hand")
162
- # Decided again on the fresh read: the list may have changed since plan().
163
- # ponytail: read-modify-write; an edit made in Open WebUI in the same second is lost.
164
- ids = cfg["OPENAI_API_CONFIGS"][str(p["index"])].setdefault("model_ids", [])
165
- if ids and model_id not in ids:
166
- ids.append(model_id)
167
- _call("/openai/config/update", cfg)
168
- done.append(f"added {model_id} to the {connection} connection's Model IDs")
169
- records = {m["id"]: m for m in _call("/api/v1/models/base")}
170
- old = records.get(p["served"]) or {}
171
- meta = dict(old.get("meta") or {})
172
- sib = (records.get(p["copy_from"]) or {}).get("meta") or {}
173
- for k in COPIED:
174
- if sib.get(k) and not meta.get(k):
175
- meta[k] = sib[k]
176
- tags = [t for t in meta.get("tags") or [] if t.get("name") != p["tag"]]
177
- meta["tags"] = tags + [{"name": p["tag"]}]
178
- form = {"id": p["served"], "name": old.get("name") or p["served"], "base_model_id": old.get("base_model_id"),
179
- "meta": meta, "params": old.get("params") or {}, "access_grants": PUBLIC, "is_active": True}
180
- try:
181
- _call("/api/v1/models/model/update" if old else "/api/v1/models/create", form)
182
- except Exception as exc: # noqa: BLE001
183
- raise ValueError("; ".join(done + [f"then the model record failed ({exc}); finish it in"
184
- " Admin > Models"])) from exc
185
- done.append(f"{'updated' if old else 'created'} its model record: public, tagged {p['tag']}"
186
- + (f", logo and switches from {p['copy_from']}" if p["copy_from"] else ", no logo found to copy"))
187
- # Open WebUI rebuilds its model list on request; wait until it serves the new id.
188
- for _ in range(10):
189
- if p["served"] in endpoints.models(endpoint()):
190
- break
191
- time.sleep(2)
192
- else:
193
- done.append(f"Open WebUI does not list {p['served']} yet; onboarding will say if it never does")
194
- return p["served"], done
195
-
196
-
197
- def main() -> None:
198
- sys.stdout.reconfigure(encoding="utf-8", errors="replace")
199
- if len(sys.argv) != 3:
200
- sys.exit(__doc__)
201
- print(json.dumps(plan(sys.argv[1], sys.argv[2]), indent=2))
202
-
203
-
204
- if __name__ == "__main__":
205
- main()
1
+ """The Open WebUI hub adapter: add a model as it is otherwise done by hand, then hand it to onboarding.
2
+
3
+ python metrics/owui.py <connection> <model id> # what would change, changes nothing
4
+
5
+ One of three hub adapters (litellm_hub.py, cliproxy.py), each exposing
6
+ SUPPORTS, CHECKED, endpoint(), plan() and add(); serve.discover_add picks one
7
+ by the endpoint's hub (endpoints.adapter).
8
+
9
+ THE HAND STEPS (2026-10-18)
10
+ ---------------------------
11
+ 1. Edit the connection: add the model to its Model IDs list (advanced
12
+ settings). Only when the list is in use; an empty list means Open WebUI
13
+ already shows everything the provider serves.
14
+ 2. Find the model in Admin > Models: make it public, tag it with the
15
+ connection's name (`nvidia-api`, `groq`, ...), give it the provider logo.
16
+
17
+ The logo, the capability switches and the default features are copied from
18
+ a model already on the same connection, because every model on a connection
19
+ carries the same ones. A connection is found by its tag: the Open WebUI
20
+ connection (`urlIdx`) its tagged models are served from. Then
21
+ `scripts/onboard.py` registers, probes and runs the suite as for any model.
22
+
23
+ Admin calls with the endpoint's admin key (endpoints.admin_key: an admin's
24
+ API key, which is the endpoint's own key unless one is stored apart); keys
25
+ are never printed, and the config is written back exactly as read, plus one
26
+ id. The endpoint is whichever is a hub of type openwebui (`endpoints.find_hub`);
27
+ an install without one has no Open WebUI to add to, and `endpoint()` says so.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import collections
33
+ import json
34
+ import pathlib
35
+ import sys
36
+ import time
37
+ import urllib.request
38
+
39
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
40
+ sys.path.insert(0, str(ROOT / "metrics"))
41
+ sys.path.insert(0, str(ROOT / "suite"))
42
+ import endpoints # noqa: E402
43
+
44
+ PUBLIC = [{"principal_type": "user", "principal_id": "*", "permission": "read"}]
45
+ COPIED = ("profile_image_url", "capabilities", "defaultFeatureIds")
46
+ # What this hub takes (RELEASE-PLAN section 2, "Adding a model to a hub"); the
47
+ # dashboard says only what is here. "" where the hub has no such thing.
48
+ SUPPORTS = {"allow_list": "Model IDs list", "tags": "the connection's tag",
49
+ "logo": "copied from the connection's other models (PNG, JPEG, GIF or WebP)",
50
+ "access": "public: every signed-in user",
51
+ "link": "/?settings=admin:models (v0.11+, the Models tab; up to v0.10 /admin/settings/models?id=<id>)"}
52
+ CHECKED = ("0.12",) # 0.12.3, 2026-10-21: the routes below and /api/version
53
+
54
+
55
+ def endpoint() -> str:
56
+ """The Open WebUI hub's endpoint name. ValueError when there is none: the
57
+ dashboard shows it as the reason, and nothing has been sent anywhere."""
58
+ name = endpoints.find_hub("openwebui")
59
+ if not name:
60
+ raise ValueError("no endpoint is an Open WebUI hub; add it on the Endpoints page as kind hub, Open WebUI")
61
+ return name
62
+
63
+
64
+ def _call(path: str, body: dict | None = None):
65
+ name = endpoint()
66
+ root = endpoints.base(name).removesuffix("/api")
67
+ req = urllib.request.Request(root + path, data=None if body is None else json.dumps(body).encode(),
68
+ method="GET" if body is None else "POST",
69
+ headers={"Authorization": f"Bearer {endpoints.admin_key(name)}",
70
+ "Content-Type": "application/json", "Accept": "application/json"})
71
+ with urllib.request.urlopen(req, timeout=60) as r:
72
+ return json.loads(r.read() or b"null")
73
+
74
+
75
+ def version() -> tuple[str, str]:
76
+ """(release, endpoints.version_note's sentence). Advice only: a hub that
77
+ will not say is still added to."""
78
+ try:
79
+ v = str((_call("/api/version") or {}).get("version") or "")
80
+ except Exception: # noqa: BLE001
81
+ v = ""
82
+ return v, endpoints.version_note("openwebui", v, CHECKED)
83
+
84
+
85
+ def connections() -> dict[str, tuple[int, str]]:
86
+ """Connection name -> (Open WebUI connection index, the tag its models carry).
87
+
88
+ The bench names a connection from config/provider-tags.json (`groq`,
89
+ `mistral`), Open WebUI's tags differ (`groq-api`, `mistral-studio`), so
90
+ the two meet at the index the bench's own registered models are served
91
+ from. Open WebUI's tag names are accepted as well."""
92
+ import route
93
+ from run import _registered_names
94
+ data = _call("/api/models").get("data", [])
95
+ by_id = {m["id"]: m for m in data}
96
+ tag_at: dict[int, collections.Counter] = collections.defaultdict(collections.Counter)
97
+ for m in data:
98
+ for t in m.get("tags") or []:
99
+ if m.get("urlIdx") is not None:
100
+ tag_at[m["urlIdx"]][t["name"]] += 1
101
+ at: dict[str, collections.Counter] = collections.defaultdict(collections.Counter)
102
+ for alias, served in _registered_names().items():
103
+ m = by_id.get(served)
104
+ if m and m.get("urlIdx") is not None:
105
+ at[route.connection_of(alias)][m["urlIdx"]] += 1
106
+ out = {}
107
+ for name, c in at.items():
108
+ idx = c.most_common(1)[0][0]
109
+ tags = tag_at.get(idx) # .get: indexing a defaultdict would add an empty one
110
+ out[name] = (idx, tags.most_common(1)[0][0] if tags else name)
111
+ for idx, c in tag_at.items():
112
+ tag = c.most_common(1)[0][0]
113
+ out.setdefault(tag, (idx, tag))
114
+ return out
115
+
116
+
117
+ def plan(connection: str, model_id: str) -> dict:
118
+ """What adding would change. Raises ValueError when it cannot be done here."""
119
+ release, note = version()
120
+ found = connections().get(connection)
121
+ if found is None:
122
+ raise ValueError(f"no Open WebUI connection is known as {connection!r}: none of the bench's"
123
+ " models is served from it and no Open WebUI model carries that tag")
124
+ idx, tag = found
125
+ cfg = _call("/openai/config")
126
+ if idx >= len(cfg.get("OPENAI_API_BASE_URLS") or []):
127
+ raise ValueError(f"Open WebUI's connections changed while this ran; try again")
128
+ conn = (cfg.get("OPENAI_API_CONFIGS") or {}).get(str(idx), {})
129
+ if conn.get("enable") is False:
130
+ raise ValueError(f"the {connection} connection is switched off in Open WebUI")
131
+ prefix = conn.get("prefix_id") or ""
132
+ served = f"{prefix}.{model_id}" if prefix else model_id
133
+ # One id, two connections: Open WebUI would serve only one of them.
134
+ elsewhere = next((m.get("urlIdx") for m in _call("/api/models").get("data", [])
135
+ if m.get("id") == served and m.get("urlIdx") not in (None, idx)), None)
136
+ if elsewhere is not None:
137
+ raise ValueError(f"Open WebUI already serves {served} from connection {elsewhere}; give the"
138
+ f" {connection} connection a prefix in Open WebUI so the ids differ")
139
+ listed = conn.get("model_ids") or []
140
+ records = {m["id"]: m for m in _call("/api/v1/models/base")}
141
+ sibling = next((m for m in records.values()
142
+ if any(t.get("name") == tag for t in (m.get("meta") or {}).get("tags") or [])
143
+ and (m.get("meta") or {}).get("profile_image_url")), None)
144
+ return {"connection": connection, "tag": tag, "index": idx, "base_url": cfg["OPENAI_API_BASE_URLS"][idx],
145
+ "model_id": model_id, "served": served,
146
+ "add_to_list": bool(listed) and model_id not in listed,
147
+ "record": "update" if served in records else "create",
148
+ "copy_from": sibling["id"] if sibling else None, "version": release, "version_note": note}
149
+
150
+
151
+ def add(connection: str, model_id: str) -> tuple[str, list[str]]:
152
+ """Do it. (served id, what was done)."""
153
+ p = plan(connection, model_id)
154
+ done = [p["version_note"]] if p["version_note"] else []
155
+ if p["add_to_list"]:
156
+ cfg = _call("/openai/config")
157
+ # The write sends every key back as read. 0.12.3 returns them whole; a
158
+ # version that masks them would have this overwrite real keys.
159
+ if any(isinstance(k, str) and ch in k for k in cfg.get("OPENAI_API_KEYS", []) for ch in "*•"):
160
+ raise ValueError("Open WebUI returned masked API keys; writing the config back would"
161
+ " replace them, so add the model ID to the connection by hand")
162
+ # Decided again on the fresh read: the list may have changed since plan().
163
+ # ponytail: read-modify-write; an edit made in Open WebUI in the same second is lost.
164
+ ids = cfg["OPENAI_API_CONFIGS"][str(p["index"])].setdefault("model_ids", [])
165
+ if ids and model_id not in ids:
166
+ ids.append(model_id)
167
+ _call("/openai/config/update", cfg)
168
+ done.append(f"added {model_id} to the {connection} connection's Model IDs")
169
+ records = {m["id"]: m for m in _call("/api/v1/models/base")}
170
+ old = records.get(p["served"]) or {}
171
+ meta = dict(old.get("meta") or {})
172
+ sib = (records.get(p["copy_from"]) or {}).get("meta") or {}
173
+ for k in COPIED:
174
+ if sib.get(k) and not meta.get(k):
175
+ meta[k] = sib[k]
176
+ tags = [t for t in meta.get("tags") or [] if t.get("name") != p["tag"]]
177
+ meta["tags"] = tags + [{"name": p["tag"]}]
178
+ form = {"id": p["served"], "name": old.get("name") or p["served"], "base_model_id": old.get("base_model_id"),
179
+ "meta": meta, "params": old.get("params") or {}, "access_grants": PUBLIC, "is_active": True}
180
+ try:
181
+ _call("/api/v1/models/model/update" if old else "/api/v1/models/create", form)
182
+ except Exception as exc: # noqa: BLE001
183
+ raise ValueError("; ".join(done + [f"then the model record failed ({exc}); finish it in"
184
+ " Admin > Models"])) from exc
185
+ done.append(f"{'updated' if old else 'created'} its model record: public, tagged {p['tag']}"
186
+ + (f", logo and switches from {p['copy_from']}" if p["copy_from"] else ", no logo found to copy"))
187
+ # Open WebUI rebuilds its model list on request; wait until it serves the new id.
188
+ for _ in range(10):
189
+ if p["served"] in endpoints.models(endpoint()):
190
+ break
191
+ time.sleep(2)
192
+ else:
193
+ done.append(f"Open WebUI does not list {p['served']} yet; onboarding will say if it never does")
194
+ return p["served"], done
195
+
196
+
197
+ def main() -> None:
198
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
199
+ if len(sys.argv) != 3:
200
+ sys.exit(__doc__)
201
+ print(json.dumps(plan(sys.argv[1], sys.argv[2]), indent=2))
202
+
203
+
204
+ if __name__ == "__main__":
205
+ main()