diff --git a/.github/skills/agentic-workflows/SKILL.md b/.github/skills/agentic-workflows/SKILL.md index c82d415e0fd..615a51e551e 100644 --- a/.github/skills/agentic-workflows/SKILL.md +++ b/.github/skills/agentic-workflows/SKILL.md @@ -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://raw.githubusercontent.com/github/gh-aw//`. -- 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` diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index 7a37a76fae3..56390ce9f68 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -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{ @@ -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..models..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") } }) } diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index fb296ecdd02..1f4e6523621 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -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 @@ -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" + } } } } diff --git a/pkg/workflow/awf_config.go b/pkg/workflow/awf_config.go index f9d50a145c0..7328df86bd3 100644 --- a/pkg/workflow/awf_config.go +++ b/pkg/workflow/awf_config.go @@ -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" @@ -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: ---api-target type AWFAPITargetConfig struct { @@ -814,11 +803,11 @@ 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 } @@ -826,9 +815,11 @@ func extractDefaultAiCreditsPricing(workflowData *WorkflowData) *AWFDefaultAiCre 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, } } diff --git a/pkg/workflow/awf_config_test.go b/pkg/workflow/awf_config_test.go index 70c14baf812..7b9a8e23d33 100644 --- a/pkg/workflow/awf_config_test.go +++ b/pkg/workflow/awf_config_test.go @@ -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", @@ -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}, @@ -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) { @@ -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) { diff --git a/pkg/workflow/frontmatter_extraction_security_test.go b/pkg/workflow/frontmatter_extraction_security_test.go index 62f5f4e99cd..4cfc44920b3 100644 --- a/pkg/workflow/frontmatter_extraction_security_test.go +++ b/pkg/workflow/frontmatter_extraction_security_test.go @@ -208,8 +208,10 @@ func TestExtractDefaultAiCreditsPricingFromModels(t *testing.T) { frontmatter := map[string]any{ "models": map[string]any{ "default-ai-credits-pricing": map[string]any{ - "input": float64(3.0), - "output": float64(15.0), + "input": float64(3.0), + "output": float64(15.0), + "cache_read": float64(0.3), + "cache_write": float64(3.0), }, }, } @@ -218,6 +220,10 @@ func TestExtractDefaultAiCreditsPricingFromModels(t *testing.T) { require.NotNil(t, pricing, "Should extract default-ai-credits-pricing") assert.InDelta(t, 3.0, pricing.Input, 1e-9, "Input should be 3.0") assert.InDelta(t, 15.0, pricing.Output, 1e-9, "Output should be 15.0") + require.NotNil(t, pricing.CachedInput, "CachedInput should be extracted when cache_read is set") + require.NotNil(t, pricing.CacheWrite, "CacheWrite should be extracted when cache_write is set") + assert.InDelta(t, 0.3, *pricing.CachedInput, 1e-9, "CachedInput should be 0.3") + assert.InDelta(t, 3.0, *pricing.CacheWrite, 1e-9, "CacheWrite should be 3.0") }) t.Run("default-ai-credits-pricing is nil when absent", func(t *testing.T) { diff --git a/pkg/workflow/sandbox.go b/pkg/workflow/sandbox.go index 120bdc0db5d..e49de49e4e9 100644 --- a/pkg/workflow/sandbox.go +++ b/pkg/workflow/sandbox.go @@ -87,9 +87,13 @@ type AgentSandboxConfig struct { // in the AWF config file. Required when maxAiCredits is active and the model is unrecognized. type AiCreditsPricingConfig struct { // Input is the input token price per 1M tokens in dollars. - Input float64 `yaml:"input"` + Input float64 `yaml:"input" json:"input"` // Output is the output token price per 1M tokens in dollars. - Output float64 `yaml:"output"` + Output float64 `yaml:"output" json:"output"` + // CachedInput is the cached-read token price per 1M tokens in dollars. + CachedInput *float64 `yaml:"cache_read,omitempty" json:"cachedInput,omitempty"` + // CacheWrite is the cache-write token price per 1M tokens in dollars. + CacheWrite *float64 `yaml:"cache_write,omitempty" json:"cacheWrite,omitempty"` } // AgentAPIProxyTargetConfig configures a single LLM provider's API proxy target. diff --git a/pkg/workflow/workflow_builder.go b/pkg/workflow/workflow_builder.go index 2285a4be065..83818d56a72 100644 --- a/pkg/workflow/workflow_builder.go +++ b/pkg/workflow/workflow_builder.go @@ -452,7 +452,20 @@ func extractDefaultAiCreditsPricingFromModels(frontmatter map[string]any) *AiCre if v, ok := toFloat64(pricingObj["output"]); ok { output = v } - return &AiCreditsPricingConfig{Input: input, Output: output} + var cachedInput *float64 + if v, ok := toFloat64(pricingObj["cache_read"]); ok { + cachedInput = &v + } + var cacheWrite *float64 + if v, ok := toFloat64(pricingObj["cache_write"]); ok { + cacheWrite = &v + } + return &AiCreditsPricingConfig{ + Input: input, + Output: output, + CachedInput: cachedInput, + CacheWrite: cacheWrite, + } } func mergeModelPolicyOverlays(importedPolicies []map[string][]string, mainPolicy map[string][]string) ([]string, []string) { diff --git a/scripts/test-wasm-golden.mjs b/scripts/test-wasm-golden.mjs index d70168494f4..e08b1716667 100644 --- a/scripts/test-wasm-golden.mjs +++ b/scripts/test-wasm-golden.mjs @@ -236,19 +236,29 @@ function normalizeDefaultRuntimeVersions(content) { ); } +// ── Normalize actions/checkout pin/version ─────────────────────────────── +// Keep golden fixtures stable across checkout action pin updates in wasm-vs-native +// comparisons. Mirrors normalizeOutput() in pkg/workflow/wasm_golden_test.go. +function normalizeCheckoutPin(content) { + return content.replace( + /actions\/checkout@[0-9a-f]{40}\s+#\s+v\d+\.\d+\.\d+/g, + "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0" + ); +} + // ── Normalize output ────────────────────────────────────────────────── // Applies all normalizations needed for stable golden comparison. // Combines heredoc delimiter and container pin normalizations so that // new normalization steps only need to be added in one place. // Mirrors normalizeOutput() in pkg/workflow/wasm_golden_test.go. function normalize(content) { - return normalizeDefaultRuntimeVersions(normalizeCopilotDefaultModel( + return normalizeCheckoutPin(normalizeDefaultRuntimeVersions(normalizeCopilotDefaultModel( normalizeProjectUTC( normalizeAWFImageTagDigests( normalizeContainerPins(normalizeHeredocDelimiters(content)) ) ) - )); + ))); } // ── Load golden file ─────────────────────────────────────────────────