Skip to content

zeroasterisk/vertex-doctor

v0.1.0Apache-2.0

Probe, diagnose and unblock model access on Google Cloud Agent Platform (formerly Vertex AI): which publisher models a project and credential can call, in which location, and the one command that fixes each failing cell. Includes quota planning (bundle, base_model bucket, request form) and a guided doctor loop. Prototype / personal hobby project.

vertex-doctor

Prototype. Personal hobby project. Not a Google product, not supported, not affiliated with anything official. It makes real API calls against your project (tiny ones, with cost gates) and can mutate project settings only when you pass --yes. Read the output before you trust it; file issues with the JSON records when it is wrong.

vertex-doctor answers one question about Google Cloud Agent Platform (the product formerly branded Vertex AI; the API host is still aiplatform.googleapis.com):

Which publisher models can this credential + this project call, in which location, through which invocation surface, and for every cell that fails, what is the one action that unblocks it?

Model access is the intersection of seven independent gates (credential binding, API enablement and quota project, IAM, org policy, per-publisher agreements, per-location per-model quota, model lifecycle), each failing with its own status code and message shape. The console, gcloud ai model-garden and gateway health checks do not compose them. This tool probes a matrix of projects x credentials x models x locations, classifies every result into a fixed taxonomy, and prints the fix once per cause.

Start here: one model, one location

vertex-doctor journey -m claude-fable-5-1@default -l global --rpm 400        # show the path
vertex-doctor journey -m claude-fable-5-1@default -l global --rpm 400 --fix  # walk it (asks before each change)

The journey is the same ten steps every time, in the order the gates are actually hit. Each step is [ok], [next] (the one thing to do now, and who does it), [later], or [you] (a human step the tool will not take: linking billing, a quota request the automated adjuster refuses).

The happy path (all ten green, then the curl and SDK call that work)

#stepwho fixes it
1credential resolves and is not certificate-boundyou (gcloud auth application-default login)
2billing account linkedyou, in the console (the tool prints the two links)
3APIs enabledthe tool
4caller holds aiplatform.endpoints.predicta project admin (the tool prints the command)
5model exists in the catalog and is spelled the way the REST path wantsthe tool (re-probes the corrected id)
6model is served at this locationthe tool (tells you which location works)
7project is entitled to the publisher (data sharing / EULA / enablement)the tool, after you confirm
8quota bundle exists and is above zerothe tool files it; if the adjuster denies, you send the printed form to your account team
9a real request returns 200the tool
10ready to usethe tool prints the curl that worked and the official SDK call; any other client on request (see below)

--rpm N is the only number you need: token quotas are sized from what sibling models already have in the project. Without --rpm the tool recommends a value the same way.

What can surprise you, and what the tool does about it

  • Changes take a minute or two to propagate (an enabled API, the data-sharing flag, a quota grant). After every change the tool re-probes until the answer changes instead of reporting the stale one.
  • A brand-new project fails on five things before the first real gate (Resource Manager, Billing, Agent Platform, Cloud Quotas and procurement APIs, then billing itself). The tool enables the APIs; billing is yours.
  • The catalog is not access: models listed there can be uncallable, and callable models can be missing from it. Only the real request counts, so every step 5 to 9 is proven by one.
  • claude-x@default is only valid when the catalog versionId is default; older models want the dated version. The tool re-probes the right spelling.
  • Newer Claude lineages are served at global (and us/eu), not in most regions, and their quota bucket is the lineage (anthropic-claude-fable), not the model id. The tool re-targets the request and names the bucket.
  • New projects have zero quota for partner models. The automated adjuster grants some requests in a minute and denies others just as fast without saying why, and it phrases a refusal at least two ways ("Quota request denied" / "We cannot grant ... '0' was granted"), so the tool decides on the granted numbers, never the sentence. When it denies, re-filing does not help; the tool says so and hands you the form instead of looping.

Probing a whole matrix instead (models x locations)

$ vertex-doctor probe -m claude-sonnet-4-6@default,claude-sonnet-4-5@default,gemini-flash-latest -l global,us-east5

my-gcp-project  anthropic  [adc]
model                       global      us-east5
claude-sonnet-4-6@default   ok 2292ms   ok 1345ms
claude-sonnet-4-5@default   x A17 404   x A17 404

