# pi-airpx — Architecture & Design

How the plugin is built, and why — read through **SOLID / DRY / KISS**.

For *usage* (install, `/login`, env vars, field mapping) see the
[README](../README.md). This document is about *structure and design intent*.

---

## 1. What the plugin does (one paragraph)

pi-airpx registers a pi provider named `airpx` whose model list is fetched
**live** from the airpx proxy's `GET /v1/models`. By default it loads the
proxy's **main list** (anonymous response = `proxy_public_models`, ~33 canonical
super-current models) with prices and discounts already applied. Auth is an
`sk-proxy-…` key entered via `/login airpx` (or `AIRPX_API_KEY`); the key is
used for **inference**, not for fetching the catalog.

---

## 2. Module map (two files, one boundary)

```
extensions/index.ts   ── I/O shell ──   fetch · cache · env · pi.registerProvider · oauth
        │  imports { mapCatalog, PiModel, ProxyModel }
        ▼
src/map.ts            ── pure core ──   ProxyModel → PiModel, skip alias/pseudo, overrides
```

The single most important design decision is this **purity boundary**:

| Layer | File | May do | May NOT do |
|-------|------|--------|------------|
| Pure core | `src/map.ts` | transform data, decide inclusion | no `fetch`, no `fs`, no `pi` import, no env |
| I/O shell | `extensions/index.ts` | network, disk cache, env, pi API, oauth | no mapping logic (delegates to core) |

Everything else below follows from keeping that line clean.

---

## 3. SOLID

### S — Single Responsibility
Each unit owns exactly one reason to change:

- `src/map.ts` — **the catalog schema mapping**. Changes only when the
  proxy row shape or the pi model shape changes.
- `extensions/index.ts` — **the runtime wiring** (where the key comes from,
  where the cache lives, how pi registers a provider). Changes only when the
  *plumbing* changes, never when a field is renamed.
- Within the core, responsibilities are further split into named pure
  functions: `isAlias`, `isPseudoRow`, `toPiModel`, `deriveThinking`,
  `mapCatalog`. Each answers one question; none does two things.

Concretely: renaming `max_output_tokens` touches only `toPiModel`. Moving the
cache path touches only `index.ts`. The two axes of change never collide.

### O — Open/Closed
- **Per-model tweaks are data, not code.** `_OVERRIDES` is a lookup table
  keyed by model id (`gpt-5.3-codex → { api: "openai-responses" }`,
  `kimi-for-coding → { name }`). Adding a tweak = adding a row, not editing the
  mapping algorithm. `mapCatalog` spreads the override on top of the mapped
  entry (`{ ...mapped, ...override }`) and never needs to change.
- **Pseudo-row detection is extensible** via the `_PROVIDER_GROUPS` set plus a
  structural heuristic — new upstream provider names extend the set without
  touching the decision logic.
- The `deriveThinking` seam is open for future `xhigh`/`max` level mapping
  without altering `toPiModel`'s call site (it already conditionally attaches
  the result).

### L — Liskov / substitutability
`PiModel` is a strict subset of pi's `ProviderModelConfig`. Every object the
core emits is a valid pi model config — the I/O shell passes them to
`pi.registerProvider` without adaptation. There is no "almost a model" type that
needs special-casing downstream.

### I — Interface Segregation
`ProxyModel` declares **only the fields the plugin consumes** (`id`,
`display_name`, `reasoning`, `supports_vision`, `context_window`,
`max_output_tokens`, `supported_parameters`, `is_free`, `alias_of`, `pricing`,
`effective_pricing`) — not the full ~20-field `/v1/models` row. The core depends
on the narrowest possible view of the upstream contract, so unrelated proxy
changes don't ripple in.

### D — Dependency Inversion
The pure core depends on **nothing** — not pi, not node builtins, not the
network. The I/O shell depends on the core (imports `mapCatalog`), never the
reverse. High-level policy (what a model *is*) does not depend on low-level
detail (how bytes arrive); both meet at the plain `ProxyModel`/`PiModel` data
contracts. This is exactly why the core is unit-testable with a frozen JSON
fixture and zero mocks.

---

## 4. DRY

- **One mapping, one place.** All proxy→pi field logic lives in `toPiModel` /
  `mapCatalog`. `index.ts` contains **zero** mapping code — it only calls
  `mapCatalog`. The default (anonymous) path and the `AIRPX_ALL_MODELS=1` keyed
  path both run through the *same* `mapCatalog`, so alias/pseudo skipping and
  overrides can never diverge between them.
- **One inclusion rule.** "Is this row loadable?" is answered once, by
  `isAlias` + `isPseudoRow`, and reused by both `mapCatalog` and the tests.
- **One cache rule.** A successful fetch replaces the last-good snapshot exactly;
  fallback reads the same cache from both startup and `/login` paths.
