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
7 changes: 0 additions & 7 deletions .github/skills/agentic-workflows/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,6 @@ Repository overlay (optional):

Read only the files you need:
Load these files from `github/gh-aw` (they are not available locally).

Critical download method for Codespaces:
- Always download instruction files from the rawusercontent endpoint, not github.com HTML pages.
- Use URLs in this format: `https://github.com/github/gh-aw/<ref>/<path>`.
- Do not rely on `gh`-authenticated github.com content fetches for these files; Codespaces `gh` tokens can lack permissions to read github.com content.
- If any required instruction file cannot be downloaded, stop immediately and report that the skill cannot continue until the file is accessible.

- `.github/aw/action-container-substitutions.md`
- `.github/aw/agentic-chat.md`
- `.github/aw/agentic-workflows-mcp.md`
Expand Down
166 changes: 164 additions & 2 deletions pkg/parser/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1947,7 +1947,29 @@ func TestMainWorkflowSchema_ModelsDefaultAiCreditsPricing(t *testing.T) {
}
})

t.Run("pricing without output is rejected", func(t *testing.T) {
t.Run("pricing with cached token classes is accepted", func(t *testing.T) {
t.Parallel()

frontmatter := map[string]any{
"on": "push",
"engine": "copilot",
"models": map[string]any{
"default-ai-credits-pricing": map[string]any{
"input": 3.0,
"output": 15.0,
"cache_read": 0.3,
"cache_write": 3.0,
},
},
}

err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/gh-aw/byok-pricing-cached-test.md")
if err != nil {
t.Fatalf("expected default-ai-credits-pricing with cached token classes to pass schema validation, got: %v", err)
}
})

t.Run("pricing without output is accepted", func(t *testing.T) {
t.Parallel()

frontmatter := map[string]any{
Expand All @@ -1961,8 +1983,148 @@ func TestMainWorkflowSchema_ModelsDefaultAiCreditsPricing(t *testing.T) {
}

err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/gh-aw/byok-pricing-missing-output-test.md")
if err != nil {
t.Fatalf("expected default-ai-credits-pricing without output to pass schema validation, got: %v", err)
}
})

t.Run("pricing with non-numeric cached value is rejected", func(t *testing.T) {
t.Parallel()

frontmatter := map[string]any{
"on": "push",
"engine": "copilot",
"models": map[string]any{
"default-ai-credits-pricing": map[string]any{
"input": 3.0,
"output": 15.0,
"cache_write": "free",
},
},
}

err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/gh-aw/byok-pricing-invalid-cache-write-test.md")
if err == nil {
t.Fatal("expected default-ai-credits-pricing with non-numeric cache_write to fail schema validation")
}
})
}

// TestMainWorkflowSchema_ModelsProvidersAiCreditsPricing verifies that
// models.providers.<provider>.models.<model>.cost uses the shared ai_credits_pricing schema.
func TestMainWorkflowSchema_ModelsProvidersAiCreditsPricing(t *testing.T) {
t.Parallel()

t.Run("numeric and string token-class costs are accepted", func(t *testing.T) {
t.Parallel()

frontmatter := map[string]any{
"on": "push",
"engine": "copilot",
"models": map[string]any{
"providers": map[string]any{
"anthropic": map[string]any{
"models": map[string]any{
"claude-custom": map[string]any{
"cost": map[string]any{
"input": "3e-07",
"output": 1.5e-06,
"cache_read": "3e-08",
"cache_write": 3.75e-07,
"reasoning": "0",
"custom": "1e-09",
},
},
},
},
},
},
}

err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/gh-aw/models-providers-cost-test.md")
if err != nil {
t.Fatalf("expected models.providers pricing to pass schema validation, got: %v", err)
}
})

t.Run("non-price value types are rejected", func(t *testing.T) {
t.Parallel()

frontmatter := map[string]any{
"on": "push",
"engine": "copilot",
"models": map[string]any{
"providers": map[string]any{
"anthropic": map[string]any{
"models": map[string]any{
"claude-custom": map[string]any{
"cost": map[string]any{
"input": true,
},
},
},
},
},
},
}

err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/gh-aw/models-providers-cost-invalid-test.md")
if err == nil {
t.Fatal("expected models.providers pricing with invalid value type to fail schema validation")
}
})

t.Run("trailing-text numeric strings are rejected", func(t *testing.T) {
t.Parallel()

frontmatter := map[string]any{
"on": "push",
"engine": "copilot",
"models": map[string]any{
"providers": map[string]any{
"anthropic": map[string]any{
"models": map[string]any{
"claude-custom": map[string]any{
"cost": map[string]any{
"input": "3oops",
},
},
},
},
},
},
}

err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/gh-aw/models-providers-cost-invalid-trailing-text-test.md")
if err == nil {
t.Fatal("expected models.providers pricing with trailing-text numeric strings to fail schema validation")
}
})

t.Run("non-numeric strings are rejected", func(t *testing.T) {
t.Parallel()

frontmatter := map[string]any{
"on": "push",
"engine": "copilot",
"models": map[string]any{
"providers": map[string]any{
"anthropic": map[string]any{
"models": map[string]any{
"claude-custom": map[string]any{
"cost": map[string]any{
"cache_write": "free",
},
},
},
},
},
},
}

err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/gh-aw/models-providers-cost-invalid-nonnumeric-test.md")
if err == nil {
t.Fatal("expected default-ai-credits-pricing without output to fail schema validation")
t.Fatal("expected models.providers pricing with non-numeric strings to fail schema validation")
}
})
}
Expand Down
74 changes: 34 additions & 40 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2880,33 +2880,8 @@
"description": "Pricing data for a single model.",
"properties": {
"cost": {
"type": "object",
"description": "Per-token cost in USD. Keys are token classes; values are numeric cost-per-token strings or numbers.",
"properties": {
"input": {
"type": ["number", "string"],
"description": "Cost per input token in USD."
},
"output": {
"type": ["number", "string"],
"description": "Cost per output token in USD."
},
"cache_read": {
"type": ["number", "string"],
"description": "Cost per cached-read token in USD."
},
"cache_write": {
"type": ["number", "string"],
"description": "Cost per cache-write token in USD."
},
"reasoning": {
"type": ["number", "string"],
"description": "Cost per reasoning token in USD."
}
},
"additionalProperties": {
"type": ["number", "string"]
}
"$ref": "#/$defs/ai_credits_pricing",
"description": "Per-token cost in USD. Keys are token classes; values are numeric cost-per-token strings or numbers."
}
},
"additionalProperties": false
Expand Down Expand Up @@ -13797,27 +13772,46 @@
},
"additionalProperties": false
},
"numeric_or_numeric_string": {
"description": "Accepts a JSON number, or a numeric string in integer, decimal, or scientific notation (for example: 123, 12.34, 1.23e-4, +5.67E+8). Rejects trailing/non-numeric formats (for example: 123abc, free, 1.2.3).",
"anyOf": [
{
"type": "number"
},
{
"type": "string",
"pattern": "^[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][+-]?\\d+)?$"
}
]
},
"ai_credits_pricing": {
"type": "object",
"description": "Per-token pricing in USD per 1M tokens. Use 0 for self-hosted or free models.",
"required": ["input", "output"],
"description": "Token-class pricing map. Keys are token classes (for example: input, output, cache_read, cache_write, reasoning) and values are numeric prices (number or numeric string).",
"properties": {
"input": {
"type": "number",
"minimum": 0,
"description": "Input token price per 1M tokens in dollars. Use 0 for self-hosted/free models."
"$ref": "#/$defs/numeric_or_numeric_string",
"description": "Cost per input token in USD."
},
"output": {
"type": "number",
"minimum": 0,
"description": "Output token price per 1M tokens in dollars. Use 0 for self-hosted/free models."
"$ref": "#/$defs/numeric_or_numeric_string",
"description": "Cost per output token in USD."
},
"cache_read": {
"$ref": "#/$defs/numeric_or_numeric_string",
"description": "Cost per cached-read token in USD."
},
"cache_write": {
"$ref": "#/$defs/numeric_or_numeric_string",
"description": "Cost per cache-write token in USD."
},
"reasoning": {
"$ref": "#/$defs/numeric_or_numeric_string",
"description": "Cost per reasoning token in USD."
}
},
"additionalProperties": false,
"examples": [
{ "input": 0, "output": 0 },
{ "input": 3.0, "output": 15.0 }
]
"additionalProperties": {
"$ref": "#/$defs/numeric_or_numeric_string"
}
}
}
}
25 changes: 8 additions & 17 deletions pkg/workflow/awf_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ type AWFAPIProxyConfig struct {
// DefaultAiCreditsPricing is the fallback per-token pricing ($/1M tokens) for
// models not in the AWF built-in pricing table. When maxAiCredits is active and
// a model is unrecognized, this rate is used instead of rejecting with HTTP 400.
DefaultAiCreditsPricing *AWFDefaultAiCreditsPricingConfig `json:"defaultAiCreditsPricing,omitempty"`
DefaultAiCreditsPricing *AiCreditsPricingConfig `json:"defaultAiCreditsPricing,omitempty"`

// Targets holds per-provider API target overrides.
// Supported keys: "openai", "anthropic", "copilot", "gemini"
Expand Down Expand Up @@ -281,17 +281,6 @@ type AWFModelFallbackConfig struct {
Enabled *TemplatableBool `json:"enabled,omitempty"`
}

// AWFDefaultAiCreditsPricingConfig is the "apiProxy.defaultAiCreditsPricing" section of the AWF config file.
// It provides fallback per-token pricing ($/1M tokens) for models not in the built-in pricing table.
// When maxAiCredits is active and a model is unrecognized, this rate is used instead of
// rejecting with HTTP 400 (unknown_model_ai_credits). Required for BYOK/self-hosted models.
type AWFDefaultAiCreditsPricingConfig struct {
// Input is the input token price per 1M tokens in dollars.
Input float64 `json:"input"`
// Output is the output token price per 1M tokens in dollars.
Output float64 `json:"output"`
}

// AWFAPITargetConfig is a single API proxy target entry.
// Maps to: --<provider>-api-target <host>
type AWFAPITargetConfig struct {
Expand Down Expand Up @@ -814,21 +803,23 @@ func extractModelFallback(workflowData *WorkflowData) *AWFModelFallbackConfig {
}
}

// extractDefaultAiCreditsPricing returns an AWFDefaultAiCreditsPricingConfig if the workflow has
// extractDefaultAiCreditsPricing returns an AiCreditsPricingConfig if the workflow has
// configured models.default-ai-credits-pricing, or nil if the field is absent.
// This fallback pricing is used when maxAiCredits is active and the requested model is not in
// the built-in pricing table, preventing HTTP 400 unknown_model_ai_credits for BYOK/self-hosted models.
func extractDefaultAiCreditsPricing(workflowData *WorkflowData) *AWFDefaultAiCreditsPricingConfig {
func extractDefaultAiCreditsPricing(workflowData *WorkflowData) *AiCreditsPricingConfig {
if workflowData == nil {
return nil
}
p := workflowData.DefaultAiCreditsPricing
if p == nil {
return nil
}
return &AWFDefaultAiCreditsPricingConfig{
Input: p.Input,
Output: p.Output,
return &AiCreditsPricingConfig{
Input: p.Input,
Output: p.Output,
CachedInput: p.CachedInput,
CacheWrite: p.CacheWrite,
}
}

Expand Down
15 changes: 13 additions & 2 deletions pkg/workflow/awf_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,8 @@ func TestBuildAWFConfigJSON(t *testing.T) {
})

t.Run("default-ai-credits-pricing is emitted with non-zero rates", func(t *testing.T) {
cachedInput := 0.3
cacheWrite := 3.0
config := AWFCommandConfig{
EngineName: "copilot",
AllowedDomains: "github.com",
Expand All @@ -963,8 +965,10 @@ func TestBuildAWFConfigJSON(t *testing.T) {
ID: "copilot",
},
DefaultAiCreditsPricing: &AiCreditsPricingConfig{
Input: 3.0,
Output: 15.0,
Input: 3.0,
Output: 15.0,
CachedInput: &cachedInput,
CacheWrite: &cacheWrite,
},
NetworkPermissions: &NetworkPermissions{
Firewall: &FirewallConfig{Enabled: true},
Expand All @@ -977,6 +981,8 @@ func TestBuildAWFConfigJSON(t *testing.T) {
assert.Contains(t, jsonStr, `"defaultAiCreditsPricing"`, "apiProxy should emit defaultAiCreditsPricing when configured")
assert.Contains(t, jsonStr, `"input":3`, "apiProxy.defaultAiCreditsPricing.input should be 3")
assert.Contains(t, jsonStr, `"output":15`, "apiProxy.defaultAiCreditsPricing.output should be 15")
assert.Contains(t, jsonStr, `"cachedInput":0.3`, "apiProxy.defaultAiCreditsPricing.cachedInput should be emitted")
assert.Contains(t, jsonStr, `"cacheWrite":3`, "apiProxy.defaultAiCreditsPricing.cacheWrite should be emitted")
})

t.Run("default-ai-credits-pricing is omitted when not configured", func(t *testing.T) {
Expand Down Expand Up @@ -1294,6 +1300,11 @@ func TestValidateAWFConfigJSON_AllowsDefaultAiCreditsPricingNonZero(t *testing.T
require.NoError(t, err, "apiProxy.defaultAiCreditsPricing with non-zero rates should pass AWF config schema validation")
}

func TestValidateAWFConfigJSON_AllowsDefaultAiCreditsPricingCachedFields(t *testing.T) {
err := validateAWFConfigJSON(`{"apiProxy":{"enabled":true,"maxRuns":500,"defaultAiCreditsPricing":{"input":3,"output":15,"cachedInput":0.3,"cacheWrite":3}}}`)
require.NoError(t, err, "apiProxy.defaultAiCreditsPricing should allow cachedInput and cacheWrite fields")
}

// TestBuildAWFConfigJSON_ValidateFlag verifies that schema validation runs when
// WorkflowData.ValidateAWFConfig is true (--validate mode) and is skipped otherwise.
func TestBuildAWFConfigJSON_ValidateFlag(t *testing.T) {
Expand Down
Loading
Loading