my-gcp-project  google  [adc]
model                 global      us-east5
gemini-flash-latest   ok 523ms    x A16 404

What to do
1. [A17] WRONG_SURFACE_OR_SPELLING (high)  2 cell(s)
   version suffix '@default' is not valid for this model; the catalog versionId is '20250929'
   run: vertex-doctor probe --project my-gcp-project --model anthropic/claude-sonnet-4-5@20250929 --location global
2. [A16] MODEL_NOT_SERVED_IN_LOCATION (high)  1 cell(s)
   google/gemini-flash-latest is not served in us-east5 but works at global
   run: vertex-doctor probe --project my-gcp-project --model google/gemini-flash-latest --location global

Install

Python 3.11+. No published package yet; run from the repo or from git with uv:

uvx --from git+https://github.com/zeroasterisk/vertex-doctor vertex-doctor --help
# or
uv tool install git+https://github.com/zeroasterisk/vertex-doctor
# or, from a checkout
uv sync && uv run vertex-doctor

Quick start

vertex-doctor                       # interactive wizard on a TTY (help when piped); the journey is the first option
vertex-doctor journey -m MODEL -l global --rpm 60 [--fix]   # one model: the ordered path to callable
vertex-doctor doctor                # many models x locations: env -> probe -> diagnose -> plan fixes (dry-run) -> report
vertex-doctor doctor --fix          # also re-probe corrected cells; mutations ask on a TTY (or need --yes)
vertex-doctor env                   # zero-cost environment check: gcloud/CBA, ADC, APIs, IAM
vertex-doctor discover -P anthropic # catalog with the exact spelling for each surface
vertex-doctor probe -m anthropic,google -l default --save probe.jsonl
vertex-doctor quota-plan -m claude-fable-5-1@default -l us-east5 --rpm 100 --input-tpm 40000000 --output-tpm 1000000
vertex-doctor client-config --from probe.jsonl                    # curl + how to call it; --format list for SDKs, harnesses, gateways, frameworks

The doctor loop

doctor is the "walk through everything" command. It runs the environment check, fixes blockers it is allowed to fix (API enablement, ADC quota project), probes the matrix, turns every failing cell into a Fix of one of four kinds, applies the allowed ones, re-probes the affected cells and prints before -> after:

kindexamplesneeds
mutateenable an API, set Anthropic data sharing, accept a EULA + enableModel--fix plus --yes or a TTY confirmation
shellgcloud auth application-default set-quota-projectsame
reprobetry global when a region 404s/400s, re-send with the corrected @version, re-send with ADC after a CBA 401--fix (costs one tiny probe per cell)
adviceIAM grants, org policy, retired models, quota requests (with the exact quota-plan command)a human

Without --fix every mutation is dry-run and printed with its HTTP request and gcloud equivalent, so an agent can show the plan and a human can approve it.

Project, credential and locations are all configurable; the wizard sniffs what the machine already has (env vars, gcloud config, the ADC file, client configs and previous runs in the current directory) and leads with a concrete recommendation such as "use my-gcp-project (gcloud config)" or "global + us-east5 ($CLOUDSDK_COMPUTE_REGION)". The flags use the same detection:

ThingFlagDefault
project-p/--project$VERTEX_DOCTOR_PROJECT, $GOOGLE_CLOUD_PROJECT, ADC quota project, gcloud config project
credential-c/--credentialauto = $VERTEX_DOCTOR_ACCESS_TOKEN, then ADC, then the gcloud user token (warned)
location-l/--locationdefault = global plus a small curated region list per publisher
models-m/--modelrequired; any spelling, a bare publisher name (newest catalog models), or @file

Credential specs: auto, adc, gcloud, sa:/path/key.json, impersonate:SA_EMAIL, token:RAW. impersonate: is the one to use when the question is "what can the gateway's service account call?" from a developer laptop.

Cost gates

Probing sends one tiny fixed prompt ("Reply with the single word: pong", max_tokens 512, thinking disabled where the surface allows it) per cell. Three gates:

  • --no-cost: only free calls (countTokens, catalog, config reads). Proves auth, API enablement and IAM. Gemini countTokens also validates model + location; the Anthropic count-tokens endpoint accepts any model name, so it proves less. No free surface exists for the OpenAI-compatible MaaS endpoint; those cells are skipped.
  • --low-cost (default): real predictions on non-premium models. Premium patterns (opus, image/video/audio/TTS/OCR) are skipped.
  • --any-cost: everything.

