feat: mirror google-adk platform provider + derive required_env from registry (proper SSOT, task #65) #2182

Merged
cp-lead merged 9 commits from feat/google-adk-platform-provider-mirror-ssot into main 2026-06-04 01:28:41 +00:00
6 changed files with 120 additions and 18 deletions
@@ -0,0 +1,21 @@
package handlers
import (
"testing"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/providers"
)
// Proper-SSOT (task #65): required_env is DERIVED from the resolved provider's
// serving classification (IsPlatform), not hand-authored — platform injects
// creds server-side (none required), BYOK requires its auth_env.
func TestRequiredEnvForRegistryProvider(t *testing.T) {
if got := requiredEnvForRegistryProvider(providers.Provider{Name: providers.PlatformProviderName}); got != nil {
t.Errorf("platform provider requiredEnv = %v; want nil (creds injected server-side)", got)
}
byok := providers.Provider{Name: "google", AuthEnv: []string{"GEMINI_API_KEY", "GOOGLE_API_KEY"}}
got := requiredEnvForRegistryProvider(byok)
if len(got) != 2 || got[0] != "GEMINI_API_KEY" {
t.Errorf("byok requiredEnv = %v; want its auth_env", got)
}
}
@@ -44,6 +44,20 @@ func billingModeForRegistryProvider(p providers.Provider) string {
return LLMBillingModeBYOK
}
// requiredEnvForRegistryProvider derives the env vars the USER must supply for
// a model owned by the resolved provider — the proper-SSOT single fact behind
// the canvas "Missing API Keys" preflight (task #65). The closed platform
// provider injects credentials server-side (the metered proxy) -> nothing
// required; BYOK providers require their auth_env. Derived from IsPlatform +
// AuthEnv so a template can no longer hand-author a required_env that drifts
// from the registry's serving classification.
func requiredEnvForRegistryProvider(p providers.Provider) []string {
if p.IsPlatform() {
return nil
}
return p.AuthEnv
}
// enrichFromRegistry populates the registry-served fields on a templateSummary
// when its runtime is known to the provider registry. It is a no-op (leaves
// RegistryBacked=false and the registry slices nil) for a runtime the registry
@@ -98,6 +112,7 @@ func enrichFromRegistry(summary *templateSummary, runtime string) {
if derived, derr := m.DeriveProvider(runtime, id, nil); derr == nil {
ms.Provider = derived.Name
ms.BillingMode = billingModeForRegistryProvider(derived)
ms.RequiredEnv = requiredEnvForRegistryProvider(derived)
}
// If DeriveProvider errors (ambiguous/overlap — a manifest defect the
// loader's tests pin against), still serve the id without a provider
@@ -16,7 +16,7 @@ const SchemaVersion = 1
// Fingerprint is a stable content hash of the generated projection (schema
// version + provider catalog + runtime native sets). It changes iff the
// registry DATA changes (comment-only YAML edits do not churn it).
const Fingerprint = "8f733b112695b926"
const Fingerprint = "a491f5ff8a17ef59"
// GenProvider is the generated projection of one provider catalog entry —
// the subset a downstream consumer needs to derive + display a provider.
@@ -50,7 +50,7 @@ var Providers = []GenProvider{
{Name: "openai-api", DisplayName: "OpenAI API", Protocol: "openai", AuthMode: "anthropic_api", AuthEnv: []string{"OPENAI_API_KEY"}, ModelPrefixMatch: "^openai-api[:/]", IsPlatform: false, UpstreamVendor: "openai"},
{Name: "moonshot", DisplayName: "Moonshot (Kimi)", Protocol: "openai", AuthMode: "third_party_anthropic_compat", AuthEnv: []string{"MOONSHOT_API_KEY", "KIMI_API_KEY"}, ModelPrefixMatch: "^moonshot[:/-]", IsPlatform: false, UpstreamVendor: "moonshot"},
{Name: "minimax", DisplayName: "MiniMax", Protocol: "openai", AuthMode: "third_party_anthropic_compat", AuthEnv: []string{"MINIMAX_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"}, ModelPrefixMatch: "(?i)^minimax-m", IsPlatform: false, UpstreamVendor: "minimax"},
{Name: "platform", DisplayName: "Platform", Protocol: "anthropic", AuthMode: "third_party_anthropic_compat", AuthEnv: []string{"ANTHROPIC_API_KEY", "MOLECULE_LLM_USAGE_TOKEN"}, ModelPrefixMatch: "^platform/", IsPlatform: true},
{Name: "platform", DisplayName: "Platform", Protocol: "anthropic", AuthMode: "third_party_anthropic_compat", AuthEnv: []string{"MOLECULE_LLM_USAGE_TOKEN"}, ModelPrefixMatch: "^platform/", IsPlatform: true},
{Name: "xiaomi-mimo", DisplayName: "Xiaomi MiMo", Protocol: "anthropic", AuthMode: "third_party_anthropic_compat", AuthEnv: []string{"ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"}, ModelPrefixMatch: "^mimo-", IsPlatform: false},
{Name: "zai", DisplayName: "Z.ai (GLM)", Protocol: "anthropic", AuthMode: "third_party_anthropic_compat", AuthEnv: []string{"GLM_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"}, ModelPrefixMatch: "(?i)^glm-", IsPlatform: false},
{Name: "kimi-coding", DisplayName: "Moonshot Kimi (coding-tuned)", Protocol: "anthropic", AuthMode: "third_party_anthropic_compat", AuthEnv: []string{"KIMI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"}, ModelPrefixMatch: "^kimi-", IsPlatform: false},
@@ -89,8 +89,9 @@ var Runtimes = map[string][]GenRuntimeRef{
{Name: "platform", Models: []string{"openai/gpt-5.4", "openai/gpt-5.4-mini"}},
},
"google-adk": {
{Name: "platform", Models: []string{"platform:gemini-2.5-pro", "platform:gemini-2.5-flash"}},
{Name: "google", Models: []string{"gemini-2.5-pro", "gemini-2.5-flash"}},
{Name: "vertex", Models: []string{"vertex:gemini-2.5-pro"}},
{Name: "google", Models: []string{"gemini-2.5-pro"}},
},
"hermes": {
{Name: "kimi-coding", Models: []string{"kimi-coding/kimi-k2"}},
@@ -0,0 +1,42 @@
package providers
import "testing"
// Proper-SSOT (task #65): google-adk keyless Gemini resolves to the closed
// platform provider -> IsPlatform=true; BYOK AI Studio -> google. The
// platform: select ids are registered so workspace-create accepts them
// (was 422 UNREGISTERED_MODEL_FOR_RUNTIME).
func TestGoogleADK_PlatformGeminiResolvesToPlatform(t *testing.T) {
m, err := LoadManifest()
if err != nil {
t.Fatal(err)
}
for _, id := range []string{"platform:gemini-2.5-pro", "platform:gemini-2.5-flash"} {
p, err := m.DeriveProvider("google-adk", id, nil)
if err != nil {
t.Fatalf("%s: %v", id, err)
}
if p.Name != PlatformProviderName || !p.IsPlatform() {
t.Errorf("%s -> %q IsPlatform=%v; want platform", id, p.Name, p.IsPlatform())
}
}
p, err := m.DeriveProvider("google-adk", "gemini-2.5-pro", nil)
if err != nil {
t.Fatal(err)
}
if p.IsPlatform() || p.Name != "google" {
t.Errorf("gemini-2.5-pro -> %q IsPlatform=%v; want google byok", p.Name, p.IsPlatform())
}
models, _ := m.ModelsForRuntime("google-adk")
want := map[string]bool{"platform:gemini-2.5-pro": false, "platform:gemini-2.5-flash": false}
for _, id := range models {
if _, ok := want[id]; ok {
want[id] = true
}
}
for id, ok := range want {
if !ok {
t.Errorf("%s not registered for google-adk — create would 422", id)
}
}
}
@@ -292,7 +292,7 @@ providers:
# PR-1 simplification when only claude-code referenced platform.
base_url_template: "https://api.moleculesai.app/api/v1/internal/llm/openai/v1"
base_url_anthropic: "https://api.moleculesai.app/api/v1/internal/llm/anthropic/v1"
auth_env: [ANTHROPIC_API_KEY, MOLECULE_LLM_USAGE_TOKEN]
auth_env: [MOLECULE_LLM_USAGE_TOKEN]
auth_token_env: ANTHROPIC_API_KEY
# Adapter routes kimi- / moonshot/ through platform by default. No bare
# vendor prefix of its own; it multiplexes other vendors' slugs. Match
@@ -412,14 +412,24 @@ providers:
model_prefix_match: "^gemini-"
model_aliases: []
# Google Vertex AI — KEYLESS arm (mirrors the anthropic-oauth / anthropic-api
# and openai-subscription / openai-api split: same vendor, distinct auth).
# google-adk serves Gemini via Vertex using Application Default Credentials
# over Workload Identity Federation (AWS EC2 role -> GCP STS -> SA), injected
# by the provisioner (cp#416 + envs.yaml vertex block) as a NON-SECRET
# external_account cred-config at GOOGLE_APPLICATION_CREDENTIALS. No API key.
# Distinct `vertex:` model namespace keeps it unambiguous vs the API-key
# `google` vendor's ^gemini- (TestNoAmbiguousModelMatch).
# Google Vertex AI — served via the Molecule CP LLM proxy (NOT on-box ADC).
# google-adk routes ALL Gemini through the proxy, which mints a Vertex token
# server-side over Workload Identity Federation (AWS -> GCP STS -> SA; see
# internal/vertexauth + llm_proxy.go google/vertex case) and meters usage to
# org credits. The former on-box ADC delivery (a NON-SECRET external_account
# cred-config written to /configs/gcp-adc.json via GOOGLE_APPLICATION_CREDENTIALS,
# injected by the provisioner) was REMOVED: a tenant has EC2 root and could
# have used that credential to call Vertex DIRECTLY, bypassing metering
# (keyless-Vertex billing leak — task #64 / RFC internal#763; provisioner
# force-off + template routes vertex: through the proxy). The `vertex:`
# namespace + this entry remain for proxy routing/billing of Vertex-upstream
# requests, distinct from the API-key `google` vendor's ^gemini-
# (TestNoAmbiguousModelMatch).
#
# NOTE: display_name ("keyless ADC") and auth_env (GOOGLE_APPLICATION_CREDENTIALS)
# are now VESTIGIAL — no consumer reads auth_env post-leak-fix, but it must stay
# non-empty (providers.go validate). Left as-is to keep this a comment-only,
# regen-free change; retiring them is a registry-regen follow-up.
- name: vertex
display_name: "Google Vertex AI (keyless ADC)"
vendor_logo: "google"
@@ -844,11 +854,24 @@ runtimes:
# this runtimes entry declares the selectable model set.
google-adk:
providers:
# Keyless Vertex (org-compliant default): Gemini via Vertex AI + ADC/WIF.
- name: vertex
# Platform-managed (keyless, metered) Gemini via the Molecule LLM proxy ->
# Vertex AI (server-side WIF mint; NO credential on the tenant box — the
# keyless-Vertex leak fix). Resolves to the closed `platform` provider ->
# IsPlatform=true -> platform_managed billing + required_env=[] (derived).
# The org-compliant default. The runtime translates the `platform:` select
# id to the bare wire id the proxy routes to Vertex.
- name: platform
models:
- vertex:gemini-2.5-pro
# API-key BYOK arm: AI Studio GEMINI_API_KEY/GOOGLE_API_KEY.
- platform:gemini-2.5-pro
- platform:gemini-2.5-flash
# API-key BYOK arm: AI Studio (the tenant's OWN GOOGLE_API_KEY).
- name: google
models:
- gemini-2.5-pro
- gemini-2.5-pro
- gemini-2.5-flash
# DEPRECATED transitional: vertex: ids stay registered until templates
# move to platform: (superseded by the platform arm above). Remove in a
# cleanup once no template references vertex:gemini-*.
- name: vertex
models:
- vertex:gemini-2.5-pro
@@ -29,7 +29,7 @@ import (
// canonicalProvidersYAMLSHA256 is the sha256 of the canonical providers.yaml as
// synced from molecule-controlplane. Bumped deliberately on each re-sync (see
// file doc). Cross-checked live by the sync-providers-yaml CI workflow.
const canonicalProvidersYAMLSHA256 = "dec73199e26cee2d395a0acece99771618d3879dc5ca724ba57cb5b38079c6ce"
const canonicalProvidersYAMLSHA256 = "021ae082c2bbbbb61c406cae03205ac6b7fff160ae5976cfc64de3de676d02b2"
func TestSyncedYAMLMatchesCanonicalSHA(t *testing.T) {
sum := sha256.Sum256(embeddedYAML)