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
Original file line number Diff line number Diff line change
@@ -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.*
6 changes: 6 additions & 0 deletions pkg/constants/version_constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions pkg/workflow/awf_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import (
_ "embed"
"encoding/json"
"fmt"
"maps"
"slices"
"strconv"
"strings"
Expand Down Expand Up @@ -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.<provider>.models.<model>.cost.{input,output,cache_read,cache_write,reasoning}
Providers map[string]any `json:"providers,omitempty"`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] Providers is typed map[string]any, which gives no compile-time safety and makes it easy for callers to pass structurally wrong data that only fails at AWF runtime. Consider defining a typed struct (e.g. AWFProviderConfig) mirroring the models.json provider shape.

💡 Why this matters

Other fields in this struct use concrete types (AWFAPITargetConfig, AiCreditsPricingConfig, etc.). Using map[string]any here is inconsistent and makes the shape opaque to future readers. A typed struct:

  • provides schema documentation in code
  • enables Go's type checker to catch structural errors before they reach the AWF binary
  • is consistent with how AWFAPITargetConfig handles targets

If the shape needs to remain flexible, at minimum add a doc comment listing the expected keys.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept Providers as map[string]any intentionally in 1fc2916 because this field forwards the upstream AWF provider catalog shape as-is and that shape is intentionally open-ended across providers/models. I added stricter behavioral guardrails instead (version gating, type-check logging, and extraction hardening) so unsupported or malformed input fails safely.


// 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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] The type assertion workflowData.ModelCosts["providers"].(map[string]any) silently discards the value if the concrete type differs from map[string]any (e.g. after JSON round-trip). A mismatch produces silent no-op behaviour identical to the original bug — no log, no error.

💡 Suggested hardening

Add a log line in the !ok branch, consistent with the pattern used elsewhere in this file:

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
}

This surfaces future type mismatches at runtime instead of masking them.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1fc2916: extractModelCostProviders now logs an explicit message when models.providers has an unexpected type and skips the overlay safely.

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 {
Expand Down
117 changes: 117 additions & 0 deletions pkg/workflow/awf_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions pkg/workflow/awf_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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://...
Expand Down
42 changes: 42 additions & 0 deletions pkg/workflow/awf_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions pkg/workflow/evals_steps.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{"*"},
Expand All @@ -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),
},
Expand All @@ -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

Expand Down
Loading
Loading