No description
  • TypeScript 100%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-24 23:23:57 -06:00
.forgejo/workflows chore: treat main as the default branch for releases. 2026-09-24 21:48:44 -06:00
examples Initial commit: canonical model map with effort, thinking, and fast variants. 2026-09-24 21:43:35 -06:00
src Validate mapped backends at session start 2026-09-24 23:23:57 -06:00
.gitignore Initial commit: canonical model map with effort, thinking, and fast variants. 2026-09-24 21:43:35 -06:00
LICENSE Initial commit: canonical model map with effort, thinking, and fast variants. 2026-09-24 21:43:35 -06:00
package-lock.json Initial commit: canonical model map with effort, thinking, and fast variants. 2026-09-24 21:43:35 -06:00
package.json Initial commit: canonical model map with effort, thinking, and fast variants. 2026-09-24 21:43:35 -06:00
README.md chore: treat main as the default branch for releases. 2026-09-24 21:48:44 -06:00
tsconfig.json Initial commit: canonical model map with effort, thinking, and fast variants. 2026-09-24 21:43:35 -06:00

pi-model-map

JSON-configurable Pi extension that collapses provider-specific baked-in effort/thinking/fast model variants into canonical model IDs, controlled by Pi's native effort selector plus two extra mapping-only toggles: /pi-model-map-thinking and /pi-model-map-fast.

Terminology note: this README calls Pi's /thinking selector "effort" throughout (as most other harnesses do — low/medium/high/etc.), to avoid confusion with the separate "thinking mode" axis described below.

The core resolution function in src/mapping.ts — the canonical-id/effort-level → backend-id lookup — is ported from @open-cursor/pi-agent's toCursorId() / MODEL_MAP index-building in src/models/mapping.ts. Everything else — the JSON config format, the mappings/map schema (including the default/thinking/fast/thinking-fast variant axes), multi-provider "provider/modelId" delegate references, loading multiple config files from a model-map/ directory, per-provider registration via Pi's registerProvider, the /pi-model-map-thinking / /pi-model-map-fast commands, and the namespace-clash warnings — is original to pi-model-map and has no equivalent in open-cursor. See Credits & License below for the precise breakdown.

Why this exists

Some providers don't expose reasoning/effort as a normal API parameter. Cursor, for example, bakes three separate axes into the model ID string itself:

  • Effort level — claude-opus-5-low / -medium / -high / etc. This maps naturally onto Pi's native /thinking <level> selector (what this README calls "effort").
  • Thinking mode — a distinct -thinking- segment (claude-opus-5-thinking-high vs. claude-opus-5-high). This is closer to Anthropic's own thinking.type: enabled/adaptive/disabled API concept — whether the model uses extended-thinking blocks at all — and is independent of effort level.
  • Fast tier — a -fast suffix (claude-opus-5-high-fast) selecting a faster routing tier. Undocumented by Cursor, but distinct from a "priority" request header some other providers use for the same idea.

That's up to 4 backend model IDs per effort level (normal, thinking, fast, thinking+fast) — usually surfaced to users as a wall of near-duplicate picker entries. pi-model-map collapses all of that into one canonical model, letting Pi's native effort selector handle the first axis, and adding two small, mapping-scoped toggle commands for the other two:

  • /pi-model-map-thinking [on|off] — use the mapped "thinking" backend variant for the current effort level.
  • /pi-model-map-fast [on|off] — use the mapped "fast" backend variant for the current effort level.

Important: these commands do not add, emulate, or send any real API parameter (no thinking.type, no service_tier header, nothing). They only change which pre-baked backend model ID string a map entry resolves to. If a provider instead exposes these as real request parameters, use that provider's own mechanism (e.g. a before_provider_request hook) instead of pi-model-map.

Install

pi install /home/jadelclemens/code/pi-model-map

Each mapping delegates inference to an existing Pi provider (e.g. cursor-agent, opencode) that owns real authentication.

Configuration

Configs live in a model-map/ directory — one .json file per provider mapping:

mkdir -p ~/.pi/agent/model-map
cp examples/cursor.json ~/.pi/agent/model-map/cursor.json