Plus --max-cells (200), --concurrency (4), --max-total-output-tokens (20k) and --dry-run, which prints every request with the token redacted and sends nothing.

Output for agents

--json / --jsonl emit one schema-versioned record per cell (see src/vertex_doctor/schema.py). Diagnostics go to stderr. Color is off when stdout is not a TTY or NO_COLOR is set. Exit codes:

codemeaning
0every probed cell is callable
1at least one cell failed with a known diagnosis
2nothing could be probed (auth/config/plan error)
3at least one cell is UNKNOWN (please file the record)

Commands

commandwhat it doesmutates?
envgcloud version and CBA config, ADC file and quota project, principal per credential, live zero-cost differential (countTokens), Service Usage state for aiplatform / cloudquotas / procurement, testIamPermissions, Anthropic publisher configno
discoverModel Garden catalog (publishers.models.list, v1beta1) with versionId, launchStage and the canonical spelling for each surfaceno
probethe matrix probe, corroborating reads, classification, a client-neutral client block per callable cellsends predictions
diagnose --fromre-classify saved records offline (no network)no
client-config --fromhow to use the callable cells from a client: curl (default), official SDKs, agent harnesses, gateways, app frameworks; --format listno
diff --configread a client's config file (--format litellm or bifrost), probe exactly those cells, bucket them: dead, misplaced, unenabled, truncatingsends predictions
watch --baselinere-probe a saved matrix and exit 1 on regressions (--interval to loop, --update to roll the baseline)sends predictions
enable-apiService Usage batchEnable for aiplatform / cloudquotas / procurementwith --yes
enable-publisherAnthropic: setPublisherModelConfig(dataSharingEnabledProvider=ANTHROPIC); others: modelGardenEula:check/:accept then enableModelwith --yes
journeyone model, one location: the ten ordered gates with status, the next action and who does it; --fix walks them (propagation-aware re-probes; billing and denied quota are human steps)with --fix (mutations ask, or --yes)
doctormany models x locations: env -> probe -> fix plan -> apply (--fix, --yes) -> re-probe -> before/afterwith --fix --yes
quota-planthe quota bundle a model runs on (QPM, input TPM, output TPM, or combined TPM) at this endpoint and comparable ones, the base_model bucket to name, what to ask for, a filled request formno
request-quotafile one QuotaPreference per ask from the plan (--rpm/--input-tpm/--output-tpm/--tpm)with --yes
interactivethe wizard (also what bare vertex-doctor runs on a TTY)asks

Every mutation has an idempotent pre-check, --dry-run that prints the exact HTTP request, a gcloud/curl equivalent, and the IAM it needs.

Diagnosis taxonomy

Matching is on HTTP status + gRPC status + google.rpc.ErrorInfo.reason + message fragment + corroborating reads (catalog get, fetchPublisherModelConfig, modelGardenEula:check, QuotaInfo, sibling cells in the same run). Precedence: auth > API/quota project > IAM/org policy > publisher agreement > location > quota > existence > response shape.

