LastNoob commited on
Commit
401da8a
·
1 Parent(s): c2dc3b9

Initial admin impl

Browse files
api/admin_config.py ADDED
@@ -0,0 +1,1104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Admin UI configuration manifest and managed env persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Iterable, Mapping
7
+ from dataclasses import dataclass
8
+ from io import StringIO
9
+ from pathlib import Path
10
+ from typing import Any, Literal
11
+
12
+ from dotenv import dotenv_values
13
+ from pydantic import ValidationError
14
+
15
+ from config.provider_catalog import PROVIDER_CATALOG
16
+ from config.settings import Settings
17
+
18
+ FieldType = Literal[
19
+ "text",
20
+ "secret",
21
+ "number",
22
+ "boolean",
23
+ "tri_boolean",
24
+ "select",
25
+ "textarea",
26
+ ]
27
+ SourceType = Literal[
28
+ "default",
29
+ "template",
30
+ "repo_env",
31
+ "managed_env",
32
+ "explicit_env_file",
33
+ "process",
34
+ ]
35
+
36
+ MASKED_SECRET = "********"
37
+ MANAGED_ENV_RELATIVE = Path(".config") / "free-claude-code" / ".env"
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class ConfigSectionSpec:
42
+ """A group of config fields rendered together in the admin UI."""
43
+
44
+ section_id: str
45
+ label: str
46
+ description: str
47
+ advanced: bool = False
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class ConfigFieldSpec:
52
+ """Typed metadata for one env-backed admin setting."""
53
+
54
+ key: str
55
+ label: str
56
+ section_id: str
57
+ field_type: FieldType = "text"
58
+ settings_attr: str | None = None
59
+ default: str = ""
60
+ options: tuple[str, ...] = ()
61
+ secret: bool = False
62
+ advanced: bool = False
63
+ restart_required: bool = False
64
+ session_sensitive: bool = False
65
+ description: str = ""
66
+
67
+
68
+ SECTIONS: tuple[ConfigSectionSpec, ...] = (
69
+ ConfigSectionSpec(
70
+ "providers",
71
+ "Providers",
72
+ "Provider keys, local endpoints, and proxy settings.",
73
+ ),
74
+ ConfigSectionSpec(
75
+ "models",
76
+ "Model Routing",
77
+ "Provider-prefixed models used for Claude model tiers.",
78
+ ),
79
+ ConfigSectionSpec(
80
+ "thinking",
81
+ "Thinking",
82
+ "Global and tier-specific thinking behavior.",
83
+ ),
84
+ ConfigSectionSpec(
85
+ "runtime",
86
+ "Runtime",
87
+ "Server API token, rate limits, timeouts, and process settings.",
88
+ ),
89
+ ConfigSectionSpec(
90
+ "messaging",
91
+ "Messaging",
92
+ "Discord, Telegram, CLI workspace, and session settings.",
93
+ ),
94
+ ConfigSectionSpec(
95
+ "voice",
96
+ "Voice",
97
+ "Voice note transcription settings.",
98
+ ),
99
+ ConfigSectionSpec(
100
+ "web_tools",
101
+ "Web Tools",
102
+ "Local Anthropic web_search and web_fetch behavior.",
103
+ ),
104
+ ConfigSectionSpec(
105
+ "diagnostics",
106
+ "Diagnostics",
107
+ "Logging and debugging flags.",
108
+ advanced=True,
109
+ ),
110
+ ConfigSectionSpec(
111
+ "smoke",
112
+ "Smoke Tests",
113
+ "Optional live smoke-test model overrides.",
114
+ advanced=True,
115
+ ),
116
+ )
117
+
118
+
119
+ FIELDS: tuple[ConfigFieldSpec, ...] = (
120
+ ConfigFieldSpec(
121
+ "NVIDIA_NIM_API_KEY",
122
+ "NVIDIA NIM API Key",
123
+ "providers",
124
+ "secret",
125
+ settings_attr="nvidia_nim_api_key",
126
+ secret=True,
127
+ description="Used by NVIDIA NIM chat and optional NIM voice transcription.",
128
+ ),
129
+ ConfigFieldSpec(
130
+ "OPENROUTER_API_KEY",
131
+ "OpenRouter API Key",
132
+ "providers",
133
+ "secret",
134
+ settings_attr="open_router_api_key",
135
+ secret=True,
136
+ ),
137
+ ConfigFieldSpec(
138
+ "DEEPSEEK_API_KEY",
139
+ "DeepSeek API Key",
140
+ "providers",
141
+ "secret",
142
+ settings_attr="deepseek_api_key",
143
+ secret=True,
144
+ ),
145
+ ConfigFieldSpec(
146
+ "KIMI_API_KEY",
147
+ "Kimi API Key",
148
+ "providers",
149
+ "secret",
150
+ settings_attr="kimi_api_key",
151
+ secret=True,
152
+ ),
153
+ ConfigFieldSpec(
154
+ "WAFER_API_KEY",
155
+ "Wafer API Key",
156
+ "providers",
157
+ "secret",
158
+ settings_attr="wafer_api_key",
159
+ secret=True,
160
+ ),
161
+ ConfigFieldSpec(
162
+ "LM_STUDIO_BASE_URL",
163
+ "LM Studio Base URL",
164
+ "providers",
165
+ settings_attr="lm_studio_base_url",
166
+ default="http://localhost:1234/v1",
167
+ ),
168
+ ConfigFieldSpec(
169
+ "LLAMACPP_BASE_URL",
170
+ "llama.cpp Base URL",
171
+ "providers",
172
+ settings_attr="llamacpp_base_url",
173
+ default="http://localhost:8080/v1",
174
+ ),
175
+ ConfigFieldSpec(
176
+ "OLLAMA_BASE_URL",
177
+ "Ollama Base URL",
178
+ "providers",
179
+ settings_attr="ollama_base_url",
180
+ default="http://localhost:11434",
181
+ ),
182
+ ConfigFieldSpec(
183
+ "NVIDIA_NIM_PROXY",
184
+ "NVIDIA NIM Proxy",
185
+ "providers",
186
+ "secret",
187
+ settings_attr="nvidia_nim_proxy",
188
+ secret=True,
189
+ advanced=True,
190
+ ),
191
+ ConfigFieldSpec(
192
+ "OPENROUTER_PROXY",
193
+ "OpenRouter Proxy",
194
+ "providers",
195
+ "secret",
196
+ settings_attr="open_router_proxy",
197
+ secret=True,
198
+ advanced=True,
199
+ ),
200
+ ConfigFieldSpec(
201
+ "LMSTUDIO_PROXY",
202
+ "LM Studio Proxy",
203
+ "providers",
204
+ "secret",
205
+ settings_attr="lmstudio_proxy",
206
+ secret=True,
207
+ advanced=True,
208
+ ),
209
+ ConfigFieldSpec(
210
+ "LLAMACPP_PROXY",
211
+ "llama.cpp Proxy",
212
+ "providers",
213
+ "secret",
214
+ settings_attr="llamacpp_proxy",
215
+ secret=True,
216
+ advanced=True,
217
+ ),
218
+ ConfigFieldSpec(
219
+ "KIMI_PROXY",
220
+ "Kimi Proxy",
221
+ "providers",
222
+ "secret",
223
+ settings_attr="kimi_proxy",
224
+ secret=True,
225
+ advanced=True,
226
+ ),
227
+ ConfigFieldSpec(
228
+ "WAFER_PROXY",
229
+ "Wafer Proxy",
230
+ "providers",
231
+ "secret",
232
+ settings_attr="wafer_proxy",
233
+ secret=True,
234
+ advanced=True,
235
+ ),
236
+ ConfigFieldSpec(
237
+ "MODEL",
238
+ "Default Model",
239
+ "models",
240
+ settings_attr="model",
241
+ default="nvidia_nim/z-ai/glm4.7",
242
+ description="Fallback provider/model route for all Claude model names.",
243
+ ),
244
+ ConfigFieldSpec(
245
+ "MODEL_OPUS",
246
+ "Opus Override",
247
+ "models",
248
+ settings_attr="model_opus",
249
+ description="Optional provider/model route for Opus requests.",
250
+ ),
251
+ ConfigFieldSpec(
252
+ "MODEL_SONNET",
253
+ "Sonnet Override",
254
+ "models",
255
+ settings_attr="model_sonnet",
256
+ description="Optional provider/model route for Sonnet requests.",
257
+ ),
258
+ ConfigFieldSpec(
259
+ "MODEL_HAIKU",
260
+ "Haiku Override",
261
+ "models",
262
+ settings_attr="model_haiku",
263
+ description="Optional provider/model route for Haiku requests.",
264
+ ),
265
+ ConfigFieldSpec(
266
+ "ENABLE_MODEL_THINKING",
267
+ "Enable Thinking",
268
+ "thinking",
269
+ "boolean",
270
+ settings_attr="enable_model_thinking",
271
+ default="true",
272
+ ),
273
+ ConfigFieldSpec(
274
+ "ENABLE_OPUS_THINKING",
275
+ "Opus Thinking",
276
+ "thinking",
277
+ "tri_boolean",
278
+ settings_attr="enable_opus_thinking",
279
+ description="Blank inherits Enable Thinking.",
280
+ ),
281
+ ConfigFieldSpec(
282
+ "ENABLE_SONNET_THINKING",
283
+ "Sonnet Thinking",
284
+ "thinking",
285
+ "tri_boolean",
286
+ settings_attr="enable_sonnet_thinking",
287
+ description="Blank inherits Enable Thinking.",
288
+ ),
289
+ ConfigFieldSpec(
290
+ "ENABLE_HAIKU_THINKING",
291
+ "Haiku Thinking",
292
+ "thinking",
293
+ "tri_boolean",
294
+ settings_attr="enable_haiku_thinking",
295
+ description="Blank inherits Enable Thinking.",
296
+ ),
297
+ ConfigFieldSpec(
298
+ "ANTHROPIC_AUTH_TOKEN",
299
+ "API/CLI Auth Token",
300
+ "runtime",
301
+ "secret",
302
+ settings_attr="anthropic_auth_token",
303
+ default="freecc",
304
+ secret=True,
305
+ description="Protects Claude/API access. It is not admin-page login.",
306
+ ),
307
+ ConfigFieldSpec(
308
+ "PROVIDER_RATE_LIMIT",
309
+ "Provider Rate Limit",
310
+ "runtime",
311
+ "number",
312
+ settings_attr="provider_rate_limit",
313
+ default="1",
314
+ ),
315
+ ConfigFieldSpec(
316
+ "PROVIDER_RATE_WINDOW",
317
+ "Provider Rate Window",
318
+ "runtime",
319
+ "number",
320
+ settings_attr="provider_rate_window",
321
+ default="3",
322
+ ),
323
+ ConfigFieldSpec(
324
+ "PROVIDER_MAX_CONCURRENCY",
325
+ "Provider Max Concurrency",
326
+ "runtime",
327
+ "number",
328
+ settings_attr="provider_max_concurrency",
329
+ default="5",
330
+ ),
331
+ ConfigFieldSpec(
332
+ "HTTP_READ_TIMEOUT",
333
+ "HTTP Read Timeout",
334
+ "runtime",
335
+ "number",
336
+ settings_attr="http_read_timeout",
337
+ default="300",
338
+ ),
339
+ ConfigFieldSpec(
340
+ "HTTP_WRITE_TIMEOUT",
341
+ "HTTP Write Timeout",
342
+ "runtime",
343
+ "number",
344
+ settings_attr="http_write_timeout",
345
+ default="60",
346
+ ),
347
+ ConfigFieldSpec(
348
+ "HTTP_CONNECT_TIMEOUT",
349
+ "HTTP Connect Timeout",
350
+ "runtime",
351
+ "number",
352
+ settings_attr="http_connect_timeout",
353
+ default="60",
354
+ ),
355
+ ConfigFieldSpec(
356
+ "HOST",
357
+ "Server Host",
358
+ "runtime",
359
+ settings_attr="host",
360
+ default="0.0.0.0",
361
+ restart_required=True,
362
+ ),
363
+ ConfigFieldSpec(
364
+ "PORT",
365
+ "Server Port",
366
+ "runtime",
367
+ "number",
368
+ settings_attr="port",
369
+ default="8082",
370
+ restart_required=True,
371
+ ),
372
+ ConfigFieldSpec(
373
+ "LOG_FILE",
374
+ "Log File",
375
+ "runtime",
376
+ settings_attr="log_file",
377
+ default="server.log",
378
+ restart_required=True,
379
+ ),
380
+ ConfigFieldSpec(
381
+ "MESSAGING_PLATFORM",
382
+ "Messaging Platform",
383
+ "messaging",
384
+ "select",
385
+ settings_attr="messaging_platform",
386
+ default="discord",
387
+ options=("telegram", "discord", "none"),
388
+ session_sensitive=True,
389
+ ),
390
+ ConfigFieldSpec(
391
+ "MESSAGING_RATE_LIMIT",
392
+ "Messaging Rate Limit",
393
+ "messaging",
394
+ "number",
395
+ settings_attr="messaging_rate_limit",
396
+ default="1",
397
+ session_sensitive=True,
398
+ ),
399
+ ConfigFieldSpec(
400
+ "MESSAGING_RATE_WINDOW",
401
+ "Messaging Rate Window",
402
+ "messaging",
403
+ "number",
404
+ settings_attr="messaging_rate_window",
405
+ default="1",
406
+ session_sensitive=True,
407
+ ),
408
+ ConfigFieldSpec(
409
+ "TELEGRAM_BOT_TOKEN",
410
+ "Telegram Bot Token",
411
+ "messaging",
412
+ "secret",
413
+ settings_attr="telegram_bot_token",
414
+ secret=True,
415
+ session_sensitive=True,
416
+ ),
417
+ ConfigFieldSpec(
418
+ "ALLOWED_TELEGRAM_USER_ID",
419
+ "Allowed Telegram User ID",
420
+ "messaging",
421
+ settings_attr="allowed_telegram_user_id",
422
+ session_sensitive=True,
423
+ ),
424
+ ConfigFieldSpec(
425
+ "DISCORD_BOT_TOKEN",
426
+ "Discord Bot Token",
427
+ "messaging",
428
+ "secret",
429
+ settings_attr="discord_bot_token",
430
+ secret=True,
431
+ session_sensitive=True,
432
+ ),
433
+ ConfigFieldSpec(
434
+ "ALLOWED_DISCORD_CHANNELS",
435
+ "Allowed Discord Channels",
436
+ "messaging",
437
+ settings_attr="allowed_discord_channels",
438
+ session_sensitive=True,
439
+ ),
440
+ ConfigFieldSpec(
441
+ "CLAUDE_WORKSPACE",
442
+ "Claude Workspace",
443
+ "messaging",
444
+ settings_attr="claude_workspace",
445
+ default="./agent_workspace",
446
+ session_sensitive=True,
447
+ ),
448
+ ConfigFieldSpec(
449
+ "ALLOWED_DIR",
450
+ "Allowed Directory",
451
+ "messaging",
452
+ settings_attr="allowed_dir",
453
+ session_sensitive=True,
454
+ ),
455
+ ConfigFieldSpec(
456
+ "CLAUDE_CLI_BIN",
457
+ "Claude CLI Binary",
458
+ "messaging",
459
+ settings_attr="claude_cli_bin",
460
+ default="claude",
461
+ session_sensitive=True,
462
+ ),
463
+ ConfigFieldSpec(
464
+ "MAX_MESSAGE_LOG_ENTRIES_PER_CHAT",
465
+ "Max Message Log Entries",
466
+ "messaging",
467
+ "number",
468
+ settings_attr="max_message_log_entries_per_chat",
469
+ advanced=True,
470
+ session_sensitive=True,
471
+ ),
472
+ ConfigFieldSpec(
473
+ "VOICE_NOTE_ENABLED",
474
+ "Voice Notes",
475
+ "voice",
476
+ "boolean",
477
+ settings_attr="voice_note_enabled",
478
+ default="false",
479
+ session_sensitive=True,
480
+ ),
481
+ ConfigFieldSpec(
482
+ "WHISPER_DEVICE",
483
+ "Whisper Device",
484
+ "voice",
485
+ "select",
486
+ settings_attr="whisper_device",
487
+ default="nvidia_nim",
488
+ options=("cpu", "cuda", "nvidia_nim"),
489
+ session_sensitive=True,
490
+ ),
491
+ ConfigFieldSpec(
492
+ "WHISPER_MODEL",
493
+ "Whisper Model",
494
+ "voice",
495
+ settings_attr="whisper_model",
496
+ default="openai/whisper-large-v3",
497
+ session_sensitive=True,
498
+ ),
499
+ ConfigFieldSpec(
500
+ "HF_TOKEN",
501
+ "Hugging Face Token",
502
+ "voice",
503
+ "secret",
504
+ settings_attr="hf_token",
505
+ secret=True,
506
+ session_sensitive=True,
507
+ ),
508
+ ConfigFieldSpec(
509
+ "FAST_PREFIX_DETECTION",
510
+ "Fast Prefix Detection",
511
+ "runtime",
512
+ "boolean",
513
+ settings_attr="fast_prefix_detection",
514
+ default="true",
515
+ advanced=True,
516
+ ),
517
+ ConfigFieldSpec(
518
+ "ENABLE_NETWORK_PROBE_MOCK",
519
+ "Network Probe Mock",
520
+ "runtime",
521
+ "boolean",
522
+ settings_attr="enable_network_probe_mock",
523
+ default="true",
524
+ advanced=True,
525
+ ),
526
+ ConfigFieldSpec(
527
+ "ENABLE_TITLE_GENERATION_SKIP",
528
+ "Title Generation Skip",
529
+ "runtime",
530
+ "boolean",
531
+ settings_attr="enable_title_generation_skip",
532
+ default="true",
533
+ advanced=True,
534
+ ),
535
+ ConfigFieldSpec(
536
+ "ENABLE_SUGGESTION_MODE_SKIP",
537
+ "Suggestion Mode Skip",
538
+ "runtime",
539
+ "boolean",
540
+ settings_attr="enable_suggestion_mode_skip",
541
+ default="true",
542
+ advanced=True,
543
+ ),
544
+ ConfigFieldSpec(
545
+ "ENABLE_FILEPATH_EXTRACTION_MOCK",
546
+ "Filepath Extraction Mock",
547
+ "runtime",
548
+ "boolean",
549
+ settings_attr="enable_filepath_extraction_mock",
550
+ default="true",
551
+ advanced=True,
552
+ ),
553
+ ConfigFieldSpec(
554
+ "ENABLE_WEB_SERVER_TOOLS",
555
+ "Web Server Tools",
556
+ "web_tools",
557
+ "boolean",
558
+ settings_attr="enable_web_server_tools",
559
+ default="true",
560
+ ),
561
+ ConfigFieldSpec(
562
+ "WEB_FETCH_ALLOWED_SCHEMES",
563
+ "Allowed Web Fetch Schemes",
564
+ "web_tools",
565
+ settings_attr="web_fetch_allowed_schemes",
566
+ default="http,https",
567
+ ),
568
+ ConfigFieldSpec(
569
+ "WEB_FETCH_ALLOW_PRIVATE_NETWORKS",
570
+ "Allow Private Networks",
571
+ "web_tools",
572
+ "boolean",
573
+ settings_attr="web_fetch_allow_private_networks",
574
+ default="false",
575
+ ),
576
+ ConfigFieldSpec(
577
+ "DEBUG_PLATFORM_EDITS",
578
+ "Debug Platform Edits",
579
+ "diagnostics",
580
+ "boolean",
581
+ settings_attr="debug_platform_edits",
582
+ default="false",
583
+ advanced=True,
584
+ ),
585
+ ConfigFieldSpec(
586
+ "DEBUG_SUBAGENT_STACK",
587
+ "Debug Subagent Stack",
588
+ "diagnostics",
589
+ "boolean",
590
+ settings_attr="debug_subagent_stack",
591
+ default="false",
592
+ advanced=True,
593
+ ),
594
+ ConfigFieldSpec(
595
+ "LOG_RAW_API_PAYLOADS",
596
+ "Log Raw API Payloads",
597
+ "diagnostics",
598
+ "boolean",
599
+ settings_attr="log_raw_api_payloads",
600
+ default="false",
601
+ advanced=True,
602
+ ),
603
+ ConfigFieldSpec(
604
+ "LOG_RAW_SSE_EVENTS",
605
+ "Log Raw SSE Events",
606
+ "diagnostics",
607
+ "boolean",
608
+ settings_attr="log_raw_sse_events",
609
+ default="false",
610
+ advanced=True,
611
+ ),
612
+ ConfigFieldSpec(
613
+ "LOG_API_ERROR_TRACEBACKS",
614
+ "Log API Error Tracebacks",
615
+ "diagnostics",
616
+ "boolean",
617
+ settings_attr="log_api_error_tracebacks",
618
+ default="false",
619
+ advanced=True,
620
+ ),
621
+ ConfigFieldSpec(
622
+ "LOG_RAW_MESSAGING_CONTENT",
623
+ "Log Raw Messaging Content",
624
+ "diagnostics",
625
+ "boolean",
626
+ settings_attr="log_raw_messaging_content",
627
+ default="false",
628
+ advanced=True,
629
+ ),
630
+ ConfigFieldSpec(
631
+ "LOG_RAW_CLI_DIAGNOSTICS",
632
+ "Log Raw CLI Diagnostics",
633
+ "diagnostics",
634
+ "boolean",
635
+ settings_attr="log_raw_cli_diagnostics",
636
+ default="false",
637
+ advanced=True,
638
+ ),
639
+ ConfigFieldSpec(
640
+ "LOG_MESSAGING_ERROR_DETAILS",
641
+ "Log Messaging Error Details",
642
+ "diagnostics",
643
+ "boolean",
644
+ settings_attr="log_messaging_error_details",
645
+ default="false",
646
+ advanced=True,
647
+ ),
648
+ ConfigFieldSpec(
649
+ "FCC_SMOKE_MODEL_NVIDIA_NIM",
650
+ "Smoke NVIDIA NIM Model",
651
+ "smoke",
652
+ advanced=True,
653
+ ),
654
+ ConfigFieldSpec(
655
+ "FCC_SMOKE_MODEL_OPEN_ROUTER",
656
+ "Smoke OpenRouter Model",
657
+ "smoke",
658
+ advanced=True,
659
+ ),
660
+ ConfigFieldSpec(
661
+ "FCC_SMOKE_MODEL_DEEPSEEK",
662
+ "Smoke DeepSeek Model",
663
+ "smoke",
664
+ advanced=True,
665
+ ),
666
+ ConfigFieldSpec(
667
+ "FCC_SMOKE_MODEL_LMSTUDIO",
668
+ "Smoke LM Studio Model",
669
+ "smoke",
670
+ advanced=True,
671
+ ),
672
+ ConfigFieldSpec(
673
+ "FCC_SMOKE_MODEL_LLAMACPP",
674
+ "Smoke llama.cpp Model",
675
+ "smoke",
676
+ advanced=True,
677
+ ),
678
+ ConfigFieldSpec(
679
+ "FCC_SMOKE_MODEL_OLLAMA",
680
+ "Smoke Ollama Model",
681
+ "smoke",
682
+ advanced=True,
683
+ ),
684
+ ConfigFieldSpec(
685
+ "FCC_SMOKE_MODEL_KIMI",
686
+ "Smoke Kimi Model",
687
+ "smoke",
688
+ advanced=True,
689
+ ),
690
+ ConfigFieldSpec(
691
+ "FCC_SMOKE_MODEL_WAFER",
692
+ "Smoke Wafer Model",
693
+ "smoke",
694
+ advanced=True,
695
+ ),
696
+ ConfigFieldSpec(
697
+ "FCC_SMOKE_NIM_MODELS",
698
+ "Smoke NIM Models",
699
+ "smoke",
700
+ advanced=True,
701
+ ),
702
+ ConfigFieldSpec(
703
+ "FCC_SMOKE_NIM_EXTRA_MODELS",
704
+ "Smoke NIM Extra Models",
705
+ "smoke",
706
+ advanced=True,
707
+ ),
708
+ ConfigFieldSpec(
709
+ "FCC_SMOKE_OPENROUTER_FREE_MODELS",
710
+ "Smoke OpenRouter Free Models",
711
+ "smoke",
712
+ advanced=True,
713
+ ),
714
+ ConfigFieldSpec(
715
+ "FCC_SMOKE_OPENROUTER_FREE_EXTRA_MODELS",
716
+ "Smoke OpenRouter Free Extra Models",
717
+ "smoke",
718
+ advanced=True,
719
+ ),
720
+ )
721
+
722
+ FIELD_BY_KEY = {field.key: field for field in FIELDS}
723
+
724
+
725
+ def managed_env_path() -> Path:
726
+ """Return the admin-managed user config path."""
727
+
728
+ return Path.home() / MANAGED_ENV_RELATIVE
729
+
730
+
731
+ def repo_env_path() -> Path:
732
+ """Return the repo-local env path."""
733
+
734
+ return Path(".env")
735
+
736
+
737
+ def explicit_env_path() -> Path | None:
738
+ """Return the explicit FCC_ENV_FILE path, when configured."""
739
+
740
+ if explicit := os.environ.get("FCC_ENV_FILE"):
741
+ return Path(explicit)
742
+ return None
743
+
744
+
745
+ def configured_env_files() -> tuple[tuple[SourceType, Path], ...]:
746
+ """Return dotenv files in low-to-high precedence order."""
747
+
748
+ files: list[tuple[SourceType, Path]] = [
749
+ ("repo_env", repo_env_path()),
750
+ ("managed_env", managed_env_path()),
751
+ ]
752
+ if explicit := explicit_env_path():
753
+ files.append(("explicit_env_file", explicit))
754
+ return tuple(files)
755
+
756
+
757
+ def _template_text() -> str:
758
+ import importlib.resources
759
+
760
+ packaged = importlib.resources.files("cli").joinpath("env.example")
761
+ if packaged.is_file():
762
+ return packaged.read_text("utf-8")
763
+
764
+ source_template = Path(__file__).resolve().parents[1] / ".env.example"
765
+ if source_template.is_file():
766
+ return source_template.read_text(encoding="utf-8")
767
+
768
+ return ""
769
+
770
+
771
+ def _dotenv_values_from_text(text: str) -> dict[str, str]:
772
+ values = dotenv_values(stream=StringIO(text))
773
+ return {key: "" if value is None else value for key, value in values.items()}
774
+
775
+
776
+ def template_values() -> dict[str, str]:
777
+ """Return .env.example values plus manifest defaults for newer fields."""
778
+
779
+ values = _dotenv_values_from_text(_template_text())
780
+ for field in FIELDS:
781
+ values.setdefault(field.key, field.default)
782
+ return values
783
+
784
+
785
+ def _dotenv_values_from_file(path: Path) -> dict[str, str]:
786
+ if not path.is_file():
787
+ return {}
788
+ values = dotenv_values(path)
789
+ return {key: "" if value is None else value for key, value in values.items()}
790
+
791
+
792
+ def _field_input_key(field: ConfigFieldSpec) -> str | None:
793
+ if field.settings_attr is None:
794
+ return None
795
+ model_field = Settings.model_fields[field.settings_attr]
796
+ alias = model_field.validation_alias
797
+ if alias is None:
798
+ return field.settings_attr
799
+ return str(alias)
800
+
801
+
802
+ def _is_locked_source(source: SourceType) -> bool:
803
+ return source in {"process", "explicit_env_file"}
804
+
805
+
806
+ def _normalize_for_env(value: Any) -> str:
807
+ if value is None:
808
+ return ""
809
+ if isinstance(value, bool):
810
+ return "true" if value else "false"
811
+ return str(value)
812
+
813
+
814
+ def _display_value(field: ConfigFieldSpec, value: str) -> str:
815
+ if field.secret and value:
816
+ return MASKED_SECRET
817
+ return value
818
+
819
+
820
+ def _load_value_state() -> dict[str, dict[str, Any]]:
821
+ values = template_values()
822
+ sources: dict[str, SourceType] = {
823
+ key: "template" if key in values else "default" for key in FIELD_BY_KEY
824
+ }
825
+
826
+ for source, path in configured_env_files():
827
+ file_values = _dotenv_values_from_file(path)
828
+ for key, value in file_values.items():
829
+ if key in FIELD_BY_KEY:
830
+ values[key] = value
831
+ sources[key] = source
832
+
833
+ for key in FIELD_BY_KEY:
834
+ if key in os.environ:
835
+ values[key] = os.environ[key]
836
+ sources[key] = "process"
837
+
838
+ return {
839
+ key: {
840
+ "value": values.get(key, ""),
841
+ "source": sources.get(key, "default"),
842
+ }
843
+ for key in FIELD_BY_KEY
844
+ }
845
+
846
+
847
+ def load_config_response() -> dict[str, Any]:
848
+ """Return manifest and current config values for the admin UI."""
849
+
850
+ state = _load_value_state()
851
+ fields: list[dict[str, Any]] = []
852
+ for field in FIELDS:
853
+ entry = state[field.key]
854
+ source = entry["source"]
855
+ raw_value = entry["value"]
856
+ fields.append(
857
+ {
858
+ "key": field.key,
859
+ "label": field.label,
860
+ "section": field.section_id,
861
+ "type": field.field_type,
862
+ "value": _display_value(field, raw_value),
863
+ "configured": bool(str(raw_value).strip()),
864
+ "source": source,
865
+ "locked": _is_locked_source(source),
866
+ "secret": field.secret,
867
+ "advanced": field.advanced,
868
+ "restart_required": field.restart_required,
869
+ "session_sensitive": field.session_sensitive,
870
+ "options": list(field.options),
871
+ "description": field.description,
872
+ }
873
+ )
874
+
875
+ return {
876
+ "sections": [
877
+ {
878
+ "id": section.section_id,
879
+ "label": section.label,
880
+ "description": section.description,
881
+ "advanced": section.advanced,
882
+ }
883
+ for section in SECTIONS
884
+ ],
885
+ "fields": fields,
886
+ "paths": {
887
+ "managed": str(managed_env_path()),
888
+ "repo": str(repo_env_path()),
889
+ "explicit": str(explicit_env_path()) if explicit_env_path() else None,
890
+ },
891
+ "provider_status": provider_config_status(state),
892
+ }
893
+
894
+
895
+ def _target_values_with_updates(updates: Mapping[str, Any]) -> dict[str, str]:
896
+ state = _load_value_state()
897
+ values = template_values()
898
+
899
+ # Preserve existing managed values when present. If no managed config exists,
900
+ # seed the first write from effective repo values to migrate legacy setups.
901
+ managed_values = _dotenv_values_from_file(managed_env_path())
902
+ if managed_values:
903
+ values.update(
904
+ {key: val for key, val in managed_values.items() if key in values}
905
+ )
906
+ else:
907
+ for key, entry in state.items():
908
+ if entry["source"] in {"repo_env", "template", "default"}:
909
+ values[key] = str(entry["value"])
910
+
911
+ for key, value in updates.items():
912
+ field = FIELD_BY_KEY.get(key)
913
+ if field is None:
914
+ continue
915
+ if _is_locked_source(state[key]["source"]):
916
+ continue
917
+ if field.secret and value == MASKED_SECRET:
918
+ continue
919
+ values[key] = _normalize_for_env(value)
920
+
921
+ for field in FIELDS:
922
+ values.setdefault(field.key, field.default)
923
+ return values
924
+
925
+
926
+ def _effective_values_for_validation(
927
+ target_values: Mapping[str, str],
928
+ ) -> dict[str, str]:
929
+ values = dict(target_values)
930
+ for key, entry in _load_value_state().items():
931
+ if _is_locked_source(entry["source"]):
932
+ values[key] = str(entry["value"])
933
+ return values
934
+
935
+
936
+ def validate_values(values: Mapping[str, str]) -> tuple[bool, list[str]]:
937
+ """Validate proposed env values against the Settings model."""
938
+
939
+ kwargs: dict[str, Any] = {"_env_file": None}
940
+ for field in FIELDS:
941
+ input_key = _field_input_key(field)
942
+ if input_key is None:
943
+ continue
944
+ kwargs[input_key] = values.get(field.key, "")
945
+
946
+ try:
947
+ Settings(**kwargs)
948
+ except ValidationError as exc:
949
+ return False, _format_validation_errors(exc)
950
+ return True, []
951
+
952
+
953
+ def _format_validation_errors(exc: ValidationError) -> list[str]:
954
+ errors: list[str] = []
955
+ for error in exc.errors():
956
+ loc = ".".join(str(part) for part in error.get("loc", ()))
957
+ message = str(error.get("msg", "Invalid value"))
958
+ errors.append(f"{loc}: {message}" if loc else message)
959
+ return errors
960
+
961
+
962
+ def validate_updates(updates: Mapping[str, Any]) -> dict[str, Any]:
963
+ """Validate partial admin updates and return a masked generated env preview."""
964
+
965
+ target_values = _target_values_with_updates(updates)
966
+ effective_values = _effective_values_for_validation(target_values)
967
+ valid, errors = validate_values(effective_values)
968
+ return {
969
+ "valid": valid,
970
+ "errors": errors,
971
+ "env_preview": render_env_file(target_values, mask_secrets=True),
972
+ }
973
+
974
+
975
+ def changed_pending_fields(updates: Mapping[str, Any]) -> list[str]:
976
+ """Return changed fields that require manual runtime action."""
977
+
978
+ state = _load_value_state()
979
+ pending: list[str] = []
980
+ for key, value in updates.items():
981
+ field = FIELD_BY_KEY.get(key)
982
+ if field is None or not (field.restart_required or field.session_sensitive):
983
+ continue
984
+ if _normalize_for_env(value) == str(state[key]["value"]):
985
+ continue
986
+ pending.append(key)
987
+ return pending
988
+
989
+
990
+ def write_managed_env(updates: Mapping[str, Any]) -> dict[str, Any]:
991
+ """Validate and atomically write the admin-managed env file."""
992
+
993
+ validation = validate_updates(updates)
994
+ if not validation["valid"]:
995
+ return validation | {"applied": False, "pending_fields": []}
996
+
997
+ target_values = _target_values_with_updates(updates)
998
+ path = managed_env_path()
999
+ path.parent.mkdir(parents=True, exist_ok=True)
1000
+ temp_path = path.with_suffix(path.suffix + ".tmp")
1001
+ temp_path.write_text(render_env_file(target_values), encoding="utf-8")
1002
+ os.replace(temp_path, path)
1003
+ return {
1004
+ "applied": True,
1005
+ "valid": True,
1006
+ "errors": [],
1007
+ "env_preview": render_env_file(target_values, mask_secrets=True),
1008
+ "path": str(path),
1009
+ "pending_fields": changed_pending_fields(updates),
1010
+ }
1011
+
1012
+
1013
+ def _quote_env_value(value: str) -> str:
1014
+ if value == "":
1015
+ return ""
1016
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
1017
+ if any(char.isspace() for char in value) or any(
1018
+ char in value for char in ('"', "#", "=", "$")
1019
+ ):
1020
+ return f'"{escaped}"'
1021
+ return value
1022
+
1023
+
1024
+ def render_env_file(values: Mapping[str, str], *, mask_secrets: bool = False) -> str:
1025
+ """Render a complete grouped env file."""
1026
+
1027
+ lines: list[str] = [
1028
+ "# Managed by Free Claude Code /admin.",
1029
+ "# Edit in the server UI when possible.",
1030
+ "",
1031
+ ]
1032
+ fields_by_section: dict[str, list[ConfigFieldSpec]] = {
1033
+ section.section_id: [] for section in SECTIONS
1034
+ }
1035
+ for field in FIELDS:
1036
+ fields_by_section.setdefault(field.section_id, []).append(field)
1037
+
1038
+ for section in SECTIONS:
1039
+ lines.append(f"# {section.label}")
1040
+ for field in fields_by_section.get(section.section_id, []):
1041
+ value = values.get(field.key, field.default)
1042
+ if mask_secrets and field.secret and value:
1043
+ value = MASKED_SECRET
1044
+ lines.append(f"{field.key}={_quote_env_value(value)}")
1045
+ lines.append("")
1046
+ return "\n".join(lines).rstrip() + "\n"
1047
+
1048
+
1049
+ def provider_config_status(
1050
+ state: Mapping[str, Mapping[str, Any]] | None = None,
1051
+ ) -> list[dict[str, Any]]:
1052
+ """Return provider configuration status without making network calls."""
1053
+
1054
+ state = state or _load_value_state()
1055
+ statuses: list[dict[str, Any]] = []
1056
+ for provider_id, descriptor in PROVIDER_CATALOG.items():
1057
+ if descriptor.credential_env is None:
1058
+ base_url = ""
1059
+ if descriptor.base_url_attr is not None:
1060
+ base_url = _value_for_settings_attr(state, descriptor.base_url_attr)
1061
+ statuses.append(
1062
+ {
1063
+ "provider_id": provider_id,
1064
+ "kind": "local",
1065
+ "status": "missing_url" if not base_url.strip() else "unknown",
1066
+ "label": "Missing URL" if not base_url.strip() else "Not checked",
1067
+ "base_url": base_url or descriptor.default_base_url or "",
1068
+ }
1069
+ )
1070
+ continue
1071
+
1072
+ value = str(state.get(descriptor.credential_env, {}).get("value", ""))
1073
+ configured = bool(value.strip())
1074
+ statuses.append(
1075
+ {
1076
+ "provider_id": provider_id,
1077
+ "kind": "remote",
1078
+ "status": "configured" if configured else "missing_key",
1079
+ "label": "Configured" if configured else "Missing key",
1080
+ "credential_env": descriptor.credential_env,
1081
+ }
1082
+ )
1083
+ return statuses
1084
+
1085
+
1086
+ def _value_for_settings_attr(
1087
+ state: Mapping[str, Mapping[str, Any]], settings_attr: str
1088
+ ) -> str:
1089
+ for field in FIELDS:
1090
+ if field.settings_attr == settings_attr:
1091
+ return str(state.get(field.key, {}).get("value", field.default))
1092
+ return ""
1093
+
1094
+
1095
+ def env_keys() -> frozenset[str]:
1096
+ """Return env keys owned by the admin manifest."""
1097
+
1098
+ return frozenset(field.key for field in FIELDS)
1099
+
1100
+
1101
+ def fields_with_attrs() -> Iterable[ConfigFieldSpec]:
1102
+ """Yield fields that validate through Settings."""
1103
+
1104
+ return (field for field in FIELDS if field.settings_attr is not None)
api/admin_routes.py ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Local admin UI routes and APIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ from pathlib import Path
7
+ from typing import Any
8
+ from urllib.parse import urlsplit
9
+
10
+ import httpx
11
+ from fastapi import APIRouter, HTTPException, Request
12
+ from fastapi.responses import FileResponse
13
+ from pydantic import BaseModel, Field
14
+
15
+ from config.settings import get_settings as get_cached_settings
16
+ from providers.registry import ProviderRegistry
17
+
18
+ from .admin_config import (
19
+ FIELD_BY_KEY,
20
+ load_config_response,
21
+ provider_config_status,
22
+ validate_updates,
23
+ write_managed_env,
24
+ )
25
+
26
+ router = APIRouter()
27
+
28
+ STATIC_DIR = Path(__file__).resolve().parent / "admin_static"
29
+ LOCAL_PROVIDER_PATHS = {
30
+ "lmstudio": "/models",
31
+ "llamacpp": "/models",
32
+ "ollama": "/api/tags",
33
+ }
34
+
35
+
36
+ class AdminConfigPayload(BaseModel):
37
+ """Partial config update submitted by the admin UI."""
38
+
39
+ values: dict[str, Any] = Field(default_factory=dict)
40
+
41
+
42
+ def _is_loopback_host(host: str | None) -> bool:
43
+ if host is None:
44
+ return False
45
+ normalized = host.strip().strip("[]").lower()
46
+ if normalized == "localhost":
47
+ return True
48
+ try:
49
+ return ipaddress.ip_address(normalized).is_loopback
50
+ except ValueError:
51
+ return False
52
+
53
+
54
+ def _origin_is_local(origin: str | None) -> bool:
55
+ if not origin:
56
+ return True
57
+ parsed = urlsplit(origin)
58
+ return _is_loopback_host(parsed.hostname)
59
+
60
+
61
+ def require_loopback_admin(request: Request) -> None:
62
+ """Allow admin access only from the local machine."""
63
+
64
+ client_host = request.client.host if request.client else None
65
+ if not _is_loopback_host(client_host):
66
+ raise HTTPException(status_code=403, detail="Admin UI is local-only")
67
+
68
+ origin = request.headers.get("origin")
69
+ if not _origin_is_local(origin):
70
+ raise HTTPException(status_code=403, detail="Admin UI is local-only")
71
+
72
+
73
+ def _asset_response(filename: str) -> FileResponse:
74
+ path = STATIC_DIR / filename
75
+ if not path.is_file():
76
+ raise HTTPException(status_code=404, detail="Admin asset not found")
77
+ return FileResponse(path)
78
+
79
+
80
+ @router.get("/admin", include_in_schema=False)
81
+ async def admin_page(request: Request):
82
+ require_loopback_admin(request)
83
+ return _asset_response("index.html")
84
+
85
+
86
+ @router.get("/admin/assets/{filename}", include_in_schema=False)
87
+ async def admin_asset(filename: str, request: Request):
88
+ require_loopback_admin(request)
89
+ if filename not in {"admin.css", "admin.js"}:
90
+ raise HTTPException(status_code=404, detail="Admin asset not found")
91
+ return _asset_response(filename)
92
+
93
+
94
+ @router.get("/admin/api/config")
95
+ async def get_admin_config(request: Request):
96
+ require_loopback_admin(request)
97
+ return load_config_response()
98
+
99
+
100
+ @router.post("/admin/api/config/validate")
101
+ async def validate_admin_config(payload: AdminConfigPayload, request: Request):
102
+ require_loopback_admin(request)
103
+ return validate_updates(_filtered_values(payload.values))
104
+
105
+
106
+ @router.post("/admin/api/config/apply")
107
+ async def apply_admin_config(payload: AdminConfigPayload, request: Request):
108
+ require_loopback_admin(request)
109
+ result = write_managed_env(_filtered_values(payload.values))
110
+ if not result["applied"]:
111
+ return result
112
+
113
+ get_cached_settings.cache_clear()
114
+ old_registry = getattr(request.app.state, "provider_registry", None)
115
+ if isinstance(old_registry, ProviderRegistry):
116
+ await old_registry.cleanup()
117
+ request.app.state.provider_registry = ProviderRegistry()
118
+ request.app.state.admin_pending_fields = result["pending_fields"]
119
+ return result
120
+
121
+
122
+ @router.get("/admin/api/status")
123
+ async def admin_status(request: Request):
124
+ require_loopback_admin(request)
125
+ settings = get_cached_settings()
126
+ registry = getattr(request.app.state, "provider_registry", None)
127
+ cached_models: dict[str, list[str]] = {}
128
+ if isinstance(registry, ProviderRegistry):
129
+ cached_models = {
130
+ provider_id: sorted(model_ids)
131
+ for provider_id, model_ids in registry.cached_model_ids().items()
132
+ }
133
+ return {
134
+ "status": "running",
135
+ "host": settings.host,
136
+ "port": settings.port,
137
+ "model": settings.model,
138
+ "provider": settings.provider_type,
139
+ "pending_fields": getattr(request.app.state, "admin_pending_fields", []),
140
+ "provider_status": provider_config_status(),
141
+ "cached_models": cached_models,
142
+ }
143
+
144
+
145
+ @router.get("/admin/api/providers/local-status")
146
+ async def local_provider_status(request: Request):
147
+ require_loopback_admin(request)
148
+ config = load_config_response()
149
+ values = {field["key"]: field["value"] for field in config["fields"]}
150
+ checks = []
151
+ for provider_id, path in LOCAL_PROVIDER_PATHS.items():
152
+ base_url = _local_provider_url(provider_id, values)
153
+ checks.append(await _check_local_provider(provider_id, base_url, path))
154
+ return {"providers": checks}
155
+
156
+
157
+ @router.post("/admin/api/providers/{provider_id}/test")
158
+ async def test_provider(provider_id: str, request: Request):
159
+ require_loopback_admin(request)
160
+ settings = get_cached_settings()
161
+ registry = getattr(request.app.state, "provider_registry", None)
162
+ if not isinstance(registry, ProviderRegistry):
163
+ registry = ProviderRegistry()
164
+ request.app.state.provider_registry = registry
165
+ try:
166
+ provider = registry.get(provider_id, settings)
167
+ infos = await provider.list_model_infos()
168
+ except Exception as exc:
169
+ return {
170
+ "provider_id": provider_id,
171
+ "ok": False,
172
+ "error_type": type(exc).__name__,
173
+ }
174
+ registry.cache_model_infos(provider_id, infos)
175
+ return {
176
+ "provider_id": provider_id,
177
+ "ok": True,
178
+ "models": sorted(info.model_id for info in infos),
179
+ }
180
+
181
+
182
+ @router.post("/admin/api/models/refresh")
183
+ async def refresh_models(request: Request):
184
+ require_loopback_admin(request)
185
+ settings = get_cached_settings()
186
+ registry = getattr(request.app.state, "provider_registry", None)
187
+ if not isinstance(registry, ProviderRegistry):
188
+ registry = ProviderRegistry()
189
+ request.app.state.provider_registry = registry
190
+ await registry.refresh_model_list_cache(settings)
191
+ return {
192
+ "cached_models": {
193
+ provider_id: sorted(model_ids)
194
+ for provider_id, model_ids in registry.cached_model_ids().items()
195
+ }
196
+ }
197
+
198
+
199
+ def _filtered_values(values: dict[str, Any]) -> dict[str, Any]:
200
+ return {key: value for key, value in values.items() if key in FIELD_BY_KEY}
201
+
202
+
203
+ def _local_provider_url(provider_id: str, values: dict[str, str]) -> str:
204
+ if provider_id == "lmstudio":
205
+ return values.get("LM_STUDIO_BASE_URL", "")
206
+ if provider_id == "llamacpp":
207
+ return values.get("LLAMACPP_BASE_URL", "")
208
+ if provider_id == "ollama":
209
+ return values.get("OLLAMA_BASE_URL", "")
210
+ return ""
211
+
212
+
213
+ async def _check_local_provider(
214
+ provider_id: str, base_url: str, path: str
215
+ ) -> dict[str, Any]:
216
+ clean_url = base_url.strip().rstrip("/")
217
+ if not clean_url:
218
+ return {
219
+ "provider_id": provider_id,
220
+ "status": "missing_url",
221
+ "label": "Missing URL",
222
+ "base_url": base_url,
223
+ }
224
+
225
+ url = f"{clean_url}{path}"
226
+ try:
227
+ async with httpx.AsyncClient(timeout=1.5) as client:
228
+ response = await client.get(url)
229
+ ok = 200 <= response.status_code < 300
230
+ return {
231
+ "provider_id": provider_id,
232
+ "status": "reachable" if ok else "offline",
233
+ "label": "Reachable" if ok else "Offline",
234
+ "base_url": base_url,
235
+ "status_code": response.status_code,
236
+ }
237
+ except Exception as exc:
238
+ return {
239
+ "provider_id": provider_id,
240
+ "status": "offline",
241
+ "label": "Offline",
242
+ "base_url": base_url,
243
+ "error_type": type(exc).__name__,
244
+ }
api/admin_static/admin.css ADDED
@@ -0,0 +1,492 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ color-scheme: dark;
3
+ --bg: #11100e;
4
+ --panel: #1a1815;
5
+ --panel-strong: #25211c;
6
+ --card: #201d19;
7
+ --input: #12110f;
8
+ --text: #f3eee7;
9
+ --muted: #aaa197;
10
+ --line: #373129;
11
+ --line-strong: #4d4439;
12
+ --accent: #2fb984;
13
+ --accent-dark: #24946b;
14
+ --warn: #f5b74f;
15
+ --error: #ff746c;
16
+ --ok: #59d994;
17
+ --info: #7cc7ff;
18
+ --shadow: 0 14px 34px rgba(0, 0, 0, 0.38);
19
+ }
20
+
21
+ * {
22
+ box-sizing: border-box;
23
+ }
24
+
25
+ body {
26
+ margin: 0;
27
+ min-width: 320px;
28
+ background: var(--bg);
29
+ color: var(--text);
30
+ font-family:
31
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
32
+ sans-serif;
33
+ letter-spacing: 0;
34
+ }
35
+
36
+ button,
37
+ input,
38
+ select,
39
+ textarea {
40
+ font: inherit;
41
+ }
42
+
43
+ .app-shell {
44
+ display: grid;
45
+ grid-template-columns: 268px minmax(0, 1fr);
46
+ min-height: 100vh;
47
+ padding-bottom: 86px;
48
+ }
49
+
50
+ .sidebar {
51
+ position: sticky;
52
+ top: 0;
53
+ height: 100vh;
54
+ border-right: 1px solid var(--line);
55
+ background: #171511;
56
+ padding: 20px 16px;
57
+ }
58
+
59
+ .brand {
60
+ display: flex;
61
+ align-items: center;
62
+ gap: 12px;
63
+ margin-bottom: 28px;
64
+ }
65
+
66
+ .brand-mark {
67
+ display: grid;
68
+ width: 38px;
69
+ height: 38px;
70
+ place-items: center;
71
+ border-radius: 8px;
72
+ background: var(--accent);
73
+ color: #ffffff;
74
+ font-weight: 800;
75
+ font-size: 14px;
76
+ }
77
+
78
+ .brand h1,
79
+ .brand p,
80
+ .topbar h2,
81
+ .topbar p,
82
+ .section-heading h3,
83
+ .section-heading p,
84
+ .strip-header h3 {
85
+ margin: 0;
86
+ }
87
+
88
+ .brand h1 {
89
+ font-size: 15px;
90
+ line-height: 1.2;
91
+ }
92
+
93
+ .brand p,
94
+ .eyebrow,
95
+ .section-heading p,
96
+ .action-meta span {
97
+ color: var(--muted);
98
+ font-size: 12px;
99
+ }
100
+
101
+ .section-nav {
102
+ display: grid;
103
+ gap: 6px;
104
+ }
105
+
106
+ .nav-link {
107
+ display: flex;
108
+ justify-content: space-between;
109
+ align-items: center;
110
+ width: 100%;
111
+ min-height: 36px;
112
+ border: 1px solid transparent;
113
+ border-radius: 8px;
114
+ background: transparent;
115
+ color: var(--text);
116
+ padding: 8px 10px;
117
+ text-align: left;
118
+ cursor: pointer;
119
+ }
120
+
121
+ .nav-link:hover,
122
+ .nav-link.active {
123
+ background: var(--panel-strong);
124
+ border-color: var(--line);
125
+ }
126
+
127
+ .main {
128
+ min-width: 0;
129
+ padding: 28px;
130
+ }
131
+
132
+ .topbar {
133
+ display: flex;
134
+ justify-content: space-between;
135
+ align-items: center;
136
+ gap: 16px;
137
+ margin-bottom: 20px;
138
+ }
139
+
140
+ .eyebrow {
141
+ margin-bottom: 4px;
142
+ text-transform: uppercase;
143
+ font-weight: 700;
144
+ }
145
+
146
+ .topbar h2 {
147
+ font-size: 28px;
148
+ line-height: 1.15;
149
+ }
150
+
151
+ .server-state {
152
+ display: flex;
153
+ flex-wrap: wrap;
154
+ justify-content: flex-end;
155
+ gap: 8px;
156
+ }
157
+
158
+ .status-pill,
159
+ .model-badge {
160
+ display: inline-flex;
161
+ align-items: center;
162
+ min-height: 30px;
163
+ border: 1px solid var(--line);
164
+ border-radius: 999px;
165
+ padding: 5px 10px;
166
+ background: var(--panel-strong);
167
+ color: var(--muted);
168
+ font-size: 12px;
169
+ font-weight: 700;
170
+ white-space: nowrap;
171
+ }
172
+
173
+ .status-pill.ok {
174
+ color: var(--ok);
175
+ background: rgba(47, 185, 132, 0.13);
176
+ border-color: rgba(89, 217, 148, 0.36);
177
+ }
178
+
179
+ .status-pill.warn {
180
+ color: var(--warn);
181
+ background: rgba(245, 183, 79, 0.13);
182
+ border-color: rgba(245, 183, 79, 0.38);
183
+ }
184
+
185
+ .status-pill.error {
186
+ color: var(--error);
187
+ background: rgba(255, 116, 108, 0.12);
188
+ border-color: rgba(255, 116, 108, 0.36);
189
+ }
190
+
191
+ .provider-strip,
192
+ .settings-section,
193
+ .env-panel {
194
+ border: 1px solid var(--line);
195
+ border-radius: 8px;
196
+ background: var(--panel);
197
+ box-shadow: var(--shadow);
198
+ }
199
+
200
+ .provider-strip {
201
+ margin-bottom: 18px;
202
+ padding: 16px;
203
+ }
204
+
205
+ .strip-header,
206
+ .section-heading {
207
+ display: flex;
208
+ align-items: center;
209
+ justify-content: space-between;
210
+ gap: 12px;
211
+ }
212
+
213
+ .strip-header {
214
+ margin-bottom: 12px;
215
+ }
216
+
217
+ .strip-header h3,
218
+ .section-heading h3 {
219
+ font-size: 16px;
220
+ }
221
+
222
+ .provider-grid {
223
+ display: grid;
224
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
225
+ gap: 10px;
226
+ }
227
+
228
+ .provider-card {
229
+ display: grid;
230
+ gap: 8px;
231
+ min-height: 108px;
232
+ border: 1px solid var(--line);
233
+ border-radius: 8px;
234
+ padding: 12px;
235
+ background: var(--card);
236
+ }
237
+
238
+ .provider-title {
239
+ display: flex;
240
+ align-items: center;
241
+ justify-content: space-between;
242
+ gap: 8px;
243
+ }
244
+
245
+ .provider-title strong {
246
+ font-size: 14px;
247
+ }
248
+
249
+ .provider-meta {
250
+ color: var(--muted);
251
+ font-size: 12px;
252
+ word-break: break-word;
253
+ }
254
+
255
+ .test-button,
256
+ .ghost-button,
257
+ .secondary-button,
258
+ .primary-button {
259
+ min-height: 34px;
260
+ border-radius: 8px;
261
+ border: 1px solid var(--line-strong);
262
+ padding: 7px 12px;
263
+ cursor: pointer;
264
+ font-weight: 700;
265
+ }
266
+
267
+ .ghost-button,
268
+ .secondary-button,
269
+ .test-button {
270
+ background: var(--panel-strong);
271
+ color: var(--text);
272
+ }
273
+
274
+ .ghost-button:hover,
275
+ .secondary-button:hover,
276
+ .test-button:hover {
277
+ border-color: var(--accent);
278
+ }
279
+
280
+ .primary-button {
281
+ border-color: var(--accent);
282
+ background: var(--accent);
283
+ color: #06100b;
284
+ }
285
+
286
+ .primary-button:hover {
287
+ background: var(--accent-dark);
288
+ }
289
+
290
+ .primary-button:disabled {
291
+ cursor: not-allowed;
292
+ border-color: var(--line);
293
+ background: #34302a;
294
+ color: #797067;
295
+ }
296
+
297
+ .form-sections {
298
+ display: grid;
299
+ gap: 18px;
300
+ }
301
+
302
+ .settings-section {
303
+ padding: 18px;
304
+ scroll-margin-top: 20px;
305
+ }
306
+
307
+ .section-heading {
308
+ margin-bottom: 16px;
309
+ }
310
+
311
+ .field-grid {
312
+ display: grid;
313
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
314
+ gap: 14px;
315
+ }
316
+
317
+ .field {
318
+ display: grid;
319
+ gap: 7px;
320
+ align-content: start;
321
+ }
322
+
323
+ .field label {
324
+ display: flex;
325
+ align-items: center;
326
+ justify-content: space-between;
327
+ gap: 8px;
328
+ font-size: 13px;
329
+ font-weight: 700;
330
+ }
331
+
332
+ .field-source {
333
+ color: var(--muted);
334
+ font-size: 11px;
335
+ font-weight: 600;
336
+ }
337
+
338
+ .field input,
339
+ .field select,
340
+ .field textarea {
341
+ width: 100%;
342
+ min-height: 38px;
343
+ border: 1px solid var(--line-strong);
344
+ border-radius: 8px;
345
+ background: var(--input);
346
+ color: var(--text);
347
+ padding: 8px 10px;
348
+ }
349
+
350
+ .field textarea {
351
+ min-height: 90px;
352
+ resize: vertical;
353
+ }
354
+
355
+ .field input:disabled,
356
+ .field select:disabled,
357
+ .field textarea:disabled {
358
+ background: #26231f;
359
+ color: #82796f;
360
+ }
361
+
362
+ .field-description {
363
+ color: var(--muted);
364
+ font-size: 12px;
365
+ line-height: 1.35;
366
+ }
367
+
368
+ .field.advanced-field {
369
+ display: none;
370
+ }
371
+
372
+ .settings-section.show-advanced .advanced-field {
373
+ display: grid;
374
+ }
375
+
376
+ .advanced-toggle {
377
+ justify-self: start;
378
+ margin-top: 14px;
379
+ }
380
+
381
+ .env-panel {
382
+ margin-top: 18px;
383
+ padding: 18px;
384
+ }
385
+
386
+ .env-preview {
387
+ overflow: auto;
388
+ max-height: 360px;
389
+ margin: 14px 0 0;
390
+ border: 1px solid var(--line);
391
+ border-radius: 8px;
392
+ background: #0b0f0d;
393
+ color: #c9f4dc;
394
+ padding: 14px;
395
+ font-size: 12px;
396
+ line-height: 1.45;
397
+ }
398
+
399
+ .action-bar {
400
+ position: fixed;
401
+ right: 0;
402
+ bottom: 0;
403
+ left: 268px;
404
+ z-index: 10;
405
+ display: grid;
406
+ grid-template-columns: minmax(0, 1fr) minmax(180px, auto) auto;
407
+ gap: 14px;
408
+ align-items: center;
409
+ min-height: 72px;
410
+ border-top: 1px solid var(--line);
411
+ background: rgba(26, 24, 21, 0.94);
412
+ padding: 12px 28px;
413
+ backdrop-filter: blur(12px);
414
+ }
415
+
416
+ .action-meta {
417
+ display: grid;
418
+ gap: 3px;
419
+ min-width: 0;
420
+ }
421
+
422
+ .action-meta strong,
423
+ .action-meta span {
424
+ overflow: hidden;
425
+ text-overflow: ellipsis;
426
+ white-space: nowrap;
427
+ }
428
+
429
+ .message-area {
430
+ min-width: 0;
431
+ color: var(--muted);
432
+ font-size: 13px;
433
+ }
434
+
435
+ .message-area.error {
436
+ color: var(--error);
437
+ }
438
+
439
+ .message-area.ok {
440
+ color: var(--ok);
441
+ }
442
+
443
+ .action-buttons {
444
+ display: flex;
445
+ gap: 8px;
446
+ }
447
+
448
+ @media (max-width: 900px) {
449
+ .app-shell {
450
+ display: block;
451
+ padding-bottom: 122px;
452
+ }
453
+
454
+ .sidebar {
455
+ position: relative;
456
+ height: auto;
457
+ border-right: 0;
458
+ border-bottom: 1px solid var(--line);
459
+ }
460
+
461
+ .section-nav {
462
+ grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
463
+ }
464
+
465
+ .main {
466
+ padding: 18px;
467
+ }
468
+
469
+ .topbar {
470
+ align-items: flex-start;
471
+ flex-direction: column;
472
+ }
473
+
474
+ .server-state {
475
+ justify-content: flex-start;
476
+ }
477
+
478
+ .action-bar {
479
+ left: 0;
480
+ grid-template-columns: 1fr;
481
+ align-items: stretch;
482
+ padding: 12px 18px;
483
+ }
484
+
485
+ .action-buttons {
486
+ justify-content: stretch;
487
+ }
488
+
489
+ .action-buttons button {
490
+ flex: 1;
491
+ }
492
+ }
api/admin_static/admin.js ADDED
@@ -0,0 +1,417 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const state = {
2
+ config: null,
3
+ status: null,
4
+ fields: new Map(),
5
+ localStatus: new Map(),
6
+ modelOptions: [],
7
+ };
8
+
9
+ const MASKED_SECRET = "********";
10
+
11
+ const byId = (id) => document.getElementById(id);
12
+
13
+ function sourceLabel(source) {
14
+ const labels = {
15
+ default: "default",
16
+ template: "template",
17
+ repo_env: "repo .env",
18
+ managed_env: "managed",
19
+ explicit_env_file: "FCC_ENV_FILE",
20
+ process: "process env",
21
+ };
22
+ return labels[source] || source;
23
+ }
24
+
25
+ function providerName(providerId) {
26
+ const names = {
27
+ nvidia_nim: "NVIDIA NIM",
28
+ open_router: "OpenRouter",
29
+ deepseek: "DeepSeek",
30
+ lmstudio: "LM Studio",
31
+ llamacpp: "llama.cpp",
32
+ ollama: "Ollama",
33
+ kimi: "Kimi",
34
+ wafer: "Wafer",
35
+ };
36
+ if (names[providerId]) return names[providerId];
37
+ return providerId
38
+ .split("_")
39
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
40
+ .join(" ");
41
+ }
42
+
43
+ function statusClass(status) {
44
+ if (["configured", "reachable", "running"].includes(status)) return "ok";
45
+ if (["missing_key", "missing_url", "unknown"].includes(status)) return "warn";
46
+ if (["offline", "error"].includes(status)) return "error";
47
+ return "neutral";
48
+ }
49
+
50
+ async function api(path, options = {}) {
51
+ const response = await fetch(path, {
52
+ headers: { "Content-Type": "application/json", ...(options.headers || {}) },
53
+ ...options,
54
+ });
55
+ if (!response.ok) {
56
+ throw new Error(`${response.status} ${response.statusText}`);
57
+ }
58
+ return response.json();
59
+ }
60
+
61
+ async function load() {
62
+ showMessage("Loading admin config");
63
+ const [config, status] = await Promise.all([
64
+ api("/admin/api/config"),
65
+ api("/admin/api/status"),
66
+ ]);
67
+ state.config = config;
68
+ state.status = status;
69
+ state.fields = new Map(config.fields.map((field) => [field.key, field]));
70
+ updateHeader(status);
71
+ renderNav(config.sections);
72
+ renderProviders(config.provider_status);
73
+ renderSections(config.sections, config.fields);
74
+ byId("configPath").textContent = config.paths.managed;
75
+ await validate(false);
76
+ await refreshLocalStatus();
77
+ updateDirtyState();
78
+ showMessage("");
79
+ }
80
+
81
+ function updateHeader(status) {
82
+ const serverStatus = byId("serverStatus");
83
+ serverStatus.textContent = "Running";
84
+ serverStatus.className = "status-pill ok";
85
+ byId("modelBadge").textContent = status.model || "";
86
+ }
87
+
88
+ function renderNav(sections) {
89
+ const nav = byId("sectionNav");
90
+ nav.innerHTML = "";
91
+ sections.forEach((section, index) => {
92
+ const button = document.createElement("button");
93
+ button.type = "button";
94
+ button.className = `nav-link${index === 0 ? " active" : ""}`;
95
+ button.textContent = section.label;
96
+ button.addEventListener("click", () => {
97
+ document.querySelectorAll(".nav-link").forEach((link) => {
98
+ link.classList.remove("active");
99
+ });
100
+ button.classList.add("active");
101
+ byId(`section-${section.id}`).scrollIntoView({ behavior: "smooth" });
102
+ });
103
+ nav.appendChild(button);
104
+ });
105
+ }
106
+
107
+ function renderProviders(providerStatus) {
108
+ const grid = byId("providerGrid");
109
+ grid.innerHTML = "";
110
+ providerStatus.forEach((provider) => {
111
+ const card = document.createElement("article");
112
+ card.className = "provider-card";
113
+ card.dataset.provider = provider.provider_id;
114
+
115
+ const title = document.createElement("div");
116
+ title.className = "provider-title";
117
+ title.innerHTML = `<strong>${providerName(provider.provider_id)}</strong>`;
118
+
119
+ const pill = document.createElement("span");
120
+ pill.className = `status-pill ${statusClass(provider.status)}`;
121
+ pill.textContent = provider.label;
122
+ title.appendChild(pill);
123
+
124
+ const meta = document.createElement("div");
125
+ meta.className = "provider-meta";
126
+ meta.textContent =
127
+ provider.kind === "local"
128
+ ? provider.base_url || "No local URL configured"
129
+ : provider.credential_env;
130
+
131
+ const button = document.createElement("button");
132
+ button.type = "button";
133
+ button.className = "test-button";
134
+ button.textContent = provider.kind === "local" ? "Test" : "Refresh models";
135
+ button.addEventListener("click", () => testProvider(provider.provider_id, button));
136
+
137
+ card.append(title, meta, button);
138
+ grid.appendChild(card);
139
+ });
140
+ }
141
+
142
+ function updateProviderCard(providerId, status, label, metaText) {
143
+ const card = document.querySelector(`[data-provider="${providerId}"]`);
144
+ if (!card) return;
145
+ const pill = card.querySelector(".status-pill");
146
+ pill.className = `status-pill ${statusClass(status)}`;
147
+ pill.textContent = label;
148
+ if (metaText) {
149
+ card.querySelector(".provider-meta").textContent = metaText;
150
+ }
151
+ }
152
+
153
+ function renderSections(sections, fields) {
154
+ const container = byId("formSections");
155
+ container.innerHTML = "";
156
+ const bySection = new Map();
157
+ sections.forEach((section) => bySection.set(section.id, []));
158
+ fields.forEach((field) => {
159
+ if (!bySection.has(field.section)) bySection.set(field.section, []);
160
+ bySection.get(field.section).push(field);
161
+ });
162
+
163
+ sections.forEach((section) => {
164
+ const sectionEl = document.createElement("section");
165
+ sectionEl.className = "settings-section";
166
+ sectionEl.id = `section-${section.id}`;
167
+
168
+ const heading = document.createElement("div");
169
+ heading.className = "section-heading";
170
+ heading.innerHTML = `<div><h3>${section.label}</h3><p>${section.description}</p></div>`;
171
+ sectionEl.appendChild(heading);
172
+
173
+ const grid = document.createElement("div");
174
+ grid.className = "field-grid";
175
+ bySection.get(section.id).forEach((field) => {
176
+ grid.appendChild(renderField(field));
177
+ });
178
+ sectionEl.appendChild(grid);
179
+
180
+ if (bySection.get(section.id).some((field) => field.advanced)) {
181
+ const toggle = document.createElement("button");
182
+ toggle.type = "button";
183
+ toggle.className = "ghost-button advanced-toggle";
184
+ toggle.textContent = "Show advanced";
185
+ toggle.addEventListener("click", () => {
186
+ const showing = sectionEl.classList.toggle("show-advanced");
187
+ toggle.textContent = showing ? "Hide advanced" : "Show advanced";
188
+ });
189
+ sectionEl.appendChild(toggle);
190
+ }
191
+
192
+ container.appendChild(sectionEl);
193
+ });
194
+ }
195
+
196
+ function renderField(field) {
197
+ const wrapper = document.createElement("div");
198
+ wrapper.className = `field${field.advanced ? " advanced-field" : ""}`;
199
+ wrapper.dataset.key = field.key;
200
+
201
+ const label = document.createElement("label");
202
+ label.htmlFor = `field-${field.key}`;
203
+ label.innerHTML = `<span>${field.label}</span><span class="field-source">${sourceLabel(
204
+ field.source,
205
+ )}${field.locked ? " locked" : ""}</span>`;
206
+
207
+ const input = inputForField(field);
208
+ input.id = `field-${field.key}`;
209
+ input.dataset.key = field.key;
210
+ input.dataset.original = field.value || "";
211
+ input.dataset.secret = field.secret ? "true" : "false";
212
+ input.dataset.configured = field.configured ? "true" : "false";
213
+ input.disabled = field.locked;
214
+ input.addEventListener("input", updateDirtyState);
215
+ input.addEventListener("change", updateDirtyState);
216
+
217
+ wrapper.append(label, input);
218
+ if (field.description) {
219
+ const description = document.createElement("div");
220
+ description.className = "field-description";
221
+ description.textContent = field.description;
222
+ wrapper.appendChild(description);
223
+ }
224
+ return wrapper;
225
+ }
226
+
227
+ function inputForField(field) {
228
+ if (field.type === "boolean") {
229
+ const input = document.createElement("input");
230
+ input.type = "checkbox";
231
+ input.checked = String(field.value).toLowerCase() === "true";
232
+ input.dataset.original = input.checked ? "true" : "false";
233
+ return input;
234
+ }
235
+
236
+ if (field.type === "tri_boolean") {
237
+ const select = document.createElement("select");
238
+ [
239
+ ["", "Inherit"],
240
+ ["true", "Enabled"],
241
+ ["false", "Disabled"],
242
+ ].forEach(([value, label]) => select.appendChild(option(value, label)));
243
+ select.value = field.value || "";
244
+ return select;
245
+ }
246
+
247
+ if (field.type === "select") {
248
+ const select = document.createElement("select");
249
+ field.options.forEach((value) => select.appendChild(option(value, value)));
250
+ select.value = field.value || field.options[0] || "";
251
+ return select;
252
+ }
253
+
254
+ if (field.type === "textarea") {
255
+ const textarea = document.createElement("textarea");
256
+ textarea.value = field.value || "";
257
+ return textarea;
258
+ }
259
+
260
+ const input = document.createElement("input");
261
+ input.type = field.type === "number" ? "number" : "text";
262
+ if (field.type === "secret") {
263
+ input.type = "password";
264
+ input.placeholder = field.configured
265
+ ? "Configured - enter a new value to replace"
266
+ : "Not configured";
267
+ input.value = "";
268
+ input.autocomplete = "off";
269
+ } else {
270
+ input.value = field.value || "";
271
+ }
272
+ if (field.key.startsWith("MODEL")) {
273
+ input.setAttribute("list", "model-options");
274
+ }
275
+ return input;
276
+ }
277
+
278
+ function option(value, label) {
279
+ const optionEl = document.createElement("option");
280
+ optionEl.value = value;
281
+ optionEl.textContent = label;
282
+ return optionEl;
283
+ }
284
+
285
+ function readFieldValue(input) {
286
+ if (input.type === "checkbox") return input.checked ? "true" : "false";
287
+ if (input.dataset.secret === "true" && input.dataset.configured === "true") {
288
+ return input.value ? input.value : MASKED_SECRET;
289
+ }
290
+ return input.value;
291
+ }
292
+
293
+ function changedValues() {
294
+ const values = {};
295
+ document.querySelectorAll("[data-key]").forEach((input) => {
296
+ if (input.disabled || !input.matches("input, select, textarea")) return;
297
+ const value = readFieldValue(input);
298
+ if (value !== input.dataset.original) {
299
+ values[input.dataset.key] = value;
300
+ }
301
+ });
302
+ return values;
303
+ }
304
+
305
+ function updateDirtyState() {
306
+ const count = Object.keys(changedValues()).length;
307
+ byId("dirtyState").textContent =
308
+ count === 0 ? "No changes" : `${count} unsaved change${count === 1 ? "" : "s"}`;
309
+ byId("applyButton").disabled = count === 0;
310
+ }
311
+
312
+ async function validate(showResult = true) {
313
+ const result = await api("/admin/api/config/validate", {
314
+ method: "POST",
315
+ body: JSON.stringify({ values: changedValues() }),
316
+ });
317
+ byId("envPreview").textContent = result.env_preview || "";
318
+ if (showResult) {
319
+ showValidationResult(result);
320
+ }
321
+ return result;
322
+ }
323
+
324
+ function showValidationResult(result) {
325
+ if (result.valid) {
326
+ showMessage("Config shape is valid", "ok");
327
+ } else {
328
+ showMessage(result.errors.join("; "), "error");
329
+ }
330
+ }
331
+
332
+ async function apply() {
333
+ const result = await api("/admin/api/config/apply", {
334
+ method: "POST",
335
+ body: JSON.stringify({ values: changedValues() }),
336
+ });
337
+ byId("envPreview").textContent = result.env_preview || "";
338
+ if (!result.applied) {
339
+ showValidationResult(result);
340
+ return;
341
+ }
342
+ const pending = result.pending_fields || [];
343
+ await load();
344
+ showMessage(
345
+ pending.length
346
+ ? `Applied. Pending manual runtime action: ${pending.join(", ")}`
347
+ : "Applied",
348
+ "ok",
349
+ );
350
+ }
351
+
352
+ async function refreshLocalStatus() {
353
+ const result = await api("/admin/api/providers/local-status");
354
+ result.providers.forEach((provider) => {
355
+ state.localStatus.set(provider.provider_id, provider);
356
+ const meta = provider.status_code
357
+ ? `${provider.base_url} returned HTTP ${provider.status_code}`
358
+ : provider.base_url;
359
+ updateProviderCard(provider.provider_id, provider.status, provider.label, meta);
360
+ });
361
+ }
362
+
363
+ async function testProvider(providerId, button) {
364
+ const original = button.textContent;
365
+ button.disabled = true;
366
+ button.textContent = "Testing";
367
+ try {
368
+ const result = await api(`/admin/api/providers/${providerId}/test`, {
369
+ method: "POST",
370
+ body: "{}",
371
+ });
372
+ if (result.ok) {
373
+ updateProviderCard(
374
+ providerId,
375
+ "reachable",
376
+ `${result.models.length} models`,
377
+ result.models.slice(0, 3).join(", ") || "No models returned",
378
+ );
379
+ state.modelOptions = Array.from(
380
+ new Set([...state.modelOptions, ...result.models.map((model) => `${providerId}/${model}`)]),
381
+ ).sort();
382
+ syncModelDatalist();
383
+ } else {
384
+ updateProviderCard(providerId, "offline", result.error_type, result.error_type);
385
+ }
386
+ } finally {
387
+ button.disabled = false;
388
+ button.textContent = original;
389
+ }
390
+ }
391
+
392
+ function syncModelDatalist() {
393
+ let datalist = byId("model-options");
394
+ if (!datalist) {
395
+ datalist = document.createElement("datalist");
396
+ datalist.id = "model-options";
397
+ document.body.appendChild(datalist);
398
+ }
399
+ datalist.innerHTML = "";
400
+ state.modelOptions.forEach((model) => datalist.appendChild(option(model, model)));
401
+ }
402
+
403
+ function showMessage(message, kind = "") {
404
+ const area = byId("messageArea");
405
+ area.textContent = message;
406
+ area.className = `message-area ${kind}`.trim();
407
+ }
408
+
409
+ byId("validateButton").addEventListener("click", () => validate(true));
410
+ byId("applyButton").addEventListener("click", apply);
411
+ byId("refreshLocal").addEventListener("click", refreshLocalStatus);
412
+
413
+ load().catch((error) => {
414
+ byId("serverStatus").textContent = "Error";
415
+ byId("serverStatus").className = "status-pill error";
416
+ showMessage(error.message, "error");
417
+ });
api/admin_static/index.html ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>Free Claude Code 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">FC</div>
15
+ <div>
16
+ <h1>Free Claude Code</h1>
17
+ <p>Server Control</p>
18
+ </div>
19
+ </div>
20
+ <nav id="sectionNav" class="section-nav" aria-label="Settings sections"></nav>
21
+ </aside>
22
+
23
+ <main class="main">
24
+ <header class="topbar">
25
+ <div>
26
+ <p class="eyebrow">Local Admin</p>
27
+ <h2>Runtime Config</h2>
28
+ </div>
29
+ <div class="server-state">
30
+ <span id="serverStatus" class="status-pill neutral">Loading</span>
31
+ <span id="modelBadge" class="model-badge"></span>
32
+ </div>
33
+ </header>
34
+
35
+ <section class="provider-strip" aria-label="Provider status">
36
+ <div class="strip-header">
37
+ <h3>Providers</h3>
38
+ <button id="refreshLocal" class="ghost-button" type="button">Check local</button>
39
+ </div>
40
+ <div id="providerGrid" class="provider-grid"></div>
41
+ </section>
42
+
43
+ <section id="formSections" class="form-sections" aria-label="Configuration"></section>
44
+
45
+ <section class="env-panel">
46
+ <div class="section-heading">
47
+ <div>
48
+ <h3>Generated Env</h3>
49
+ <p>Read-only preview of the managed config file.</p>
50
+ </div>
51
+ </div>
52
+ <pre id="envPreview" class="env-preview"></pre>
53
+ </section>
54
+ </main>
55
+
56
+ <footer class="action-bar">
57
+ <div class="action-meta">
58
+ <strong id="dirtyState">No changes</strong>
59
+ <span id="configPath"></span>
60
+ </div>
61
+ <div id="messageArea" class="message-area"></div>
62
+ <div class="action-buttons">
63
+ <button id="validateButton" class="secondary-button" type="button">Validate</button>
64
+ <button id="applyButton" class="primary-button" type="button" disabled>Apply</button>
65
+ </div>
66
+ </footer>
67
+ </div>
68
+ <script src="/admin/assets/admin.js"></script>
69
+ </body>
70
+ </html>
api/admin_urls.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Helpers for presenting local admin URLs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from config.settings import Settings
6
+
7
+
8
+ def local_admin_url(settings: Settings) -> str:
9
+ """Return a browser-friendly URL for the localhost-only admin UI."""
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 f"http://{host}:{settings.port}/admin"
17
+
18
+
19
+ def admin_launch_message(settings: Settings) -> str:
20
+ """Return the startup message shown by supported launch commands."""
21
+
22
+ return f"Admin UI: {local_admin_url(settings)} (local-only)"
api/app.py CHANGED
@@ -15,6 +15,7 @@ from config.logging_config import configure_logging
15
  from config.settings import get_settings
