Skip to content
Closed
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
39 changes: 36 additions & 3 deletions pkg/workflow/awf_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,15 @@ var awfConfigSchema string

var awfConfigLog = logger.New("workflow:awf_config")

func defaultCopilotFallbackAICreditsPricing() *AWFAICreditsPricing {
return &AWFAICreditsPricing{
Input: 3.0,
Output: 15.0,
CachedInput: 0.3,
CacheWrite: 3.75,
}
}

// Cached compiled AWF config schema to avoid recompiling on every validation.
var (
compiledAWFConfigSchemaOnce sync.Once
Expand Down Expand Up @@ -240,6 +249,10 @@ type AWFAPIProxyConfig struct {
// MaxAICredits is the explicit per-run AI credits budget enforced by the API proxy.
MaxAICredits int64 `json:"maxAiCredits,omitempty"`

// DefaultAICreditsPricing provides conservative fallback pricing for models that
// are not present in AWF's built-in catalog when AI credits enforcement is active.
DefaultAICreditsPricing *AWFAICreditsPricing `json:"defaultAiCreditsPricing,omitempty"`

// ModelFallback configures the model fallback policy for unresolved model selections.
// When nil, the AWF default (enabled=true, strategy=middle_power) is used.
// Set enabled=false to prevent AWF from silently rewriting deployment names, which
Expand Down Expand Up @@ -267,6 +280,14 @@ type AWFAPIProxyConfig struct {
DisallowedModels []string `json:"disallowedModels,omitempty"`
}

// AWFAICreditsPricing is the "apiProxy.defaultAiCreditsPricing" section of the AWF config file.
type AWFAICreditsPricing struct {
Input float64 `json:"input"`
Output float64 `json:"output"`
CachedInput float64 `json:"cachedInput,omitempty"`
CacheWrite float64 `json:"cacheWrite,omitempty"`
}

// AWFModelFallbackConfig is the "apiProxy.modelFallback" section of the AWF config file.
// It controls whether model fallback is enabled for unresolved model selections.
type AWFModelFallbackConfig struct {
Expand Down Expand Up @@ -461,6 +482,7 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) {
// BuildAWFCommand (see injectMaxAICreditsExpression in awf_helpers.go).
maxAICredits := int64(0)
maxRuns := constants.DefaultMaxRuns
isCopilotBYOK := isCopilotBYOKConfigured(config.WorkflowData)
// GetMaxTurnCacheMisses handles nil receiver and env-var fallback, so pre-init
// via the nil receiver avoids a redundant os.Getenv when EngineConfig is set.
maxTurnCacheMisses := (*EngineConfig)(nil).GetMaxTurnCacheMisses()
Expand All @@ -475,8 +497,10 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) {
// Token steering is enabled by default. Setting max-ai-credits to a negative
// value (-1) omits that budget from the AWF config and disables token steering.
// When maxAICredits is 0 (runtime default), token steering stays enabled here.
enableTokenSteering := maxAICredits >= 0
if maxAICredits < 0 {
// BYOK Copilot runs must bypass the AI credits guard entirely because requests
// are routed to a user-provided endpoint rather than GitHub AI credits.
enableTokenSteering := maxAICredits >= 0 && !isCopilotBYOK
if maxAICredits < 0 || isCopilotBYOK {
// Negative signals "disabled" — omit the budget from the AWF config.
maxAICredits = 0
}
Expand All @@ -490,11 +514,20 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) {
}

if !enableTokenSteering {
awfConfigLog.Printf("Skipping apiProxy.enableTokenSteering: max-ai-credits is negative (disabled)")
if isCopilotBYOK {
awfConfigLog.Printf("Skipping apiProxy.enableTokenSteering and maxAiCredits: Copilot BYOK provider routing is configured")
} else {
awfConfigLog.Printf("Skipping apiProxy.enableTokenSteering: max-ai-credits is negative (disabled)")
}
} else if !awfSupportsTokenSteering(firewallConfig) {
awfConfigLog.Printf("Skipping apiProxy.enableTokenSteering: AWF version %q requires at least %s", getAWFImageTag(firewallConfig), constants.AWFTokenSteeringMinVersion)
}

if config.EngineName == "copilot" && !isCopilotBYOK {
apiProxy.DefaultAICreditsPricing = defaultCopilotFallbackAICreditsPricing()
awfConfigLog.Printf("API proxy: configured defaultAiCreditsPricing fallback for unresolved Copilot models")
}

if mf := extractModelFallback(config.WorkflowData); mf != nil {
apiProxy.ModelFallback = mf
enabledDisplay := "<unset>"
Expand Down
73 changes: 73 additions & 0 deletions pkg/workflow/awf_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,29 @@ func TestBuildAWFConfigJSON(t *testing.T) {
assert.NotContains(t, jsonStr, `"maxAiCredits"`, "apiProxy should omit maxAiCredits when unset (resolved at runtime via vars expression)")
})

t.Run("copilot config emits default ai credits pricing fallback", func(t *testing.T) {
config := AWFCommandConfig{
EngineName: "copilot",
AllowedDomains: "github.com",
WorkflowData: &WorkflowData{
EngineConfig: &EngineConfig{
ID: "copilot",
},
NetworkPermissions: &NetworkPermissions{
Firewall: &FirewallConfig{Enabled: true},
},
},
}

jsonStr, err := BuildAWFConfigJSON(config)
require.NoError(t, err)
assert.Contains(t, jsonStr, `"defaultAiCreditsPricing"`, "apiProxy should emit a fallback pricing block for unresolved Copilot models")
assert.Contains(t, jsonStr, `"input":3`, "fallback pricing should include the default input price")
assert.Contains(t, jsonStr, `"cachedInput":0.3`, "fallback pricing should include the default cached input price")
assert.Contains(t, jsonStr, `"cacheWrite":3.75`, "fallback pricing should include the default cache-write price")
assert.Contains(t, jsonStr, `"output":15`, "fallback pricing should include the default output price")
})

t.Run("enterprise default max-ai-credits env var is NOT used at compile time (resolved at action runtime)", func(t *testing.T) {
t.Setenv(compilerenv.DefaultMaxAICredits, "2k")
config := AWFCommandConfig{
Expand Down Expand Up @@ -305,6 +328,31 @@ func TestBuildAWFConfigJSON(t *testing.T) {
assert.Contains(t, jsonStr, `"maxAiCredits":333`, "apiProxy should bake in frontmatter maxAiCredits (skipping runtime expression)")
})

t.Run("copilot BYOK omits ai credits guardrail config", func(t *testing.T) {
config := AWFCommandConfig{
EngineName: "copilot",
AllowedDomains: "github.com",
WorkflowData: &WorkflowData{
EngineConfig: &EngineConfig{
ID: "copilot",
MaxAICredits: 333,
Env: map[string]string{
constants.CopilotProviderBaseURL: "http://localhost:11434/v1",
},
},
NetworkPermissions: &NetworkPermissions{
Firewall: &FirewallConfig{Enabled: true},
},
},
}

jsonStr, err := BuildAWFConfigJSON(config)
require.NoError(t, err)
assert.NotContains(t, jsonStr, `"maxAiCredits"`, "BYOK requests should bypass the AI credits budget guardrail entirely")
assert.NotContains(t, jsonStr, `"enableTokenSteering"`, "BYOK requests should not enable AI credits token steering")
assert.NotContains(t, jsonStr, `"defaultAiCreditsPricing"`, "BYOK requests should not require fallback AI credits pricing")
})

t.Run("default max-turn-cache-misses uses built-in default when unset", func(t *testing.T) {
// Clear any ambient env override so this test actually exercises the built-in default path.
t.Setenv(compilerenv.DefaultMaxTurnCacheMisses, "")
Expand Down Expand Up @@ -1416,6 +1464,31 @@ func TestBuildAWFCommand_ResolvesMaxAICreditsFromEnv(t *testing.T) {
}
}

func TestBuildAWFCommand_SkipsMaxAICreditsForCopilotBYOK(t *testing.T) {
config := AWFCommandConfig{
EngineName: "copilot",
EngineCommand: "copilot --prompt-file /tmp/prompt.txt",
LogFile: "/tmp/gh-aw/agent-stdio.log",
AllowedDomains: "github.com,api.github.com",
ResolveMaxAICreditsFromEnv: true,
WorkflowData: &WorkflowData{
EngineConfig: &EngineConfig{
ID: "copilot",
Env: map[string]string{
constants.CopilotProviderBaseURL: "http://localhost:11434/v1",
},
},
NetworkPermissions: &NetworkPermissions{
Firewall: &FirewallConfig{Enabled: true},
},
},
}

command := BuildAWFCommand(config)
assert.NotContains(t, command, "GH_AW_MAX_AI_CREDITS", "BYOK runs should not inject a runtime AI credits budget")
assert.NotContains(t, command, `"maxAiCredits":`, "BYOK runs should not emit maxAiCredits in the generated AWF config JSON")
}

func TestBuildAWFCommand_PreservesGitHubExpressionOperatorsInConfigJSON(t *testing.T) {
config := AWFCommandConfig{
EngineName: "copilot",
Expand Down
8 changes: 7 additions & 1 deletion pkg/workflow/awf_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@ func applyDefaultMaxAICreditsEnvToMap(env map[string]string, workflowData *Workf
if env == nil {
return
}
if isCopilotBYOKConfigured(workflowData) {
return
}
if workflowData != nil && workflowData.EngineConfig != nil && workflowData.EngineConfig.MaxAICredits != 0 {
return
}
Expand Down Expand Up @@ -347,7 +350,8 @@ fi`,
// For detection runs, use the detection-specific variable/fallback;
// for standard agent runs, use the main-agent variable/fallback.
var maxAICreditsExportLine string
if config.WorkflowData == nil || config.WorkflowData.EngineConfig == nil || config.WorkflowData.EngineConfig.MaxAICredits == 0 {
if (config.WorkflowData == nil || config.WorkflowData.EngineConfig == nil || config.WorkflowData.EngineConfig.MaxAICredits == 0) &&
!isCopilotBYOKConfigured(config.WorkflowData) {
defaultMaxAICredits := strconv.FormatInt(constants.DefaultMaxAICredits, 10)
if config.WorkflowData != nil && config.WorkflowData.IsDetectionRun {
defaultMaxAICredits = strconv.FormatInt(constants.DefaultDetectionMaxAICredits, 10)
Expand All @@ -363,6 +367,8 @@ fi`,
maxAICreditsExportLine = fmt.Sprintf(`%s="%s"`, awfMaxAICreditsVarName, expr)
}
awfHelpersLog.Printf("Injected maxAiCredits local var reference into AWF config JSON")
} else if isCopilotBYOKConfigured(config.WorkflowData) {
awfHelpersLog.Printf("Skipping maxAiCredits runtime injection: Copilot BYOK provider routing is configured")
}
// Write the config JSON to ${RUNNER_TEMP}/gh-aw/awf-config.json before AWF runs.
// When the generated JSON contains compiler-owned runtime variables such as
Expand Down
14 changes: 14 additions & 0 deletions pkg/workflow/awf_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,20 @@ func TestApplyDefaultMaxAICreditsEnvToMap(t *testing.T) {
_, exists := env[awfMaxAICreditsVarName]
assert.False(t, exists)
})

t.Run("does not set expression for copilot BYOK runs", func(t *testing.T) {
env := map[string]string{}
applyDefaultMaxAICreditsEnvToMap(env, &WorkflowData{
EngineConfig: &EngineConfig{
ID: "copilot",
Env: map[string]string{
constants.CopilotProviderBaseURL: "http://localhost:11434/v1",
},
},
})
_, exists := env[awfMaxAICreditsVarName]
assert.False(t, exists)
})
}

// TestExtractAPITargetAuthHeader tests the extractAPITargetAuthHeader function that reads
Expand Down
10 changes: 10 additions & 0 deletions pkg/workflow/engine_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import (
"regexp"
"strings"

"github.com/github/gh-aw/pkg/constants"
"github.com/github/gh-aw/pkg/workflow/compilerenv"

"github.com/github/gh-aw/pkg/logger"
Expand Down Expand Up @@ -102,6 +103,15 @@ func engineEnvHasKey(workflowData *WorkflowData, key string) bool {
return ok
}

// isCopilotBYOKConfigured reports whether Copilot BYOK provider routing is configured
// via engine.env. When true, Copilot requests are routed to a user-supplied endpoint
// instead of GitHub AI credits infrastructure.
func isCopilotBYOKConfigured(workflowData *WorkflowData) bool {
return engineEnvHasKey(workflowData, constants.CopilotProviderBaseURL) ||
engineEnvHasKey(workflowData, constants.CopilotProviderAPIKey) ||
engineEnvHasKey(workflowData, constants.CopilotProviderBearerToken)
}

// applyEngineCwdEnv sets the GH_AW_ENGINE_CWD environment variable in the given env map
// when engine.cwd is configured. This variable is consumed by JS harness processes (via
// process_runner.cjs) and by shell-based engine command prefixes so the engine spawns in
Expand Down
Loading