rulecodetriggerprimary remediation
A1AUTH_CBA_BOUND_TOKEN401 from a gcloud token with reason=ACCESS_TOKEN_TYPE_UNSUPPORTED, or ADC succeeds on the same request, or context_aware/use_client_certificate=trueuse ADC / mTLS host / a service account
A2AUTH_INVALID_OR_EXPIREDother 401gcloud auth application-default login
A3QUOTA_PROJECT_MISSING_OR_DISABLED403 SERVICE_DISABLED naming a consumer that is not the target project, or "requires a quota project"set-quota-project, x-goog-user-project
A3bQUOTA_PROJECT_USE_DENIED403 USER_PROJECT_DENIED: the caller can see the project but lacks serviceusage.services.use on itgrant roles/serviceusage.serviceUsageConsumer (Editor/Owner include it)
A4API_DISABLED403 SERVICE_DISABLED naming the target projectenable-api
A4bBILLING_DISABLED403 BILLING_DISABLED: no billing account linked (every brand-new project)gcloud billing projects link P --billing-account=ID; the doctor only does this with an explicit --billing-account
A5IAM_PREDICT_DENIED403 naming aiplatform.endpoints.predictgrant roles/aiplatform.user
A6PUBLISHER_DATA_SHARING_REQUIRED403 "requires data sharing to be enabled for publisher"enable-publisher --publisher anthropic
A7EULA_NOT_ACCEPTED403/400 mentioning EULA / license / termsmodelGardenEula:accept
A8MODEL_NOT_ENABLED_FOR_PROJECT403 mentioning enablement / questionnaire / private offer / procurementenableModel, then console
A9ORG_POLICY_BLOCKED403 mentioning organization policy / vertexai.allowedModels / procurement APIorg admin
A10PERMISSION_DENIED_UNCLASSIFIEDany other 403env; file as a new rule
A11REGIONAL_QUOTA_ZERO429 in a region where QuotaInfo has no row or 0 for (region, base_model), or the same model is OK at globaluse global
A12QUOTA_EXHAUSTED429 where QuotaInfo shows a positive limitquota-plan, then request-quota, or lower concurrency
A12bQUOTA_ZERO_FOR_PROJECT429 at global where the project's row is unset/0 (every brand-new project, for partner models)quota-plan for the bundle, then request-quota or the form
A13(annotation RATE_LIMITED_TRANSIENT)429 then success on the backoff retrynone
A14GLOBAL_ONLY_MODEL400 "only available via global endpoint"use global
A15MODEL_RETIRED_OR_UNKNOWN404 and the catalog has no such model (only for publishers the catalog lists)discover
A16MODEL_NOT_SERVED_IN_LOCATION404 / 400 "not servable in region" / "not available in region" with success elsewheremove location
A17WRONG_SURFACE_OR_SPELLING404 with a surface that does not match the publisher; 404 where the @version suffix does not match the catalog versionId; 400 "cannot be called directly via"use the printed canonical spelling
A18NOT_FOUND_POSSIBLY_ENTITLEMENT404 where the catalog knows the model and no location worksenable-publisher --dry-run, try global
A19REQUEST_SHAPE_INVALID400 INVALID_ARGUMENT about the bodytool bug or wrong --surface; file it
A20OK_TRUNCATED_BY_REASONING200 with finish_reason length/MAX_TOKENS, no text, reasoning tokens spentaccess confirmed; raise budgets, set health_check_max_tokens
A21OK200 with contentready to use; client-config prints how
A21fOK_FREE_PROBE--no-cost successrun a real probe to confirm
A22OK_PREVIEW_OR_UNSTABLE200 but catalog says EXPERIMENTAL / PRIVATE_PREVIEW / UNSTABLEpin before production
A23UPSTREAM_ERROR5xx / transport error after one retryretry later
A24UNKNOWNanything elseexit 3; file the record

Annotations that ride along independently: CATALOG_FLAG_MISMATCH (callable but not in the catalog, or vice versa: listing is not access), PUBLIC_PREVIEW, TRUNCATED_AT_DEFAULT_BUDGET, RATE_LIMITED_TRANSIENT.

Model ID spellings

All of these normalize to the same (publisher, model_id); discover prints the right one for each surface:

wherespelling
Model Garden cataloganthropic/claude-sonnet-4-6 (no version; versionId is a field)
REST path (Anthropic, Gemini, embeddings, Mistral)claude-sonnet-4-6@default, claude-sonnet-4-5@20250929, gemini-flash-latest
OpenAI-compatible endpoint body (xai, openai, meta, deepseek-ai, qwen)xai/grok-4.1-fast-non-reasoning
third-party clientseach has its own spelling; client-config --format <name> prints it (LiteLLM: vertex_ai/claude-sonnet-4-6@default, vertex_ai/xai/grok-4.1-fast-non-reasoning; Anthropic SDK: claude-sonnet-4-6; Bifrost: vertex/claude-sonnet-4-6@default)

Surfaces: Anthropic rawPredict (anthropic_version: vertex-2023-10-16), Gemini generateContent (thinkingBudget: 0, dropped on 400), embeddings predict, Mistral rawPredict with a Mistral-native body, and endpoints/openapi/chat/completions for the rest. Host is aiplatform.googleapis.com for global, {location}-aiplatform.googleapis.com otherwise.

