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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ OBJECT_STORE_DRIVER=memory
# SecretStore against the running workflow's environment, e.g.
# LLM_OPENAI_API_KEY={{secret:openai_prod_key}}
# Store the value per environment with: fuse secrets set --env <env> openai_prod_key sk-...
# Or reference a credential id; its apiKey field supplies the key (per environment):
# LLM_OPENAI_CREDENTIAL=openai-prod
# fuse credentials set openai-prod apiKey sk-... --env <env>
# LLM_DEFAULT_PROVIDER=ollama
#
# Ollama (local dev, no API key needed) — OpenAI-compatible endpoint:
Expand Down
13 changes: 11 additions & 2 deletions internal/app/cli/secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"

"github.com/open-source-cloud/fuse/internal/app/config"
"github.com/open-source-cloud/fuse/internal/app/di"
Expand Down Expand Up @@ -59,11 +60,19 @@ func newSecretsListCommand() *cobra.Command {
if err != nil {
return err
}
// Hide credential-backed secrets (cred/<id>/<field>); they are managed via
// `fuse credentials`, not exposed in the plain secret surface (ADR-0031).
visible := make([]string, 0, len(names))
for _, n := range names {
if !strings.HasPrefix(n, "cred/") {
visible = append(visible, n)
}
}
fmt.Printf("secrets in environment %q:\n", env)
if len(names) == 0 {
if len(visible) == 0 {
fmt.Println(" (none)")
}
for _, n := range names {
for _, n := range visible {
fmt.Printf(" - %s\n", n)
}
return nil
Expand Down
11 changes: 7 additions & 4 deletions internal/app/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,13 @@ type (

// LLMProviderConfig configures a single LLM provider connection.
LLMProviderConfig struct {
Enabled bool `env:"ENABLED" envDefault:"false"`
APIKey string `env:"API_KEY"`
BaseURL string `env:"BASE_URL"`
Model string `env:"MODEL"`
Enabled bool `env:"ENABLED" envDefault:"false"`
APIKey string `env:"API_KEY"`
BaseURL string `env:"BASE_URL"`
Model string `env:"MODEL"`
// Credential references a credential id (ADR-0031); when set and API_KEY is not given, the
// provider's apiKey is taken from that credential's apiKey field (resolved per environment).
Credential string `env:"CREDENTIAL"`
Temperature float32 `env:"TEMPERATURE" envDefault:"0.7"`
}

Expand Down
38 changes: 31 additions & 7 deletions internal/app/di/llm.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,21 @@ func provideLLMRegistry(cfg *config.Config, secretStore secrets.SecretStore) llm
return llm.NewRegistry(factories, cfg.LLM.DefaultProvider)
}

// newProviderFactory returns a factory for one provider. When the config has no {{secret:NAME}}
// references the provider is built once and the factory returns that singleton (preserving the
// previous static behavior). Otherwise the factory resolves the references per call against the
// given environment (falling back to defaultEnv when empty) and builds a fresh provider.
// newProviderFactory returns a factory for one provider. When the config has no {{secret:NAME}} or
// {{credential:ID.FIELD}} references the provider is built once and the factory returns that
// singleton (preserving the previous static behavior). Otherwise the factory resolves the
// references per call against the given environment (falling back to defaultEnv when empty) and
// builds a fresh provider.
func newProviderFactory(name string, conf config.LLMProviderConfig, build providerBuilder, store secrets.SecretStore, defaultEnv string) llm.ProviderFactory {
if !secrets.HasSecretRef(conf.APIKey) && !secrets.HasSecretRef(conf.BaseURL) {
// A configured credential id supplies the apiKey as a credential reference (ADR-0031) when the
// key is not set explicitly; it then resolves like any other reference, per environment. Only
// apiKey is mapped automatically — a base URL is provider-specific and rarely part of a
// credential, so set BASE_URL={{credential:<id>.baseUrl}} explicitly if needed.
if conf.Credential != "" && conf.APIKey == "" {
conf.APIKey = secrets.CredentialRefToken(conf.Credential, "apiKey")
}

if !hasAnyRef(conf.APIKey) && !hasAnyRef(conf.BaseURL) {
p := build(name, conf.APIKey, conf.BaseURL, conf.Model)
return func(_ context.Context, _ string) (llm.Provider, error) { return p, nil }
}
Expand All @@ -102,14 +111,29 @@ func newProviderFactory(name string, conf config.LLMProviderConfig, build provid
}
return v.Reveal(), nil
}
apiKey, err := secrets.ReplaceSecretRefs(conf.APIKey, resolve)
apiKey, err := resolveRefs(conf.APIKey, resolve)
if err != nil {
return nil, fmt.Errorf("llm[%s]: resolve api key for environment %q: %w", name, env, err)
}
baseURL, err := secrets.ReplaceSecretRefs(conf.BaseURL, resolve)
baseURL, err := resolveRefs(conf.BaseURL, resolve)
if err != nil {
return nil, fmt.Errorf("llm[%s]: resolve base url for environment %q: %w", name, env, err)
}
return build(name, apiKey, baseURL, conf.Model), nil
}
}

// hasAnyRef reports whether s contains a secret or credential reference.
func hasAnyRef(s string) bool {
return secrets.HasSecretRef(s) || secrets.HasCredentialRef(s)
}

// resolveRefs resolves both {{secret:NAME}} and {{credential:ID.FIELD}} references in s via the
// same per-environment resolve function (credential refs map to their reserved secret name).
func resolveRefs(s string, resolve func(string) (string, error)) (string, error) {
out, err := secrets.ReplaceSecretRefs(s, resolve)
if err != nil {
return "", err
}
return secrets.ReplaceCredentialRefs(out, resolve)
}
32 changes: 32 additions & 0 deletions internal/app/di/llm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,38 @@ func TestProvideLLMRegistry_ResolvesKeyPerEnvironment(t *testing.T) {
assert.ErrorIs(t, err, secrets.ErrSecretNotFound)
}

func TestProvideLLMRegistry_ResolvesCredential(t *testing.T) {
ctx := context.Background()
store := secrets.NewMemorySecretStore()
// A credential's apiKey field value lives at the reserved cred/<id>/apiKey secret name.
require.NoError(t, store.Set(ctx, secrets.Scope{Environment: "staging"},
secrets.CredentialSecretName("openai-prod", "apiKey"), "sk-from-credential"))

cfg := &config.Config{
Environment: "default",
LLM: config.LLMConfig{
DefaultProvider: providerOpenAI,
OpenAI: config.LLMProviderConfig{
Enabled: true,
Credential: "openai-prod",
Model: "gpt-4o-mini",
},
},
}

reg := provideLLMRegistry(cfg, store)

// The credential's apiKey resolves in staging -> provider builds.
prov, err := reg.Get(ctx, "staging", providerOpenAI)
require.NoError(t, err)
assert.Equal(t, providerOpenAI, prov.Name())

// An environment lacking the credential value surfaces a resolution error, not a panic.
_, err = reg.Get(ctx, "prod", providerOpenAI)
require.Error(t, err)
assert.ErrorIs(t, err, secrets.ErrSecretNotFound)
}

func TestProvideLLMRegistry_StaticProviderIsSingleton(t *testing.T) {
ctx := context.Background()
cfg := &config.Config{
Expand Down
4 changes: 4 additions & 0 deletions internal/workflow/edge_schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ const (
// scoped by the workflow's environment. The resolved value is redacted in every
// engine sink (ADR-0031).
SourceSecret InputMappingSource = "secret"
// SourceCredential resolves a credential field, with Variable holding "<id>.<field>".
// The value is read from the SecretStore at cred/<id>/<field>, scoped by the workflow's
// environment, and redacted in every engine sink (ADR-0031).
SourceCredential InputMappingSource = "credential"
)

type (
Expand Down
Loading
Loading