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
21 changes: 21 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,28 @@ venv/
.terraform.lock.hcl
terraform.tfvars

# Claude Code activator (generated by scripts/configure-claude-code.*)
claude-code.env.ps1
claude-code.env.sh
# VS Code workspace settings populated by the postprovision hook with
# user-specific deployment info. Other .vscode/ files (launch.json,
# extensions.json, ...) remain trackable.
.vscode/settings.json

# IDE
.vscode/
.idea/
.DS_Store

# Local-only scratch area (helper scripts, logs, env backups not referenced by README).
# Anything in here stays on disk and never gets pushed.
local-only/

# Claude Code per-workspace permission cache (runtime state, not for sharing).
.claude/

# Workspace-scoped Azure CLI config + MSAL token cache. Set by
# AZURE_CONFIG_DIR in the activators and .vscode/settings.json so that
# 'az login' / 'azd' done in this workspace never touch ~/.azure and
# never leak into other VS Code windows. Pure runtime state — do not commit.
.azure-cli/
13 changes: 13 additions & 0 deletions Get-ClaudeRegions.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,19 @@ param(

$ErrorActionPreference = 'Stop'

# When invoked via `pwsh -File ... -Regions a,b,c`, parameter binding can pass
# the comma-joined string as a single element instead of an array. Normalize
# any string that contains commas into its comma-split parts.
$Regions = @(
foreach ($r in $Regions) {
if ($r -is [string] -and $r -match ',') {
$r.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ }
} else {
$r
}
}
)