16
  from providers.exceptions import ProviderError
17
 
 
18
  from .routes import router
19
  from .runtime import AppRuntime, startup_failure_message
20
  from .validation_log import summarize_request_validation_body
@@ -95,6 +96,7 @@ def create_app(*, lifespan_enabled: bool = True) -> FastAPI:
95
  app = FastAPI(**app_kwargs)
96
 
97
  # Register routes
 
98
  app.include_router(router)
99
 
100
  # Exception handlers
 
15
  from config.settings import get_settings
16
  from providers.exceptions import ProviderError
17
 
18
+ from .admin_routes import router as admin_router
19
  from .routes import router
20
  from .runtime import AppRuntime, startup_failure_message
21
  from .validation_log import summarize_request_validation_body
 
96
  app = FastAPI(**app_kwargs)
97
 
98
  # Register routes
99
+ app.include_router(admin_router)
100
  app.include_router(router)
101
 
102
  # Exception handlers
api/runtime.py CHANGED
@@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any
10
  from fastapi import FastAPI
11
  from loguru import logger
12
 
 
13
  from config.settings import Settings, get_settings
14
  from providers.exceptions import ServiceUnavailableError
15
  from providers.registry import ProviderRegistry
@@ -100,11 +101,12 @@ class AppRuntime:
100
 
