devcopilot 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. api/__init__.py +17 -0
  2. api/admin_config.py +1303 -0
  3. api/admin_routes.py +287 -0
  4. api/admin_static/admin.css +459 -0
  5. api/admin_static/admin.js +497 -0
  6. api/admin_static/index.html +77 -0
  7. api/admin_urls.py +34 -0
  8. api/app.py +194 -0
  9. api/command_utils.py +164 -0
  10. api/dependencies.py +144 -0
  11. api/detection.py +152 -0
  12. api/gateway_model_ids.py +54 -0
  13. api/model_catalog.py +133 -0
  14. api/model_router.py +125 -0
  15. api/models/__init__.py +45 -0
  16. api/models/anthropic.py +234 -0
  17. api/models/openai_responses.py +28 -0
  18. api/models/responses.py +60 -0
  19. api/optimization_handlers.py +154 -0
  20. api/request_pipeline.py +424 -0
  21. api/routes.py +156 -0
  22. api/runtime.py +334 -0
  23. api/validation_log.py +48 -0
  24. api/web_server_tools.py +22 -0
  25. api/web_tools/__init__.py +17 -0
  26. api/web_tools/constants.py +15 -0
  27. api/web_tools/egress.py +99 -0
  28. api/web_tools/outbound.py +278 -0
  29. api/web_tools/parsers.py +104 -0
  30. api/web_tools/request.py +87 -0
  31. api/web_tools/streaming.py +206 -0
  32. cli/__init__.py +5 -0
  33. cli/claude_env.py +12 -0
  34. cli/entrypoints.py +166 -0
  35. cli/env.example +209 -0
  36. cli/launchers/__init__.py +1 -0
  37. cli/launchers/claude.py +84 -0
  38. cli/launchers/codex.py +204 -0
  39. cli/launchers/codex_model_catalog.py +186 -0
  40. cli/launchers/common.py +93 -0
  41. cli/managed/__init__.py +6 -0
  42. cli/managed/claude.py +215 -0
  43. cli/managed/manager.py +157 -0
  44. cli/managed/session.py +260 -0
  45. cli/process_registry.py +78 -0
  46. config/__init__.py +5 -0
  47. config/constants.py +13 -0
  48. config/logging_config.py +159 -0
  49. config/nim.py +118 -0
  50. config/paths.py +91 -0
  51. config/provider_catalog.py +259 -0
  52. config/provider_ids.py +7 -0
  53. config/settings.py +538 -0
  54. core/__init__.py +1 -0
  55. core/anthropic/__init__.py +46 -0
  56. core/anthropic/content.py +31 -0
  57. core/anthropic/conversion.py +587 -0
  58. core/anthropic/emitted_sse_tracker.py +346 -0
  59. core/anthropic/errors.py +70 -0
  60. core/anthropic/native_messages_request.py +280 -0
  61. core/anthropic/native_sse_block_policy.py +313 -0
  62. core/anthropic/provider_stream_error.py +34 -0
  63. core/anthropic/server_tool_sse.py +14 -0
  64. core/anthropic/sse.py +440 -0
  65. core/anthropic/stream_contracts.py +205 -0
  66. core/anthropic/stream_recovery.py +346 -0
  67. core/anthropic/stream_recovery_session.py +133 -0
  68. core/anthropic/thinking.py +140 -0
  69. core/anthropic/tokens.py +117 -0
  70. core/anthropic/tools.py +212 -0
  71. core/anthropic/utils.py +9 -0
  72. core/openai_responses/__init__.py +5 -0
  73. core/openai_responses/adapter.py +31 -0
  74. core/openai_responses/anthropic_sse.py +59 -0
  75. core/openai_responses/errors.py +22 -0
  76. core/openai_responses/events.py +19 -0
  77. core/openai_responses/ids.py +21 -0
  78. core/openai_responses/input.py +258 -0
  79. core/openai_responses/items.py +37 -0
  80. core/openai_responses/reasoning.py +52 -0
  81. core/openai_responses/stream.py +25 -0
  82. core/openai_responses/stream_state.py +654 -0
  83. core/openai_responses/tools.py +374 -0
  84. core/openai_responses/usage.py +37 -0
  85. core/rate_limit.py +60 -0
  86. core/trace.py +216 -0
  87. devcopilot-0.2.0.dist-info/METADATA +687 -0
  88. devcopilot-0.2.0.dist-info/RECORD +189 -0
  89. devcopilot-0.2.0.dist-info/WHEEL +4 -0
  90. devcopilot-0.2.0.dist-info/entry_points.txt +6 -0
  91. devcopilot-0.2.0.dist-info/licenses/LICENSE +21 -0
  92. messaging/__init__.py +26 -0
  93. messaging/cli_event_constants.py +67 -0
  94. messaging/command_context.py +66 -0
  95. messaging/command_dispatcher.py +37 -0
  96. messaging/commands.py +275 -0
  97. messaging/event_parser.py +181 -0
  98. messaging/limiter.py +300 -0
  99. messaging/models.py +36 -0
  100. messaging/node_event_pipeline.py +127 -0
  101. messaging/node_runner.py +342 -0
  102. messaging/platforms/__init__.py +15 -0
  103. messaging/platforms/base.py +228 -0
  104. messaging/platforms/discord.py +567 -0
  105. messaging/platforms/factory.py +103 -0
  106. messaging/platforms/outbox.py +144 -0
  107. messaging/platforms/telegram.py +688 -0
  108. messaging/platforms/voice_flow.py +295 -0
  109. messaging/rendering/__init__.py +3 -0
  110. messaging/rendering/discord_markdown.py +318 -0
  111. messaging/rendering/markdown_tables.py +49 -0
  112. messaging/rendering/profiles.py +55 -0
  113. messaging/rendering/telegram_markdown.py +327 -0
  114. messaging/safe_diagnostics.py +17 -0
  115. messaging/session.py +334 -0
  116. messaging/transcript.py +581 -0
  117. messaging/transcription.py +164 -0
  118. messaging/trees/__init__.py +15 -0
  119. messaging/trees/data.py +482 -0
  120. messaging/trees/manager.py +433 -0
  121. messaging/trees/processor.py +179 -0
  122. messaging/trees/repository.py +177 -0
  123. messaging/turn_intake.py +235 -0
  124. messaging/ui_updates.py +101 -0
  125. messaging/voice.py +76 -0
  126. messaging/workflow.py +200 -0
  127. providers/__init__.py +31 -0
  128. providers/base.py +152 -0
  129. providers/cerebras/__init__.py +7 -0
  130. providers/cerebras/client.py +31 -0
  131. providers/cerebras/request.py +55 -0
  132. providers/codestral/__init__.py +7 -0
  133. providers/codestral/client.py +34 -0
  134. providers/deepseek/__init__.py +11 -0
  135. providers/deepseek/client.py +51 -0
  136. providers/deepseek/request.py +475 -0
  137. providers/defaults.py +41 -0
  138. providers/error_mapping.py +309 -0
  139. providers/exceptions.py +113 -0
  140. providers/fireworks/__init__.py +5 -0
  141. providers/fireworks/client.py +45 -0
  142. providers/fireworks/request.py +48 -0
  143. providers/gemini/__init__.py +7 -0
  144. providers/gemini/client.py +49 -0
  145. providers/gemini/request.py +199 -0
  146. providers/groq/__init__.py +7 -0
  147. providers/groq/client.py +31 -0
  148. providers/groq/request.py +83 -0
  149. providers/kimi/__init__.py +10 -0
  150. providers/kimi/client.py +53 -0
  151. providers/kimi/request.py +42 -0
  152. providers/llamacpp/__init__.py +3 -0
  153. providers/llamacpp/client.py +16 -0
  154. providers/lmstudio/__init__.py +5 -0
  155. providers/lmstudio/client.py +16 -0
  156. providers/mistral/__init__.py +7 -0
  157. providers/mistral/client.py +31 -0
  158. providers/mistral/request.py +37 -0
  159. providers/model_listing.py +133 -0
  160. providers/nvidia_nim/__init__.py +7 -0
  161. providers/nvidia_nim/client.py +91 -0
  162. providers/nvidia_nim/request.py +430 -0
  163. providers/nvidia_nim/voice.py +95 -0
  164. providers/ollama/__init__.py +7 -0
  165. providers/ollama/client.py +39 -0
  166. providers/open_router/__init__.py +7 -0
  167. providers/open_router/client.py +124 -0
  168. providers/open_router/request.py +42 -0
  169. providers/opencode/__init__.py +11 -0
  170. providers/opencode/client.py +31 -0
  171. providers/opencode/request.py +35 -0
  172. providers/rate_limit.py +300 -0
  173. providers/registry.py +527 -0
  174. providers/transports/__init__.py +1 -0
  175. providers/transports/anthropic_messages/__init__.py +5 -0
  176. providers/transports/anthropic_messages/http.py +118 -0
  177. providers/transports/anthropic_messages/recovery.py +206 -0
  178. providers/transports/anthropic_messages/stream.py +295 -0
  179. providers/transports/anthropic_messages/transport.py +236 -0
  180. providers/transports/openai_chat/__init__.py +5 -0
  181. providers/transports/openai_chat/recovery.py +217 -0
  182. providers/transports/openai_chat/stream.py +384 -0
  183. providers/transports/openai_chat/tool_calls.py +293 -0
  184. providers/transports/openai_chat/transport.py +156 -0
  185. providers/wafer/__init__.py +10 -0
  186. providers/wafer/client.py +50 -0
  187. providers/zai/__init__.py +10 -0
  188. providers/zai/client.py +46 -0
  189. providers/zai/request.py +42 -0