Things the probe taught us (and now checks for)

  • @default is not universal. The Anthropic REST path takes name@versionId from the catalog. @default works only for models whose catalog versionId is default; older ones want the dated version (claude-sonnet-4-5@20250929). The wrong suffix is a 404 that looks exactly like a retired model; A17 tells them apart from the catalog.
  • CBA tokens have a server-side tell. A certificate-bound gcloud auth print-access-token token fails tokeninfo with invalid_token and fails aiplatform with 401 reason=ACCESS_TOKEN_TYPE_UNSUPPORTED, while ADC works. env shows both.
  • Listing is not access, in both directions. openai, meta, deepseek-ai and qwen MaaS models are absent from the un-projected catalog yet callable; some catalog entries are not callable by a given project; a retired dated Claude version was still callable while absent from the catalog. Only a real request is ground truth.
  • 429 messages name the quota metric; QuotaInfo ids are CamelCase. The message says online_prediction_input_tokens_per_minute_per_base_model; the id you file against is OnlinePredictionInputTokensPerMinutePerRegionPerBaseModel. quota-plan resolves one from the other (and prefers the per-minute quota when a per-day one shares the metric).
  • Newest Claude lineages have no regional quota rows at all. QuotaInfo for this project lists anthropic-claude-fable, -sonnet, -opus buckets only under the Global... and Us/Eu... multi-region quotas; regional rows exist only for older per-version buckets. A regional 429 for those models is "quota is zero here", not "you are burning it". The lineage fallback in request-quota maps anthropic-claude-fable-5-1 to anthropic-claude-fable.
  • Data sharing is per model generation. In the same project, older Claude models answered while the newest returned PUBLISHER_DATA_SHARING_REQUIRED. fetchPublisherModelConfig returns 404 until something is set, so absence of a config does not predict the gate.
  • The OpenAI-compatible endpoint wraps errors in a list ([{"error": {...}}]). Handled.
  • A brand-new project fails five times before the first real gate. On a project created minutes earlier the doctor found, in order: cloudresourcemanager, cloudbilling, aiplatform, cloudquotas and the procurement API disabled (it enabled them), then BILLING_DISABLED on every cell. Billing is a money decision, so that one is never taken automatically: the doctor prints the console links and stops. Once billing was linked, Gemini, embeddings and Grok answered immediately while Claude returned 429 with an unset global quota row: partner models start at zero and the project has to ask for the bundle (QUOTA_ZERO_FOR_PROJECT).
  • Some catalog entries want another API entirely (... only supported in the Interactions API and cannot be called directly via generateContent). Classified A17, not treated as a tool bug.

Where will you use the model? (clients)

The core never names a client. Once a cell is callable, client-config (or the last question of the wizard) renders the client block through an emitter. --format list prints the registry:

categoryemittersnotes
rawcurl (default)the exact request that worked, with the ADC token
official SDKsgoogle-genai, anthropic-sdk, openai-sdk, mistral-sdkone per surface; what Google and the publishers document
agent harnessesopencode, gemini-cliopencode.json provider block; env + settings.json
gateways / proxieslitellm, bifrost, envoy-ai-gatewaylitellm and bifrost also have importers for diff --config
app frameworkslangchain, adk, ai-sdk, pydantic-aiconstructor lines with project/location/credentials

Each emitter only drives the surfaces its target documents (an SDK for Claude does not pretend to call Grok); cells it cannot drive are listed on stderr. Every emitter was written against the target's primary docs; the module docstring cites the URL and marks anything unverified. Adding one is a ~40-line module in src/vertex_doctor/emitters/ that registers itself; the selection above is the top of each category by adoption and documented Vertex partner-model support, not an endorsement.

Quotas: what to ask for