Pi loads every *.json file in the first matching directory:

  1. ./model-map/
  2. ./.pi/model-map/
  3. ~/.pi/agent/model-map/

Each file registers a separate provider namespace (e.g. cursor/...). Add another .json file (e.g. opencode.json) to register additional provider namespaces alongside it, one file per delegate provider.

Per-file schema

{
  // Provider namespace in /model. Defaults to the filename stem (cursor.json → "cursor").
  "provider": "cursor",

  // Pi catalog metadata (not used for auth — see below).
  "api": "cursor-agent",
  "baseUrl": "https://api2.cursor.sh",
  "name": "Cursor (model map)",

  // Optional: path to the delegate provider's live catalog cache, used only to warn if a
  // backend id below has been renamed/retired upstream (see "Verifying backend ids still
  // exist" below). "~" expands to $HOME; relative paths resolve against this config file.
  "source": "~/.pi/agent/cache/pi-cursor-agent/models.json",
  "sourceListPath": "models",

  // Canonical model id -> mapping entry (no need to re-prefix with "cursor-" — the
  // provider namespace above already disambiguates it as /model cursor claude-opus-5)
  "mappings": {
    "claude-opus-5": {
      // Display/catalog metadata (all optional)
      "name": "Claude Opus 5 (Cursor)",
      "reasoning": true,
      // "offSupported" overrides the automatic check for whether Pi's "off" thinking level
      // means anything real for this family; see "Hiding a misleading 'off' level" below.
      // "offSupported": false,
      "contextWindow": 1000000,
      "maxTokens": 128000,

      // Effort level -> backend reference(s). Required.
      "map": {
        // Bare "provider/modelId" string = only a "default" variant exists for this level.
        "low": "cursor-agent/claude-opus-5-low",

        // Object form: up to 4 variants for this effort level.
        // "default" is required; the rest are optional and fall back to "default"
        // (or to "thinking"/"fast" for "thinking-fast") when omitted or toggled off.
        "high": {
          "default": "cursor-agent/claude-opus-5-high",
          "thinking": "cursor-agent/claude-opus-5-thinking-high",
          "fast": "cursor-agent/claude-opus-5-high-fast",
          "thinking-fast": "cursor-agent/claude-opus-5-thinking-high-fast"
        },

        // A level can exist only in "thinking" form (no plain non-thinking backend at all).
        // "default" still must be present — set it to whatever backend you want used when
        // /pi-model-map-thinking is off but no non-thinking backend exists.
        "xhigh": {
          "default": "cursor-agent/claude-opus-5-thinking-xhigh",
          "thinking": "cursor-agent/claude-opus-5-thinking-xhigh",
          "thinking-fast": "cursor-agent/claude-opus-5-thinking-xhigh-fast"
        }
      }
    }
  }
}

Effort level keys: default (used when no /thinking level is set), minimal, low, medium, high, xhigh, max.

Pi's /thinking picker only shows levels you actually mapped

Each family only lists a map entry for the effort levels it genuinely supports — e.g. a family whose real backends are only low/high/max has no minimal/medium/xhigh entries at all. pi-model-map reflects this in the registered model's thinkingLevelMap by setting every unmapped level to null explicitly, rather than leaving it out. This matters because Pi treats an omitted standard-level key (minimal through high) as "supported via provider default," not as unsupported — only an explicit null hides/clamps a level from /thinking and pi.setThinkingLevel(). Without this, a family that only mapped low/high/max would still show minimal/medium as selectable in the picker, silently falling back to default underneath instead of honoring what was picked. With it, /thinking (and its autocomplete/picker UI) only ever offers the levels a given canonical family actually maps.

Hiding a misleading "off" level

