Plan: one Vertex provider for every publisher (#76)
On this page
Closes: #76. Branch: feat/vertex-publishers. Status: Done (2026-09-07) — shipped as 0.22.0; all three kinds answer through the published package.
TL;DR
Developers configure Claude on Vertex one way — a config file with a
region pinned per model, a probe that finds what their project can call,
ADC retry on reauth — and were about to configure Gemini a second way
(env vars or /login) because pi happens to ship a google-vertex
provider. Two styles for one platform is an anti-pattern, and the
operator has said every MaaS model on Vertex is coming through the same
door. So the extension becomes generic: a model entry names its
publisher kind, each kind is registered under its own provider id from
the same file, and each kind delegates the wire protocol to code that
already exists — the Anthropic SDK today, pi-ai’s api/google-vertex
for Gemini (it takes project and location per call), pi-ai’s
api/openai-completions for MaaS (Vertex’s OpenAI-compatible endpoint
takes an ADC bearer). Packaged defaults are pinned from the 2026-09-07
probe so it works on install with the ADC every developer already has;
the probe regenerates them for any other project.
Decisions
Publisher kinds, not providers, are the schema. An entry gains
publisher: "anthropic" | "google" | "openai-compatible" (default
anthropic, so every existing file is valid unchanged). The extension
registers one provider id per kind present in the file:
vertex-anthropic, vertex-gemini, vertex-maas. A fourth kind later
is a fourth branch and a fourth id, not a new configuration style.
pi-facing id and Vertex id are separate fields. MaaS model strings
carry a slash (openai/gpt-oss-120b-maas); pi refs are provider/id and
several parsers split at the first slash. An entry has id (pi-facing,
no slash) and optional vertexModel (the publisher path Vertex wants,
when it differs). Anthropic and Gemini entries omit it.
Delegate the wire, own the routing — through the alias surface only.
pi resolves @earendil-works/* for an installed extension through a
fixed map: package roots, /compat, /oauth, /providers/all
(index.ts, "EXTENSION ALIAS SURFACE"). Deep subpaths such as
pi-ai/api/google-vertex resolve in the workspace and break in a real
install, so they are not used. The google kind takes the built-in
google-vertex Provider from builtinProviders() in
@earendil-works/pi-ai/providers/all and calls its streamSimple with
options.env = { GOOGLE_CLOUD_PROJECT: project, GOOGLE_CLOUD_LOCATION:
entry.region } — the same channel pi uses to hand a stored credential’s
env to that adapter — and NO apiKey (the adapter would take one as a
Vertex API key and skip ADC). Proven 2026-09-07 with no env set:
gemini-3.8-flash at global answered; the same model at us-east5
returned 404, so the pin is real and its failure is loud. The
openai-compatible kind calls streamSimple from
@earendil-works/pi-ai/compat (after registerBuiltInApiProviders())
with the model re-tagged api: "openai-completions", baseUrl set to
https://<host>/v1/projects/<project>/locations/<region>/endpoints/openapi
(host aiplatform.googleapis.com for global, <region>-aiplatform…
otherwise) and apiKey set to a live ADC access token. Proven the same
day: openai/gpt-oss-120b-maas answered, and pi-ai surfaced its
reasoning as a thinking block. The anthropic kind is untouched.
Availability without a login. pi shows a provider’s models only when
it is "configured"; the current oauth sentinel needs a one-time /login
per provider id, which would be three logins. Each kind registers with a
literal apiKey: "adc" placeholder instead — pi’s documented way to mark
a keyless provider configured — so every kind appears in /model on
install. No kind ever forwards that placeholder: anthropic ignores it
(own client), google strips it, maas replaces it with the bearer. The
existing vertex-anthropic oauth record in a developer’s auth.json
stays harmless. Step 0 proves the placeholder route before anything is
built on it.
Erratum (2026-09-07): the placeholder key is declared beside the oauth
sentinel, not instead of it — with a pre-publisher login record in
auth.json and no oauth block declared, pi lets the stored record win and
the provider vanishes from /model (observed against consumer-sim’s
staged install); with both declared, both agent-dir states resolve.
Defaults come from the catalogue that knows, read at runtime. For a
google entry whose id the installed pi-ai’s Vertex catalogue lists
(builtinProviders() → google-vertex → getModels(), so the
operator’s pi, not the workspace’s, is the source: 0.85.1 lists
gemini-3.8-flash with thinkingLevelMap { off: null }; 0.84.3 does
not), contextWindow, maxTokens, cost and thinkingLevelMap default
from that entry; the file overrides. An id the catalogue lacks gets the
file’s values or the extension’s defaults, as Anthropic entries do;
nothing is synthesized from a neighbour.
No packaged entry without a real rate. unit.test.ts today requires a
cost block on every catalogue entry and checks Anthropic’s multipliers
(5x output, 1.25x cache write, 0.1x cache read) on all of them. The
"every entry has rates" half stays universal — a zero-cost entry
misreports session cost forever; the multiplier half is scoped to the
anthropic kind. Google rates come from Google’s published Vertex
pricing (pi-ai’s catalogue carries them for listed ids); MaaS rates come
from the Vertex Model Garden pricing page at probe date, cited in the
//cost note. A MaaS model whose price cannot be found is NOT shipped
in the packaged file — the probe still finds it for anyone who wants
it in their own.
Thinking per kind. anthropic keeps thinking: adaptive|budget|none.
google maps pi’s reasoning level through pi-ai’s adapter (LOW/MEDIUM/
HIGH; the catalogue’s thinkingLevelMap says which a model lacks).
openai-compatible passes reasoning through pi-ai’s completions adapter
(reasoning_effort). A vendor that ignores that field answers 200
without reasoning — silent, so it is not left to chance: a packaged MaaS
entry is marked reasoning: true only when the probe observed a thinking
block in its reply (gpt-oss did), otherwise reasoning: false, and the
page lists which is which.
The config file is renamed. vertex-anthropic-models.json becomes
vertex-models.json and $VERTEX_ANTHROPIC_CONFIG becomes
$VERTEX_CONFIG. Pre-1.0, callers move with the code: no detection of
the old name, no shim; the changelog entry leads with the rename and an
operator with an override file renames it.
Package name stays this step. @gadhs/pi-vertex-anthropic is a
misnomer once it serves three publishers, but the rename churns the
registry, the meta package and every doc; it is its own issue (#77),
the operator’s call, and this plan does not depend on it.
pi’s built-in google-vertex is left alone. A developer who also sets
its env vars sees Gemini twice under two ids. Documented, not
suppressed: the package does not reach into pi’s providers.
ADC retry is shared; the bearer refresh is the attempt’s. The pump
that survives a mid-session reauth (pumpWithAdcRetry, which waits
intervalMs between tries) wraps every kind unchanged. For maas the
token is fetched inside the attempt factory, so each retry picks up a
rotated credential; a 401 on a cached token forces one immediate
refetch inside the same attempt before the pump’s wait applies — the
only place an immediate refetch lives.
Design
Schema (vertex-models.json)
{ "project": "$ANTHROPIC_VERTEX_PROJECT_ID",
"models": [
{ "id": "claude-opus-5", "region": "us", "thinking": "adaptive", "xhigh": true },
{ "id": "gemini-3.8-flash", "publisher": "google", "region": "global" },
{ "id": "gpt-oss-120b", "publisher": "openai-compatible", "region": "global",
"vertexModel": "openai/gpt-oss-120b-maas" } ] }
ModelEntry gains publisher? and vertexModel?; loadConfig refuses
an unknown publisher and a vertexModel on a non-maas entry by name.
resolveConfigPath reads the new names only.
Routing (index.ts)
The factory groups entries by kind and registers one provider per group.
streamSimple is built per kind:
-
anthropic— the existing closure, moved intostreamAnthropicKind. -
google—streamGoogleKind(entry, model, context, options): stripapiKey, setoptions.envto the project and the entry’s region, call the built-ingoogle-vertexProvider’sstreamSimplewith the model carryingprovider: "vertex-gemini"and the catalogue’s fields. -
openai-compatible—streamMaasKind(…): obtain the bearer fromAdcToken(below), call/compat’s `streamSimplewith the model re-taggedapi: "openai-completions",id: entry.vertexModel ?? id,baseUrlas above,apiKey: token; on a 401 with a cached token, refetch once and retry within the attempt.
All three inside pumpWithAdcRetry when retry is enabled. Each function
stays under the line by keeping option-building in its own helper, as
buildAnthropicOptions is today.
AdcToken
One small module: getAccessToken(force?): Promise<string> over
google-auth-library’s `GoogleAuth (already a transitive dependency of
the Anthropic Vertex SDK; declared directly now), cloud-platform scope,
cached per process and refetched when within two minutes of expiry or
when forced. Single-flight: concurrent callers during a fetch share the
one in-flight promise (two helpers firing at once is the normal case),
pinned by a concurrency test. Never logged; the existing "sentinels, not
secrets" test gains a check that the maas path’s error text carries no
token.
Probe (probe.mjs, candidates.mjs)
--publisher anthropic|google|maas|all (default all). Each kind has
its endpoint shape and classifier: anthropic rawPredict (as today),
google generateContent, maas chat/completions with the bearer. The
output is paste-ready entries with publisher and vertexModel filled.
candidates.mjs gains GOOGLE_MODELS and MAAS_MODELS; the #62 twin-
list test extends to them (every packaged entry of a kind is a candidate
of that kind).
Packaged defaults (models.json)
From the 2026-09-07 probe of gadhs-claude-enterprise-dev: google at
global — gemini-3.8-flash, gemini-3.7-flash, gemini-3.5-flash,
gemini-3.1-pro-preview, gemini-2.5-pro; maas at global — gpt-oss
120b, grok-4.20 reasoning and non-reasoning, kimi-k2-thinking, deepseek
v3.2, qwen3-coder-480b, qwen3-next-80b-thinking, minimax-m2. The
Anthropic entries are unchanged. // notes name the probe date.
Tests
-
unit: publisher default and refusal,
vertexModelrules, the new config path names, google defaults from the runtime catalogue with the file override winning; the cost test split — rates required on every entry, Anthropic multipliers on the anthropic kind only. -
contract (fake Provider / fake compat stream injected): google receives the project and the pinned location in
options.envand no apiKey; maas receives the bearer, the endpoint URL forglobaland for a region,vertexModelas the id, and one forced refetch on a 401;AdcTokensingle-flight under concurrent callers; anthropic unchanged in what it sends. -
provider-surface: three registrations from one file, each with the placeholder auth and only its own models; a file with one kind registers one provider.
-
live (
describe.skipIfon ADC + project): one-token smoke ongemini-3.8-flashand ongpt-oss-120b, beside the Claude one. -
tools/consumer-sim.mjs: one Gemini round trip through installed pi.
Docs
tuning.adoc "Models on Vertex" rewritten around publisher kinds, the
probe, the config file’s new name and the built-in-duplicate note; the "Run a
helper on a different model" recipe loses its login/env block — the ref
is vertex-gemini/gemini-3.8-flash and there is nothing to configure;
packages.adoc and the package README; reviewer-eval.adoc’s role section
points at the new ref; a short ADR-005 records why the wire is delegated
to pi-ai’s adapters rather than pi’s built-in provider used or a client
written (provider ids and schema are developer-facing and hard to walk
back); CHANGELOG.
Scope
In
Steps, one commit each on feat/vertex-publishers:
-
Prove the remaining fact and re-prove the two seams from an installed layout: in
consumer-sim’s packed staging (a real pi install, not the workspace), a provider registered with a literal `apiKeyplaceholder and noauth.jsonappears inpi --list-models; and the two calls proven from the workspace on 2026-09-07 (google viaproviders/allwithoptions.env; maas via/compatwith a bearer) answer from there too. Recorded in the commit message of step 1; a "no" on any reopens the Decisions section before code. -
Schema, loader, config path rename, unit tests including the cost test split.
-
Per-kind registration with placeholder auth; provider-surface tests.
-
google kind: routing, defaults from pi-ai’s catalogue, contract and live smoke.
-
openai-compatible kind:
AdcToken, routing, contract and live smoke. -
Probe and candidates for all kinds; twin-list test extended.
-
Packaged defaults from the probe, each with a cited rate;
//notes name the probe date, the pricing sources, and which MaaS entries showed reasoning. -
Docs, ADR-005, consumer-sim round trip.
-
Release:
pi-vertex-anthropic0.4.0, meta 0.22.0 — minor: new provider ids and schema; the file rename is a pre-1.0 move, called out in the changelog; pipeline andconsumer-sim --registrybefore close. -
Close #76 with SHAs, releases, tests named, and what the operator saw in
/model; this page to Archive.
Out
-
The package rename (#77, operator’s call).
-
Reviewer or judge on any non-Anthropic model (#73, #74 answered that).
-
Ranking Gemini or MaaS models in
chooseReviewer’s tier table: an unranked author reviews itself today; whether a `vertex-geminiauthor should be routed to Fable is api-workflowquestion for its own issue once anyone authors on one. -
Dynamic discovery of what a project can call at session start: the probe is the discovery; the file is the record.
-
Suppressing pi’s built-in
google-vertexprovider.
Risks
-
The placeholder key reaches a request. Fail-closed: a Vertex endpoint given
adcas a key answers 401, never a silent success; pinned per kind in the contract tests before any kind ships. -
A MaaS vendor rejects a field pi-ai’s completions adapter sends (tools, sampling). Fail-closed: a 4xx ends the turn with the vendor’s message; the packaged list carries only probe-verified models and a quirk found later is its own
fix:issue. -
A MaaS vendor silently ignores
reasoning_effort. Fail-open in the sense that the reply arrives unreasoned without saying so; mitigated by markingreasoning: trueonly where the probe saw a thinking block and naming the rest on the page. -
Two concurrent helpers race the first token fetch. Fail-closed if unhandled (a duplicated fetch, never a wrong token); single-flight in
AdcToken, pinned by a concurrency test. -
The cached ADC token outlives a revoked login. Fail-closed: the 401 forces one refetch inside the attempt, then the existing reauth notice and wait run; the token is never written anywhere.
-
The config file rename strands an operator’s override. Silent for that operator until they read the changelog, which leads with it — accepted over a shim, per the pre-1.0 rule.
-
pi-ai’s Vertex catalogue lags a new Gemini id. Silent-but-correct: the id gets the file’s values or the extension’s defaults, as Anthropic entries do; nothing is synthesized from a neighbour.
-
Gemini appears twice when a developer also configured pi’s built-in provider. Silent duplicate, harmless, documented.