Ctrl K
ComunidadGuías…

ROADMAP.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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.