101
  async def startup(self) -> None:
102
  logger.info("Starting Claude Code Proxy...")
 
103
  self._provider_registry = ProviderRegistry()
104
  self.app.state.provider_registry = self._provider_registry
105
  try:
106
  warn_if_process_auth_token(self.settings)
107
- await self._provider_registry.validate_configured_models(self.settings)
108
  self._provider_registry.start_model_list_refresh(self.settings)
109
  await self._start_messaging_if_configured()
110
  self._publish_state()
@@ -117,6 +119,21 @@ class AppRuntime:
117
  )
118
  raise
119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  async def shutdown(self) -> None:
121
  verbose = self.settings.log_api_error_tracebacks
122
  if self.message_handler is not None:
 
10
  from fastapi import FastAPI
11
  from loguru import logger
12
 
13
+ from api.admin_urls import local_admin_url
14
  from config.settings import Settings, get_settings
15
  from providers.exceptions import ServiceUnavailableError
16
  from providers.registry import ProviderRegistry
 
101
 
102
  async def startup(self) -> None:
103
  logger.info("Starting Claude Code Proxy...")
104
+ logger.info("Admin UI: {} (local-only)", local_admin_url(self.settings))
105
  self._provider_registry = ProviderRegistry()
106
  self.app.state.provider_registry = self._provider_registry
