From 6242613d195289f735168c9823633b89eea3f571 Mon Sep 17 00:00:00 2001 From: Naveen Kurra Date: Thu, 11 Jun 2026 12:08:51 -0400 Subject: [PATCH] feat(build): wire the model provider's API key into generated secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider's APIKeyEnvVar (OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY) is defined in forge-core/catalog but was never referenced at build time, so it never became a secrets.yaml placeholder or a Deployment secretKeyRef env entry. A deployed agent therefore had no way to receive its provider key and the LLM client fell back to a stub at runtime. Add ModelProviderStage: it looks up the configured model provider in the catalog and adds its APIKeyEnvVar to Spec.Requirements.EnvOptional — optional, because a provider may authenticate via OAuth and local providers (e.g. Ollama) need no key at all. The existing secrets.yaml / deployment.yaml templates then emit the placeholder and the secretKeyRef env entry, so an operator-supplied key reaches the running agent. Runs after ChannelsStage; de-dups against skill/channel-declared env vars. --- forge-cli/build/model_provider_stage.go | 58 +++++++++++++++ forge-cli/build/model_provider_stage_test.go | 77 ++++++++++++++++++++ forge-cli/cmd/build.go | 1 + 3 files changed, 136 insertions(+) create mode 100644 forge-cli/build/model_provider_stage.go create mode 100644 forge-cli/build/model_provider_stage_test.go diff --git a/forge-cli/build/model_provider_stage.go b/forge-cli/build/model_provider_stage.go new file mode 100644 index 0000000..498fcb5 --- /dev/null +++ b/forge-cli/build/model_provider_stage.go @@ -0,0 +1,58 @@ +package build + +import ( + "context" + "sort" + + "github.com/initializ/forge/forge-core/agentspec" + "github.com/initializ/forge/forge-core/catalog" + "github.com/initializ/forge/forge-core/pipeline" +) + +// ModelProviderStage adds the configured model provider's API-key env var +// (e.g. OPENAI_API_KEY) to Spec.Requirements.EnvOptional, so the generated +// secrets.yaml placeholder and the Deployment's secretKeyRef env entry include +// it. Without this the provider key — declared in the catalog but never +// referenced at build time — never reaches the running agent, and the LLM +// client falls back to a stub. +// +// The key is added as OPTIONAL (not required) because a provider may +// authenticate via OAuth instead of an API key, and local providers (e.g. +// Ollama) need no key at all; an unset optional secret key is simply ignored at +// runtime. When a key is supplied at deploy time it is wired through to the pod. +type ModelProviderStage struct{} + +func (s *ModelProviderStage) Name() string { return "model-provider-env" } + +func (s *ModelProviderStage) Execute(ctx context.Context, bc *pipeline.BuildContext) error { + if bc.Config == nil || bc.Spec == nil { + return nil + } + p, ok := catalog.ProviderByID(bc.Config.Model.Provider) + if !ok || p.APIKeyEnvVar == "" { + // Unknown provider, or one that takes no API key (e.g. local/custom). + return nil + } + key := p.APIKeyEnvVar + + if bc.Spec.Requirements == nil { + bc.Spec.Requirements = &agentspec.AgentRequirements{} + } + // Leave it alone if a skill or channel already declares it (required or + // optional) — don't duplicate or downgrade an existing requirement. + for _, v := range bc.Spec.Requirements.EnvRequired { + if v == key { + return nil + } + } + for _, v := range bc.Spec.Requirements.EnvOptional { + if v == key { + return nil + } + } + + opt := append(bc.Spec.Requirements.EnvOptional, key) + sort.Strings(opt) + bc.Spec.Requirements.EnvOptional = opt + return nil +} diff --git a/forge-cli/build/model_provider_stage_test.go b/forge-cli/build/model_provider_stage_test.go new file mode 100644 index 0000000..c793b17 --- /dev/null +++ b/forge-cli/build/model_provider_stage_test.go @@ -0,0 +1,77 @@ +package build + +import ( + "context" + "slices" + "testing" + + "github.com/initializ/forge/forge-core/agentspec" + "github.com/initializ/forge/forge-core/pipeline" + "github.com/initializ/forge/forge-core/types" +) + +func TestModelProviderStage_AddsProviderKeyAsOptional(t *testing.T) { + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{}) + bc.Config = &types.ForgeConfig{Model: types.ModelRef{Provider: "openai"}} + bc.Spec = &agentspec.AgentSpec{ + Requirements: &agentspec.AgentRequirements{EnvRequired: []string{"SKILL_API_KEY"}}, + } + + if err := (&ModelProviderStage{}).Execute(context.Background(), bc); err != nil { + t.Fatalf("Execute: %v", err) + } + + if !slices.Contains(bc.Spec.Requirements.EnvOptional, "OPENAI_API_KEY") { + t.Errorf("EnvOptional = %v, want it to contain OPENAI_API_KEY", bc.Spec.Requirements.EnvOptional) + } + if slices.Contains(bc.Spec.Requirements.EnvRequired, "OPENAI_API_KEY") { + t.Errorf("OPENAI_API_KEY should be optional, not required") + } + // Existing skill env vars are untouched. + if !slices.Contains(bc.Spec.Requirements.EnvRequired, "SKILL_API_KEY") { + t.Errorf("existing EnvRequired lost: %v", bc.Spec.Requirements.EnvRequired) + } +} + +func TestModelProviderStage_PopulatesRequirementsWhenNil(t *testing.T) { + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{}) + bc.Config = &types.ForgeConfig{Model: types.ModelRef{Provider: "anthropic"}} + bc.Spec = &agentspec.AgentSpec{} // Requirements nil + + if err := (&ModelProviderStage{}).Execute(context.Background(), bc); err != nil { + t.Fatalf("Execute: %v", err) + } + if bc.Spec.Requirements == nil || !slices.Contains(bc.Spec.Requirements.EnvOptional, "ANTHROPIC_API_KEY") { + t.Errorf("expected ANTHROPIC_API_KEY in EnvOptional, got %+v", bc.Spec.Requirements) + } +} + +func TestModelProviderStage_SkipsWhenAlreadyDeclared(t *testing.T) { + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{}) + bc.Config = &types.ForgeConfig{Model: types.ModelRef{Provider: "openai"}} + bc.Spec = &agentspec.AgentSpec{ + Requirements: &agentspec.AgentRequirements{EnvRequired: []string{"OPENAI_API_KEY"}}, + } + + if err := (&ModelProviderStage{}).Execute(context.Background(), bc); err != nil { + t.Fatalf("Execute: %v", err) + } + if slices.Contains(bc.Spec.Requirements.EnvOptional, "OPENAI_API_KEY") { + t.Errorf("should not re-add OPENAI_API_KEY to optional when already required") + } +} + +func TestModelProviderStage_KeylessOrUnknownProviderAddsNothing(t *testing.T) { + for _, prov := range []string{"ollama", "custom", "does-not-exist", ""} { + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{}) + bc.Config = &types.ForgeConfig{Model: types.ModelRef{Provider: prov}} + bc.Spec = &agentspec.AgentSpec{} + + if err := (&ModelProviderStage{}).Execute(context.Background(), bc); err != nil { + t.Fatalf("Execute(%q): %v", prov, err) + } + if bc.Spec.Requirements != nil && len(bc.Spec.Requirements.EnvOptional) > 0 { + t.Errorf("provider %q should add no env var, got %v", prov, bc.Spec.Requirements.EnvOptional) + } + } +} diff --git a/forge-cli/cmd/build.go b/forge-cli/cmd/build.go index b2507c4..b7e1dc6 100644 --- a/forge-cli/cmd/build.go +++ b/forge-cli/cmd/build.go @@ -117,6 +117,7 @@ func runBuild(cmd *cobra.Command, args []string) error { &build.SecurityAnalysisStage{PolicyPathOverride: buildPolicyPath}, &build.RequirementsStage{}, &build.ChannelsStage{}, + &build.ModelProviderStage{}, &build.PolicyStage{}, &build.EgressStage{}, &build.DockerfileStage{},