Pi's /thinking picker always includes a literal off state ("no reasoning parameter sent at all") — that's baked into Pi's own ModelThinkingLevel type and UI, not something pi-model-map can rename. The trouble is some provider families have no genuine non-reasoning backend at all: every one of their raw backend ids implies some minimum reasoning tier, so this project's own map.default entry ends up aliased to whatever the provider treats as its floor (e.g. Cursor's claude-sonnet-5 has no bare claude-sonnet-5 id in its catalog at all — map.default is aliased to the exact same backend as map.medium). Selecting "off" for that family doesn't actually turn reasoning off; it silently reasons at medium anyway, which is exactly the mismatch this project should not paper over.

pi-model-map detects this automatically per family: if mappings.<id>.map.default's default variant resolves to the exact same backend reference as one of that family's own named effort levels, it sets thinkingLevelMap.off = null for that model, hiding "off" from /thinking entirely (same mechanism Pi's own models.md documents for models where thinking can't be disabled, via a "off": null entry in thinkingLevelMap) — so you're never offered a state that lies about what's actually happening underneath. Families with a genuine floor (a bare id with no effort suffix, or a provider's own explicit "none"/no-reasoning id, e.g. gpt-5.1, composer-2.5) keep "off" as a real, distinct option.

Set "offSupported": true or "offSupported": false explicitly on a mappings.<id> entry to override the automatic check in either direction.

map values are provider/modelId, optionally split into 4 variants

Every backend reference is a full delegate reference — "<delegateProvider>/<backendModelId>" — not a bare model ID. There is no separate top-level delegateProvider field; the delegate is embedded per reference.

Each effort level's value is either:

  • a bare string — shorthand for { "default": "<value>" }, i.e. no thinking/fast split exists for that level, or
  • an object with up to 4 keys:
Key Meaning
default thinking off, fast off. Required.
thinking thinking on, fast off.
fast thinking off, fast on.
thinking-fast thinking on, fast on.

At request time, pi-model-map picks a key based on the current /pi-model-map-thinking and /pi-model-map-fast toggle state. If the exact combination isn't configured for that level, it falls back to whichever configured variant honors the most of what was actually requested — e.g. if a level only has thinking/thinking-fast configured (no default/fast at all) and only /pi-model-map-fast is on, it uses thinking-fast rather than plain thinking, since thinking-fast also honors the fast request (both force thinking on regardless, since that's all the level has). Ties between equally-honored fallbacks prefer satisfying thinking over fast. See resolveVariant() in src/mapping.ts for the exact scoring.

cursor/claude-opus-5  +  /thinking high  +  /pi-model-map-thinking on
  → map.high.thinking = "cursor-agent/claude-opus-5-thinking-high"
  → delegate: cursor-agent, streamSimple({ id: "claude-opus-5-thinking-high", provider: "cursor-agent", ... })

This also means:

  • Different mappings in the same file can delegate to different providers. claude-opus-5 could route to cursor-agent/... while another entry in the same file routes to anthropic/....
  • Different levels, or different variants within one level, can in principle delegate to different providers, though this isn't a typical use case.
  • pi-model-map resolves every distinct provider referenced anywhere in map and validates/monitors each one independently (see warnings below).

Registration apiKey quirk

Pi requires an apiKey (or oauth) when extension providers register models, even when streamSimple only delegates elsewhere. pi-model-map sets a placeholder for its own provider registration:

PI_MODEL_MAP_FAKE_KEY_NEEDED_FOR_PROVIDER_REGISTRATION

This placeholder only satisfies Pi's registration schema for pi-model-map's own provider entry — it is never sent to a real API. Nested calls go through ctx.modelRegistry.streamSimple() with the placeholder apiKey / virtual headers / env stripped, so Pi re-resolves the delegate's request-time credentials (whatever /login <delegate> or its configured apiKey/OAuth actually is). If that resolution fails, the nested stream fails with the delegate's auth error rather than sending the placeholder key.

Naming convention

Canonical model IDs generally do not need a provider-echoing prefix (e.g. claude-opus-5, not cursor-claude-opus-5) — the config file's own provider field (/model cursor claude-opus-5) already disambiguates which mapping a picker entry came from. Only prefix a canonical ID when the bare name would otherwise collide with another family in the same config file, or when it improves clarity for a specific integration chain (e.g. Cursor's own raw cursor-grok-4.5/cursor-grok-4.6 backend IDs already carry a literal cursor- segment from Cursor itself, which is left as-is rather than stripped or doubled).

