Skip to content

fix(vertex): discover and route partner models correctly - #12471

Merged
diegosouzapw merged 17 commits into
diegosouzapw:release/v3.8.51from
JxnLexn:dev/fix-vertex-ai-model-discovery
Sep 16, 2026
Merged

diegosouzapw merged 17 commits into
diegosouzapw:release/v3.8.51from
JxnLexn:dev/fix-vertex-ai-model-discovery

Conversation

@JxnLexn

@JxnLexn JxnLexn commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • support both Vertex API keys and Service Account JSON credentials without mixing their capabilities
  • infer and persist the Google Cloud consumer project for project-scoped API-key partner inference
  • use API-key model discovery only for Gemini and fall back intentionally to a curated project-scoped catalog because Google rejects API keys for publishers.models.list
  • fetch the live Model Garden catalog for Google, xAI, Anthropic, Mistral, and open-MaaS publishers with OAuth or Service Account credentials using Google's maximum page size of 300
  • treat the authenticated Google publisher catalog as the live source for current Gemini chat models, including Gemini 3.5, 3.6, 3.7, and 3.8 Flash
  • retain successful publisher results when the separate Gemini Developer API rejects Service Accounts
  • accept requestAccess-backed MaaS model cards while excluding image, TTS, live, transcription, embedding, OCR-only, and retired Gemini entries from the chat catalog
  • replace invalid speculative Vertex IDs with documented MaaS IDs such as deepseek-ai/deepseek-v3.2-maas, qwen/qwen3-next-80b-a3b-instruct-maas, and zai-org/glm-5-maas
  • route Gemini, Anthropic, Mistral, xAI, and generic OpenAI-compatible MaaS publishers through their correct transports
  • label the dashboard credential field accurately and explain that Service Account JSON enables live Model Garden discovery
  • reject OAuth client configuration JSON with a specific explanation instead of reporting missing Service Account fields

Authentication behavior

A project-bound API key can execute partner models, but Google does not allow it to call Model Garden publishers.models.list. OmniRoute extracts the bound project from structured Google error metadata and uses the curated project-scoped catalog.

A Service Account JSON credential is exchanged for an OAuth access token. That bearer token can list publishers/google/models and the partner publisher catalogs, so successful discovery now returns catalogMode=live_vertex_catalog without supplementing the result with a stale static Gemini baseline.

Google separately rejects Service Accounts on generativelanguage.googleapis.com. That expected rejection no longer marks the entire Vertex discovery as failed.

An OAuth web-client JSON file is only client configuration and is not itself an access credential. It requires a complete authorization-code and refresh-token flow and is now rejected with a clear message.

Reproduction covered

The live Publisher Model list request used pageSize=1000 although Google enforces a maximum of 300. Every publisher returned HTTP 400, leaving an empty discovery result and causing Sync Models to report:

Vertex model catalogs unavailable — using local catalog

The corrected request returns the live Google and partner catalogs. Gemini chat filtering is capability-oriented: current Flash/Flash Lite/Pro variants from Gemini 2.5 onward are imported, while non-chat and retired entries are excluded.

Verification

  • focused Vertex Node tests on the release branch: 24 passed
  • focused provider-dialog tests from the credential UI change: 20 passed
  • npm run typecheck:core
  • live Service Account token exchange: HTTP 200
  • deployed fork image revision fed218e
  • deployed live discovery: source=api, catalogMode=live_vertex_catalog, 29 synchronized models, no warning
  • deployed live catalog includes gemini-3.5-flash, gemini-3.6-flash, gemini-3.7-flash, gemini-3.8-flash, claude-opus-4-1, and mistral-medium-3
  • deployed catalog excludes image, TTS, retired Gemini 1.5, and the former speculative partner IDs
  • deployed vertex/gemini-3.7-flash model test: HTTP 200