A real quota request is a bundle for exactly one project, one base_model bucket and one endpoint type, not one number. quota-plan reads QuotaInfo and answers the four questions people get wrong:

  1. Which family? Anthropic / Mistral run on online_prediction_* quotas; Gemini and the OpenAI-compatible MaaS partners (grok, gpt-oss, ...) on generate_content_*; embeddings on embed_content_* (regional request quota under online_prediction). A model always needs the requests/min quota plus either input + output tokens/min (current lineages) or one combined tokens/min (older ones).
  2. Which endpoint type? Prefix "" (regional, dimensioned on region + base_model), global_, us_multi_region_, eu_multi_region_. Each is a different quota with a different CamelCase id (OnlinePredictionInputTokensPerMinutePerRegionPerBaseModel vs GlobalOnlinePredictionInputTokensPerMinutePerBaseModel).
  3. Which bucket? The base_model dimension is not the model id: claude-fable-5-1@default is metered as anthropic-claude-fable, mistral-small-2503 as mistralai-mistral-small-2503, gemini-embedding-001 as gemini-embedding, grok-4.1-fast-reasoning as itself. The planner walks the lineage (-5-1 -> -5 -> family) until QuotaInfo has a row.
  4. Does the row exist where you are asking? For the newest Claude lineages there are no regional rows at all; a regional request cannot be granted. The plan re-targets to the endpoint that has rows (global / us / eu) and says so on the form.

That form is the same shape support / account-team request templates use; request-quota --yes files the same three values as QuotaPreferences, waits up to 90 s for the automated adjuster, and reports granted / denied / pending per quota. With no targets, quota-plan recommends values: the median of what sibling buckets of the same publisher already have at that endpoint in this project (a number that was granted before is the most defensible ask), or a starter tier (60 QPM, 2M input, 200k output tokens/min) when there is nothing to compare with; request-quota --recommended files those. Existing requests for the bucket are listed with their state, and a denial comes with the three usual reasons (not yet entitled to the model, bucket not self-serve, ask too large). Other per-base-model quota varieties exist (responses_* for the Responses API, bidi/live concurrency, image/audio/video generation and per-resolution input quotas, long-running predictions for video); they are listed by QuotaInfo and out of scope for the planner today.

Seed data

src/vertex_doctor/ids.py carries a small table per publisher: surface, default locations, a fallback model list for publishers the catalog does not list, id style (bare vs publisher-prefixed), quota base_model prefix. It is a starting point the catalog and the probe correct, not a serving guarantee. gemini-flash-latest is a good evergreen alias for Gemini and is global-only.

For agents

Use --json/--jsonl, read diagnosis.code, diagnosis.remediation.command and the exit code. Never run enable-* / request-quota without --dry-run first. See skills/vertex-doctor/SKILL.md and AGENTS.md.

The repo is packaged the way google/skills packages its google-cloud-developer plugin (plugin.json, .claude-plugin/, .codex-plugin/, gemini-extension.json, skills/<name>/SKILL.md with a category and a "Use when / Don't use for" description), and the skill defers to that catalog's google-cloud-recipe-auth, gcloud, agent-platform-inference and agent-platform-troubleshooting skills for what they own:

npx skills add google/skills          # the official companions
claude plugin marketplace add zeroasterisk/vertex-doctor && claude plugin install vertex-doctor@vertex-doctor

Demos and recordings

The GIFs above come from demo/*.tape (rendered with vhs) and the walkthrough at zeroasterisk.github.io/vertex-doctor from demo/record.py, which drives the real CLI in a pty, answers its prompts, redacts identifiers and refuses to export if one survives. Re-record with:

for t in journey journey-ok quota-plan probe doctor; do VD_DEMO_PROJECT=... vhs demo/$t.tape; done   # -> docs/gifs/
uv run python demo/record.py all                                          # -> docs/casts/

Both make the same tiny real requests the tool always makes, against VD_DEMO_PROJECT, and replace every real=replacement pair listed in demo/redact.local (gitignored; see demo/redact.local.example) before anything is written. The recorder refuses to export a cast in which a real identifier survived.

Development

uv sync
uv run pytest            # offline: fixtures under tests/fixtures are real, scrubbed responses
uv run ruff check src tests && uv run ruff format src tests

Adding a rule: put the scrubbed error body in tests/fixtures/errors/, add a branch to classify() in diagnose.py in precedence order, add a test in tests/test_diagnose.py, add the row to the table above.

Not goals

Deploying self-hosted Model Garden models, benchmarking, Provisioned Throughput, replacing the gateway's own runtime health checks (the emitters configure them instead), being a general gcloud wrapper.

License

Apache-2.0. Contributions welcome but expectations should be low: this is a hobby prototype.