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
11 changes: 11 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@
},
"codespaces": {
"repositories": {
"gh-aw": {
"permissions": {
"actions": "write",
"checks": "write",
"contents": "write",
"discussions": "write",
"issues": "write",
"pull-requests": "write",
"workflows": "write"
}
},
"github/gh-aw": {
"permissions": {
"actions": "write",
Expand Down
22 changes: 12 additions & 10 deletions .github/workflows/agent-performance-analyzer.lock.yml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions .github/workflows/agent-performance-analyzer.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ imports:
toolsets: [default, actions, repos]
- shared/reporting.md
- shared/otlp.md
- shared/default-ai-credits-pricing.md
safe-outputs:
create-issue:
expires: 2d
Expand Down
22 changes: 12 additions & 10 deletions .github/workflows/audit-workflows.lock.yml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions .github/workflows/audit-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ imports:


- shared/otlp.md
- shared/default-ai-credits-pricing.md
features:
gh-aw-detection: true
sandbox:
Expand Down
22 changes: 12 additions & 10 deletions .github/workflows/aw-failure-investigator.lock.yml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions .github/workflows/aw-failure-investigator.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ imports:
- shared/reporting.md

- shared/otlp.md
- shared/default-ai-credits-pricing.md
steps:
- name: Deterministic pre-fetch for failure analysis
env:
Expand Down
22 changes: 12 additions & 10 deletions .github/workflows/deep-report.lock.yml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions .github/workflows/deep-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ imports:


- shared/otlp.md
- shared/default-ai-credits-pricing.md
evals:
- id: output_format_goal_met
question: Does the agent output show that the objective for experiment output_format was successfully completed?
Expand Down
14 changes: 14 additions & 0 deletions .github/workflows/shared/default-ai-credits-pricing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
# Default AI-credits pricing fallback.
# Use in workflows that may invoke models without catalog pricing, to prevent
# missing_model_pricing / unknown_model_ai_credits failures.
# Rates: $5 per M input tokens, $25 per M output tokens (conservative GPT-4-class defaults).
#
# Usage:
# imports:
# - shared/default-ai-credits-pricing.md
models:
default-ai-credits-pricing:
input: 5.0
output: 25.0
---
22 changes: 12 additions & 10 deletions .github/workflows/workflow-health-manager.lock.yml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions .github/workflows/workflow-health-manager.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ max-ai-credits: 1500
imports:
- shared/reporting.md
- shared/otlp.md
- shared/default-ai-credits-pricing.md
steps:
- name: Build Inventory
env:
Expand Down
43 changes: 43 additions & 0 deletions docs/adr/48746-propagate-imported-default-ai-credits-pricing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# ADR-48746: Propagate Imported Default AI-Credits Pricing Through Import Aggregation Pipeline

**Date**: 2026-07-29
**Status**: Draft
**Deciders**: pelikhan, copilot-swe-agent

---

### Context

Workflows that import shared AWF config (e.g., `.github/workflows/shared/otlp.md`) were compiling without `apiProxy.defaultAiCreditsPricing` in the output, even when the shared import defined `models.default-ai-credits-pricing`. This caused `API Error: 400` failures (surfaced as `missing_model_pricing` / `unknown_model_ai_credits`) across multiple workflows using models such as `claude-opus-5` that lack catalog pricing. The failure was systemic and recurring: per-workflow patches had been applied (e.g., PR #48292) but the same regression kept appearing for newly authored workflows. A repo-wide fallback mechanism was needed that would automatically apply to any workflow importing the shared config.

### Decision

We will extend the parser import result model to carry `default-ai-credits-pricing` extracted from imported config, propagate it through import aggregation (first-wins across all imports), and resolve it in the workflow builder so that the compiled AWF config includes `apiProxy.defaultAiCreditsPricing` whenever the main workflow does not define its own pricing. The shared import `.github/workflows/shared/otlp.md` will define the canonical fallback values (`input: 5.0`, `output: 25.0`).

### Alternatives Considered

#### Alternative 1: Per-Workflow Pricing Patches

Apply `default-ai-credits-pricing` individually to each workflow that encounters the missing-pricing error. This was the approach used before this PR (e.g., PR #48292). It is directly traceable and has no impact on unrelated workflows, but it is not sustainable: each new workflow that imports shared config without its own pricing definition regresses into the same failure, requiring another manual patch. The issue tracker at the time of this ADR listed at least six affected workflows with more expected.

#### Alternative 2: Centralised Static Config File (Non-Import Mechanism)

Define a global defaults config file (e.g., `.github/workflows/shared/pricing-defaults.yml`) that the compiler always applies, independent of imports. This avoids the need to thread pricing through the import aggregation model. However, it introduces a new implicit loading mechanism outside the existing import pipeline, which increases compiler surface area and makes pricing override precedence harder to understand. The import-propagation approach reuses the established import pipeline and makes the relationship between shared config and compiled output explicit.

### Consequences

#### Positive
- Any workflow that imports the shared otlp/shared config automatically inherits the pricing fallback without further manual intervention, eliminating the recurrence pattern seen across at least six workflows.
- The precedence rule (main-workflow pricing overrides imported default) is explicit and tested, making it safe to define per-workflow pricing when needed without breaking the fallback.

#### Negative
- First-wins semantics across multiple imports means that if two imports each define `default-ai-credits-pricing`, only the first encountered value is used and a warning is emitted. Authors of new shared imports must be aware of this behaviour.
- Adding `default-ai-credits-pricing` to `.github/workflows/shared/otlp.md` couples a pricing concern to a telemetry-oriented shared config, which may surprise maintainers of that file.

#### Neutral
- The import result model gains a new optional field; existing imports that do not define `default-ai-credits-pricing` are unaffected.
- New tests cover extraction, first-wins behaviour, invalid-shape warning, and builder resolution precedence, increasing test surface area for the import pipeline.

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
14 changes: 13 additions & 1 deletion pkg/parser/import_field_extractor.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ type importAccumulator struct {
models []map[string][]string // model alias maps from each imported file (appended in import order)
modelPolicies []map[string][]string // model policy sets from each imported file (appended in import order)
modelCosts []map[string]any // model pricing overlays from each imported file (appended in import order)
defaultAiCreditsPricing map[string]any // first models.default-ai-credits-pricing object found in imports (first-wins)
runInstallScripts bool // true if any imported workflow sets runtimes.node.run-install-scripts: true
agentFile string
agentImportSpec string
Expand Down Expand Up @@ -704,12 +705,22 @@ func (acc *importAccumulator) appendModelsField(fm map[string]any, importPath st
parserLog.Printf("Extracted model costs from import: providers=%d", len(providerMap))
}
}
if acc.defaultAiCreditsPricing == nil {
if defaultPricing, hasDefaultPricing := rawModels["default-ai-credits-pricing"]; hasDefaultPricing {
if pricingMap, ok := defaultPricing.(map[string]any); ok {
acc.defaultAiCreditsPricing = maps.Clone(pricingMap)
Comment on lines +710 to +711
parserLog.Printf("Extracted default-ai-credits-pricing from import: %s", importPath)
} else {
acc.warnings = append(acc.warnings, fmt.Sprintf("import %q: models.default-ai-credits-pricing must be an object; skipping invalid value", importPath))
}
}
}

aliasModels := make(map[string]any, len(rawModels))
for key, value := range rawModels {
// providers is reserved for model-cost overlays and should not be treated
// as an alias key, even when aliases and providers coexist.
if key == "providers" || isModelPolicyKey(key) {
if key == "providers" || key == "default-ai-credits-pricing" || isModelPolicyKey(key) {
continue
}
aliasModels[key] = value
Expand Down Expand Up @@ -913,6 +924,7 @@ func (acc *importAccumulator) toImportsResult(topologicalOrder []string) *Import
MergedModels: acc.models,
MergedModelPolicies: acc.modelPolicies,
MergedModelCosts: acc.modelCosts,
MergedDefaultAiCreditsPricing: acc.defaultAiCreditsPricing,
MergedObservability: mergeObservabilityConfigs(acc.observabilityConfigs),
ImportedFiles: topologicalOrder,
AgentFile: acc.agentFile,
Expand Down
46 changes: 46 additions & 0 deletions pkg/parser/import_field_extractor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,52 @@ func TestAppendModelsField_ProvidersAndAliasesBothExtracted(t *testing.T) {
assert.Equal(t, []string{"gpt-5"}, acc.models[0]["agent"])
}

func TestAppendModelsField_ExtractsDefaultAICreditsPricingFirstWins(t *testing.T) {
acc := newImportAccumulator()
first := map[string]any{
"models": map[string]any{
"default-ai-credits-pricing": map[string]any{
"input": 5.0,
"output": 25.0,
},
},
}
second := map[string]any{
"models": map[string]any{
"default-ai-credits-pricing": map[string]any{
"input": 1.0,
"output": 2.0,
},
},
}

acc.appendModelsField(first, "import-first.md")
acc.appendModelsField(second, "import-second.md")

require.NotNil(t, acc.defaultAiCreditsPricing)
input, ok := acc.defaultAiCreditsPricing["input"].(float64)
require.True(t, ok)
output, ok := acc.defaultAiCreditsPricing["output"].(float64)
require.True(t, ok)
assert.InDelta(t, 5.0, input, 1e-9)
assert.InDelta(t, 25.0, output, 1e-9)
}

func TestAppendModelsField_InvalidDefaultAICreditsPricingWarns(t *testing.T) {
acc := newImportAccumulator()
fm := map[string]any{
"models": map[string]any{
"default-ai-credits-pricing": "not-an-object",
},
}

acc.appendModelsField(fm, "import-invalid.md")

assert.Nil(t, acc.defaultAiCreditsPricing)
require.NotEmpty(t, acc.warnings)
assert.Contains(t, strings.Join(acc.warnings, "\n"), "models.default-ai-credits-pricing must be an object")
}

func TestMergeExcludedEnv_SingleImport(t *testing.T) {
acc := newImportAccumulator()
fm := map[string]any{
Expand Down
1 change: 1 addition & 0 deletions pkg/parser/import_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ type ImportsResult struct {
MergedModels []map[string][]string // Merged model alias definitions from all imports (first import to define a key wins among imports)
MergedModelPolicies []map[string][]string // Merged model policy sets from all imports (models.allowed/blocked)
MergedModelCosts []map[string]any // Merged model pricing overlays (models.json provider structure) from all imports
MergedDefaultAiCreditsPricing map[string]any // First models.default-ai-credits-pricing object found across all imports (first-wins)
MergedObservability string // Merged observability config (JSON) from all imports as an endpoint array (deduped by URL)
MergedEngineMCPToolTimeout string // First engine.mcp.tool-timeout found across all imports (Go duration string, e.g. "10m")
MergedEngineMCPSessionTimeout string // First engine.mcp.session-timeout found across all imports (Go duration string, e.g. "4h")
Expand Down
35 changes: 35 additions & 0 deletions pkg/workflow/frontmatter_extraction_security_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,41 @@ func TestExtractDefaultAiCreditsPricingFromModels(t *testing.T) {
})
}

func TestResolveDefaultAiCreditsPricing(t *testing.T) {
t.Run("falls back to imported default pricing when main is absent", func(t *testing.T) {
frontmatter := map[string]any{}
imported := map[string]any{
"input": 5.0,
"output": 25.0,
}

pricing := resolveDefaultAiCreditsPricing(frontmatter, imported)
require.NotNil(t, pricing)
assert.InDelta(t, 5.0, pricing.Input, 1e-9)
assert.InDelta(t, 25.0, pricing.Output, 1e-9)
})

t.Run("main workflow pricing overrides imported default pricing", func(t *testing.T) {
frontmatter := map[string]any{
"models": map[string]any{
"default-ai-credits-pricing": map[string]any{
"input": 1.0,
"output": 2.0,
},
},
}
imported := map[string]any{
"input": 5.0,
"output": 25.0,
}

pricing := resolveDefaultAiCreditsPricing(frontmatter, imported)
require.NotNil(t, pricing)
assert.InDelta(t, 1.0, pricing.Input, 1e-9)
assert.InDelta(t, 2.0, pricing.Output, 1e-9)
})
}

// TestExtractMCPGatewayConfigPayloadFields tests extraction of payload-related fields
// from MCP gateway frontmatter configuration
func TestExtractMCPGatewayConfigPayloadFields(t *testing.T) {
Expand Down
25 changes: 24 additions & 1 deletion pkg/workflow/workflow_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ func (c *Compiler) buildInitialWorkflowData(
workflowData.ModelPolicyBlocked = disallowedModels
}

if pricing := extractDefaultAiCreditsPricingFromModels(result.Frontmatter); pricing != nil {
if pricing := resolveDefaultAiCreditsPricing(result.Frontmatter, importsResult.MergedDefaultAiCreditsPricing); pricing != nil {
workflowData.DefaultAiCreditsPricing = pricing
}

Expand Down Expand Up @@ -430,13 +430,29 @@ func toFloat64(v any) (float64, bool) {
}
}

// resolveDefaultAiCreditsPricing returns models.default-ai-credits-pricing from the main
// workflow frontmatter when present, otherwise falls back to the first imported value.
func resolveDefaultAiCreditsPricing(frontmatter map[string]any, imported map[string]any) *AiCreditsPricingConfig {
if pricing := extractDefaultAiCreditsPricingFromModels(frontmatter); pricing != nil {
return pricing
}
return extractDefaultAiCreditsPricingFromObject(imported)
}

// extractDefaultAiCreditsPricingFromModels returns the fallback AI credits pricing configured
// under models.default-ai-credits-pricing in the workflow frontmatter, or nil if absent.
func extractDefaultAiCreditsPricingFromModels(frontmatter map[string]any) *AiCreditsPricingConfig {
modelsMap, ok := frontmatter["models"].(map[string]any)
if !ok {
return nil
}
return extractDefaultAiCreditsPricingFromModelsMap(modelsMap)
}

func extractDefaultAiCreditsPricingFromModelsMap(modelsMap map[string]any) *AiCreditsPricingConfig {
if modelsMap == nil {
return nil
}
pricingVal, hasPricing := modelsMap["default-ai-credits-pricing"]
if !hasPricing {
return nil
Expand All @@ -445,6 +461,13 @@ func extractDefaultAiCreditsPricingFromModels(frontmatter map[string]any) *AiCre
if !ok {
return nil
}
return extractDefaultAiCreditsPricingFromObject(pricingObj)
}

func extractDefaultAiCreditsPricingFromObject(pricingObj map[string]any) *AiCreditsPricingConfig {
if pricingObj == nil {
return nil
}
var input, output float64
if v, ok := toFloat64(pricingObj["input"]); ok {
input = v
Expand Down
Loading