@JxnLexn JxnLexn changed the title fix(vertex): support Express catalogs and xAI MaaS routing fix(vertex): discover and route partner models correctly Sep 2, 2026
JxnLexn and others added 12 commits September 2, 2026 16:03
Keep the PR's Model Garden MaaS ids and xAI catalog spread, and keep
the release tip's new Gemma 4 / DeepSeek V4 / Qwen 3.6 / GLM-5.1 /
Claude Fable 5.1 entries.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
en.json and de.json already had the keys; the i18n parity test failed
on pt-BR (and the other 39 locales). Copy the English fallback next to
vertexServiceAccountPlaceholder so every locale has both keys.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Move Vertex model-listing out of the frozen models route and credential
copy out of EditConnectionModal so both stay under their file-size caps.
Split publisher parsing and URL building into helpers to keep the
complexity ratchet at the merge-base. Drop the bare Gemma / DeepSeek-V4 /
Qwen3.6 / GLM-5.1 ids the merge reintroduced; they are unroutable without
a publisher namespace and this PR already replaced them with MaaS ids.
Sync the free-tier catalog counts (437 → 443).

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Keep the PR's Model Garden MaaS ids and xAI catalog spread, and keep
the release tip. Drop the bare Gemma / DeepSeek-V4 / Qwen 3.6 / GLM-5.1
ids the tip reintroduced; they are unroutable without a publisher
namespace. Sync free-tier catalog counts (444 → 450).

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Regenerate the budget card and FREE-TIERS-GUIDE to 450 entries so they
match the merged namespaced Vertex catalog.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
@diegosouzapw

Copy link
Copy Markdown
Owner

This is a real, well-documented fix (the Model Garden pageSize<=300 limit is a genuine bug)
and the architectural extraction into vertexModelDiscovery.ts/vertexModelMetadata.ts is a
welcome cleanup. Local probe: 27/27 focused tests pass. Two things need fixing before merge:
(1) ESLint reports 5 real errors introduced by this PR (3 no-explicit-any + 2
no-unused-vars in executors/vertex.ts and registry/vertex/index.ts) — these match the
CI reds on the PR, they're not stale-base noise. (2) This PR removes the exact code block that
#12332 patches in models/route.ts (a smaller, already-mergeable fix for the same
Express-key-400 class of bug) — your new vertexDiscovery.ts module already implements the
equivalent intentional: true behavior, so no logic conflict, just a rebase once #12332
lands. Happy to help coordinate the merge order.

…-vertex-12471

# Conflicts:
#	README.md
#	docs/diagrams/free-tier-budget.svg
#	docs/getting-started/FREE-TIERS-GUIDE.md
#	docs/screenshots/free-tier-budget-card.svg
@JxnLexn

JxnLexn commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — reproduced all five ESLint errors, then removed the three explicit-any annotations and marked the two unused parameters explicitly. The touched executor, registry, discovery modules and route tests now pass targeted ESLint; npm run typecheck:core also passes.

I updated this branch to release tip ac52d4d (merge commit 5b8f338, preserving the branch history and current upstream documentation). Changelog integrity now passes as well.

The joint check with still-open #12332 found one behavioral gap beyond the merge conflict: the extracted API-key path also marked transient HTTP/network failures as intentional catalog fallback. Fixed in b49d624: 400/401/403 catalog rejection remains intentional; empty discovery caused by 429/5xx/network failure follows the degraded fallback. Warnings identify the HTTP rejection without copying raw upstream error bodies. New route regressions cover these cases.