- **Deliberately NOT shared with the server.** The Python `agent_config.py`
  projection and this TS mapper serve *different consumers on different
  lifecycles*; forcing a cross-language "single source" would couple two
  independently-evolving artifacts. DRY means *don't repeat a decision within a
  cohesive unit* — not *fuse two systems*. The blind review confirmed a
  cross-language parity test would be ceremony; a plain TS unit test is the
  right guard.

---

## 5. KISS

- **Two files, no build step.** Runs under `node --experimental-strip-types`;
  no bundler, no transpile, no framework.
- **Anonymous fetch by default.** The subtle "key role is the owner's role, so
  a user key can see all 348 models" problem is solved by the *simplest*
  possible move — don't send the key when fetching the list. No server flag, no
  client-side allowlist reconstruction, no role parsing.
- **Server owns authorization.** The plugin does not re-implement access
  control; it reflects what `/v1/models` returns. Dedup/skip in the client is
  cosmetic, not a security boundary.
- **`deriveThinking` returns `undefined` today** (lets pi use its default
  off..high ladder). We did not build a speculative level-mapping engine before
  the data justifies it — YAGNI.
- **Prices are runtime-only.** No committed price tables to keep in sync with a
  time-versioned, discounted pricing backend.

---

## 6. Data flow

```
startup (async factory)                    /login airpx
─────────────────────                      ────────────
resolveKey()  (auth.json → AIRPX_API_KEY)  prompt sk-proxy key
      │                                          │
fetchCatalog(key?)                         fetchCatalog(key)
  · anon by default (no Authorization)       · 401/403 → "key rejected", creds NOT saved
  · AIRPX_ALL_MODELS=1 → Bearer key          · else fetch + map
  · AbortController 15s                            │
      │                                            │
mapCatalog(rows)  ── skip alias/pseudo, map, override
      │                                            │
fresh catalog (successful fetch) ── replaces last-good cache exactly
      │                                            │
saveCache(fresh)                           saveCache(fresh)
      │                                            │
register(pi, key, fresh)  ── provider "airpx"
```

### Two invariants worth calling out

1. **Developer-role handling lives in the proxy.** pi may send OpenAI's
   `developer` role to airpx; the proxy normalizes it for non-OpenAI upstreams
   (Anthropic/Kimi/Gemini/GLM/etc.) and preserves it for OpenAI/Responses routes.
   Do not force `compat.supportsDeveloperRole=false` at the plugin level — that
   would unnecessarily downgrade OpenAI-family models.
2. **Successful fetch replaces the cache exactly.** The cache is a last-good
   snapshot, not a union. Removed/re-priced models disappear on the next
   successful main-list fetch, and `AIRPX_ALL_MODELS=1` never permanently pollutes
   the default list.

---

## 7. Failure model (never break boot)

The async factory **never throws**. Every failure degrades to the last-good
cache, and the provider is registered regardless — even with an empty list —
so `/login airpx` keeps working and pi always starts.

| Situation | Behavior |
|-----------|----------|
| Network error / timeout (15s) | register from cache; log stale |
| 401/403 during `/login` | clear "key rejected"; credentials NOT saved |
| Corrupt / missing cache | `loadCache()` returns `[]`; register empty |
| Malformed row in response | `mapCatalog` skips rows without a string `id` |
| Cache write fails | best-effort; boot continues |

---

## 8. Test strategy

- **Unit tests target the pure core** (`test/map.test.mjs`) against a frozen
  fixture (`test/fixtures/v1_models.sample.json`) — no network, deterministic.
- Fixtures use **undiscounted `pricing`** (not live `effective_pricing`) so they
  never rot as discounts change.
- Assertions are meaningful, not weak: full `deepEqual` on cost; explicit checks
  that alias/pseudo rows are excluded while a legacy `context_window: 0` row is
  *included* (proving pseudo-detection is structural, not ctx-based).
- The I/O shell is validated live (`pi --list-models`, a real claude round-trip
  to confirm the compat flag) rather than mocked — the seam is thin enough that
  a mock would test the mock.

---

## 9. Extension points (where to change things)

| I want to… | Touch |
|------------|-------|
| Add a per-model tweak (api/name/…) | `_OVERRIDES` in `src/map.ts` |
| Change a field mapping | `toPiModel` in `src/map.ts` |
| Recognize a new provider-group pseudo id | `_PROVIDER_GROUPS` in `src/map.ts` |
| Add thinking-level derivation | `deriveThinking` in `src/map.ts` |
| Change where the key/cache comes from | `index.ts` (resolveKey / CACHE_PATH) |
| Change fetch/auth/timeout policy | `fetchCatalog` in `index.ts` |
| Change how pi registers the provider | `register` in `index.ts` |

If a change needs edits in **both** files to add one model tweak, the
Open/Closed boundary has been broken — reach for `_OVERRIDES` instead.