107
  try:
108
  warn_if_process_auth_token(self.settings)
109
+ await self._validate_configured_models_best_effort()
110
  self._provider_registry.start_model_list_refresh(self.settings)
111
  await self._start_messaging_if_configured()
112
  self._publish_state()
 
119
  )
120
  raise
121
 
122
+ async def _validate_configured_models_best_effort(self) -> None:
123
+ """Warm validation status without blocking first-run/admin access."""
124
+ if self._provider_registry is None:
125
+ return
126
+ try:
127
+ await self._provider_registry.validate_configured_models(self.settings)
128
+ except ServiceUnavailableError as exc:
129
+ self.app.state.startup_validation_error = exc.message
130
+ logger.warning(
131
+ "Configured provider model validation failed during startup; "
132
+ "server will continue and requests will fail at provider resolution "
133
+ "when config is incomplete. {}",
134
+ exc.message,
135
+ )
136
+
137
  async def shutdown(self) -> None:
138
  verbose = self.settings.log_api_error_tracebacks
139
  if self.message_handler is not None:
config/settings.py CHANGED
@@ -29,8 +29,8 @@ class ConfiguredChatModelRef:
29
  def _env_files() -> tuple[Path, ...]:
30
  """Return env file paths in priority order (later overrides earlier)."""