Standalone validation: 28/28 passed across vertex-xai-models, vertex-models-route, vertex-publisher-models-parser, vertex-model-metadata and vertex-anthropic-models (all under tests/unit/*.test.ts).

Merge-order proposal: land #12332 first, then retain the maybeHandleVertexModelDiscovery(...) delegation when resolving this PR's route conflict. In a separate local combined checkout, 26/26 tests pass, including #12332's four tests, after adapting only its obsolete transport/warning expectations:

  • Assert the key is in x-goog-api-key and not in the URL (this PR intentionally moved it out of URL-bearing logs).
  • Assert HTTP 400/403 plus the curated Express warning rather than hard-coding API_KEY_INVALID for every rejection.
  • Keep the intentional 400/403, non-intentional 500, and unchanged service-account discovery assertions.

No commits were pushed to #12332 and it was not merged into this PR. Its branch remains open, so the final integration refresh must happen after its actual merge. Full PR CI has not been awaited.

…-vertex-12471

Resolves i18n message-file conflicts by keeping the release tip's existing
translations for keys both sides touched, and adding the two new PR-only
keys (providers.vertexCredentialLabel/vertexCredentialHint) that the tip
does not have yet. No existing locale key is lost (verified with
scripts/i18n/check-ui-keys-coverage.mjs: 0 missing across all 42 locales
this PR already touches).

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
@diegosouzapw
diegosouzapw merged commit 502e614 into diegosouzapw:release/v3.8.51 Sep 16, 2026
9 of 16 checks passed
diegosouzapw added a commit that referenced this pull request Sep 16, 2026
Lands the combined-board reconciliation of today's JxnLexn wave as one follow-up: i18n fill for #12471/#13555's new keys (real vi translations), free-tier count 446→452, file-size rebaseline for #13556. check-new-key-coverage PASS; only the three pre-existing file-size reds remain.
diegosouzapw added a commit that referenced this pull request Sep 17, 2026
* fix(quality): clear the release/v3.8.51 base-reds

19 failing unit tests plus the API Route Typecheck and mutation-test-coverage
gates, all reproduced on the clean tip before touching anything.

Ten of the failures share one cause. #13452/#13798 made `*-compatible-*`
buildUrl() refuse a connection with no baseUrl instead of quietly defaulting to
the real OpenAI/Anthropic API — which would ship the operator's stored key to a
public third party. The guard is right; three fixtures still built those
connections unhydrated, and one of them put baseUrl at the top level of
credentials, where the chat path never reads it.

The rest:

- modelDiscovery.ts missed the VertexModelMetadataProvenance cast that its
  read-path twin in db/models/synced.ts already had — both written by #12471.
- A provider-test regexp carried raw 0x00/0x1f bytes, which makes git, GitHub
  and ripgrep treat the file as binary. Same character class, written
  with escapes instead of the bytes themselves.
- #13399 (Agnes AI China) adds "agnes-cn" + "agnescn": the only two provider
  prefixes since the count was last set (412 -> 414). Everything else added in
  that range is model ids.
- The free-tier budget card SVG was stale (443 -> 452 models); regenerated by
  its own script.
- Three new tests were missing from stryker.conf.json tap.testFiles, so the
  mutants they kill did not count.

Three guards asserted syntax rather than the invariant they protect, and broke
when the source legitimately changed. Each was re-expressed and then verified by
mutating the source back:

- #2331 required modelEffort to head the rawEffort chain; #13556 deliberately
  put the server-selected force rule first. The real invariant is relative —
  modelEffort outranks the defaults a client injects — and it still trips when
  explicitReasoning is moved ahead of it.
- The OAuth loopback guard matched the isLocalhost arm literally; #9944 added
  `&& !opts?.manualLoopback`. It now matches the arm whatever guards it, and
  still fails when the hint stops being built.
- The i18n scanner flagged dynamically-built keys — t("effort." + mode) reaches
  it as a literal prefix, never a string. It now accepts a prefix that resolves
  to a namespace holding messages, and still fails when the namespace is gone.

tests/unit/sse-auth.test.ts (#12080) expected a bare null where #13879 now
returns the key-policy diagnostic — the same sentinel shape the terminal-state
path has used since #12441. The assertion was rewritten to the constraint #12080
actually protects: nothing usable comes back and neither connection leaks. The
contract risk that remains — those sentinels are truthy, and executeWebSearch
treats any truthy value as a credential — is filed as #13945 rather than
widened into this PR.

Refs #13866

* fix(quality): clear the second wave of release/v3.8.51 base-reds

The tip moved 13 commits while the first pass was running and brought its own
reds. All reproduced locally on the merged tree first.

vitest 4.1.11 -> 5.0.0 in the #13661 development-group bump is a major, and
vitest 5 moved `vite` from a dependency to a peerDependency. This repo only ever
declared `vite` under `overrides`, which pins a version but installs nothing, so
`npm ci` stopped providing it and the Vitest job died at startup with
ERR_MODULE_NOT_FOUND. Declared as the devDependency it actually is — the same
^8.0.16 the override already pinned, and what @vitejs/plugin-react asks for as a
peer — and regenerated the lockfile: 684 lines added, none changed.

#12909 filtered a mapped array with `toolCall is JsonRecord`, but the element
type is the tool-call literal or null, and a predicate's type has to be
assignable to the parameter's (TS2677). Narrowed by the element's own type
instead; the literal still satisfies JsonRecord at the return.

#12906 added `|| result.errorCode === "empty_response"` to the stream-failure
condition and Prettier rewrapped it, so the #8928 probe — which located the
branch by an exact four-line string — stopped finding it. It now matches on what
the branch tests rather than how it is typeset, and still fails when the
eviction call is removed.

probe-7293 is the visible half of a real conflict, filed as #13948. #7293 merges
a mid-array system into index 0; #12908, landed later, demotes it to "user" in
place instead. Both target the same constraint and only one can win, and the
combination also reorders: the pre-translation hoist moves the turn forward
expecting it to stay a system message, then the demotion converts it where it
now sits, ahead of the conversation. Choosing between the two strategies is a
product call, not a base-red one, so the test was realigned to assert the half
that protects the caller — the instruction survives, as a user turn — and pins
the current ordering with a pointer to the issue, so the eventual decision shows
up as a deliberate test change instead of a silent regression.

Refs #13866, #13948

* fix(quality): allowlist vite, rebaseline tip growth, drop a dead import

Third pass on the release/v3.8.51 base-reds. Declaring `vite` in the previous
commit was correct but incomplete: check-deps is a human review point against
typosquatting, so a newly declared package has to be vouched for by name.
Recorded in dependency-allowlist.json with why it is needed — the official Vite
build tool, already pinned through overrides, and a required peer of both
vitest 5 and @vitejs/plugin-react. That also turns check-deps.test.ts green.

check-file-size went red on nine files. One is mine: sse-auth.test.ts grew when
the #12080 assertion was rewritten. Three of the four assertions I had added
were redundant with the strict deepEqual that follows them, so they are gone and
the file grows by 4 lines instead of 8; the cap absorbs the rest.

The other eight are production and test files this PR does not touch, grown by
other work and never rebaselined — which is the whole reason a base-red drain
exists. Each is attributed to the commit that grew it: #12906 (chat.ts,
chatHelpers.ts, proxyFetch.ts, stream.ts), #12904 + #12910 (chatCore.ts), and
batch_api.test.ts from the same wave. Two of them predate the wave entirely and
were already over cap on 3d5baf1 — imageGeneration.ts (#13748) and
roundRobinCombo.ts (#13776) — so they were base-reds hiding behind a gate that
only surfaced them once the tip was merged in. Both are recorded separately from
the wave so the history stays honest about when each cap actually moved.

Note for whoever reads the gate next: it counts one line more than `wc -l`,
since it measures split length rather than newlines.

Finally, #13290 replaced rmSync with cleanupTempDataDir in
zcode-executor.test.ts but left the import behind, which the frozen-warning
ESLint gate rejects. Removed.

Refs #13866
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…w#12471)

Merged. Root cause first: `publishers.models.list` was called with `pageSize=1000` against Google's hard maximum of 300, so every publisher answered 400 and discovery silently fell back to the stale static catalog. On top of that the PR separates API-key from Service-Account capabilities correctly (keys cannot list Model Garden — project-scoped curated catalog; SA tokens can — live catalog), stops treating the expected generativelanguage rejection of a Service Account as a discovery failure, replaces the speculative partner IDs with documented MaaS IDs, and rejects OAuth client-config JSON with a clear message instead of a misleading one.

The 5 ESLint errors flagged during the earlier fix sweep were fixed in your own follow-up commits; the branch was reconciled with the release tip and the 42 locale files were checked for lost keys before this merge.

Validated as a combined board first (this PR merged with the 4 siblings of the JxnLexn wave on the release tip): eslint with the frozen suppressions, typecheck:core, check:open-sse-typecheck, complexity, cognitive-complexity, changelog-integrity, docs-sync, migration-numbering, provider-consistency and a duplicate-identifier audit all green, plus 77 passing / 0 failing focused node:test cases across the test files the wave touches. The wave's i18n fill (new keys carried to all 66 locales), free-tier doc counts and file-size rebaseline land in one follow-up PR right after the wave, as with diegosouzapw#13904.

Thank you — the credential-capability distinction and the retired/non-chat filtering are what make the Vertex listing trustworthy.
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…uzapw#13921)

Lands the combined-board reconciliation of today's JxnLexn wave as one follow-up: i18n fill for diegosouzapw#12471/diegosouzapw#13555's new keys (real vi translations), free-tier count 446→452, file-size rebaseline for diegosouzapw#13556. check-new-key-coverage PASS; only the three pre-existing file-size reds remain.
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
* fix(quality): clear the release/v3.8.51 base-reds

19 failing unit tests plus the API Route Typecheck and mutation-test-coverage
gates, all reproduced on the clean tip before touching anything.

Ten of the failures share one cause. diegosouzapw#13452/diegosouzapw#13798 made `*-compatible-*`
buildUrl() refuse a connection with no baseUrl instead of quietly defaulting to
the real OpenAI/Anthropic API — which would ship the operator's stored key to a
public third party. The guard is right; three fixtures still built those
connections unhydrated, and one of them put baseUrl at the top level of
credentials, where the chat path never reads it.

The rest:

- modelDiscovery.ts missed the VertexModelMetadataProvenance cast that its
  read-path twin in db/models/synced.ts already had — both written by diegosouzapw#12471.
- A provider-test regexp carried raw 0x00/0x1f bytes, which makes git, GitHub
  and ripgrep treat the file as binary. Same character class, written
  with escapes instead of the bytes themselves.
- diegosouzapw#13399 (Agnes AI China) adds "agnes-cn" + "agnescn": the only two provider
  prefixes since the count was last set (412 -> 414). Everything else added in
  that range is model ids.
- The free-tier budget card SVG was stale (443 -> 452 models); regenerated by
  its own script.
- Three new tests were missing from stryker.conf.json tap.testFiles, so the
  mutants they kill did not count.

Three guards asserted syntax rather than the invariant they protect, and broke
when the source legitimately changed. Each was re-expressed and then verified by
mutating the source back:

- diegosouzapw#2331 required modelEffort to head the rawEffort chain; diegosouzapw#13556 deliberately
  put the server-selected force rule first. The real invariant is relative —
  modelEffort outranks the defaults a client injects — and it still trips when
  explicitReasoning is moved ahead of it.
- The OAuth loopback guard matched the isLocalhost arm literally; diegosouzapw#9944 added
  `&& !opts?.manualLoopback`. It now matches the arm whatever guards it, and
  still fails when the hint stops being built.
- The i18n scanner flagged dynamically-built keys — t("effort." + mode) reaches
  it as a literal prefix, never a string. It now accepts a prefix that resolves
  to a namespace holding messages, and still fails when the namespace is gone.

tests/unit/sse-auth.test.ts (diegosouzapw#12080) expected a bare null where diegosouzapw#13879 now
returns the key-policy diagnostic — the same sentinel shape the terminal-state
path has used since diegosouzapw#12441. The assertion was rewritten to the constraint diegosouzapw#12080
actually protects: nothing usable comes back and neither connection leaks. The
contract risk that remains — those sentinels are truthy, and executeWebSearch
treats any truthy value as a credential — is filed as diegosouzapw#13945 rather than
widened into this PR.

Refs diegosouzapw#13866

* fix(quality): clear the second wave of release/v3.8.51 base-reds

The tip moved 13 commits while the first pass was running and brought its own
reds. All reproduced locally on the merged tree first.

vitest 4.1.11 -> 5.0.0 in the diegosouzapw#13661 development-group bump is a major, and
vitest 5 moved `vite` from a dependency to a peerDependency. This repo only ever
declared `vite` under `overrides`, which pins a version but installs nothing, so
`npm ci` stopped providing it and the Vitest job died at startup with
ERR_MODULE_NOT_FOUND. Declared as the devDependency it actually is — the same
^8.0.16 the override already pinned, and what @vitejs/plugin-react asks for as a
peer — and regenerated the lockfile: 684 lines added, none changed.

diegosouzapw#12909 filtered a mapped array with `toolCall is JsonRecord`, but the element
type is the tool-call literal or null, and a predicate's type has to be
assignable to the parameter's (TS2677). Narrowed by the element's own type
instead; the literal still satisfies JsonRecord at the return.

diegosouzapw#12906 added `|| result.errorCode === "empty_response"` to the stream-failure
condition and Prettier rewrapped it, so the diegosouzapw#8928 probe — which located the
branch by an exact four-line string — stopped finding it. It now matches on what
the branch tests rather than how it is typeset, and still fails when the
eviction call is removed.

probe-7293 is the visible half of a real conflict, filed as diegosouzapw#13948. diegosouzapw#7293 merges
a mid-array system into index 0; diegosouzapw#12908, landed later, demotes it to "user" in
place instead. Both target the same constraint and only one can win, and the
combination also reorders: the pre-translation hoist moves the turn forward
expecting it to stay a system message, then the demotion converts it where it
now sits, ahead of the conversation. Choosing between the two strategies is a
product call, not a base-red one, so the test was realigned to assert the half
that protects the caller — the instruction survives, as a user turn — and pins
the current ordering with a pointer to the issue, so the eventual decision shows
up as a deliberate test change instead of a silent regression.

Refs diegosouzapw#13866, diegosouzapw#13948

* fix(quality): allowlist vite, rebaseline tip growth, drop a dead import

Third pass on the release/v3.8.51 base-reds. Declaring `vite` in the previous
commit was correct but incomplete: check-deps is a human review point against
typosquatting, so a newly declared package has to be vouched for by name.
Recorded in dependency-allowlist.json with why it is needed — the official Vite
build tool, already pinned through overrides, and a required peer of both
vitest 5 and @vitejs/plugin-react. That also turns check-deps.test.ts green.

check-file-size went red on nine files. One is mine: sse-auth.test.ts grew when
the diegosouzapw#12080 assertion was rewritten. Three of the four assertions I had added
were redundant with the strict deepEqual that follows them, so they are gone and
the file grows by 4 lines instead of 8; the cap absorbs the rest.

The other eight are production and test files this PR does not touch, grown by
other work and never rebaselined — which is the whole reason a base-red drain
exists. Each is attributed to the commit that grew it: diegosouzapw#12906 (chat.ts,
chatHelpers.ts, proxyFetch.ts, stream.ts), diegosouzapw#12904 + diegosouzapw#12910 (chatCore.ts), and
batch_api.test.ts from the same wave. Two of them predate the wave entirely and
were already over cap on 8a95ffa — imageGeneration.ts (diegosouzapw#13748) and
roundRobinCombo.ts (diegosouzapw#13776) — so they were base-reds hiding behind a gate that
only surfaced them once the tip was merged in. Both are recorded separately from
the wave so the history stays honest about when each cap actually moved.

Note for whoever reads the gate next: it counts one line more than `wc -l`,
since it measures split length rather than newlines.

Finally, diegosouzapw#13290 replaced rmSync with cleanupTempDataDir in
zcode-executor.test.ts but left the import behind, which the frozen-warning
ESLint gate rejects. Removed.

Refs diegosouzapw#13866
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants