@iam2r/pi-upstream-compat
Pi extension: upstream compatibility shims for models served through third-party gateways — OpenCode session headers and thinking-mode reasoning_content replay, both decided by model id
Package details
Install @iam2r/pi-upstream-compat from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:@iam2r/pi-upstream-compat- Package
@iam2r/pi-upstream-compat- Version
0.6.2- Published
- Oct 21, 2026
- Downloads
- 857/mo · 11/wk
- Author
- iam2r
- License
- MIT
- Types
- extension
- Size
- 61.6 KB
- Dependencies
- 0 dependencies · 2 peers
Pi manifest JSON
{
"extensions": [
"./src/index.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
@iam2r/pi-upstream-compat
A Pi extension that fixes two upstream quirks Pi only handles for its own built-in providers — both decided by model id, so they work for any model served through a third-party gateway:
- OpenCode session affinity — attach
x-opencode-session/x-opencode-clientto models whose id mentionsopencode. - Thinking-mode replay — mark DeepSeek-family thinking models so Pi replays
reasoning_contenton assistant turns, which upstream requires.
The problems
1. OpenCode session headers
Pi only sends OpenCode's session headers when the selected model is served by its
own built-in opencode / opencode-go providers, or when the request URL host is
exactly opencode.ai
(getSessionHeaders()).
Model ids also travel through third-party OpenAI-compatible gateways — for example
builtin:opencode/deepseek-v4.1-flash-free served by a custom provider. Pi sees the
gateway as the provider, so the gate can never match and no session header is
sent at all. Upstream then either
- rejects the request outright —
400 MissingSessionID— or - accepts it but derives a fresh session per request, destroying prompt-cache affinity (every turn pays full input-cache cost and latency).
2. Thinking-mode reasoning_content
DeepSeek-family thinking upstreams reject a replayed assistant turn that carries
tool_calls but no reasoning_content:
400 The `reasoning_content` in the thinking mode must be passed back to the API.
Pi already knows how to satisfy that: a model with
compat.requiresReasoningContentOnAssistantMessages gets reasoning_content: ""
replayed on assistant messages. Pi sets that flag on its built-in opencode-go and
deepseek models, and its other detection is URL-based (opencode.ai,
api.deepseek.com) — never id-based. A model declared with a custom provider and a
gateway base URL therefore never gets the flag, and every tool-using turn fails.
Install
pi install npm:@iam2r/pi-upstream-compat
For a single run without installing:
pi -e npm:@iam2r/pi-upstream-compat
Then restart Pi — extensions load at startup.
Renamed. This package was published as
@iam2r/pi-opencode-session-headerup to 0.3.3. Replace it with the name above; the oldPI_OPENCODE_*environment variables keep working, butPI_UPSTREAM_COMPAT_*is the documented spelling now.
What it matches
Session headers
A request gets the session header when any of these hold:
| Condition | Example |
|---|---|
provider is opencode or opencode-go |
Pi's built-in providers |
baseUrl host is exactly opencode.ai |
https://opencode.ai/inference/go/v1 |
model id contains opencode (case-insensitive) |
builtin:opencode/deepseek-v4.1-flash-free |
It appends x-opencode-session (the session id, see below) plus
x-opencode-client: pi, and never overwrites a header another layer already set.
The id rule is the one that breaks in practice, because a gateway need not keep the
upstream model name: deepseek-v4.1-flash-free, with no opencode anywhere in it,
matches nothing above. Set PI_UPSTREAM_COMPAT_MODEL_MATCH to a comma-separated
list of extra substrings (for example deepseek,v4.1) and those ids are recognised
too.
Thinking-mode compat
An assistant turn is replayed with reasoning_content when the model id contains
one of these fragments (case-insensitive):
| Default fragments | Family |
|---|---|
deepseek, kimi, glm, minimax, mimo |
thinking upstreams that require the field back |
Add more with PI_UPSTREAM_COMPAT_THINKING_MATCH (comma separated, appended to
the defaults — setting one extra family cannot silently switch DeepSeek off), and skip
individual ids with PI_UPSTREAM_COMPAT_THINKING_EXCLUDE.
PI_UPSTREAM_COMPAT_NO_THINKING=1 disables this half of the extension entirely.
What the extension does for a matching model, right before Pi serializes the request:
- sets
model.reasoning = true(unlocks Pi's thinking levels and is the gate Pi uses for the replay), unless the model already saysreasoning: true - sets
compat.requiresReasoningContentOnAssistantMessages = true, merging into an existingcompatobject and keeping its other flags - leaves models that Pi's own catalog already defines untouched — for those, Pi's
curated metadata is the better answer (and guessing over it by id would turn, say,
a non-thinking
deepseek-chatinto a reasoning model) - never mutates a frozen model: it hands the implementation a shallow copy instead
reasoning: false cannot express an opt-out: Pi's provider composer fills that default
for every models.json model that does not set it, so it is the normal state of the
gateway models this extension exists for. Use PI_UPSTREAM_COMPAT_THINKING_EXCLUDE
instead.
Where it applies
- The session's model registry (
session_start,model_select,before_agent_start, header event) — capabilities the UI reads live on these objects (AgentSession.supportsThinking()checksmodel.reasoning), so decorating the registry is what makes thinking levels and the model picker agree with the request builder. Decoration is scoped to the provider in use and skips every model Pi's catalog defines. - Before every request in both layers below — guarantees the model the implementation actually receives is marked, including models a gateway adds after startup, and regardless of what the registry holds.
The decision is per request, so it also covers models added to models.json after Pi
started, and models whose ids a gateway rewrites.
pi --list-modelsprints the catalog without a session, so itsthinkingcolumn still saysnofor gateway models: nosession_startfires there, and Pi exposes no model-registry handle at extension-load time. Inside a session the model is marked before the first turn.
Marking a model as reasoning is not free — Pi offers thinking levels for it and may send reasoning parameters. The defaults stay narrow on purpose. If you add a family that is not actually a thinking model, expect upstream errors; drop it from
PI_UPSTREAM_COMPAT_THINKING_MATCHor list the offending ids inPI_UPSTREAM_COMPAT_THINKING_EXCLUDE.
Two layers
Neither mechanism covers every request path on its own, so both are installed.
before_provider_headers— the official event. Emitted only from the coding-agent SDK's request-options builder, which covers the main agent loop but not callers that invokectx.modelRegistry.streamSimple(...)directly. Extensions do that for side requests (pi-subagents' pruned-fork summaries, for example), and those would reach the gateway with no session header.An api-level wrapper —
registerApiProvider()around the api implementation named byPI_UPSTREAM_COMPAT_API(defaultopenai-completions). It injects for matching models, applies thinking compat, and delegates to the implementation it captured, so it sits below every caller and needs no knowledge of which provider serves the model.
Both layers are idempotent with each other: whichever sees the request first sets the headers or model flag, and the second one finds nothing left to do.
The registry decoration above is idempotent too — repeated session_starts and model
switches only count models that still lack the flag.
Why layer 2 is re-asserted on every session
Two host behaviours shape this layer.
First, it cannot be a named provider registration:
// pi-coding-agent/dist/core/extensions/loader.js
registerProvider: (name, config, extensionPath) => {
runtime.pendingProviderRegistrations.push({ name, config, extensionPath });
};
The host drains pendingProviderRegistrations once, at extension-load time
(extensions/runner.js:bindCore()). A pi.registerProvider(...) call made after
that — from a session_start handler, say — only appends to a queue nobody reads
again, so it is silently dropped, even though the call returns normally. Wrapping the
api implementation sidesteps that queue, needs no provider id, and covers every
provider serving that api at once.
Second, Pi clears api overrides when a session reloads: AgentSession.reload()
(core/agent-session.js), which serves /reload and session replacement, calls
resetApiProviders() — every registration is dropped and only the built-ins are put
back. So the wrapper is installed while the factory runs and re-asserted on every
session_start. Re-installing is idempotent: the previous entry is dropped first, and
the wrapper delegates to a captured base instead of to the registry, so installs
cannot stack.
Why the session id is read lazily
The api wrapper runs outside any event context, so it needs a session id of its own.
The cache is filled on session_start and refreshed on every before_provider_headers,
and requests read the live session whenever that cache is empty — the session file
name (<timestamp>_<id>.jsonl) is the last resort. A context captured before a reload
counts as no answer instead of throwing.
This is hardening rather than a fix for a reproduced failure: at this extension's
session_start the id is already available (measured), so the cache is normally
populated before anything can ask. The fallback matters when it is not — an earlier
handler that yields for a while delays every handler after it, since Pi awaits them in
load order, and a session can be asked for a request while it starts.
The remaining boundary is inherent: a side request that arrives before any handler of
this extension has run cannot be helped. The request carries no session information of
its own, and Pi exposes no session id outside an event context (ExtensionAPI has no
session getter). Requests after the first handler are covered.
Configuration
All optional, via environment variables. The pre-rename PI_OPENCODE_* names are
still read as fallbacks.
| Variable | Default | Meaning |
|---|---|---|
PI_UPSTREAM_COMPAT_API |
openai-completions |
Api id(s) layer 2 wraps, comma separated. Every provider serving a model with one of these api ids is covered. |
PI_UPSTREAM_COMPAT_SESSION_STYLE |
raw |
raw forwards Pi's session id verbatim. canonical derives a ses_… id instead. |
PI_UPSTREAM_COMPAT_MODEL_MATCH |
(unset) | Extra model-id substrings (comma separated) that also count as OpenCode, for gateways that rename them. |
PI_UPSTREAM_COMPAT_NO_PROVIDER |
(unset) | Set to 1 to disable layer 2 and use the event hook alone. |
PI_UPSTREAM_COMPAT_THINKING_MATCH |
(unset) | Extra model-id fragments that get the thinking-mode compat flag, appended to the defaults. |
PI_UPSTREAM_COMPAT_THINKING_EXCLUDE |
(unset) | Model-id fragments that keep their metadata as-is, e.g. a non-thinking model whose id mentions deepseek. |
PI_UPSTREAM_COMPAT_NO_THINKING |
(unset) | Set to 1 to leave model metadata untouched. |
PI_UPSTREAM_COMPAT_DEBUG |
(unset) | File path; appends one JSON line per injection, plus one per layer-2 install. |
Set PI_UPSTREAM_COMPAT_API if your gateway speaks a different api (for example
anthropic-messages or openai-responses). Unlike the event hook, layer 2 sees only
the api ids it is told about.
raw vs canonical
rawsends Pi's own session id (01a144cf-8eeb-72cd-aec1-3ee2789673ef). Correct when the gateway forwards the header unchanged to the upstream OpenCode deployment.canonicalderivesses_…in the same descending-timestamp + base62 layout OpenCode uses, so a gateway that validates the shape of the header also accepts it. The derivation is deterministic — a stable Pi session always produces the same id — and idempotent, so an already-canonical value passes through.
Pick canonical if upstream rejects a raw id for being malformed; pick raw if
upstream only requires a stable opaque key.
Verify
PI_UPSTREAM_COMPAT_DEBUG=/tmp/oc-headers.log pi -e npm:@iam2r/pi-upstream-compat
Each injected request appends a line naming the layer (event or api), the model,
whether the thinking-mode compat applies to it, and the header values actually sent. A
registry line appears when a session start or model switch marks models, with the
models count. Other lines report the layer-2 install: api-installed (fresh wrapper,
or a new base after the host replaced ours), api-reinstalled (re-asserted unchanged on
session_start), api-missing, api-incomplete, api-failed.
A request is only ever modified when an injection line appears for it. Thinking-mode
compat has no line of its own: check the thinking field of a request's line, then the
compat flag on that model.
License
MIT