Verifying backend ids still exist (source)

Providers periodically rename or retire raw backend model ids (Cursor especially, given how much of its naming bakes effort/thinking/fast into the id string itself). Nothing about the mapping schema itself would ever tell you a map entry now points at a dead id — it would just surface as a runtime API error from the delegate the next time someone actually selects it.

Set source (plus sourceListPath / sourceIdField if needed) in a config file to point at a JSON file describing that provider's live catalog, and pi-model-map will cross-check every backend id in mappings against it:

{
  "source": "~/.pi/agent/cache/pi-cursor-agent/models.json",
  "sourceListPath": "models",
  // "sourceIdField" defaults to "modelId" (Cursor's cache shape) and only matters when list
  // entries are objects rather than bare strings.
  ...
}
  • source — path to the catalog file. ~ expands to $HOME; relative paths resolve against the config file's own directory. Optional — omit it and this check is skipped entirely (no assumption is made about where, or whether, a given provider even maintains such a file; this is deliberately not Cursor-specific, since other delegates like a hypothetical opencode mapping may have a completely different cache format or none at all).
  • sourceListPath — dot-path to the array of catalog entries inside the parsed JSON (e.g. "models" for Cursor's { "models": [...] } shape). Omit if the file's root value is already that array.
  • sourceIdField — field name holding each entry's raw id, when entries are objects rather than bare strings. Defaults to "modelId".

When source is set, pi-model-map warns (at config-load time, same as the other startup checks) about any backend id referenced anywhere in mappings that isn't present in the catalog file, naming exactly where it's used (e.g. mappings.claude-opus-5.map.high.thinking) so you can find and fix it quickly. You can also re-run the same check on demand, without restarting Pi, with /pi-model-map-check-source — useful right after refreshing a delegate's cache file.

Commands

Command Effect
/pi-model-map-thinking [on|off] Set (or, with no argument, toggle) use of each family's mapped thinking/thinking-fast backend variant.
/pi-model-map-thinking-on / -off Explicit enable/disable shortcuts.
/pi-model-map-fast [on|off] Set (or toggle) use of each family's mapped fast/thinking-fast backend variant.
/pi-model-map-fast-on / -off Explicit enable/disable shortcuts.
/pi-model-map-check-source Re-check every config file's source catalog now and report any backend ids missing from it. No-op (with a hint) for config files that don't set source.

Both toggles are session-wide and apply across every pi-model-map provider/family currently in use. If the active model's family has no matching variant configured for the requested state, pi-model-map notifies you and falls back to default (or the nearest configured variant) rather than erroring.

These toggles are independent of Pi's own /thinking <level> effort selector — set effort with /thinking, and layer /pi-model-map-thinking / /pi-model-map-fast on top for providers whose backend also splits thinking-mode and routing-tier into separate model IDs.

Some effort levels only exist in "thinking" form for a given family (e.g. claude-opus-5's xhigh/max levels have no non-thinking backend at all — see "Hiding a misleading 'off' level"). Selecting one of those levels with /pi-model-map-thinking off still resolves to the thinking backend regardless — there's nothing else to fall back to — and pi-model-map warns about it. This warning fires from three places, so you see it as early as possible rather than only after actually sending a message:

  • The moment you select an affected model (/model, Ctrl+P cycling, or session restore) at whatever effort level is already active.
  • The moment you change /thinking to an affected level on the currently active model.
  • At the start of a session that already had an affected model+level combination selected (e.g. from before this check existed, or across /reload).

It's also still checked at request time as a final backstop (in case a session's effective thinking level was clamped by some path other than the two events above), so it can never go completely unwarned before an affected request actually goes out.

It tracks only the most recently checked model+level combination, not "every combination ever warned about this session" — so cycling /thinking xhigh -> /thinking max -> /thinking xhigh warns all three times (each is a genuine change from whatever was checked immediately before it), while sending several turns in a row without changing the model or effort level does not repeat the warning, since request-time checks see the same combination the preceding selection-time check already reported. Turning /pi-model-map-thinking on and back off again while staying on the same affected level also re-warns, since that's a real change in whether the toggle mismatch applies.

Provider namespace warnings

Pi's registerProvider(id, …) replaces any existing registration for that id.

Your config Safe?
provider: "cursor", map values reference cursor-agent/... Yes — distinct namespaces
provider: "cursor-agent" No — replaces @open-cursor/pi-agent; any map entry referencing cursor-agent/... breaks

Startup checks (per config file)

  1. Rejects provider appearing as a delegate provider in any map entry
  2. Rejects duplicate provider ids across files in the same directory
  3. Warns if provider is a known built-in/extension id that may clobber another namespace
  4. Warns if a single config file's mappings reference more than one distinct delegate provider overall, and separately (more specifically) if a single mapping splits its own effort levels/variants across more than one provider (see below)
  5. Warns (only if source is set) about any backend id in mappings that's missing from the referenced live catalog file, or if that file can't be read/parsed as configured
  6. On first session start, verifies each distinct delegate provider referenced in map is registered, and warns if delegation would call itself

One config file, one delegate provider

The "provider/modelId" syntax on every map entry exists so a mapping's target is explicit and self-contained (and so different effort levels/variants can technically point anywhere) — not to let one canonical model fan out across multiple real backend providers. Grouping unrelated providers under one virtual model (e.g. a "thinking tier" or "planning tier" that round-robins between different providers' models based on task type) is a different kind of plugin and out of scope here: pi-model-map has no cross-provider fallback, health-check, or selection logic — whichever provider a given effort level/variant happens to reference is what gets used, deterministically, every time.