31
  files: list[Path] = [
32
- Path.home() / ".config" / "free-claude-code" / ".env",
33
  Path(".env"),
 
34
  ]
35
  if explicit := os.environ.get("FCC_ENV_FILE"):
36
  files.append(Path(explicit))
 
29
  def _env_files() -> tuple[Path, ...]:
30
  """Return env file paths in priority order (later overrides earlier)."""
31
  files: list[Path] = [
 
32
  Path(".env"),
33
+ Path.home() / ".config" / "free-claude-code" / ".env",
34
  ]
35
  if explicit := os.environ.get("FCC_ENV_FILE"):
36
  files.append(Path(explicit))
pyproject.toml CHANGED
@@ -46,6 +46,7 @@ packages = ["api", "cli", "config", "core", "messaging", "providers"]
46
 
47
  [tool.hatch.build.targets.wheel.force-include]
48
  ".env.example" = "cli/env.example"
 
49
 
50
  [tool.uv.sources]
51
  torch = { index = "pytorch-cu130" }
 
46
 
47
  [tool.hatch.build.targets.wheel.force-include]
48
  ".env.example" = "cli/env.example"
49
+ "api/admin_static" = "api/admin_static"
50
 
51
  [tool.uv.sources]
52
  torch = { index = "pytorch-cu130" }
