@miller-tech/uap 2.21.2 → 2.36.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -10
- package/config/model-profiles/qwen38-coder390.json +46 -0
- package/config/model-profiles/qwen38.json +3 -2
- package/config/model-registry.json +110 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/benchmarks/paired/types.d.ts +2 -2
- package/dist/bin/cli.js +315 -20
- package/dist/bin/cli.js.map +1 -1
- package/dist/cli/attention.d.ts +6 -0
- package/dist/cli/attention.d.ts.map +1 -0
- package/dist/cli/attention.js +115 -0
- package/dist/cli/attention.js.map +1 -0
- package/dist/cli/bench-report.d.ts +82 -0
- package/dist/cli/bench-report.d.ts.map +1 -0
- package/dist/cli/bench-report.js +237 -0
- package/dist/cli/bench-report.js.map +1 -0
- package/dist/cli/clients.d.ts +6 -0
- package/dist/cli/clients.d.ts.map +1 -0
- package/dist/cli/clients.js +142 -0
- package/dist/cli/clients.js.map +1 -0
- package/dist/cli/dashboard-grants.d.ts +26 -0
- package/dist/cli/dashboard-grants.d.ts.map +1 -0
- package/dist/cli/dashboard-grants.js +69 -0
- package/dist/cli/dashboard-grants.js.map +1 -0
- package/dist/cli/dashboard-pace.d.ts +31 -0
- package/dist/cli/dashboard-pace.d.ts.map +1 -0
- package/dist/cli/dashboard-pace.js +42 -0
- package/dist/cli/dashboard-pace.js.map +1 -0
- package/dist/cli/dashboard-session.d.ts +25 -0
- package/dist/cli/dashboard-session.d.ts.map +1 -0
- package/dist/cli/dashboard-session.js +814 -0
- package/dist/cli/dashboard-session.js.map +1 -0
- package/dist/cli/dashboard-types.d.ts +18 -0
- package/dist/cli/dashboard-types.d.ts.map +1 -0
- package/dist/cli/dashboard-types.js +9 -0
- package/dist/cli/dashboard-types.js.map +1 -0
- package/dist/cli/dashboard.d.ts +2 -18
- package/dist/cli/dashboard.d.ts.map +1 -1
- package/dist/cli/dashboard.js +43 -793
- package/dist/cli/dashboard.js.map +1 -1
- package/dist/cli/deliver.d.ts +35 -0
- package/dist/cli/deliver.d.ts.map +1 -1
- package/dist/cli/deliver.js +93 -9
- package/dist/cli/deliver.js.map +1 -1
- package/dist/cli/diff.d.ts +85 -0
- package/dist/cli/diff.d.ts.map +1 -0
- package/dist/cli/diff.js +293 -0
- package/dist/cli/diff.js.map +1 -0
- package/dist/cli/eta.d.ts +35 -0
- package/dist/cli/eta.d.ts.map +1 -0
- package/dist/cli/eta.js +101 -0
- package/dist/cli/eta.js.map +1 -0
- package/dist/cli/handsfree.d.ts +7 -0
- package/dist/cli/handsfree.d.ts.map +1 -1
- package/dist/cli/handsfree.js +27 -0
- package/dist/cli/handsfree.js.map +1 -1
- package/dist/cli/hooks.d.ts.map +1 -1
- package/dist/cli/hooks.js +126 -5
- package/dist/cli/hooks.js.map +1 -1
- package/dist/cli/loops.d.ts +24 -5
- package/dist/cli/loops.d.ts.map +1 -1
- package/dist/cli/loops.js +92 -12
- package/dist/cli/loops.js.map +1 -1
- package/dist/cli/models.d.ts +62 -0
- package/dist/cli/models.d.ts.map +1 -0
- package/dist/cli/models.js +567 -0
- package/dist/cli/models.js.map +1 -0
- package/dist/cli/plan.d.ts +9 -0
- package/dist/cli/plan.d.ts.map +1 -1
- package/dist/cli/plan.js +57 -4
- package/dist/cli/plan.js.map +1 -1
- package/dist/cli/proxy-relay.d.ts +55 -0
- package/dist/cli/proxy-relay.d.ts.map +1 -0
- package/dist/cli/proxy-relay.js +116 -0
- package/dist/cli/proxy-relay.js.map +1 -0
- package/dist/cli/proxy.d.ts +4 -0
- package/dist/cli/proxy.d.ts.map +1 -1
- package/dist/cli/proxy.js +26 -1
- package/dist/cli/proxy.js.map +1 -1
- package/dist/cli/sandbox.d.ts +101 -4
- package/dist/cli/sandbox.d.ts.map +1 -1
- package/dist/cli/sandbox.js +393 -44
- package/dist/cli/sandbox.js.map +1 -1
- package/dist/cli/self.d.ts +13 -0
- package/dist/cli/self.d.ts.map +1 -0
- package/dist/cli/self.js +94 -0
- package/dist/cli/self.js.map +1 -0
- package/dist/cli/systemd-services.d.ts +2 -8
- package/dist/cli/systemd-services.d.ts.map +1 -1
- package/dist/cli/systemd-services.js +5 -20
- package/dist/cli/systemd-services.js.map +1 -1
- package/dist/cli/task.js +1 -1
- package/dist/cli/task.js.map +1 -1
- package/dist/cli/wizard-config.d.ts.map +1 -1
- package/dist/cli/wizard-config.js +31 -2
- package/dist/cli/wizard-config.js.map +1 -1
- package/dist/config/settings-registry.d.ts +1 -1
- package/dist/config/settings-registry.d.ts.map +1 -1
- package/dist/config/settings-registry.js +108 -0
- package/dist/config/settings-registry.js.map +1 -1
- package/dist/coordination/capability-router.js +2 -2
- package/dist/coordination/capability-router.js.map +1 -1
- package/dist/coordination/database.d.ts +13 -0
- package/dist/coordination/database.d.ts.map +1 -1
- package/dist/coordination/database.js +63 -7
- package/dist/coordination/database.js.map +1 -1
- package/dist/coordination/eta-inject.d.ts +32 -0
- package/dist/coordination/eta-inject.d.ts.map +1 -0
- package/dist/coordination/eta-inject.js +64 -0
- package/dist/coordination/eta-inject.js.map +1 -0
- package/dist/coordination/reactor.d.ts.map +1 -1
- package/dist/coordination/reactor.js +14 -0
- package/dist/coordination/reactor.js.map +1 -1
- package/dist/coordination/service.d.ts +18 -8
- package/dist/coordination/service.d.ts.map +1 -1
- package/dist/coordination/service.js +38 -28
- package/dist/coordination/service.js.map +1 -1
- package/dist/dashboard/client-registry.d.ts +163 -0
- package/dist/dashboard/client-registry.d.ts.map +1 -0
- package/dist/dashboard/client-registry.js +549 -0
- package/dist/dashboard/client-registry.js.map +1 -0
- package/dist/dashboard/controls.d.ts.map +1 -1
- package/dist/dashboard/controls.js +33 -4
- package/dist/dashboard/controls.js.map +1 -1
- package/dist/dashboard/data-seeder.d.ts +10 -7
- package/dist/dashboard/data-seeder.d.ts.map +1 -1
- package/dist/dashboard/data-seeder.js +31 -75
- package/dist/dashboard/data-seeder.js.map +1 -1
- package/dist/dashboard/data-service.d.ts +9 -0
- package/dist/dashboard/data-service.d.ts.map +1 -1
- package/dist/dashboard/data-service.js +32 -3
- package/dist/dashboard/data-service.js.map +1 -1
- package/dist/dashboard/placement-routes.d.ts +119 -0
- package/dist/dashboard/placement-routes.d.ts.map +1 -0
- package/dist/dashboard/placement-routes.js +331 -0
- package/dist/dashboard/placement-routes.js.map +1 -0
- package/dist/dashboard/server.d.ts.map +1 -1
- package/dist/dashboard/server.js +408 -2
- package/dist/dashboard/server.js.map +1 -1
- package/dist/delivery/attention.d.ts +130 -0
- package/dist/delivery/attention.d.ts.map +1 -0
- package/dist/delivery/attention.js +341 -0
- package/dist/delivery/attention.js.map +1 -0
- package/dist/delivery/decompose.d.ts +18 -0
- package/dist/delivery/decompose.d.ts.map +1 -1
- package/dist/delivery/decompose.js +22 -1
- package/dist/delivery/decompose.js.map +1 -1
- package/dist/delivery/eta-estimate.d.ts +111 -0
- package/dist/delivery/eta-estimate.d.ts.map +1 -0
- package/dist/delivery/eta-estimate.js +171 -0
- package/dist/delivery/eta-estimate.js.map +1 -0
- package/dist/delivery/eta.d.ts +23 -0
- package/dist/delivery/eta.d.ts.map +1 -0
- package/dist/delivery/eta.js +59 -0
- package/dist/delivery/eta.js.map +1 -0
- package/dist/delivery/orchestrated-mission.d.ts +10 -0
- package/dist/delivery/orchestrated-mission.d.ts.map +1 -1
- package/dist/delivery/orchestrated-mission.js +15 -1
- package/dist/delivery/orchestrated-mission.js.map +1 -1
- package/dist/delivery/plan-check.d.ts +7 -1
- package/dist/delivery/plan-check.d.ts.map +1 -1
- package/dist/delivery/plan-check.js +23 -2
- package/dist/delivery/plan-check.js.map +1 -1
- package/dist/delivery/run-state.d.ts +1 -1
- package/dist/delivery/run-state.d.ts.map +1 -1
- package/dist/delivery/run-state.js +8 -1
- package/dist/delivery/run-state.js.map +1 -1
- package/dist/delivery/task-orchestrator.d.ts +8 -3
- package/dist/delivery/task-orchestrator.d.ts.map +1 -1
- package/dist/delivery/task-orchestrator.js +75 -43
- package/dist/delivery/task-orchestrator.js.map +1 -1
- package/dist/delivery/task-sync.d.ts +21 -0
- package/dist/delivery/task-sync.d.ts.map +1 -1
- package/dist/delivery/task-sync.js +44 -8
- package/dist/delivery/task-sync.js.map +1 -1
- package/dist/delivery/task-workspace.d.ts +3 -3
- package/dist/delivery/task-workspace.js +3 -3
- package/dist/generators/self-card.d.ts +65 -0
- package/dist/generators/self-card.d.ts.map +1 -0
- package/dist/generators/self-card.js +256 -0
- package/dist/generators/self-card.js.map +1 -0
- package/dist/inference/probe.js +1 -1
- package/dist/inference/probe.js.map +1 -1
- package/dist/memory/prepopulate.d.ts.map +1 -1
- package/dist/memory/prepopulate.js +2 -0
- package/dist/memory/prepopulate.js.map +1 -1
- package/dist/models/analytics.d.ts +32 -0
- package/dist/models/analytics.d.ts.map +1 -1
- package/dist/models/analytics.js +68 -5
- package/dist/models/analytics.js.map +1 -1
- package/dist/models/plan-readability.d.ts +55 -0
- package/dist/models/plan-readability.d.ts.map +1 -0
- package/dist/models/plan-readability.js +339 -0
- package/dist/models/plan-readability.js.map +1 -0
- package/dist/placement/admission.d.ts +59 -0
- package/dist/placement/admission.d.ts.map +1 -0
- package/dist/placement/admission.js +197 -0
- package/dist/placement/admission.js.map +1 -0
- package/dist/placement/auto.d.ts +44 -0
- package/dist/placement/auto.d.ts.map +1 -0
- package/dist/placement/auto.js +212 -0
- package/dist/placement/auto.js.map +1 -0
- package/dist/placement/enforce.d.ts +128 -0
- package/dist/placement/enforce.d.ts.map +1 -0
- package/dist/placement/enforce.js +655 -0
- package/dist/placement/enforce.js.map +1 -0
- package/dist/placement/idle.d.ts +50 -0
- package/dist/placement/idle.d.ts.map +1 -0
- package/dist/placement/idle.js +213 -0
- package/dist/placement/idle.js.map +1 -0
- package/dist/placement/ledger.d.ts +323 -0
- package/dist/placement/ledger.d.ts.map +1 -0
- package/dist/placement/ledger.js +263 -0
- package/dist/placement/ledger.js.map +1 -0
- package/dist/placement/preview.d.ts +51 -0
- package/dist/placement/preview.d.ts.map +1 -0
- package/dist/placement/preview.js +61 -0
- package/dist/placement/preview.js.map +1 -0
- package/dist/placement/probes.d.ts +47 -0
- package/dist/placement/probes.d.ts.map +1 -0
- package/dist/placement/probes.js +207 -0
- package/dist/placement/probes.js.map +1 -0
- package/dist/placement/proxy-env.d.ts +21 -0
- package/dist/placement/proxy-env.d.ts.map +1 -0
- package/dist/placement/proxy-env.js +42 -0
- package/dist/placement/proxy-env.js.map +1 -0
- package/dist/placement/registry.d.ts +1038 -0
- package/dist/placement/registry.d.ts.map +1 -0
- package/dist/placement/registry.js +274 -0
- package/dist/placement/registry.js.map +1 -0
- package/dist/policies/liveness.d.ts +6 -8
- package/dist/policies/liveness.d.ts.map +1 -1
- package/dist/policies/liveness.js +6 -25
- package/dist/policies/liveness.js.map +1 -1
- package/dist/policies/sandbox-anchor.d.ts +91 -0
- package/dist/policies/sandbox-anchor.d.ts.map +1 -0
- package/dist/policies/sandbox-anchor.js +187 -0
- package/dist/policies/sandbox-anchor.js.map +1 -0
- package/dist/policies/sandbox-grants.d.ts +131 -0
- package/dist/policies/sandbox-grants.d.ts.map +1 -0
- package/dist/policies/sandbox-grants.js +562 -0
- package/dist/policies/sandbox-grants.js.map +1 -0
- package/dist/state/manifest.d.ts +4 -2
- package/dist/state/manifest.d.ts.map +1 -1
- package/dist/state/manifest.js +32 -16
- package/dist/state/manifest.js.map +1 -1
- package/dist/utils/checkout-root.d.ts +2 -0
- package/dist/utils/checkout-root.d.ts.map +1 -0
- package/dist/utils/checkout-root.js +43 -0
- package/dist/utils/checkout-root.js.map +1 -0
- package/dist/utils/resolve-uap-bin.d.ts +5 -0
- package/dist/utils/resolve-uap-bin.d.ts.map +1 -0
- package/dist/utils/resolve-uap-bin.js +33 -0
- package/dist/utils/resolve-uap-bin.js.map +1 -0
- package/dist/utils/terminal-safe.d.ts +12 -0
- package/dist/utils/terminal-safe.d.ts.map +1 -0
- package/dist/utils/terminal-safe.js +53 -0
- package/dist/utils/terminal-safe.js.map +1 -0
- package/docs/ATTRIBUTION.md +1 -0
- package/docs/DASHBOARD_UPLIFT_SPEC.md +8 -8
- package/docs/INDEX.md +6 -5
- package/docs/architecture/OVERVIEW.md +15 -3
- package/docs/architecture/PROTOCOL.md +7 -1
- package/docs/architecture/adr/0006-operator-directed-model-placement.md +86 -0
- package/docs/architecture/adr/0007-auto-displacement-allowlist-as-standing-consent.md +79 -0
- package/docs/architecture/adr/0008-fleet-clients-link-out.md +92 -0
- package/docs/architecture/adr/0009-completion-triggered-dispatch-and-per-leaf-model-dials.md +71 -0
- package/docs/architecture/adr/0010-attention-queue-and-afk-auto-decider.md +159 -0
- package/docs/architecture/adr/0011-sandbox-grants-and-network-egress-boundary.md +219 -0
- package/docs/architecture/adr/0012-operator-legibility-surfaces.md +175 -0
- package/docs/getting-started/INSTALLATION.md +1 -1
- package/docs/guides/AUTOMATIC_FEATURES.md +8 -0
- package/docs/guides/LOCAL_MODELS.md +1 -1
- package/docs/guides/PROXY.md +2 -1
- package/docs/guides/SANDBOX.md +91 -4
- package/docs/integrations/MCP_ROUTER.md +1 -1
- package/docs/plans/model-performance-proof-options.md +176 -0
- package/docs/reference/API.md +1 -1
- package/docs/reference/CLI.md +169 -4
- package/docs/reference/COMPARISON.md +1 -1
- package/docs/reference/CONFIGURATION.md +12 -3
- package/docs/reference/CONFIGURATION_REFERENCE.md +269 -14
- package/docs/reference/DATABASE_SCHEMA.md +1 -1
- package/docs/reference/FEATURES.md +15 -3
- package/docs/reference/PATTERNS.md +1 -1
- package/docs/reference/PLATFORMS.md +14 -1
- package/docs/specs/operator-model-placement.md +1314 -0
- package/docs/specs/operator-model-placement.review.md +339 -0
- package/package.json +2 -2
- package/scripts/maintenance/publish-npm.sh +23 -11
- package/scripts/version-bump.sh +6 -51
- package/src/policies/enforcers/__pycache__/_common.cpython-311.pyc +0 -0
- package/src/policies/enforcers/__pycache__/expert_review_required.cpython-311.pyc +0 -0
- package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-311.pyc +0 -0
- package/src/policies/schemas/policies/mandatory-testing-deployment.md +2 -5
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-311.pyc +0 -0
- package/templates/hooks/session-start.sh +44 -0
- package/tools/agents/UAP/cli.py +14 -3
- package/tools/agents/UAP/paths.py +50 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-311.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +1386 -99
- package/tools/agents/scripts/toolcall_path_normalizer.py +46 -1
- package/tools/agents/tests/test_anthropic_proxy_streaming.py +29 -6
- package/tools/agents/tests/test_doubling_break.py +64 -5
- package/tools/agents/tests/test_error_loop_break.py +10 -4
- package/tools/agents/tests/test_error_loop_ignores_correctives.py +13 -2
- package/tools/agents/tests/test_lockstep_escalation.py +280 -9
- package/tools/agents/tests/test_path_containment.py +94 -0
- package/tools/agents/tests/test_proxy_unix_socket.py +162 -0
- package/tools/agents/tests/test_signature_quality.py +349 -0
- package/tools/agents/tests/test_uap_compliance.py +11 -1
- package/web/dash/core.js +1 -0
- package/web/dash/tab-clients.js +196 -0
- package/web/dash/tab-models.js +387 -8
- package/web/dash/tab-policies.js +87 -4
- package/web/dash/tabs.js +77 -73
- package/web/dashboard.html +6 -0
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/__pycache__/expert_review_required.cpython-312.pyc +0 -0
- package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-312.pyc +0 -0
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/web/dash/tab-agents.js +0 -21
- package/web/dash/tab-deliver.js +0 -12
- package/web/dash/tab-memory.js +0 -12
- package/web/dash/tab-orchestration.js +0 -21
- package/web/dash/tab-overview.js +0 -21
- package/web/dash/tab-tasks.js +0 -21
|
@@ -0,0 +1,1314 @@
|
|
|
1
|
+
# Operator-Directed Model Placement (MOS + MPC)
|
|
2
|
+
|
|
3
|
+
Status: draft for review. Not implemented.
|
|
4
|
+
Scope: local inference placement — which model sits on which device, who is
|
|
5
|
+
allowed to ask for a different one, and what happens to everyone already
|
|
6
|
+
resident when the answer is no.
|
|
7
|
+
|
|
8
|
+
## 1. The problem
|
|
9
|
+
|
|
10
|
+
Model choice in UAP is a **deployment-time** decision, not a request-time one.
|
|
11
|
+
|
|
12
|
+
Today the operator picks a backend by hand. `~/.config/uap/model-switch.sh`
|
|
13
|
+
takes one of `flash | exl3 | signal | gsq | ampere | swift | strata`, disables
|
|
14
|
+
every other backend unit, starts the chosen one, and blocks on
|
|
15
|
+
`http://127.0.0.1:8080/health`. The units are mutually exclusive by
|
|
16
|
+
declaration — `uap-strata-server.service` carries:
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
Conflicts=uap-exl3-server.service uap-llama-server.service uap-ampere-server.service
|
|
20
|
+
uap-swift-server.service uap-signal-server.service uap-gsq-rco-server.service
|
|
21
|
+
uap-flashnext-server.service uap-dflash-server.service
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
So the card holds exactly one model at a time, and switching it is a manual,
|
|
25
|
+
blind act. Three consequences:
|
|
26
|
+
|
|
27
|
+
1. **A request can name a model the machine cannot load, and nothing says
|
|
28
|
+
so.** The proxy rewrites the wire model (`_reconcile_wire_model()`,
|
|
29
|
+
`tools/agents/scripts/anthropic_proxy.py:1681`, called at `:15036`) and
|
|
30
|
+
forwards. If the named model is not resident, the request either gets the
|
|
31
|
+
resident model or fails downstream, after the client has already paid the
|
|
32
|
+
round trip.
|
|
33
|
+
2. **Switching destroys work with no warning.** There is no view of who is
|
|
34
|
+
attached to the resident model, what they are doing, or how much context
|
|
35
|
+
they lose. The operator chooses blind.
|
|
36
|
+
3. **The cost of loading a second model is unknown at decision time.** The
|
|
37
|
+
numbers exist — but only as prose in profile comments.
|
|
38
|
+
`config/exl3-profiles/qwen38-27b-exl3.env:17` records
|
|
39
|
+
`VRAM resident: 20964 MiB / 24576 MiB`;
|
|
40
|
+
`config/llama-profiles/qwen38-27b-dflash2.env:37` records
|
|
41
|
+
`23449 / 24576 MiB` at `--parallel 2`;
|
|
42
|
+
`config/llama-profiles/gemma4-26b-a4b-mtp.env:38` records
|
|
43
|
+
`~20-21 GB, leaving headroom for the ub2048 compute buffers`.
|
|
44
|
+
Nothing machine-readable consumes them.
|
|
45
|
+
|
|
46
|
+
What is wanted: an agent (coding, video, image) asks UAP for a model path; the
|
|
47
|
+
system answers with what it *can* do on this hardware right now; the operator
|
|
48
|
+
chooses which models load on which devices; and a forced switch shows, before
|
|
49
|
+
it happens, exactly what gets killed or paused.
|
|
50
|
+
|
|
51
|
+
## 2. What already exists (verified)
|
|
52
|
+
|
|
53
|
+
Do not rebuild these. Placement composes on top of them.
|
|
54
|
+
|
|
55
|
+
| Concern | Existing mechanism |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| Declared service budgets, headroom, restart budgets | `config/capacity-policy.json`, `src/capacity/policy.ts` |
|
|
58
|
+
| Health semantics, fail-open vs fail-closed probes | `docs/guides/CAPACITY_POLICY.md`, `src/capacity/probe.ts` |
|
|
59
|
+
| Free VRAM probe | `probeGpuFreeMiB()` — **GPU 0 only** |
|
|
60
|
+
| Post-load config verification | `metricsMustMatch` (`{"kv":"int8","max_context":131072}` for `strata-server`) |
|
|
61
|
+
| Slot budget: env → config → probe → default | `src/utils/model-slots.ts` (`DEFAULT_SLOTS = 2`, 30s cache) |
|
|
62
|
+
| Cross-process slot semaphore, AIMD backpressure | `src/utils/model-slot-lease.ts`, `src/coordination/service.ts:1045-1174` |
|
|
63
|
+
| Lease persistence | `model_leases` table, `src/coordination/database.ts` |
|
|
64
|
+
| Local server discovery (no hardcoded port) | `src/utils/llama-discovery.ts` (`ss -ltnp`) |
|
|
65
|
+
| Inference quality, not just liveness | `uap inference health`, `src/inference/strata.ts` |
|
|
66
|
+
| Dashboard write authorization | `mutationAuthorized(req)` in `src/dashboard/server.ts` |
|
|
67
|
+
| Operator's current manual switch | `~/.config/uap/model-switch.sh` |
|
|
68
|
+
|
|
69
|
+
Two gaps that shape the design:
|
|
70
|
+
|
|
71
|
+
- **`model_backpressure` is one row** (`id = 1 CHECK`). Every target shares one
|
|
72
|
+
adaptive limit. Two models on one machine cannot have independent pressure.
|
|
73
|
+
- **`upstream_semaphore` is global**, sized from `PROXY_CONCURRENCY_LIMIT`
|
|
74
|
+
(default `1`). One card-wide gate for every upstream, whatever is resident.
|
|
75
|
+
|
|
76
|
+
## 3. Decisions taken
|
|
77
|
+
|
|
78
|
+
From the operator, before drafting:
|
|
79
|
+
|
|
80
|
+
1. **Surface:** a shared core with two front ends — TUI (`uap models`) and a
|
|
81
|
+
dashboard Models tab. Neither is authoritative; both read the same state.
|
|
82
|
+
2. **Trigger:** the proxy admission gate. Every client is covered — Claude
|
|
83
|
+
Code, Codex, OpenCode, UAP agents — because they all enter through
|
|
84
|
+
`:4000/v1`. A gate in the TypeScript router alone would cover only UAP's
|
|
85
|
+
own callers.
|
|
86
|
+
3. **Eviction:** prompt first. Never evict without an explicit yes. Auto mode
|
|
87
|
+
may load alongside; it may never displace.
|
|
88
|
+
4. **Devices:** multi-device abstraction from the start — GPU0, GPU1, CPU
|
|
89
|
+
offload tier, each with its own budget. Single-GPU is the degenerate case,
|
|
90
|
+
not the model.
|
|
91
|
+
|
|
92
|
+
Eight more were settled while reviewing this draft. Each one changed the design
|
|
93
|
+
rather than just the wording:
|
|
94
|
+
|
|
95
|
+
5. **Registry location:** repo defaults **merged with** machine-local overrides.
|
|
96
|
+
`config/model-registry.json` is the shipped template; `~/.uap/model-registry.json`
|
|
97
|
+
carries this machine's measured numbers and wins on conflict. The measured
|
|
98
|
+
figures in this spec are machine state, so they cannot live only in the repo.
|
|
99
|
+
6. **Enforcement host:** a TypeScript module mounted on the **dashboard server**,
|
|
100
|
+
reached by the proxy over localhost HTTP. Not a new daemon, because the
|
|
101
|
+
lifecycle helper already starts, reference-counts, and hands the proxy
|
|
102
|
+
`UAP_DASHBOARD_URL`.
|
|
103
|
+
7. **Alternate configs are not measured now.** Every non-active config ships with
|
|
104
|
+
`status: "unmeasured"` and is refused until `uap models measure` fills it in.
|
|
105
|
+
Measuring them means taking the live backend down.
|
|
106
|
+
8. **`paused` means offload and reload from disk later.** It is not the proxy's
|
|
107
|
+
existing KV checkpoint, which saves KV to host RAM and frees no VRAM. §4.5
|
|
108
|
+
states what this machine can actually do about that today.
|
|
109
|
+
|
|
110
|
+
## 4. Architecture
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
┌──────────────────────────────────────────┐
|
|
114
|
+
client request ──►│ proxy admission gate (anthropic_proxy.py)│
|
|
115
|
+
│ before _reconcile_wire_model() │
|
|
116
|
+
└───────┬──────────────────────────┬───────┘
|
|
117
|
+
│ resident fits │ needs a decision
|
|
118
|
+
▼ ▼
|
|
119
|
+
forward upstream POST /api/placement/* (core)
|
|
120
|
+
│
|
|
121
|
+
┌───────────────────┼──────────────────┐
|
|
122
|
+
▼ ▼ ▼
|
|
123
|
+
model registry placement ledger enforcement
|
|
124
|
+
(cost, static) (live state, (systemd +
|
|
125
|
+
fail-closed) pending requests) verify)
|
|
126
|
+
│ │ │
|
|
127
|
+
└──── TUI (uap models) + dashboard Models tab
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The core is a library (`src/placement/`) hosted by the dashboard server, not a
|
|
131
|
+
new daemon. Decision 2 in §9 forces this: the proxy calls a loopback control
|
|
132
|
+
endpoint, so some process must be alive to answer it, and that process is the
|
|
133
|
+
one that owns the ledger. The CLI talks to the same server over loopback HTTP;
|
|
134
|
+
when the server is down, `uap models status` reads the ledger file directly and
|
|
135
|
+
says so, but `apply` refuses rather than racing a live writer. No new
|
|
136
|
+
long-lived process: the dashboard server is already running, and the proxy is
|
|
137
|
+
already the only component that sees every request.
|
|
138
|
+
|
|
139
|
+
### 4.1 Model registry — `config/model-registry.json`
|
|
140
|
+
|
|
141
|
+
Machine-readable cost per **config**, replacing the prose in profile comments.
|
|
142
|
+
Two files, merged. `config/model-registry.json` in the repo carries the shape:
|
|
143
|
+
devices, models, config names, advertised ids, task affinity. `~/.uap/model-registry.json`
|
|
144
|
+
on the machine carries the measurements, because a footprint is a property of
|
|
145
|
+
*this hardware*, not of the repo, and a second box with a different card must
|
|
146
|
+
not inherit this box's numbers. The machine-local file overrides per config; a
|
|
147
|
+
config that appears only in the repo file is `unmeasured`. `uap models validate`
|
|
148
|
+
prints which file each entry came from, so a wrong number is at least
|
|
149
|
+
attributable.
|
|
150
|
+
|
|
151
|
+
Cost is per config, not per model: the same weights cost 21.8 GiB under Strata
|
|
152
|
+
with `--kv-resident 32768` and a different amount under a different pack or
|
|
153
|
+
context size. The entry below is **measured on this machine**, not estimated:
|
|
154
|
+
|
|
155
|
+
```json
|
|
156
|
+
{
|
|
157
|
+
"version": 1,
|
|
158
|
+
"devices": {
|
|
159
|
+
"gpu0": {
|
|
160
|
+
"kind": "gpu", "name": "NVIDIA GeForce RTX 3090", "total_mib": 24576,
|
|
161
|
+
"reserved_mib": 2048,
|
|
162
|
+
"reserve_reason": "1516 MiB attributed to desktop compute processes (Xorg 1227, Chrome 214, Steam 75) + ~900-1000 MiB of graphics memory that --query-compute-apps does not attribute to any PID; permanent while the desktop is up"
|
|
163
|
+
},
|
|
164
|
+
"cpu0": {
|
|
165
|
+
"kind": "cpu", "total_mib": 126944, "reserved_mib": 8192,
|
|
166
|
+
"reserve_reason": "page cache and the TS side must survive a 52 GiB engine attach"
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
"models": {
|
|
170
|
+
"qwen3.8-flash-next": {
|
|
171
|
+
"display": "Qwen3.8 Flash Next",
|
|
172
|
+
"engine": "strata",
|
|
173
|
+
"unit": "uap-strata-server",
|
|
174
|
+
"launch": "/home/cogtek/dev/strata/run-iq3_xxs.sh",
|
|
175
|
+
"endpoint": "http://127.0.0.1:8080",
|
|
176
|
+
"rails": 1,
|
|
177
|
+
"rails_source": "metrics:expert_slots",
|
|
178
|
+
"task_affinity": ["code", "general"],
|
|
179
|
+
"affinity": { "device": ["gpu0"] },
|
|
180
|
+
"configs": {
|
|
181
|
+
"strata-iq3_s": {
|
|
182
|
+
"resident_gpu_mib": 21812,
|
|
183
|
+
"host_rss_mib": 52857,
|
|
184
|
+
"context_pool_cells": 131072,
|
|
185
|
+
"per_session_cap_cells": 114688,
|
|
186
|
+
"kv_resident_cells": 32768,
|
|
187
|
+
"kv_mib_per_1k_cells": 12.2,
|
|
188
|
+
"measured_at": "2026-10-08T14:02:00Z",
|
|
189
|
+
"measured_on": "gpu0",
|
|
190
|
+
"flags": ["--max-context", "131072", "--kv", "int8", "--kv-resident", "32768", "--vram-reserve-mib", "1200"]
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
"advertises": ["qwen3.8-flash-next-iq3_s", "gpt-5.1-codex"]
|
|
194
|
+
},
|
|
195
|
+
"qwen3.8-27b": {
|
|
196
|
+
"display": "Qwen3.8 27B",
|
|
197
|
+
"engine": "llama.cpp",
|
|
198
|
+
"unit": "uap-llama-server",
|
|
199
|
+
"profile": "config/llama-profiles/qwen38-27b-mtp.env",
|
|
200
|
+
"endpoint": "http://127.0.0.1:8080",
|
|
201
|
+
"task_affinity": ["code", "general"],
|
|
202
|
+
"affinity": { "device": ["gpu0"] },
|
|
203
|
+
"configs": {
|
|
204
|
+
"llama-mtp": { "status": "unmeasured" },
|
|
205
|
+
"exl3-4bit": { "status": "unmeasured" },
|
|
206
|
+
"gsq-rco": { "status": "unmeasured" }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
The second entry is the honest state of every alternate on this machine: the
|
|
214
|
+
units exist, the profiles exist, the VRAM prose exists in the profile comments,
|
|
215
|
+
and none of it is machine-readable. They ship as `status: "unmeasured"` and stay
|
|
216
|
+
that way until `uap models measure` fills them in, which requires taking the
|
|
217
|
+
active backend down.
|
|
218
|
+
|
|
219
|
+
`resident_gpu_mib` is the whole-config footprint attributed to the **engine's own
|
|
220
|
+
PID** (`nvidia-smi --query-compute-apps=pid,used_memory`), taken with the server
|
|
221
|
+
idle. It is not weights plus overhead: as §4.1.1 shows, this engine streams
|
|
222
|
+
expert weights through an elastic VRAM cache, so there is no honest weights
|
|
223
|
+
number to add to an overhead number. Admission cost is the resident figure plus
|
|
224
|
+
marginal KV beyond the pool the config already paid for at startup:
|
|
225
|
+
|
|
226
|
+
```
|
|
227
|
+
cost(config, cells) = resident_gpu_mib
|
|
228
|
+
+ max(0, cells - kv_resident_cells) / 1000 × kv_mib_per_1k_cells
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
For the live config, `cells ≤ 32768` costs exactly 21,812 MiB — the KV pool is
|
|
232
|
+
already resident. Requesting the full 114,688-cell session cap costs
|
|
233
|
+
21,812 + 81,920/1000 × 12.2 ≈ **22,810 MiB**, which exceeds the card. That is
|
|
234
|
+
not a rounding error; it is the reason the profile caps sessions at 114,688
|
|
235
|
+
against a 131,072 pool and why the pool itself is the constraint the operator
|
|
236
|
+
is really trading.
|
|
237
|
+
|
|
238
|
+
Rules:
|
|
239
|
+
|
|
240
|
+
- **Unknown cost fails closed.** A config with no `measured_at` is not
|
|
241
|
+
placeable. It may be served if already resident; it may never be loaded, and
|
|
242
|
+
it never enters an eviction candidate set. Guessing a footprint is how a
|
|
243
|
+
24 GiB card gets OOM-killed. `status: "unmeasured"` is the shipped default for
|
|
244
|
+
every config that has not been measured, so the registry can describe the
|
|
245
|
+
machine's full option set without making any claim it has not verified.
|
|
246
|
+
- **`measured_on` gates reuse.** A footprint measured on `gpu0` (24 GiB, sm_86)
|
|
247
|
+
is not valid for another device class or a different engine build. A stale or
|
|
248
|
+
mismatched measurement is treated as unknown.
|
|
249
|
+
- **Host RAM gates placement too.** `host_rss_mib` is checked against host
|
|
250
|
+
availability alongside `resident_gpu_mib`; §4.1.1 is why.
|
|
251
|
+
- **Cross-check against `config/capacity-policy.json`.** `uap models validate`
|
|
252
|
+
fails if a registry entry contradicts the policy it should agree with:
|
|
253
|
+
`metricsMustMatch` (`kv`, `max_context`), the launch flags in
|
|
254
|
+
`execStartMustContain` (`-c 229376`, `-np 2`, `--vbr-vram 5120M`,
|
|
255
|
+
`--cache-ram 32768` for `gsq-rco-server`), and the declared headroom
|
|
256
|
+
(`gpuMinFreeMiB: 200` for `strata-server`). One source of truth per number;
|
|
257
|
+
the registry references the policy, it does not restate it.
|
|
258
|
+
- **`rails` is not the model's concurrency claim.** `config/model-profiles/qwen38.json`
|
|
259
|
+
says `concurrency: 1` because Strata exposes one rail, while
|
|
260
|
+
`config/llama-profiles/qwen38-27b-mtp.env` sets `LLAMA_PARALLEL=2`. The
|
|
261
|
+
registry states it per config, cross-checked against a live probe. A mismatch
|
|
262
|
+
is surfaced, never silently resolved — `src/utils/model-slots.ts:13` falls
|
|
263
|
+
back to `DEFAULT_SLOTS = 2` when the probe fails, which is **safe for
|
|
264
|
+
llama.cpp and unsafe for Strata**: handing two concurrent requests to a
|
|
265
|
+
1-rail server is exactly the overflow the profile warns about. Rail discovery
|
|
266
|
+
must become backend-aware before the registry can be trusted to allocate.
|
|
267
|
+
|
|
268
|
+
**Ownership — the registry must not become a fifth source of truth.** Endpoints
|
|
269
|
+
already live in four places: `src/models/types.ts:229` pins the live-backend
|
|
270
|
+
alias with a comment explaining why pinning more would "turn a backend switch
|
|
271
|
+
into an outage"; `config/model-profiles/`; `config/llama-profiles/`; and
|
|
272
|
+
`config/capacity-policy.json`. The registry therefore **references** rather
|
|
273
|
+
than restates, and `models validate` fails on a contradiction:
|
|
274
|
+
|
|
275
|
+
| Number | Owner | Registry holds |
|
|
276
|
+
| --- | --- | --- |
|
|
277
|
+
| Endpoint the proxy forwards to | proxy env (`LLAMA_CPP_BASE`) | read-only echo for display, never consumed by routing |
|
|
278
|
+
| Liveness + budget + metrics identity | `config/capacity-policy.json` (`services` is a **list** keyed by `name` — lookups must not assume a map) | the service `name` it cross-checks against |
|
|
279
|
+
| Launch flags per llama profile | `config/llama-profiles/*.env` | the profile path |
|
|
280
|
+
| Advertised model ids + session caps | `config/model-profiles/*.json` | cross-check only |
|
|
281
|
+
| Measured footprints (`resident_gpu_mib`, `host_rss_mib`, KV geometry) | `~/.uap/model-registry.json` | the only owner |
|
|
282
|
+
| Device totals + `reserved_mib` | `~/.uap/model-registry.json` | the only owner |
|
|
283
|
+
|
|
284
|
+
#### 4.1.1 What measurement actually returns (measured on this host)
|
|
285
|
+
|
|
286
|
+
The naive recipe — file size for weights, `nvidia-smi` used-minus-weights for
|
|
287
|
+
overhead — is wrong on both counts, and the numbers below are why.
|
|
288
|
+
|
|
289
|
+
Current live config: `strata-iq3_s.json`, engine `strata`, pack `iq3_s`,
|
|
290
|
+
`--max-context 131072 --kv int8 --kv-resident 32768 --vram-reserve-mib 1200
|
|
291
|
+
--spec 8 --mtp rt --expert-cache auto`.
|
|
292
|
+
|
|
293
|
+
| Quantity | Measured | Source |
|
|
294
|
+
| --- | ---: | --- |
|
|
295
|
+
| GPU total | 24,576 MiB | `nvidia-smi` |
|
|
296
|
+
| GPU used, engine pid 246788 | 21,812 MiB | `nvidia-smi compute-apps` |
|
|
297
|
+
| GPU used, non-backend processes | 290–1,516 MiB, varies by sample | same |
|
|
298
|
+
| GPU free | 245–975 MiB, drifting | `nvidia-smi`, `/metrics` |
|
|
299
|
+
| Engine host RSS | 52,857 MiB | `ps -o rss` |
|
|
300
|
+
| Server (python) RSS | 247 MiB | `ps -o rss` |
|
|
301
|
+
| Host RAM used / total | 92 GiB / 121 GiB | `/metrics` |
|
|
302
|
+
| GGUF on disk (native + PLE) | 52 GiB + 27 GiB | `ls -lh` |
|
|
303
|
+
| MTP draft on disk | 1.1 GiB | `ls -lh` |
|
|
304
|
+
| `expert_slots` | 7,736 | `/metrics` |
|
|
305
|
+
| `expert_cache_mib` | 15,017 MiB | `/metrics` |
|
|
306
|
+
| `arena_mib` | 47,962 MiB | `/metrics` |
|
|
307
|
+
| `vram_free_mib` | 917 MiB | `/metrics` |
|
|
308
|
+
|
|
309
|
+
Four conclusions the registry schema has to absorb:
|
|
310
|
+
|
|
311
|
+
1. **Disk size is not a proxy for anything.** The GGUFs total 79 GiB; the GPU
|
|
312
|
+
footprint is 21.8 GiB. Expert weights are streamed from disk into a
|
|
313
|
+
15 GiB VRAM cache sized by what is left over (`--expert-cache auto`). So no
|
|
314
|
+
pinned-weights figure can come from `ls`, and none can come from the engine
|
|
315
|
+
either: the pinned/cache split is not derivable from the fields Strata
|
|
316
|
+
reports. The registry therefore stores the whole-config **resident** figure
|
|
317
|
+
and nothing else.
|
|
318
|
+
2. **The engine preallocates at startup.** `--vram-reserve-mib 1200` and
|
|
319
|
+
`--expert-cache auto` mean the process claims the card at load time. Free
|
|
320
|
+
VRAM after a load is a leftover, not a budget — which is why "free + what
|
|
321
|
+
the victim frees" is the only meaningful headroom question, and why
|
|
322
|
+
`load_alongside` is effectively unavailable on this host right now.
|
|
323
|
+
3. **Host RAM is a first-class constraint, not a footnote.** The engine holds
|
|
324
|
+
52.8 GiB of RSS (mmap'd GGUFs) against 121 GiB total with 92 GiB used. A
|
|
325
|
+
placement decision that fits VRAM and blows the page cache is a bad
|
|
326
|
+
decision. `host_rss_mib` therefore gates placement alongside
|
|
327
|
+
`resident_gpu_mib`: a candidate is admissible on a device only if that
|
|
328
|
+
device's measured host free RAM covers the candidate's RSS. (At measure time
|
|
329
|
+
the model is already resident, so the check belongs to admission, not
|
|
330
|
+
measurement — `measure` records the footprint and the host state it saw.)
|
|
331
|
+
4. **The desktop is a permanent tenant, and it is bigger than the floor
|
|
332
|
+
assumes.** Non-backend GPU use measured 290–1,516 MiB across samples: one
|
|
333
|
+
sample showed Xorg 1,227 + Chrome 214 + Steam 75 = 1,516 MiB with 975 MiB
|
|
334
|
+
free; another showed only Warp 206 + nautilus 52 + the Logi overlay 24 +
|
|
335
|
+
snapd-desktop-integration 8 = 290 MiB, with `memory.used` at 23,105 MiB
|
|
336
|
+
against 21,812 + 290 = 22,102 accounted, leaving ~1,003 MiB attributed to no
|
|
337
|
+
PID at all. So `--query-compute-apps` is not a reliable census of GPU
|
|
338
|
+
consumers — Xorg appears in it sometimes and not others. The robust
|
|
339
|
+
quantity is the *unattributed remainder*, and `uap models measure` should
|
|
340
|
+
derive `reserved_mib` as `device.capacity_mib − free − Σ(compute apps that
|
|
341
|
+
are UAP backends)`, which captures graphics memory whether or not it is
|
|
342
|
+
attributed to a PID. Call it 1–1.5 GiB, about 5% of the card, permanently
|
|
343
|
+
unavailable to any backend. `config/capacity-policy.json` sets
|
|
344
|
+
`gpuMinFreeMiB: 200` for `strata-server`, which is why the doctor reports
|
|
345
|
+
GREEN at 245 MiB free: the policy's floor is smaller than the tenant it is
|
|
346
|
+
meant to protect. The placement layer must not inherit that number; it needs
|
|
347
|
+
its own measured per-device `reserved_mib`.
|
|
348
|
+
|
|
349
|
+
So `uap models measure` records, per *config*: `resident_gpu_mib` and
|
|
350
|
+
`host_rss_mib` from the live process, `context_pool_cells` /
|
|
351
|
+
`kv_resident_cells` / KV dtype from `/metrics`, and `expert_cache_mib` +
|
|
352
|
+
`arena_mib` where the engine reports them. Those last two are diagnostic only:
|
|
353
|
+
the admission cost uses the whole-config resident figure, because splitting an
|
|
354
|
+
elastic expert cache into "pinned" and "streamed" halves does not make the
|
|
355
|
+
decision any safer. Where an engine reports no cache, no split is invented.
|
|
356
|
+
|
|
357
|
+
`measure` refuses to write a footprint when the reading is not trustworthy:
|
|
358
|
+
engine PID not found, `resident_gpu_mib` below a sanity floor, a compute-apps
|
|
359
|
+
reading that includes another backend's allocation, or a device whose derived
|
|
360
|
+
`reserved_mib` exceeds its own capacity. It records `measured_on`,
|
|
361
|
+
`measured_at`, and the host free RAM it saw alongside every number, and
|
|
362
|
+
`validate` fails an entry whose measurement predates the unit's current
|
|
363
|
+
`ExecStart`.
|
|
364
|
+
|
|
365
|
+
### 4.2 Placement ledger — `~/.uap/placement.json`
|
|
366
|
+
|
|
367
|
+
Live state. Written under an advisory lock; every writer re-reads before
|
|
368
|
+
committing so a stale view cannot clobber.
|
|
369
|
+
|
|
370
|
+
```json
|
|
371
|
+
{
|
|
372
|
+
"version": 1,
|
|
373
|
+
"updated_at": "2026-10-08T14:02:11Z",
|
|
374
|
+
"devices": {
|
|
375
|
+
"gpu0": { "kind": "gpu", "total_mib": 24576, "free_mib": 1016,
|
|
376
|
+
"reserved_mib": 1293, "source": "nvidia-smi" },
|
|
377
|
+
"gpu1": { "kind": "gpu", "total_mib": 0, "free_mib": 0,
|
|
378
|
+
"headroom_mib": 0, "source": "absent" },
|
|
379
|
+
"cpu0": { "kind": "cpu", "total_mib": 126944, "free_mib": 30424,
|
|
380
|
+
"headroom_mib": 8192, "source": "MemAvailable" }
|
|
381
|
+
},
|
|
382
|
+
"residents": [
|
|
383
|
+
{ "model": "qwen3.8-flash-next-iq3_s", "config": "strata-iq3_s",
|
|
384
|
+
"device": "gpu0", "engine": "strata",
|
|
385
|
+
"endpoint": "http://127.0.0.1:8080", "unit": "uap-strata-server",
|
|
386
|
+
"state": "hot", "gpu_mib": 21812, "host_rss_mib": 52857,
|
|
387
|
+
"holders": [], "since": "2026-10-08T09:14:02Z" }
|
|
388
|
+
],
|
|
389
|
+
"pending": [
|
|
390
|
+
{ "id": "plc-7f2a", "requested_model": "qwen3.8-27b", "client": "claude-code",
|
|
391
|
+
"session": "sess-…", "pid": 41122, "created_at": "…", "expires_at": "…",
|
|
392
|
+
"options": [ … ], "reason": "not_resident" }
|
|
393
|
+
]
|
|
394
|
+
}
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
`free_mib` for `gpu0` is what `nvidia-smi` reports right now (1,016 MiB at the
|
|
398
|
+
time of writing; observed range 245–1,016 as the engine's expert cache grows
|
|
399
|
+
and shrinks), not what would be free after a victim is drained — see §4.4.
|
|
400
|
+
|
|
401
|
+
Host RAM is a placement constraint in its own right, which is why `cpu0` is a
|
|
402
|
+
device and not a footnote. `free -m` on this box: 123.8 GiB total, 89.1 GiB
|
|
403
|
+
used, 29.7 GiB available. The Strata engine's RSS is 52.9 GiB (VmPeak 54.4
|
|
404
|
+
GiB) because mmap'd GGUF pages count toward RSS, and the expert cache is
|
|
405
|
+
15,017 MiB of it. Two consequences:
|
|
406
|
+
|
|
407
|
+
- A CPU-offload candidate costs host RAM, so `cpu0.free_mib` is the budget and
|
|
408
|
+
`headroom_mib` must stay large enough for the proxy, the dashboard, and page
|
|
409
|
+
cache. Loading a second large model host-side would push this machine into
|
|
410
|
+
swap, which is a worse failure than refusing the request.
|
|
411
|
+
- `load_alongside` must check **both** devices. A candidate that fits the GPU
|
|
412
|
+
but not host RAM is infeasible, and the reason string must name the device
|
|
413
|
+
that failed rather than reporting a generic "insufficient memory".
|
|
414
|
+
|
|
415
|
+
Resident states: `hot` (serving), `warming` (loading, not yet accepting),
|
|
416
|
+
`paused` (offloaded, reloadable from disk — see below), `draining` (marked
|
|
417
|
+
for removal, refusing new requests, finishing in-flight).
|
|
418
|
+
|
|
419
|
+
`free_mib` is refreshed on read from `probeGpuFreeMiB()` — never trusted from
|
|
420
|
+
the last writer's snapshot. The ledger records intent; the probe records fact.
|
|
421
|
+
When they disagree, the probe wins and the disagreement is surfaced.
|
|
422
|
+
|
|
423
|
+
#### What `paused` is, and what it is not
|
|
424
|
+
|
|
425
|
+
Per Decision 4 in §9, `paused` means **the config's weights are offloaded and
|
|
426
|
+
will be reloaded from disk on resume**. It is not the proxy's existing
|
|
427
|
+
save/restore plumbing (`PROXY_SLOT_SAVE_RESTORE`,
|
|
428
|
+
`/slots/{id}?action=save|restore`), which is the right mechanism for a
|
|
429
|
+
different problem and the wrong answer here:
|
|
430
|
+
|
|
431
|
+
- A saved slot is a **host-RAM** artifact. It frees no VRAM, so pausing that
|
|
432
|
+
way cannot make room for a model that does not fit.
|
|
433
|
+
- The save path is single-threaded and slow: 10–20 s per slot at 128k context.
|
|
434
|
+
A pause that takes longer than a model load is not a pause.
|
|
435
|
+
- With 29.3 GiB of host RAM free against a 52.9 GiB resident process, there
|
|
436
|
+
may be no headroom for a checkpoint at all.
|
|
437
|
+
|
|
438
|
+
So a paused entry records the config's offload location and is resumable by
|
|
439
|
+
reloading from disk. No engine on this host has that reload path today: Strata
|
|
440
|
+
claims the card at startup (`--expert-cache auto`, `--vram-reserve-mib 1200`)
|
|
441
|
+
and has no in-process unload. Until one exists, `paused` is a **display state
|
|
442
|
+
the ledger can hold but the executor cannot produce**, and the only executable
|
|
443
|
+
option in the preview is `kill`. The preview must label which one it is
|
|
444
|
+
offering, because "pause" sounds free and currently is not.
|
|
445
|
+
|
|
446
|
+
### 4.3 Admission gate — in the proxy
|
|
447
|
+
|
|
448
|
+
The gate runs **before** the wire model is rewritten and **before** anything is
|
|
449
|
+
sent upstream, and it decides on **the model the client asked for** — the id
|
|
450
|
+
that needs a placement decision. (An earlier draft gated after
|
|
451
|
+
`_reconcile_wire_model()` on its rewritten value; that ordering defeats the
|
|
452
|
+
gate: a non-resident id is silently rewritten to `ids[0]` before the gate ever
|
|
453
|
+
sees it, and the gate forwards exactly what it exists to park. Implemented the
|
|
454
|
+
other way, and in `mode=ask|auto` `_reconcile_wire_model()` skips its silent
|
|
455
|
+
rewrite entirely — an id the upstream does not serve is the operator's decision,
|
|
456
|
+
never an automatic substitution. `mode=off` keeps the rewrite, byte-identical.)
|
|
457
|
+
|
|
458
|
+
But **it is not unbypassable, and an earlier draft claimed it was.**
|
|
459
|
+
`_reconcile_wire_model` has exactly one call site, on `/v1/messages`. The proxy
|
|
460
|
+
also sends upstream from `/anthropic/v1/messages` (`:15743`),
|
|
461
|
+
`/v1/chat/completions` (`:15832`), `/api/chat` (`:16618`), and `/api/generate`
|
|
462
|
+
(`:16655`), plus the passthrough client (`_pt_client`, `:14473`) and the
|
|
463
|
+
save/restore client (`_sr_client`, `:13823`). The implemented gate is a
|
|
464
|
+
function of the requested model, and it is reached because every non-exempt
|
|
465
|
+
route funnels through the `/v1/messages` handler (the OpenAI and Ollama
|
|
466
|
+
surfaces run a synthetic request through `messages()` via
|
|
467
|
+
`_guarded_openai_completion`):
|
|
468
|
+
|
|
469
|
+
- `/v1/messages` — the gate, placed immediately before
|
|
470
|
+
`_reconcile_wire_model()`. Every named client (Claude Code, Codex, OpenCode,
|
|
471
|
+
UAP agents) enters here; this is the phase-2 deliverable.
|
|
472
|
+
- `/v1/chat/completions`, `/anthropic/v1/messages` — same handler; a park
|
|
473
|
+
surfaces in the OpenAI error dialect with the placement id and the
|
|
474
|
+
`uap models pending` resolve path.
|
|
475
|
+
- `/api/chat`, `/api/generate` (Ollama-compatible) — same handler; a park
|
|
476
|
+
surfaces in the Ollama error dialect the same way.
|
|
477
|
+
- Passthrough (`_pt_client`) — exempt by construction: it returns before the
|
|
478
|
+
gate, the model is cloud, it has no local cost, and it never reaches
|
|
479
|
+
`:8080`.
|
|
480
|
+
|
|
481
|
+
The §7 test list includes a test that each non-exempt route, in `mode=ask`,
|
|
482
|
+
parks rather than forwards when the requested local model is not resident.
|
|
483
|
+
"Gate the choke point" was the right instinct; "the choke point covers
|
|
484
|
+
everything" was a false statement about 17k lines and is now corrected.
|
|
485
|
+
|
|
486
|
+
Decision order:
|
|
487
|
+
|
|
488
|
+
| Condition | Action |
|
|
489
|
+
| --- | --- |
|
|
490
|
+
| Resolved model already resident and satisfies the request | forward |
|
|
491
|
+
| Cloud preset (no local cost) | forward, no placement decision |
|
|
492
|
+
| In registry, fits in free − reserve, `mode=auto` | controller auto-loads alongside, then forward (§4.4.1; displacing only for allowlisted models) |
|
|
493
|
+
| In registry, fits in free − reserve, `mode=ask` | park, ask operator |
|
|
494
|
+
| In registry, fits only by displacing | park, ask operator with impact preview |
|
|
495
|
+
| Not in registry | park, ask operator; **never auto-load** |
|
|
496
|
+
| `mode=off` | current behavior, byte-identical |
|
|
497
|
+
|
|
498
|
+
Parked requests return `409` with a machine-readable body (Anthropic error
|
|
499
|
+
shape so every gated client parses it with the code it already has; the
|
|
500
|
+
OpenAI/Ollama surfaces carry the same placement id and resolve path in their
|
|
501
|
+
own dialects), plus a `Retry-After: 2` header:
|
|
502
|
+
|
|
503
|
+
```json
|
|
504
|
+
{ "type": "error",
|
|
505
|
+
"error": { "type": "model_placement_pending",
|
|
506
|
+
"message": "model qwen3.8-27b is not loaded; resolve with `uap models pending` (placement plc-7f2a)",
|
|
507
|
+
"placement_id": "plc-7f2a",
|
|
508
|
+
"requested_model": "qwen3.8-27b",
|
|
509
|
+
"reason": "not_resident",
|
|
510
|
+
"retry_after_ms": 2000,
|
|
511
|
+
"resolve_with": "uap models pending | uap models apply <placement_id> <option> | dashboard Models tab" } }
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
Contract notes (api-designer sign-off, 2026-10-08 — verdict: approve with
|
|
515
|
+
changes, all applied):
|
|
516
|
+
|
|
517
|
+
- `retry_after_ms` (and the header) are a re-poll **floor**, not an expected
|
|
518
|
+
resolution time — resolution is operator-bound; both dedupe windows make a
|
|
519
|
+
short re-poll cheap, and a resolved placement serves the next retry.
|
|
520
|
+
- `message` is the one field every Anthropic SDK surface renders to the
|
|
521
|
+
human; the operator instructions live there. The OpenAI/Ollama dialects
|
|
522
|
+
prefer this message over re-derived copy.
|
|
523
|
+
- `resolve_with` is human-renderable text, display-only, **no stability
|
|
524
|
+
guarantee** — machine consumers key on `type`, `placement_id`, `reason`
|
|
525
|
+
only.
|
|
526
|
+
- `reason` is machine-computed, never guessed: `unknown_model`,
|
|
527
|
+
`no_measured_config` (fail closed), `not_resident` (loadable, not loaded),
|
|
528
|
+
`auto_loading` (phase 4: the controller is loading the model itself — the
|
|
529
|
+
client's retry IS the protocol, no operator step exists, so the `message`
|
|
530
|
+
deliberately says so instead of pointing at `uap models pending`),
|
|
531
|
+
`auto_failed` (phase 4: the auto attempt failed; the pending entry survives
|
|
532
|
+
marked with this reason and the operator resolves it like any other park),
|
|
533
|
+
`unroutable_target` (the controller vouched for a resident on another
|
|
534
|
+
endpoint; this proxy posts to one discovered upstream, so the id is not
|
|
535
|
+
sent to it raw), `no_controller` (read-only admission with no reachable
|
|
536
|
+
controller — the resolution is "start the dashboard / set
|
|
537
|
+
PROXY_PLACEMENT_CONTROLLER", not a placement decision), or
|
|
538
|
+
`invalid_request` (controller-side: missing model_id). New values may be
|
|
539
|
+
added additively.
|
|
540
|
+
- `placement_id` is 24 bits within a 120s dedupe window: ~3e-6 collision at
|
|
541
|
+
~10 concurrent pendings. Widen only if pending volume exceeds ~100 per
|
|
542
|
+
window.
|
|
543
|
+
|
|
544
|
+
`409` rather than a hang: the client keeps control, the proxy holds no socket
|
|
545
|
+
open across an unbounded operator decision, and a client that understands the
|
|
546
|
+
code can fall back on its own. `PROXY_PLACEMENT_HOLD_SECS` optionally holds the
|
|
547
|
+
connection for a bounded window first (capped 30s), for clients too dumb to
|
|
548
|
+
retry.
|
|
549
|
+
|
|
550
|
+
Pending requests dedupe on `(client, requested_model)`: ten concurrent Claude
|
|
551
|
+
Code requests for the same unloaded model produce one operator prompt, not ten.
|
|
552
|
+
Two windows, deliberately different: the proxy's own dedupe map is a **short
|
|
553
|
+
burst window** (~5s, enough to coalesce a burst that arrives in milliseconds)
|
|
554
|
+
so a resolved placement forwards on the very next request, while the
|
|
555
|
+
controller's ledger pending entry dedupes for **120s** — the operator-prompt
|
|
556
|
+
window. The controller re-checks the ledger and answers `forward` the moment a
|
|
557
|
+
live resident serves the id, so the long window never outlives a resolution.
|
|
558
|
+
|
|
559
|
+
Admission is two-tier. With `PROXY_PLACEMENT_CONTROLLER` set (emitted by
|
|
560
|
+
`uap setup` as the co-located dashboard, loopback), the proxy asks it: the
|
|
561
|
+
controller owns the registry + ledger view, answers `forward` with the
|
|
562
|
+
resident's target id (which keys the proxy's per-target concurrency budget),
|
|
563
|
+
or answers `park` and writes the pending entry. Without a controller, or when
|
|
564
|
+
it does not answer within its short timeout, the proxy degrades to read-only
|
|
565
|
+
admission from its own cached upstream ids: an id the upstream serves
|
|
566
|
+
forwards; an id it does not serve parks (`not_resident`); no knowledge
|
|
567
|
+
(unprobed cache) forwards — the same fail-open direction
|
|
568
|
+
`_reconcile_wire_model` takes with the same cache.
|
|
569
|
+
|
|
570
|
+
Environment: `PROXY_PLACEMENT_MODE=off|ask|auto` (default `off`;
|
|
571
|
+
`auto` is accepted and treated as `ask` until phase-3 enforcement exists),
|
|
572
|
+
`PROXY_PLACEMENT_CONTROLLER` (default empty → read-only admission),
|
|
573
|
+
`PROXY_PLACEMENT_HOLD_SECS` (default `0`).
|
|
574
|
+
|
|
575
|
+
#### The proxy cannot load a model today
|
|
576
|
+
|
|
577
|
+
This is the largest piece of new machinery and the spec should not imply
|
|
578
|
+
otherwise. `anthropic_proxy.py` has no subprocess spawn, no systemd call, and
|
|
579
|
+
no model lifecycle anywhere in its 17,000 lines. It forwards, and it discovers
|
|
580
|
+
what is already listening (`_discover_local_llama_base()` via `ss -ltnp`).
|
|
581
|
+
Model switching is a shell script an operator runs by hand.
|
|
582
|
+
|
|
583
|
+
So enforcement is a new module, `src/placement/enforce.ts`, invoked by the
|
|
584
|
+
proxy over a local control socket (or by the CLI/dashboard directly), which:
|
|
585
|
+
|
|
586
|
+
1. drains victims (§4.5),
|
|
587
|
+
2. `systemctl --user stop <victim unit>`,
|
|
588
|
+
3. polls `nvidia-smi` until the expected MiB are actually released — with a
|
|
589
|
+
timeout, because a process that lingers holds the card,
|
|
590
|
+
4. `systemctl --user start <new unit>` with the profile's flags,
|
|
591
|
+
5. polls `/health`, then `/slots`, then `/metrics`, applying the capacity
|
|
592
|
+
policy's `metricsMustMatch` so a server that came up with the wrong KV
|
|
593
|
+
quant or context is caught rather than served,
|
|
594
|
+
6. writes the ledger, releases the pending request,
|
|
595
|
+
7. on any failure, rolls back: restart the victim unit, mark the request
|
|
596
|
+
failed, and let parked clients fall back.
|
|
597
|
+
|
|
598
|
+
Steps 2–5 are already what `~/.config/uap/model-switch.sh` does by hand,
|
|
599
|
+
including the `Conflicts=` unit graph. The script is the reference
|
|
600
|
+
implementation; the module is that script with drain, verification, and
|
|
601
|
+
rollback.
|
|
602
|
+
|
|
603
|
+
**One caveat the earlier draft glossed: those units are machine-local.**
|
|
604
|
+
`grep -rn "Conflicts=" config/` returns nothing — the mutually-conflicting
|
|
605
|
+
backend units live in `~/.config/systemd/user/`, installed by hand, and the
|
|
606
|
+
capacity policy itself records `strata-server` as unit-less. Enforcement
|
|
607
|
+
step 4 (`systemctl --user start <new unit>`) depends on units the repo does
|
|
608
|
+
not ship. Phase 2 therefore needs tracked unit templates with the `Conflicts=`
|
|
609
|
+
edges (extending `src/cli/systemd-services.ts`, which already installs
|
|
610
|
+
`uap-anthropic-proxy.service` and `uap-llama-server.service`), or the
|
|
611
|
+
registry must record that a unit's provenance is machine-local and
|
|
612
|
+
enforcement must refuse configs whose unit it cannot install. Degrading to
|
|
613
|
+
raw `systemd kill`/`ExecStart` strings is the failure the risk table already
|
|
614
|
+
forbids.
|
|
615
|
+
|
|
616
|
+
Two things that script does not do, and that the module must:
|
|
617
|
+
|
|
618
|
+
**Rollback after a failed start is the trust-critical step.** A failed start
|
|
619
|
+
that leaves the old unit stopped means the operator has no backend at all. The
|
|
620
|
+
module restarts the victim, captures its stderr into the event, and if the
|
|
621
|
+
rollback start also fails it stops trying and surfaces the observed state as a
|
|
622
|
+
red banner rather than looping. This path must be exercised in tests against a
|
|
623
|
+
simulated failed start before `auto` mode is available at all.
|
|
624
|
+
|
|
625
|
+
**The timeouts have an envelope nobody has measured.** The drain timeout, the
|
|
626
|
+
hold ceiling, and the release gate all need numbers, and those numbers depend on
|
|
627
|
+
how long this machine actually takes to tear a backend down and bring one up.
|
|
628
|
+
The only way to measure that is to perform a switch while watching, which takes
|
|
629
|
+
the live backend down and interrupts whatever is using it. So it is sequenced
|
|
630
|
+
deliberately: measure the envelope during one planned switch with the operator's
|
|
631
|
+
go-ahead, and until then the timeouts are conservative defaults labelled
|
|
632
|
+
unvalidated. Publishing a hold ceiling without a measured envelope behind it is
|
|
633
|
+
the same mistake as publishing a VRAM cost without a measurement.
|
|
634
|
+
|
|
635
|
+
**How the proxy finds the controller.** This is new wiring, not reuse — an
|
|
636
|
+
earlier draft claimed the lifecycle helper already passes `UAP_DASHBOARD_URL`
|
|
637
|
+
to the proxy; that variable exists nowhere in the repo. The proxy reads no
|
|
638
|
+
dashboard address today. So: `uap setup` emits `PROXY_PLACEMENT_CONTROLLER`
|
|
639
|
+
into `.uap/proxy.env` next to the other `PROXY_*` knobs it already emits, and
|
|
640
|
+
the unit file's `EnvironmentFile` picks it up. The value is a loopback URL.
|
|
641
|
+
Two constraints from the existing server code (`src/dashboard/server.ts`): the
|
|
642
|
+
dashboard may bind port 0 (OS-picked, resolved only after `listen`), and it may
|
|
643
|
+
bind `0.0.0.0` for LAN reachability — a placement control endpoint that stops
|
|
644
|
+
and starts systemd units must be **loopback-bound regardless of `--host`**,
|
|
645
|
+
with `mutationAuthorized(req)` on every write. If the controller URL is unset
|
|
646
|
+
or nothing answers it, admission runs read-only: it can answer "already
|
|
647
|
+
loaded" from the ledger, but it cannot load anything, so an unloaded request
|
|
648
|
+
gets `model_placement_pending` with reason `no_controller`, and
|
|
649
|
+
`uap models pending` tells the operator to start the dashboard or act from
|
|
650
|
+
the CLI.
|
|
651
|
+
|
|
652
|
+
#### Concurrency must become per-target
|
|
653
|
+
|
|
654
|
+
`upstream_semaphore` is one global `asyncio.Semaphore(PROXY_CONCURRENCY_LIMIT)`
|
|
655
|
+
(default 1) created at startup. With two models resident, one global limit is
|
|
656
|
+
wrong in both directions: a 2-slot llama.cpp server is throttled to 1, and a
|
|
657
|
+
1-rail Strata server is allowed 2 and overflows.
|
|
658
|
+
|
|
659
|
+
Replace it with a per-target semaphore map keyed by the resolved wire model's
|
|
660
|
+
target, sized from the registry's `rails` and cross-checked against the live
|
|
661
|
+
`/slots` count — the same reconciliation `getModelSlotBudget()` already does
|
|
662
|
+
for the TypeScript side. The wire-model decision and the concurrency gate must
|
|
663
|
+
read the same target or they will disagree under load.
|
|
664
|
+
|
|
665
|
+
`model_backpressure` is likewise one row (`CHECK(id = 1)`), so exhaustion on
|
|
666
|
+
one endpoint currently suppresses every other endpoint. Key it by target id.
|
|
667
|
+
`model_leases` has no target column at all, so a lease taken against one
|
|
668
|
+
budget is consumed by a different model's request. Both changes are additive
|
|
669
|
+
schema migrations with a default target for existing rows.
|
|
670
|
+
|
|
671
|
+
### 4.4 Options and cost math
|
|
672
|
+
|
|
673
|
+
Cost is per **config**, not per model, and it is measured rather than derived
|
|
674
|
+
from file sizes. Measured today:
|
|
675
|
+
|
|
676
|
+
| Config | Disk | GPU (measured) | Host RSS (measured) |
|
|
677
|
+
| --- | --- | --- | --- |
|
|
678
|
+
| `strata-iq3_s` | 79 GiB (52 native + 27 PLE) | **21,812 MiB** | **52,857 MiB** |
|
|
679
|
+
| `strata-ple` | ~52 GiB | not measured | not measured |
|
|
680
|
+
| `qwen38-27b-mtp` (llama.cpp) | 15 GiB | not measured | not measured |
|
|
681
|
+
|
|
682
|
+
Disk size is useless as a cost proxy: the active config puts 79 GiB on disk and
|
|
683
|
+
21.8 GiB on the card, while the exl3 config puts 15 GiB on disk and needs more
|
|
684
|
+
than 22 GiB on the card. `nvidia-smi --query-compute-apps=pid,used_memory`
|
|
685
|
+
against the engine's own PID is the only number that means what the admission
|
|
686
|
+
gate needs, and `uap models measure` reads it from there.
|
|
687
|
+
|
|
688
|
+
The other half of the row is host RAM, and on this machine it is the binding
|
|
689
|
+
constraint. The Strata engine's resident set is 52.9 GiB of a 128 GiB host with
|
|
690
|
+
30 GiB available — it keeps the 52 GiB native GGUF and the 27 GiB PLE file
|
|
691
|
+
mapped. A second config of that shape does not fit on the host even if the card
|
|
692
|
+
had room, so `fits_host` is not a formality and the registry must carry
|
|
693
|
+
`host_rss_mib` as a measured field with the same fail-closed rule as the GPU
|
|
694
|
+
number.
|
|
695
|
+
|
|
696
|
+
What the measured row corresponds to, from the live unit's `ExecStart`
|
|
697
|
+
(`config/strata-configs/strata-iq3_s.json`):
|
|
698
|
+
|
|
699
|
+
```
|
|
700
|
+
--max-context 131072 --kv int8 --kv-resident 32768
|
|
701
|
+
--vram-reserve-mib 1200 --expert-cache auto --spec 8 --spec-min-p 0.8
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
`/metrics` reports `expert_cache_mib: 15017` and `expert_slots: 7736` for that
|
|
705
|
+
config. So `kv_resident_cells` is 32768, not 131072: the first 32k cells of
|
|
706
|
+
context are already inside the 21,812 MiB, and marginal KV only starts accruing
|
|
707
|
+
past that. `--vram-reserve-mib 1200` is the engine's own internal floor and
|
|
708
|
+
belongs in `reserved_mib` for the device, not in the config's cost.
|
|
709
|
+
|
|
710
|
+
```
|
|
711
|
+
cost_mib(config, cells) = resident_gpu_mib(config)
|
|
712
|
+
+ max(0, cells − kv_resident_cells(config))
|
|
713
|
+
× kv_mib_per_1k_cells(config) / 1000
|
|
714
|
+
|
|
715
|
+
fits_gpu = cost_mib ≤ device.total_mib − Σ(resident_gpu_mib of everything
|
|
716
|
+
staying loaded) − device.reserved_mib
|
|
717
|
+
fits_host = host_rss_mib(config) ≤ cpu0.free_mib − cpu0.reserved_mib
|
|
718
|
+
fits = fits_gpu AND fits_host
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
Three things that formula gets right and the naive version gets wrong:
|
|
722
|
+
|
|
723
|
+
**The device budget is a sum, not a free-memory reading.** The card is 24,576
|
|
724
|
+
MiB. The running engine holds 21,812 MiB and the desktop holds 1,516 MiB of
|
|
725
|
+
attributable compute plus roughly another 900 MiB of unattributed graphics
|
|
726
|
+
memory. The correct question is "does the set I intend to have loaded fit in
|
|
727
|
+
24,576 minus the device's reserved floor minus nothing else", not "does
|
|
728
|
+
`nvidia-smi` say there is room".
|
|
729
|
+
`probeGpuFreeMiB()` reads `memory.free`, which on this machine reports 245–975
|
|
730
|
+
MiB at steady state — a number that is true and useless at the same time,
|
|
731
|
+
because it describes what is left after the operator's chosen configuration
|
|
732
|
+
already filled the card.
|
|
733
|
+
|
|
734
|
+
**`free_mib` must exclude the process being displaced.** Reading free memory
|
|
735
|
+
while the victim still holds the card reports the opposite of the truth: the
|
|
736
|
+
preview would promise room that does not exist until the victim is gone, and
|
|
737
|
+
the load would then OOM. Concretely, free is 975 MiB and the victim holds
|
|
738
|
+
21,812 MiB, so a displacement that needs 22.5 GiB has 22,787 MiB to work with —
|
|
739
|
+
and only after the stop is verified, not before.
|
|
740
|
+
|
|
741
|
+
**Host RSS is a hard gate, not a footnote.** 52.8 GiB of the machine's 121.7
|
|
742
|
+
GiB is held by the running engine, with 29.7 GiB available. A placement that
|
|
743
|
+
fits the GPU and OOMs the host is still a failed placement. This is also what
|
|
744
|
+
makes `pause` expensive: save/restore moves KV to host RAM, and on this machine
|
|
745
|
+
there is not a lot of host RAM to move into.
|
|
746
|
+
|
|
747
|
+
`resident_gpu_mib` already contains whatever the engine preallocates, so no
|
|
748
|
+
separate overhead term is needed for the common case. For the live Strata
|
|
749
|
+
config, `/metrics` reports `expert_cache_mib: 15017` and `arena_mib: 47962`
|
|
750
|
+
inside the 21,812 MiB resident total; those are reported for diagnosis only and
|
|
751
|
+
never enter the decision. The marginal KV term matters only when a request asks
|
|
752
|
+
for more context than the measured pool (`--max-context 131072` with
|
|
753
|
+
`--kv-resident 32768` today); below that, the pool is already paid for.
|
|
754
|
+
|
|
755
|
+
`reserve_mib` is the operator's placement reserve, declared in the placement
|
|
756
|
+
config, and it is a different number from the capacity policy's
|
|
757
|
+
`gpuMinFreeMiB`. The policy's `200` for `strata-server` is a doctor floor
|
|
758
|
+
deliberately set low, because that service fills the card by design and the
|
|
759
|
+
measured steady state with the desktop sharing the GPU is ~245 MiB free; a
|
|
760
|
+
600 MiB floor would hold the doctor permanently RED at the operator's chosen
|
|
761
|
+
operating point and train everyone to ignore it. The placement reserve is the
|
|
762
|
+
opposite question — how much to keep back so the *next* model can load — and
|
|
763
|
+
on a 24 GiB card it has to be stated explicitly or it is silently zero.
|
|
764
|
+
|
|
765
|
+
On today's machine the arithmetic is blunt: 24,576 total, 21,812 held by one
|
|
766
|
+
config, ~290 by the desktop, and every alternate config in the registry needs
|
|
767
|
+
22–25 GiB. `load_alongside` has no solutions. Displacement is the only option,
|
|
768
|
+
and that is the correct output, not a bug — it is the same conclusion the
|
|
769
|
+
`Conflicts=` graph already encodes, now stated in MiB instead of unit names.
|
|
770
|
+
|
|
771
|
+
Generated options, in ranking order:
|
|
772
|
+
|
|
773
|
+
1. **reuse** — a resident model already satisfies the request.
|
|
774
|
+
2. **load_alongside** — fits on a device with room; nothing is displaced.
|
|
775
|
+
3. **displace** — minimal eviction set that makes room, smallest first.
|
|
776
|
+
|
|
777
|
+
Ranking: reuse > load_alongside with matching `task_affinity` > load_alongside
|
|
778
|
+
> smallest displacement > largest displacement. The operator always sees the
|
|
779
|
+
full list; the ranking only sets default order and the recommended row.
|
|
780
|
+
|
|
781
|
+
#### 4.4.1 Auto-resolution policy (phase 4)
|
|
782
|
+
A parked request whose option list is non-empty does not have to wait for a
|
|
783
|
+
human `apply`. `src/placement/auto.ts` picks from the same ranked list the
|
|
784
|
+
operator sees, under two rules:
|
|
785
|
+
|
|
786
|
+
1. **Non-displacing options auto-enforce when the policy is enabled** —
|
|
787
|
+
reuse needs nothing, load-alongside evicts nothing; the budget math
|
|
788
|
+
already said it fits.
|
|
789
|
+
2. **Displacement auto-enforces only for allowlisted models.** The
|
|
790
|
+
allowlist (`uap models auto --allow-displace <model> --yes`) is the
|
|
791
|
+
standing operator confirmation the core invariant requires — see the
|
|
792
|
+
§6 phase-4 note for why this extends, not violates, "never displacement".
|
|
793
|
+
|
|
794
|
+
The policy lives in `~/.uap/placement-auto.json` (`UAP_PLACEMENT_AUTO`):
|
|
795
|
+
`{"enabled": bool, "allow_displace": [model keys]}` plus the §4.4.2 knobs.
|
|
796
|
+
**Opt-in, fail-closed**:
|
|
797
|
+
a missing file, a corrupt file, or a missing `enabled: true` key all mean
|
|
798
|
+
auto is OFF — every park waits for a manual apply, exactly as in phase 3.
|
|
799
|
+
`uap models auto` with no flags prints status; `--enable`/`--disable` flip
|
|
800
|
+
it; `--allow-displace` refuses to record anything without `--yes`, because
|
|
801
|
+
what it records is consent to evict unattended.
|
|
802
|
+
|
|
803
|
+
Mechanics, all fail-closed (hardened after the phase-4 parallel review):
|
|
804
|
+
|
|
805
|
+
- **One background run per requested MODEL** (`scheduleAutoResolution` keys
|
|
806
|
+
its run map by model, not placement id): the client-supplied identity
|
|
807
|
+
headers can mint fresh placement ids at request rate, so the id is no
|
|
808
|
+
dedupe at all — the model is the real "same load" key. The proxy parks
|
|
809
|
+
`auto_loading` reasons under a model-only burst key too, so rotating the
|
|
810
|
+
client-id header does not even re-ask the controller.
|
|
811
|
+
- **Single-flight machine-wide**: auto runs queue on one tail promise —
|
|
812
|
+
two alongside options computed against the same pre-load free-memory
|
|
813
|
+
snapshot can otherwise both start and oversubscribe the card. A hard cap
|
|
814
|
+
(`AUTO_MAX_CONCURRENT_RUNS = 2`) bounds the queue outright.
|
|
815
|
+
- **The entry outlives its run**: scheduling extends the pending entry's
|
|
816
|
+
`expires_at` past the enforcement envelope (drain + stops + verify-free +
|
|
817
|
+
start can exceed the 120s operator-prompt TTL), because a mid-run expiry
|
|
818
|
+
would mint a fresh entry and a second concurrent run for the same model.
|
|
819
|
+
- **A failed run parks its model in a cooldown** (~10 min): a persistently
|
|
820
|
+
failing load would otherwise re-enforce at every TTL expiry — an
|
|
821
|
+
unattended drain→stop→fail→rollback oscillation the operator consented
|
|
822
|
+
to neither. A later success clears the cooldown.
|
|
823
|
+
- **The run never rejects**: every ledger write inside it is caught — a
|
|
824
|
+
lock hiccup in a background run must not become an unhandled rejection
|
|
825
|
+
that kills the dashboard daemon. The entry self-heals via TTL expiry.
|
|
826
|
+
- Enforcement starts on a `setImmediate` yield, after the park answer is
|
|
827
|
+
built: its synchronous prefix (device probes, `systemctl is-active`, the
|
|
828
|
+
ledger lock) must not stall the admit path, which the proxy holds to a
|
|
829
|
+
2s timeout.
|
|
830
|
+
- Success removes the pending entry; the client's next retry forwards.
|
|
831
|
+
Failure (or a thrown error) marks the entry `reason: "auto_failed"` —
|
|
832
|
+
one attempt per entry, plus the per-model cooldown above; the operator
|
|
833
|
+
sees it in `uap models pending` like any other park.
|
|
834
|
+
- Only a persisted, won-the-write-race entry schedules (the answer's
|
|
835
|
+
placement id must be the ledger's entry id, or a resolved pending would
|
|
836
|
+
have nothing to remove). A lost race or an unpersisted answer reports
|
|
837
|
+
the honest reason — never `auto_loading` for a run that will not
|
|
838
|
+
execute.
|
|
839
|
+
- Unknown and unmeasured models never auto-load — there is nothing to
|
|
840
|
+
enforce. The park reasons `unknown_model` / `no_measured_config` stand.
|
|
841
|
+
- If the controller dies mid-enforcement, the machine can be left with the
|
|
842
|
+
victim stopped and nothing loaded — the ledger self-heals (TTL, then
|
|
843
|
+
resync), the machine does not; recovery is manual (`uap models load`).
|
|
844
|
+
A durable enforcement queue is deliberately out of scope for one
|
|
845
|
+
operator's loopback dashboard; revisit only if controller death
|
|
846
|
+
mid-load is ever observed.
|
|
847
|
+
|
|
848
|
+
Precedence note: the CONTROLLER's auto policy (this section) decides
|
|
849
|
+
auto-loads; the proxy's `PROXY_PLACEMENT_MODE` never reaches the
|
|
850
|
+
controller. An operator running `mode=ask` with the auto policy enabled
|
|
851
|
+
gets auto-loads — the policy file is the single authority, and
|
|
852
|
+
`uap models auto --disable` is the off switch.
|
|
853
|
+
|
|
854
|
+
#### 4.4.2 Idle-resident unload (the missing reverse)
|
|
855
|
+
|
|
856
|
+
§4.4.1 closes the loop on the *load* side only: a parked request loads its
|
|
857
|
+
model, but nothing ever unloads a resident again — the first auto-loaded
|
|
858
|
+
model holds its device until a human remembers to stop it. The idle sweep
|
|
859
|
+
(`src/placement/idle.ts`, wired into the dashboard process that already owns
|
|
860
|
+
enforcement) is the consent-gated reverse:
|
|
861
|
+
|
|
862
|
+
- **Off by default.** No `unload_idle_after_secs` in the policy → the sweep
|
|
863
|
+
is a per-tick no-op. The knob floors at 60 s — under a minute the sweep
|
|
864
|
+
would oscillate (unload, next request auto-loads, unload again).
|
|
865
|
+
- **Consent per model.** `unload_allow` is the standing-consent twin of
|
|
866
|
+
`allow_displace` (`uap models auto --allow-unload <model> --yes`; the
|
|
867
|
+
dashboard's `/api/placement/auto` takes `allow_unload` with the same
|
|
868
|
+
`yes: true` gate). A resident not on the list is never touched.
|
|
869
|
+
- **A gated request served resets the clock.** The admit forward path
|
|
870
|
+
records `usage[model] = now` in the ledger (top-level, so it survives
|
|
871
|
+
the resident rebuild in `syncLedger`). The touch is fail-soft with a
|
|
872
|
+
short lock fuse (250 ms): a ledger hiccup or lock contention loses the
|
|
873
|
+
touch, which only delays an unload — never blocks a forward.
|
|
874
|
+
- **Arming never evicts the already-idle.** A resident with no usage record
|
|
875
|
+
gets its clock SEEDED at first observation; the sweep acts only on
|
|
876
|
+
windows it actually watched. A successful unload CLEARS the entry, so a
|
|
877
|
+
RELOADED model gets a fresh window instead of inheriting a stale clock
|
|
878
|
+
that predates it — and entries for models with no live resident are
|
|
879
|
+
pruned each tick, so residue cannot accumulate.
|
|
880
|
+
- **The sweep never races placement's own work.** A model with a
|
|
881
|
+
non-expired pending entry (parked for the operator, or mid-auto-load)
|
|
882
|
+
is skipped — demand exists. A refused or faulting unload parks the
|
|
883
|
+
model in a 10-minute failure cooldown, the twin of auto-load's: a
|
|
884
|
+
persistently failing stop would otherwise drain→stop→restart the
|
|
885
|
+
backend every 60 s, unsupervised. Overlapping ticks are dropped (a
|
|
886
|
+
60 s drain window can exceed the 60 s cadence).
|
|
887
|
+
- **Unknown means busy.** The usage clock only sees gated traffic, so the
|
|
888
|
+
sweep also probes the endpoint for ESTABLISHED connections (`ss`, scoped
|
|
889
|
+
to the backend's server-side sockets): a client past the gate (direct
|
|
890
|
+
curl, another agent on the port) resets the clock and skips the tick.
|
|
891
|
+
If `ss` cannot answer, the resident is skipped — a refusal to guess,
|
|
892
|
+
never a guess that it's idle.
|
|
893
|
+
- **The stop is the operator's stop.** The sweep only decides WHEN; the
|
|
894
|
+
unload itself is `unloadPlacement` — drain, in-flight refusal (a
|
|
895
|
+
generation running at the final check aborts the stop), verify-free,
|
|
896
|
+
rollback. Fleet-spawned client dashboards set `UAP_IDLE_SWEEP=0`
|
|
897
|
+
(`false`/`off` also accepted), so exactly one sweeper touches the
|
|
898
|
+
shared ledger. Two OPERATOR-started dashboards would both sweep — same
|
|
899
|
+
pre-existing exposure as two manual unloads; run one.
|
|
900
|
+
- **Policy edits take effect without a restart**: the sweep re-reads the
|
|
901
|
+
policy file every tick.
|
|
902
|
+
- **Accepted loop surface (documented, not mitigated):** an unauthenticated
|
|
903
|
+
loopback caller can keep a resident pinned forever by re-admitting
|
|
904
|
+
(resetting its clock), or — with both auto-load and idle-unload armed —
|
|
905
|
+
drive park→load→idle→unload churn. The same caller could just use the
|
|
906
|
+
model; the allowlists still bound WHICH models are affected.
|
|
907
|
+
|
|
908
|
+
The dashboard's `/api/placement/auto` preserves these fields across every
|
|
909
|
+
save (spread, not rebuild), so an unrelated `--enable` cannot silently wipe
|
|
910
|
+
the unload consent.
|
|
911
|
+
|
|
912
|
+
### 4.5 Impact preview
|
|
913
|
+
|
|
914
|
+
Every `displace` option carries the victim list. This is the answer to "what
|
|
915
|
+
will be killed or paused", and it is computed **before** the operator commits.
|
|
916
|
+
|
|
917
|
+
| Victim state | In flight | Consequence |
|
|
918
|
+
| --- | --- | --- |
|
|
919
|
+
| `hot` | yes | active generation aborted; context lost |
|
|
920
|
+
| `hot` | no | session idle, context lost, reload needed |
|
|
921
|
+
| `warming` | — | load wasted; VRAM returns on stop |
|
|
922
|
+
| `paused` | — | already offloaded; restore path lost |
|
|
923
|
+
|
|
924
|
+
Per victim: model, state, device, holders (client, pid, job, turns, context
|
|
925
|
+
cells), in-flight flag, cost it frees, and estimated reload cost for the
|
|
926
|
+
displaced model.
|
|
927
|
+
|
|
928
|
+
Two sources exist and neither alone answers "who is using this model":
|
|
929
|
+
|
|
930
|
+
- `model_leases` (`src/coordination/database.ts`) has a `holder` column, but
|
|
931
|
+
`activeModelLeases()` (`src/coordination/service.ts:1061`) returns only a
|
|
932
|
+
count, and the holder is a lease-holder string (`model:<apiModel>`,
|
|
933
|
+
`agentic:<apiModel>`), not a client identity.
|
|
934
|
+
- The proxy holds the live request state, but it is **not exposed**. An
|
|
935
|
+
earlier draft named a `_client_registry` served at `/v1/api/clients` —
|
|
936
|
+
neither exists. The real primitives are `_current_request_session`
|
|
937
|
+
(contextvar, `:4853`), the in-flight counters hung on the httpx client
|
|
938
|
+
(`_inflight_inc`/`_inflight_dec`, `:4628-4637`), session admission state
|
|
939
|
+
(`_admitted_sessions`, `:4920`), and client identity
|
|
940
|
+
(`resolve_client_id` + `_client_request_times`, `:2011-2053`). None has a
|
|
941
|
+
route.
|
|
942
|
+
|
|
943
|
+
So the preview needs a **new proxy endpoint** — in-flight work per target
|
|
944
|
+
(client id, session, request id, started-at) — as an explicit phase-2
|
|
945
|
+
deliverable, not a join of two existing registries. **Shipped** (phase 3) as
|
|
946
|
+
the loopback-only `GET /internal/placement/inflight`, keyed by the same
|
|
947
|
+
target id the admission gate sets (`device:endpoint`, `default` when no
|
|
948
|
+
controller answer set one). The registration lives at the httpx client's
|
|
949
|
+
`send()` override — the one choke point every upstream call shares — not at
|
|
950
|
+
`_post_with_retry` alone: guardrail and recipe passes post directly, and the
|
|
951
|
+
first cut (guarded only `_post_with_retry`) was caught by a live smoke showing
|
|
952
|
+
an empty list mid-generation. The `uap models apply` impact list reads this
|
|
953
|
+
endpoint (best-effort; `unknown` when the proxy does not answer — never a
|
|
954
|
+
guessed "idle"). The lease side still needs a target column (§4.3) before it
|
|
955
|
+
can attribute a holder to a specific resident model.
|
|
956
|
+
|
|
957
|
+
Nothing in this view is advisory-only: the enforcement step re-derives the
|
|
958
|
+
same list and **refuses to proceed if it differs** from what the operator
|
|
959
|
+
approved. A preview that lies is worse than no preview.
|
|
960
|
+
|
|
961
|
+
### 4.6 Enforcement
|
|
962
|
+
|
|
963
|
+
Only after an explicit operator yes. The sequence is §4.3's module, with the
|
|
964
|
+
failure modes the repo has already recorded:
|
|
965
|
+
|
|
966
|
+
1. Mark victims `draining`; refuse new requests to them.
|
|
967
|
+
2. Wait `drain_ms` for in-flight work to finish. Do not cut it off silently.
|
|
968
|
+
3. Stop the victim unit (`systemctl --user stop`).
|
|
969
|
+
4. **Verify the VRAM actually came back.** `qwen38-27b-dflash2.env:50-56`
|
|
970
|
+
documents the failure mode: a server started outside systemd was observed
|
|
971
|
+
leaving an orphaned `llama-server` holding 21362 MiB with no listening
|
|
972
|
+
socket, starving the real service on load. Check
|
|
973
|
+
`nvidia-smi --query-compute-apps=pid,used_memory --format=csv` for an
|
|
974
|
+
orphan before concluding the load failed for capacity reasons.
|
|
975
|
+
5. Start the new unit with the profile's flags.
|
|
976
|
+
6. Poll `/health`, then the advertised ids (`/v1/models`): the load-bearing
|
|
977
|
+
check is that the requested model is actually SERVABLE now. **Shipped
|
|
978
|
+
scope (phase 3): id-level verify-up only** — the deep `/slots` +
|
|
979
|
+
`/metrics` `metricsMustMatch` check ("wrong `kv` kind or pool size is
|
|
980
|
+
RED, not up") stays `uap doctor`'s job (the existing capacity cross-check
|
|
981
|
+
runs the same probes with the policy attached); wiring it into the
|
|
982
|
+
enforcement sequence is an open follow-up, not a silent drop.
|
|
983
|
+
7. **Invalidate the proxy's cached view of the upstream, then** update the
|
|
984
|
+
ledger; release the pending request; the client retries and is served.
|
|
985
|
+
`_upstream_model_ids_cached()` (`:1648`) caches the upstream's advertised
|
|
986
|
+
ids **for the process lifetime** ("fetched at most once per process"), and
|
|
987
|
+
`_upstream_model_name()` (`:1724`) reads the same cache. Skip this and the
|
|
988
|
+
first request after a successful switch fails the `requested in ids`
|
|
989
|
+
check and gets silently rewritten to the *previous* backend's first id —
|
|
990
|
+
no banner, wrong model. The invalidation must also cover the `/props` and
|
|
991
|
+
`/slots` snapshots, `_admitted_sessions` (`:4920`), and the parked-request
|
|
992
|
+
burst map. This is a step in the sequence and a row in the §7 test list,
|
|
993
|
+
not an implementation detail. Shipped as the loopback-only
|
|
994
|
+
`POST /internal/placement/refresh` endpoint; the pooled connections are a
|
|
995
|
+
documented deviation (one retried request post-swap rather than a pool
|
|
996
|
+
swap). Phase 3 automates this step inside the sequence (`uap models
|
|
997
|
+
apply`/`unload` fire it as `refresh-proxy`); a MANUAL switch run outside
|
|
998
|
+
the tooling still fires the same hook by hand:
|
|
999
|
+
|
|
1000
|
+
curl -s -X POST http://127.0.0.1:${PROXY_PORT:-4000}/internal/placement/refresh \
|
|
1001
|
+
-H "Authorization: Bearer ${PROXY_AUTH_TOKEN:-}"
|
|
1002
|
+
8. Any step fails → roll back to the previous resident set and return a
|
|
1003
|
+
fallback to the client rather than leaving the machine with nothing loaded.
|
|
1004
|
+
A unit STARTED during the failed sequence is stopped first (two
|
|
1005
|
+
same-device residents is the same outcome step 8 exists to prevent), and
|
|
1006
|
+
the sequence's `draining` markers are cleared (the marker must survive
|
|
1007
|
+
concurrent ledger syncs MID-drain, but must not leak past the rollback).
|
|
1008
|
+
|
|
1009
|
+
Step 8 is the one that decides whether this feature is trustworthy. A partial
|
|
1010
|
+
enforcement leaves the machine with no model loaded and every agent pointed at
|
|
1011
|
+
`:4000`. The rollback must be exercised in tests against a simulated failed
|
|
1012
|
+
start, not only in the success path.
|
|
1013
|
+
|
|
1014
|
+
**The enforcement envelope is unmeasured, and it needs to be.** The pending-hold
|
|
1015
|
+
timeout and the drain timeout in step 3 are constants in every draft so far.
|
|
1016
|
+
They should come from a measured teardown/start envelope per config: how long
|
|
1017
|
+
`systemctl --user stop <unit>` takes, whether VRAM release can be confirmed by
|
|
1018
|
+
re-probing the engine PID rather than trusting unit state, and how long the
|
|
1019
|
+
alternate profile takes to reach `/health` warm versus cold. Measuring this
|
|
1020
|
+
means briefly taking the live backend down, so it needs the operator's explicit
|
|
1021
|
+
go-ahead; it is the first thing to do once they are ready to touch a running
|
|
1022
|
+
model.
|
|
1023
|
+
|
|
1024
|
+
### 4.7 Surfaces
|
|
1025
|
+
|
|
1026
|
+
Namespace note: `uap models` (plural, placement) sits next to the existing
|
|
1027
|
+
`uap model` (singular, multi-model routing at `src/bin/cli.ts:2035`) — a
|
|
1028
|
+
near-homonym, kept because they answer different questions (where does it
|
|
1029
|
+
live vs which model answers). Help text for both cross-references the other.
|
|
1030
|
+
|
|
1031
|
+
**TUI** — `uap models`:
|
|
1032
|
+
|
|
1033
|
+
```
|
|
1034
|
+
uap models status residents, devices, free/headroom, holders
|
|
1035
|
+
uap models pending parked requests with ranked options
|
|
1036
|
+
uap models apply <id> <option> explicit yes; prints the impact list first
|
|
1037
|
+
uap models dismiss <id> refuse; client gets a clean fallback
|
|
1038
|
+
uap models load <model> operator-initiated, same gate (no --device yet)
|
|
1039
|
+
uap models unload <model> operator-initiated, same preview
|
|
1040
|
+
uap models units write Conflicts= drop-ins for same-device units
|
|
1041
|
+
uap models measure <model> populate measured footprints
|
|
1042
|
+
uap models validate registry ↔ capacity-policy cross-check
|
|
1043
|
+
```
|
|
1044
|
+
|
|
1045
|
+
**Dashboard** — replace the Models tab stub. `web/dash/tab-models.js` prints
|
|
1046
|
+
"Models — coming soon"; `web/dash/tabs.js:494` registers a `models` tab that
|
|
1047
|
+
renders cost and routing only. Neither shows placement. New routes on the
|
|
1048
|
+
existing server:
|
|
1049
|
+
|
|
1050
|
+
```
|
|
1051
|
+
GET /api/placement/state GET /api/placement/pending GET /api/placement/preview
|
|
1052
|
+
POST /api/placement/resolve POST /api/placement/dismiss
|
|
1053
|
+
POST /api/placement/load POST /api/placement/unload
|
|
1054
|
+
```
|
|
1055
|
+
|
|
1056
|
+
Reads are open, like other dashboard reads. Writes go through
|
|
1057
|
+
`mutationAuthorized(req)`, the same gate as `/api/policy/*/toggle`. Push uses
|
|
1058
|
+
the existing event stream; it needs a new category (`placement`) alongside
|
|
1059
|
+
`policy`, `memory`, `deploy`, `agent`, `task`, `skill`, `pattern`, `cost`,
|
|
1060
|
+
`system`.
|
|
1061
|
+
|
|
1062
|
+
Both surfaces call the same `src/placement/` functions. A decision made in the
|
|
1063
|
+
TUI appears in the dashboard on the next event, and vice versa.
|
|
1064
|
+
|
|
1065
|
+
## 5. What this forces us to fix
|
|
1066
|
+
|
|
1067
|
+
Not optional extras — the design does not work without them.
|
|
1068
|
+
|
|
1069
|
+
1. **Per-target leases and backpressure.** `model_backpressure` is one row, so
|
|
1070
|
+
two residents share one adaptive limit; a struggling model would throttle a
|
|
1071
|
+
healthy one. Key `model_leases` and `model_backpressure` by target id,
|
|
1072
|
+
keeping the single-transaction reap → count → insert that makes
|
|
1073
|
+
`acquireModelSlot()` safe today.
|
|
1074
|
+
2. **Per-target upstream semaphores.** `upstream_semaphore` is one global gate
|
|
1075
|
+
sized from `PROXY_CONCURRENCY_LIMIT`. With two residents it lets traffic
|
|
1076
|
+
aimed at model A queue behind model B. The wire-model decision and the
|
|
1077
|
+
concurrency gate must not diverge — `qwen38-27b-mtp.env` already warns to
|
|
1078
|
+
keep `PROXY_CONCURRENCY_LIMIT` equal to the slot count and
|
|
1079
|
+
`PROXY_CONTEXT_WINDOW` equal to per-slot context.
|
|
1080
|
+
3. **Backend-aware rail discovery.** `getModelSlotBudget()` falls back to
|
|
1081
|
+
`DEFAULT_SLOTS = 2` when a probe errors. On a single-rail backend that is a
|
|
1082
|
+
silent lie: it advertises two slots to a card that has one. A live server
|
|
1083
|
+
answering 404/405/501 at `/slots` is already treated as single-rail; an
|
|
1084
|
+
unreachable or unrecognized backend must not default to 2.
|
|
1085
|
+
4. **Multi-GPU probing.** `probeGpuFreeMiB()` reads GPU 0 only. The
|
|
1086
|
+
multi-device abstraction needs per-UUID readings.
|
|
1087
|
+
|
|
1088
|
+
### 5.1 The lease migration, concretely
|
|
1089
|
+
|
|
1090
|
+
Item 1 is the only change here that touches live state, so it gets its own
|
|
1091
|
+
sketch. Today (`src/coordination/service.ts:1050-1088`):
|
|
1092
|
+
|
|
1093
|
+
```ts
|
|
1094
|
+
acquireModelSlot(holder: string, budget: number, ttlMs = 120_000): number | null {
|
|
1095
|
+
const txn = this.db.transaction((): number | null => {
|
|
1096
|
+
const nowIso = new Date(Date.now()).toISOString();
|
|
1097
|
+
// DELETE first so the transaction takes the write lock before counting.
|
|
1098
|
+
this.db.prepare(`DELETE FROM model_leases WHERE expires_at < ?`).run(nowIso);
|
|
1099
|
+
const c = (this.db.prepare(`SELECT COUNT(*) as c FROM model_leases`).get() as { c: number }).c;
|
|
1100
|
+
if (c >= Math.max(1, budget)) return null;
|
|
1101
|
+
const info = this.db
|
|
1102
|
+
.prepare(`INSERT INTO model_leases (holder, acquired_at, expires_at) VALUES (?, ?, ?)`)
|
|
1103
|
+
.run(holder, nowIso, new Date(Date.now() + ttlMs).toISOString());
|
|
1104
|
+
return Number(info.lastInsertRowid);
|
|
1105
|
+
});
|
|
1106
|
+
return txn();
|
|
1107
|
+
}
|
|
1108
|
+
```
|
|
1109
|
+
|
|
1110
|
+
The safety property is the ordering: delete, then count, then insert, inside
|
|
1111
|
+
one transaction, so the write lock is held before the count is read. Any
|
|
1112
|
+
target-keyed version must preserve that ordering. The change is a `target`
|
|
1113
|
+
column and a `WHERE target = ?` on the count:
|
|
1114
|
+
|
|
1115
|
+
```sql
|
|
1116
|
+
ALTER TABLE model_leases ADD COLUMN target TEXT NOT NULL DEFAULT 'default';
|
|
1117
|
+
CREATE INDEX IF NOT EXISTS idx_model_leases_target ON model_leases(target, expires_at);
|
|
1118
|
+
```
|
|
1119
|
+
|
|
1120
|
+
`model_backpressure` drops its `CHECK(id = 1)` constraint in favour of a
|
|
1121
|
+
primary key on the target id. SQLite cannot drop a table constraint in place,
|
|
1122
|
+
so this is the standard create-new-table → copy → drop → rename, inside one
|
|
1123
|
+
transaction. Map the legacy `id = 1` row to `target = 'default'` in the copy so
|
|
1124
|
+
a revert of the code loses no operator-tuned state — that keeps the one-way
|
|
1125
|
+
door reversible in code only.
|
|
1126
|
+
|
|
1127
|
+
Three rules keep this from breaking anything:
|
|
1128
|
+
|
|
1129
|
+
- **`target = 'default'` is the legacy bucket.** Existing rows and existing
|
|
1130
|
+
callers that do not pass a target land there, so behaviour is unchanged
|
|
1131
|
+
until a caller opts in by naming a target. The migration is additive.
|
|
1132
|
+
- **`target` is the placement target identity (device + endpoint from the
|
|
1133
|
+
ledger), never the model name.** The existing holder strings already embed
|
|
1134
|
+
the model id (`model:<apiModel>`, `agentic:<apiModel>` — see
|
|
1135
|
+
`src/models/openai-compat-client.ts:260` and
|
|
1136
|
+
`src/delivery/agentic-executor.ts:2569`). Keying `target` on `apiModel`
|
|
1137
|
+
would make a backend switch change the key mid-flight: outstanding leases
|
|
1138
|
+
orphan, and per-target backpressure resets — the exact hazard
|
|
1139
|
+
`src/models/types.ts:229` documents for pinned endpoints. The ledger's
|
|
1140
|
+
target id is stable across model changes on the same endpoint.
|
|
1141
|
+
- **`acquireModelSlot` keeps its current signature** with `target` as an
|
|
1142
|
+
optional trailing argument. The only direct TS caller is
|
|
1143
|
+
`src/utils/model-slot-lease.ts:94` (an earlier draft also listed
|
|
1144
|
+
`src/cli/coord.ts` — it does not call it); the real consumers all go through
|
|
1145
|
+
`withModelSlot()` at `src/models/openai-compat-client.ts:260` and
|
|
1146
|
+
`src/delivery/agentic-executor.ts:2569`. None of them changes in the same
|
|
1147
|
+
commit that adds the column.
|
|
1148
|
+
|
|
1149
|
+
**The migration needs an idempotence guard this database does not have.**
|
|
1150
|
+
`src/coordination/database.ts` contains only `CREATE TABLE IF NOT EXISTS` — no
|
|
1151
|
+
`ALTER TABLE`, no `PRAGMA user_version`, no version marker at all, so an
|
|
1152
|
+
unguarded `ALTER` next to the creates throws on the second open. The precedent
|
|
1153
|
+
to follow is `src/memory/short-term/schema.ts:23` — probe
|
|
1154
|
+
`PRAGMA table_info(model_leases)`, add the column only if `target` is absent,
|
|
1155
|
+
and guard the `model_backpressure` rebuild on its actual column shape. The
|
|
1156
|
+
backpressure rebuild copies the same create-new-table → copy → drop → rename
|
|
1157
|
+
shape that file's comment describes verbatim ("SQLite doesn't support ALTER
|
|
1158
|
+
TABLE to change CHECK constraints, so we must rebuild the table").
|
|
1159
|
+
|
|
1160
|
+
## 6. Phasing
|
|
1161
|
+
|
|
1162
|
+
| Phase | Ships | Proxy mode |
|
|
1163
|
+
| --- | --- | --- |
|
|
1164
|
+
| 0 | registry, `models validate`, `models measure` | `off` |
|
|
1165
|
+
| 1 | ledger, `models status`, `models pending`, cost math, preview | `off` |
|
|
1166
|
+
| 2 | proxy gate, `409` pending, dashboard state/pending, Models tab | `ask` |
|
|
1167
|
+
| 3 | enforcement: drain, stop, verify-free, start, verify-metrics, rollback; `apply`/`dismiss`/`load`/`unload`/`units` surfaces; `Conflicts=` drop-ins; the in-flight proxy endpoint | `ask` |
|
|
1168
|
+
| 4 | auto mode: the controller auto-loads a parked, placeable request — alongside always; by displacement only for models the operator allowlisted (`uap models auto --allow-displace <model> --yes`). Never displacement by default. | `auto` |
|
|
1169
|
+
|
|
1170
|
+
Phase 4's displacement allowlist extends the earlier "never displacement"
|
|
1171
|
+
wording deliberately, and the argument is the spec's own invariant: *never
|
|
1172
|
+
evict without explicit operator confirmation*. The allowlist IS that
|
|
1173
|
+
confirmation — standing, written deliberately under a `--yes` that prints
|
|
1174
|
+
what it consents to, auditable in `~/.uap/placement-auto.json`, and removable
|
|
1175
|
+
per model at any time. Displacement stays impossible without it; alongside
|
|
1176
|
+
auto-load is the only automatic path. (Recorded here because the phase table
|
|
1177
|
+
previously said "never displacement" flatly, and a reviewer should find the
|
|
1178
|
+
reasoning, not just the change. The decision record is
|
|
1179
|
+
`docs/architecture/adr/0007-auto-displacement-allowlist-as-standing-consent.md`.)
|
|
1180
|
+
|
|
1181
|
+
Phase 0 is useful on its own: it turns the prose in five profile files into
|
|
1182
|
+
data `uap doctor` can act on. Nothing gates on the rest.
|
|
1183
|
+
|
|
1184
|
+
Two ordering constraints, plus one refactor the review forced us to schedule:
|
|
1185
|
+
|
|
1186
|
+
- **Item 3 (backend-aware rail discovery) lands in phase 0, not later.** The
|
|
1187
|
+
registry's `rails` field is only trustworthy if the probe that fills it
|
|
1188
|
+
distinguishes "single rail" from "probe failed". Fixing the fallback after
|
|
1189
|
+
building on top of it means re-measuring every entry.
|
|
1190
|
+
- **Item 1 (target-keyed leases and backpressure) lands between phases 1 and
|
|
1191
|
+
2.** The ledger can be built against today's single-row backpressure, but
|
|
1192
|
+
the proxy gate cannot: a gate that admits per-target while the lease budget
|
|
1193
|
+
and the upstream semaphore are global will admit more traffic than the
|
|
1194
|
+
resident model can serve. Phase 2 does not open until the lease table
|
|
1195
|
+
carries a target column and the semaphore map is keyed the same way.
|
|
1196
|
+
- **Phase 1.5: per-target upstream addressing in the proxy.** A per-target
|
|
1197
|
+
semaphore on top of a single upstream address is decorative: `LLAMA_CPP_BASE`
|
|
1198
|
+
is one module constant with 35 references, including every derived probe
|
|
1199
|
+
(`.replace("/v1", "/props")` at `:3403`/`:16452`, `/slots` at `:3470`/
|
|
1200
|
+
`:3529`/`:4926`/`:15073`, `/health` at `:5210`/`:16722`), CLOSE-WAIT
|
|
1201
|
+
accounting (`_upstream_port()`, `:4580`), and the streaming retry loops
|
|
1202
|
+
(`:15065-15700`). The enabler is a resolved target object
|
|
1203
|
+
`{target_id, base_url, slots_url, props_url, health_url}` threaded through
|
|
1204
|
+
the send paths, with `LLAMA_CPP_BASE` as the default target so single-backend
|
|
1205
|
+
behavior is unchanged. It is the riskiest refactor in a 17k-line file; it
|
|
1206
|
+
gets its own branch, its own review, and lands before the phase-2 gate —
|
|
1207
|
+
otherwise phases 3–4 admit per-target placement the proxy cannot address.
|
|
1208
|
+
- **Tracked unit templates are a phase-2 prerequisite** (see §4.3's caveat):
|
|
1209
|
+
enforcement can only `systemctl --user start` units that exist, and today
|
|
1210
|
+
the `Conflicts=` graph lives only in `~/.config/systemd/user/`.
|
|
1211
|
+
|
|
1212
|
+
Implementation runs in `.worktrees/345-model-placement/` on
|
|
1213
|
+
`feature/345-model-placement`, per the worktree gate.
|
|
1214
|
+
|
|
1215
|
+
## 7. Tests
|
|
1216
|
+
|
|
1217
|
+
- Cost math: boundary at exactly `free − headroom`; unknown footprint fails
|
|
1218
|
+
closed; `kv_per_1k` scaling.
|
|
1219
|
+
- Preview: minimal eviction set selection; victim list matches enforcement
|
|
1220
|
+
(a mismatch must abort).
|
|
1221
|
+
- Registry: schema validation; disagreement with `capacity-policy.json` fails.
|
|
1222
|
+
- Ledger: concurrent writers, pending expiry, dead-PID holder pruning.
|
|
1223
|
+
- Proxy gate: `409` shape, dedupe per `(client, model)`, **`mode=off`
|
|
1224
|
+
produces byte-identical behavior to today** — the regression that matters
|
|
1225
|
+
most, because the proxy serves every client — and, per §4.3's route list,
|
|
1226
|
+
**each non-exempt entry route parks in `mode=ask`** when the requested
|
|
1227
|
+
local model is not resident (`/v1/chat/completions`, `/api/chat`,
|
|
1228
|
+
`/api/generate`, `/anthropic/v1/messages`), while passthrough stays exempt.
|
|
1229
|
+
- Enforcement: after a successful swap the proxy's upstream id cache is
|
|
1230
|
+
invalidated — the next request must not be rewritten to the previous
|
|
1231
|
+
backend's id (§4.6 step 7).
|
|
1232
|
+
- Dashboard: writes require `mutationAuthorized()`; reads do not.
|
|
1233
|
+
- Existing suites must stay green: `test/models/lease-heartbeat.test.ts`,
|
|
1234
|
+
`test/models/openai-compat-lease.test.ts`,
|
|
1235
|
+
`test/coordination/adaptive-backpressure.test.ts`,
|
|
1236
|
+
`test/coordination/model-slot-lease.test.ts`.
|
|
1237
|
+
|
|
1238
|
+
## 8. Risks
|
|
1239
|
+
|
|
1240
|
+
| Risk | Mitigation |
|
|
1241
|
+
| --- | --- |
|
|
1242
|
+
| Footprint drift: measured cost stops matching reality | `models measure` re-measures; `doctor` compares resident actual vs declared and flags drift |
|
|
1243
|
+
| Orphaned VRAM after a failed stop | query compute-apps for orphans before diagnosing capacity; documented in the profile since 2026-08-20 |
|
|
1244
|
+
| Operator absent, requests park forever | `expires_at` on every pending request; expiry returns a clean fallback to the client |
|
|
1245
|
+
| Two controllers race on the ledger | advisory lock + re-read-before-commit; enforcement re-derives the victim set |
|
|
1246
|
+
| A monitor watchdog fights enforcement | the units already declare `Conflicts=`; enforcement uses `systemctl --user`, never raw signals |
|
|
1247
|
+
| Preview and enforcement disagree | enforcement aborts rather than acting on a stale preview |
|
|
1248
|
+
| A backend with no systemd unit cannot be displaced | the capacity policy probes `strata-server` over HTTP precisely because the repo ships no unit for it; enforcement treats "no unit" as a distinct state, never as zero cost, and phase 2 ships tracked unit templates (§4.3) |
|
|
1249
|
+
| Auto mode displaces something important | `auto_load.displace: false` is the default and the only supported value in phase 4 |
|
|
1250
|
+
|
|
1251
|
+
## 9. Decisions taken
|
|
1252
|
+
|
|
1253
|
+
These were open in earlier drafts and are now settled.
|
|
1254
|
+
|
|
1255
|
+
1. **Registry location: repo defaults merged with machine-local overrides.**
|
|
1256
|
+
`config/model-registry.json` holds the model names, engines, units, launch
|
|
1257
|
+
scripts, rails, and context geometry — everything reviewable and mostly
|
|
1258
|
+
machine-independent. `~/.uap/model-registry.json` holds the measured numbers
|
|
1259
|
+
for this machine: device totals, `reserved_mib`, and each config's
|
|
1260
|
+
`resident_gpu_mib` / `host_rss_mib`. The loader deep-merges the local file
|
|
1261
|
+
over the repo file per model and per device, and `uap models validate`
|
|
1262
|
+
reports which file each field in each entry came from, so a stale machine
|
|
1263
|
+
number is never mistaken for a reviewed default. A machine with no local
|
|
1264
|
+
file has no measured footprints, which means everything fails closed — the
|
|
1265
|
+
correct outcome for an unmeasured box.
|
|
1266
|
+
2. **Enforcement lives on the TS side, invoked by the proxy over loopback
|
|
1267
|
+
HTTP.** The proxy is Python and has no systemd machinery; the TS side already
|
|
1268
|
+
owns the units, the capacity policy, the lease ledger, and the dashboard.
|
|
1269
|
+
The proxy calls a loopback control endpoint exposed by the dashboard server
|
|
1270
|
+
(`src/dashboard/server.ts` already runs an HTTP server with mutation
|
|
1271
|
+
authorization) rather than by a new daemon, so there is one process holding
|
|
1272
|
+
placement state and the TUI and dashboard read the same one. The drain
|
|
1273
|
+
therefore reaches the proxy's live request state through an endpoint, not
|
|
1274
|
+
in-process.
|
|
1275
|
+
3. **Alternate configs stay unknown and fail closed.** Only the running config
|
|
1276
|
+
has a measured footprint. The other seven backends keep `cost: null` until
|
|
1277
|
+
the operator runs `uap models measure` against a live instance of each,
|
|
1278
|
+
which means they appear in the UI as "not measured" rather than as loadable
|
|
1279
|
+
options. Deliberately not measuring them now: measuring requires starting
|
|
1280
|
+
each one, which takes the live backend down.
|
|
1281
|
+
4. **`paused` means offload and reload from disk later.** It is not the proxy's
|
|
1282
|
+
existing save/restore mechanism, which checkpoints one slot's KV to host RAM
|
|
1283
|
+
and keeps the slot — it frees no VRAM, is opt-in, gated on single-session
|
|
1284
|
+
use, and with 29.3 GiB of host RAM available against a 52.9 GiB resident
|
|
1285
|
+
process may not have headroom for a checkpoint at all. A paused config is
|
|
1286
|
+
recorded with its offload location and is resumable by reloading from disk.
|
|
1287
|
+
Until that reload path exists for a given engine, `paused` is display-only
|
|
1288
|
+
for that config and the only executable option is `kill`.
|
|
1289
|
+
|
|
1290
|
+
## 10. Open questions
|
|
1291
|
+
|
|
1292
|
+
1. **Video and image models.** They are the trigger the operator described but
|
|
1293
|
+
they are not LLMs and have a different cost shape. Do they get a separate
|
|
1294
|
+
placement lane in v1, or does the registry stay LLM-only?
|
|
1295
|
+
2. **Whose consent for someone else's eviction.** The operator confirms. Does
|
|
1296
|
+
the client whose live generation is being aborted also get a say, or is the
|
|
1297
|
+
operator's yes sufficient? v1 assumes the latter.
|
|
1298
|
+
3. **Cloud models as placement options.** They cost nothing locally, so they
|
|
1299
|
+
pass through. Should they appear in the ranked list as a fallback when
|
|
1300
|
+
nothing fits locally?
|
|
1301
|
+
4. **The enforcement time envelope.** The drain timeout and the pending-hold
|
|
1302
|
+
timeout are constants in every draft so far. They need a measured teardown
|
|
1303
|
+
and start envelope per config: how long stopping each unit takes, whether
|
|
1304
|
+
VRAM release can be confirmed by re-probing the engine PID rather than
|
|
1305
|
+
trusting unit state, and how long the alternate profile takes to reach
|
|
1306
|
+
`/health` warm versus cold. Measuring it takes the live backend down, so it
|
|
1307
|
+
needs the operator's explicit go-ahead.
|
|
1308
|
+
5. **Is host RSS a hard gate or a warning?** It is the binding constraint on
|
|
1309
|
+
this machine (92.4 GiB of 121.7 GiB used), but the number is dominated by
|
|
1310
|
+
mmap'd weights and page cache, so a naive threshold risks false alarms.
|
|
1311
|
+
6. **How `paused` reload actually works per engine.** Strata's `--vram-elastic`
|
|
1312
|
+
and `POST /v1/vram` adjust the arena between requests, which is a different
|
|
1313
|
+
lever from offloading a whole config to disk and bringing it back. Which
|
|
1314
|
+
engines can do the latter at all, and what it costs, is unmeasured.
|