diff --git a/docs/adr/48107-propagate-model-provider-pricing-into-awf-apiproxy-config.md b/docs/adr/48107-propagate-model-provider-pricing-into-awf-apiproxy-config.md new file mode 100644 index 00000000000..c9989ec368d --- /dev/null +++ b/docs/adr/48107-propagate-model-provider-pricing-into-awf-apiproxy-config.md @@ -0,0 +1,49 @@ +# ADR-48107: Propagate `models.providers` Pricing into AWF `apiProxy` Config + +**Date**: 2026-07-26 +**Status**: Draft +**Deciders**: Unknown + +--- + +### Context + +The AWF runtime enforces AI-credits consumption through an API proxy guardrail that resolves per-model pricing at request time. Pricing data for built-in models is embedded in the proxy's static catalog. When users configure custom or BYOK models (e.g., via `models.providers` overrides), those pricing entries are registered only in the agent-side catalog; the API proxy evaluates a separately generated `awf-config.json` that contains no provider pricing, causing every custom-model request to fail at turn 1 with `unknown_model_ai_credits`. The same data flow gap exists for threat-detection runs, which construct their own `WorkflowData` from the primary run's data without propagating `ModelCosts`. + +### Decision + +We will embed `models.providers` pricing overlays from `WorkflowData.ModelCosts` into the generated `awf-config.json` under the `apiProxy.providers` key, and propagate `ModelCosts` into the `WorkflowData` constructed for threat-detection runs. This makes the API proxy's pricing resolver self-contained within the AWF config it receives, removing the dependency on a separate catalog lookup at proxy evaluation time. + +### Alternatives Considered + +#### Alternative 1: Shared Pricing Service / Catalog Query at Proxy Evaluation Time + +The API proxy could query a shared in-process or network pricing service at request evaluation time rather than reading pricing from the config. This would decouple pricing data from the serialized config artifact. + +Not chosen because it would require a new inter-component dependency (the proxy calling back into the agent process or an external service), adding latency on every guarded request and significant complexity to both the proxy and the agent runtime. The config-embedding approach requires no new interfaces. + +#### Alternative 2: Default Fallback Cost for Unknown Models + +The API proxy could apply a configurable default cost for any model not found in its pricing table, rather than failing with `unknown_model_ai_credits`. + +Not chosen because a silent fallback would allow under-accounting of AI-credit consumption for BYOK models — a correctness and auditability concern. Users configure explicit pricing precisely to get accurate accounting. Propagating actual pricing preserves correctness without requiring any policy decisions about default values. + +### Consequences + +#### Positive +- Custom and BYOK models resolve pricing correctly at the API proxy layer, eliminating `unknown_model_ai_credits` failures for all runs using `models.providers` overrides. +- Threat-detection runs gain pricing parity with primary agent runs, ensuring that detection phases do not fail or under-account when a custom model is configured. +- The AWF config is self-contained: all information the proxy needs to evaluate a request is present in the config artifact, simplifying debugging and offline replay. +- A focused test suite regression-guards both paths (main `BuildAWFConfigJSON` and detection step generation). + +#### Negative +- The `apiProxy.providers` field is typed as `map[string]any`, offering no compile-time validation of pricing structure; malformed cost entries (e.g., non-numeric strings) would be forwarded to the proxy silently. +- Each `awf-config.json` artifact now embeds a copy of the provider pricing catalog, increasing config size in proportion to the number of custom models configured. + +#### Neutral +- The AWF config JSON schema (`awf-config.schema.json`) is extended to allow `apiProxy.providers` with `additionalProperties: true`, preventing schema-validation rejection of the new field while intentionally not constraining the nested pricing shape. +- Existing runs with no `models.providers` configuration are unaffected: `extractModelCostProviders` returns `nil` when `ModelCosts` is empty or has no `providers` key. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* diff --git a/pkg/constants/version_constants.go b/pkg/constants/version_constants.go index ee1a5bcc5f2..296ad6af219 100644 --- a/pkg/constants/version_constants.go +++ b/pkg/constants/version_constants.go @@ -116,6 +116,12 @@ const AWFContainerRuntimeMinVersion Version = "v0.27.30" // Workflows pinning an older AWF version must use the old --security-mode compat behavior. const AWFLegacySecurityMinVersion Version = "v0.27.32" +// AWFAPIProxyProvidersMinVersion is the minimum AWF version that supports +// apiProxy.providers in awf-config.json. +// Workflows pinning an older AWF version must not emit this field because older +// AWF strict config validation rejects unknown apiProxy properties. +const AWFAPIProxyProvidersMinVersion Version = "v0.27.42" + // DefaultGVisorVersion is the pinned gVisor release used by the compiler-generated // install step. A specific dated release name is used instead of "latest" to ensure // reproducible, verifiable installs. Each release provides SHA-512 files for diff --git a/pkg/workflow/awf_config.go b/pkg/workflow/awf_config.go index 9efd20b06cf..1d6937fbe68 100644 --- a/pkg/workflow/awf_config.go +++ b/pkg/workflow/awf_config.go @@ -67,6 +67,7 @@ import ( _ "embed" "encoding/json" "fmt" + "maps" "slices" "strconv" "strings" @@ -259,6 +260,12 @@ type AWFAPIProxyConfig struct { // The "gemini" target is also used for Antigravity engine routing. Targets map[string]*AWFAPITargetConfig `json:"targets,omitempty"` + // Providers holds per-provider model pricing overlays used by the API proxy + // AI-credits guardrails for models not present in the built-in pricing table. + // Structure matches models.json provider format: + // providers..models..cost.{input,output,cache_read,cache_write,reasoning} + Providers map[string]any `json:"providers,omitempty"` + // Models contains model alias and fallback policy definitions. // Keys are alias names (empty string "" = default policy); values are ordered // lists of vendor/modelid patterns or other alias names to try in sequence. @@ -602,6 +609,15 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) { awfConfigLog.Printf("API proxy: %d custom targets configured", len(targets)) } + if providers := extractModelCostProviders(config.WorkflowData); len(providers) > 0 { + if awfSupportsAPIProxyProviders(firewallConfig) { + apiProxy.Providers = providers + awfConfigLog.Printf("API proxy: %d model-cost provider override(s) configured", len(providers)) + } else { + awfConfigLog.Printf("Skipping apiProxy.providers: AWF version %q requires at least %s", getAWFImageTag(firewallConfig), constants.AWFAPIProxyProvidersMinVersion) + } + } + // ── Models section (nested under apiProxy per AWF config schema) ────────── if config.WorkflowData != nil && len(config.WorkflowData.ModelMappings) > 0 { apiProxy.Models = config.WorkflowData.ModelMappings @@ -866,6 +882,23 @@ func extractDefaultAiCreditsPricing(workflowData *WorkflowData) *AiCreditsPricin } } +func extractModelCostProviders(workflowData *WorkflowData) map[string]any { + if workflowData == nil || len(workflowData.ModelCosts) == 0 { + return nil + } + providers, ok := workflowData.ModelCosts["providers"].(map[string]any) + if !ok { + awfConfigLog.Printf("API proxy: models.providers has unexpected type %T; skipping provider overlay", workflowData.ModelCosts["providers"]) + return nil + } + if len(providers) == 0 { + return nil + } + clone := make(map[string]any, len(providers)) + maps.Copy(clone, providers) + return clone +} + // getRunnerTopology extracts the runner topology string from WorkflowData. // Returns an empty string when no topology is configured. func getRunnerTopology(workflowData *WorkflowData) string { diff --git a/pkg/workflow/awf_config_test.go b/pkg/workflow/awf_config_test.go index 4cb6c0d00ba..42d8053783e 100644 --- a/pkg/workflow/awf_config_test.go +++ b/pkg/workflow/awf_config_test.go @@ -1203,6 +1203,123 @@ func TestBuildAWFConfigJSON(t *testing.T) { require.NoError(t, err) assert.NotContains(t, jsonStr, `"defaultAiCreditsPricing"`, "apiProxy should omit defaultAiCreditsPricing when not configured") }) + + t.Run("models.providers cost overlay is emitted in apiProxy config when AWF supports providers", func(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "claude", + AllowedDomains: "github.com", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ + ID: "claude", + }, + ModelCosts: map[string]any{ + "providers": map[string]any{ + "anthropic": map[string]any{ + "models": map[string]any{ + "accounts/fireworks/models/minimax-m3": map[string]any{ + "cost": map[string]any{ + "input": "3e-07", + "output": "1.5e-06", + "cache_read": "3e-08", + "cache_write": "3.75e-07", + }, + }, + }, + }, + }, + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true, Version: string(constants.AWFAPIProxyProvidersMinVersion)}, + }, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(jsonStr), &parsed)) + apiProxy, ok := parsed["apiProxy"].(map[string]any) + require.True(t, ok, "expected apiProxy object") + providers, ok := apiProxy["providers"].(map[string]any) + require.True(t, ok, "expected apiProxy.providers object") + anthropic, ok := providers["anthropic"].(map[string]any) + require.True(t, ok, "expected anthropic provider") + models, ok := anthropic["models"].(map[string]any) + require.True(t, ok, "expected anthropic.models object") + model, ok := models["accounts/fireworks/models/minimax-m3"].(map[string]any) + require.True(t, ok, "expected custom model key in providers") + cost, ok := model["cost"].(map[string]any) + require.True(t, ok, "expected model cost object") + assert.Equal(t, "3e-08", cost["cache_read"], "apiProxy.providers should preserve custom cache_read pricing") + }) + + t.Run("models.providers is not emitted when AWF version does not support apiProxy.providers", func(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "claude", + AllowedDomains: "github.com", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ + ID: "claude", + }, + ModelCosts: map[string]any{ + "providers": map[string]any{ + "anthropic": map[string]any{ + "models": map[string]any{ + "accounts/fireworks/models/minimax-m3": map[string]any{ + "cost": map[string]any{ + "input": "3e-07", + "output": "1.5e-06", + }, + }, + }, + }, + }, + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true, Version: "v0.27.41"}, + }, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(jsonStr), &parsed)) + apiProxy, ok := parsed["apiProxy"].(map[string]any) + require.True(t, ok, "expected apiProxy object") + _, hasProviders := apiProxy["providers"] + assert.False(t, hasProviders, "apiProxy should omit providers when AWF version does not support it") + }) +} + +func TestExtractModelCostProviders(t *testing.T) { + t.Run("returns a cloned providers map", func(t *testing.T) { + workflowData := &WorkflowData{ + ModelCosts: map[string]any{ + "providers": map[string]any{ + "anthropic": map[string]any{"models": map[string]any{}}, + }, + }, + } + + got := extractModelCostProviders(workflowData) + require.NotNil(t, got) + got["openai"] = map[string]any{} + + origProviders := workflowData.ModelCosts["providers"].(map[string]any) + _, mutatedOriginal := origProviders["openai"] + assert.False(t, mutatedOriginal, "returned providers map should not alias ModelCosts.providers") + }) + + t.Run("returns nil when providers has unexpected type", func(t *testing.T) { + workflowData := &WorkflowData{ + ModelCosts: map[string]any{ + "providers": map[string]string{"anthropic": "invalid"}, + }, + } + assert.Nil(t, extractModelCostProviders(workflowData)) + }) } // TestBuildAWFConfigSchemaURL verifies that buildAWFConfigSchemaURL returns a release-pinned diff --git a/pkg/workflow/awf_helpers.go b/pkg/workflow/awf_helpers.go index 5a135837311..22e6a7ee1c8 100644 --- a/pkg/workflow/awf_helpers.go +++ b/pkg/workflow/awf_helpers.go @@ -1093,6 +1093,12 @@ func awfSupportsLegacySecurity(firewallConfig *FirewallConfig) bool { return awfVersionAtLeast(firewallConfig, constants.AWFLegacySecurityMinVersion) } +// awfSupportsAPIProxyProviders returns true when the effective AWF version supports +// apiProxy.providers in awf-config.json. +func awfSupportsAPIProxyProviders(firewallConfig *FirewallConfig) bool { + return awfVersionAtLeast(firewallConfig, constants.AWFAPIProxyProvidersMinVersion) +} + // buildArcDindChrootConfigPatchBody returns the Node.js command that patches the AWF // config file with chroot.binariesSourcePath and chroot.identity.*. It is designed to be // embedded inside a bash if-block that already guards on DOCKER_HOST=tcp://... diff --git a/pkg/workflow/awf_helpers_test.go b/pkg/workflow/awf_helpers_test.go index 26ebad7bd88..a5c2a1773c2 100644 --- a/pkg/workflow/awf_helpers_test.go +++ b/pkg/workflow/awf_helpers_test.go @@ -1819,6 +1819,48 @@ func TestAWFSupportsChrootConfig(t *testing.T) { } } +// TestAWFSupportsAPIProxyProviders tests the awfSupportsAPIProxyProviders version gate. +func TestAWFSupportsAPIProxyProviders(t *testing.T) { + tests := []struct { + name string + firewallConfig *FirewallConfig + want bool + }{ + { + name: "nil firewall config returns false (uses default version)", + firewallConfig: nil, + want: false, + }, + { + name: "empty version returns false (uses default version)", + firewallConfig: &FirewallConfig{}, + want: false, + }, + { + name: "latest returns true", + firewallConfig: &FirewallConfig{Version: "latest"}, + want: true, + }, + { + name: "v0.27.42 supports apiProxy.providers (exact minimum version)", + firewallConfig: &FirewallConfig{Version: "v0.27.42"}, + want: true, + }, + { + name: "v0.27.41 does not support apiProxy.providers", + firewallConfig: &FirewallConfig{Version: "v0.27.41"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := awfSupportsAPIProxyProviders(tt.firewallConfig) + assert.Equal(t, tt.want, got, "awfSupportsAPIProxyProviders result") + }) + } +} + // TestBuildAWFCommand_IncludesChrootInjectScript verifies that BuildAWFCommand // includes the chroot injection script in the generated run step when the AWF // version supports it. diff --git a/pkg/workflow/evals_steps.go b/pkg/workflow/evals_steps.go index 42ba80133b7..70f2d4b665b 100644 --- a/pkg/workflow/evals_steps.go +++ b/pkg/workflow/evals_steps.go @@ -196,6 +196,8 @@ func (c *Compiler) buildEvalsEngineSteps(data *WorkflowData) []string { // ModelMappings is propagated so the evals awf-config.json includes the alias map // (apiProxy.models). Without it, copilot_harness.cjs cannot resolve alias model names // (e.g. "small") to concrete ids before spawning the engine in the evals job. + // ModelCosts is propagated so evals awf-config.json includes provider pricing overlays + // (apiProxy.providers), allowing max-ai-credits pricing lookup for custom/BYOK models. evalsData := &WorkflowData{ Tools: map[string]any{ "bash": []any{"*"}, @@ -211,6 +213,7 @@ func (c *Compiler) buildEvalsEngineSteps(data *WorkflowData) []string { IsEvalsRun: true, RunnerConfig: data.RunnerConfig, // propagate runner.topology (e.g. arc-dind) to the evals job ModelMappings: data.ModelMappings, // propagate alias map so evals awf-config.json can resolve model aliases + ModelCosts: data.ModelCosts, // propagate pricing providers so evals awf-config.json can resolve AI-credit costs NetworkPermissions: &NetworkPermissions{ Allowed: getThreatDetectionAdditionalAllowedDomains(data), }, @@ -220,6 +223,17 @@ func (c *Compiler) buildEvalsEngineSteps(data *WorkflowData) []string { }, }, } + if firewallConfig := getFirewallConfig(data); firewallConfig != nil { + firewallCopy := *firewallConfig + evalsData.NetworkPermissions.Firewall = &firewallCopy + if evalsData.SandboxConfig == nil { + evalsData.SandboxConfig = &SandboxConfig{} + } + if evalsData.SandboxConfig.Agent == nil { + evalsData.SandboxConfig.Agent = &AgentSandboxConfig{Type: SandboxTypeAWF} + } + evalsData.SandboxConfig.Agent.Version = firewallCopy.Version + } var steps []string diff --git a/pkg/workflow/evals_steps_test.go b/pkg/workflow/evals_steps_test.go index 6de216199ca..ae9cc360d09 100644 --- a/pkg/workflow/evals_steps_test.go +++ b/pkg/workflow/evals_steps_test.go @@ -410,6 +410,7 @@ func TestBuildEvalsEngineStepsModelMappingsPropagated(t *testing.T) { if !strings.Contains(steps, `\"models\"`) { t.Errorf("expected evals engine steps to include AWF config with \"models\" key when ModelMappings is set;\ngot:\n%s", steps) } + }) t.Run("model mappings absent from evals AWF config when nil on parent WorkflowData", func(t *testing.T) { @@ -431,3 +432,50 @@ func TestBuildEvalsEngineStepsModelMappingsPropagated(t *testing.T) { } }) } + +func TestBuildEvalsEngineStepsModelCostsProvidersPropagated(t *testing.T) { + compiler := NewCompiler() + + data := &WorkflowData{ + AI: "claude", + Model: "accounts/fireworks/models/minimax-m3", + EngineConfig: &EngineConfig{ + ID: "claude", + }, + ModelCosts: map[string]any{ + "providers": map[string]any{ + "anthropic": map[string]any{ + "models": map[string]any{ + "accounts/fireworks/models/minimax-m3": map[string]any{ + "cost": map[string]any{ + "input": "3e-07", + "output": "1.5e-06", + }, + }, + }, + }, + }, + }, + Evals: &EvalsConfig{ + Questions: []EvalDefinition{ + {ID: "check", Question: "Did the agent complete the task?"}, + }, + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true, Version: string(constants.AWFAPIProxyProvidersMinVersion)}, + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + Version: string(constants.AWFAPIProxyProvidersMinVersion), + }, + }, + } + + steps := strings.Join(compiler.buildEvalsEngineSteps(data), "") + if !strings.Contains(steps, "providers") { + t.Errorf("expected evals awf-config.json to contain apiProxy.providers; got:\n%s", steps) + } + if !strings.Contains(steps, "accounts/fireworks/models/minimax-m3") { + t.Errorf("expected evals awf-config.json to contain custom model pricing key; got:\n%s", steps) + } +} diff --git a/pkg/workflow/schemas/awf-config.schema.json b/pkg/workflow/schemas/awf-config.schema.json index c80de0cb9d5..df1258f28cf 100644 --- a/pkg/workflow/schemas/awf-config.schema.json +++ b/pkg/workflow/schemas/awf-config.schema.json @@ -129,6 +129,11 @@ }, "additionalProperties": false }, + "providers": { + "type": "object", + "description": "Per-provider model pricing catalog overlays merged into the API proxy model pricing table for AI-credits accounting.", + "additionalProperties": true + }, "modelMultipliers": { "type": "object", "description": "Per-model multipliers for effective token accounting. Each model's weighted tokens are multiplied by this value before accumulation. Unlisted models use defaultModelMultiplier when set, otherwise the highest configured multiplier. See spec \u00a710.2.", diff --git a/pkg/workflow/threat_detection_external.go b/pkg/workflow/threat_detection_external.go index 0a05b0a6008..cfd5b3c31eb 100644 --- a/pkg/workflow/threat_detection_external.go +++ b/pkg/workflow/threat_detection_external.go @@ -86,12 +86,13 @@ func buildThreatDetectionWorkflowData(data *WorkflowData, engineID string) *Work engineID = "claude" } - return &WorkflowData{ + detectionData := &WorkflowData{ AI: engineID, ActionCache: data.ActionCache, Features: data.Features, Permissions: data.Permissions, CachedPermissions: data.CachedPermissions, + ModelCosts: data.ModelCosts, IsDetectionRun: true, RunnerConfig: data.RunnerConfig, SandboxConfig: &SandboxConfig{ @@ -100,6 +101,20 @@ func buildThreatDetectionWorkflowData(data *WorkflowData, engineID string) *Work }, }, } + + if firewallConfig := getFirewallConfig(data); firewallConfig != nil { + firewallCopy := *firewallConfig + detectionData.NetworkPermissions = &NetworkPermissions{Firewall: &firewallCopy} + if detectionData.SandboxConfig == nil { + detectionData.SandboxConfig = &SandboxConfig{} + } + if detectionData.SandboxConfig.Agent == nil { + detectionData.SandboxConfig.Agent = &AgentSandboxConfig{Type: SandboxTypeAWF} + } + detectionData.SandboxConfig.Agent.Version = firewallCopy.Version + } + + return detectionData } // buildPullAWFContainersStep creates a step that pre-pulls AWF (agent workflow firewall) diff --git a/pkg/workflow/threat_detection_inline_engine.go b/pkg/workflow/threat_detection_inline_engine.go index bba53d48c9f..2ca084c3ef6 100644 --- a/pkg/workflow/threat_detection_inline_engine.go +++ b/pkg/workflow/threat_detection_inline_engine.go @@ -129,8 +129,13 @@ func (c *Compiler) buildDetectionEngineExecutionStep(data *WorkflowData) []strin threatDetectionData.Model = resolvedDetectionModel threatDetectionData.EngineConfig = detectionEngineConfig threatDetectionData.ModelMappings = data.ModelMappings // propagate alias map so detection awf-config.json can resolve model aliases + var detectionFirewall *FirewallConfig + if threatDetectionData.NetworkPermissions != nil { + detectionFirewall = threatDetectionData.NetworkPermissions.Firewall + } threatDetectionData.NetworkPermissions = &NetworkPermissions{ - Allowed: getThreatDetectionAdditionalAllowedDomains(data), + Allowed: getThreatDetectionAdditionalAllowedDomains(data), + Firewall: detectionFirewall, } var steps []string diff --git a/pkg/workflow/threat_detection_test.go b/pkg/workflow/threat_detection_test.go index bb801654a79..797107f4e33 100644 --- a/pkg/workflow/threat_detection_test.go +++ b/pkg/workflow/threat_detection_test.go @@ -2701,3 +2701,54 @@ func TestBuildDetectionEngineExecutionStepPropagatesModelMappings(t *testing.T) t.Errorf("expected detection awf-config.json to contain model alias 'mini'; got:\n%s", allSteps) } } + +func TestBuildDetectionEngineExecutionStepPropagatesModelCostsProviders(t *testing.T) { + compiler := NewCompiler() + + data := &WorkflowData{ + AI: "claude", + Model: "accounts/fireworks/models/minimax-m3", + EngineConfig: &EngineConfig{ + ID: "claude", + }, + ModelCosts: map[string]any{ + "providers": map[string]any{ + "anthropic": map[string]any{ + "models": map[string]any{ + "accounts/fireworks/models/minimax-m3": map[string]any{ + "cost": map[string]any{ + "input": "3e-07", + "output": "1.5e-06", + }, + }, + }, + }, + }, + }, + SafeOutputs: &SafeOutputsConfig{ + ThreatDetection: &ThreatDetectionConfig{}, + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true, Version: string(constants.AWFAPIProxyProvidersMinVersion)}, + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + Version: string(constants.AWFAPIProxyProvidersMinVersion), + }, + }, + } + + steps := compiler.buildDetectionEngineExecutionStep(data) + if len(steps) == 0 { + t.Fatal("expected non-empty detection steps") + } + + allSteps := strings.Join(steps, "") + + if !strings.Contains(allSteps, "providers") { + t.Errorf("expected detection awf-config.json to contain apiProxy.providers; got:\n%s", allSteps) + } + if !strings.Contains(allSteps, "accounts/fireworks/models/minimax-m3") { + t.Errorf("expected detection awf-config.json to contain custom model pricing key; got:\n%s", allSteps) + } +}