tests/api/test_admin.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from unittest.mock import patch
5
+
6
+ import httpx
7
+ from fastapi.testclient import TestClient
8
+
9
+ from api.admin_config import MASKED_SECRET
10
+ from api.admin_urls import local_admin_url
11
+ from api.app import create_app
12
+ from config.settings import Settings
13
+
14
+
15
+ def _local_client(app):
16
+ return TestClient(app, client=("127.0.0.1", 50000))
17
+
18
+
19
+ def _set_home(monkeypatch, tmp_path: Path) -> None:
20
+ monkeypatch.setenv("HOME", str(tmp_path))
21
+ monkeypatch.setenv("USERPROFILE", str(tmp_path))
22
+
23
+
24
+ def _clear_process_config(monkeypatch) -> None:
25
+ for key in (
26
+ "MODEL",
27
+ "NVIDIA_NIM_API_KEY",
28
+ "OPENROUTER_API_KEY",
29
+ "ANTHROPIC_AUTH_TOKEN",
30
+ "FCC_ENV_FILE",
31
+ ):
32
+ monkeypatch.delenv(key, raising=False)
33
+
34
+
35
+ def test_admin_page_is_loopback_only(monkeypatch, tmp_path):
36
+ _set_home(monkeypatch, tmp_path)
37
+ app = create_app(lifespan_enabled=False)
38
+
39
+ assert _local_client(app).get("/admin").status_code == 200
40
+ remote_client = TestClient(app, client=("203.0.113.10", 50000))
41
+ assert remote_client.get("/admin").status_code == 403
42
+
43
+
44
+ def test_admin_config_masks_secrets_and_exposes_manifest(monkeypatch, tmp_path):
45
+ _set_home(monkeypatch, tmp_path)
46
+ _clear_process_config(monkeypatch)
47
+ app = create_app(lifespan_enabled=False)
48
+
49
+ response = _local_client(app).get("/admin/api/config")
50
+
51
+ assert response.status_code == 200
52
+ body = response.json()
53
+ keys = {field["key"] for field in body["fields"]}
54
+ assert "ANTHROPIC_AUTH_TOKEN" in keys
55
+ assert "OPENROUTER_API_KEY" in keys
56
+ auth_field = next(
57
+ field for field in body["fields"] if field["key"] == "ANTHROPIC_AUTH_TOKEN"
58
+ )
59
+ assert auth_field["secret"] is True
60
+ assert auth_field["value"] == MASKED_SECRET
61
+ assert auth_field["source"] == "template"
62
+
63
+
64
+ def test_admin_validate_rejects_bad_model_shape(monkeypatch, tmp_path):
65
+ _set_home(monkeypatch, tmp_path)
66
+ _clear_process_config(monkeypatch)
67
+ app = create_app(lifespan_enabled=False)
68
+
69
+ response = _local_client(app).post(
70
+ "/admin/api/config/validate",
71
+ json={"values": {"MODEL": "missing-provider-prefix"}},
72
+ )
73
+
74
+ assert response.status_code == 200
75
+ body = response.json()
76
+ assert body["valid"] is False
77
+ assert any("provider type" in error for error in body["errors"])
78
+
79
+
80
+ def test_admin_apply_writes_complete_managed_env_and_masks_preview(
81
+ monkeypatch, tmp_path
82
+ ):
83
+ _set_home(monkeypatch, tmp_path)
84
+ _clear_process_config(monkeypatch)
85
+ app = create_app(lifespan_enabled=False)
86
+
87
+ response = _local_client(app).post(
88
+ "/admin/api/config/apply",
89
+ json={
90
+ "values": {
91
+ "MODEL": "open_router/test-model",
92
+ "OPENROUTER_API_KEY": "router-secret",
93
+ }
94
+ },
95
+ )
96
+
97
+ assert response.status_code == 200
98
+ body = response.json()
99
+ assert body["applied"] is True
100
+ assert "OPENROUTER_API_KEY=********" in body["env_preview"]
101
+ env_file = tmp_path / ".config" / "free-claude-code" / ".env"
102
+ text = env_file.read_text("utf-8")
103
+ assert "MODEL=open_router/test-model" in text
104
+ assert "OPENROUTER_API_KEY=router-secret" in text
105
+ assert "ANTHROPIC_AUTH_TOKEN=" in text
106
+
107
+
108
+ def test_admin_process_env_values_are_locked_and_not_written(monkeypatch, tmp_path):
109
+ _set_home(monkeypatch, tmp_path)
110
+ _clear_process_config(monkeypatch)
111
+ monkeypatch.setenv("MODEL", "open_router/process-model")
112
+ app = create_app(lifespan_enabled=False)
113
+
114
+ config = _local_client(app).get("/admin/api/config").json()
115
+ model_field = next(field for field in config["fields"] if field["key"] == "MODEL")
116
+ assert model_field["locked"] is True
117
+ assert model_field["source"] == "process"
118
+
119
+ response = _local_client(app).post(
120
+ "/admin/api/config/apply",
121
+ json={"values": {"MODEL": "deepseek/managed-model"}},
122
+ )
123
+
124
+ assert response.status_code == 200
125
+ env_file = tmp_path / ".config" / "free-claude-code" / ".env"
126
+ assert "deepseek/managed-model" not in env_file.read_text("utf-8")
127
+
128
+
129
+ def test_admin_first_apply_migrates_repo_env(monkeypatch, tmp_path):
130
+ _set_home(monkeypatch, tmp_path)
131
+ _clear_process_config(monkeypatch)
132
+ monkeypatch.chdir(tmp_path)
133
+ (tmp_path / ".env").write_text(
134
+ "MODEL=deepseek/deepseek-chat\nDEEPSEEK_API_KEY=deepseek-secret\n",
135
+ encoding="utf-8",
136
+ )
137
+ app = create_app(lifespan_enabled=False)
138
+
139
+ config = _local_client(app).get("/admin/api/config").json()
140
+ model_field = next(field for field in config["fields"] if field["key"] == "MODEL")
141
+ assert model_field["value"] == "deepseek/deepseek-chat"
142
+ assert model_field["source"] == "repo_env"
143
+
144
+ response = _local_client(app).post(
145
+ "/admin/api/config/apply",
146
+ json={"values": {}},
147
+ )
148
+
149
+ assert response.status_code == 200
150
+ managed_text = (tmp_path / ".config" / "free-claude-code" / ".env").read_text(
151
+ "utf-8"
152
+ )
153
+ assert "MODEL=deepseek/deepseek-chat" in managed_text
154
+ assert "DEEPSEEK_API_KEY=deepseek-secret" in managed_text
155
+
156
+
157
+ def test_admin_local_provider_status_reports_reachable(monkeypatch, tmp_path):
158
+ _set_home(monkeypatch, tmp_path)
159
+ _clear_process_config(monkeypatch)
160
+ app = create_app(lifespan_enabled=False)
161
+
162
+ class FakeAsyncClient:
163
+ def __init__(self, *args, **kwargs):
164
+ pass
165
+
166
+ async def __aenter__(self):
167
+ return self
168
+
169
+ async def __aexit__(self, *args):
170
+ return None
171
+
172
+ async def get(self, url: str):
173
+ return httpx.Response(200, json={"data": []})
174
+
175
+ with patch("api.admin_routes.httpx.AsyncClient", FakeAsyncClient):
176
+ response = _local_client(app).get("/admin/api/providers/local-status")
177
+
178
+ assert response.status_code == 200
179
+ providers = response.json()["providers"]
180
+ assert {provider["status"] for provider in providers} == {"reachable"}
181
+
182
+
183
+ def test_admin_launch_url_uses_loopback_for_wildcard_host():
184
+ settings = Settings.model_construct(host="0.0.0.0", port=8082)
185
+
186
+ assert local_admin_url(settings) == "http://127.0.0.1:8082/admin"
tests/api/test_app_lifespan_and_errors.py CHANGED
@@ -353,13 +353,13 @@ def test_app_lifespan_cleanup_continues_if_platform_stop_raises(tmp_path):
353
 
