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-vertexgetModels(), 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 into streamAnthropicKind.

  • googlestreamGoogleKind(entry, model, context, options): strip apiKey, set options.env to the project and the entry’s region, call the built-in google-vertex Provider’s streamSimple with the model carrying provider: "vertex-gemini" and the catalogue’s fields.

  • openai-compatiblestreamMaasKind(…​): obtain the bearer from AdcToken (below), call /compat’s `streamSimple with the model re-tagged api: "openai-completions", id: entry.vertexModel ?? id, baseUrl as 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 globalgemini-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, vertexModel rules, 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.env and no apiKey; maas receives the bearer, the endpoint URL for global and for a region, vertexModel as the id, and one forced refetch on a 401; AdcToken single-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.skipIf on ADC + project): one-token smoke on gemini-3.8-flash and on gpt-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:

  1. 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 `apiKey placeholder and no auth.json appears in pi --list-models; and the two calls proven from the workspace on 2026-09-07 (google via providers/all with options.env; maas via /compat with a bearer) answer from there too. Recorded in the commit message of step 1; a "no" on any reopens the Decisions section before code.

  2. Schema, loader, config path rename, unit tests including the cost test split.

  3. Per-kind registration with placeholder auth; provider-surface tests.

  4. google kind: routing, defaults from pi-ai’s catalogue, contract and live smoke.

  5. openai-compatible kind: AdcToken, routing, contract and live smoke.

  6. Probe and candidates for all kinds; twin-list test extended.

  7. Packaged defaults from the probe, each with a cited rate; // notes name the probe date, the pricing sources, and which MaaS entries showed reasoning.

  8. Docs, ADR-005, consumer-sim round trip.

  9. Release: pi-vertex-anthropic 0.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 and consumer-sim --registry before close.

  10. 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-gemini author should be routed to Fable is a pi-workflow question 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-vertex provider.

Risks

  • The placeholder key reaches a request. Fail-closed: a Vertex endpoint given adc as 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 marking reasoning: true only 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.

Edit this page · latest