ROADMAP.md
Rama main · réplica de lectura del repositorio original.
# ROADMAP — polyrouter
Where polyrouter is going, what's actually open, and what's been explored but not
committed to. This file is **direction, not a schedule** — nothing here carries a date,
and nothing here is a promise.
For what has already shipped, see [`CHANGELOG.md`](./CHANGELOG.md) — it is the record of
record. For the engineering rules everything here is bound by (pinned stack, the 12
non-negotiable invariants, build order), see [`CLAUDE.md`](./CLAUDE.md).
**No feature code is written from this file.** Work reaches the codebase only as an
approved, spec-delta-carrying change proposal. An item here is thinking; an item that has
graduated has a proposal behind it.
---
## Prioritization
How anything moves. Four bands, and the axes that decide which band something sits in.
### Bands
| Band | Meaning |
|---|---|
| **Now** | Committed and unblocked. A proposal exists; it is being built. |
| **Next** | Committed, but **blocked on a named condition**. Deliberately unscheduled — the condition decides when, not a calendar. |
| **Candidate** | Parked. The design is understood well enough to scope; would be taken up on a clear demand signal. |
| **Speculative** | Parked. Needs a spike or an external trigger before it can even be scoped honestly. |
### Axes
| Axis | The question | Why it carries weight here |
|---|---|---|
| **Reach** | How many self-hosters does this unblock — and is it a hard block or a convenience? | Self-hosting is the product. A hard block for a few beats a nicety for many. |
| **Cost** | Rough size, XS → XL. | — |
| **Confidence** | Is the design known, or is there a load-bearing unknown? | An unknown is answered by a spike, not by a proposal. |
| **Invariant pressure** | Does this require loosening one of the 12 invariants? | The invariants are the quality bar. Loosening one costs disproportionate review and leaves lasting risk, so it is priced separately from effort. |
### Rules
1. **Invariant pressure is a veto, not a weight.** Anything that needs an invariant relaxed
gets that decision made *on its own merits, first* — never as a side effect of shipping
a feature that happens to need it.
2. **Low confidence caps the band at Speculative**, however high the reach. The remedy is
a cheap experiment that converts the unknown, not a more detailed plan.
3. **Reach breaks ties.** Between two Candidates of similar cost, the one that unblocks a
hard stop wins over the one that removes friction.
4. **Cheap and reversible beats correct and total.** A slice that ships behind a flag and
can be withdrawn outranks a complete design that cannot.
5. **Nothing enters Now without a written proposal and spec deltas.**
### Promotion
```
Speculative ──spike answers the unknown──▶ Candidate
Candidate ──demand signal + open decisions settled──▶ Next
Next ──named condition met──▶ Now ──proposal ▸ implement ▸ archive──▶ CHANGELOG
```
A **demand signal** is a real one: an issue, a user report, or a deployment that is
actually blocked. Anticipated demand is not a signal — that is what the parking lot is
for.
---
## Now — committed and building
### Mobile parity for the dashboard — a three-phase epic
The dashboard shell is mobile-aware; the layout inside it is not. `styles.css` carries
exactly one `@media` rule and it is `prefers-reduced-motion` — there are zero width-based
breakpoints, so every page is laid out for a desktop viewport. On a phone the sidebar alone
takes 53% of the width and the requests table needs ~1020px to be legible.
The work is much smaller than the frontend's 743 inline style attributes suggest. Of their
2,065 declarations only **46** are breakpoint-relevant, 13 of those are `max-width` values
that already shrink correctly, and the remaining 33 reduce to **8 grid patterns**. The
decisive lever: of 186 inline `display:flex` declarations, **none** declares
`flex-direction: row`, so a breakpoint class can stack 137 implicit rows with no specificity
fight and no JSX edit.
Split into three phases because each answers a different question, and because a single
change would run past anything shipped here (the largest to date is 50 tasks).
| Phase | Scope | Cost · Confidence · Invariant pressure |
|---|---|---|
| **1 — pages** ✅ **shipped** (archived 2026-08-06) | Media layer + per-table container queries at measured thresholds; `STYLESEED.md` gained a locked Responsive section and the `/ss-score` gate enforces it; sidebar → **expandable** icon rail; 9 grid templates across 13 page sites; **all four** tables → stacked records; 24px/44px target floors | M · high · none |
| **2 — overlays** ✅ **shipped** (archived 2026-08-06) | The inspector drawer (440px on a 390px screen, 120px off-edge at 320px), all six modal kinds and both confirmation dialogs become bottom sheets below 768px, scrolling internally — three modal kinds previously put their actions past a fixed backdrop where no scroll reached them. First handling of the on-screen keyboard (visual-viewport seam, pinch-zoom distinguished from a keyboard) and safe-area insets; toast given a width | M · high · none |
| **3 — touch reorder** ✅ **shipped** (archived 2026-08-06) | Explicit ↑/↓ controls per chain row, sharing the mover with the keyboard path so the three transports cannot drift; shown below `narrow` **or** on `any-pointer: coarse` (a touch laptop reports `pointer: fine` while being exactly the device that cannot drag). Measuring the row also found it was **already broken**: at 320px it had 194px of space and put 253px in it, so the model id computed to zero width with the price painted over it — fixed by separating information from actions | S · high · none |
**Why this order.** Phase 1 pays the one-time costs the others inherit: where breakpoints
live, the class vocabulary, the lock extension, the table→card pattern, and how to test
layout in a harness that performs no layout. Phases 2 and 3 are then "apply the established
pattern," not new decisions. Nothing in phase 1 creates rework for the others — it is a
strict prefix, not a divergent path.
**A prerequisite landed between the phases.** `centralize-overlay-layering` replaced the
per-surface Escape predicates with one arbiter, because phase 2 adds overlay surfaces and the
old mechanism cost a hand-written relationship per pair. It also fixed a live accessibility
defect it uncovered — two body-capture dialogs claiming `aria-modal` with no focus trap — and
narrowed the `<dialog>` question below: deriving paint order made the drawer's backdrop cover
the sidebar, so the expanded nav and an open drawer can no longer be reached together through
the UI.
**Phase 2 checked that and found the premise false.** Visually exclusive is not the same as
exclusive: in *state* the two coexist deliberately, and the archived contract requires it —
"Dismissing an underlying layer leaves the ones above it alone", with `navRail.test.tsx:218`
guarding the nav+drawer instance ("the drawer must survive the nav closing"). Making them
exclusive would therefore be a behaviour change contradicting an archived requirement, not
the free clarification it looked like, and it buys only the *option* of a native `<dialog>`
migration. Phase 2 did not need it, so **the question stays open** — now with the evidence
attached instead of a wrong assumption. Whoever migrates to `<dialog>` must settle it first:
a `position: fixed` nav can never paint above the top layer.
**What phase 1 actually left phase 2.** More than planned, and in phase 2's favour: a
Playwright suite with a `FakeApiClient`-backed harness and the locked viewport matrix
already exists, so phase 2 writes assertions rather than infrastructure. The expandable
rail also built a real modal surface — scrim, focus trap, `inert` content behind, and a
resolved dismissal order across stacked overlays — which is the groundwork the drawer and
modals need. The one thing phase 1 could not resolve — that a third overlay would hit the
same class of Escape collision — is what `centralize-overlay-layering` then went and fixed,
so phase 2 inherits a single arbiter rather than a third pairwise patch.
**Epic complete.** All three phases shipped 2026-08-06. What each phase actually cost, versus
the estimate: phase 1 as expected; phase 2 larger, because the keyboard/safe-area work was new
capability rather than a layout fix, and because three modal kinds were unreachable rather than
merely cramped; phase 3 larger too, because the chain row was already broken before a control
was added to it. The recurring lesson is in that pattern — every phase found something by
**measuring the thing** that reading the code had not suggested.
**Phase 3 was cheaper than it looks.** The reorder model is already transport-agnostic
(`moveTierEntry` / `beginTierDrag` / `endTierDrag`, plus `keyboardMove`) and already has two
consumers — pointer drag and an `Alt`+Arrow keyboard path. A touch path is a third consumer
of the same functions, and the expensive part (deferred writes, rollback to the latest
confirmed order, refresh-can't-repaint-mid-drag, sign-out discard) is built and covered by
`routingDrag.test.tsx`'s 835 lines. Those invariants are transport-independent. Explicit
↑/↓ controls would reuse `keyboardMove` verbatim — no new reorder logic at all — and are
arguably better touch UX than drag inside a scrolling list.
**Open decisions, to settle in the phase they belong to.** Whether the narrow-width nav
should ultimately be a bottom tab bar rather than the icon rail phase 1 ships (a bottom bar
inverts the shell from a flex row to a column and cannot hold 9 entries without new IA —
deliberately deferred, not rejected). Whether container queries should supersede viewport
media queries once the shell's pane containment can be touched safely — attractive because
it would also fix the desktop case of the inspector drawer squeezing the requests table,
which a media query cannot see.
**The cost that is not one-time:** after phase 3 every future UI change must be checked at
two widths, and the `/ss-score` gate has to enforce it or the work decays within a few
features. That ongoing tax is the strongest argument for stopping after a phase if the
later ones stop earning their keep — each phase is independently shippable.
### Workload routing — Epic W (decided 2026-08-22)
`model: auto` asks one question today — *how hard is this?* — and maps the answer onto the
STRONG / CHEAP band targets. It never asks *what kind of work* a request is, so an operator
with a coding tier, a vision-capable tier, or a cheap reliable-JSON extractor cannot have
`auto` reach them without per-client `model:` pinning. Epic W adds that second, categorical
axis — named **workload** — in telemetry-first order (the same trust-precedes-autonomy
sequencing as Epic AR): record and show the mix, then route on it.
Settled up front, so they are not re-litigated per change: the feature is "workload" (a
tier stays the destination, never the feature); the taxonomy is fixed at five classes —
`code`, `research`, `vision`, `structured`, `writing` (+ the telemetry-only `none`) — and
bumps only by a change; detection is invariant-bound — structural signals in the baseline
image, an embedding-vs-anchors source only inside the flag-gated semantic module, and
**never** a keyword path (invariant 9 / language neutrality), which makes `research` and
`writing` semantic-only; it is **not an L4** — structural workloads are a second verdict from
the L1 mechanism, semantic ones from L2, with no new header and no new `ROUTING_AUTO_LAYERS`
token. Two problems were hiding in the original ask and ship separately: **hard capability**
(never pick a member that cannot serve images / tools / JSON — the OC-1 principle, catalog
`supports_*` flags) and **soft specialty** (prefer a coding model for code).
| Change | Scope | Cost · Confidence · Invariant pressure | Status |
|---|---|---|---|
| **W-1 `add-workload-telemetry`** | Pure structural classifier over the EXISTING L1 feature vector (`vision` ← image block; `structured` ← declared JSON output format; `code` ← fenced share ≥ 0.30 ∧ ≥ 200 chars; precedence vision > structured > code); the quad `workload_class/score/source/revision` on parent request-log rows (migration 0026); `workloadMix` on `GET /api/analytics/auto` (reported-basis spend on both ledgers, unpriced / coverage / revision disclosures); inspector chip + "Workload mix" block. Zero routing change. | S/M · high · none | ✅ **shipped** (archived 2026-08-22; five codex clink rounds folded) |
| **W-2 `add-workload-routing`** | `auto_workload` rules (class → tier/model; `priority` orders duplicates within a class — co-firing is resolved by the classifier, vision > structured > code; `none` never routable); the workload stage BEFORE the complexity pipeline (confident → serve, `decision_layer='workload'`, no cascade/L2; `none`/no rule/unroutable → today's flow byte-identical); "Workload targets" card beside Band targets (reserved classes disabled until W-3) | M · high · none | ✅ **shipped** (archived 2026-08-22; built on user direction — the W-1 evidence gate was **skipped**, as for L2; five codex clink rounds folded: 4 artifact + 1 implementation diff) |
| **W-3 `add-semantic-workloads`** | Per-class bundled anchors → N centroids, argmax + margin (`research`/`writing` light up); runs on ALL `auto` when the module is enabled, ONE embed shared with the L2 complexity classifier | M/L · medium · none | **Now** — spike PASSED 2026-08-22 (5-way top-1 92.7 %, research/writing 42/44; shipped reserved-only rule 0.05/0.20: precision 100 % / recall 93 %; real MiniLM through the shipped loader — Plans/Workload.md); proposed, four codex artifact rounds → APPROVE, applied and **archived 2026-08-22** (five codex rounds folded; real-bundle boot smoke green) — ✅ **shipped** |
| **W-4 `add-workload-scoped-bands`** | Class-qualified `auto_high`/`auto_low` + cascade within a class | L · medium · none | **Shipped 2026-08-22** (archived; gate skipped on direction) |
Adjacent and independent of the ladder (Candidate, no proposal yet): **`add-capability-guardrails`**
— the OC-1 extension that parses the catalog's `supports_vision` / `supports_function_calling` /
`supports_response_schema` and filters or defers router-chosen chain members that cannot serve
the request's declared demand (unknown caps never filter); and **`add-agent-default-tier`** —
a per-agent default tier (its precedence — Layer-0 default vs explicit pin — is the one decision
that proposal must settle) plus tier display-name/description editing in the Routing card.
---
## Next — committed, blocked on a named condition
The build plan is otherwise complete; the release history in
[`CHANGELOG.md`](./CHANGELOG.md) is current through v0.11.0.
**Mobile parity phases 2 and 3** sit here, blocked on a condition that is not evidence but
sequence: **phase 1 archived**. They are scoped in the epic above and get proposals of their
own when that condition is met — drafting their specs now would bake in assumptions about a
breakpoint layer that does not exist yet.
Two further committed items remain open. Both are **evidence-gated**: the evidence can only come
from real traffic on a deployed instance, so neither is scheduled, and building either one
early would be guessing.
| Item | Build only when this is observed | Cost · Confidence · Invariant pressure |
|---|---|---|
| **Auto-routing band ladder** — generalize the `high` / `low` / `ambiguous` split into a configured N-band ladder | The calibrated 3-band scheme **saturates**: ambiguous share stays **> 50%** in the Auto-performance view *after* calibration converges (history shows moves, then goes quiet), **AND** edge-zone rates are bimodal — both edges keep qualifying against opposite bounds | M · high · none |
| **Does structure have anything left to give?** — the successor question to the ladder | The ladder's condition, **plus** a negative result: calibration converged and band mix stable, yet quality-escalation in the ambiguous **middle** — not the edges — stays high | — · low · none |
| **W-3 semantic workloads** — `research` / `writing` via per-class anchors in the flag-gated module | An **offline spike passes** (the Plans/L2.md task-0.1 pattern: curated per-class anchors + a disjoint eval set, confusion matrix, real MiniLM through the shipped bundle loader); a null result stops W-3 and W-1/W-2 stand on their own | M/L · medium · none |
| **W-4 class-scoped bands** — cheap-code → strong-code inside a workload | ~~W-1/W-2 telemetry shows BOTH mixed classes AND within-class difficulty spread~~ shipped 2026-08-22 (gate skipped on direction — unassessable pre-deployment) | L · medium · none |
**Gate status, last evaluated 2026-07-20: `UNASSESSABLE` pre-deployment.** Both need weeks
of real traffic under threshold calibration. The gates are evaluated by a read-only query
against a deployed instance's Postgres, with thresholds mirroring the shipped
`calibrationStats` / `effectiveThresholds` / `autoPerformance` definitions exactly, emitting
a per-tenant `MET | NOT_MET | INSUFFICIENT_DATA` verdict.
> **On the Layer-2 semantic classifier.** An earlier gated item proposed a local embedding
> classifier between the structural and cascade layers. It is **closed — superseded**: the
> L2 semantic stack was built and released in **v0.8.0** as a four-change epic (embedder →
> routing → learning → dashboard). Its evidence gate was never evaluated; the build was
> directed rather than triggered. The constitution amendment that gate required *did*
> happen first and explicitly — [`CLAUDE.md`](./CLAUDE.md) was amended to reclassify the L2
> stack from cloud-tier-only to a **flag-gated optional module** (the baseline image stays
> ONNX-runtime- and model-free; it activates only via `SEMANTIC_MODEL_PATH` +
> `ROUTING_AUTO_LAYERS`). What remains open is only the narrower successor question above.
**Also deferred, not gated:** multi-seat organizations/workspaces, and the two genuine
cloud-tier graduations — splitting the data plane into its own service, and moving request
analytics to a time-series store. All three are forward-compatible stubs in the schema
today; none is scheduled.
---
## Potential — parking lot
Explored and written down so the reasoning isn't lost. **Not committed**, no proposal.
Each entry is written to be picked up cold: what was verified, what is still unknown, what
decisions are outstanding, and the shape it would take if pursued.
Vendor facts carry the date they were verified — cloud APIs move, and a stale fact is
worse than no fact. **Re-verify before proposing.**
---
### Enterprise providers — Azure OpenAI, Google Vertex AI, Amazon Bedrock
*Explored 2026-07-31.*
| Slice | Band | Reach | Cost | Confidence | Invariant pressure |
|---|---|---|---|---|---|
| **1 · Azure + Bedrock via their OpenAI-compatible endpoints** | **Candidate** | High — unblocks the two most-asked enterprise backends | XS/S | High — nothing unverified | **Low**, but non-zero: pricing host-matching (see decisions) |
| **2 · Credential minting** (Vertex service-account JWT; Bedrock short-term keys) | Speculative | Medium — Vertex is unusable without it | M | Medium | **Veto-class** if ambient cloud credentials are in scope — see invariant 6 below |
| **3 · Native partner models** (Claude on Vertex / Bedrock) | Speculative | Medium | M/L | Low — gated on experiment 2 | Low |
| **4 · Bedrock streaming** (binary `eventstream` framing) | Speculative | Low | M | Low — may evaporate entirely | Low |
**The short version:** this is very likely **not** three new provider adapters. All three
now ship an OpenAI-compatible front door that polyrouter's existing `openai_compatible`
protocol already speaks, so the first useful slice needs **zero data-plane code**. The real
work is credential lifecycle, pricing host-matching, and onboarding UX — plus a genuine
long tail for partner models.
#### What was verified (2026-07-31)
| | Compat endpoint | Auth | Model specified in |
|---|---|---|---|
| **Azure** | `https://{res}.openai.azure.com/openai/v1/` | `api-key:` **or** `Authorization: Bearer <key>` | body (= deployment name) |
| **Bedrock** | `https://bedrock-runtime.{region}.amazonaws.com/openai/v1/` | `Authorization: Bearer $AWS_BEARER_TOKEN_BEDROCK` | body |
| **Vertex** | `https://{loc}-aiplatform.googleapis.com/v1/projects/{p}/locations/{l}/endpoints/openapi` | `Authorization: Bearer <access token>` | body (`google/gemini-3.5-flash`) |
Two findings collapse the parts that looked hardest:
1. **Azure's v1 API drops `api-version` and the `/deployments/{name}/` path segment.**
The model moves into the body. That removes the per-request-URL problem for Azure
entirely. (`base_url` also accepts the `.services.ai.azure.com` form.)
2. **Amazon Bedrock API keys are plain bearer tokens that work on the _native_ runtime
paths**, not just the compat one — including Claude:
```
POST https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-sonnet-4-6/converse
Authorization: Bearer $AWS_BEARER_TOKEN_BEDROCK
```
**SigV4 request signing is therefore optional, not required.** This was the single
scariest item on the list and it is off the table.
Sources: [Azure v1 API](https://learn.microsoft.com/en-us/azure/foundry/openai/api-version-lifecycle) ·
[Bedrock API keys](https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html) ·
[Bedrock OpenAI models](https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-openai.html) ·
[Vertex OpenAI compatibility](https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/start/openai) ·
[Claude on Vertex](https://platform.claude.com/docs/en/build-with-claude/claude-on-vertex-ai)
#### Gap analysis against the existing seam
Read against `packages/data-plane/src/providers/http-adapter.ts`:
```
HttpAdapterSpec today Azure Bedrock Vertex Claude-on-
/v1 /openai/v1 /openapi Vertex|Bedrock
─────────────────────────────────────────────────────────────────────────────────
chatPath: string ← STATIC, joined ok ok ok ✗ model in path
once at adapter creation
authHeaders(cred) ← SYNC, cred-only ok ok ok* ok
readSseChunks ← SSE hardcoded ok ok ok ✗ Bedrock stream
translate.requestOut ok ok ok ✗ anthropic_version,
drop `model`
─────────────────────────────────────────────────────────────────────────────────
data-plane LOC needed: 0 0 0 real work
* access token expires in ~1h
```
`http-adapter.ts:227` — `const chatUrl = joinUrl(config.baseUrl, spec.chatPath)`, computed
once at adapter creation — is the single line gating the right-hand column. Everything in
the left three columns runs on today's code **unmodified**.
#### The four real problems
1. **Credential lifecycle.** Vertex access tokens live ~1h; Bedrock *short-term* keys ~12h.
Azure keys and Bedrock *long-term* keys never expire (paste-once — effectively free).
Refresh rails already exist from the subscription-OAuth work: `credentialExpiresAt`,
`credentialError`, and the `oauth_bearer` auth scheme.
2. **Pricing / host matching.** `PROVIDER_FAMILY_HOSTS`
(`packages/shared/src/server/pricing/resolve.ts:64`) is an **exact-host** map by design.
Enterprise hosts are per-tenant/per-region wildcards. Without a fix, every enterprise
request records **cost unknown** — the most user-visible gap of the four.
3. **Onboarding.** Nobody hand-types
`.../projects/{p}/locations/{l}/endpoints/openapi`. This is a preset + wizard problem,
and the bundled preset registry already exists
(`packages/control-plane/src/subscription-oauth/presets.ts`).
4. **The long tail.** Claude on Vertex/Bedrock genuinely needs per-request URLs, body
shaping (`anthropic_version`, drop `model`), and — for Bedrock streaming — an
`application/vnd.amazon.eventstream` binary frame decoder.
#### Open decisions (settle these before proposing)
- **Pricing host patterns vs. cost-correctness.** `resolve.ts:149` documents the exact-host
map as a deliberate guard: *"an unknown/reseller host NEVER inherits a well-known
provider's price."* Enterprise hosts force pattern matching — a real loosening of a
guard adjacent to the cost-immutability invariant. Doable conservatively (anchored
suffix allowlist, `unknown` still the default), but it deserves its own scrutiny.
- **Azure deployment names are arbitrary.** A deployment called `prod-chat` derives
`azure:prod-chat` → catalog miss → cost unknown, permanently. Correct pricing needs the
deployment→model mapping read off the models listing. This is the one part of the
otherwise-free Azure slice that isn't free, and it may argue for capturing a
`resolved_model` on the Model row **generally** — which would also help resellers.
- **Ambient cloud credentials vs. invariant 6.** The natural enterprise deploy is
polyrouter on EKS/GKE with a workload identity and zero pasted secrets. Both AWS IMDS
and GCP metadata live at `169.254.169.254` — the link-local range invariant 6 blocks.
Reading: the invariant governs *user-supplied* URLs, and IMDS is a fixed constant like
the OAuth token endpoints already are, so this is an implementation gate rather than an
invariant violation. But per prioritization rule 1 it needs a deliberate, narrow
carve-out on the **credential-minting client only** — never on the provider transport —
decided on its own merits, not as a side effect of a Vertex task.
- **Where structured credentials live.** A GCP service-account JSON or an AWS key pair
isn't a string. The OAuth envelope already carries structured credentials, so extending
it with machine grant types (e.g. `gcp_service_account`) reuses refresh, expiry display,
and the reauthorize-required state. It's a conceptual stretch — no user consent, no
`state`, no authorize URL — but the alternative is a parallel credential-provider
abstraction. Current lean: reuse the existing rails.
- **Kind taxonomy.** `kind ∈ {api_key, subscription, custom, local}` drives both the SSRF
guard context and the "model-own price honored only for `custom`/`local`" rule. Adding
an `enterprise` kind ripples through both; `api_key` plus a separate vendor/preset field
is probably cleaner.
#### Shape if pursued
```
Slice 1 Azure + Bedrock, OpenAI-compat presets + pricing hosts. 0 data-plane LOC
Slice 2 Credential minting Vertex SA-JWT, Bedrock short-term keys
Slice 3 Native partner models per-request URL + body shaping
Slice 4 Bedrock streaming eventstream frame decoder
```
Dependency-ordered, each independently shippable. Note this **inverts the intuitive
ordering**: Vertex looks like "just another Google endpoint" but is the one that cannot
work at all without new machinery, because Vertex has no long-lived API key —
IAM tokens only.
#### Two experiments to run before writing any proposal
Both are cheap, and each one converts a Speculative slice into a scoped one — the
prioritization rule 2 remedy.
1. **Add Azure today through the existing custom-provider form** —
`base_url = https://{res}.openai.azure.com/openai/v1`, kind `custom`, protocol
`openai_compatible`, paste the key. The path was traced (DTO → `joinUrl` →
`Authorization: Bearer` → `/models` → SSRF-public) and nothing appears to block it.
If it works, slice 1 for Azure is documentation plus a pricing-host entry.
2. **Does Bedrock's `/openai/v1/chat/completions` accept a non-`openai.*` model id?**
AWS documents it under "OpenAI models" with only `openai.gpt-oss-*` in every example,
but the Mantle-engine references suggest it may be broader. If Claude works through
that path, **slices 3 and 4 collapse entirely for Bedrock** — no per-request URLs, no
binary framing. Highest-leverage single test on the board.
Both fit the pattern already used for the OAuth presets: ship disabled, verify live, then
flip the enablement gate.