# Verify az login context
try {
$ctx = az account show -o json 2>$null | ConvertFrom-Json
Expand Down
398 changes: 374 additions & 24 deletions README.md

Large diffs are not rendered by default.

23 changes: 16 additions & 7 deletions infra-bicep/azure.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,24 @@ hooks:
preprovision:
windows:
shell: pwsh
run: pwsh -NoProfile -ExecutionPolicy Bypass -File ../scripts/preflight-claude.ps1
posix:
shell: sh
run: |
if (-not $env:CLAUDE_ORGANIZATION_NAME) {
Write-Error "CLAUDE_ORGANIZATION_NAME is required. Run: azd env set CLAUDE_ORGANIZATION_NAME 'Your Org'"
exit 1
}
if command -v pwsh >/dev/null 2>&1; then
pwsh -NoProfile -File ../scripts/preflight-claude.ps1
else
bash ../scripts/preflight-claude.sh
fi
postprovision:
windows:
shell: pwsh
run: pwsh -NoProfile -ExecutionPolicy Bypass -File ../scripts/configure-claude-code.ps1
posix:
shell: sh
run: |
if [ -z "$CLAUDE_ORGANIZATION_NAME" ]; then
echo "CLAUDE_ORGANIZATION_NAME is required. Run: azd env set CLAUDE_ORGANIZATION_NAME 'Your Org'"
exit 1
if command -v pwsh >/dev/null 2>&1; then
pwsh -NoProfile -File ../scripts/configure-claude-code.ps1
else
bash ../scripts/configure-claude-code.sh
fi
113 changes: 94 additions & 19 deletions infra-bicep/infra/foundry.bicep
Original file line number Diff line number Diff line change
@@ -1,23 +1,42 @@
// Foundry account + project + Claude deployment + optional RBAC.
// Foundry account + project + per-family Claude deployments + optional RBAC.
//
// Each of haikuModel / sonnetModel / opusModel is independent. Empty string
// means "skip that family". The three deployments share the same Foundry
// account; the per-family capacity controls TPM allocation.
param location string
param tags object
param accountName string
param projectName string
param deploymentName string
param modelName string
param suffix string

param haikuModel string
param sonnetModel string
param opusModel string
param haikuCapacity int
param sonnetCapacity int
param opusCapacity int
param modelVersion string
param modelCapacity int

param claudeOrganizationName string
param claudeCountryCode string
param claudeIndustry string
param principalId string
param assignRbac string

var rbacEnabled = toLower(assignRbac) == 'true' && !empty(principalId)
var nameSuffix = take(suffix, 6)

// Pre-compute deployment names so outputs work even when a family is skipped.
var haikuDeploymentNameVar = empty(haikuModel) ? '' : '${haikuModel}-${nameSuffix}'
var sonnetDeploymentNameVar = empty(sonnetModel) ? '' : '${sonnetModel}-${nameSuffix}'
var opusDeploymentNameVar = empty(opusModel) ? '' : '${opusModel}-${nameSuffix}'

// Built-in role definition IDs.
var azureAiUserRoleId = '53ca6127-db72-4b80-b1b0-d745d6d5456d'
var azureAiProjectManagerRoleId = 'eadc314b-1a2d-4efa-be10-5d325db5065e'
// NOTE: Azure renamed these roles. The GUIDs are stable.
// 53ca6127-... : "Azure AI User" -> "Foundry User" (data-plane access)
// eadc314b-... : "Azure AI Project Manager" -> "Foundry Project Manager"
var foundryUserRoleId = '53ca6127-db72-4b80-b1b0-d745d6d5456d'
var foundryProjectManagerRoleId = 'eadc314b-1a2d-4efa-be10-5d325db5065e'

resource account 'Microsoft.CognitiveServices/accounts@2025-10-01-preview' = {
name: accountName
Expand Down Expand Up @@ -49,21 +68,74 @@ resource project 'Microsoft.CognitiveServices/accounts/projects@2025-10-01-previ
properties: {}
}

resource claudeDeployment 'Microsoft.CognitiveServices/accounts/deployments@2025-10-01-preview' = {
resource haikuDeployment 'Microsoft.CognitiveServices/accounts/deployments@2025-10-01-preview' = if (!empty(haikuModel)) {
parent: account
name: haikuDeploymentNameVar
sku: {
name: 'GlobalStandard'
capacity: haikuCapacity
}
properties: {
model: {
format: 'Anthropic'
name: haikuModel
version: modelVersion
}
modelProviderData: {
organizationName: claudeOrganizationName
countryCode: claudeCountryCode
industry: claudeIndustry
}
versionUpgradeOption: 'OnceNewDefaultVersionAvailable'
raiPolicyName: 'Microsoft.DefaultV2'
}
dependsOn: [
project
]
}

resource sonnetDeployment 'Microsoft.CognitiveServices/accounts/deployments@2025-10-01-preview' = if (!empty(sonnetModel)) {
parent: account
name: sonnetDeploymentNameVar
sku: {
name: 'GlobalStandard'
capacity: sonnetCapacity
}
properties: {
model: {
format: 'Anthropic'
name: sonnetModel
version: modelVersion
}
modelProviderData: {
organizationName: claudeOrganizationName
countryCode: claudeCountryCode
industry: claudeIndustry
}
versionUpgradeOption: 'OnceNewDefaultVersionAvailable'
raiPolicyName: 'Microsoft.DefaultV2'
}
// Foundry serializes deployments under one account; chain them to avoid
// 409s on concurrent create.
dependsOn: [
project
haikuDeployment
]
}

resource opusDeployment 'Microsoft.CognitiveServices/accounts/deployments@2025-10-01-preview' = if (!empty(opusModel)) {
parent: account
name: deploymentName
name: opusDeploymentNameVar
sku: {
name: 'GlobalStandard'
capacity: modelCapacity
capacity: opusCapacity
}
properties: {
model: {
// `Anthropic` is the on-the-wire format literal in the Foundry catalog.
format: 'Anthropic'
name: modelName
name: opusModel
version: modelVersion
}
// REQUIRED for Claude. `industry` must be lowercase.
modelProviderData: {
organizationName: claudeOrganizationName
countryCode: claudeCountryCode
Expand All @@ -74,30 +146,33 @@ resource claudeDeployment 'Microsoft.CognitiveServices/accounts/deployments@2025
}
dependsOn: [
project
sonnetDeployment
]
}

resource aiUserAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (rbacEnabled) {
name: guid(account.id, principalId, azureAiUserRoleId)
resource foundryUserAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (rbacEnabled) {
name: guid(account.id, principalId, foundryUserRoleId)
scope: account
properties: {
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', azureAiUserRoleId)
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', foundryUserRoleId)
principalId: principalId
principalType: 'User'
}
}

resource aiProjectManagerAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (rbacEnabled) {
name: guid(account.id, principalId, azureAiProjectManagerRoleId)
resource foundryProjectManagerAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (rbacEnabled) {
name: guid(account.id, principalId, foundryProjectManagerRoleId)
scope: account
properties: {
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', azureAiProjectManagerRoleId)
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', foundryProjectManagerRoleId)
principalId: principalId
principalType: 'User'
}
}

output claudeBaseUrl string = 'https://${account.name}.services.ai.azure.com/anthropic'
output foundryProjectEndpoint string = 'https://${account.name}.services.ai.azure.com/api/projects/${project.name}'
output claudeDeploymentName string = claudeDeployment.name
output foundryAccountName string = account.name
output haikuDeploymentName string = haikuDeploymentNameVar
output sonnetDeploymentName string = sonnetDeploymentNameVar
output opusDeploymentName string = opusDeploymentNameVar
81 changes: 63 additions & 18 deletions infra-bicep/infra/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,19 @@
// MUST be lowercase to match the Foundry portal dropdown.
// `allowProjectManagement = true` is required to create projects under the
// Foundry account.
//
// Per-family deployment mode:
// Set any of CLAUDE_HAIKU_MODEL / CLAUDE_SONNET_MODEL / CLAUDE_OPUS_MODEL to
// deploy that family (empty = skip). Each family gets its own capacity var.
// If all three family vars are empty, falls back to legacy CLAUDE_MODEL_NAME
// single-deployment behavior.
// ============================================================================
targetScope = 'subscription'

@description('azd environment name. Used for resource group + tagging.')
param environmentName string

@description('Azure region. Claude in Foundry: eastus2 or swedencentral (or westus2 for opus).')
@description('Azure region. All three families coexist in eastus2 or swedencentral.')
@allowed([
'eastus2'
'swedencentral'
Expand All @@ -22,25 +28,38 @@ param location string
@description('Object id of the deploying user/SP. Empty disables RBAC.')
param principalId string = ''

@description('Whether to assign Azure AI User / Project Manager to principalId. Set to "true" to enable.')
@description('Whether to assign Foundry User + Foundry Project Manager (formerly Azure AI User / Project Manager) to principalId. Set to "true" to enable.')
param assignRbac string = 'false'

@description('Short prefix for resource names.')
param baseName string = 'claude'

@allowed([
'claude-haiku-4-5'
'claude-sonnet-4-5'
'claude-sonnet-4-6'
'claude-opus-4-1'
'claude-opus-4-5'
'claude-opus-4-6'
'claude-opus-4-7'
])
param modelName string = 'claude-sonnet-4-6'
// --- Per-family model selection (preferred) ---------------------------------
@description('Haiku family model id. Empty = do not deploy haiku.')
param haikuModel string = ''
@description('Sonnet family model id. Empty = do not deploy sonnet.')
param sonnetModel string = ''
@description('Opus family model id. Empty = do not deploy opus.')
param opusModel string = ''

@description('Haiku deployment capacity (TPM / 1000). Default 25 is a low-risk value that fits most subscriptions; raise via `azd env set CLAUDE_HAIKU_CAPACITY <n>` when quota allows.')
param haikuCapacity int = 25
@description('Sonnet deployment capacity (TPM / 1000). Default 25 is a low-risk value that fits most subscriptions; raise via `azd env set CLAUDE_SONNET_CAPACITY <n>` when quota allows.')
param sonnetCapacity int = 25
@description('Opus deployment capacity (TPM / 1000). Default 25 is a low-risk value that fits most subscriptions; raise via `azd env set CLAUDE_OPUS_CAPACITY <n>` when quota allows.')
param opusCapacity int = 25

@description('Model version for each family deployment.')
param modelVersion string = '1'
param modelCapacity int = 50

// --- Legacy single-model fallback -------------------------------------------
// Only used when none of haikuModel / sonnetModel / opusModel are set.
@description('Legacy single-model name. Ignored when any of the per-family vars are set.')
param modelName string = 'claude-sonnet-4-6'
@description('Legacy single-model capacity. Ignored when any of the per-family vars are set.')
param modelCapacity int = 25

// --- modelProviderData ------------------------------------------------------
@description('Organization name surfaced via modelProviderData.')
param claudeOrganizationName string
@description('Two-letter ISO country code.')
Expand All @@ -67,7 +86,21 @@ var tags = {
var suffix = take(uniqueString(subscription().id, environmentName), 8)
var accountName = '${baseName}-foundry-${suffix}'
var projectName = '${baseName}-proj-${suffix}'
var deploymentName = '${modelName}-${take(suffix, 6)}'

// Resolve effective per-family models. If no family vars are set, route the
// legacy modelName into its matching slot for back-compat.
var anyFamilySet = !empty(haikuModel) || !empty(sonnetModel) || !empty(opusModel)
var legacyLower = toLower(modelName)
var legacyIsHaiku = contains(legacyLower, 'haiku')
var legacyIsSonnet = contains(legacyLower, 'sonnet')
var legacyIsOpus = contains(legacyLower, 'opus')

var effectiveHaikuModel = anyFamilySet ? haikuModel : (legacyIsHaiku ? modelName : '')
var effectiveSonnetModel = anyFamilySet ? sonnetModel : (legacyIsSonnet ? modelName : '')
var effectiveOpusModel = anyFamilySet ? opusModel : (legacyIsOpus ? modelName : '')
var effectiveHaikuCapacity = anyFamilySet ? haikuCapacity : modelCapacity
var effectiveSonnetCapacity = anyFamilySet ? sonnetCapacity : modelCapacity
var effectiveOpusCapacity = anyFamilySet ? opusCapacity : modelCapacity

resource rg 'Microsoft.Resources/resourceGroups@2024-03-01' = {
name: 'rg-${environmentName}'
Expand All @@ -83,10 +116,14 @@ module foundry 'foundry.bicep' = {
tags: tags
accountName: accountName
projectName: projectName
deploymentName: deploymentName
modelName: modelName
suffix: suffix
haikuModel: effectiveHaikuModel
sonnetModel: effectiveSonnetModel
opusModel: effectiveOpusModel
haikuCapacity: effectiveHaikuCapacity
sonnetCapacity: effectiveSonnetCapacity
opusCapacity: effectiveOpusCapacity
modelVersion: modelVersion
modelCapacity: modelCapacity
claudeOrganizationName: claudeOrganizationName
claudeCountryCode: claudeCountryCode
claudeIndustry: claudeIndustry
Expand All @@ -97,7 +134,15 @@ module foundry 'foundry.bicep' = {

output CLAUDE_BASE_URL string = foundry.outputs.claudeBaseUrl
output FOUNDRY_PROJECT_ENDPOINT string = foundry.outputs.foundryProjectEndpoint
output CLAUDE_DEPLOYMENT_NAME string = foundry.outputs.claudeDeploymentName
output FOUNDRY_ACCOUNT_NAME string = foundry.outputs.foundryAccountName
output AZURE_RESOURCE_GROUP string = rg.name
output AZURE_LOCATION string = location

// Per-family deployment names. Empty string when that family wasn't deployed.
output CLAUDE_HAIKU_DEPLOYMENT_NAME string = foundry.outputs.haikuDeploymentName
output CLAUDE_SONNET_DEPLOYMENT_NAME string = foundry.outputs.sonnetDeploymentName
output CLAUDE_OPUS_DEPLOYMENT_NAME string = foundry.outputs.opusDeploymentName

// Legacy single-deployment-name output. Set to the first non-empty family
// deployment so older configure-claude-code scripts continue to work.
output CLAUDE_DEPLOYMENT_NAME string = !empty(foundry.outputs.sonnetDeploymentName) ? foundry.outputs.sonnetDeploymentName : (!empty(foundry.outputs.opusDeploymentName) ? foundry.outputs.opusDeploymentName : foundry.outputs.haikuDeploymentName)
6 changes: 6 additions & 0 deletions infra-bicep/infra/main.parameters.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
"principalId": { "value": "${AZURE_PRINCIPAL_ID=}" },
"assignRbac": { "value": "${ASSIGN_RBAC=false}" },
"baseName": { "value": "${AZURE_BASE_NAME=claude}" },
"haikuModel": { "value": "${CLAUDE_HAIKU_MODEL=}" },
"sonnetModel": { "value": "${CLAUDE_SONNET_MODEL=}" },
"opusModel": { "value": "${CLAUDE_OPUS_MODEL=}" },
"haikuCapacity": { "value": "${CLAUDE_HAIKU_CAPACITY=50}" },
"sonnetCapacity": { "value": "${CLAUDE_SONNET_CAPACITY=50}" },
"opusCapacity": { "value": "${CLAUDE_OPUS_CAPACITY=50}" },
"modelName": { "value": "${CLAUDE_MODEL_NAME=claude-sonnet-4-6}" },
"modelVersion": { "value": "${CLAUDE_MODEL_VERSION=1}" },
"modelCapacity": { "value": "${CLAUDE_MODEL_CAPACITY=50}" },
Expand Down
Loading