Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ccbb1bb
Add models policy frontmatter merge and env overrides
Copilot Jun 26, 2026
8927d44
Merge origin/main
Copilot Jun 27, 2026
888bb0a
Merge branch 'main' into copilot/add-frontmatter-models-fields
github-actions[bot] Jun 27, 2026
f510234
Apply remaining changes
Copilot Jun 27, 2026
39778e6
Apply remaining changes
Copilot Jun 27, 2026
66293f9
Merge branch 'main' into copilot/add-frontmatter-models-fields
github-actions[bot] Jun 27, 2026
da3a121
smoke-claude: disallow opus models
Copilot Jun 27, 2026
5115392
Merge remote-tracking branch 'origin/main' into copilot/add-frontmatt…
Copilot Jun 27, 2026
c83355b
Merge branch 'main' into copilot/add-frontmatter-models-fields
github-actions[bot] Jun 27, 2026
5a0239c
recompile workflow lockfiles after main sync
Copilot Jun 27, 2026
ea65e5b
Merge branch 'main' into copilot/add-frontmatter-models-fields
github-actions[bot] Jun 27, 2026
fdbc775
Merge branch 'main' into copilot/add-frontmatter-models-fields
github-actions[bot] Jun 28, 2026
6bbe647
Apply remaining changes
Copilot Jun 28, 2026
2691d46
Merge remote-tracking branch 'origin/main' into copilot/add-frontmatt…
Copilot Jun 28, 2026
c1b8c99
Update lockfiles after merge recompile
Copilot Jun 28, 2026
e946e4e
Fix model policy conflict and import warnings
Copilot Jun 28, 2026
4e8d94c
Merge remote-tracking branch 'origin/main' into copilot/add-frontmatt…
Copilot Jun 28, 2026
bcf66e9
Merge branch 'main' into copilot/add-frontmatter-models-fields
github-actions[bot] Jun 28, 2026
972e1b7
Plan pr-finisher pass
Copilot Jun 28, 2026
da58ed3
Update smoke-copilot wasm golden after branch refresh
Copilot Jun 28, 2026
ae5ae10
Merge branch 'main' into copilot/add-frontmatter-models-fields
github-actions[bot] Jun 28, 2026
ff35ed8
Plan: resolve remaining PR feedback
Copilot Jun 28, 2026
9e29c15
Merge origin/main into copilot/add-frontmatter-models-fields
Copilot Jun 28, 2026
d5664b2
Fix remaining model-policy review gaps
Copilot Jun 28, 2026
d9db05a
Clarify symmetric wildcard conflict and providers alias handling
Copilot Jun 28, 2026
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
4 changes: 2 additions & 2 deletions .github/workflows/smoke-claude.lock.yml

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions .github/workflows/smoke-claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ permissions:
actions: read

name: Smoke Claude
models:
disallowed: ["*opus*"]
max-turns: 100
engine:
id: claude
Expand Down
142 changes: 125 additions & 17 deletions pkg/parser/import_field_extractor.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ type importAccumulator struct {
caches []string
features []map[string]any
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)
runInstallScripts bool // true if any imported workflow sets runtimes.node.run-install-scripts: true
agentFile string
Expand Down Expand Up @@ -89,6 +90,11 @@ type importAccumulator struct {
warnings []string
}

const (
modelPolicyAllowedKey = "allowed"
modelPolicyDisallowedKey = "disallowed"
)

// newImportAccumulator creates and initializes a new importAccumulator.
// Maps (botsSet, etc.) are explicitly initialized to prevent nil map panics
// during deduplication. Slices are left as nil, which is valid for append operations.
Expand Down Expand Up @@ -583,7 +589,7 @@ func (acc *importAccumulator) extractFeatureAndObservabilityFields(fm map[string
acc.mergeLabels(fm)
acc.appendCacheField(fm)
acc.appendFeaturesField(fm)
acc.appendModelsField(fm)
acc.appendModelsField(fm, fullPath)
acc.extractRunInstallScripts(fm, fullPath)
acc.appendObservabilityField(fm, fullPath)
}
Expand Down Expand Up @@ -612,48 +618,149 @@ func (acc *importAccumulator) appendFeaturesField(fm map[string]any) {
}
}

