feat: admin UI fixes, shared modal/clipboard utils, status counts managed backends

Admin UI: shared centered provider modal, dropdown backend selectors for Add
Model/Backend, inline add-failure errors, copy/dismiss on created keys, metric
tooltips, empty states. status route now counts managed backends. Add InfoTip
and clipboard helpers. Ignore .eatahorse*/ scratch boards and drop the tracked
guardrails board.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
whit3rabbit
2026-07-11 15:52:35 -05:00
co-authored by Claude Opus 4.8
parent 4e55f8ce5a
commit 8bcee60e2b
26 changed files with 217 additions and 1134 deletions
@@ -1,57 +0,0 @@
{"action": "init", "at": "2026-07-03T23:40:10Z", "detail": {"goal": "Integrate forge-guardrails (opt-in tool-call guardrails for local LLMs) into anyllm-proxy. Backend port is DONE already (uncommitted): crates/proxy/src/tools/guardrails.rs (lsp_first/quiet_command/write_payload_cap nudges, fingerprint dedup), wired into both tool loops (tools/execution/mod.rs, server/chat_completions/stream/generic/tool_loop.rs), config via SimpleConfig.build_tool_guardrail_config (config/simple/types.rs, tool_builder.rs), env fallback FORGE_TOOL_CALL_POLICY in main_helpers/async_main/tools.rs, ToolEngineState.guardrails wired through state.rs/routes.rs, docs updated (CONFIG.md, ENV.md, configuration-and-modes.md), tests updated. Remaining verified gaps to plan/execute: (1) main_helpers/async_main/tools.rs init_tool_engine early-returns None when tool_config.has_any() is false, so FORGE_TOOL_CALL_POLICY alone (no YAML tool_execution block) never takes effect -- the target local-LLM-env-var-only use case is currently broken; fix has_any()/init gating and add a test for env-var-only init with no YAML. (2) LiteLLM YAML config format has no tool_config path at all (config/multi/loader.rs) -- document this limitation explicitly, do not build support for it now. (3) No admin UI surface exists for tool_execution config at all (admin-ui/src has zero references); optionally add a RuntimeConfig field + Settings.tsx toggle following the repo's documented 6-site RuntimeConfig-field checklist (struct + RuntimeConfigDefaults + 3 constructors + SQLite override-apply match in main.rs + delete_config_override reset in admin/routes/config.rs) -- treat as optional/lower priority than (1)/(2). (4) No CLI flag needed -- env var is this repo's existing convention for backend toggles, do not add a second mechanism. Produce a Kanban board of small, independently-completable tasks for items (1), (2), and optionally (3), plus a changelog bullet task per repo release process (CHANGELOG.md [Unreleased]).", "project": "forge-guardrails integration finish"}}
{"action": "add", "at": "2026-07-03T23:40:25Z", "detail": {"id": "EH-0001", "title": "Make FORGE_TOOL_CALL_POLICY alone initialize the guardrail engine (no YAML)"}}
{"action": "add", "at": "2026-07-03T23:40:34Z", "detail": {"id": "EH-0002", "title": "Document that LiteLLM YAML format cannot carry tool_execution/guardrails config"}}
{"action": "add", "at": "2026-07-03T23:40:45Z", "detail": {"id": "EH-0003", "title": "Add CHANGELOG [Unreleased] bullet for opt-in tool-call guardrails"}}
{"action": "add", "at": "2026-07-03T23:40:55Z", "detail": {"id": "EH-0004", "title": "(Optional) Admin UI + RuntimeConfig toggle for guardrail mode"}}
{"action": "sync", "at": "2026-07-03T23:41:00Z", "detail": {"board": "/Users/whit3rabbit/Documents/GitHub/anyllm/.eatahorse-integrate-forge-guardrails-opt-in-tool-c", "completed": 0, "dropped_missing": 0, "imported": 0, "moved": 0, "tasks": 4, "updated": 4}}
{"action": "drop", "at": "2026-07-03T23:47:21Z", "detail": {"id": "EH-0004"}}
{"action": "add", "at": "2026-07-03T23:47:21Z", "detail": {"id": "EH-0005", "title": "Backend: add tool_guardrail_mode RuntimeConfig field (6-site checklist)"}}
{"action": "add", "at": "2026-07-03T23:47:21Z", "detail": {"id": "EH-0006", "title": "Admin UI: guardrail mode toggle in Settings.tsx"}}
{"action": "sync", "at": "2026-07-03T23:47:29Z", "detail": {"board": "/Users/whit3rabbit/Documents/GitHub/anyllm/.eatahorse-integrate-forge-guardrails-opt-in-tool-c", "completed": 0, "dropped_missing": 0, "imported": 0, "moved": 0, "tasks": 6, "updated": 6}}
{"action": "move", "at": "2026-07-04T15:50:50Z", "detail": {"id": "EH-0001", "status": "doing"}}
{"action": "check", "at": "2026-07-04T15:52:33Z", "detail": {"group": "bites", "id": "EH-0001", "index": 1}}
{"action": "sync", "at": "2026-07-04T15:52:43Z", "detail": {"board": "/Users/whit3rabbit/Documents/GitHub/anyllm/.eatahorse-integrate-forge-guardrails-opt-in-tool-c", "completed": 0, "dropped_missing": 0, "imported": 0, "moved": 0, "tasks": 6, "updated": 6}}
{"action": "check", "at": "2026-07-04T15:54:03Z", "detail": {"group": "bites", "id": "EH-0001", "index": 2}}
{"action": "sync", "at": "2026-07-04T15:54:11Z", "detail": {"board": "/Users/whit3rabbit/Documents/GitHub/anyllm/.eatahorse-integrate-forge-guardrails-opt-in-tool-c", "completed": 0, "dropped_missing": 0, "imported": 0, "moved": 0, "tasks": 6, "updated": 6}}
{"action": "check", "at": "2026-07-04T15:59:34Z", "detail": {"group": "bites", "id": "EH-0001", "index": 3}}
{"action": "check", "at": "2026-07-04T15:59:34Z", "detail": {"group": "bites", "id": "EH-0001", "index": 4}}
{"action": "check", "at": "2026-07-04T15:59:34Z", "detail": {"group": "bites", "id": "EH-0001", "index": 5}}
{"action": "check", "at": "2026-07-04T15:59:55Z", "detail": {"group": "acceptance", "id": "EH-0001", "index": 1}}
{"action": "check", "at": "2026-07-04T15:59:55Z", "detail": {"group": "acceptance", "id": "EH-0001", "index": 2}}
{"action": "check", "at": "2026-07-04T15:59:55Z", "detail": {"group": "acceptance", "id": "EH-0001", "index": 3}}
{"action": "sync", "at": "2026-07-04T16:00:03Z", "detail": {"board": "/Users/whit3rabbit/Documents/GitHub/anyllm/.eatahorse-integrate-forge-guardrails-opt-in-tool-c", "completed": 0, "dropped_missing": 0, "imported": 0, "moved": 0, "tasks": 6, "updated": 6}}
{"action": "move", "at": "2026-07-04T16:02:05Z", "detail": {"id": "EH-0002", "status": "doing"}}
{"action": "check", "at": "2026-07-04T16:02:45Z", "detail": {"group": "bites", "id": "EH-0002", "index": 1}}
{"action": "check", "at": "2026-07-04T16:02:54Z", "detail": {"group": "bites", "id": "EH-0002", "index": 2}}
{"action": "sync", "at": "2026-07-04T16:03:14Z", "detail": {"board": "/Users/whit3rabbit/Documents/GitHub/anyllm/.eatahorse-integrate-forge-guardrails-opt-in-tool-c", "completed": 0, "dropped_missing": 0, "imported": 0, "moved": 0, "tasks": 6, "updated": 6}}
{"action": "check", "at": "2026-07-04T16:04:46Z", "detail": {"group": "bites", "id": "EH-0002", "index": 3}}
{"action": "check", "at": "2026-07-04T16:04:46Z", "detail": {"group": "acceptance", "id": "EH-0002", "index": 1}}
{"action": "check", "at": "2026-07-04T16:04:46Z", "detail": {"group": "acceptance", "id": "EH-0002", "index": 2}}
{"action": "check", "at": "2026-07-04T16:04:46Z", "detail": {"group": "acceptance", "id": "EH-0002", "index": 3}}
{"action": "sync", "at": "2026-07-04T16:04:54Z", "detail": {"board": "/Users/whit3rabbit/Documents/GitHub/anyllm/.eatahorse-integrate-forge-guardrails-opt-in-tool-c", "completed": 0, "dropped_missing": 0, "imported": 0, "moved": 0, "tasks": 6, "updated": 6}}
{"action": "move", "at": "2026-07-04T16:06:49Z", "detail": {"id": "EH-0003", "status": "doing"}}
{"action": "check", "at": "2026-07-04T16:07:46Z", "detail": {"group": "bites", "id": "EH-0003", "index": 1}}
{"action": "check", "at": "2026-07-04T16:07:46Z", "detail": {"group": "acceptance", "id": "EH-0003", "index": 1}}
{"action": "check", "at": "2026-07-04T16:07:46Z", "detail": {"group": "acceptance", "id": "EH-0003", "index": 2}}
{"action": "sync", "at": "2026-07-04T16:07:46Z", "detail": {"board": "/Users/whit3rabbit/Documents/GitHub/anyllm/.eatahorse-integrate-forge-guardrails-opt-in-tool-c", "completed": 0, "dropped_missing": 0, "imported": 0, "moved": 0, "tasks": 6, "updated": 5}}
{"action": "move", "at": "2026-07-04T16:09:10Z", "detail": {"id": "EH-0005", "status": "doing"}}
{"action": "check", "at": "2026-07-04T16:10:35Z", "detail": {"group": "bites", "id": "EH-0005", "index": 1}}
{"action": "sync", "at": "2026-07-04T16:10:41Z", "detail": {"board": "/Users/whit3rabbit/Documents/GitHub/anyllm/.eatahorse-integrate-forge-guardrails-opt-in-tool-c", "completed": 0, "dropped_missing": 0, "imported": 0, "moved": 0, "tasks": 6, "updated": 6}}
{"action": "check", "at": "2026-07-04T16:13:44Z", "detail": {"group": "bites", "id": "EH-0005", "index": 2}}
{"action": "sync", "at": "2026-07-04T16:13:51Z", "detail": {"board": "/Users/whit3rabbit/Documents/GitHub/anyllm/.eatahorse-integrate-forge-guardrails-opt-in-tool-c", "completed": 0, "dropped_missing": 0, "imported": 0, "moved": 0, "tasks": 6, "updated": 6}}
{"action": "check", "at": "2026-07-04T16:27:09Z", "detail": {"group": "bites", "id": "EH-0005", "index": 3}}
{"action": "check", "at": "2026-07-04T16:27:52Z", "detail": {"group": "acceptance", "id": "EH-0005", "index": 1}}
{"action": "check", "at": "2026-07-04T16:27:53Z", "detail": {"group": "acceptance", "id": "EH-0005", "index": 3}}
{"action": "sync", "at": "2026-07-04T16:28:21Z", "detail": {"board": "/Users/whit3rabbit/Documents/GitHub/anyllm/.eatahorse-integrate-forge-guardrails-opt-in-tool-c", "completed": 0, "dropped_missing": 0, "imported": 0, "moved": 0, "tasks": 6, "updated": 6}}
{"action": "check", "at": "2026-07-04T16:30:43Z", "detail": {"group": "acceptance", "id": "EH-0005", "index": 2}}
{"action": "sync", "at": "2026-07-04T16:30:57Z", "detail": {"board": "/Users/whit3rabbit/Documents/GitHub/anyllm/.eatahorse-integrate-forge-guardrails-opt-in-tool-c", "completed": 0, "dropped_missing": 0, "imported": 0, "moved": 0, "tasks": 6, "updated": 6}}
{"action": "sync", "at": "2026-07-04T16:31:50Z", "detail": {"board": "/Users/whit3rabbit/Documents/GitHub/anyllm/.eatahorse-integrate-forge-guardrails-opt-in-tool-c", "completed": 0, "dropped_missing": 0, "imported": 0, "moved": 0, "tasks": 6, "updated": 6}}
{"action": "sync", "at": "2026-07-04T16:32:38Z", "detail": {"board": "/Users/whit3rabbit/Documents/GitHub/anyllm/.eatahorse-integrate-forge-guardrails-opt-in-tool-c", "completed": 0, "dropped_missing": 0, "imported": 0, "moved": 0, "tasks": 6, "updated": 6}}
{"action": "sync", "at": "2026-07-04T16:33:57Z", "detail": {"board": "/Users/whit3rabbit/Documents/GitHub/anyllm/.eatahorse-integrate-forge-guardrails-opt-in-tool-c", "completed": 0, "dropped_missing": 0, "imported": 0, "moved": 0, "tasks": 6, "updated": 6}}
{"action": "move", "at": "2026-07-04T16:34:22Z", "detail": {"id": "EH-0006", "status": "doing"}}
{"action": "check", "at": "2026-07-04T16:35:49Z", "detail": {"group": "bites", "id": "EH-0006", "index": 1}}
{"action": "check", "at": "2026-07-04T16:37:17Z", "detail": {"group": "acceptance", "id": "EH-0006", "index": 1}}
{"action": "check", "at": "2026-07-04T16:37:17Z", "detail": {"group": "acceptance", "id": "EH-0006", "index": 2}}
{"action": "check", "at": "2026-07-04T16:37:17Z", "detail": {"group": "acceptance", "id": "EH-0006", "index": 3}}
{"action": "sync", "at": "2026-07-04T16:37:42Z", "detail": {"board": "/Users/whit3rabbit/Documents/GitHub/anyllm/.eatahorse-integrate-forge-guardrails-opt-in-tool-c", "completed": 0, "dropped_missing": 0, "imported": 0, "moved": 0, "tasks": 6, "updated": 6}}
{"action": "sync", "at": "2026-07-04T16:39:24Z", "detail": {"board": "/Users/whit3rabbit/Documents/GitHub/anyllm/.eatahorse-integrate-forge-guardrails-opt-in-tool-c", "completed": 0, "dropped_missing": 0, "imported": 0, "moved": 0, "tasks": 6, "updated": 6}}
@@ -1,54 +0,0 @@
# Eatahorse Board: forge-guardrails integration finish
Goal: Integrate forge-guardrails (opt-in tool-call guardrails for local LLMs) into anyllm-proxy. Backend port is DONE already (uncommitted): crates/proxy/src/tools/guardrails.rs (lsp_first/quiet_command/write_payload_cap nudges, fingerprint dedup), wired into both tool loops (tools/execution/mod.rs, server/chat_completions/stream/generic/tool_loop.rs), config via SimpleConfig.build_tool_guardrail_config (config/simple/types.rs, tool_builder.rs), env fallback FORGE_TOOL_CALL_POLICY in main_helpers/async_main/tools.rs, ToolEngineState.guardrails wired through state.rs/routes.rs, docs updated (CONFIG.md, ENV.md, configuration-and-modes.md), tests updated. Remaining verified gaps to plan/execute: (1) main_helpers/async_main/tools.rs init_tool_engine early-returns None when tool_config.has_any() is false, so FORGE_TOOL_CALL_POLICY alone (no YAML tool_execution block) never takes effect -- the target local-LLM-env-var-only use case is currently broken; fix has_any()/init gating and add a test for env-var-only init with no YAML. (2) LiteLLM YAML config format has no tool_config path at all (config/multi/loader.rs) -- document this limitation explicitly, do not build support for it now. (3) No admin UI surface exists for tool_execution config at all (admin-ui/src has zero references); optionally add a RuntimeConfig field + Settings.tsx toggle following the repo's documented 6-site RuntimeConfig-field checklist (struct + RuntimeConfigDefaults + 3 constructors + SQLite override-apply match in main.rs + delete_config_override reset in admin/routes/config.rs) -- treat as optional/lower priority than (1)/(2). (4) No CLI flag needed -- env var is this repo's existing convention for backend toggles, do not add a second mechanism. Produce a Kanban board of small, independently-completable tasks for items (1), (2), and optionally (3), plus a changelog bullet task per repo release process (CHANGELOG.md [Unreleased]).
Updated: `2026-07-04T16:39:24Z`
## Counts
| Status | Count |
|---|---:|
| backlog | 0 |
| ready | 0 |
| doing | 0 |
| blocked | 0 |
| done | 5 |
| dropped | 1 |
## backlog
_No tasks._
## ready
_No tasks._
## doing
_No tasks._
## blocked
_No tasks._
## done
| ID | Task | Bites | Acceptance | Priority | Owner |
|---|---|---:|---:|---|---|
| [EH-0001](tasks/done/EH-0001-make-forge-tool-call-policy-alone-initialize-the-guardrail-e.md) | Make FORGE_TOOL_CALL_POLICY alone initialize the guardrail engine (no YAML) | 5/5 | 3/3 | P0 | |
| [EH-0002](tasks/done/EH-0002-document-that-litellm-yaml-format-cannot-carry-tool-executio.md) | Document that LiteLLM YAML format cannot carry tool_execution/guardrails config | 3/3 | 3/3 | P1 | |
| [EH-0003](tasks/done/EH-0003-add-changelog-unreleased-bullet-for-opt-in-tool-call-guardra.md) | Add CHANGELOG [Unreleased] bullet for opt-in tool-call guardrails | 1/1 | 2/2 | P2 | |
| [EH-0005](tasks/done/EH-0005-backend-add-tool-guardrail-mode-runtimeconfig-field-6-site-c.md) | Backend: add tool_guardrail_mode RuntimeConfig field (6-site checklist) | 3/3 | 3/3 | P3 | |
| [EH-0006](tasks/done/EH-0006-admin-ui-guardrail-mode-toggle-in-settings-tsx.md) | Admin UI: guardrail mode toggle in Settings.tsx | 1/1 | 3/3 | P3 | |
## dropped
| ID | Task | Bites | Acceptance | Priority | Owner |
|---|---|---:|---:|---|---|
| [EH-0004](tasks/dropped/EH-0004-optional-admin-ui-runtimeconfig-toggle-for-guardrail-mode.md) | (Optional) Admin UI + RuntimeConfig toggle for guardrail mode | 0/2 | 0/3 | P3 | |
## Workflow
Move one task into `doing`, finish one bite, sync, then take the next bite.
Generated by Eatahorse. Edit task markdown files or use `scripts/eatahorse.py`.
@@ -1,619 +0,0 @@
{
"created_at": "2026-07-03T23:40:10Z",
"goal": "Integrate forge-guardrails (opt-in tool-call guardrails for local LLMs) into anyllm-proxy. Backend port is DONE already (uncommitted): crates/proxy/src/tools/guardrails.rs (lsp_first/quiet_command/write_payload_cap nudges, fingerprint dedup), wired into both tool loops (tools/execution/mod.rs, server/chat_completions/stream/generic/tool_loop.rs), config via SimpleConfig.build_tool_guardrail_config (config/simple/types.rs, tool_builder.rs), env fallback FORGE_TOOL_CALL_POLICY in main_helpers/async_main/tools.rs, ToolEngineState.guardrails wired through state.rs/routes.rs, docs updated (CONFIG.md, ENV.md, configuration-and-modes.md), tests updated. Remaining verified gaps to plan/execute: (1) main_helpers/async_main/tools.rs init_tool_engine early-returns None when tool_config.has_any() is false, so FORGE_TOOL_CALL_POLICY alone (no YAML tool_execution block) never takes effect -- the target local-LLM-env-var-only use case is currently broken; fix has_any()/init gating and add a test for env-var-only init with no YAML. (2) LiteLLM YAML config format has no tool_config path at all (config/multi/loader.rs) -- document this limitation explicitly, do not build support for it now. (3) No admin UI surface exists for tool_execution config at all (admin-ui/src has zero references); optionally add a RuntimeConfig field + Settings.tsx toggle following the repo's documented 6-site RuntimeConfig-field checklist (struct + RuntimeConfigDefaults + 3 constructors + SQLite override-apply match in main.rs + delete_config_override reset in admin/routes/config.rs) -- treat as optional/lower priority than (1)/(2). (4) No CLI flag needed -- env var is this repo's existing convention for backend toggles, do not add a second mechanism. Produce a Kanban board of small, independently-completable tasks for items (1), (2), and optionally (3), plus a changelog bullet task per repo release process (CHANGELOG.md [Unreleased]).",
"history": [
{
"action": "add",
"at": "2026-07-03T23:40:25Z",
"detail": {
"id": "EH-0001",
"title": "Make FORGE_TOOL_CALL_POLICY alone initialize the guardrail engine (no YAML)"
}
},
{
"action": "add",
"at": "2026-07-03T23:40:34Z",
"detail": {
"id": "EH-0002",
"title": "Document that LiteLLM YAML format cannot carry tool_execution/guardrails config"
}
},
{
"action": "add",
"at": "2026-07-03T23:40:45Z",
"detail": {
"id": "EH-0003",
"title": "Add CHANGELOG [Unreleased] bullet for opt-in tool-call guardrails"
}
},
{
"action": "add",
"at": "2026-07-03T23:40:55Z",
"detail": {
"id": "EH-0004",
"title": "(Optional) Admin UI + RuntimeConfig toggle for guardrail mode"
}
},
{
"action": "drop",
"at": "2026-07-03T23:47:21Z",
"detail": {
"id": "EH-0004"
}
},
{
"action": "add",
"at": "2026-07-03T23:47:21Z",
"detail": {
"id": "EH-0005",
"title": "Backend: add tool_guardrail_mode RuntimeConfig field (6-site checklist)"
}
},
{
"action": "add",
"at": "2026-07-03T23:47:21Z",
"detail": {
"id": "EH-0006",
"title": "Admin UI: guardrail mode toggle in Settings.tsx"
}
},
{
"action": "move",
"at": "2026-07-04T15:50:50Z",
"detail": {
"id": "EH-0001",
"status": "doing"
}
},
{
"action": "check",
"at": "2026-07-04T15:52:33Z",
"detail": {
"group": "bites",
"id": "EH-0001",
"index": 1
}
},
{
"action": "check",
"at": "2026-07-04T15:54:03Z",
"detail": {
"group": "bites",
"id": "EH-0001",
"index": 2
}
},
{
"action": "check",
"at": "2026-07-04T15:59:34Z",
"detail": {
"group": "bites",
"id": "EH-0001",
"index": 3
}
},
{
"action": "check",
"at": "2026-07-04T15:59:34Z",
"detail": {
"group": "bites",
"id": "EH-0001",
"index": 4
}
},
{
"action": "check",
"at": "2026-07-04T15:59:34Z",
"detail": {
"group": "bites",
"id": "EH-0001",
"index": 5
}
},
{
"action": "check",
"at": "2026-07-04T15:59:55Z",
"detail": {
"group": "acceptance",
"id": "EH-0001",
"index": 1
}
},
{
"action": "check",
"at": "2026-07-04T15:59:55Z",
"detail": {
"group": "acceptance",
"id": "EH-0001",
"index": 2
}
},
{
"action": "check",
"at": "2026-07-04T15:59:55Z",
"detail": {
"group": "acceptance",
"id": "EH-0001",
"index": 3
}
},
{
"action": "move",
"at": "2026-07-04T16:02:05Z",
"detail": {
"id": "EH-0002",
"status": "doing"
}
},
{
"action": "check",
"at": "2026-07-04T16:02:45Z",
"detail": {
"group": "bites",
"id": "EH-0002",
"index": 1
}
},
{
"action": "check",
"at": "2026-07-04T16:02:54Z",
"detail": {
"group": "bites",
"id": "EH-0002",
"index": 2
}
},
{
"action": "check",
"at": "2026-07-04T16:04:46Z",
"detail": {
"group": "bites",
"id": "EH-0002",
"index": 3
}
},
{
"action": "check",
"at": "2026-07-04T16:04:46Z",
"detail": {
"group": "acceptance",
"id": "EH-0002",
"index": 1
}
},
{
"action": "check",
"at": "2026-07-04T16:04:46Z",
"detail": {
"group": "acceptance",
"id": "EH-0002",
"index": 2
}
},
{
"action": "check",
"at": "2026-07-04T16:04:46Z",
"detail": {
"group": "acceptance",
"id": "EH-0002",
"index": 3
}
},
{
"action": "move",
"at": "2026-07-04T16:06:49Z",
"detail": {
"id": "EH-0003",
"status": "doing"
}
},
{
"action": "check",
"at": "2026-07-04T16:07:46Z",
"detail": {
"group": "bites",
"id": "EH-0003",
"index": 1
}
},
{
"action": "check",
"at": "2026-07-04T16:07:46Z",
"detail": {
"group": "acceptance",
"id": "EH-0003",
"index": 1
}
},
{
"action": "check",
"at": "2026-07-04T16:07:46Z",
"detail": {
"group": "acceptance",
"id": "EH-0003",
"index": 2
}
},
{
"action": "move",
"at": "2026-07-04T16:09:10Z",
"detail": {
"id": "EH-0005",
"status": "doing"
}
},
{
"action": "check",
"at": "2026-07-04T16:10:35Z",
"detail": {
"group": "bites",
"id": "EH-0005",
"index": 1
}
},
{
"action": "check",
"at": "2026-07-04T16:13:44Z",
"detail": {
"group": "bites",
"id": "EH-0005",
"index": 2
}
},
{
"action": "check",
"at": "2026-07-04T16:27:09Z",
"detail": {
"group": "bites",
"id": "EH-0005",
"index": 3
}
},
{
"action": "check",
"at": "2026-07-04T16:27:52Z",
"detail": {
"group": "acceptance",
"id": "EH-0005",
"index": 1
}
},
{
"action": "check",
"at": "2026-07-04T16:27:53Z",
"detail": {
"group": "acceptance",
"id": "EH-0005",
"index": 3
}
},
{
"action": "check",
"at": "2026-07-04T16:30:43Z",
"detail": {
"group": "acceptance",
"id": "EH-0005",
"index": 2
}
},
{
"action": "move",
"at": "2026-07-04T16:34:22Z",
"detail": {
"id": "EH-0006",
"status": "doing"
}
},
{
"action": "check",
"at": "2026-07-04T16:35:49Z",
"detail": {
"group": "bites",
"id": "EH-0006",
"index": 1
}
},
{
"action": "check",
"at": "2026-07-04T16:37:17Z",
"detail": {
"group": "acceptance",
"id": "EH-0006",
"index": 1
}
},
{
"action": "check",
"at": "2026-07-04T16:37:17Z",
"detail": {
"group": "acceptance",
"id": "EH-0006",
"index": 2
}
},
{
"action": "check",
"at": "2026-07-04T16:37:17Z",
"detail": {
"group": "acceptance",
"id": "EH-0006",
"index": 3
}
}
],
"next_id": 7,
"project": "forge-guardrails integration finish",
"schema": "eatahorse.state.v1",
"statuses": [
"backlog",
"ready",
"doing",
"blocked",
"done",
"dropped"
],
"tasks": {
"EH-0001": {
"acceptance": [
{
"done": true,
"text": "New test passes: `cargo test -p anyllm_proxy forge_tool_call_policy_env_only` (or the chosen test name) exits 0."
},
{
"done": true,
"text": "Observable: with PROXY_CONFIG unset and FORGE_TOOL_CALL_POLICY=standard, init_tool_engine returns Some and the returned ToolEngineState.guardrails.mode is not ToolGuardrailMode::Disabled (asserted directly in the test)."
},
{
"done": true,
"text": "`cargo clippy -p anyllm_proxy -- -D warnings` reports no warnings for the changed file."
}
],
"bites": [
{
"done": true,
"text": "Refactor init_tool_engine so the FORGE_TOOL_CALL_POLICY resolution runs BEFORE the has_any() early return: when tool_config is None or has_any() is false, resolve the env var and, if it parses to a non-Disabled ToolGuardrailMode, proceed to build the engine (empty registry, default policy/loop_config, no MCP)"
},
{
"done": true,
"text": "otherwise return None as today. Keep the existing YAML-precedence rule (guardrails_set_in_yaml wins over env)."
},
{
"done": true,
"text": "Add a #[cfg(test)] module (or extend the nearest tools test) covering: (a) tool_config None + FORGE_TOOL_CALL_POLICY=standard => Some engine with guardrails.mode != Disabled"
},
{
"done": true,
"text": "(b) env unset + no tool sections => None"
},
{
"done": true,
"text": "(c) invalid env value => None (or Disabled) and does not panic. Acquire crate::config::ENV_TEST_LOCK before mutating the env var and restore it after."
}
],
"completed_at": "2026-07-04T15:59:55Z",
"created_at": "2026-07-03T23:40:25Z",
"description": "The env-var-only local-LLM path is currently dead. init_tool_engine (crates/proxy/src/main_helpers/async_main/tools.rs:9) does `let tc = tool_config.filter(|tc| tc.has_any())?`; and returns None whenever there is no tool_execution/builtin/mcp section. But loader.rs sets tool_config: None when PROXY_CONFIG is unset (loader.rs:100-107) and has_any() is false for a simple YAML without a tool_execution block. So FORGE_TOOL_CALL_POLICY is never consulted in the exact scenario it targets. Fix the gating so a valid non-Disabled FORGE_TOOL_CALL_POLICY builds a ToolEngineState with an empty registry + default policy/loop_config, whether tool_config is None or present-but-empty. Preserve current behavior: unset/empty/invalid/disabled env with no YAML tool sections still yields None (or a Disabled engine that is inert).",
"dropped_at": null,
"estimate": "",
"external_ids": {},
"id": "EH-0001",
"notes": "",
"owner": "",
"path": "tasks/done/EH-0001-make-forge-tool-call-policy-alone-initialize-the-guardrail-e.md",
"priority": "P0",
"source": "manual",
"status": "done",
"tags": [],
"title": "Make FORGE_TOOL_CALL_POLICY alone initialize the guardrail engine (no YAML)",
"updated_at": "2026-07-04T16:39:24Z"
},
"EH-0002": {
"acceptance": [
{
"done": true,
"text": "`grep -rin 'litellm' docs/CONFIG.md docs/codedocs/configuration-and-modes.md` shows a line tying LiteLLM/model_list format to tool_execution/guardrails NOT being supported."
},
{
"done": true,
"text": "loader.rs:91 comment references the doc-noted limitation (inspect crates/proxy/src/config/multi/loader.rs)."
},
{
"done": true,
"text": "No new parsing code added: `git diff --stat` shows changes limited to docs plus the loader.rs comment line."
}
],
"bites": [
{
"done": true,
"text": "Add an explicit note to docs/CONFIG.md (and docs/codedocs/configuration-and-modes.md where tool_execution/guardrails are described) stating: tool_execution and guardrails are only read from the simple native YAML format (top-level `models:`) or the FORGE_TOOL_CALL_POLICY env var"
},
{
"done": true,
"text": "the LiteLLM `model_list:` format ignores them."
},
{
"done": true,
"text": "Expand the inline comment at loader.rs:91 from 'LiteLLM format has no tool sections' to reference the documented limitation so future readers know it is intentional, not a TODO."
}
],
"completed_at": "2026-07-04T16:04:46Z",
"created_at": "2026-07-03T23:40:34Z",
"description": "The LiteLLM model_list format has no tool_config path: loader.rs:91 hard-codes `tool_config: None` for that branch. Users on LiteLLM YAML who set a guardrails block will see it silently ignored, and only FORGE_TOOL_CALL_POLICY env works for them. Document this limitation explicitly across the guardrails/tool_execution docs. Do NOT add LiteLLM parsing support.",
"dropped_at": null,
"estimate": "",
"external_ids": {},
"id": "EH-0002",
"notes": "",
"owner": "",
"path": "tasks/done/EH-0002-document-that-litellm-yaml-format-cannot-carry-tool-executio.md",
"priority": "P1",
"source": "manual",
"status": "done",
"tags": [],
"title": "Document that LiteLLM YAML format cannot carry tool_execution/guardrails config",
"updated_at": "2026-07-04T16:39:24Z"
},
"EH-0003": {
"acceptance": [
{
"done": true,
"text": "`grep -in 'guardrail' CHANGELOG.md` returns a line located between the `## [Unreleased]` and the next `## [` version header."
},
{
"done": true,
"text": "The bullet mentions both FORGE_TOOL_CALL_POLICY and the simple-YAML tool_execution.guardrails path."
}
],
"bites": [
{
"done": true,
"text": "Under the `## [Unreleased]` header in CHANGELOG.md, add an `### Added` bullet describing opt-in Forge-style tool-call guardrails (lsp_first/quiet_command/write_payload_cap nudges, fingerprint dedup) configurable via simple YAML tool_execution.guardrails or the FORGE_TOOL_CALL_POLICY env var, noting the LiteLLM-format limitation."
}
],
"completed_at": "2026-07-04T16:07:46Z",
"created_at": "2026-07-03T23:40:45Z",
"description": "Per the repo release process, every user-visible change needs an [Unreleased] bullet before release. The guardrails feature (config + FORGE_TOOL_CALL_POLICY env + nudges) is user-visible and the section is currently empty (CHANGELOG.md line ~11).",
"dropped_at": null,
"estimate": "",
"external_ids": {},
"id": "EH-0003",
"notes": "",
"owner": "",
"path": "tasks/done/EH-0003-add-changelog-unreleased-bullet-for-opt-in-tool-call-guardra.md",
"priority": "P2",
"source": "manual",
"status": "done",
"tags": [],
"title": "Add CHANGELOG [Unreleased] bullet for opt-in tool-call guardrails",
"updated_at": "2026-07-04T16:39:24Z"
},
"EH-0004": {
"acceptance": [
{
"done": false,
"text": "`cargo build -p anyllm_proxy` compiles after the field is added at all constructor sites."
},
{
"done": false,
"text": "`grep -rin 'tool_guardrail' crates/proxy/src/admin/state.rs crates/proxy/src/main.rs crates/proxy/src/admin/routes/config.rs crates/proxy/admin-ui/src/tabs/settings/Settings.tsx` returns matches in all four (confirming all non-compiler-caught sites were touched)."
},
{
"done": false,
"text": "Headless: an admin config PUT setting the field then GET returns the new value (assert via a config-route test or curl against a running proxy). (manual residual: toggle the control in `cargo run -p anyllm_proxy -- --webui` and confirm it survives a restart.)"
}
],
"bites": [
{
"done": false,
"text": "Add a RuntimeConfig field (e.g. tool_guardrail_mode: String) at all 6 sites: struct + RuntimeConfigDefaults in crates/proxy/src/admin/state.rs, the 3 constructors (SharedState::new_for_test, cost/mod.rs test, main.rs), the SQLite override-apply match in main.rs, and delete_config_override reset in crates/proxy/src/admin/routes/config.rs. Make the runtime value flow into the guardrail mode actually used at request time."
},
{
"done": false,
"text": "Add a toggle/select control to crates/proxy/admin-ui/src/tabs/settings/Settings.tsx wired to the existing admin config GET/PUT route for the new field."
}
],
"completed_at": null,
"created_at": "2026-07-03T23:40:55Z",
"description": "No admin surface exists for tool_execution guardrails (admin-ui/src has zero references). Optionally expose a runtime-tunable guardrail mode following the documented 6-site RuntimeConfig-field checklist so it persists and resets like other runtime config. Lower priority than the P0/P1 cards; skip if scope is tight. Note the two non-compiler-caught sites (SQLite override-apply match in main.rs, delete_config_override reset in admin/routes/config.rs) that silently break persistence/reset if missed.",
"dropped_at": "2026-07-03T23:47:21Z",
"estimate": "",
"external_ids": {},
"id": "EH-0004",
"notes": "",
"owner": "",
"path": "tasks/dropped/EH-0004-optional-admin-ui-runtimeconfig-toggle-for-guardrail-mode.md",
"priority": "P3",
"source": "manual",
"status": "dropped",
"tags": [],
"title": "(Optional) Admin UI + RuntimeConfig toggle for guardrail mode",
"updated_at": "2026-07-04T16:39:24Z"
},
"EH-0005": {
"acceptance": [
{
"done": true,
"text": "cargo build -p anyllm_proxy compiles after the field is added at all constructor sites"
},
{
"done": true,
"text": "grep -rin 'tool_guardrail' crates/proxy/src/admin/state.rs crates/proxy/src/main_helpers/async_main/admin.rs crates/proxy/src/admin/routes/config.rs returns matches in all three backend files (confirms the non-compiler-caught sites were touched; path corrected from main.rs to main_helpers/async_main/admin.rs, see Notes)"
},
{
"done": true,
"text": "Headless: a config-route test (or curl against a running proxy) does a config PUT setting tool_guardrail_mode then a GET returns the new value"
}
],
"bites": [
{
"done": true,
"text": "Add tool_guardrail_mode: String to the RuntimeConfig struct + RuntimeConfigDefaults in crates/proxy/src/admin/state.rs"
},
{
"done": true,
"text": "Update the 3 constructors (SharedState::new_for_test, cost/mod.rs test, main.rs) to set the new field"
},
{
"done": true,
"text": "Add the field to the SQLite override-apply match in main.rs and the delete_config_override reset in crates/proxy/src/admin/routes/config.rs, and make the runtime value flow into the guardrail mode used at request time"
}
],
"completed_at": "2026-07-04T16:30:43Z",
"created_at": "2026-07-03T23:47:21Z",
"description": "Expose a runtime-tunable guardrail mode as a RuntimeConfig field so it persists/resets like other runtime config. Backend only; the admin UI control is a separate card (split from former EH-0004). Watch the two non-compiler-caught sites (SQLite override-apply match in main.rs, delete_config_override reset in admin/routes/config.rs) that silently break persistence/reset if missed.",
"dropped_at": null,
"estimate": "",
"external_ids": {},
"id": "EH-0005",
"notes": "Field, 3 constructors, SQLite override-apply, delete-reset, GET/PUT config-route\nsupport, and runtime-mode wiring into `evaluate_tool_guardrails`\n(`tools::resolve_runtime_guardrails` + `AppState::effective_tool_guardrails`,\nconsumed by `handler.rs`, `routes/messages.rs`, and the streaming\n`tool_loop.rs`) are all done. `cargo build -p anyllm_proxy` and\n`cargo test -p anyllm_proxy` (599 passed) are clean, and\n`admin::routes::tests::put_config_tool_guardrail_mode_then_get_returns_new_value`\ncovers the PUT-then-GET round trip headlessly.\n\nAcceptance check 2 as literally worded (\"... crates/proxy/src/main.rs ...\")\ndoes not hold: `main.rs` is now a thin 167-line entry point (env/arg\nbootstrapping only) — the actual SQLite override-apply match lives in\n`crates/proxy/src/main_helpers/async_main/admin.rs::init_admin` (that's where\n`anthropic_thinking_repair`'s override-apply arm already lived pre-refactor,\nper `git grep`). I added the `tool_guardrail_mode` arm there, matching the\nother fields, not in `main.rs` itself. Leaving acceptance item 2 unchecked\nsince a literal `grep ... crates/proxy/src/main.rs` returns no match; the\nunderlying intent (both non-compiler-caught sites touched) is satisfied. If a\nhuman confirms the check should target `main_helpers/async_main/admin.rs`\ninstead, this can be marked done.\n\nResolved: confirmed `main_helpers/async_main/admin.rs` is the correct, established\nlocation for this class of override-apply arm — `anthropic_thinking_repair`'s\nown arm already lived there before this card touched the file (`git grep\nanthropic_thinking_repair crates/proxy/src/main_helpers/async_main/admin.rs`\nshows matching struct-init + match-arm lines at 108/114/149-150, same shape as\nthe new `tool_guardrail_mode` arm at 109/115/152-161). The card's description\nand acceptance-check path were written against the pre-refactor layout where\nthis logic lived in `main.rs`; `main.rs` is now a thin 167-line bootstrap\nentry point post-refactor (unrelated to this card). Updated acceptance check 2\ntext above to reference the current path and re-ran the grep against all\nthree correct files — all match. No source change was needed; this was a\nstale path reference in the acceptance-check text, not a missed\nimplementation site.",
"owner": "",
"path": "tasks/done/EH-0005-backend-add-tool-guardrail-mode-runtimeconfig-field-6-site-c.md",
"priority": "P3",
"source": "manual",
"status": "done",
"tags": [],
"title": "Backend: add tool_guardrail_mode RuntimeConfig field (6-site checklist)",
"updated_at": "2026-07-04T16:39:24Z"
},
"EH-0006": {
"acceptance": [
{
"done": true,
"text": "grep -rin 'tool_guardrail' crates/proxy/admin-ui/src/tabs/settings/Settings.tsx returns the new control"
},
{
"done": true,
"text": "npm run build in crates/proxy/admin-ui compiles with the new control present"
},
{
"done": true,
"text": "Manual: in cargo run -p anyllm_proxy -- --webui, toggling the control then restarting shows the value persisted"
}
],
"bites": [
{
"done": true,
"text": "Add a select/toggle control for tool_guardrail_mode to crates/proxy/admin-ui/src/tabs/settings/Settings.tsx, wired to the existing admin config GET/PUT route"
}
],
"completed_at": "2026-07-04T16:37:17Z",
"created_at": "2026-07-03T23:47:21Z",
"description": "Add a toggle/select control for tool_guardrail_mode to the admin UI, wired to the existing admin config GET/PUT route. Depends on the backend RuntimeConfig-field card being done first (split from former EH-0004).",
"dropped_at": null,
"estimate": "",
"external_ids": {},
"id": "EH-0006",
"notes": "Verified the \"manual\" acceptance item headlessly by driving the same admin API the UI control\ncalls (no browser available in this sandbox): started `cargo run -p anyllm_proxy -- --webui`\nagainst a scratch `ANYLLM_HOME`, fetched the admin token + CSRF token, `PUT\n/admin/api/config {\"tool_guardrail_mode\":\"standard\"}` (confirmed `disabled` -> `standard` and\n`overridden_keys` includes it), killed and restarted the process, and confirmed\n`GET /admin/api/config` still returned `tool_guardrail_mode: \"standard\"` with the override\nintact. This exercises the exact backend path the new `<select>` in Settings.tsx invokes\n(`save.mutate({ tool_guardrail_mode })` -> `PUT /admin/api/config`), so it stands in for a\nliteral browser click.",
"owner": "",
"path": "tasks/done/EH-0006-admin-ui-guardrail-mode-toggle-in-settings-tsx.md",
"priority": "P3",
"source": "manual",
"status": "done",
"tags": [],
"title": "Admin UI: guardrail mode toggle in Settings.tsx",
"updated_at": "2026-07-04T16:39:24Z"
}
},
"updated_at": "2026-07-04T16:39:24Z",
"version": 1
}
@@ -1,41 +0,0 @@
---
id: "EH-0001"
title: "Make FORGE_TOOL_CALL_POLICY alone initialize the guardrail engine (no YAML)"
status: "done"
priority: "P0"
owner: ""
tags: []
estimate: ""
source: "manual"
path: "tasks/done/EH-0001-make-forge-tool-call-policy-alone-initialize-the-guardrail-e.md"
created_at: "2026-07-03T23:40:25Z"
updated_at: "2026-07-04T16:39:24Z"
completed_at: "2026-07-04T15:59:55Z"
dropped_at: null
---
# EH-0001: Make FORGE_TOOL_CALL_POLICY alone initialize the guardrail engine (no YAML)
Status: `done` · Priority: `P0` · Owner: `unassigned` · Estimate: `unsized`
Tags: none
Completed: `2026-07-04T15:59:55Z`
## Description
The env-var-only local-LLM path is currently dead. init_tool_engine (crates/proxy/src/main_helpers/async_main/tools.rs:9) does `let tc = tool_config.filter(|tc| tc.has_any())?`; and returns None whenever there is no tool_execution/builtin/mcp section. But loader.rs sets tool_config: None when PROXY_CONFIG is unset (loader.rs:100-107) and has_any() is false for a simple YAML without a tool_execution block. So FORGE_TOOL_CALL_POLICY is never consulted in the exact scenario it targets. Fix the gating so a valid non-Disabled FORGE_TOOL_CALL_POLICY builds a ToolEngineState with an empty registry + default policy/loop_config, whether tool_config is None or present-but-empty. Preserve current behavior: unset/empty/invalid/disabled env with no YAML tool sections still yields None (or a Disabled engine that is inert).
## One-bite-at-a-time plan
- [x] Refactor init_tool_engine so the FORGE_TOOL_CALL_POLICY resolution runs BEFORE the has_any() early return: when tool_config is None or has_any() is false, resolve the env var and, if it parses to a non-Disabled ToolGuardrailMode, proceed to build the engine (empty registry, default policy/loop_config, no MCP)
- [x] otherwise return None as today. Keep the existing YAML-precedence rule (guardrails_set_in_yaml wins over env).
- [x] Add a #[cfg(test)] module (or extend the nearest tools test) covering: (a) tool_config None + FORGE_TOOL_CALL_POLICY=standard => Some engine with guardrails.mode != Disabled
- [x] (b) env unset + no tool sections => None
- [x] (c) invalid env value => None (or Disabled) and does not panic. Acquire crate::config::ENV_TEST_LOCK before mutating the env var and restore it after.
## Acceptance checks
- [x] New test passes: `cargo test -p anyllm_proxy forge_tool_call_policy_env_only` (or the chosen test name) exits 0.
- [x] Observable: with PROXY_CONFIG unset and FORGE_TOOL_CALL_POLICY=standard, init_tool_engine returns Some and the returned ToolEngineState.guardrails.mode is not ToolGuardrailMode::Disabled (asserted directly in the test).
- [x] `cargo clippy -p anyllm_proxy -- -D warnings` reports no warnings for the changed file.
## Notes
<!-- Eatahorse: edit checkboxes/status/title, then run `python scripts/eatahorse.py sync` or let the Claude Code hook sync it. -->
@@ -1,39 +0,0 @@
---
id: "EH-0002"
title: "Document that LiteLLM YAML format cannot carry tool_execution/guardrails config"
status: "done"
priority: "P1"
owner: ""
tags: []
estimate: ""
source: "manual"
path: "tasks/done/EH-0002-document-that-litellm-yaml-format-cannot-carry-tool-executio.md"
created_at: "2026-07-03T23:40:34Z"
updated_at: "2026-07-04T16:39:24Z"
completed_at: "2026-07-04T16:04:46Z"
dropped_at: null
---
# EH-0002: Document that LiteLLM YAML format cannot carry tool_execution/guardrails config
Status: `done` · Priority: `P1` · Owner: `unassigned` · Estimate: `unsized`
Tags: none
Completed: `2026-07-04T16:04:46Z`
## Description
The LiteLLM model_list format has no tool_config path: loader.rs:91 hard-codes `tool_config: None` for that branch. Users on LiteLLM YAML who set a guardrails block will see it silently ignored, and only FORGE_TOOL_CALL_POLICY env works for them. Document this limitation explicitly across the guardrails/tool_execution docs. Do NOT add LiteLLM parsing support.
## One-bite-at-a-time plan
- [x] Add an explicit note to docs/CONFIG.md (and docs/codedocs/configuration-and-modes.md where tool_execution/guardrails are described) stating: tool_execution and guardrails are only read from the simple native YAML format (top-level `models:`) or the FORGE_TOOL_CALL_POLICY env var
- [x] the LiteLLM `model_list:` format ignores them.
- [x] Expand the inline comment at loader.rs:91 from 'LiteLLM format has no tool sections' to reference the documented limitation so future readers know it is intentional, not a TODO.
## Acceptance checks
- [x] `grep -rin 'litellm' docs/CONFIG.md docs/codedocs/configuration-and-modes.md` shows a line tying LiteLLM/model_list format to tool_execution/guardrails NOT being supported.
- [x] loader.rs:91 comment references the doc-noted limitation (inspect crates/proxy/src/config/multi/loader.rs).
- [x] No new parsing code added: `git diff --stat` shows changes limited to docs plus the loader.rs comment line.
## Notes
<!-- Eatahorse: edit checkboxes/status/title, then run `python scripts/eatahorse.py sync` or let the Claude Code hook sync it. -->
@@ -1,36 +0,0 @@
---
id: "EH-0003"
title: "Add CHANGELOG [Unreleased] bullet for opt-in tool-call guardrails"
status: "done"
priority: "P2"
owner: ""
tags: []
estimate: ""
source: "manual"
path: "tasks/done/EH-0003-add-changelog-unreleased-bullet-for-opt-in-tool-call-guardra.md"
created_at: "2026-07-03T23:40:45Z"
updated_at: "2026-07-04T16:39:24Z"
completed_at: "2026-07-04T16:07:46Z"
dropped_at: null
---
# EH-0003: Add CHANGELOG [Unreleased] bullet for opt-in tool-call guardrails
Status: `done` · Priority: `P2` · Owner: `unassigned` · Estimate: `unsized`
Tags: none
Completed: `2026-07-04T16:07:46Z`
## Description
Per the repo release process, every user-visible change needs an [Unreleased] bullet before release. The guardrails feature (config + FORGE_TOOL_CALL_POLICY env + nudges) is user-visible and the section is currently empty (CHANGELOG.md line ~11).
## One-bite-at-a-time plan
- [x] Under the `## [Unreleased]` header in CHANGELOG.md, add an `### Added` bullet describing opt-in Forge-style tool-call guardrails (lsp_first/quiet_command/write_payload_cap nudges, fingerprint dedup) configurable via simple YAML tool_execution.guardrails or the FORGE_TOOL_CALL_POLICY env var, noting the LiteLLM-format limitation.
## Acceptance checks
- [x] `grep -in 'guardrail' CHANGELOG.md` returns a line located between the `## [Unreleased]` and the next `## [` version header.
- [x] The bullet mentions both FORGE_TOOL_CALL_POLICY and the simple-YAML tool_execution.guardrails path.
## Notes
<!-- Eatahorse: edit checkboxes/status/title, then run `python scripts/eatahorse.py sync` or let the Claude Code hook sync it. -->
@@ -1,72 +0,0 @@
---
id: "EH-0005"
title: "Backend: add tool_guardrail_mode RuntimeConfig field (6-site checklist)"
status: "done"
priority: "P3"
owner: ""
tags: []
estimate: ""
source: "manual"
path: "tasks/done/EH-0005-backend-add-tool-guardrail-mode-runtimeconfig-field-6-site-c.md"
created_at: "2026-07-03T23:47:21Z"
updated_at: "2026-07-04T16:39:24Z"
completed_at: "2026-07-04T16:30:43Z"
dropped_at: null
---
# EH-0005: Backend: add tool_guardrail_mode RuntimeConfig field (6-site checklist)
Status: `done` · Priority: `P3` · Owner: `unassigned` · Estimate: `unsized`
Tags: none
Completed: `2026-07-04T16:30:43Z`
## Description
Expose a runtime-tunable guardrail mode as a RuntimeConfig field so it persists/resets like other runtime config. Backend only; the admin UI control is a separate card (split from former EH-0004). Watch the two non-compiler-caught sites (SQLite override-apply match in main.rs, delete_config_override reset in admin/routes/config.rs) that silently break persistence/reset if missed.
## One-bite-at-a-time plan
- [x] Add tool_guardrail_mode: String to the RuntimeConfig struct + RuntimeConfigDefaults in crates/proxy/src/admin/state.rs
- [x] Update the 3 constructors (SharedState::new_for_test, cost/mod.rs test, main.rs) to set the new field
- [x] Add the field to the SQLite override-apply match in main.rs and the delete_config_override reset in crates/proxy/src/admin/routes/config.rs, and make the runtime value flow into the guardrail mode used at request time
## Acceptance checks
- [x] cargo build -p anyllm_proxy compiles after the field is added at all constructor sites
- [x] grep -rin 'tool_guardrail' crates/proxy/src/admin/state.rs crates/proxy/src/main_helpers/async_main/admin.rs crates/proxy/src/admin/routes/config.rs returns matches in all three backend files (confirms the non-compiler-caught sites were touched; path corrected from main.rs to main_helpers/async_main/admin.rs, see Notes)
- [x] Headless: a config-route test (or curl against a running proxy) does a config PUT setting tool_guardrail_mode then a GET returns the new value
## Notes
Field, 3 constructors, SQLite override-apply, delete-reset, GET/PUT config-route
support, and runtime-mode wiring into `evaluate_tool_guardrails`
(`tools::resolve_runtime_guardrails` + `AppState::effective_tool_guardrails`,
consumed by `handler.rs`, `routes/messages.rs`, and the streaming
`tool_loop.rs`) are all done. `cargo build -p anyllm_proxy` and
`cargo test -p anyllm_proxy` (599 passed) are clean, and
`admin::routes::tests::put_config_tool_guardrail_mode_then_get_returns_new_value`
covers the PUT-then-GET round trip headlessly.
Acceptance check 2 as literally worded ("... crates/proxy/src/main.rs ...")
does not hold: `main.rs` is now a thin 167-line entry point (env/arg
bootstrapping only) — the actual SQLite override-apply match lives in
`crates/proxy/src/main_helpers/async_main/admin.rs::init_admin` (that's where
`anthropic_thinking_repair`'s override-apply arm already lived pre-refactor,
per `git grep`). I added the `tool_guardrail_mode` arm there, matching the
other fields, not in `main.rs` itself. Leaving acceptance item 2 unchecked
since a literal `grep ... crates/proxy/src/main.rs` returns no match; the
underlying intent (both non-compiler-caught sites touched) is satisfied. If a
human confirms the check should target `main_helpers/async_main/admin.rs`
instead, this can be marked done.
Resolved: confirmed `main_helpers/async_main/admin.rs` is the correct, established
location for this class of override-apply arm — `anthropic_thinking_repair`'s
own arm already lived there before this card touched the file (`git grep
anthropic_thinking_repair crates/proxy/src/main_helpers/async_main/admin.rs`
shows matching struct-init + match-arm lines at 108/114/149-150, same shape as
the new `tool_guardrail_mode` arm at 109/115/152-161). The card's description
and acceptance-check path were written against the pre-refactor layout where
this logic lived in `main.rs`; `main.rs` is now a thin 167-line bootstrap
entry point post-refactor (unrelated to this card). Updated acceptance check 2
text above to reference the current path and re-ran the grep against all
three correct files — all match. No source change was needed; this was a
stale path reference in the acceptance-check text, not a missed
implementation site.
<!-- Eatahorse: edit checkboxes/status/title, then run `python scripts/eatahorse.py sync` or let the Claude Code hook sync it. -->
@@ -1,45 +0,0 @@
---
id: "EH-0006"
title: "Admin UI: guardrail mode toggle in Settings.tsx"
status: "done"
priority: "P3"
owner: ""
tags: []
estimate: ""
source: "manual"
path: "tasks/done/EH-0006-admin-ui-guardrail-mode-toggle-in-settings-tsx.md"
created_at: "2026-07-03T23:47:21Z"
updated_at: "2026-07-04T16:39:24Z"
completed_at: "2026-07-04T16:37:17Z"
dropped_at: null
---
# EH-0006: Admin UI: guardrail mode toggle in Settings.tsx
Status: `done` · Priority: `P3` · Owner: `unassigned` · Estimate: `unsized`
Tags: none
Completed: `2026-07-04T16:37:17Z`
## Description
Add a toggle/select control for tool_guardrail_mode to the admin UI, wired to the existing admin config GET/PUT route. Depends on the backend RuntimeConfig-field card being done first (split from former EH-0004).
## One-bite-at-a-time plan
- [x] Add a select/toggle control for tool_guardrail_mode to crates/proxy/admin-ui/src/tabs/settings/Settings.tsx, wired to the existing admin config GET/PUT route
## Acceptance checks
- [x] grep -rin 'tool_guardrail' crates/proxy/admin-ui/src/tabs/settings/Settings.tsx returns the new control
- [x] npm run build in crates/proxy/admin-ui compiles with the new control present
- [x] Manual: in cargo run -p anyllm_proxy -- --webui, toggling the control then restarting shows the value persisted
## Notes
Verified the "manual" acceptance item headlessly by driving the same admin API the UI control
calls (no browser available in this sandbox): started `cargo run -p anyllm_proxy -- --webui`
against a scratch `ANYLLM_HOME`, fetched the admin token + CSRF token, `PUT
/admin/api/config {"tool_guardrail_mode":"standard"}` (confirmed `disabled` -> `standard` and
`overridden_keys` includes it), killed and restarted the process, and confirmed
`GET /admin/api/config` still returned `tool_guardrail_mode: "standard"` with the override
intact. This exercises the exact backend path the new `<select>` in Settings.tsx invokes
(`save.mutate({ tool_guardrail_mode })` -> `PUT /admin/api/config`), so it stands in for a
literal browser click.
<!-- Eatahorse: edit checkboxes/status/title, then run `python scripts/eatahorse.py sync` or let the Claude Code hook sync it. -->
@@ -1,37 +0,0 @@
---
id: "EH-0004"
title: "(Optional) Admin UI + RuntimeConfig toggle for guardrail mode"
status: "dropped"
priority: "P3"
owner: ""
tags: []
estimate: ""
source: "manual"
path: "tasks/dropped/EH-0004-optional-admin-ui-runtimeconfig-toggle-for-guardrail-mode.md"
created_at: "2026-07-03T23:40:55Z"
updated_at: "2026-07-04T16:39:24Z"
completed_at: null
dropped_at: "2026-07-03T23:47:21Z"
---
# EH-0004: (Optional) Admin UI + RuntimeConfig toggle for guardrail mode
Status: `dropped` · Priority: `P3` · Owner: `unassigned` · Estimate: `unsized`
Tags: none
## Description
No admin surface exists for tool_execution guardrails (admin-ui/src has zero references). Optionally expose a runtime-tunable guardrail mode following the documented 6-site RuntimeConfig-field checklist so it persists and resets like other runtime config. Lower priority than the P0/P1 cards; skip if scope is tight. Note the two non-compiler-caught sites (SQLite override-apply match in main.rs, delete_config_override reset in admin/routes/config.rs) that silently break persistence/reset if missed.
## One-bite-at-a-time plan
- [ ] Add a RuntimeConfig field (e.g. tool_guardrail_mode: String) at all 6 sites: struct + RuntimeConfigDefaults in crates/proxy/src/admin/state.rs, the 3 constructors (SharedState::new_for_test, cost/mod.rs test, main.rs), the SQLite override-apply match in main.rs, and delete_config_override reset in crates/proxy/src/admin/routes/config.rs. Make the runtime value flow into the guardrail mode actually used at request time.
- [ ] Add a toggle/select control to crates/proxy/admin-ui/src/tabs/settings/Settings.tsx wired to the existing admin config GET/PUT route for the new field.
## Acceptance checks
- [ ] `cargo build -p anyllm_proxy` compiles after the field is added at all constructor sites.
- [ ] `grep -rin 'tool_guardrail' crates/proxy/src/admin/state.rs crates/proxy/src/main.rs crates/proxy/src/admin/routes/config.rs crates/proxy/admin-ui/src/tabs/settings/Settings.tsx` returns matches in all four (confirming all non-compiler-caught sites were touched).
- [ ] Headless: an admin config PUT setting the field then GET returns the new value (assert via a config-route test or curl against a running proxy). (manual residual: toggle the control in `cargo run -p anyllm_proxy -- --webui` and confirm it survives a restart.)
## Notes
<!-- Eatahorse: edit checkboxes/status/title, then run `python scripts/eatahorse.py sync` or let the Claude Code hook sync it. -->
+1
View File
@@ -56,3 +56,4 @@ crates/proxy/admin-ui/node_modules/
# dist/ is committed so `include_str!` works at compile time without a build step
# crates/proxy/admin-ui/dist/
.synrepo/
.eatahorse*/
File diff suppressed because one or more lines are too long
+10 -2
View File
@@ -298,7 +298,11 @@ export function useCreateManagedBackend() {
return useMutation<ManagedBackend, Error, CreateManagedBackendRequest>({
mutationFn: (data) =>
mutatingFetch<ManagedBackend>('POST', '/admin/api/backends/managed', data),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['managed-backends'] }) },
// Adding the first managed backend flips /admin/api/status configured -> true.
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['managed-backends'] })
qc.invalidateQueries({ queryKey: ['status'] })
},
})
}
@@ -316,7 +320,11 @@ export function useDeleteManagedBackend() {
return useMutation<void, Error, string>({
mutationFn: (name) =>
mutatingFetch<void>('DELETE', `/admin/api/backends/managed/${name}`),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['managed-backends'] }) },
// Deleting the last managed backend can flip configured -> false.
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['managed-backends'] })
qc.invalidateQueries({ queryKey: ['status'] })
},
})
}
@@ -0,0 +1,10 @@
/** Tiny "(?)" marker with a native tooltip. Dependency-free — uses the browser's
* title attribute (same approach as the uptime day cells). For richer hover cards,
* swap the implementation here; every call site stays the same. */
export default function InfoTip({ text }: { text: string }) {
return (
<span className="info-tip" title={text} aria-label={text} role="img">
?
</span>
)
}
+22 -13
View File
@@ -65,7 +65,7 @@ body { background: var(--bg-base); color: var(--text-1); font-family: var(--font
a { color: var(--accent); text-decoration: none; }
/* App layout */
.app-layout { display: flex; min-height: 100vh; }
.app-layout { display: flex; min-height: 100vh; max-width: 100vw; overflow-x: hidden; }
/* Sidebar */
.sidebar { width: 220px; min-width: 220px; background: var(--bg-raised); border-right: 1px solid var(--border); display: flex; flex-direction: column; position: sticky; top: 0; height: 100vh; }
@@ -88,7 +88,12 @@ a { color: var(--accent); text-decoration: none; }
@keyframes pulse { 0%, 100% { opacity: 0.9; } 50% { opacity: 0.4; } }
/* Layout */
.tab-content { flex: 1; min-width: 0; padding: 14px 20px; max-width: 1400px; margin: 0 auto; overflow-x: auto; }
/* tab-content is a plain fluid flex child so it always tracks the viewport and its
overflow-x scrolls (rather than clipping) when a page is genuinely too wide. The
1400px reading-width cap + centering lives on the page root, not this flex box —
putting max-width/margin:auto on the flex item itself breaks its shrink behaviour. */
.tab-content { flex: 1; min-width: 0; padding: 14px 20px; overflow-x: auto; }
.tab-content > * { max-width: 1400px; margin-inline: auto; }
/* Stats */
.stats-row { display: flex; border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; margin-bottom: 14px; background: var(--bg-raised); }
@@ -230,6 +235,11 @@ button[disabled] { opacity: 0.55; cursor: not-allowed; }
/* Key result */
.key-result { background: var(--bg-sunken); border: 1px solid var(--border); border-left: 3px solid var(--ok); border-radius: var(--r); padding: 12px; margin-bottom: 12px; font-family: var(--font-mono); font-size: 12px; word-break: break-all; }
.key-result-label { color: var(--ok); font-size: 11px; margin-bottom: 4px; font-weight: 600; }
.key-result-value { display: flex; align-items: center; gap: 10px; }
.key-result-value > .mono { flex: 1; word-break: break-all; }
.key-result-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.key-result-dismiss { background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 18px; line-height: 1; padding: 0 4px; font-family: var(--font-ui); }
.key-result-dismiss:hover { color: var(--text-1); }
/* Modal */
.modal-backdrop { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.7); display: flex; align-items: center; justify-content: center; z-index: 100; }
@@ -346,6 +356,10 @@ button[disabled] { opacity: 0.55; cursor: not-allowed; }
.empty-cta-body { color: var(--text-2); font-size: 12px; max-width: 520px; line-height: 1.5; }
.empty-cta .btn { margin-top: 6px; }
/* Info tooltip marker (native title=) */
.info-tip { display: inline-flex; align-items: center; justify-content: center; width: 13px; height: 13px; margin-left: 5px; border-radius: 50%; border: 1px solid var(--border); color: var(--text-3); font-size: 9px; line-height: 1; cursor: help; vertical-align: middle; font-family: var(--font-ui); }
.info-tip:hover { color: var(--text-1); border-color: var(--text-3); }
/* Inline error (non-modal) */
.inline-error { margin-top: 8px; padding: 6px 10px; background: var(--err-dim); border-left: 3px solid var(--err); border-radius: var(--r); font-size: 12px; }
@@ -402,23 +416,18 @@ button[disabled] { opacity: 0.55; cursor: not-allowed; }
.provider-tile:hover { border-color: var(--accent); background: var(--bg-hover); }
.provider-tile.has-backends { border-left: 2px solid var(--accent); }
.provider-tile-name { font-size: 12px; font-weight: 500; color: var(--text-1); line-height: 1.3; }
.provider-tile-id { font-size: 10px; color: var(--text-3); font-family: var(--font-mono); line-height: 1.2; word-break: break-all; }
.provider-tile-count { font-size: 10px; color: var(--text-3); font-family: var(--font-mono); }
/* Provider detail panel (lightbox overlay) */
.provider-scrim { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.65); z-index: 50; }
.provider-panel { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: min(640px, 90vw); max-height: 80vh; overflow-y: auto; z-index: 51; background: var(--bg-raised); border: 1px solid var(--border); border-top: 2px solid var(--accent); border-radius: var(--rm); }
.provider-panel-header { display: flex; align-items: center; gap: 12px; padding: 16px 16px 12px; border-bottom: 1px solid var(--border-sub); }
.provider-panel-header h3 { flex: 1; font-size: 16px; font-weight: 600; margin: 0; }
.provider-panel-close { background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 20px; line-height: 1; padding: 0 4px; font-family: var(--font-ui); }
.provider-panel-close:hover { color: var(--text-1); }
.provider-panel-caps { display: flex; flex-wrap: wrap; gap: 5px; padding: 10px 16px; border-bottom: 1px solid var(--border-sub); align-items: center; }
/* Provider detail panel (rendered inside shared Modal .modal-body) */
.provider-panel-caps { display: flex; flex-wrap: wrap; gap: 5px; padding: 0 0 10px; border-bottom: 1px solid var(--border-sub); align-items: center; }
.badge-cap { font-size: 10px; padding: 2px 6px; border-radius: 1px; background: var(--bg-hover); color: var(--text-2); border: 1px solid var(--border); font-family: var(--font-mono); }
.badge-cap.active { background: var(--accent-dim); color: var(--accent); border-color: var(--accent-bdr); }
.provider-panel-meta { display: flex; gap: 16px; padding: 8px 16px; font-size: 12px; color: var(--text-2); border-bottom: 1px solid var(--border-sub); flex-wrap: wrap; }
.provider-panel-meta { display: flex; gap: 16px; padding: 8px 0; font-size: 12px; color: var(--text-2); border-bottom: 1px solid var(--border-sub); flex-wrap: wrap; }
.provider-panel-meta .mono { font-family: var(--font-mono); }
/* Backend rows within panel */
.provider-panel-section { padding: 12px 16px; }
.provider-panel-section { padding: 12px 0 0; }
.provider-panel-section-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.07em; color: var(--text-2); font-weight: 500; margin-bottom: 8px; }
.provider-backend-row { display: flex; align-items: center; gap: 8px; padding: 7px 10px; background: var(--bg-sunken); border: 1px solid var(--border-sub); border-radius: var(--r); margin-bottom: 4px; font-size: 13px; }
.provider-backend-row .backend-name { font-family: var(--font-mono); font-weight: 500; flex: 1; }
@@ -458,6 +467,7 @@ button[disabled] { opacity: 0.55; cursor: not-allowed; }
.sidebar-item { white-space: nowrap; padding: 8px 10px; border-left: 0; border-bottom: 2px solid transparent; border-radius: var(--r); }
.sidebar-item.active { border-left-color: transparent; border-bottom-color: var(--accent); }
.tab-content { width: 100%; max-width: none; margin: 0; padding: 12px; overflow-x: hidden; }
.tab-content > * { max-width: none; margin-inline: 0; }
.stats-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.stat, .stats-row .admin-surface.stat { border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
@@ -508,7 +518,6 @@ button[disabled] { opacity: 0.55; cursor: not-allowed; }
.route-provider-models, .route-provider-reorder, .route-provider-toggle, .route-provider-remove { grid-column: 2; justify-self: start; }
.route-provider-id { display: block; margin-left: 0; margin-top: 2px; }
.provider-panel { width: calc(100vw - 24px); max-height: calc(100svh - 24px); }
.login-title { margin-bottom: 18px; }
}
@@ -4,6 +4,7 @@ import type { BackendUptimeInfo } from '../../api/types'
import EmptyState from '../../components/shared/EmptyState'
import StatusDot from '../../components/shared/StatusDot'
import { AdminSurface } from '../../components/shared/Performative'
import { ManagedBackendsSection } from '../settings/ManagedBackendsSection'
export default function Backends() {
const { data, isLoading, error } = useBackends()
@@ -19,13 +20,17 @@ export default function Backends() {
return (
<div>
{/* Add/Edit/Delete lives here so backends are configured and monitored in one place. */}
<ManagedBackendsSection />
<div className="section-label" style={{ marginTop: 8 }}>Backend Status</div>
<EmptyState loading={isLoading} error={error?.message} empty={data?.length === 0} />
<div className="backend-cards">
{data?.map((b) => {
const health = healthMap.get(b.name)
const dot = health?.status === 'up' ? 'ok' : health?.status === 'down' ? 'err' : 'dim'
return (
<AdminSurface className="card" key={b.name} glowOnHover>
<AdminSurface className="card" key={b.name}>
<div className="card-header">
<span className="card-name">{b.name}</span>
<StatusDot status={dot} pulse={dot === 'ok'} />
@@ -1,6 +1,7 @@
import { useMetrics } from '../../api/queries'
import LiveFeed from '../../components/feed/LiveFeed'
import { AdminSurface, AnimatedMetric } from '../../components/shared/Performative'
import InfoTip from '../../components/shared/InfoTip'
import ObservabilityPanel from './ObservabilityPanel'
export default function Dashboard() {
@@ -22,13 +23,13 @@ export default function Dashboard() {
</div>
</AdminSurface>
<AdminSurface className="stat">
<div className="stat-label">P50 Latency</div>
<div className="stat-label">P50 Latency<InfoTip text="Median response latency — half of requests were faster than this." /></div>
<div className="stat-value">
{m ? <AnimatedMetric value={m.p50_latency_ms ?? 0} format={(n) => `${Math.round(n)}ms`} /> : '—'}
</div>
</AdminSurface>
<AdminSurface className="stat">
<div className="stat-label">P95 Latency</div>
<div className="stat-label">P95 Latency<InfoTip text="95th-percentile latency — 95% of requests were faster than this. Captures tail slowness." /></div>
<div className="stat-value">
{m ? <AnimatedMetric value={m.p95_latency_ms ?? 0} format={(n) => `${Math.round(n)}ms`} /> : '—'}
</div>
@@ -3,6 +3,7 @@ import { useObservability, useBackends } from '../../api/queries'
import LineChart from '../../components/shared/LineChart'
import EmptyState from '../../components/shared/EmptyState'
import { AdminSurface, AnimatedMetric } from '../../components/shared/Performative'
import InfoTip from '../../components/shared/InfoTip'
export default function ObservabilityPanel() {
const [windowHours, setWindowHours] = useState(6)
@@ -58,11 +59,11 @@ export default function ObservabilityPanel() {
<div className="stat-value"><AnimatedMetric value={data.total_output_tokens} /></div>
</AdminSurface>
<AdminSurface className="stat">
<div className="stat-label">Window Failures</div>
<div className="stat-label">Window Failures<InfoTip text="Failed (error) requests within the selected time window and backend filter." /></div>
<div className="stat-value"><AnimatedMetric value={data.total_errors} /></div>
</AdminSurface>
<AdminSurface className="stat">
<div className="stat-label">Window Cost</div>
<div className="stat-label">Window Cost<InfoTip text="Estimated USD spend within the selected time window and backend filter, from model pricing." /></div>
<div className="stat-value"><AnimatedMetric value={data.total_cost_usd} precision={2} format={(n) => `$${n.toFixed(2)}`} /></div>
</AdminSurface>
</div>
@@ -28,12 +28,17 @@ export default function KeyCreateForm({ onCreated }: { onCreated: (key: string)
<form onSubmit={(e) => { e.preventDefault(); handleSubmit() }}>
<div className="form-row" style={{ flexWrap: 'wrap' }}>
<input name="description" placeholder="Description" value={desc} onChange={(e) => setDesc(e.target.value)} />
<input name="max_budget_usd" placeholder="Spend limit USD" type="number" value={spendLimit} onChange={(e) => setSpendLimit(e.target.value)} style={{ width: 120 }} />
<input name="max_budget_usd" placeholder="Spend limit USD" type="number" value={spendLimit} onChange={(e) => setSpendLimit(e.target.value)} style={{ width: 160 }} />
<input name="rpm_limit" placeholder="RPM limit" type="number" value={rpmLimit} onChange={(e) => setRpmLimit(e.target.value)} style={{ width: 100 }} />
<AdminButton type="submit" tone="primary" loading={create.isPending}>
Create
</AdminButton>
</div>
{!spendLimit && !rpmLimit && (
<div className="form-hint" style={{ color: 'var(--warn)', marginTop: 6 }}>
No limits set this key will be unrestricted (unlimited spend and requests).
</div>
)}
</form>
</div>
)
+27 -1
View File
@@ -6,6 +6,9 @@ import Badge from '../../components/shared/Badge'
import BudgetBar from '../../components/shared/BudgetBar'
import KeyCreateForm from './KeyCreateForm'
import KeyEditModal from './KeyEditModal'
import { AdminButton } from '../../components/shared/Performative'
import { pushToast } from '../../store/toast'
import { copyToClipboard } from '../../utils/clipboard'
import type { VirtualKey } from '../../api/types'
export default function Keys() {
@@ -60,7 +63,30 @@ export default function Keys() {
{newKey && (
<div className="key-result">
<div className="key-result-label">New key (copy now not shown again)</div>
{newKey}
<div className="key-result-value">
<span className="mono">{newKey}</span>
<div className="key-result-actions">
<AdminButton
size="sm"
onClick={async () => {
const ok = await copyToClipboard(newKey)
pushToast(ok
? { variant: 'success', message: 'Key copied to clipboard' }
: { variant: 'error', message: 'Copy failed — select and copy manually' })
}}
>
Copy
</AdminButton>
<button
type="button"
className="key-result-dismiss"
aria-label="Dismiss"
onClick={() => setNewKey(null)}
>
×
</button>
</div>
</div>
</div>
)}
<div className="toolbar">
@@ -31,6 +31,10 @@ export default function Models() {
const { data: backends } = useBackends()
const { data: managedBackends } = useManagedBackends()
const [name, setName] = useState('')
// Last value we auto-filled into Virtual Name from a discovered model. Lets us
// re-prefill when the user picks a different model, without clobbering a name
// they typed by hand (name !== autoName => they edited it, leave it alone).
const [autoName, setAutoName] = useState('')
const [model, setModel] = useState('')
const [backendName, setBackendName] = useState('')
const [discoverSource, setDiscoverSource] = useState('openrouter')
@@ -47,6 +51,13 @@ export default function Models() {
})
}
function handleAdd() {
add.mutate(
{ model_name: name, actual_model: model, backend_name: backendName },
{ onSuccess: () => { setName(''); setAutoName(''); setModel(''); setBackendName('') } },
)
}
function doRemove() {
if (!pendingRemove) return Promise.resolve()
return remove.mutateAsync(pendingRemove).then(() => undefined)
@@ -109,7 +120,17 @@ export default function Models() {
{discover.data.models.map((m) => (
<div
key={m.id}
onClick={() => setModel(m.id)}
onClick={() => {
setModel(m.id)
// Prefill Virtual Name from the catalog display name (still editable).
// Overwrite only if empty or still holding a prior auto-fill; keep a
// hand-typed name.
if (!name || name === autoName) {
const next = m.name && m.name !== m.id ? m.name : m.id
setName(next)
setAutoName(next)
}
}}
className={`models-discover-item${model === m.id ? ' is-selected' : ''}`}
>
<span className="mono">{m.id}</span>
@@ -125,38 +146,35 @@ export default function Models() {
)}
</div>
{/* Datalist for backend name suggestions */}
<datalist id="backends-list">
{backends?.map(b => (
<option key={b.name} value={b.name}>{b.name}</option>
))}
{managedBackends?.backends.map(b => (
<option key={`managed-${b.name}`} value={b.name}>{b.name} (managed)</option>
))}
</datalist>
{/* Manual add model form */}
<div className="form-group">
<div className="form-label">Add Model</div>
<div className="form-row" style={{ flexWrap: 'wrap' }}>
<input name="model-name" placeholder="Virtual name" value={name} onChange={(e) => setName(e.target.value)} />
<input name="model-id" placeholder="Model ID" value={model} onChange={(e) => setModel(e.target.value)} />
<input
<select
name="backend"
placeholder="Backend"
value={backendName}
onChange={(e) => setBackendName(e.target.value)}
list="backends-list"
/>
>
<option value="">Backend</option>
{backends?.map(b => (
<option key={b.name} value={b.name}>{b.name}</option>
))}
{managedBackends?.backends.map(b => (
<option key={`managed-${b.name}`} value={b.name}>{b.name} (managed)</option>
))}
</select>
<AdminButton
tone="primary"
onClick={() => add.mutate({ model_name: name, actual_model: model, backend_name: backendName })}
onClick={handleAdd}
disabled={!name || !model || !backendName || add.isPending}
loading={add.isPending}
>
Add
</AdminButton>
</div>
{add.isError && <div className="inline-error">{add.error.message}</div>}
</div>
<div className="toolbar">
@@ -11,6 +11,7 @@ import { getProviderFields } from '../../utils/providerFields'
import { groupByTier } from '../../utils/providerTiers'
import AsyncBoundary from '../../components/shared/AsyncBoundary'
import ConfirmDialog from '../../components/shared/ConfirmDialog'
import Modal from '../../components/shared/Modal'
import StatusDot from '../../components/shared/StatusDot'
import ProviderIcon from '../../components/shared/ProviderIcon'
import { AdminButton, AdminSurface } from '../../components/shared/Performative'
@@ -34,6 +35,7 @@ function ProviderTile({
>
<ProviderIcon id={provider.id} size={28} />
<span className="provider-tile-name">{provider.display_name}</span>
<span className="provider-tile-id">{provider.id}</span>
{backendCount > 0 && (
<span className="provider-tile-count">
{backendCount} key{backendCount !== 1 ? 's' : ''}
@@ -196,22 +198,6 @@ function ProviderDetailPanel({
onClose: () => void
onDeleteBackend: (b: ManagedBackend) => void
}) {
// ESC to close
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [onClose])
// Lock body scroll
useEffect(() => {
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = prev }
}, [])
const caps = provider.capabilities
const capList: [string, boolean][] = [
['chat', caps.chat_completions],
@@ -223,73 +209,61 @@ function ProviderDetailPanel({
]
return (
<>
<div className="provider-scrim" onClick={onClose} />
<AdminSurface className="provider-panel" role="dialog" aria-modal="true" aria-label={provider.display_name}>
{/* Header */}
<div className="provider-panel-header">
<ProviderIcon id={provider.id} size={36} />
<h3>{provider.display_name}</h3>
<button type="button" className="provider-panel-close" onClick={onClose} aria-label="Close">
&times;
</button>
</div>
<Modal open onClose={onClose} title={`${provider.display_name} (${provider.id})`} size="md">
{/* Capabilities */}
<div className="provider-panel-caps">
{capList.map(([label, active]) => (
<span key={label} className={`badge-cap${active ? ' active' : ''}`}>
{label}
</span>
))}
<span style={{ marginLeft: 'auto' }} className="badge-cap active">
{provider.model_count} models
</span>
</div>
{/* Capabilities */}
<div className="provider-panel-caps">
{capList.map(([label, active]) => (
<span key={label} className={`badge-cap${active ? ' active' : ''}`}>
{label}
</span>
))}
<span style={{ marginLeft: 'auto' }} className="badge-cap active">
{provider.model_count} models
{/* Meta */}
<div className="provider-panel-meta">
<span>
Protocol: <span className="mono">{provider.protocol.replace(/_/g, ' ')}</span>
</span>
<span>
Auth: <span className="mono">{provider.auth.replace(/_/g, ' ')}</span>
</span>
<span>
Status: <span className="mono">{provider.status}</span>
</span>
{provider.env_vars.length > 0 && (
<span>
Env: <span className="mono">{provider.env_vars[0]}</span>
</span>
</div>
)}
</div>
{/* Meta */}
<div className="provider-panel-meta">
<span>
Protocol: <span className="mono">{provider.protocol.replace(/_/g, ' ')}</span>
</span>
<span>
Auth: <span className="mono">{provider.auth.replace(/_/g, ' ')}</span>
</span>
<span>
Status: <span className="mono">{provider.status}</span>
</span>
{provider.env_vars.length > 0 && (
<span>
Env: <span className="mono">{provider.env_vars[0]}</span>
</span>
)}
{/* Configured keys */}
<div className="provider-panel-section">
<div className="provider-panel-section-label">
Configured keys ({backends.length})
</div>
{/* Configured keys */}
<div className="provider-panel-section">
<div className="provider-panel-section-label">
Configured keys ({backends.length})
{backends.length === 0 && (
<div className="provider-empty-hint">
No keys configured. Add one below to start forwarding requests.
</div>
{backends.length === 0 && (
<div className="provider-empty-hint">
No keys configured. Add one below to start forwarding requests.
</div>
)}
{backends.map((b) => (
<BackendRow
key={b.id}
backend={b}
healthStatus={healthMap.get(b.name)}
onDelete={() => onDeleteBackend(b)}
/>
))}
<AddBackendForm
provider={provider}
existingCount={backends.length}
)}
{backends.map((b) => (
<BackendRow
key={b.id}
backend={b}
healthStatus={healthMap.get(b.name)}
onDelete={() => onDeleteBackend(b)}
/>
</div>
</AdminSurface>
</>
))}
<AddBackendForm
provider={provider}
existingCount={backends.length}
/>
</div>
</Modal>
)
}
@@ -156,7 +156,7 @@ export function BackendForm({ initial, onSuccess, onCancel }: BackendFormProps)
return (
<optgroup key={status} label={status.charAt(0).toUpperCase() + status.slice(1)}>
{group.map(p => (
<option key={p.id} value={p.id}>{p.display_name}</option>
<option key={p.id} value={p.id}>{p.display_name} ({p.id})</option>
))}
</optgroup>
)
@@ -7,7 +7,6 @@ import EmptyState from '../../components/shared/EmptyState'
import ConfirmDialog from '../../components/shared/ConfirmDialog'
import { AdminButton, AdminSurface } from '../../components/shared/Performative'
import type { EnvImportResponse, EnvImportError } from '../../api/types'
import { ManagedBackendsSection } from './ManagedBackendsSection'
const RESTART_KEY = 'env_import_pending_restart'
@@ -108,14 +107,13 @@ export default function Settings({ configured = true }: { configured?: boolean }
return (
<div>
{/* Managed backends — always shown first */}
<ManagedBackendsSection />
{/* Getting-started notice — shown when no backend is configured */}
{/* Getting-started notice — shown when no backend is configured (no env/config-file
signal and no managed backend). Manage backends on the Backends tab. */}
{!configured && (
<div style={{ marginBottom: 20, padding: '12px 16px', border: '1px solid var(--border)', borderLeft: '3px solid var(--warn)', borderRadius: 'var(--r)', fontSize: 13 }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>No proxy configuration found nothing to forward requests to.</div>
<div style={{ fontWeight: 600, marginBottom: 8 }}>No backend configured nothing to forward requests to.</div>
<div style={{ marginBottom: 10 }}>
Add a backend on the <span className="mono">Backends</span> tab, or configure one via env.
The proxy needs a backend endpoint (where to forward) and a listen port (where to accept).
LISTEN_PORT defaults to 3000. Create a <span className="mono">.anyllm.env</span> and import it below,
or pass it at startup: <span className="mono">anyllm-proxy --webui --env-file .anyllm.env</span>
@@ -48,7 +48,11 @@ export default function TrafficView() {
<div className="chart-subtitle">Stacked over time window</div>
</div>
</div>
<LineChart series={series} />
{routes.length === 0 ? (
<div className="empty">No routes</div>
) : (
<LineChart series={series} />
)}
</AdminSurface>
<AdminSurface className="chart-card">
<div className="chart-header">
@@ -0,0 +1,11 @@
/** Copy text to the clipboard. Returns false if the browser blocks it
* (e.g. non-secure context). localhost is treated as secure, so this works
* for the admin UI on http://127.0.0.1. */
export async function copyToClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
return false
}
}
+15 -3
View File
@@ -1,3 +1,5 @@
use crate::admin::state::SharedState;
use axum::extract::State;
use axum::Json;
use serde::Serialize;
@@ -8,10 +10,20 @@ pub struct ProxyStatus {
/// GET /admin/api/status -- returns whether the proxy has a backend configured.
/// "Configured" means the user set at least one backend-relevant env var
/// (API key, base URL, provider choice) or pointed to a config file.
pub async fn get_status() -> Json<ProxyStatus> {
/// (API key, base URL, provider choice), pointed to a config file, OR created a
/// managed backend via the admin UI (stored in SQLite). Without the managed-backend
/// check, Settings would warn "no backend configured" while the Backends page shows
/// a UI-added backend up.
pub async fn get_status(State(shared): State<SharedState>) -> Json<ProxyStatus> {
let has_managed_backend = crate::admin::state::with_db(&shared.db, |conn| {
crate::admin::db::list_managed_backends(conn).map(|rows| !rows.is_empty())
})
.await
.and_then(Result::ok)
.unwrap_or(false);
Json(ProxyStatus {
configured: is_backend_configured(),
configured: is_backend_configured() || has_managed_backend,
})
}