@@ -0,0 +1,497 @@
1
+ const state = {
2
+ config: null,
3
+ fields: new Map(),
4
+ localStatus: new Map(),
5
+ modelOptions: [],
6
+ activeView: "providers",
7
+ };
8
+
9
+ const MASKED_SECRET = "********";
10
+ const VIEW_GROUPS = [
11
+ {
12
+ id: "providers",
13
+ label: "Providers",
14
+ title: "Providers",
15
+ sections: ["providers", "runtime"],
16
+ containerId: "providersSections",
17
+ },
18
+ {
19
+ id: "model_config",
20
+ label: "Model Config",
21
+ title: "Model Config",
22
+ sections: ["models", "thinking", "web_tools"],
23
+ containerId: "modelConfigSections",
24
+ },
25
+ {
26
+ id: "messaging",
27
+ label: "Messaging",
28
+ title: "Messaging",
29
+ sections: ["messaging", "voice"],
30
+ containerId: "messagingSections",
31
+ },
32
+ ];
33
+
34
+ const byId = (id) => document.getElementById(id);
35
+
36
+ function sourceLabel(source) {
37
+ const labels = {
38
+ default: "default",
39
+ template: "template",
40
+ repo_env: "repo .env",
41
+ managed_env: "",
42
+ explicit_env_file: "FCC_ENV_FILE",
43
+ process: "process env",
44
+ };
45
+ return Object.prototype.hasOwnProperty.call(labels, source) ? labels[source] : source;
46
+ }
47
+
48
+ function sourceText(field) {
49
+ const parts = [];
50
+ const label = sourceLabel(field.source);
51
+ if (label) {
52
+ parts.push(label);
53
+ }
54
+ if (field.locked) {
55
+ parts.push("locked");
56
+ }
57
+ return parts.join(" ");
58
+ }
59
+
60
+ function providerName(providerId) {
61
+ const names = {
62
+ nvidia_nim: "NVIDIA NIM",
63
+ open_router: "OpenRouter",
64
+ mistral_codestral: "Mistral Codestral",
65
+ deepseek: "DeepSeek",
66
+ lmstudio: "LM Studio",
67
+ llamacpp: "llama.cpp",
68
+ ollama: "Ollama",
69
+ kimi: "Kimi",
70
+ wafer: "Wafer",
71
+ opencode: "OpenCode Zen",
72
+ opencode_go: "OpenCode Go",
73
+ zai: "Z.ai",
74
+ };
75
+ if (names[providerId]) return names[providerId];
76
+ return providerId
77
+ .split("_")
78
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
79
+ .join(" ");
80
+ }
81
+
82
+ function statusClass(status) {
83
+ if (["configured", "reachable", "running"].includes(status)) return "ok";
84
+ if (["missing_key", "missing_url", "unknown"].includes(status)) return "warn";
85
+ if (["offline", "error"].includes(status)) return "error";
86
+ return "neutral";
87
+ }
88
+
89
+ async function api(path, options = {}) {
90
+ const response = await fetch(path, {
91
+ headers: { "Content-Type": "application/json", ...(options.headers || {}) },
92
+ ...options,
93
+ });
94
+ if (!response.ok) {
95
+ throw new Error(`${response.status} ${response.statusText}`);
96
+ }
97
+ return response.json();
98
+ }
99
+
100
+ async function load() {
101
+ showMessage("Loading admin config");
102
+ const config = await api("/admin/api/config");
103
+ state.config = config;
104
+ state.fields = new Map(config.fields.map((field) => [field.key, field]));
105
+ renderNav();
106
+ renderProviders(config.provider_status);
107
+ renderSections(config.sections, config.fields);
108
+ byId("configPath").textContent = config.paths.managed;
109
+ await validate(false);
110
+ await refreshLocalStatus();
111
+ updateDirtyState();
112
+ showMessage("");
113
+ }
114
+
115
+ function renderNav() {
116
+ const nav = byId("sectionNav");
117
+ nav.innerHTML = "";
118
+ VIEW_GROUPS.forEach((view, index) => {
119
+ const button = document.createElement("button");
120
+ button.type = "button";
121
+ button.className = `nav-link${index === 0 ? " active" : ""}`;
122
+ button.dataset.view = view.id;
123
+ button.textContent = view.label;
124
+ if (index === 0) {
125
+ button.setAttribute("aria-current", "page");
126
+ }
127
+ button.addEventListener("click", () => {
128
+ setActiveView(view.id, { scroll: true });
129
+ });
130
+ nav.appendChild(button);
131
+ });
132
+ setActiveView(state.activeView, { scroll: false });
133
+ }
134
+
135
+ function setActiveView(viewId, { scroll = false } = {}) {
136
+ const activeView =
137
+ VIEW_GROUPS.find((view) => view.id === viewId) || VIEW_GROUPS[0];
138
+ state.activeView = activeView.id;
139
+ byId("pageTitle").textContent = activeView.title;
140
+
141
+ document.querySelectorAll(".nav-link").forEach((link) => {
142
+ const selected = link.dataset.view === activeView.id;
143
+ link.classList.toggle("active", selected);
144
+ if (selected) {
145
+ link.setAttribute("aria-current", "page");
146
+ } else {
147
+ link.removeAttribute("aria-current");
148
+ }
149
+ });
150
+
151
+ document.querySelectorAll(".admin-view").forEach((view) => {
152
+ const selected = view.dataset.view === activeView.id;
153
+ view.classList.toggle("active", selected);
154
+ view.hidden = !selected;
155
+ });
156
+
157
+ if (scroll) {
158
+ window.scrollTo({ top: 0, behavior: "smooth" });
159
+ }
160
+ }
161
+
162
+ function renderProviders(providerStatus) {
163
+ const grid = byId("providerGrid");
164
+ grid.innerHTML = "";
165
+ providerStatus.forEach((provider) => {
166
+ const card = document.createElement("article");
167
+ card.className = "provider-card";
168
+ card.dataset.provider = provider.provider_id;
169
+
170
+ const title = document.createElement("div");
171
+ title.className = "provider-title";
172
+ title.innerHTML = `<strong>${providerName(provider.provider_id)}</strong>`;
173
+
174
+ const pill = document.createElement("span");
175
+ pill.className = `status-pill ${statusClass(provider.status)}`;
176
+ pill.textContent = provider.label;
177
+ title.appendChild(pill);
178
+
179
+ const meta = document.createElement("div");
180
+ meta.className = "provider-meta";
181
+ meta.textContent =
182
+ provider.kind === "local"
183
+ ? provider.base_url || "No local URL configured"
184
+ : provider.credential_env;
185
+
186
+ const button = document.createElement("button");
187
+ button.type = "button";
188
+ button.className = "test-button";
189
+ button.textContent = provider.kind === "local" ? "Test" : "Refresh models";
190
+ button.addEventListener("click", () => testProvider(provider.provider_id, button));
191
+
192
+ card.append(title, meta, button);
193
+ grid.appendChild(card);
194
+ });
195
+ }
196
+
197
+ function updateProviderCard(providerId, status, label, metaText) {
198
+ const card = document.querySelector(`[data-provider="${providerId}"]`);
199
+ if (!card) return;
200
+ const pill = card.querySelector(".status-pill");
201
+ pill.className = `status-pill ${statusClass(status)}`;
202
+ pill.textContent = label;
203
+ if (metaText) {
204
+ card.querySelector(".provider-meta").textContent = metaText;
205
+ }
206
+ }
207
+
208
+ function renderSections(sections, fields) {
209
+ VIEW_GROUPS.forEach((view) => {
210
+ byId(view.containerId).innerHTML = "";
211
+ });
212
+
213
+ const sectionById = new Map(sections.map((section) => [section.id, section]));
214
+ const bySection = new Map();
215
+ sections.forEach((section) => bySection.set(section.id, []));
216
+ fields.forEach((field) => {
217
+ if (!bySection.has(field.section)) bySection.set(field.section, []);
218
+ bySection.get(field.section).push(field);
219
+ });
220
+
221
+ VIEW_GROUPS.forEach((view) => {
222
+ const container = byId(view.containerId);
223
+ view.sections.forEach((sectionId) => {
224
+ const section = sectionById.get(sectionId);
225
+ const sectionFields = bySection.get(sectionId) || [];
226
+ if (!section || sectionFields.length === 0) return;
227
+
228
+ const sectionEl = document.createElement("section");
229
+ sectionEl.className = "settings-section";
230
+ sectionEl.id = `section-${section.id}`;
231
+
232
+ const heading = document.createElement("div");
233
+ heading.className = "section-heading";
234
+ heading.innerHTML = `<div><h3>${section.label}</h3><p>${section.description}</p></div>`;
235
+ sectionEl.appendChild(heading);
236
+
237
+ const grid = document.createElement("div");
238
+ grid.className = "field-grid";
239
+ sectionFields.forEach((field) => {
240
+ grid.appendChild(renderField(field));
241
+ });
242
+ sectionEl.appendChild(grid);
243
+
244
+ if (sectionFields.some((field) => field.advanced)) {
245
+ const toggle = document.createElement("button");
246
+ toggle.type = "button";
247
+ toggle.className = "ghost-button advanced-toggle";
248
+ toggle.textContent = "Show advanced";
249
+ toggle.addEventListener("click", () => {
250
+ const showing = sectionEl.classList.toggle("show-advanced");
251
+ toggle.textContent = showing ? "Hide advanced" : "Show advanced";
252
+ });
253
+ sectionEl.appendChild(toggle);
254
+ }
255
+
256
+ container.appendChild(sectionEl);
257
+ });
258
+ });
259
+ }
260
+
261
+ function renderField(field) {
262
+ const wrapper = document.createElement("div");
263
+ wrapper.className = `field${field.advanced ? " advanced-field" : ""}`;
264
+ wrapper.dataset.key = field.key;
265
+
266
+ const label = document.createElement("label");
267
+ label.htmlFor = `field-${field.key}`;
268
+ const labelText = document.createElement("span");
269
+ labelText.textContent = field.label;
270
+ label.appendChild(labelText);
271
+
272
+ const source = sourceText(field);
273
+ if (source) {
274
+ const sourceEl = document.createElement("span");
275
+ sourceEl.className = "field-source";
276
+ sourceEl.textContent = source;
277
+ label.appendChild(sourceEl);
278
+ }
279
+
280
+ const input = inputForField(field);
281
+ input.id = `field-${field.key}`;
282
+ input.dataset.key = field.key;
283
+ input.dataset.original = field.value || "";
284
+ input.dataset.secret = field.secret ? "true" : "false";
285
+ input.dataset.configured = field.configured ? "true" : "false";
286
+ input.disabled = field.locked;
287
+ input.addEventListener("input", updateDirtyState);
288
+ input.addEventListener("change", updateDirtyState);
289
+
290
+ wrapper.append(label, input);
291
+ if (field.description) {
292
+ const description = document.createElement("div");
293
+ description.className = "field-description";
294
+ description.textContent = field.description;
295
+ wrapper.appendChild(description);
296
+ }
297
+ return wrapper;
298
+ }
299
+
300
+ function inputForField(field) {
301
+ if (field.type === "boolean") {
302
+ const input = document.createElement("input");
303
+ input.type = "checkbox";
304
+ input.checked = String(field.value).toLowerCase() === "true";
305
+ input.dataset.original = input.checked ? "true" : "false";
306
+ return input;
307
+ }
308
+
309
+ if (field.type === "tri_boolean") {
310
+ const select = document.createElement("select");
311
+ [
312
+ ["", "Inherit"],
313
+ ["true", "Enabled"],
314
+ ["false", "Disabled"],
315
+ ].forEach(([value, label]) => select.appendChild(option(value, label)));
316
+ select.value = field.value || "";
317
+ return select;
318
+ }
319
+
320
+ if (field.type === "select") {
321
+ const select = document.createElement("select");
322
+ field.options.forEach((value) => select.appendChild(option(value, value)));
323
+ select.value = field.value || field.options[0] || "";
324
+ return select;
325
+ }
326
+
327
+ if (field.type === "textarea") {
328
+ const textarea = document.createElement("textarea");
329
+ textarea.value = field.value || "";
330
+ return textarea;
331
+ }
332
+
333
+ const input = document.createElement("input");
334
+ input.type = field.type === "number" ? "number" : "text";
335
+ if (field.type === "secret") {
336
+ input.type = "password";
337
+ input.placeholder = field.configured
338
+ ? "Configured - enter a new value to replace"
339
+ : "Not configured";
340
+ input.value = "";
341
+ input.autocomplete = "off";
342
+ } else {
343
+ input.value = field.value || "";
344
+ }
345
+ if (field.key.startsWith("MODEL")) {
346
+ input.setAttribute("list", "model-options");
347
+ }
348
+ return input;
349
+ }
350
+
351
+ function option(value, label) {
352
+ const optionEl = document.createElement("option");
353
+ optionEl.value = value;
354
+ optionEl.textContent = label;
355
+ return optionEl;
356
+ }
357
+
358
+ function readFieldValue(input) {
359
+ if (input.type === "checkbox") return input.checked ? "true" : "false";
360
+ if (input.dataset.secret === "true" && input.dataset.configured === "true") {
361
+ return input.value ? input.value : MASKED_SECRET;
362
+ }
363
+ return input.value;
364
+ }
365
+
366
+ function changedValues() {
367
+ const values = {};
368
+ document.querySelectorAll("[data-key]").forEach((input) => {
369
+ if (input.disabled || !input.matches("input, select, textarea")) return;
370
+ const value = readFieldValue(input);
371
+ if (value !== input.dataset.original) {
372
+ values[input.dataset.key] = value;
373
+ }
374
+ });
375
+ return values;
376
+ }
377
+
378
+ function updateDirtyState() {
379
+ const count = Object.keys(changedValues()).length;
380
+ byId("dirtyState").textContent =
381
+ count === 0 ? "No changes" : `${count} unsaved change${count === 1 ? "" : "s"}`;
382
+ byId("applyButton").disabled = count === 0;
383
+ }
384
+
385
+ async function validate(showResult = true) {
386
+ const result = await api("/admin/api/config/validate", {
387
+ method: "POST",
388
+ body: JSON.stringify({ values: changedValues() }),
389
+ });
390
+ if (showResult) {
391
+ showValidationResult(result);
392
+ }
393
+ return result;
394
+ }
395
+
396
+ function showValidationResult(result) {
397
+ if (result.valid) {
398
+ showMessage("Config shape is valid", "ok");
399
+ } else {
400
+ showMessage(result.errors.join("; "), "error");
401
+ }
402
+ }
403
+
404
+ async function apply() {
405
+ const result = await api("/admin/api/config/apply", {
406
+ method: "POST",
407
+ body: JSON.stringify({ values: changedValues() }),
408
+ });
409
+ if (!result.applied) {
410
+ showValidationResult(result);
411
+ return;
412
+ }
413
+ const restart = result.restart || {};
414
+ if (restart.required && restart.automatic) {
415
+ showMessage("Applied. Restarting server...", "ok");
416
+ byId("applyButton").disabled = true;
417
+ setTimeout(() => {
418
+ window.location.href = restart.admin_url || "/admin";
419
+ }, 1600);
420
+ return;
421
+ }
422
+ const pending = restart.required ? restart.fields || [] : result.pending_fields || [];
423
+ await load();
424
+ showMessage(
425
+ pending.length
426
+ ? `Applied. Restart devcopilot-server to use: ${pending.join(", ")}`
427
+ : "Applied",
428
+ "ok",
429
+ );
430
+ }
431
+
432
+ async function refreshLocalStatus() {
433
+ const result = await api("/admin/api/providers/local-status");
434
+ result.providers.forEach((provider) => {
435
+ state.localStatus.set(provider.provider_id, provider);
436
+ const meta = provider.status_code
437
+ ? `${provider.base_url} returned HTTP ${provider.status_code}`
438
+ : provider.base_url;
439
+ updateProviderCard(provider.provider_id, provider.status, provider.label, meta);
440
+ });
441
+ }
442
+
443
+ async function testProvider(providerId, button) {
444
+ const original = button.textContent;
445
+ button.disabled = true;
446
+ button.textContent = "Testing";
447
+ try {
448
+ const result = await api(`/admin/api/providers/${providerId}/test`, {
449
+ method: "POST",
450
+ body: "{}",
451
+ });
452
+ if (result.ok) {
453
+ updateProviderCard(
454
+ providerId,
455
+ "reachable",
456
+ `${result.models.length} models`,
457
+ result.models.slice(0, 3).join(", ") || "No models returned",
458
+ );
459
+ state.modelOptions = Array.from(
460
+ new Set([
461
+ ...state.modelOptions,
462
+ ...result.models.map((model) => `${providerId}/${model}`),
463
+ ]),
464
+ ).sort();
465
+ syncModelDatalist();
466
+ } else {
467
+ updateProviderCard(providerId, "offline", result.error_type, result.error_type);
468
+ }
469
+ } finally {
470
+ button.disabled = false;
471
+ button.textContent = original;
472
+ }
473
+ }
474
+
475
+ function syncModelDatalist() {
476
+ let datalist = byId("model-options");
477
+ if (!datalist) {
478
+ datalist = document.createElement("datalist");
479
+ datalist.id = "model-options";
480
+ document.body.appendChild(datalist);
481
+ }
482
+ datalist.innerHTML = "";
483
+ state.modelOptions.forEach((model) => datalist.appendChild(option(model, model)));
484
+ }
485
+
486
+ function showMessage(message, kind = "") {
487
+ const area = byId("messageArea");
488
+ area.textContent = message;
489
+ area.className = `message-area ${kind}`.trim();
490
+ }
491
+
492
+ byId("validateButton").addEventListener("click", () => validate(true));
493
+ byId("applyButton").addEventListener("click", apply);
494
+
495
+ load().catch((error) => {
496
+ showMessage(error.message, "error");
497
+ });
@@ -0,0 +1,77 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>DevCopilot Admin</title>
7
+ <link rel="icon" href="data:," />
8
+ <link rel="stylesheet" href="/admin/assets/admin.css" />
9
+ </head>
10
+ <body>
11
+ <div class="app-shell">
12
+ <aside class="sidebar">
13
+ <div class="brand">
14
+ <div class="brand-mark">DC</div>
15
+ <div>
16
+ <h1>DevCopilot</h1>
17
+ <p>Server Control</p>
18
+ </div>
19
+ </div>
20
+ <nav id="sectionNav" class="section-nav" aria-label="Admin views"></nav>
21
+ </aside>
22
+
23
+ <main class="main">
24
+ <header class="topbar">
25
+ <div>
26
+ <h2 id="pageTitle">Providers</h2>
27
+ </div>
28
+ </header>
29
+
30
+ <div id="adminViews" class="admin-views">
31
+ <section id="view-providers" class="admin-view active" data-view="providers">
32
+ <section class="provider-strip" aria-label="Provider status">
33
+ <div class="strip-header">
34
+ <h3>Providers</h3>
35
+ </div>
36
+ <div id="providerGrid" class="provider-grid"></div>
37
+ </section>
38
+ <div
39
+ id="providersSections"
40
+ class="form-sections"
41
+ aria-label="Provider configuration"
42
+ ></div>
43
+ </section>
44
+
45
+ <section id="view-model_config" class="admin-view" data-view="model_config" hidden>
46
+ <div
47
+ id="modelConfigSections"
48
+ class="form-sections"
49
+ aria-label="Model configuration"
50
+ ></div>
51
+ </section>
52
+
53
+ <section id="view-messaging" class="admin-view" data-view="messaging" hidden>
54
+ <div
55
+ id="messagingSections"
56
+ class="form-sections"
57
+ aria-label="Messaging configuration"
58
+ ></div>
59
+ </section>
60
+ </div>
61
+ </main>
62
+
63
+ <footer class="action-bar">
64
+ <div class="action-meta">
65
+ <strong id="dirtyState">No changes</strong>
66
+ <span id="configPath"></span>
67
+ </div>
68
+ <div id="messageArea" class="message-area"></div>
69
+ <div class="action-buttons">
70
+ <button id="validateButton" class="secondary-button" type="button">Validate</button>
71
+ <button id="applyButton" class="primary-button" type="button" disabled>Apply</button>
72
+ </div>
73
+ </footer>
74
+ </div>
75
+ <script src="/admin/assets/admin.js"></script>
76
+ </body>
77
+ </html>
api/admin_urls.py ADDED
@@ -0,0 +1,34 @@
1
+ """Helpers for presenting local admin URLs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from config.settings import Settings
6
+
7
+
8
+ def _browser_host_for_local_urls(settings: Settings) -> str:
9
+ """Host fragment for URLs shown to humans on the same machine as the server."""
10
+
11
+ host = settings.host.strip() if settings.host else "127.0.0.1"
12
+ if host in {"0.0.0.0", "::", "[::]"}:
13
+ host = "127.0.0.1"
14
+ if ":" in host and not host.startswith("["):
15
+ host = f"[{host}]"
16
+ return host
17
+
18
+
19
+ def local_proxy_root_url(settings: Settings) -> str:
20
+ """Return the proxy root URL (no path) for clients on the same machine."""
21
+
22
+ return f"http://{_browser_host_for_local_urls(settings)}:{settings.port}"
23
+
24
+
25
+ def local_admin_url(settings: Settings) -> str:
26
+ """Return a browser-friendly URL for the localhost-only admin UI."""
27
+
28
+ return f"{local_proxy_root_url(settings)}/admin"
29
+
30
+
31
+ def admin_launch_message(settings: Settings) -> str:
32
+ """Return the startup message shown by supported launch commands."""
33
+
34
+ return f"Admin UI: {local_admin_url(settings)} (local-only)"