Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions forge-cli/build/model_provider_stage.go
Original file line number Diff line number Diff line change
@@ -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
}
77 changes: 77 additions & 0 deletions forge-cli/build/model_provider_stage_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
1 change: 1 addition & 0 deletions forge-cli/cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{},
Expand Down
Loading