diff --git a/.github/workflows/smoke-claude.lock.yml b/.github/workflows/smoke-claude.lock.yml index 89a4120ce71..00155ad3904 100644 --- a/.github/workflows/smoke-claude.lock.yml +++ b/.github/workflows/smoke-claude.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"c985ca9f0fb4bfa2e3eedadb7345641ff2694aeaa8ff58a660fbbe5cffc5b227","body_hash":"36c065e0560b79a1c971cb1ef686449073e0170c2e67f5e10ecd9ebd481b02fc","strict":true,"agent_id":"claude","engine_versions":{"claude":"2.1.195"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"b3759f6dcb823cb1fedc90d1022955a20d4cc8fca50da7dead2b43dbc33c8b56","body_hash":"36c065e0560b79a1c971cb1ef686449073e0170c2e67f5e10ecd9ebd481b02fc","strict":true,"agent_id":"claude","engine_versions":{"claude":"2.1.195"}} # gh-aw-manifest: {"version":1,"secrets":["ANTHROPIC_API_KEY","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN","TAVILY_API_KEY"],"actions":[{"repo":"actions/cache/restore","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/cache/save","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/checkout","sha":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-go","sha":"4a3601121dd01d1626a1e23e37211e3254c1c06c","version":"v6.4.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"docker/build-push-action","sha":"f9f3042f7e2789586610d6e8b85c8f03e5195baf","version":"v7.2.0"},{"repo":"docker/setup-buildx-action","sha":"d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5","version":"v4.1.0"},{"repo":"github/codeql-action/upload-sarif","sha":"8aad20d150bbac5944a9f9d289da16a4b0d87c1e","version":"v4.36.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.11","digest":"sha256:979723c628182da7729333f2208bb249fd25ddee579645cf9a3892d681a929c7","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.11@sha256:979723c628182da7729333f2208bb249fd25ddee579645cf9a3892d681a929c7"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.11","digest":"sha256:807e4831999b44513b0a66e5859d478dc4da7ae74ab1918cec967d513f95bf9d","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.11@sha256:807e4831999b44513b0a66e5859d478dc4da7ae74ab1918cec967d513f95bf9d"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.11"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.11","digest":"sha256:ff27ea0525ad953a6adee28a5fbe9d2e22be47dbec755c15767af4ea3f91df7d","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.11@sha256:ff27ea0525ad953a6adee28a5fbe9d2e22be47dbec755c15767af4ea3f91df7d"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.30","digest":"sha256:35625d1a2269b1238606078c879f59a91cffc4ac33eb54bf39c6418822c1a8be","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.30@sha256:35625d1a2269b1238606078c879f59a91cffc4ac33eb54bf39c6418822c1a8be"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -1705,7 +1705,7 @@ jobs: touch /tmp/gh-aw/agent-step-summary.md (umask 177 && touch /tmp/gh-aw/agent-stdio.log) GH_AW_MAX_AI_CREDITS="${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }}" - printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.11/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"*.githubusercontent.com\",\"*.grafana.net\",\"*.sentry.io\",\"anthropic.com\",\"api.anthropic.com\",\"api.github.com\",\"api.snapcraft.io\",\"archive.ubuntu.com\",\"azure.archive.ubuntu.com\",\"cdn.playwright.dev\",\"codeload.github.com\",\"crl.geotrust.com\",\"crl.globalsign.com\",\"crl.identrust.com\",\"crl.sectigo.com\",\"crl.thawte.com\",\"crl.usertrust.com\",\"crl.verisign.com\",\"crl3.digicert.com\",\"crl4.digicert.com\",\"crls.ssl.com\",\"docs.github.com\",\"files.pythonhosted.org\",\"ghcr.io\",\"github-cloud.githubusercontent.com\",\"github-cloud.s3.amazonaws.com\",\"github.blog\",\"github.com\",\"github.githubassets.com\",\"go.dev\",\"golang.org\",\"goproxy.io\",\"host.docker.internal\",\"json-schema.org\",\"json.schemastore.org\",\"keyserver.ubuntu.com\",\"lfs.github.com\",\"mcp.tavily.com\",\"objects.githubusercontent.com\",\"ocsp.digicert.com\",\"ocsp.geotrust.com\",\"ocsp.globalsign.com\",\"ocsp.identrust.com\",\"ocsp.sectigo.com\",\"ocsp.ssl.com\",\"ocsp.thawte.com\",\"ocsp.usertrust.com\",\"ocsp.verisign.com\",\"packagecloud.io\",\"packages.cloud.google.com\",\"packages.microsoft.com\",\"patch-diff.githubusercontent.com\",\"pkg.go.dev\",\"playwright.download.prss.microsoft.com\",\"ppa.launchpad.net\",\"proxy.golang.org\",\"pypi.org\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"s.symcb.com\",\"s.symcd.com\",\"security.ubuntu.com\",\"sentry.io\",\"statsig.anthropic.com\",\"storage.googleapis.com\",\"sum.golang.org\",\"ts-crl.ws.symantec.com\",\"ts-ocsp.ws.symantec.com\",\"www.googleapis.com\"],\"isolation\":true,\"topologyAttach\":[\"awmg-mcpg\",\"awmg-cli-proxy\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":100,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.5\",\"gpt-5.4\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.27.11,squid=sha256:ff27ea0525ad953a6adee28a5fbe9d2e22be47dbec755c15767af4ea3f91df7d,agent=sha256:979723c628182da7729333f2208bb249fd25ddee579645cf9a3892d681a929c7,api-proxy=sha256:807e4831999b44513b0a66e5859d478dc4da7ae74ab1918cec967d513f95bf9d\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" + printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.11/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"*.githubusercontent.com\",\"*.grafana.net\",\"*.sentry.io\",\"anthropic.com\",\"api.anthropic.com\",\"api.github.com\",\"api.snapcraft.io\",\"archive.ubuntu.com\",\"azure.archive.ubuntu.com\",\"cdn.playwright.dev\",\"codeload.github.com\",\"crl.geotrust.com\",\"crl.globalsign.com\",\"crl.identrust.com\",\"crl.sectigo.com\",\"crl.thawte.com\",\"crl.usertrust.com\",\"crl.verisign.com\",\"crl3.digicert.com\",\"crl4.digicert.com\",\"crls.ssl.com\",\"docs.github.com\",\"files.pythonhosted.org\",\"ghcr.io\",\"github-cloud.githubusercontent.com\",\"github-cloud.s3.amazonaws.com\",\"github.blog\",\"github.com\",\"github.githubassets.com\",\"go.dev\",\"golang.org\",\"goproxy.io\",\"host.docker.internal\",\"json-schema.org\",\"json.schemastore.org\",\"keyserver.ubuntu.com\",\"lfs.github.com\",\"mcp.tavily.com\",\"objects.githubusercontent.com\",\"ocsp.digicert.com\",\"ocsp.geotrust.com\",\"ocsp.globalsign.com\",\"ocsp.identrust.com\",\"ocsp.sectigo.com\",\"ocsp.ssl.com\",\"ocsp.thawte.com\",\"ocsp.usertrust.com\",\"ocsp.verisign.com\",\"packagecloud.io\",\"packages.cloud.google.com\",\"packages.microsoft.com\",\"patch-diff.githubusercontent.com\",\"pkg.go.dev\",\"playwright.download.prss.microsoft.com\",\"ppa.launchpad.net\",\"proxy.golang.org\",\"pypi.org\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"s.symcb.com\",\"s.symcd.com\",\"security.ubuntu.com\",\"sentry.io\",\"statsig.anthropic.com\",\"storage.googleapis.com\",\"sum.golang.org\",\"ts-crl.ws.symantec.com\",\"ts-ocsp.ws.symantec.com\",\"www.googleapis.com\"],\"isolation\":true,\"topologyAttach\":[\"awmg-mcpg\",\"awmg-cli-proxy\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":100,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.5\",\"gpt-5.4\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]},\"disallowedModels\":[\"*opus*\"]},\"container\":{\"imageTag\":\"0.27.11,squid=sha256:ff27ea0525ad953a6adee28a5fbe9d2e22be47dbec755c15767af4ea3f91df7d,agent=sha256:979723c628182da7729333f2208bb249fd25ddee579645cf9a3892d681a929c7,api-proxy=sha256:807e4831999b44513b0a66e5859d478dc4da7ae74ab1918cec967d513f95bf9d\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json" GH_AW_DOCKER_HOST="" diff --git a/.github/workflows/smoke-claude.md b/.github/workflows/smoke-claude.md index 343f3b5b87e..d6ea603dd7c 100644 --- a/.github/workflows/smoke-claude.md +++ b/.github/workflows/smoke-claude.md @@ -21,6 +21,8 @@ permissions: actions: read name: Smoke Claude +models: + disallowed: ["*opus*"] max-turns: 100 engine: id: claude diff --git a/pkg/parser/import_field_extractor.go b/pkg/parser/import_field_extractor.go index f1baa9d5764..0cae364623f 100644 --- a/pkg/parser/import_field_extractor.go +++ b/pkg/parser/import_field_extractor.go @@ -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 @@ -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. @@ -621,41 +627,88 @@ func (acc *importAccumulator) appendModelsField(fm map[string]any) { if jsonErr := json.Unmarshal([]byte(modelsContent), &rawModels); jsonErr != nil { 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)) + if modelPolicy := normalizeModelPolicies(rawModels); 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 { + acc.modelCosts = append(acc.modelCosts, map[string]any{"providers": providers}) + if providerMap, ok := providers.(map[string]any); ok { + parserLog.Printf("Extracted model costs from import: providers=%d", len(providerMap)) } else { parserLog.Printf("Extracted model costs from import") } return } - modelsMap := normalizeModelAliases(rawModels) + aliasModels := make(map[string]any, len(rawModels)) + for key, value := range rawModels { + if 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) map[string][]string { + parse := func(key string) []string { + return parseStringSliceField(rawModels[key], false) + } + 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) - if !ok { + strs := parseStringSliceField(v, true) + if len(strs) == 0 { continue } - strs := make([]string, 0, len(patterns)) - for _, p := range patterns { - if s, ok := p.(string); ok { - strs = append(strs, s) - } - } modelsMap[k] = strs } return modelsMap } +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) + } + } + 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) { if acc.runInstallScripts { return @@ -737,6 +790,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, diff --git a/pkg/parser/import_field_extractor_test.go b/pkg/parser/import_field_extractor_test.go index 97d6d4d2818..720a985f098 100644 --- a/pkg/parser/import_field_extractor_test.go +++ b/pkg/parser/import_field_extractor_test.go @@ -699,3 +699,50 @@ 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) + + 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) + + 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) + } +} diff --git a/pkg/parser/import_processor.go b/pkg/parser/import_processor.go index 18a3c94a3be..cc1812eb383 100644 --- a/pkg/parser/import_processor.go +++ b/pkg/parser/import_processor.go @@ -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") diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index d4ff6273c59..34d2afa8ef4 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -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.", diff --git a/pkg/workflow/awf_config.go b/pkg/workflow/awf_config.go index c2b3df65030..d371458b84f 100644 --- a/pkg/workflow/awf_config.go +++ b/pkg/workflow/awf_config.go @@ -71,6 +71,7 @@ import ( "github.com/github/gh-aw/pkg/jsonutil" "github.com/github/gh-aw/pkg/logger" "github.com/github/gh-aw/pkg/setutil" + "github.com/github/gh-aw/pkg/workflow/compilerenv" ) //go:embed schemas/awf-config.schema.json @@ -242,6 +243,11 @@ type AWFAPIProxyConfig struct { // AWF resolves aliases recursively; loops are not permitted. // Per the AWF config schema, this lives under apiProxy.models. Models map[string][]string `json:"models,omitempty"` + + // AllowedModels is the explicit allowlist policy for model names/patterns. + AllowedModels []string `json:"allowedModels,omitempty"` + // DisallowedModels is the explicit denylist policy for model names/patterns. + DisallowedModels []string `json:"disallowedModels,omitempty"` } // AWFModelFallbackConfig is the "apiProxy.modelFallback" section of the AWF config file. @@ -492,6 +498,15 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) { apiProxy.Models = config.WorkflowData.ModelMappings awfConfigLog.Printf("Models section: %d alias entries", len(config.WorkflowData.ModelMappings)) } + allowedModels, disallowedModels := resolveModelPolicyForAWFConfig(config.WorkflowData) + if len(allowedModels) > 0 { + apiProxy.AllowedModels = allowedModels + awfConfigLog.Printf("Models policy: %d allowed model pattern(s)", len(allowedModels)) + } + if len(disallowedModels) > 0 { + apiProxy.DisallowedModels = disallowedModels + awfConfigLog.Printf("Models policy: %d disallowed model pattern(s)", len(disallowedModels)) + } awfConfig.APIProxy = apiProxy @@ -550,6 +565,24 @@ func splitDomainList(domains string) []string { return result } +func resolveModelPolicyForAWFConfig(workflowData *WorkflowData) ([]string, []string) { + envAllowed, hasAllowedOverride := compilerenv.ResolvePolicyModelsAllowed() + envDisallowed, hasDisallowedOverride := compilerenv.ResolvePolicyModelsDisallowed() + var allowed []string + var disallowed []string + if hasAllowedOverride { + allowed = envAllowed + } else if workflowData != nil { + allowed = workflowData.ModelPolicyAllowed + } + if hasDisallowedOverride { + disallowed = envDisallowed + } else if workflowData != nil { + disallowed = workflowData.ModelPolicyDisallowed + } + return allowed, disallowed +} + func extractModelMultipliers(workflowData *WorkflowData) map[string]float64 { if workflowData == nil || workflowData.EngineConfig == nil || workflowData.EngineConfig.TokenWeights == nil { return nil diff --git a/pkg/workflow/awf_config_test.go b/pkg/workflow/awf_config_test.go index 9db4f12bb22..77b57038ed3 100644 --- a/pkg/workflow/awf_config_test.go +++ b/pkg/workflow/awf_config_test.go @@ -1619,3 +1619,48 @@ func TestBuildAWFTopologyAttachList(t *testing.T) { assert.Equal(t, []string{"awmg-mcpg", "awmg-cli-proxy"}, targets) }) } + +func TestBuildAWFConfigJSON_EmitsModelPolicyFromWorkflowData(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + AllowedDomains: "github.com", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + ModelPolicyAllowed: []string{"gpt-5", "claude-sonnet"}, + ModelPolicyDisallowed: []string{"gpt-5-pro", "claude-opus"}, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + assert.Contains(t, jsonStr, `"allowedModels":["gpt-5","claude-sonnet"]`) + assert.Contains(t, jsonStr, `"disallowedModels":["gpt-5-pro","claude-opus"]`) +} + +func TestBuildAWFConfigJSON_ModelPolicyEnvOverridePrecedence(t *testing.T) { + t.Setenv(compilerenv.PolicyModelsAllowed, "gemini-pro,gpt-5-mini") + t.Setenv(compilerenv.PolicyModelsDisallowed, "claude-opus, gpt-5-pro") + + config := AWFCommandConfig{ + EngineName: "copilot", + AllowedDomains: "github.com", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + ModelPolicyAllowed: []string{"frontmatter-allowed"}, + ModelPolicyDisallowed: []string{"frontmatter-disallowed"}, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + assert.Contains(t, jsonStr, `"allowedModels":["gemini-pro","gpt-5-mini"]`) + assert.Contains(t, jsonStr, `"disallowedModels":["claude-opus","gpt-5-pro"]`) + assert.NotContains(t, jsonStr, "frontmatter-allowed") + assert.NotContains(t, jsonStr, "frontmatter-disallowed") +} diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 7e085984236..338fa07911a 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -610,6 +610,8 @@ type WorkflowData struct { KnownActionCredentialEnvVars map[string]struct{} // env vars for clean_known_action_credentials.sh; keyed by GH_AW_CLEAN_* names; nil when no known credential-leaking actions are detected ModelMappings map[string][]string // merged model alias map (builtins + imported workflow aliases + main frontmatter overrides, in priority order); NOT yet emitted to AWF config JSON — pending AWF firewall support (config.models) ModelCosts map[string]any // model pricing data from frontmatter `models` field (providers structure); merged with built-in models.json at runtime by generate_aw_info.cjs + ModelPolicyAllowed []string // merged models.allowed policy list (union across imports + main frontmatter) + ModelPolicyDisallowed []string // merged models.disallowed policy list (union across imports + main frontmatter) ActionPinMappings map[string]string // action-pin redirect table from aw.json action_pins: maps "owner/repo@version" → "owner/repo@version" } diff --git a/pkg/workflow/compilerenv/manager.go b/pkg/workflow/compilerenv/manager.go index bf1da22975f..dc6ff00280c 100644 --- a/pkg/workflow/compilerenv/manager.go +++ b/pkg/workflow/compilerenv/manager.go @@ -50,6 +50,10 @@ const ( // PolicyStrict enables runtime enforcement that workflows must be compiled in strict mode // when GH_AW_POLICY_STRICT is set to the string value "true". PolicyStrict = "GH_AW_POLICY_STRICT" + // PolicyModelsAllowed centrally overrides models.allowed frontmatter policy. + PolicyModelsAllowed = "GHAW_POLICY_MODELS_ALLOWED" + // PolicyModelsDisallowed centrally overrides models.disallowed frontmatter policy. + PolicyModelsDisallowed = "GHAW_POLICY_MODELS_DISALLOWED" // PolicyAllowCreatePullRequest controls whether create-pull-request safe-outputs // remain runtime-compliant. Set to the string value "false" to disable the // create_pull_request safe-output tool at runtime. @@ -165,3 +169,46 @@ func BuildModelOverrideExpression(primaryVar, enterpriseDefaultVar, builtinFallb func BuildModelOverrideExpressionEmptyFallback(primaryVar, enterpriseDefaultVar string) string { return fmt.Sprintf("${{ vars.%s || vars.%s || '' }}", primaryVar, enterpriseDefaultVar) } + +// ResolvePolicyModelsAllowed returns configured allowed model policy entries. +// When the env var is unset/empty, ok=false and callers should use frontmatter policy. +func ResolvePolicyModelsAllowed() ([]string, bool) { + return resolveModelListEnv(PolicyModelsAllowed) +} + +// ResolvePolicyModelsDisallowed returns configured disallowed model policy entries. +// When the env var is unset/empty, ok=false and callers should use frontmatter policy. +func ResolvePolicyModelsDisallowed() ([]string, bool) { + return resolveModelListEnv(PolicyModelsDisallowed) +} + +func resolveModelListEnv(name string) ([]string, bool) { + raw := strings.TrimSpace(os.Getenv(name)) + if raw == "" { + return nil, false + } + parts := strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == '\n' || r == '\r' + }) + if len(parts) == 0 { + return nil, false + } + result := make([]string, 0, len(parts)) + seen := map[string]struct{}{} + for _, part := range parts { + model := strings.TrimSpace(part) + if model == "" { + continue + } + if _, exists := seen[model]; exists { + continue + } + seen[model] = struct{}{} + result = append(result, model) + } + if len(result) == 0 { + return nil, false + } + managerLog.Printf("Applying model policy override %s with %d model(s)", name, len(result)) + return result, true +} diff --git a/pkg/workflow/compilerenv/manager_test.go b/pkg/workflow/compilerenv/manager_test.go index cea6733c18b..3bae1016130 100644 --- a/pkg/workflow/compilerenv/manager_test.go +++ b/pkg/workflow/compilerenv/manager_test.go @@ -150,3 +150,35 @@ func TestResolveDefaultUTC(t *testing.T) { assert.Equal(t, "-08:00", ResolveDefaultUTC("+00:00")) }) } + +func TestResolvePolicyModelsAllowed(t *testing.T) { + t.Run("unset returns no override", func(t *testing.T) { + t.Setenv(PolicyModelsAllowed, "") + got, ok := ResolvePolicyModelsAllowed() + assert.False(t, ok) + assert.Nil(t, got) + }) + + t.Run("comma-separated list is parsed", func(t *testing.T) { + t.Setenv(PolicyModelsAllowed, "gpt-5, claude-sonnet, gpt-5") + got, ok := ResolvePolicyModelsAllowed() + assert.True(t, ok) + assert.Equal(t, []string{"gpt-5", "claude-sonnet"}, got) + }) +} + +func TestResolvePolicyModelsDisallowed(t *testing.T) { + t.Run("unset returns no override", func(t *testing.T) { + t.Setenv(PolicyModelsDisallowed, "") + got, ok := ResolvePolicyModelsDisallowed() + assert.False(t, ok) + assert.Nil(t, got) + }) + + t.Run("comma/newline-separated list is parsed", func(t *testing.T) { + t.Setenv(PolicyModelsDisallowed, "gpt-5-pro,\nclaude-opus") + got, ok := ResolvePolicyModelsDisallowed() + assert.True(t, ok) + assert.Equal(t, []string{"gpt-5-pro", "claude-opus"}, got) + }) +} diff --git a/pkg/workflow/frontmatter_parsing.go b/pkg/workflow/frontmatter_parsing.go index cb32f6762fe..01ba0b1f2dc 100644 --- a/pkg/workflow/frontmatter_parsing.go +++ b/pkg/workflow/frontmatter_parsing.go @@ -90,10 +90,37 @@ func ParseFrontmatterConfig(frontmatter map[string]any) (*FrontmatterConfig, err // legacy bare-array form and the new object form are available as ExperimentConfig // structs without callers needing to type-assert config.Experiments entries. config.ExperimentConfigs = extractExperimentConfigsFromFrontmatter(frontmatter) + config.ModelPolicyAllowed, config.ModelPolicyDisallowed = extractModelPolicyFromFrontmatter(frontmatter) frontmatterTypesLog.Printf("Successfully parsed frontmatter config: name=%s, engine=%v", config.Name, config.Engine) return &config, nil } + +func extractModelPolicyFromFrontmatter(frontmatter map[string]any) ([]string, []string) { + modelsRaw, ok := frontmatter["models"].(map[string]any) + if !ok { + return nil, nil + } + return parseModelPolicyList(modelsRaw["allowed"]), parseModelPolicyList(modelsRaw["disallowed"]) +} + +func parseModelPolicyList(value any) []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 && s != "" { + result = append(result, s) + } + } + if len(result) == 0 { + return nil + } + return result +} + func parseOnNeedsConfig(on map[string]any) ([]string, error) { return parseOnNeedsValues(on) } diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index 975fa3d7fff..c7af910c474 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -377,6 +377,10 @@ type FrontmatterConfig struct { // so that custom or adjusted cost values are reflected in effective-token accounting. // Structure: {"providers": {"": {"models": {"": {"cost": {...}}}}}} ModelCosts map[string]any `json:"models,omitempty"` + // ModelPolicyAllowed is frontmatter models.allowed (allowlist), merged as a union across imports. + ModelPolicyAllowed []string `json:"-"` + // ModelPolicyDisallowed is frontmatter models.disallowed (denylist), merged as a union across imports. + ModelPolicyDisallowed []string `json:"-"` // Rate limiting configuration RateLimit *RateLimitConfig `json:"user-rate-limit,omitempty"` diff --git a/pkg/workflow/model_aliases_test.go b/pkg/workflow/model_aliases_test.go index 64f0886ea87..96e3fd7b5ad 100644 --- a/pkg/workflow/model_aliases_test.go +++ b/pkg/workflow/model_aliases_test.go @@ -331,4 +331,20 @@ func TestFrontmatterModelsField(t *testing.T) { require.True(t, ok, "ModelCosts should contain a providers key") assert.Contains(t, providers, "anthropic", "providers should contain anthropic") }) + + t.Run("models policy fields populate parsed model policy lists", func(t *testing.T) { + frontmatter := map[string]any{ + "name": "test-workflow", + "models": map[string]any{ + "allowed": []any{"gpt-5", "claude-sonnet"}, + "disallowed": []any{"gpt-5-pro"}, + }, + } + + config, err := ParseFrontmatterConfig(frontmatter) + require.NoError(t, err, "ParseFrontmatterConfig should succeed with model policy fields") + require.NotNil(t, config, "parsed config should not be nil") + assert.Equal(t, []string{"gpt-5", "claude-sonnet"}, config.ModelPolicyAllowed) + assert.Equal(t, []string{"gpt-5-pro"}, config.ModelPolicyDisallowed) + }) } diff --git a/pkg/workflow/workflow_builder.go b/pkg/workflow/workflow_builder.go index 8bd374e877e..df1214a55c9 100644 --- a/pkg/workflow/workflow_builder.go +++ b/pkg/workflow/workflow_builder.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "maps" + "sort" "strings" "github.com/github/gh-aw/pkg/logger" @@ -152,6 +153,14 @@ func (c *Compiler) buildInitialWorkflowData( if len(mergedModelCosts) > 0 { workflowData.ModelCosts = mergedModelCosts } + mainModelPolicy := extractMainModelPolicyOverlay(toolsResult, result.Frontmatter) + allowedModels, disallowedModels := mergeModelPolicyOverlays(importsResult.MergedModelPolicies, mainModelPolicy) + if len(allowedModels) > 0 { + workflowData.ModelPolicyAllowed = allowedModels + } + if len(disallowedModels) > 0 { + workflowData.ModelPolicyDisallowed = disallowedModels + } return workflowData } @@ -188,7 +197,10 @@ func extractMainModelCostsOverlay(toolsResult *toolsProcessingResult, frontmatte // Fall back to raw frontmatter when ParseFrontmatterConfig failed (e.g. due to unrecognized // tool config shapes like bash: ["*"]). if toolsResult.parsedFrontmatter != nil && len(toolsResult.parsedFrontmatter.ModelCosts) > 0 { - return toolsResult.parsedFrontmatter.ModelCosts + if providers, hasProviders := toolsResult.parsedFrontmatter.ModelCosts["providers"]; hasProviders { + return map[string]any{"providers": providers} + } + return nil } rawModels, ok := frontmatter["models"] @@ -199,10 +211,11 @@ func extractMainModelCostsOverlay(toolsResult *toolsProcessingResult, frontmatte if !ok { return nil } - if _, hasProviders := modelsMap["providers"]; !hasProviders { + providers, hasProviders := modelsMap["providers"] + if !hasProviders { return nil } - return modelsMap + return map[string]any{"providers": providers} } func mergeModelCostOverlays(importedOverlays []map[string]any, mainOverlay map[string]any) map[string]any { @@ -276,6 +289,68 @@ func mergeModelCostOverlayPair(base, overlay map[string]any) map[string]any { return result } +func extractMainModelPolicyOverlay(toolsResult *toolsProcessingResult, frontmatter map[string]any) map[string][]string { + if toolsResult.parsedFrontmatter != nil { + mainPolicy := map[string][]string{ + "allowed": toolsResult.parsedFrontmatter.ModelPolicyAllowed, + "disallowed": toolsResult.parsedFrontmatter.ModelPolicyDisallowed, + } + if len(mainPolicy["allowed"]) > 0 || len(mainPolicy["disallowed"]) > 0 { + return mainPolicy + } + } + modelsMap, ok := frontmatter["models"].(map[string]any) + if !ok { + return nil + } + mainPolicy := map[string][]string{ + "allowed": parseModelPolicyList(modelsMap["allowed"]), + "disallowed": parseModelPolicyList(modelsMap["disallowed"]), + } + if len(mainPolicy["allowed"]) == 0 && len(mainPolicy["disallowed"]) == 0 { + return nil + } + return mainPolicy +} + +func mergeModelPolicyOverlays(importedPolicies []map[string][]string, mainPolicy map[string][]string) ([]string, []string) { + overlays := make([]map[string][]string, 0, len(importedPolicies)+1) + overlays = append(overlays, importedPolicies...) + if len(mainPolicy) > 0 { + overlays = append(overlays, mainPolicy) + } + if len(overlays) == 0 { + return nil, nil + } + + allowedSet := map[string]struct{}{} + disallowedSet := map[string]struct{}{} + for _, overlay := range overlays { + for _, model := range overlay["allowed"] { + if model != "" { + allowedSet[model] = struct{}{} + } + } + for _, model := range overlay["disallowed"] { + if model != "" { + disallowedSet[model] = struct{}{} + } + } + } + + allowedModels := make([]string, 0, len(allowedSet)) + for model := range allowedSet { + allowedModels = append(allowedModels, model) + } + disallowedModels := make([]string, 0, len(disallowedSet)) + for model := range disallowedSet { + disallowedModels = append(disallowedModels, model) + } + sort.Strings(allowedModels) + sort.Strings(disallowedModels) + return allowedModels, disallowedModels +} + // resolveInlinedImports returns true if inlined-imports is enabled. // It reads the value directly from the raw (pre-parsed) frontmatter map, which is always // populated regardless of whether ParseFrontmatterConfig succeeded. diff --git a/pkg/workflow/workflow_builder_model_policy_test.go b/pkg/workflow/workflow_builder_model_policy_test.go new file mode 100644 index 00000000000..6a24654550f --- /dev/null +++ b/pkg/workflow/workflow_builder_model_policy_test.go @@ -0,0 +1,96 @@ +//go:build !integration + +package workflow + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMergeModelPolicyOverlays_UnionizesAllowedAndDisallowed(t *testing.T) { + imported := []map[string][]string{ + { + "allowed": {"gpt-5", "claude-sonnet"}, + "disallowed": {"gpt-5-pro"}, + }, + { + "allowed": {"gpt-5-mini"}, + "disallowed": {"claude-opus"}, + }, + } + main := map[string][]string{ + "allowed": {"gpt-5"}, + "disallowed": {"gemini-pro"}, + } + + allowed, disallowed := mergeModelPolicyOverlays(imported, main) + assert.Equal(t, []string{"claude-sonnet", "gpt-5", "gpt-5-mini"}, allowed) + assert.Equal(t, []string{"claude-opus", "gemini-pro", "gpt-5-pro"}, disallowed) +} + +func TestExtractMainModelPolicyOverlay_UsesParsedFrontmatterWhenPresent(t *testing.T) { + toolsResult := &toolsProcessingResult{ + parsedFrontmatter: &FrontmatterConfig{ + ModelPolicyAllowed: []string{"gpt-5"}, + ModelPolicyDisallowed: []string{"gpt-5-pro"}, + }, + } + + policy := extractMainModelPolicyOverlay(toolsResult, map[string]any{}) + require.NotNil(t, policy) + assert.Equal(t, []string{"gpt-5"}, policy["allowed"]) + assert.Equal(t, []string{"gpt-5-pro"}, policy["disallowed"]) +} + +func TestExtractMainModelPolicyOverlay_FallsBackToRawFrontmatter(t *testing.T) { + toolsResult := &toolsProcessingResult{} + frontmatter := map[string]any{ + "models": map[string]any{ + "allowed": []any{"gpt-5-mini"}, + "disallowed": []any{"claude-opus"}, + }, + } + + policy := extractMainModelPolicyOverlay(toolsResult, frontmatter) + require.NotNil(t, policy) + assert.Equal(t, []string{"gpt-5-mini"}, policy["allowed"]) + assert.Equal(t, []string{"claude-opus"}, policy["disallowed"]) +} + +func TestExtractMainModelCostsOverlay_ExtractsNilWhenModelCostsHasOnlyPolicyKeys(t *testing.T) { + toolsResult := &toolsProcessingResult{ + parsedFrontmatter: &FrontmatterConfig{ + ModelCosts: map[string]any{ + "allowed": []any{"gpt-5"}, + }, + }, + } + + costs := extractMainModelCostsOverlay(toolsResult, map[string]any{}) + assert.Nil(t, costs) +} + +func TestExtractMainModelCostsOverlay_ExtractsOnlyProvidersAndExcludesPolicyKeys(t *testing.T) { + toolsResult := &toolsProcessingResult{} + frontmatter := map[string]any{ + "models": map[string]any{ + "allowed": []any{"gpt-5"}, + "providers": map[string]any{ + "openai": map[string]any{ + "models": map[string]any{ + "gpt-5": map[string]any{ + "cost": map[string]any{"input": "1e-6"}, + }, + }, + }, + }, + }, + } + + costs := extractMainModelCostsOverlay(toolsResult, frontmatter) + require.NotNil(t, costs) + assert.Contains(t, costs, "providers") + assert.NotContains(t, costs, "allowed") +}