func (acc *importAccumulator) appendModelsField(fm map[string]any) {
func (acc *importAccumulator) appendModelsField(fm map[string]any, importPath string) {
modelsContent, err := extractFieldJSONFromMap(fm, "models", "{}")
if err != nil || modelsContent == "" || modelsContent == "{}" {
return
}
var rawModels map[string]any
if jsonErr := json.Unmarshal([]byte(modelsContent), &rawModels); jsonErr != nil {
acc.warnings = append(acc.warnings, fmt.Sprintf("import %q: models field is not a valid object; skipping invalid value", importPath))
return
}
if _, hasProviders := rawModels["providers"]; hasProviders {
acc.modelCosts = append(acc.modelCosts, rawModels)
if providers, ok := rawModels["providers"].(map[string]any); ok {
parserLog.Printf("Extracted model costs from import: providers=%d", len(providers))
} else {
parserLog.Printf("Extracted model costs from import")
if modelPolicy := normalizeModelPolicies(rawModels, importPath, &acc.warnings); len(modelPolicy) > 0 {
acc.modelPolicies = append(acc.modelPolicies, modelPolicy)
parserLog.Printf("Extracted model policy from import: allowed=%d, disallowed=%d", len(modelPolicy["allowed"]), len(modelPolicy["disallowed"]))
}
if providers, hasProviders := rawModels["providers"]; hasProviders {
if providerMap, ok := sanitizeModelProvidersForCosts(providers, importPath, &acc.warnings); ok {
acc.modelCosts = append(acc.modelCosts, map[string]any{"providers": providerMap})
parserLog.Printf("Extracted model costs from import: providers=%d", len(providerMap))
}
return
}

modelsMap := normalizeModelAliases(rawModels)
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) {
continue
}
aliasModels[key] = value
}
if len(aliasModels) == 0 {
return
}
modelsMap := normalizeModelAliases(aliasModels)
if len(modelsMap) > 0 {
acc.models = append(acc.models, modelsMap)
parserLog.Printf("Extracted model aliases from import: %d entries", len(modelsMap))
}
}

func normalizeModelPolicies(rawModels map[string]any, importPath string, warnings *[]string) map[string][]string {
parse := func(key string) []string {
value, exists := rawModels[key]
if !exists {
return nil
}
return parseModelPolicyField(value, key, importPath, warnings)
}
allowed := parse(modelPolicyAllowedKey)
disallowed := parse(modelPolicyDisallowedKey)
if len(allowed) == 0 && len(disallowed) == 0 {
return nil
}
return map[string][]string{
modelPolicyAllowedKey: allowed,
modelPolicyDisallowedKey: disallowed,
}
}

func normalizeModelAliases(rawModels map[string]any) map[string][]string {
modelsMap := make(map[string][]string, len(rawModels))
for k, v := range rawModels {
patterns, ok := v.([]any)
strs := parseStringSliceField(v, true)
if len(strs) == 0 {
continue
}
modelsMap[k] = strs
}
return modelsMap
}

// parseModelPolicyField parses one imported models policy field as a string list.
// Invalid field shapes or entries are ignored and appended to warnings.
func parseModelPolicyField(value any, fieldName, importPath string, warnings *[]string) []string {
values, ok := value.([]any)
if !ok {
*warnings = append(*warnings, fmt.Sprintf("import %q: models.%s must be an array; skipping invalid value", importPath, fieldName))
return nil
}
result := make([]string, 0, len(values))
for _, v := range values {
s, ok := v.(string)
if !ok {
*warnings = append(*warnings, fmt.Sprintf("import %q: models.%s contains a non-string entry; skipping invalid entry", importPath, fieldName))
continue
}
if s == "" {
*warnings = append(*warnings, fmt.Sprintf("import %q: models.%s contains an empty string entry; skipping invalid entry", importPath, fieldName))
continue
}
strs := make([]string, 0, len(patterns))
for _, p := range patterns {
if s, ok := p.(string); ok {
strs = append(strs, s)
result = append(result, s)
}
if len(result) == 0 {
return nil
}
return result
}

// sanitizeModelProvidersForCosts validates models.providers from an import.
// It returns the provider map and true when the input is a non-empty object; otherwise false.
func sanitizeModelProvidersForCosts(providers any, importPath string, warnings *[]string) (map[string]any, bool) {
providerMap, ok := providers.(map[string]any)
if !ok || len(providerMap) == 0 {
*warnings = append(*warnings, fmt.Sprintf("import %q: models.providers must be a non-empty object; skipping invalid value", importPath))
return nil, false
}
sanitizedProviders := make(map[string]any, len(providerMap))
for providerName, providerValue := range providerMap {
if isModelPolicyKey(providerName) || providerName == "blocked" {
*warnings = append(*warnings, fmt.Sprintf("import %q: models.providers.%s is reserved for policy and ignored in cost data", importPath, providerName))
continue
}
sanitizedProviders[providerName] = providerValue
}
if len(sanitizedProviders) == 0 {
*warnings = append(*warnings, fmt.Sprintf("import %q: models.providers must contain at least one non-policy provider key", importPath))
return nil, false
}
return sanitizedProviders, true
}

func parseStringSliceField(value any, keepEmpty bool) []string {
values, ok := value.([]any)
if !ok {
return nil
}
result := make([]string, 0, len(values))
for _, v := range values {
if s, ok := v.(string); ok {
if s == "" && !keepEmpty {
continue
}
result = append(result, s)
}
modelsMap[k] = strs
}
return modelsMap
if len(result) == 0 {
return nil
}
return result
}

func isModelPolicyKey(key string) bool {
return key == modelPolicyAllowedKey || key == modelPolicyDisallowedKey
}

func (acc *importAccumulator) extractRunInstallScripts(fm map[string]any, fullPath string) {
Expand Down Expand Up @@ -737,6 +844,7 @@ func (acc *importAccumulator) toImportsResult(topologicalOrder []string) *Import
MergedEnvSources: acc.envSources,
MergedFeatures: acc.features,
MergedModels: acc.models,
MergedModelPolicies: acc.modelPolicies,
MergedModelCosts: acc.modelCosts,
MergedObservability: mergeObservabilityConfigs(acc.observabilityConfigs),
ImportedFiles: topologicalOrder,
Expand Down
133 changes: 133 additions & 0 deletions pkg/parser/import_field_extractor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -699,3 +699,136 @@ func TestExtractConfigFields_FirstWinsAndAccumulates(t *testing.T) {
assert.Contains(t, acc.secretMaskingBuilder.String(), "enabled")
assert.Contains(t, acc.secretMaskingBuilder.String(), "log-mask")
}

func TestAppendModelsField_ExtractsModelPolicySets(t *testing.T) {
acc := newImportAccumulator()
fm := map[string]any{
"models": map[string]any{
"allowed": []any{"gpt-5", "claude-sonnet"},
"disallowed": []any{"gpt-5-pro"},
},
}

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

require.Len(t, acc.modelPolicies, 1, "expected one model policy set")
assert.Equal(t, []string{"gpt-5", "claude-sonnet"}, acc.modelPolicies[0]["allowed"])
assert.Equal(t, []string{"gpt-5-pro"}, acc.modelPolicies[0]["disallowed"])
assert.Empty(t, acc.models, "policy fields should not be interpreted as model aliases")
}

func TestAppendModelsField_ExtractsModelCostsAndPolicyTogether(t *testing.T) {
acc := newImportAccumulator()
fm := map[string]any{
"models": map[string]any{
"allowed": []any{"gpt-5-mini"},
"providers": map[string]any{
"openai": map[string]any{
"models": map[string]any{
"gpt-5-mini": map[string]any{
"cost": map[string]any{"input": "1e-6"},
},
},
},
},
},
}

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

require.Len(t, acc.modelCosts, 1, "expected one model cost overlay")
require.Len(t, acc.modelPolicies, 1, "expected one model policy set")
assert.Equal(t, []string{"gpt-5-mini"}, acc.modelPolicies[0]["allowed"])
assert.Contains(t, acc.modelCosts[0], "providers")
assert.Len(t, acc.modelCosts[0], 1)
for _, key := range []string{"allowed", "disallowed"} {
_, present := acc.modelCosts[0][key]
assert.Falsef(t, present, "model cost overlay should not contain policy key %q", key)
}
}

func TestAppendModelsField_InvalidPolicyAndProviders_EmitsWarningsAndSkipsCosts(t *testing.T) {
acc := newImportAccumulator()
fm := map[string]any{
"models": map[string]any{
"allowed": "gpt-5",
"providers": "not-an-object",
},
}

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

assert.Empty(t, acc.modelPolicies)
assert.Empty(t, acc.modelCosts)
require.NotEmpty(t, acc.warnings)
warningsText := strings.Join(acc.warnings, "\n")
assert.Contains(t, warningsText, "models.allowed")
assert.Contains(t, warningsText, "models.providers")
}

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

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

assert.Empty(t, acc.modelPolicies)
assert.Empty(t, acc.modelCosts)
require.NotEmpty(t, acc.warnings)
assert.Contains(t, strings.Join(acc.warnings, "\n"), "models field is not a valid object")
}

func TestAppendModelsField_ProvidersPolicyKeysAreExcludedFromModelCosts(t *testing.T) {
acc := newImportAccumulator()
fm := map[string]any{
"models": map[string]any{
"providers": map[string]any{
"allowed": []any{"gpt-5"},
"openai": map[string]any{
"models": map[string]any{
"gpt-5": map[string]any{
"cost": map[string]any{"input": "1e-6"},
},
},
},
},
},
}

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

require.Len(t, acc.modelCosts, 1)
providers, ok := acc.modelCosts[0]["providers"].(map[string]any)
require.True(t, ok)
assert.Contains(t, providers, "openai")
assert.NotContains(t, providers, "allowed")
assert.NotContains(t, providers, "disallowed")
require.NotEmpty(t, acc.warnings)
assert.Contains(t, strings.Join(acc.warnings, "\n"), "models.providers.allowed is reserved for policy")
}

func TestAppendModelsField_ProvidersAndAliasesBothExtracted(t *testing.T) {
acc := newImportAccumulator()
fm := map[string]any{
"models": map[string]any{
"providers": map[string]any{
"openai": map[string]any{
"models": map[string]any{
"gpt-5": map[string]any{
"cost": map[string]any{"input": "1e-6"},
},
},
},
},
"agent": []any{"gpt-5"},
},
}

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

require.Len(t, acc.modelCosts, 1)
require.Len(t, acc.models, 1)
assert.Equal(t, []string{"gpt-5"}, acc.models[0]["agent"])
}
1 change: 1 addition & 0 deletions pkg/parser/import_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ type ImportsResult struct {
MergedEnvSources map[string]string // env var name → source import path (for conflict detection and lock file header listing)
MergedFeatures []map[string]any // Merged features configuration from all imports (parsed YAML structures)
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/disallowed)
MergedModelCosts []map[string]any // Merged model pricing overlays (models.json provider structure) from all imports
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")
Expand Down
17 changes: 15 additions & 2 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2693,10 +2693,23 @@
]
},
"models": {
"description": "Custom model pricing data in the same structure as models.json. Merged with the built-in models.json at runtime; frontmatter entries override matching models and fill gaps for unknown models. Useful for custom or private models, or to adjust pricing for AI Credits cost accounting.",
"description": "Model policy and optional pricing configuration. The policy fields (allowed/disallowed) are merged as unions across imports. The providers field is optional and supplies pricing data merged by provider/model key.",
"type": "object",
"required": ["providers"],
"properties": {
"allowed": {
"type": "array",
"description": "Allowlist of model names/patterns. Mapped to AWF apiProxy.allowedModels.",
"items": {
"type": "string"
}
},
"disallowed": {
"type": "array",
"description": "Denylist of model names/patterns. Mapped to AWF apiProxy.disallowedModels.",
"items": {
"type": "string"
}
},
"providers": {
"type": "object",
"description": "Provider-keyed map of model pricing data.",
Expand Down
Loading