354
 
355
  @pytest.mark.asyncio
356
- async def test_runtime_startup_validation_blocks_messaging_and_cleans_up(tmp_path):
357
  import api.runtime as api_runtime_mod
358
 
359
  settings = _app_settings(
360
- messaging_platform="telegram",
361
- telegram_bot_token="token",
362
- allowed_telegram_user_id="123",
363
  discord_bot_token=None,
364
  allowed_discord_channels=None,
365
  allowed_dir=str(tmp_path / "workspace"),
@@ -379,27 +379,29 @@ async def test_runtime_startup_validation_blocks_messaging_and_cleans_up(tmp_pat
379
  with (
380
  patch.object(ProviderRegistry, "validate_configured_models", new=validation),
381
  patch.object(ProviderRegistry, "cleanup", new=cleanup),
382
- patch.object(api_runtime_mod.logger, "error") as log_error,
383
  patch(
384
- "messaging.platforms.factory.create_messaging_platform"
 
385
  ) as create_platform,
386
- pytest.raises(ServiceUnavailableError, match="bad model"),
387
  ):
388
  await runtime.startup()
 
389
 
390
  validation.assert_awaited_once_with(settings)
391
  cleanup.assert_awaited_once()
392
- create_platform.assert_not_called()
393
  logged = " ".join(
394
- str(arg) for call in log_error.call_args_list for arg in call.args
395
  )
396
- assert "Startup failed" in logged
397
  assert "bad model" in logged
398
  assert "Traceback" not in logged
 
399
 
400
 
401
  @pytest.mark.asyncio
402
- async def test_graceful_asgi_lifespan_failure_sends_no_traceback(tmp_path):
403
  import api.app as api_app_mod
404
 
405
  settings = _app_settings(
@@ -416,9 +418,13 @@ async def test_graceful_asgi_lifespan_failure_sends_no_traceback(tmp_path):
416
  )
417
  app = api_app_mod.GracefulLifespanApp(FastAPI())
418
  sent: list[MutableMapping[str, Any]] = []
 
 
 
 
419
 
420
  async def receive() -> MutableMapping[str, Any]:
421
- return {"type": "lifespan.startup"}
422
 
423
  async def send(message: MutableMapping[str, Any]) -> None:
424
  sent.append(message)
@@ -432,7 +438,10 @@ async def test_graceful_asgi_lifespan_failure_sends_no_traceback(tmp_path):
432
  ):
433
  await app({"type": "lifespan"}, receive, send)
434
 
435
- assert sent == [{"type": "lifespan.startup.failed", "message": "bad model"}]
 
 
 
436
 
437
 
438
  def test_app_lifespan_messaging_import_error_no_crash(tmp_path, caplog):
 
353
 
354
 
355
  @pytest.mark.asyncio
356
+ async def test_runtime_startup_validation_failure_does_not_block_server(tmp_path):
357
  import api.runtime as api_runtime_mod
358
 
359
  settings = _app_settings(
360
+ messaging_platform="none",
361
+ telegram_bot_token=None,
362
+ allowed_telegram_user_id=None,
363
  discord_bot_token=None,
364
  allowed_discord_channels=None,
365
  allowed_dir=str(tmp_path / "workspace"),
 
379
  with (
380
  patch.object(ProviderRegistry, "validate_configured_models", new=validation),
381
  patch.object(ProviderRegistry, "cleanup", new=cleanup),
382
+ patch.object(api_runtime_mod.logger, "warning") as log_warning,
383
  patch(
384
+ "messaging.platforms.factory.create_messaging_platform",
385
+ return_value=None,
386
  ) as create_platform,
 
387
  ):
388
  await runtime.startup()
389
+ await runtime.shutdown()
390
 
391
  validation.assert_awaited_once_with(settings)
392
  cleanup.assert_awaited_once()
393
+ create_platform.assert_called_once()
394
  logged = " ".join(
395
+ str(arg) for call in log_warning.call_args_list for arg in call.args
396
  )
397
+ assert "validation failed" in logged
398
  assert "bad model" in logged
399
  assert "Traceback" not in logged
400
+ assert app.state.startup_validation_error == "bad model"
401
 
402
 
403
  @pytest.mark.asyncio
404
+ async def test_graceful_asgi_lifespan_model_validation_failure_starts(tmp_path):
405
  import api.app as api_app_mod
406
 
407
  settings = _app_settings(
 
418
  )
419
  app = api_app_mod.GracefulLifespanApp(FastAPI())
420
  sent: list[MutableMapping[str, Any]] = []
421
+ received = [
422
+ {"type": "lifespan.startup"},
423
+ {"type": "lifespan.shutdown"},
424
+ ]
425
 
426
  async def receive() -> MutableMapping[str, Any]:
427
+ return received.pop(0)
428
 
429
  async def send(message: MutableMapping[str, Any]) -> None:
430
  sent.append(message)
 
438
  ):
439
  await app({"type": "lifespan"}, receive, send)
440
 
441
+ assert sent == [
442
+ {"type": "lifespan.startup.complete"},
443
+ {"type": "lifespan.shutdown.complete"},
444
+ ]
445
 
446
 
447
  def test_app_lifespan_messaging_import_error_no_crash(tmp_path, caplog):