Because of this, pi-model-map warns (but does not block loading) if a config file's mappings collectively reference more than one delegate provider, and warns more specifically if a single canonical mapping does so across its own levels/variants — the latter usually indicates a typo (e.g. mistakenly writing anthropic/claude-opus-4-5 in one level of a mapping that's otherwise entirely cursor-agent/...). If you genuinely want to map models from more than one real provider, use one config file per provider in the model-map/ directory (see Configuration) rather than mixing providers inside a single file.

Usage

/model cursor claude-opus-5
/thinking high
/pi-model-map-thinking on
/pi-model-map-fast on

With multiple config files, each registers its own /model <provider> ... namespace (e.g. a second file with provider: "opencode" would add /model opencode ...).

Verifying routing (debug logging)

Set PI_MODEL_MAP_DEBUG=1 (or true) before starting Pi to print a one-line trace for every request a mapped provider handles, showing exactly which backend provider/model id it resolved to:

PI_MODEL_MAP_DEBUG=1 pi
[pi-model-map:debug] cursor/claude-opus-5 (reasoning=high, thinking=true, fast=false) -> cursor-agent/claude-opus-5-thinking-high

This is off by default (silent, like every other Pi provider) and is printed via console.error from inside the extension's streamSimple handler — i.e. it reflects the exact backend reference toBackendRef() resolved and handed off to the delegate provider, not a guess. Each line is printed with a leading blank line, since Pi's TUI doesn't otherwise insert one before extension stderr output and it would otherwise print immediately after your echoed input on the same terminal line.

If you want to independently confirm the actual outbound request (not just what pi-model-map thinks it resolved to), Pi's own before_provider_request extension hook fires right before the payload is sent and includes the real serialized model/request body — see the "Extension Hooks" section of Pi's own docs/extensions.md for a ready-to-use snippet (e.g. pi.on("before_provider_request", (event) => console.log(JSON.stringify(event.payload, null, 2)))). Pi's hidden /debug command also writes the last messages sent to the LLM to ~/.pi/agent/pi-debug.log.

Examples

Bundled configs are in examples/. See examples/README.md.

