feat: add Gemini via Vertex AI, authenticated with Workload Identity Federation - #107
Draft
jcschaff wants to merge 4 commits into
Draft
feat: add Gemini via Vertex AI, authenticated with Workload Identity Federation#107jcschaff wants to merge 4 commits into
jcschaff wants to merge 4 commits into
Conversation
Adds the plumbing for GCP Workload Identity Federation so the proxy can reach Vertex AI with no stored credential: a dedicated ServiceAccount, a projected 1-hour token scoped to the WIF provider's audience, and the external-account config describing the exchange. A downloaded service account key is not an option -- org policy iam.managed.disableServiceAccountKeyCreation blocks key creation on the CAIA project -- and we would rather not hold one anyway. Three things worth knowing about the shape of this: - The ServiceAccount is dedicated rather than the namespace default, because the pool's attribute condition names exactly one subject. If we federated `default`, any pod in the namespace could mint Vertex credentials. - gcp-wif.json is in the plain ConfigMap, not a sealed secret. Google is explicit that a credential configuration "doesn't contain a private key and doesn't need to be kept confidential" -- it only names the pool, the service account to impersonate, and where to read the token. - The projected volume's audience uses the https:// form while gcp-wif.json uses the schemeless // form for the same provider. That asymmetry is Google's, not a typo; both are default allowed audiences. The pool and provider IDs are placeholders until Burwood creates them (REQ0040169), so this is not yet deployable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ZbiJ9tw2PWi8A4coNs5w5
Third model option alongside openai-model and local-model, serving gemini-2.5-flash from us-central1 on the CAIA grant project. The alias carries no api_key. LiteLLM calls google.auth.default(), which follows GOOGLE_APPLICATION_CREDENTIALS to the external-account config and from there to the projected ServiceAccount token. Using the env var rather than litellm_params.vertex_credentials is deliberate. Both work, but leaving the variable unset makes google.auth.default() fall back to the developer's own gcloud ADC, so the same alias works locally with no config change -- which is how it was first prototyped. Pointing vertex_credentials at a path that does not exist locally would instead fail outright, because LiteLLM treats a non-existent path as inline JSON and tries to parse it. Hence vcell-ai-local sets the Vertex project and location but not GOOGLE_APPLICATION_CREDENTIALS: there is no projected token outside the cluster to point it at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ZbiJ9tw2PWi8A4coNs5w5
The alias name is a three-way contract -- the LiteLLM config, the backend LLMModel Literal, and the frontend ModelId union all have to agree, or the dropdown offers a model the API rejects. The budget-exceeded fallback in llms_service.py needs no change: it only special-cases local-model, and treats everything else uniformly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ZbiJ9tw2PWi8A4coNs5w5
Gemini is the first model here without an API key, and the reason is not obvious from the manifests alone. This writes down the mechanism, the measurements taken before committing to it, and the one ongoing maintenance item. The non-obvious constraint: our API server is VPN-only and the issuer (https://kubernetes.default.svc.cluster.local) does not resolve publicly, so Google cannot fetch our JWKS. The provider has to be created with the keys uploaded inline via --jwk-json-path. Google documents this for self-hosted clusters and states the cluster "doesn't need to be accessible over the internet." The consequence is that the uploaded JWKS is a static snapshot: if the cluster's service-account signing key is ever rotated, every Gemini call starts failing until someone re-uploads it. That is a silent failure mode with no alerting, so it gets its own section and an error-to-cause table. Also notes in the kustomize README why Gemini is deliberately absent from the sealed-secret inventory. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ZbiJ9tw2PWi8A4coNs5w5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
gemini-modelas a third option next toopenai-modelandlocal-model, servinggemini-2.5-flashfromus-central1on the CAIA grant projectcaia-fac-uconn-2-225-2026.Draft until Burwood creates the identity pool (REQ0040169). The pool and provider IDs are
REPLACE_MEplaceholders in two places — the projected volume'saudienceinkustomize/base/litellm.yamland theaudiencefield ofkustomize/base/gcp-wif.json. Everythingelse is finished and verified.
Why this isn't just an API key
Every other model here authenticates with a key in a sealed secret. Gemini can't: org policy
iam.managed.disableServiceAccountKeyCreationblocks service-account key creation on the CAIAproject. Burwood offered to exclude our project from that policy; we'd rather not — it would put a
non-expiring, portable credential in this repo, make us the org's standing exception to a policy
that exists for good reason, and leave us owning rotation.
So the proxy authenticates with Workload Identity Federation instead. The only credential in the pod
is a 1-hour projected ServiceAccount token that kubelet rotates in place:
The credential config holds no key material, so it rides in the plain
litellm-config-fileConfigMap. Adding Gemini required no
kubesealround-trip and no change tosecrets.datorsealed_secret_litellm.sh— that's the practical payoff.The non-obvious constraint
Our API server is VPN-only and the issuer
https://kubernetes.default.svc.cluster.localdoesn'tresolve publicly, so Google cannot fetch our JWKS. The provider must be created with the keys
uploaded inline via
--jwk-json-path. This is a documented path, not a workaround — Google'sWorkload Identity Federation with Kubernetes
covers self-hosted clusters and states "The cluster doesn't need to be accessible over the internet."
This was the one correction we sent back on the ticket: Burwood's first reply asked for the issuer
URL and JWKS, which was right, but the provider has to be told to trust the uploaded keys rather
than go and get them.
What was verified against the live cluster
Measured against
vcell-ai-rke-devand the runninglitellmpod, rather than assumed:sub = system:serviceaccount:vcell-ai-rke-dev:<sa>,issas above.--api-audiencesdoes not restrict projected-token audiencessts,iamcredentials,us-central1-aiplatformall reachable; no firewall or proxy in the waygoogle-auth2.52.0 —vertex_llm_base.pyalready branches ontype == "external_account"and callsidentity_pool.Credentials.from_info(). Nothing needs upgradinggcp-wif.jsonplus a real cluster token was fed togoogle.authinside the pod: parsed as an identity-pool credential, wired impersonation, read the token, and reached Google's STS, which rejected only thePOOL_IDplaceholder. Every link except the pool is provenDesign choices
litellmServiceAccount, not the namespacedefault— the pool's attributecondition names exactly one subject, so federating
defaultwould let any pod in the namespacemint Vertex credentials.
GOOGLE_APPLICATION_CREDENTIALS, notlitellm_params.vertex_credentials. Both work, butleaving the variable unset makes
google.auth.default()fall back to a developer's own gcloudADC, so the same alias works locally with no config change. Pointing
vertex_credentialsat apath that doesn't exist locally would instead fail, because LiteLLM treats a non-existent path as
inline JSON and tries to parse it. Hence
vcell-ai-localsets the project and location but notthe credentials path.
sidesteps the configuration where LiteLLM's since-fixed missing-scopes bug used to bite. One extra
IAM binding.
https://in the projected volume(Google's manifest form), schemeless
//ingcp-wif.json(whatcreate-cred-configemits).Both are default allowed audiences; there's a comment saying not to "fix" it.
Ongoing maintenance to accept
The uploaded JWKS is a static snapshot. If the cluster's service-account signing key is ever
rotated, every Gemini call starts failing with no alerting until someone re-uploads it. Documented
in
docs/gcp-wif-findings.mdwith the recovery command and an STS-error-to-cause table.Testing
kubectl kustomizeon thevcell-ai-rke-devoverlay builds clean; ServiceAccount, projectedvolume, both ConfigMap mounts and the env vars all render as intended.
LLMModelLiteral, and the frontendModelIdunion.vcell-ai-rkeandvcell-ai-localdo notkustomize build, but that is pre-existing (theirsealed secrets are gitignored and absent from disk) and reproduces identically on
main.Still to do once the pool exists: replace the placeholders, then walk the 5-step verification ladder
in
docs/gcp-wif-findings.md— token shape, STS exchange, the LiteLLM alias, end-to-end through theUI, and a call more than an hour after pod start to confirm token rotation.
🤖 Generated with Claude Code
https://claude.ai/code/session_014ZbiJ9tw2PWi8A4coNs5w5