diff --git a/workspace-server/internal/handlers/required_env_derive_test.go b/workspace-server/internal/handlers/required_env_derive_test.go new file mode 100644 index 000000000..6c84d0365 --- /dev/null +++ b/workspace-server/internal/handlers/required_env_derive_test.go @@ -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) + } +} diff --git a/workspace-server/internal/handlers/templates_registry.go b/workspace-server/internal/handlers/templates_registry.go index c9a431b63..fa62c3c98 100644 --- a/workspace-server/internal/handlers/templates_registry.go +++ b/workspace-server/internal/handlers/templates_registry.go @@ -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 diff --git a/workspace-server/internal/providers/gen/registry_gen.go b/workspace-server/internal/providers/gen/registry_gen.go index 51c96dee6..f4501f13a 100644 --- a/workspace-server/internal/providers/gen/registry_gen.go +++ b/workspace-server/internal/providers/gen/registry_gen.go @@ -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"}}, diff --git a/workspace-server/internal/providers/google_adk_platform_test.go b/workspace-server/internal/providers/google_adk_platform_test.go new file mode 100644 index 000000000..54906d4c4 --- /dev/null +++ b/workspace-server/internal/providers/google_adk_platform_test.go @@ -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) + } + } +} diff --git a/workspace-server/internal/providers/providers.yaml b/workspace-server/internal/providers/providers.yaml index 5035bfa3f..1514ab5de 100644 --- a/workspace-server/internal/providers/providers.yaml +++ b/workspace-server/internal/providers/providers.yaml @@ -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 \ No newline at end of file + - 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 \ No newline at end of file diff --git a/workspace-server/internal/providers/sync_canonical_test.go b/workspace-server/internal/providers/sync_canonical_test.go index 56acff2e0..463ad3003 100644 --- a/workspace-server/internal/providers/sync_canonical_test.go +++ b/workspace-server/internal/providers/sync_canonical_test.go @@ -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)