I recommend using @zigai/pi-model-filter to hide the providers/models behind your configured mappings (e.g. raw cursor-agent/* entries once you've mapped them via cursor/*), simplifying your model list. See examples/README.md for more information.

Releasing

.forgejo/workflows/publish.yml publishes to npm whenever a vX.Y.Z tag is pushed. It's a PoC drafted against a Forgejo instance that was offline at the time — review before relying on it:

  1. Verifies the tag's commit is reachable from main (refuses to publish tags cut from other branches).
  2. Installs dependencies and runs npm run typecheck.
  3. Verifies the tag version (vX.Y.Z -> X.Y.Z) matches package.json's version field, so a forgotten version bump fails the build instead of publishing under the wrong number.
  4. Runs npm publish --access public using an NPM_TOKEN repo/org secret.

Forgejo reads workflows from .forgejo/workflows/ (preferred) or .gitea/workflows/ (Gitea-compatible) — don't duplicate the file in both. Before this can actually run on a live instance you'll need to:

  • Confirm a Forgejo Actions runner is registered for the repo and update runs-on: to match its label(s) (the workflow assumes the default ubuntu-latest mapping).
  • Confirm actions/checkout and actions/setup-node are reachable from your instance (mirrored internally, or your runner has outbound access to fetch them).
  • Add an NPM_TOKEN secret (an npm automation token, which bypasses 2FA prompts for CI) under the repo's or org's Actions secrets.
  • Update the placeholder repository/homepage/bugs URLs in package.json (currently forgejo.example.com) once the repo has a real home.

Cutting a release: bump version in package.json, commit to main, then git tag vX.Y.Z && git push origin vX.Y.Z.

Credits & License

pi-model-map is licensed under the MIT License.

@open-cursor/pi-agent (integrations/pi-agent), also MIT-licensed, is the direct source of two things in this project:

Logic — ported, not just inspired by. ModelMapper.toBackendRef() in src/mapping.ts reproduces the same algorithm as open-cursor's toCursorId() in src/models/mapping.ts: look up the family/map for a canonical id, use the default entry when no effort level is requested, otherwise look up the level with a fallback to default. The index-building loop (buildIndexes(), populating the family/map lookup structures) is likewise the same approach as open-cursor's module-level index construction. Identifiers, data structures (Map/class fields vs. plain objects/module-level functions), the provider-prefixed key format ("provider/modelId"), and the default/thinking/fast/thinking-fast variant split were added/changed to fit this project's multi-provider, multi-axis design, but the control flow and logic of toBackendRef() is a port of open-cursor's, not an independent reimplementation.

(An earlier version of pi-model-map also ported open-cursor's reverse lookup, toCanonicalId(), for hiding raw backend-variant models from the picker. That method was unused dead code — nothing in this project ever called it — and has been removed; use @zigai/pi-model-filter to hide a delegate provider's raw models instead, per the recommendation above.)

examples/cursor.json's data is not from open-cursor. Earlier revisions of this example file copied mappings data from open-cursor's hardcoded MODEL_MAP, but that data had drifted noticeably from Cursor's actual current catalog (missing several model families, referencing IDs Cursor no longer serves). examples/cursor.json is now generated directly from Cursor's own live model catalog (the cached response at ~/.pi/agent/cache/pi-cursor-agent/models.json, see examples/README.md), grouped and split into the default/thinking/fast/thinking-fast schema by this project's own generation logic. Only the resolution logic that consumes this data remains ported from open-cursor, as described above — the example data itself is independently sourced.

Everything outside of src/mapping.ts's two resolution functions — including the JSON-driven config design, the mappings/map schema and its thinking/fast variant axes, multi-provider delegate references, the model-map/ directory config loader, Pi provider-registration wiring in src/index.ts, the /pi-model-map-thinking / /pi-model-map-fast commands, the namespace-clash validation/warnings in src/warnings.ts, and the current examples/cursor.json data — was written/generated independently for this project and has no open-cursor counterpart.

In accordance with the MIT License's requirement to preserve copyright and permission notices for reused/derivative code, the original open-cursor license text is reproduced in LICENSE alongside this project's own license.

Renamed from pi-thinking-map

This project was originally named pi-thinking-map. It was renamed to pi-model-map once its scope grew beyond effort-level mapping to include the /pi-model-map-thinking and /pi-model-map-fast toggles, which needed a command prefix that wasn't specific to "thinking." Config directories, the fake registration API key, and all internal identifiers were updated accordingly — see the Configuration section for the current model-map/ directory convention.