Claude on Amazon Bedrockの利用コストに蓋をするCloudFormationテンプレートを作ってみた

こんにちは。SCSK渡辺(大)です。

Claude on Amazon Bedrockの利用コストに蓋をするCloudFormationテンプレートを作ってみました。

CloudFormationテンプレートたった1つで、ユーザー/ロール(チーム)単位のコスト監視・通知・自動遮断・セルフサービスダッシュボードが全部動くようにしました。HTMLファイルの別途配置も不要です。

検証向けです。本番向けの場合にはLiteLLM Proxyなど別の方法をご検討ください。

 

誰に嬉しいのか

「BedrockでClaude Code / Claude Desktopを検証開始したいが、コスト青天井が怖くて上司の許可が下りない」という方向けです。

上司への説明例:

  • ユーザーごと、またはチームのロールごとに月額上限を設定し、超過したら自動的にAPI利用を遮断します
  • 検知から遮断まで最大16分の遅延がありますが、暴走を放置するリスクと比較して十分実用的です
  • 日次でコストレポートがメールで届くため、利用状況を常に把握できます
  • 全てCloudFormation管理のため、不要になればスタック削除で即撤去できます(一部手動で撤去)
  • このシステム自体の運用コストは小規模利用であれば月額数ドル程度です

向かない人:

  • 50名超の大規模チーム → LiteLLMやAWS公式GenAI Gateway等のプロキシ方式を推奨
  • 厳密なコスト上限保証が必要 → 最大16分の遅延があるため、1円単位の厳密な上限制御には不向き
  • IAM Identity Center(SSO)で直接Bedrockを利用しており、自動遮断まで必要 → Protected roleの制約で遮断不可

 

このシステムで何ができるようになるか

デプロイ後すぐに得られる機能:

  • 💰 ユーザー/チーム(ロール)単位のコスト可視化 — 誰が・どのチームがいくら使っているか、日次メールで自動把握
  • 🚫 月額上限の自動遮断 — 設定額を超えたら自動検知し、IAM DenyPolicyで即座にブロック(※検知までの遅延は後述)
  • 📊 リアルタイム運用ダッシュボード — モデル別の呼び出し回数・トークン量・レイテンシ・スロットリングを一画面で把握
  • 🔔 スロットリング即時アラート — TPMクォータ超過でリクエストが弾かれたら即メール通知
  • 🔄 月初自動リセット — 遮断されたユーザーも月初1日0:00(JST)に自動解除、運用負荷ゼロ
  • 🔐 S3ログ保管(オプション) — KMS暗号化+閲覧者制限付きでBedrockログをS3に長期保管
  • 📋 セルフサービスダッシュボード — 利用者が自分のコストをCognito認証で確認可能

検知遅延について

コスト超過の検知には最大約16分の遅延があります:

  • CloudWatch Logsへのログ配信遅延: 5〜15分
  • Lambda cap_check の実行間隔: 1分

このため、上限を超えてから遮断までの間に追加の利用が発生する可能性があります。
厳密な上限保証ではなく、急増を抑えるためのガードレールとして位置づけてください。

 

アーキテクチャ

主要なリソースのみ記載しています。

 

対応する利用形態

対応ツール

ツール コスト集計 備考
Claude Code(CLI) requestMetadata.source で識別
Claude Desktop(Chat / Cowork / Code)
※Chatは6月24時点でベータ版
requestMetadata.source で識別。
Desktop内のChat/Cowork/Codeの区別は不可(全て同じsource値)

対応するIAM認証方式

認証方式 コスト集計 上限遮断 備考
aws login
(セッションベース)
✅ ユーザー単位 推奨。一時クレデンシャルで安全
IAMアクセスキー
(長期クレデンシャル)
✅ ユーザー単位 環境変数または~/.aws/credentials
IAMロール(AssumeRole) ✅ ロール単位 チーム共有ロールパターンを想定
IAM Identity Center
(SSO)
✅(想定) ❌ 非対応(想定) 未検証
集計は可能と想定(identity.arnにSSOロール名が入る前提)。遮断はProtected roleのためDenyPolicy不可と想定。カスタムロールを作成し、そこにAssumeRoleして利用する構成であれば対応できる想定。
Bedrock APIキー ✅ ユーザー単位(想定) ✅(想定) 未検証
IAMユーザーに紐づくため動作する想定

 

運用コスト

このシステム自体の月額コスト:$2未満/月(約300円) ※小規模の場合

リソース 内訳 月額目安
Lambda 128MB / 120s timeout / 約1,441回/日(1分間隔×1440 + 日次1回) $0.50〜1.00
DynamoDB オンデマンドモード。書き込み:ログイベントごと。読み取り:毎分+日次 〜$0.25
SNS (Email) 無料枠内 $0
CloudWatch Logs Bedrock呼び出しログ保持。取り込み量に依存 〜$0.50/GB
CloudWatch Dashboard 最初の3つまで無料 $0
CloudWatch Alarm 標準メトリクス1個 $0.10

S3ログ保管を有効にした場合の追加コスト:

リソース 内訳 月額目安
S3 ストレージ ログ量に依存(1呼び出し≒0.5KB) 〜$0.025/GB
KMS CMKキー1個 + API呼び出し $1.00 + $0.03/10,000リクエスト

 

なぜこの構成なのか — 他のコスト管理アプローチを選ばなかった理由

本テンプレートはCloudFormationコンソールにYAMLファイルを1つアップロードするだけ。
プロキシサーバー不要、コンテナ不要。
5〜20名規模の検証・導入フェーズに最適です。
大規模チーム(50名超)にはLiteLLM等への移行を推奨します。

アプローチ 概要 不採用の理由
LiteLLM Proxy 全リクエストをプロキシ経由にし、ユーザー/チーム別の予算上限・ルーティング・ログを一元管理 EC2/ECS + RDS のインフラ構築が必要。運用コスト月$50〜。本格運用には強力だが「まず試す」には重い
AWS Multi-Provider GenAI Gateway AWSが提供するLiteLLMベースのリファレンスアーキテクチャ プロダクション向けの本格構成。CloudFormation数十スタック構成で、初期セットアップに半日〜1日かかる
Guidance for Claude Code with Amazon Bedrock OpenTelemetry Collector + ECS Fargate + CloudWatch/S3 開発生産性メトリクスまで取れるが、Cognito + ALB + ECS の構築が必要。大規模チーム向け
AWS Budgets アカウント全体の月額予算に対するアラート ユーザー/ロール単位の粒度がない。超過時の自動遮断もできない

 

チーム利用パターン

チームごとにBedrock利用ロールを作成し、メンバーにAssumeRoleさせるパターンです。

 

運用シーケンス

参考です。

利用者フロー

管理者フロー

 

セルフサービスダッシュボード

利用者が自分のClaude利用コストをブラウザから確認できるWebダッシュボードです。Cognito認証で保護されており、テンプレートデプロイ時に自動構築されます。

 

CloudWatch ダッシュボード

# パネル名 表示内容 異常時のアクション
1 テキストヘッダー アカウントID・リージョン・概要
2 呼び出し回数+エラー Invocations + ClientErrors Errors急増 → リクエスト形式やパラメータに問題がある可能性。400系エラーはBedrock側ではなくアプリ側の不備
3 トークン量 Input/Output/CacheRead/CacheWrite Output急増 → 特定ユーザーが大量生成している可能性が高い。出力トークンは入力の3〜5倍のコストがかかるため放置するとコスト急増
4 モデル別呼び出し回数 Opus/Sonnet/Haiku比率 Opus偏重 → Opusは Sonnetの5倍の単価。同等品質でSonnetで足りるタスクならSonnet/Haiku切替でコスト大幅削減
5 モデル別出力トークン 各モデルの出力トークン比率 特定モデル突出 → コスト急増の予兆。CostLimitUsdの見直しが必要になる前兆
6 レイテンシ p99 99パーセンタイル応答時間 悪化 → Bedrock側のキャパシティ逼迫。利用者体験が悪化するためクロスリージョン推論でリクエストを分散
7 TTFT 平均 Time To First Token 悪化 → リクエストがキューで待たされている。TPMクォータに余裕がないためService Quotas引き上げで解消
8 TPMクォータ消費 Tokens Per Minute使用量 上限接近 → このまま増えるとスロットリング(リクエスト拒否)が発生する。事前にService Quotas引き上げ申請
9 スロットリング リクエスト拒否回数 1件でも発生 → 利用者のリクエストが実際に失敗している。即座にクォータ引き上げまたはリクエスト分散が必要

 

デプロイ方法

必要なファイル

CloudFormationテンプレート 1ファイルのみ。

そのままCloudFormationにアップロードすれば動きます。

セルフサービスダッシュボードのHTML(index.html / callback.html)はテンプレート内にインライン埋め込みされており、スタック作成時にカスタムリソースが自動でS3にアップロードします。Cognito設定値(ClientId / Domain / API URL)もCloudFormationの変数展開で自動設定されるため、手動でのファイル配置や書き換えは一切不要です。

ブログ投稿のためHTMLエンティティに変換しています。
コピー後にそのままファイルとして保存すれば動作する想定です。
bedrock-cost-guard.yaml(クリックで展開)

AWSTemplateFormatVersion: '2010-09-09'
Description: >-
  Bedrock Cost Guard - Amazon Bedrock (Claude) per-user/role cost monitoring,
  daily report, monthly budget cap with automatic Deny policy, KMS-encrypted
  metadata-only invocation logging, per-user cost dashboard, and DynamoDB
  aggregation for self-service usage visibility.

Parameters:
  EmailAddress:
    Type: String
    Description: 'Email address for daily cost reports and cap alerts (SNS subscription confirmation required)'
  SourceTags:
    Type: String
    Default: 'claude-code,claude-desktop'
    Description: 'Bedrock requestMetadata.source tags to track (comma-separated, e.g. claude-code,claude-desktop)'
  CostLimitUsd:
    Type: String
    Default: '0'
    Description: 'Per-identity monthly cost cap (format: name:USD,name:USD,...). 0 = no cap.'
  ExcludeUsers:
    Type: String
    Default: ''
    Description: Users/roles to exclude from cost cap (comma-separated)
  LogGroupName:
    Type: String
    Default: '/aws/bedrock/cost-guard'
    Description: CloudWatch Logs group name for invocation logging
  ResourcePrefix:
    Type: String
    Default: 'bedrock-cost-guard'
    Description: Prefix for resource names
  CostTag:
    Type: String
    Default: ''
    Description: 'Cost allocation tag value applied to all resources (for cost tracking)'
  LogReaderArns:
    Type: String
    Default: ''
    Description: 'IAM ARNs allowed to decrypt logs (comma-separated). Empty = no restriction.'
  LogRetentionDays:
    Type: Number
    Default: 365
    Description: Log retention (days)
  EnableS3Logging:
    Type: String
    Default: 'false'
    AllowedValues: ['true', 'false']
    Description: 'Also send Bedrock invocation logs to S3 (CWL is always enabled)'
  EnableLogEncryption:
    Type: String
    Default: 'false'
    AllowedValues: ['true', 'false']
    Description: 'Enable KMS encryption for S3 log bucket (only when S3 logging is enabled)'
  LogTextData:
    Type: String
    Default: 'false'
    AllowedValues: ['true', 'false']
    Description: 'Include text input/output body in logs'
  LogImageData:
    Type: String
    Default: 'false'
    AllowedValues: ['true', 'false']
    Description: 'Include image data in logs'
  LogVideoData:
    Type: String
    Default: 'false'
    AllowedValues: ['true', 'false']
    Description: 'Include video data in logs'
  LogEmbeddingData:
    Type: String
    Default: 'false'
    AllowedValues: ['true', 'false']
    Description: 'Include embedding data in logs'
  LogAudioData:
    Type: String
    Default: 'false'
    AllowedValues: ['true', 'false']
    Description: 'Include audio data in logs'
  CapCheckIntervalMinutes:
    Type: Number
    Default: 1
    Description: Cap check interval (minutes)
  WarningThresholdPercent:
    Type: Number
    Default: 80
    Description: Warning threshold (%)
  DashboardDomainPrefix:
    Type: String
    Default: 'bedrock-cost-guard'
    Description: 'Cognito domain prefix for self-service dashboard (must be globally unique across all AWS accounts)'
  AllowedCidrs:
    Type: String
    Default: '0.0.0.0/0'
    Description: 'Allowed CIDRs for self-service dashboard access (comma-separated). 0.0.0.0/0 = no IP restriction (Cognito auth only).'
  JpyRate:
    Type: Number
    Default: 160
    Description: USD to JPY conversion rate for reports
  ModelPricing:
    Type: String
    Default: 'sonnet:3.0/15.0/3.75/0.30,opus:5.0/25.0/6.25/0.50,haiku:1.0/5.0/1.25/0.10'
    Description: 'Model pricing per 1M tokens (format: tier:input/output/cache_write/cache_read, comma-separated)'
  ExistingLogGroupName:
    Type: String
    Default: ''
    Description: 'Existing CWL log group name. Empty = create new.'
  ExistingCostTableName:
    Type: String
    Default: ''
    Description: 'Existing DynamoDB table name. Empty = create new.'
  ExistingSnsTopicArn:
    Type: String
    Default: ''
    Description: 'Existing SNS topic ARN. Empty = create new.'
  ExistingLogBucketName:
    Type: String
    Default: ''
    Description: 'Existing S3 bucket for logs. Empty = create new (when S3 logging enabled).'
  ExistingDashboardBucketName:
    Type: String
    Default: ''
    Description: 'Existing S3 bucket for dashboard. Empty = create new.'
  ReportScheduleHourJst:
    Type: Number
    Default: 9
    Description: 'Hour (JST, 0-23) to send the daily cost report email'
  ReportScheduleMinuteJst:
    Type: Number
    Default: 0
    Description: 'Minute (0-59) to send the daily cost report email'

Conditions:
  HasLogReaderRestriction:
    !Not [!Equals [!Ref LogReaderArns, '']]
  UseS3:
    !Equals [!Ref EnableS3Logging, 'true']
  UseKms:
    !And
      - !Equals [!Ref EnableS3Logging, 'true']
      - !Equals [!Ref EnableLogEncryption, 'true']
  UseKmsWithReaderRestriction:
    !And
      - !Condition UseKms
      - !Condition HasLogReaderRestriction
  CreateLogGroup:
    !Equals [!Ref ExistingLogGroupName, '']
  CreateCostTable:
    !Equals [!Ref ExistingCostTableName, '']
  CreateSnsTopic:
    !Equals [!Ref ExistingSnsTopicArn, '']
  CreateLogBucket:
    !And
      - !Condition UseS3
      - !Equals [!Ref ExistingLogBucketName, '']
  CreateDashboardBucket:
    !Equals [!Ref ExistingDashboardBucketName, '']

Resources:

  # ============================================================
  # 1. KMS Key for S3 log encryption (created only when S3+KMS enabled)
  # ============================================================
  LogsKmsKey:
    Type: AWS::KMS::Key
    Condition: UseKms
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      Description: !Sub 'CMK for ${ResourcePrefix} Bedrock invocation log S3 encryption'
      EnableKeyRotation: true
      KeyPolicy:
        Version: '2012-10-17'
        Statement:
          - Sid: EnableRootAccount
            Effect: Allow
            Principal:
              AWS: !Sub 'arn:aws:iam::${AWS::AccountId}:root'
            Action: 'kms:*'
            Resource: '*'
          - Sid: AllowBedrockServiceWrite
            Effect: Allow
            Principal:
              Service: bedrock.amazonaws.com
            Action:
              - kms:GenerateDataKey
            Resource: '*'
            Condition:
              StringEquals:
                'aws:SourceAccount': !Ref 'AWS::AccountId'
              ArnLike:
                'aws:SourceArn': !Sub 'arn:aws:bedrock:${AWS::Region}:${AWS::AccountId}:*'
          - !If
            - HasLogReaderRestriction
            - Sid: AllowLogReaderDecrypt
              Effect: Allow
              Principal:
                AWS: !Split [',', !Ref LogReaderArns]
              Action:
                - kms:Decrypt
                - kms:DescribeKey
              Resource: '*'
            - !Ref 'AWS::NoValue'
          - !If
            - UseKmsWithReaderRestriction
            - Sid: DenyDecryptExceptAllowedPrincipals
              Effect: Deny
              Principal:
                AWS: '*'
              Action:
                - kms:Decrypt
              Resource: '*'
              Condition:
                StringNotLike:
                  'aws:PrincipalArn': !Split [',', !Ref LogReaderArns]
                ArnNotLike:
                  'aws:PrincipalArn':
                    - !Sub 'arn:aws:iam::${AWS::AccountId}:role/${ResourcePrefix}-lambda-role'
                    - !Sub 'arn:aws:iam::${AWS::AccountId}:root'
            - !Ref 'AWS::NoValue'
      Tags:
        - Key: Cost
          Value: !Ref CostTag

  LogsKmsAlias:
    Type: AWS::KMS::Alias
    Condition: UseKms
    Properties:
      AliasName: !Sub 'alias/${ResourcePrefix}-logs'
      TargetKeyId: !Ref LogsKmsKey

  # ============================================================
  # 1b. S3 Bucket for Bedrock invocation logs (created only when S3 destination selected)
  # ============================================================
  LogBucket:
    Type: AWS::S3::Bucket
    Condition: CreateLogBucket
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      BucketName: !Sub '${ResourcePrefix}-logs-${AWS::AccountId}'
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: !If [UseKms, 'aws:kms', AES256]
              KMSMasterKeyID: !If [UseKms, !GetAtt LogsKmsKey.Arn, !Ref 'AWS::NoValue']
            BucketKeyEnabled: !If [UseKms, true, !Ref 'AWS::NoValue']
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      LifecycleConfiguration:
        Rules:
          - Id: ExpireLogs
            Status: Enabled
            ExpirationInDays: !Ref LogRetentionDays
      Tags:
        - Key: Cost
          Value: !Ref CostTag

  # ============================================================
  # 2. Log Group for Bedrock Model Invocation Logging (no KMS, retained)
  # ============================================================
  BedrockInvocationLogGroup:
    Type: AWS::Logs::LogGroup
    Condition: CreateLogGroup
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      LogGroupName: !Ref LogGroupName
      RetentionInDays: !Ref LogRetentionDays
      Tags:
        - Key: Cost
          Value: !Ref CostTag

  # 3. IAM Role for Bedrock to write to CloudWatch Logs
  BedrockLoggingRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${ResourcePrefix}-logging-role'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: bedrock.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: WriteToLogGroup
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:${LogGroupName}:*'
      Tags:
        - Key: Cost
          Value: !Ref CostTag

  # 3b. Custom Resource to configure Bedrock Model Invocation Logging
  BedrockLoggingConfig:
    Type: AWS::CloudFormation::CustomResource
    DependsOn:
      - SubscriptionFilterPermission
    Properties:
      ServiceToken: !GetAtt CostGuardFunction.Arn
      ServiceTimeout: 120
      Mode: configure_logging
      LogDestination: !If [UseS3, 'Both', 'CWL']
      LogGroupName: !If [CreateLogGroup, !Ref LogGroupName, !Ref ExistingLogGroupName]
      LoggingRoleArn: !GetAtt BedrockLoggingRole.Arn
      S3BucketName: !If [UseS3, !If [CreateLogBucket, !Ref LogBucket, !Ref ExistingLogBucketName], '']
      TextDataDeliveryEnabled: !Ref LogTextData
      ImageDataDeliveryEnabled: !Ref LogImageData
      VideoDataDeliveryEnabled: !Ref LogVideoData
      EmbeddingDataDeliveryEnabled: !Ref LogEmbeddingData
      AudioDataDeliveryEnabled: !Ref LogAudioData
      SubscriptionFilterLogGroup: !If [CreateLogGroup, !Ref LogGroupName, !Ref ExistingLogGroupName]
      SubscriptionFilterDestinationArn: !GetAtt CostGuardFunction.Arn
      SubscriptionFilterName: !Sub '${ResourcePrefix}-subscription'

  # ============================================================
  # 4. DynamoDB table for per-user monthly cost (updated every minute)
  # ============================================================
  CostTable:
    Type: AWS::DynamoDB::Table
    Condition: CreateCostTable
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: identityKey
          AttributeType: S
        - AttributeName: period
          AttributeType: S
      KeySchema:
        - AttributeName: identityKey
          KeyType: HASH
        - AttributeName: period
          KeyType: RANGE
      PointInTimeRecoverySpecification:
        PointInTimeRecoveryEnabled: true
      TimeToLiveSpecification:
        AttributeName: ttl
        Enabled: true
      SSESpecification:
        SSEEnabled: true
      Tags:
        - Key: Cost
          Value: !Ref CostTag

  # 5. SNS Topic
  CostReportTopic:
    Type: AWS::SNS::Topic
    Condition: CreateSnsTopic
    Properties:
      TopicName: !Ref ResourcePrefix
      Tags:
        - Key: Cost
          Value: !Ref CostTag

  # 6. SNS Subscription
  CostReportSubscription:
    Type: AWS::SNS::Subscription
    Condition: CreateSnsTopic
    Properties:
      TopicArn: !Ref CostReportTopic
      Protocol: email
      Endpoint: !Ref EmailAddress

  # ============================================================
  # 7. Lambda Execution Role
  # ============================================================
  ReportLambdaRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${ResourcePrefix}-lambda-role'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: CostGuardPermissions
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - bedrock:PutModelInvocationLoggingConfiguration
                  - bedrock:DeleteModelInvocationLoggingConfiguration
                  - bedrock:GetModelInvocationLoggingConfiguration
                Resource: '*'
              - Effect: Allow
                Action:
                  - logs:PutSubscriptionFilter
                  - logs:DeleteSubscriptionFilter
                Resource: !Sub
                  - 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:${LogGroup}:*'
                  - LogGroup: !If [CreateLogGroup, !Ref LogGroupName, !Ref ExistingLogGroupName]
              - !If
                - UseS3
                - Effect: Allow
                  Action:
                    - s3:GetBucketPolicy
                    - s3:PutBucketPolicy
                  Resource: !Sub
                    - 'arn:aws:s3:::${Bucket}'
                    - Bucket: !If [CreateLogBucket, !Ref LogBucket, !Ref ExistingLogBucketName]
                - !Ref 'AWS::NoValue'
              - Effect: Allow
                Action:
                  - iam:PassRole
                Resource: !Sub 'arn:aws:iam::${AWS::AccountId}:role/${ResourcePrefix}-logging-role'
              - Effect: Allow
                Action:
                  - dynamodb:PutItem
                  - dynamodb:UpdateItem
                  - dynamodb:GetItem
                  - dynamodb:Query
                  - dynamodb:Scan
                Resource: !If [CreateCostTable, !GetAtt CostTable.Arn, !Sub 'arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${ExistingCostTableName}']
              - Effect: Allow
                Action:
                  - sns:Publish
                Resource: !If [CreateSnsTopic, !Ref CostReportTopic, !Ref ExistingSnsTopicArn]
              - Effect: Allow
                Action:
                  - iam:AttachUserPolicy
                  - iam:DetachUserPolicy
                Resource: !Sub 'arn:aws:iam::${AWS::AccountId}:user/*'
                Condition:
                  ArnEquals:
                    iam:PolicyArn: !Ref BedrockCostLimitDenyPolicy
              - Effect: Allow
                Action:
                  - iam:ListAttachedUserPolicies
                Resource: !Sub 'arn:aws:iam::${AWS::AccountId}:user/*'
              - Effect: Allow
                Action:
                  - iam:AttachRolePolicy
                  - iam:DetachRolePolicy
                Resource: !Sub 'arn:aws:iam::${AWS::AccountId}:role/*'
                Condition:
                  ArnEquals:
                    iam:PolicyArn: !Ref BedrockCostLimitDenyPolicy
              - Effect: Allow
                Action:
                  - iam:ListAttachedRolePolicies
                Resource: !Sub 'arn:aws:iam::${AWS::AccountId}:role/*'
              - Effect: Allow
                Action:
                  - iam:ListEntitiesForPolicy
                Resource: !Ref BedrockCostLimitDenyPolicy
      Tags:
        - Key: Cost
          Value: !Ref CostTag

  # ============================================================
  # 8. Lambda Function
  # ============================================================
  CostGuardFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Ref ResourcePrefix
      Runtime: python3.12
      Handler: index.handler
      Timeout: 120
      MemorySize: 128
      ReservedConcurrentExecutions: 1
      Role: !GetAtt ReportLambdaRole.Arn
      Environment:
        Variables:
          LOG_GROUP_NAME: !Ref LogGroupName
          SNS_TOPIC_ARN: !If [CreateSnsTopic, !Ref CostReportTopic, !Ref ExistingSnsTopicArn]
          SOURCE_TAGS: !Ref SourceTags
          COST_LIMIT_USD: !Ref CostLimitUsd
          EXCLUDE_USERS: !Ref ExcludeUsers
          DENY_POLICY_ARN: !Ref BedrockCostLimitDenyPolicy
          COST_TABLE_NAME: !If [CreateCostTable, !Ref CostTable, !Ref ExistingCostTableName]
          WARNING_THRESHOLD_PERCENT: !Ref WarningThresholdPercent
          CAP_CHECK_INTERVAL_MINUTES: !Ref CapCheckIntervalMinutes
          JPY_RATE: !Ref JpyRate
          MODEL_PRICING: !Ref ModelPricing
      Tags:
        - Key: Cost
          Value: !Ref CostTag
      Code:
        ZipFile: |
          import os
          import time
          import json
          import base64
          import gzip
          import boto3
          import cfnresponse
          from datetime import datetime, timedelta, timezone

          JST = timezone(timedelta(hours=9))

          def parse_pricing(raw):
              pricing = {}
              for entry in raw.split(','):
                  tier, rates = entry.split(':')
                  parts = rates.split('/')
                  pricing[tier.strip()] = {'in': float(parts[0]), 'out': float(parts[1]), 'cache_write': float(parts[2]), 'cache_read': float(parts[3])}
              return pricing

          PRICING = parse_pricing(os.environ.get('MODEL_PRICING', 'sonnet:3.0/15.0/3.75/0.30,opus:5.0/25.0/6.25/0.50,haiku:1.0/5.0/1.25/0.10'))

          def get_model_tier(model_id):
              m = model_id.lower()
              if 'opus' in m: return 'opus'
              if 'haiku' in m: return 'haiku'
              return 'sonnet'

          def calc_cost(total_in, total_out, cache_write, cache_read, tier):
              p = PRICING.get(tier, PRICING['sonnet'])
              return (total_in / 1_000_000 * p['in']) + (total_out / 1_000_000 * p['out']) + (cache_write / 1_000_000 * p['cache_write']) + (cache_read / 1_000_000 * p['cache_read'])

          def identity_from_arn(arn):
              parts = arn.split('/')
              if ':user/' in arn:
                  return ('user', parts[-1])
              elif ':assumed-role/' in arn and len(parts) >= 2:
                  return ('role', parts[1] if len(parts) >= 3 else parts[-1])
              return ('unknown', parts[-1] if parts else arn)

          def parse_limits(raw):
              limits = {}
              if raw and raw != '0':
                  for entry in raw.split(','):
                      entry = entry.strip()
                      if ':' in entry:
                          name, amt = entry.rsplit(':', 1)
                          try:
                              limits[name.strip()] = float(amt.strip())
                          except ValueError:
                              pass
              return limits

          def handle_subscription_event(event, context):
              """Process log events from CWL Subscription Filter."""
              ddb = boto3.client('dynamodb')
              table = os.environ['COST_TABLE_NAME']
              source_tags = [t.strip() for t in os.environ['SOURCE_TAGS'].split(',')]
              now = datetime.now(JST)
              period = now.strftime('%Y-%m')

              # Decode and decompress the CWL subscription payload
              payload = base64.b64decode(event['awslogs']['data'])
              log_data = json.loads(gzip.decompress(payload))

              if log_data.get('messageType') == 'CONTROL_MESSAGE':
                  return {'statusCode': 200, 'body': 'control'}

              for log_event in log_data.get('logEvents', []):
                  try:
                      record = json.loads(log_event['message'])
                  except (json.JSONDecodeError, KeyError):
                      continue

                  # Filter by source tags
                  source = record.get('requestMetadata', {}).get('source', '')
                  if source not in source_tags:
                      continue

                  request_id = record.get('requestId', '')
                  if not request_id:
                      continue

                  identity_arn = record.get('identity', {}).get('arn', '')
                  model_id = record.get('modelId', '')
                  if 'haiku' in model_id:
                      continue
                  kind, name = identity_from_arn(identity_arn)
                  tier = get_model_tier(model_id)

                  input_tokens = record.get('input', {}).get('inputTokenCount', 0) or 0
                  output_tokens = record.get('output', {}).get('outputTokenCount', 0) or 0
                  cache_read = record.get('input', {}).get('cacheReadInputTokenCount', 0) or 0
                  cache_write = record.get('input', {}).get('cacheWriteInputTokenCount', 0) or 0

                  cost = calc_cost(input_tokens, output_tokens, cache_write, cache_read, tier)
                  if cost <= 0:
                      continue

                  # Determine source-specific cost field
                  source_field = source.replace('-', '_')  # claude-code -> claude_code

                  # Dedup: write requestId as separate item (conditional put fails if exists)
                  try:
                      ddb.put_item(
                          TableName=table,
                          Item={
                              'identityKey': {'S': f'req#{request_id}'},
                              'period': {'S': period},
                              'ttl': {'N': str(int((now + timedelta(hours=72)).timestamp()))},
                          },
                          ConditionExpression='attribute_not_exists(identityKey)',
                      )
                  except ddb.exceptions.ConditionalCheckFailedException:
                      continue  # Already processed
                  except Exception as ex:
                      print(f'[WARN] Dedup write failed for {request_id}: {ex}')
                      continue

                  # Increment cost for this identity
                  model_short = model_id.split('/')[-1] if '/' in model_id else model_id
                  try:
                      ddb.update_item(
                          TableName=table,
                          Key={'identityKey': {'S': f'{kind}:{name}'}, 'period': {'S': period}},
                          UpdateExpression='SET identityName=:n, identityKind=:k, lastUpdated=:t ADD monthlySpendUsd :cost, #src :srcCost, invocationCount :one',
                          ExpressionAttributeNames={'#src': f'{source_field}Usd'},
                          ExpressionAttributeValues={
                              ':cost': {'N': str(round(cost, 6))},
                              ':n': {'S': name},
                              ':k': {'S': kind},
                              ':t': {'S': now.isoformat()},
                              ':srcCost': {'N': str(round(cost, 6))},
                              ':one': {'N': '1'},
                          },
                      )
                  except Exception as ex:
                      print(f'[WARN] DynamoDB update failed for {name}/{request_id}: {ex}')

                  # Increment daily aggregate
                  day_str = now.strftime('%Y-%m-%d')
                  try:
                      ddb.update_item(
                          TableName=table,
                          Key={'identityKey': {'S': f'daily:{day_str}'}, 'period': {'S': period}},
                          UpdateExpression='SET lastUpdated=:t ADD totalUsd :cost, #src :srcCost',
                          ExpressionAttributeNames={'#src': f'{source_field}Usd'},
                          ExpressionAttributeValues={
                              ':cost': {'N': str(round(cost, 6))},
                              ':t': {'S': now.isoformat()},
                              ':srcCost': {'N': str(round(cost, 6))},
                          },
                      )
                  except Exception as ex:
                      print(f'[WARN] Daily aggregate update failed for {day_str}: {ex}')

                  # Increment model aggregate
                  try:
                      ddb.update_item(
                          TableName=table,
                          Key={'identityKey': {'S': f'model:{model_short}'}, 'period': {'S': period}},
                          UpdateExpression='SET lastUpdated=:t ADD totalUsd :cost, invocationCount :one, #src :srcCost',
                          ExpressionAttributeNames={'#src': f'{source_field}Usd'},
                          ExpressionAttributeValues={
                              ':cost': {'N': str(round(cost, 6))},
                              ':t': {'S': now.isoformat()},
                              ':one': {'N': '1'},
                              ':srcCost': {'N': str(round(cost, 6))},
                          },
                      )
                  except Exception as ex:
                      print(f'[WARN] Model aggregate update failed for {model_short}: {ex}')

                  # Increment per-user daily aggregate
                  try:
                      ddb.update_item(
                          TableName=table,
                          Key={'identityKey': {'S': f'ud:{name}:{day_str}'}, 'period': {'S': period}},
                          UpdateExpression='SET lastUpdated=:t, identityName=:n ADD totalUsd :cost, #src :srcCost',
                          ExpressionAttributeNames={'#src': f'{source_field}Usd'},
                          ExpressionAttributeValues={
                              ':cost': {'N': str(round(cost, 6))},
                              ':t': {'S': now.isoformat()},
                              ':n': {'S': name},
                              ':srcCost': {'N': str(round(cost, 6))},
                          },
                      )
                  except Exception as ex:
                      print(f'[WARN] User-daily aggregate update failed for {name}/{day_str}: {ex}')

                  # Increment per-user model aggregate
                  try:
                      ddb.update_item(
                          TableName=table,
                          Key={'identityKey': {'S': f'um:{name}:{model_short}'}, 'period': {'S': period}},
                          UpdateExpression='SET lastUpdated=:t ADD totalUsd :cost, invocationCount :one, #src :srcCost',
                          ExpressionAttributeNames={'#src': f'{source_field}Usd'},
                          ExpressionAttributeValues={
                              ':cost': {'N': str(round(cost, 6))},
                              ':t': {'S': now.isoformat()},
                              ':one': {'N': '1'},
                              ':srcCost': {'N': str(round(cost, 6))},
                          },
                      )
                  except Exception as ex:
                      print(f'[WARN] User-model aggregate update failed: {ex}')

              return {'statusCode': 200, 'body': 'processed'}

          def handle_cap_check(event, context):
              """Check DynamoDB for cap breaches and apply Deny policy."""
              ddb = boto3.client('dynamodb')
              iam = boto3.client('iam')
              sns = boto3.client('sns')
              table = os.environ['COST_TABLE_NAME']
              topic_arn = os.environ['SNS_TOPIC_ARN']
              deny_policy_arn = os.environ['DENY_POLICY_ARN']
              limits = parse_limits(os.environ.get('COST_LIMIT_USD', '0'))
              exclude = [x.strip() for x in os.environ.get('EXCLUDE_USERS', '').split(',') if x.strip()]
              warning_pct = int(os.environ.get('WARNING_THRESHOLD_PERCENT', '80')) / 100.0

              if not limits:
                  return {'statusCode': 200, 'body': 'no_limits'}

              now = datetime.now(JST)
              period = now.strftime('%Y-%m')

              # Monthly reset on day 1: clear all deny policies
              if now.day == 1 and now.hour == 0 and now.minute < int(os.environ.get('CAP_CHECK_INTERVAL_MINUTES', '1')) + 1:
                  try:
                      entities = iam.list_entities_for_policy(PolicyArn=deny_policy_arn)
                      for u in entities.get('PolicyUsers', []):
                          iam.detach_user_policy(UserName=u['UserName'], PolicyArn=deny_policy_arn)
                      for r in entities.get('PolicyRoles', []):
                          iam.detach_role_policy(RoleName=r['RoleName'], PolicyArn=deny_policy_arn)
                  except Exception as ex:
                      print(f'[WARN] Monthly reset failed: {ex}')

              for name, limit in limits.items():
                  if name in exclude:
                      continue

                  # Try both user: and role: prefixes
                  spend = 0.0
                  for kind in ('user', 'role'):
                      try:
                          resp = ddb.get_item(
                              TableName=table,
                              Key={'identityKey': {'S': f'{kind}:{name}'}, 'period': {'S': period}},
                              ProjectionExpression='monthlySpendUsd'
                          )
                          item = resp.get('Item', {})
                          if item:
                              spend = float(item.get('monthlySpendUsd', {}).get('N', '0'))
                              break
                      except Exception:
                          continue

                  # 100% - enforce deny
                  if spend >= limit:
                      try:
                          kind_for_attach = 'user' if ':user/' in name or kind == 'user' else 'role'
                          if kind_for_attach == 'user':
                              attached = iam.list_attached_user_policies(UserName=name)
                              already = any(p['PolicyArn'] == deny_policy_arn for p in attached.get('AttachedPolicies', []))
                              iam.attach_user_policy(UserName=name, PolicyArn=deny_policy_arn)
                          else:
                              attached = iam.list_attached_role_policies(RoleName=name)
                              already = any(p['PolicyArn'] == deny_policy_arn for p in attached.get('AttachedPolicies', []))
                              iam.attach_role_policy(RoleName=name, PolicyArn=deny_policy_arn)
                          if not already:
                              sns.publish(
                                  TopicArn=topic_arn,
                                  Subject=f'[Bedrock Cost Guard] 利用停止: {name}',
                                  Message=f'{name} が月額上限 ${limit} を超過しました (実績: ${spend:.2f})。\n全Claudeモデルへのアクセスを遮断しました。'
                              )
                      except Exception as ex:
                          print(f'[WARN] Failed to attach deny policy to {name}: {ex}')
                  # Warning threshold
                  elif spend >= limit * warning_pct:
                      # Send warning (use DynamoDB to track if already warned this period)
                      try:
                          warn_resp = ddb.update_item(
                              TableName=table,
                              Key={'identityKey': {'S': f'{kind}:{name}'}, 'period': {'S': period}},
                              UpdateExpression='SET warningNotified=:t',
                              ConditionExpression='attribute_not_exists(warningNotified)',
                              ExpressionAttributeValues={':t': {'S': now.isoformat()}},
                              ReturnValues='ALL_NEW'
                          )
                          pct_display = int(spend / limit * 100)
                          sns.publish(
                              TopicArn=topic_arn,
                              Subject=f'[Bedrock Cost Guard] 警告: {name} が上限の{pct_display}%に到達',
                              Message=f'{name} の月額利用が上限の{pct_display}%に達しました。\n\n実績: ${spend:.2f} / 上限: ${limit:.2f} ({pct_display}%)\n\nこのまま利用を続けると上限到達時にBedrock全モデルへのアクセスが自動遮断されます。'
                          )
                      except ddb.exceptions.ConditionalCheckFailedException:
                          pass  # Already warned this period
                      except Exception as ex:
                          print(f'[WARN] Warning notification failed for {name}: {ex}')

              return {'statusCode': 200, 'body': 'cap_check'}

          def handle_report(event, context):
              """Generate daily report from DynamoDB data."""
              ddb = boto3.client('dynamodb')
              sns = boto3.client('sns')
              table = os.environ['COST_TABLE_NAME']
              topic_arn = os.environ['SNS_TOPIC_ARN']
              limits = parse_limits(os.environ.get('COST_LIMIT_USD', '0'))
              account_id = context.invoked_function_arn.split(':')[4]

              now = datetime.now(JST)
              period = now.strftime('%Y-%m')
              JPY = int(os.environ.get('JPY_RATE', '160'))

              # Query all items for this period (exclude dedup records)
              items = []
              last_key = None
              while True:
                  scan_kwargs = {
                      'TableName': table,
                      'FilterExpression': 'period = :p AND NOT begins_with(identityKey, :prefix)',
                      'ExpressionAttributeValues': {':p': {'S': period}, ':prefix': {'S': 'req#'}},
                  }
                  if last_key:
                      scan_kwargs['ExclusiveStartKey'] = last_key
                  resp = ddb.scan(**scan_kwargs)
                  items.extend(resp.get('Items', []))
                  last_key = resp.get('LastEvaluatedKey')
                  if not last_key:
                      break

              # Build report
              mtd_cost = 0.0
              users = {}
              roles = {}
              for item in items:
                  identity_key = item.get('identityKey', {}).get('S', '')
                  kind, name = identity_key.split(':', 1) if ':' in identity_key else ('unknown', identity_key)
                  if kind not in ('user', 'role'):
                      continue
                  spend = float(item.get('monthlySpendUsd', {}).get('N', '0'))
                  code_usd = float(item.get('claude_codeUsd', {}).get('N', '0'))
                  desktop_usd = float(item.get('claude_desktopUsd', {}).get('N', '0'))
                  mtd_cost += spend
                  entry = {'cost': spend, 'claude-code': code_usd, 'claude-desktop': desktop_usd}
                  if kind == 'user':
                      users[name] = entry
                  else:
                      roles[name] = entry

              month_start = now.replace(day=1)
              lines = []
              lines.append(f'[Bedrock Cost Guard] 日次コストレポート {now.strftime("%Y-%m-%d")}')
              lines.append('')
              lines.append(f'AWSアカウント: {account_id}')
              lines.append(f'(1 USD = {JPY} JPY)')
              lines.append('')
              lines.append(f'今月 ({month_start.strftime("%-m/%-d")}-{now.strftime("%-m/%-d")}): ${mtd_cost:.2f} (JPY{int(mtd_cost*JPY):,})')
              lines.append('')
              lines.append('---')
              lines.append('')

              def fmt(items_dict, header):
                  if not items_dict:
                      return
                  lines.append(header)
                  lines.append('')
                  for name, e in sorted(items_dict.items(), key=lambda x: -x[1]['cost']):
                      subtotal = e['cost']
                      limit = limits.get(name, 0)
                      parts = []
                      if e['claude-code'] > 0: parts.append(f'Code ${e["claude-code"]:.2f}')
                      if e['claude-desktop'] > 0: parts.append(f'Desktop ${e["claude-desktop"]:.2f}')
                      limit_str = f' (上限: ${limit:.2f})' if limit > 0 else ''
                      if parts:
                          lines.append(f'  {name}: ${subtotal:.2f} (JPY{int(subtotal*JPY):,}){limit_str}  [{" / ".join(parts)}]')
                      else:
                          lines.append(f'  {name}: ${subtotal:.2f} (JPY{int(subtotal*JPY):,}){limit_str}')
                  lines.append('')

              fmt(users, '[今月のユーザー別コスト]')
              fmt(roles, '[今月のロール別コスト]')

              report = '\n'.join(lines)
              sns.publish(
                  TopicArn=topic_arn,
                  Subject=f'[Bedrock Cost Guard] 日次コストレポート {now.strftime("%Y-%m-%d")}',
                  Message=report
              )
              return {'statusCode': 200, 'body': 'report'}

          def handle_configure_logging(event, context):
              """Custom Resource handler for Bedrock logging configuration."""
              try:
                  request_type = event.get('RequestType', 'Create')
                  props = event.get('ResourceProperties', {})
                  bedrock = boto3.client('bedrock')

                  if request_type == 'Delete':
                      try:
                          bedrock.delete_model_invocation_logging_configuration()
                      except Exception as de:
                          print(f'delete logging config: {de}')
                      sf_log_group = props.get('SubscriptionFilterLogGroup', '')
                      sf_name = props.get('SubscriptionFilterName', '')
                      if sf_log_group and sf_name:
                          try:
                              logs = boto3.client('logs')
                              logs.delete_subscription_filter(logGroupName=sf_log_group, filterName=sf_name)
                          except Exception as de:
                              print(f'delete subscription filter: {de}')
                      cfnresponse.send(event, context, cfnresponse.SUCCESS, {})
                      return

                  dest = props.get('LogDestination', 'CWL')
                  config = {'textDataDeliveryEnabled': props.get('TextDataDeliveryEnabled', 'false') == 'true',
                            'imageDataDeliveryEnabled': props.get('ImageDataDeliveryEnabled', 'false') == 'true',
                            'embeddingDataDeliveryEnabled': props.get('EmbeddingDataDeliveryEnabled', 'false') == 'true',
                            'videoDataDeliveryEnabled': props.get('VideoDataDeliveryEnabled', 'false') == 'true',
                            'audioDataDeliveryEnabled': props.get('AudioDataDeliveryEnabled', 'false') == 'true'}

                  if dest in ('CWL', 'Both'):
                      config['cloudWatchConfig'] = {
                          'logGroupName': props['LogGroupName'],
                          'roleArn': props['LoggingRoleArn']
                      }
                  if dest in ('S3', 'Both'):
                      config['s3Config'] = {
                          'bucketName': props['S3BucketName'],
                          'keyPrefix': ''
                      }

                  for attempt in range(5):
                      try:
                          bedrock.put_model_invocation_logging_configuration(loggingConfig=config)
                          break
                      except Exception as ve:
                          if attempt < 4 and ('permissions for bucket' in str(ve) or 'bucket policy' in str(ve).lower()):
                              import time
                              time.sleep(10)
                          else:
                              raise

                  sf_log_group = props.get('SubscriptionFilterLogGroup', '')
                  sf_dest = props.get('SubscriptionFilterDestinationArn', '')
                  sf_name = props.get('SubscriptionFilterName', '')
                  if sf_log_group and sf_dest and sf_name:
                      logs = boto3.client('logs')
                      import time
                      for attempt in range(6):
                          try:
                              logs.put_subscription_filter(
                                  logGroupName=sf_log_group,
                                  filterName=sf_name,
                                  filterPattern='',
                                  destinationArn=sf_dest
                              )
                              break
                          except Exception as se:
                              if attempt < 5 and ('Could not execute the lambda' in str(se) or 'permission' in str(se).lower()):
                                  time.sleep(10)
                              else:
                                  raise
                  cfnresponse.send(event, context, cfnresponse.SUCCESS, {'Status': 'configured'})
              except Exception as e:
                  print(f'Error: {e}')
                  cfnresponse.send(event, context, cfnresponse.FAILED, {'Error': str(e)})

          def handler(event, context):
              # Subscription Filter events have 'awslogs' key
              if 'awslogs' in event:
                  return handle_subscription_event(event, context)

              # CloudFormation Custom Resource events
              if 'RequestType' in event:
                  if event.get('ResourceProperties', {}).get('Mode') == 'configure_logging':
                      return handle_configure_logging(event, context)
                  # Unknown custom resource - respond SUCCESS to avoid timeout
                  cfnresponse.send(event, context, cfnresponse.SUCCESS, {})
                  return

              # EventBridge Scheduler events
              mode = event.get('mode', 'report') if event else 'report'
              if mode == 'cap_check':
                  return handle_cap_check(event, context)
              else:
                  return handle_report(event, context)

  # ============================================================
  # 9. EventBridge Scheduler Role
  # ============================================================
  SchedulerRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${ResourcePrefix}-scheduler-role'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: scheduler.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: InvokeLambda
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: lambda:InvokeFunction
                Resource: !GetAtt CostGuardFunction.Arn
      Tags:
        - Key: Cost
          Value: !Ref CostTag

  # 10. EventBridge Scheduler (daily JST 0:00 - report)
  DailySchedule:
    Type: AWS::Scheduler::Schedule
    Properties:
      Name: !Sub '${ResourcePrefix}-daily-report'
      ScheduleExpression: !Sub 'cron(${ReportScheduleMinuteJst} ${ReportScheduleHourJst} * * ? *)'
      ScheduleExpressionTimezone: Asia/Tokyo
      FlexibleTimeWindow:
        Mode: 'OFF'
      Target:
        Arn: !GetAtt CostGuardFunction.Arn
        RoleArn: !GetAtt SchedulerRole.Arn
        Input: '{"mode": "report"}'

  # 11. EventBridge Scheduler (cap check)
  CapCheckSchedule:
    Type: AWS::Scheduler::Schedule
    Properties:
      Name: !Sub '${ResourcePrefix}-cap-check'
      ScheduleExpression: !Sub 'rate(${CapCheckIntervalMinutes} minutes)'
      ScheduleExpressionTimezone: Asia/Tokyo
      FlexibleTimeWindow:
        Mode: 'OFF'
      Target:
        Arn: !GetAtt CostGuardFunction.Arn
        RoleArn: !GetAtt SchedulerRole.Arn
        Input: '{"mode": "cap_check"}'

  # 12. Managed Deny Policy for cost cap
  BedrockCostLimitDenyPolicy:
    Type: AWS::IAM::ManagedPolicy
    Properties:
      ManagedPolicyName: !Sub '${ResourcePrefix}-deny'
      Description: Denies all Claude model access when the monthly cost cap is exceeded. Managed by the Cost Guard Lambda.
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Deny
            Action:
              - bedrock:InvokeModel
              - bedrock:InvokeModelWithResponseStream
            Resource:
              - 'arn:aws:bedrock:*::foundation-model/anthropic.claude-*'
              - 'arn:aws:bedrock:*:*:inference-profile/*anthropic.claude-*'

  # ============================================================
  # 13. Subscription Filter (CWL -> Lambda)
  # ============================================================
  SubscriptionFilterPermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref CostGuardFunction
      Action: lambda:InvokeFunction
      Principal: !Sub 'logs.${AWS::Region}.amazonaws.com'
      SourceAccount: !Ref 'AWS::AccountId'
      SourceArn: !Sub
        - 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:${LogGroup}:*'
        - LogGroup: !If [CreateLogGroup, !Ref LogGroupName, !Ref ExistingLogGroupName]

  # ============================================================
  # 14. CloudWatch Dashboard (admin + per-user cost visibility)
  # ============================================================
  CostDashboard:
    Type: AWS::CloudWatch::Dashboard
    Properties:
      DashboardName: !Sub '${ResourcePrefix}-dashboard'
      DashboardBody: !Sub |
        {
          "start": "-P0M",
          "widgets": [
            {
              "type": "text", "x": 0, "y": 0, "width": 24, "height": 2,
              "properties": { "markdown": "# Bedrock Cost Guard\nコスト詳細は[セルフサービスダッシュボード](https://${DashboardDistribution.DomainName}/)を参照。ここではインフラメトリクスのみ表示。" }
            },
            {
              "type": "metric", "x": 0, "y": 2, "width": 12, "height": 6,
              "properties": {
                "title": "呼び出し回数 (全体, 日次)",
                "region": "${AWS::Region}", "stat": "Sum", "period": 86400, "view": "timeSeries",
                "metrics": [
                  ["AWS/Bedrock", "Invocations", {"label": "Invocations"}],
                  ["AWS/Bedrock", "InvocationClientErrors", {"label": "Errors"}]
                ]
              }
            },
            {
              "type": "metric", "x": 12, "y": 2, "width": 12, "height": 6,
              "properties": {
                "title": "トークン量 (全体, 日次)",
                "region": "${AWS::Region}", "stat": "Sum", "period": 86400, "view": "timeSeries",
                "metrics": [
                  ["AWS/Bedrock", "InputTokenCount", {"label": "Input"}],
                  ["AWS/Bedrock", "OutputTokenCount", {"label": "Output"}],
                  ["AWS/Bedrock", "CacheReadInputTokenCount", {"label": "CacheRead"}],
                  ["AWS/Bedrock", "CacheWriteInputTokenCount", {"label": "CacheWrite"}]
                ]
              }
            },
            {
              "type": "metric", "x": 0, "y": 8, "width": 12, "height": 6,
              "properties": {
                "title": "モデル別 呼び出し回数 (日次)",
                "region": "${AWS::Region}", "period": 86400, "view": "timeSeries", "stacked": true,
                "metrics": [
                  [ { "expression": "SEARCH('{AWS/Bedrock,ModelId} MetricName=\"Invocations\" anthropic', 'Sum', 86400)", "id": "e1", "region": "${AWS::Region}" } ]
                ]
              }
            },
            {
              "type": "metric", "x": 12, "y": 8, "width": 12, "height": 6,
              "properties": {
                "title": "モデル別 出力トークン (日次)",
                "region": "${AWS::Region}", "period": 86400, "view": "timeSeries", "stacked": true,
                "metrics": [
                  [ { "expression": "SEARCH('{AWS/Bedrock,ModelId} MetricName=\"OutputTokenCount\" anthropic', 'Sum', 86400)", "id": "e2", "region": "${AWS::Region}" } ]
                ]
              }
            },
            {
              "type": "metric", "x": 0, "y": 14, "width": 8, "height": 6,
              "properties": {
                "title": "レイテンシ p99 (5min)",
                "region": "${AWS::Region}", "period": 300, "view": "timeSeries",
                "metrics": [
                  [ { "expression": "SEARCH('{AWS/Bedrock,ModelId} MetricName=\"InvocationLatency\" anthropic', 'p99', 300)", "id": "e3", "region": "${AWS::Region}" } ]
                ]
              }
            },
            {
              "type": "metric", "x": 8, "y": 14, "width": 8, "height": 6,
              "properties": {
                "title": "TTFT (平均, 5min)",
                "region": "${AWS::Region}", "period": 300, "view": "timeSeries",
                "metrics": [
                  [ { "expression": "SEARCH('{AWS/Bedrock,ModelId} MetricName=\"TimeToFirstToken\" anthropic', 'Average', 300)", "id": "e4", "region": "${AWS::Region}" } ]
                ]
              }
            },
            {
              "type": "metric", "x": 16, "y": 14, "width": 8, "height": 6,
              "properties": {
                "title": "TPMクォータ消費 (1min)",
                "region": "${AWS::Region}", "period": 60, "view": "timeSeries",
                "metrics": [
                  [ { "expression": "SEARCH('{AWS/Bedrock,ModelId} MetricName=\"EstimatedTPMQuotaUsage\" anthropic', 'Sum', 60)", "id": "e5", "region": "${AWS::Region}" } ]
                ]
              }
            },
            {
              "type": "metric", "x": 0, "y": 20, "width": 24, "height": 4,
              "properties": {
                "title": "スロットリング (5min)",
                "region": "${AWS::Region}", "stat": "Sum", "period": 300, "view": "timeSeries",
                "metrics": [
                  ["AWS/Bedrock", "InvocationThrottles", {"label": "Throttles"}]
                ]
              }
            }
          ]
        }

  # ============================================================
  # 15. CloudWatch Alarm (throttling)
  # ============================================================
  ThrottlingAlarm:
    Type: AWS::CloudWatch::Alarm
    Properties:
      AlarmName: !Sub '${ResourcePrefix}-throttling-alarm'
      AlarmDescription: Bedrockでスロットリングが発生(ユーザーのリクエストが拒否されている)。Service Quotasの引き上げを検討してください。
      Namespace: AWS/Bedrock
      MetricName: InvocationThrottles
      Statistic: Sum
      Period: 300
      EvaluationPeriods: 1
      Threshold: 1
      ComparisonOperator: GreaterThanOrEqualToThreshold
      TreatMissingData: notBreaching
      AlarmActions:
        - !If [CreateSnsTopic, !Ref CostReportTopic, !Ref ExistingSnsTopicArn]

  # ============================================================
  # 16. Self-Service Dashboard: Cognito User Pool + App Client
  # ============================================================
  DashboardUserPool:
    Type: AWS::Cognito::UserPool
    Properties:
      UserPoolName: !Sub '${ResourcePrefix}-dashboard-users'
      AutoVerifiedAttributes:
        - email
      UsernameAttributes:
        - email
      Policies:
        PasswordPolicy:
          MinimumLength: 8
          RequireUppercase: true
          RequireLowercase: true
          RequireNumbers: true
          RequireSymbols: false
      Schema:
        - Name: email
          AttributeDataType: String
          Required: true
          Mutable: false
      UserPoolTags:
        Cost: !Ref CostTag

  DashboardUserPoolDomain:
    Type: AWS::Cognito::UserPoolDomain
    Properties:
      Domain: !Ref DashboardDomainPrefix
      UserPoolId: !Ref DashboardUserPool

  DashboardUserPoolClient:
    Type: AWS::Cognito::UserPoolClient
    Properties:
      ClientName: !Sub '${ResourcePrefix}-dashboard-client'
      UserPoolId: !Ref DashboardUserPool
      GenerateSecret: false
      ExplicitAuthFlows:
        - ALLOW_USER_SRP_AUTH
        - ALLOW_REFRESH_TOKEN_AUTH
      SupportedIdentityProviders:
        - COGNITO
      AllowedOAuthFlows:
        - implicit
      AllowedOAuthScopes:
        - openid
        - email
      AllowedOAuthFlowsUserPoolClient: true
      CallbackURLs:
        - !Sub 'https://${DashboardDistribution.DomainName}/callback.html'
      LogoutURLs:
        - !Sub 'https://${DashboardDistribution.DomainName}/'

  # ============================================================
  # 17. Self-Service Dashboard: S3 Bucket (static site)
  # ============================================================
  DashboardBucket:
    Type: AWS::S3::Bucket
    Condition: CreateDashboardBucket
    Properties:
      BucketName: !Sub '${ResourcePrefix}-dashboard-${AWS::AccountId}'
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      Tags:
        - Key: Cost
          Value: !Ref CostTag

  DashboardBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !If [CreateDashboardBucket, !Ref DashboardBucket, !Ref ExistingDashboardBucketName]
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: cloudfront.amazonaws.com
            Action: s3:GetObject
            Resource: !Sub
              - '${BucketArn}/*'
              - BucketArn: !If [CreateDashboardBucket, !GetAtt DashboardBucket.Arn, !Sub 'arn:aws:s3:::${ExistingDashboardBucketName}']
            Condition:
              StringEquals:
                'AWS:SourceArn': !Sub 'arn:aws:cloudfront::${AWS::AccountId}:distribution/${DashboardDistribution}'

  # ============================================================
  # 18. Self-Service Dashboard: CloudFront + OAC
  # ============================================================
  DashboardOAC:
    Type: AWS::CloudFront::OriginAccessControl
    Properties:
      OriginAccessControlConfig:
        Name: !Sub '${ResourcePrefix}-dashboard-oac'
        OriginAccessControlOriginType: s3
        SigningBehavior: always
        SigningProtocol: sigv4

  DashboardIpRestriction:
    Type: AWS::CloudFront::Function
    Properties:
      Name: !Sub '${ResourcePrefix}-ip-restrict'
      AutoPublish: true
      FunctionConfig:
        Comment: IP restriction for dashboard
        Runtime: cloudfront-js-2.0
      FunctionCode: !Sub |
        var ALLOWED_CIDRS = '${AllowedCidrs}'.split(',');
        function ipToNum(ip) {
          var parts = ip.split('.');
          return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
        }
        function isAllowed(ip) {
          var ipNum = ipToNum(ip);
          for (var i = 0; i < ALLOWED_CIDRS.length; i++) {
            var parts = ALLOWED_CIDRS[i].split('/');
            var base = ipToNum(parts[0]);
            var mask = (~0 << (32 - parseInt(parts[1]))) >>> 0;
            if ((ipNum & mask) === (base & mask)) return true;
          }
          return false;
        }
        function handler(event) {
          var clientIp = event.viewer.ip;
          if (!isAllowed(clientIp)) {
            return { statusCode: 403, statusDescription: 'Forbidden',
              headers: { 'content-type': { value: 'text/plain' } },
              body: 'Access denied' };
          }
          return event.request;
        }

  DashboardDistribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Enabled: true
        DefaultRootObject: index.html
        Comment: !Sub '${ResourcePrefix} self-service dashboard'
        Origins:
          - Id: S3Origin
            DomainName: !If [CreateDashboardBucket, !GetAtt DashboardBucket.RegionalDomainName, !Sub '${ExistingDashboardBucketName}.s3.${AWS::Region}.amazonaws.com']
            OriginAccessControlId: !Ref DashboardOAC
            S3OriginConfig:
              OriginAccessIdentity: ''
        DefaultCacheBehavior:
          TargetOriginId: S3Origin
          ViewerProtocolPolicy: redirect-to-https
          CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6
          AllowedMethods: [GET, HEAD]
          CachedMethods: [GET, HEAD]
          FunctionAssociations:
            - EventType: viewer-request
              FunctionARN: !GetAtt DashboardIpRestriction.FunctionARN
        PriceClass: PriceClass_200
      Tags:
        - Key: Cost
          Value: !Ref CostTag

  # ============================================================
  # 19. Self-Service Dashboard: API Gateway (REST) + Lambda
  # ============================================================
  DashboardApi:
    Type: AWS::ApiGateway::RestApi
    Properties:
      Name: !Sub '${ResourcePrefix}-dashboard-api'
      Description: Self-service cost query API
      Tags:
        - Key: Cost
          Value: !Ref CostTag

  DashboardApiGatewayResponse4XX:
    Type: AWS::ApiGateway::GatewayResponse
    Properties:
      RestApiId: !Ref DashboardApi
      ResponseType: DEFAULT_4XX
      ResponseParameters:
        gatewayresponse.header.Access-Control-Allow-Origin: !Sub "'https://${DashboardDistribution.DomainName}'"
        gatewayresponse.header.Access-Control-Allow-Headers: "'Content-Type,Authorization'"

  DashboardApiAuthorizer:
    Type: AWS::ApiGateway::Authorizer
    Properties:
      Name: CognitoAuth
      RestApiId: !Ref DashboardApi
      Type: COGNITO_USER_POOLS
      IdentitySource: method.request.header.Authorization
      ProviderARNs:
        - !GetAtt DashboardUserPool.Arn

  DashboardApiResource:
    Type: AWS::ApiGateway::Resource
    Properties:
      RestApiId: !Ref DashboardApi
      ParentId: !GetAtt DashboardApi.RootResourceId
      PathPart: my-cost

  DashboardApiMethod:
    Type: AWS::ApiGateway::Method
    Properties:
      RestApiId: !Ref DashboardApi
      ResourceId: !Ref DashboardApiResource
      HttpMethod: GET
      AuthorizationType: COGNITO_USER_POOLS
      AuthorizerId: !Ref DashboardApiAuthorizer
      Integration:
        Type: AWS_PROXY
        IntegrationHttpMethod: POST
        Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${DashboardQueryFunction.Arn}/invocations'

  DashboardApiMethodOptions:
    Type: AWS::ApiGateway::Method
    Properties:
      RestApiId: !Ref DashboardApi
      ResourceId: !Ref DashboardApiResource
      HttpMethod: OPTIONS
      AuthorizationType: NONE
      Integration:
        Type: MOCK
        RequestTemplates:
          application/json: '{"statusCode": 200}'
        IntegrationResponses:
          - StatusCode: 200
            ResponseParameters:
              method.response.header.Access-Control-Allow-Headers: "'Content-Type,Authorization'"
              method.response.header.Access-Control-Allow-Methods: "'GET,OPTIONS'"
              method.response.header.Access-Control-Allow-Origin: !Sub "'https://${DashboardDistribution.DomainName}'"
      MethodResponses:
        - StatusCode: 200
          ResponseParameters:
            method.response.header.Access-Control-Allow-Headers: true
            method.response.header.Access-Control-Allow-Methods: true
            method.response.header.Access-Control-Allow-Origin: true

  DashboardApiAllCostsResource:
    Type: AWS::ApiGateway::Resource
    Properties:
      RestApiId: !Ref DashboardApi
      ParentId: !GetAtt DashboardApi.RootResourceId
      PathPart: all-costs

  DashboardApiAllCostsMethod:
    Type: AWS::ApiGateway::Method
    Properties:
      RestApiId: !Ref DashboardApi
      ResourceId: !Ref DashboardApiAllCostsResource
      HttpMethod: GET
      AuthorizationType: COGNITO_USER_POOLS
      AuthorizerId: !Ref DashboardApiAuthorizer
      Integration:
        Type: AWS_PROXY
        IntegrationHttpMethod: POST
        Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${DashboardQueryFunction.Arn}/invocations'

  DashboardApiAllCostsOptions:
    Type: AWS::ApiGateway::Method
    Properties:
      RestApiId: !Ref DashboardApi
      ResourceId: !Ref DashboardApiAllCostsResource
      HttpMethod: OPTIONS
      AuthorizationType: NONE
      Integration:
        Type: MOCK
        RequestTemplates:
          application/json: '{"statusCode": 200}'
        IntegrationResponses:
          - StatusCode: 200
            ResponseParameters:
              method.response.header.Access-Control-Allow-Headers: "'Content-Type,Authorization'"
              method.response.header.Access-Control-Allow-Methods: "'GET,OPTIONS'"
              method.response.header.Access-Control-Allow-Origin: !Sub "'https://${DashboardDistribution.DomainName}'"
      MethodResponses:
        - StatusCode: 200
          ResponseParameters:
            method.response.header.Access-Control-Allow-Headers: true
            method.response.header.Access-Control-Allow-Methods: true
            method.response.header.Access-Control-Allow-Origin: true

  DashboardApiDeployment:
    Type: AWS::ApiGateway::Deployment
    DependsOn:
      - DashboardApiMethod
      - DashboardApiMethodOptions
      - DashboardApiAllCostsMethod
      - DashboardApiAllCostsOptions
    Properties:
      RestApiId: !Ref DashboardApi

  DashboardApiStage:
    Type: AWS::ApiGateway::Stage
    Properties:
      RestApiId: !Ref DashboardApi
      DeploymentId: !Ref DashboardApiDeployment
      StageName: v1

  # ============================================================
  # 20. Self-Service Dashboard: Query Lambda
  # ============================================================
  DashboardQueryLambdaRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${ResourcePrefix}-dashboard-query-role'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: ReadCostTable
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - dynamodb:GetItem
                  - dynamodb:Query
                  - dynamodb:Scan
                Resource: !If [CreateCostTable, !GetAtt CostTable.Arn, !Sub 'arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${ExistingCostTableName}']
      Tags:
        - Key: Cost
          Value: !Ref CostTag

  DashboardQueryFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub '${ResourcePrefix}-dashboard-query'
      Runtime: python3.12
      Handler: index.handler
      Timeout: 10
      MemorySize: 128
      Role: !GetAtt DashboardQueryLambdaRole.Arn
      Environment:
        Variables:
          COST_TABLE_NAME: !If [CreateCostTable, !Ref CostTable, !Ref ExistingCostTableName]
          COST_LIMIT_USD: !Ref CostLimitUsd
          EXCLUDE_USERS: !Ref ExcludeUsers
          ALLOWED_ORIGIN: !Sub 'https://${DashboardDistribution.DomainName}'
      Tags:
        - Key: Cost
          Value: !Ref CostTag
      Code:
        ZipFile: |
          import os
          import json
          import boto3
          from datetime import datetime, timedelta, timezone

          JST = timezone(timedelta(hours=9))

          def parse_limits(raw):
              limits = {}
              if raw and raw != '0':
                  for entry in raw.split(','):
                      entry = entry.strip()
                      if ':' in entry:
                          name, amt = entry.rsplit(':', 1)
                          try:
                              limits[name.strip()] = float(amt.strip())
                          except ValueError:
                              pass
              return limits

          def handle_my_cost(event, headers):
              try:
                  claims = event['requestContext']['authorizer']['claims']
                  email = claims.get('email', '')
                  username = email.split('@')[0] if '@' in email else email
              except (KeyError, TypeError):
                  return {'statusCode': 401, 'headers': headers, 'body': json.dumps({'error': 'unauthorized'})}

              ddb = boto3.client('dynamodb')
              table = os.environ['COST_TABLE_NAME']
              now = datetime.now(JST)
              period = now.strftime('%Y-%m')

              spend = 0.0
              last_updated = None
              for kind in ('user', 'role'):
                  try:
                      resp = ddb.get_item(
                          TableName=table,
                          Key={'identityKey': {'S': f'{kind}:{username}'}, 'period': {'S': period}},
                      )
                      item = resp.get('Item')
                      if item:
                          spend = float(item.get('monthlySpendUsd', {}).get('N', '0'))
                          last_updated = item.get('lastUpdated', {}).get('S', '')
                          break
                  except Exception:
                      continue

              limits = parse_limits(os.environ.get('COST_LIMIT_USD', '0'))
              limit = limits.get(username, 0)
              remaining = max(0, limit - spend) if limit > 0 else None

              body = {
                  'username': username,
                  'period': period,
                  'spendUsd': round(spend, 4),
                  'limitUsd': limit if limit > 0 else None,
                  'remainingUsd': round(remaining, 4) if remaining is not None else None,
                  'lastUpdated': last_updated,
              }
              return {'statusCode': 200, 'headers': headers, 'body': json.dumps(body)}

          def get_aggregates(ddb, table, period, prefix):
              items = []
              last_key = None
              while True:
                  kwargs = {
                      'TableName': table,
                      'FilterExpression': 'period = :p AND begins_with(identityKey, :prefix)',
                      'ExpressionAttributeValues': {':p': {'S': period}, ':prefix': {'S': prefix}},
                  }
                  if last_key:
                      kwargs['ExclusiveStartKey'] = last_key
                  resp = ddb.scan(**kwargs)
                  items.extend(resp.get('Items', []))
                  last_key = resp.get('LastEvaluatedKey')
                  if not last_key:
                      break
              return items

          def get_daily_costs(ddb, table, period):
              items = get_aggregates(ddb, table, period, 'daily:')
              days = []
              for item in items:
                  key = item.get('identityKey', {}).get('S', '')
                  day_str = key.replace('daily:', '')
                  code = float(item.get('claude_codeUsd', {}).get('N', '0'))
                  desktop = float(item.get('claude_desktopUsd', {}).get('N', '0'))
                  days.append({'date': day_str[5:], 'code': round(code, 4), 'desktop': round(desktop, 4)})
              days.sort(key=lambda x: x['date'])
              return days

          def get_model_stats(ddb, table, period):
              items = get_aggregates(ddb, table, period, 'model:')
              models = []
              for item in items:
                  key = item.get('identityKey', {}).get('S', '')
                  name = key.replace('model:', '')
                  cost = float(item.get('totalUsd', {}).get('N', '0'))
                  count = int(item.get('invocationCount', {}).get('N', '0'))
                  code = float(item.get('claude_codeUsd', {}).get('N', '0'))
                  desktop = float(item.get('claude_desktopUsd', {}).get('N', '0'))
                  models.append({'name': name, 'costUsd': round(cost, 4), 'invocations': count, 'codeUsd': round(code, 4), 'desktopUsd': round(desktop, 4)})
              models.sort(key=lambda x: -x['costUsd'])
              return models

          def get_user_daily_costs(ddb, table, period):
              items = get_aggregates(ddb, table, period, 'ud:')
              user_days = {}
              for item in items:
                  key = item.get('identityKey', {}).get('S', '')
                  # ud:{name}:{date}
                  parts = key[3:].rsplit(':', 1)
                  if len(parts) != 2:
                      continue
                  uname, day_str = parts
                  cost = float(item.get('totalUsd', {}).get('N', '0'))
                  if uname not in user_days:
                      user_days[uname] = {}
                  user_days[uname][day_str] = round(cost, 4)
              return user_days

          def get_user_models(ddb, table, period):
              items = get_aggregates(ddb, table, period, 'um:')
              user_models = {}
              for item in items:
                  key = item.get('identityKey', {}).get('S', '')
                  parts = key.replace('um:', '').split(':', 1)
                  if len(parts) != 2:
                      continue
                  uname, model = parts
                  cost = float(item.get('totalUsd', {}).get('N', '0'))
                  count = int(item.get('invocationCount', {}).get('N', '0'))
                  code = float(item.get('claude_codeUsd', {}).get('N', '0'))
                  desktop = float(item.get('claude_desktopUsd', {}).get('N', '0'))
                  if uname not in user_models:
                      user_models[uname] = []
                  user_models[uname].append({'model': model, 'costUsd': round(cost, 4), 'invocations': count, 'codeUsd': round(code, 4), 'desktopUsd': round(desktop, 4)})
              return user_models

          def handle_all_costs(event, headers):
              ddb = boto3.client('dynamodb')
              table = os.environ['COST_TABLE_NAME']
              now = datetime.now(JST)
              period = now.strftime('%Y-%m')
              limits = parse_limits(os.environ.get('COST_LIMIT_USD', '0'))
              exclude = [x.strip() for x in os.environ.get('EXCLUDE_USERS', '').split(',') if x.strip()]

              items = []
              last_key = None
              while True:
                  kwargs = {
                      'TableName': table,
                      'FilterExpression': 'period = :p AND NOT begins_with(identityKey, :req) AND NOT begins_with(identityKey, :daily) AND NOT begins_with(identityKey, :model) AND NOT begins_with(identityKey, :um) AND NOT begins_with(identityKey, :ud)',
                      'ExpressionAttributeValues': {':p': {'S': period}, ':req': {'S': 'req#'}, ':daily': {'S': 'daily:'}, ':model': {'S': 'model:'}, ':um': {'S': 'um:'}, ':ud': {'S': 'ud:'}},
                  }
                  if last_key:
                      kwargs['ExclusiveStartKey'] = last_key
                  resp = ddb.scan(**kwargs)
                  items.extend(resp.get('Items', []))
                  last_key = resp.get('LastEvaluatedKey')
                  if not last_key:
                      break

              users = []
              for item in items:
                  identity_key = item.get('identityKey', {}).get('S', '')
                  kind, name = identity_key.split(':', 1) if ':' in identity_key else ('unknown', identity_key)
                  spend = float(item.get('monthlySpendUsd', {}).get('N', '0'))
                  code_usd = float(item.get('claude_codeUsd', {}).get('N', '0'))
                  desktop_usd = float(item.get('claude_desktopUsd', {}).get('N', '0'))
                  invocations = int(item.get('invocationCount', {}).get('N', '0'))
                  limit = limits.get(name, 0)
                  users.append({
                      'name': name,
                      'kind': kind,
                      'spendUsd': round(spend, 4),
                      'codeUsd': round(code_usd, 4),
                      'desktopUsd': round(desktop_usd, 4),
                      'invocations': invocations,
                      'limitUsd': limit if limit > 0 else None,
                      'remainingUsd': round(max(0, limit - spend), 4) if limit > 0 else None,
                      'lastUpdated': item.get('lastUpdated', {}).get('S', ''),
                      'excluded': name in exclude,
                  })

              users.sort(key=lambda x: -x['spendUsd'])
              daily_costs = get_daily_costs(ddb, table, period)
              model_stats = get_model_stats(ddb, table, period)
              user_daily_costs = get_user_daily_costs(ddb, table, period)
              user_models = get_user_models(ddb, table, period)
              for u in users:
                  um = user_models.get(u['name'], [])
                  u['topModelByCost'] = sorted(um, key=lambda x: -x['costUsd'])[0]['model'] if um else None
                  u['topModelByCount'] = sorted(um, key=lambda x: -x['invocations'])[0]['model'] if um else None
                  u['models'] = sorted(um, key=lambda x: -x['costUsd'])
              body = {
                  'period': period,
                  'users': users,
                  'totalUsd': round(sum(u['spendUsd'] for u in users), 4),
                  'dailyCosts': daily_costs,
                  'userDailyCosts': user_daily_costs,
                  'models': model_stats,
                  'excludeUsers': exclude,
              }
              return {'statusCode': 200, 'headers': headers, 'body': json.dumps(body)}

          def handler(event, context):
              origin = os.environ['ALLOWED_ORIGIN']
              headers = {
                  'Content-Type': 'application/json',
                  'Access-Control-Allow-Origin': origin,
                  'Access-Control-Allow-Headers': 'Content-Type,Authorization',
              }

              path = event.get('path', '')
              if path.endswith('/all-costs'):
                  return handle_all_costs(event, headers)
              return handle_my_cost(event, headers)

  DashboardQueryPermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref DashboardQueryFunction
      Action: lambda:InvokeFunction
      Principal: apigateway.amazonaws.com
      SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${DashboardApi}/*'

  # ============================================================
  # 21. Self-Service Dashboard: Deploy static files via Custom Resource
  # ============================================================
  DashboardDeployRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${ResourcePrefix}-dashboard-deploy-role'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: S3Deploy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - s3:PutObject
                  - s3:DeleteObject
                  - s3:ListBucket
                Resource:
                  - !If [CreateDashboardBucket, !GetAtt DashboardBucket.Arn, !Sub 'arn:aws:s3:::${ExistingDashboardBucketName}']
                  - !Sub
                    - '${BucketArn}/*'
                    - BucketArn: !If [CreateDashboardBucket, !GetAtt DashboardBucket.Arn, !Sub 'arn:aws:s3:::${ExistingDashboardBucketName}']
      Tags:
        - Key: Cost
          Value: !Ref CostTag

  DashboardDeployFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub '${ResourcePrefix}-dashboard-deploy'
      Runtime: python3.12
      Handler: index.handler
      Timeout: 60
      MemorySize: 128
      Role: !GetAtt DashboardDeployRole.Arn
      Environment:
        Variables:
          BUCKET_NAME: !If [CreateDashboardBucket, !Ref DashboardBucket, !Ref ExistingDashboardBucketName]
      Tags:
        - Key: Cost
          Value: !Ref CostTag
      Code:
        ZipFile: |
          import os
          import json
          import boto3
          import cfnresponse

          def handler(event, context):
              physical_id = 'dashboard-deploy-singleton'
              try:
                  bucket = os.environ['BUCKET_NAME']
                  s3 = boto3.client('s3')

                  if event['RequestType'] == 'Delete':
                      if event.get('ResourceProperties', {}).get('StackDelete') == 'true':
                          objs = s3.list_objects_v2(Bucket=bucket)
                          for obj in objs.get('Contents', []):
                              s3.delete_object(Bucket=bucket, Key=obj['Key'])
                      cfnresponse.send(event, context, cfnresponse.SUCCESS, {}, physical_id)
                      return

                  files = json.loads(event['ResourceProperties']['Files'])
                  for f in files:
                      s3.put_object(
                          Bucket=bucket,
                          Key=f['key'],
                          Body=f['body'],
                          ContentType=f.get('contentType', 'text/html'),
                      )

                  cfnresponse.send(event, context, cfnresponse.SUCCESS, {}, physical_id)
              except Exception as e:
                  print(f'Error: {e}')
                  cfnresponse.send(event, context, cfnresponse.FAILED, {'Error': str(e)}, physical_id)

  DashboardDeployCustomResource:
    Type: Custom::DashboardDeploy
    Properties:
      ServiceToken: !GetAtt DashboardDeployFunction.Arn
      Version: '7'
      Files: !Sub |
        [
          {
            "key": "index.html",
            "contentType": "text/html",
            "body": "<!DOCTYPE html>\n<html lang=\"ja\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>Claude利用状況ダッシュボード</title>\n<script src=\"https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js\"></script>\n<script src=\"https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.2.0/dist/chartjs-plugin-datalabels.min.js\"></script>\n<style>\n*{margin:0;padding:0;box-sizing:border-box}\nbody{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh;padding:1.5rem}\n.header{display:flex;justify-content:space-between;align-items:center;margin-bottom:2rem}\n.header h1{font-size:1.3rem;color:#f8fafc}\n.header-right{display:flex;gap:.8rem;align-items:center}\n.header button{background:#334155;color:#94a3b8;border:none;padding:.5rem 1rem;border-radius:6px;cursor:pointer;font-size:.85rem}\n.header button:hover{background:#475569}\n\n.summary{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem;margin-bottom:2rem}\n.stat-card{background:#1e293b;border-radius:10px;padding:1.2rem;position:relative;overflow:hidden}\n.stat-card::before{content:'';position:absolute;top:0;left:0;right:0;height:3px}\n.stat-card.blue::before{background:linear-gradient(90deg,#3b82f6,#60a5fa)}\n.stat-card.green::before{background:linear-gradient(90deg,#10b981,#34d399)}\n.stat-card.amber::before{background:linear-gradient(90deg,#f59e0b,#fbbf24)}\n.stat-card.purple::before{background:linear-gradient(90deg,#8b5cf6,#a78bfa)}\n.stat-card .label{font-size:.7rem;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em}\n.stat-card .value{font-size:1.5rem;font-weight:700;color:#f8fafc;margin-top:.25rem}\n.stat-card .sub{font-size:.78rem;color:#94a3b8;margin-top:.2rem}\n\n.charts-row{display:grid;grid-template-columns:2fr 1fr;gap:1rem;margin-bottom:1.5rem}\n.charts-row-2{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:2rem}\n@media(max-width:900px){.charts-row,.charts-row-2{grid-template-columns:1fr}}\n.chart-card{background:#1e293b;border-radius:10px;padding:1.2rem}\n.chart-card h3{font-size:.85rem;color:#e2e8f0;margin-bottom:1rem;font-weight:500}\n.chart-card canvas{width:100%!important;max-height:220px}\n\n.section-title{font-size:.85rem;color:#e2e8f0;text-transform:uppercase;letter-spacing:.06em;margin-bottom:1rem;padding-left:.2rem}\n.team-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:1rem;margin-bottom:2rem}\n.user-card{background:#1e293b;border-radius:10px;padding:1.2rem;border-left:4px solid #3b82f6;transition:transform .15s,box-shadow .15s}\n.user-card:hover{transform:translateY(-2px);box-shadow:0 4px 24px rgba(0,0,0,.3)}\n.user-card.warning{border-left-color:#f59e0b}\n.user-card.danger{border-left-color:#ef4444}\n.user-card .name{font-size:1rem;font-weight:600;color:#f8fafc;margin-bottom:.8rem;display:flex;justify-content:space-between;align-items:center}\n.user-card .name .badge{font-size:.65rem;padding:.2rem .5rem;border-radius:4px;background:#334155;color:#94a3b8;text-transform:uppercase}\n.user-card .bar-bg{height:8px;background:#334155;border-radius:4px;overflow:hidden;margin:.6rem 0}\n.user-card .bar-fill{height:100%;border-radius:4px;transition:width .6s ease}\n.user-card .details{display:grid;grid-template-columns:1fr 1fr;gap:.4rem;font-size:.8rem;color:#94a3b8}\n.user-card .details .val{color:#e2e8f0;font-weight:500;text-align:right}\n.user-card .pct-label{font-size:.75rem;color:#64748b;text-align:right;margin-top:.2rem}\n\n.login-wrap{display:flex;align-items:center;justify-content:center;min-height:80vh}\n.login-card{background:#1e293b;border-radius:12px;padding:2.5rem;text-align:center;max-width:360px}\n.login-card h2{color:#f8fafc;margin-bottom:1rem}\n.login-card p{color:#94a3b8;margin-bottom:1.5rem;font-size:.9rem}\n.login-btn{background:#3b82f6;color:#fff;border:none;padding:.8rem 2rem;border-radius:8px;font-size:1rem;cursor:pointer;transition:background .2s}\n.login-btn:hover{background:#2563eb}\n#error{color:#ef4444;text-align:center;margin:1rem 0;display:none}\n.meta{text-align:center;font-size:.75rem;color:#475569;margin-top:1.5rem}\n</style>\n</head>\n<body>\n<div id=\"login-view\" class=\"login-wrap\">\n  <div class=\"login-card\">\n    <h2>Claude利用状況ダッシュボード</h2>\n    <p>チームのClaude利用状況を確認</p>\n    <button class=\"login-btn\" onclick=\"login()\">ログイン</button>\n  </div>\n</div>\n<div id=\"data-view\" style=\"display:none\">\n  <div class=\"header\">\n    <div>\n      <h1>Claude利用状況ダッシュボード</h1>\n      <p style=\"font-size:.75rem;color:#94a3b8;margin-top:.3rem\">Claude Code / Claude Desktop の利用コストを表示 | コスト上限 (LimitUsd) が未設定のユーザーは集計対象外です | SDK直接呼出し等は含まれません</p>\n    </div>\n    <div class=\"header-right\">\n      <button onclick=\"load()\">更新</button>\n      <button onclick=\"logout()\">ログアウト</button>\n    </div>\n  </div>\n  <div class=\"summary\" id=\"summary\"></div>\n  <div class=\"charts-row\">\n    <div class=\"chart-card\">\n      <h3>日別ユーザーコスト推移 (USD)</h3>\n      <canvas id=\"dailyChart\"></canvas>\n    </div>\n    <div class=\"chart-card\">\n      <h3>ツール別内訳</h3>\n      <canvas id=\"sourceChart\"></canvas>\n    </div>\n  </div>\n  <div class=\"charts-row-2\">\n    <div class=\"chart-card\">\n      <h3>モデル別コスト (USD)</h3>\n      <canvas id=\"modelCostChart\"></canvas>\n    </div>\n    <div class=\"chart-card\">\n      <h3>モデル別リクエスト数</h3>\n      <canvas id=\"modelCountChart\"></canvas>\n    </div>\n  </div>\n  <div class=\"section-title\">利用者別</div>\n  <div class=\"team-grid\" id=\"team\"></div>\n  <p class=\"meta\" id=\"meta\"></p>\n</div>\n<p id=\"error\"></p>\n<script>\nconst CONFIG = {\n  clientId: '${DashboardUserPoolClient}',\n  domain: '${DashboardDomainPrefix}.auth.${AWS::Region}.amazoncognito.com',\n  redirectUri: location.origin + '/callback.html',\n  apiBase: 'https://${DashboardApi}.execute-api.${AWS::Region}.amazonaws.com/v1'\n};\n\nconst charts = {};\nChart.register(ChartDataLabels);\n\nfunction shortModel(name) {\n  return name.replace(/^anthropic\\./, '').replace(/-\\d{8}.*/, '');\n}\n\nfunction login() {\n  const u = new URL('https://' + CONFIG.domain + '/login');\n  u.searchParams.set('client_id', CONFIG.clientId);\n  u.searchParams.set('response_type', 'token');\n  u.searchParams.set('scope', 'openid email');\n  u.searchParams.set('redirect_uri', CONFIG.redirectUri);\n  location.href = u.toString();\n}\n\nfunction logout() {\n  localStorage.removeItem('id_token');\n  const u = new URL('https://' + CONFIG.domain + '/logout');\n  u.searchParams.set('client_id', CONFIG.clientId);\n  u.searchParams.set('logout_uri', location.origin + '/');\n  location.href = u.toString();\n}\n\nasync function load() {\n  const token = localStorage.getItem('id_token');\n  if (!token) {\n    document.getElementById('login-view').style.display = '';\n    document.getElementById('data-view').style.display = 'none';\n    return;\n  }\n  document.getElementById('login-view').style.display = 'none';\n  document.getElementById('data-view').style.display = '';\n  try {\n    const r = await fetch(CONFIG.apiBase + '/all-costs', { headers: { Authorization: token } });\n    if (r.status === 401 || r.status === 403) {\n      localStorage.removeItem('id_token');\n      showError('セッション期限切れ。再ログインしてください。');\n      document.getElementById('login-view').style.display = '';\n      document.getElementById('data-view').style.display = 'none';\n      return;\n    }\n    if (!r.ok) { showError('API error: ' + r.status); return; }\n    const d = await r.json();\n    render(d);\n  } catch (e) {\n    showError('通信エラー: ' + e.message);\n  }\n}\n\nfunction showError(msg) {\n  const el = document.getElementById('error');\n  el.textContent = msg;\n  el.style.display = 'block';\n}\n\nfunction render(d) {\n  document.getElementById('error').style.display = 'none';\n  const JPY = 160;\n  const totalJpy = Math.round(d.totalUsd * JPY);\n  const excludeUsers = d.excludeUsers || [];\n  const withLimit = d.users.filter(u => u.limitUsd && !u.excluded);\n  const avgPct = withLimit.length ? withLimit.reduce((a, u) => a + (u.spendUsd / u.limitUsd * 100), 0) / withLimit.length : 0;\n  const totalCode = d.users.reduce((a, u) => a + (u.codeUsd || 0), 0);\n  const totalDesktop = d.users.reduce((a, u) => a + (u.desktopUsd || 0), 0);\n  const capped = d.users.filter(u => u.limitUsd && !u.excluded && u.spendUsd >= u.limitUsd).length;\n  const excludeCount = excludeUsers.length;\n\n  let s = '';\n  s += '<div class=\"stat-card blue\"><div class=\"label\">今月合計</div><div class=\"value\">$' + d.totalUsd.toFixed(2) + '</div><div class=\"sub\">&yen;' + totalJpy.toLocaleString() + ' (1USD=' + JPY + '円)</div></div>';\n  s += '<div class=\"stat-card green\"><div class=\"label\">利用者数</div><div class=\"value\">' + d.users.length + ' <span style=\"font-size:.8rem;font-weight:400;color:#94a3b8\">人</span></div><div class=\"sub\">' + d.period + (excludeCount ? ' (除外: ' + excludeCount + '人)' : '') + '</div></div>';\n  s += '<div class=\"stat-card amber\"><div class=\"label\">平均消費率</div><div class=\"value\">' + avgPct.toFixed(0) + '%</div><div class=\"sub\">上限設定者 ' + withLimit.length + '人の平均 (除外者含まず)</div></div>';\n  s += '<div class=\"stat-card purple\"><div class=\"label\">上限到達</div><div class=\"value\">' + capped + ' <span style=\"font-size:.8rem;font-weight:400;color:#94a3b8\">人</span></div><div class=\"sub\">制限中のユーザー</div></div>';\n  document.getElementById('summary').innerHTML = s;\n\n  renderDailyChart(d.dailyCosts || [], d.userDailyCosts || {});\n  renderSourceChart(totalCode, totalDesktop);\n  renderModelCharts(d.models || []);\n  renderUserCards(d.users, JPY);\n\n  document.getElementById('meta').textContent = '最終更新: ' + new Date().toLocaleString('ja-JP') + ' | 期間: ' + d.period;\n}\n\nfunction renderDailyChart(dailyCosts, userDailyCosts) {\n  const ctx = document.getElementById('dailyChart');\n  if (charts.daily) charts.daily.destroy();\n  if (!dailyCosts.length) { charts.daily = null; return; }\n\n  const USER_COLORS = ['#60a5fa','#fbbf24','#34d399','#f87171','#a78bfa','#22d3ee','#fb923c','#a3e635','#f472b6','#2dd4bf','#818cf8','#fcd34d'];\n  const dates = dailyCosts.map(x => x.date);\n\n  const userNames = Object.keys(userDailyCosts || {});\n  userNames.sort((a, b) => {\n    const totalA = Object.values(userDailyCosts[a]).reduce((s, v) => s + v, 0);\n    const totalB = Object.values(userDailyCosts[b]).reduce((s, v) => s + v, 0);\n    return totalB - totalA;\n  });\n\n  const datasets = userNames.map((name, i) => {\n    const dayMap = userDailyCosts[name];\n    const data = dailyCosts.map(x => {\n      const match = Object.keys(dayMap).find(k => k.slice(5) === x.date);\n      return match ? dayMap[match] : 0;\n    });\n    const isLast = (i === userNames.length - 1);\n    return { label: name, data: data, backgroundColor: USER_COLORS[i % USER_COLORS.length], borderRadius: 3, barPercentage: 0.8, stack: 'daily', order: 2,\n      datalabels: { display: isLast, color: '#f8fafc', anchor: 'end', align: 'top', font: { size: 9, weight: 'bold' },\n        formatter: (v, ctx) => { let sum = 0; for (let ds of ctx.chart.data.datasets) { if (ds.stack === 'daily') sum += ds.data[ctx.dataIndex] || 0; } return sum > 0 ? '$' + sum.toFixed(1) : ''; }\n      }\n    };\n  });\n\n  charts.daily = new Chart(ctx, {\n    type: 'bar',\n    data: { labels: dates, datasets: datasets },\n    options: {\n      responsive: true, maintainAspectRatio: false,\n      interaction: { mode: 'index', intersect: false },\n      plugins: {\n        legend: { position: 'top', labels: { color: '#e2e8f0', boxWidth: 12, font: { size: 11 } } },\n        tooltip: { callbacks: { label: c => c.dataset.label + ': $' + (c.raw || 0).toFixed(2) } },\n        datalabels: { display: false }\n      },\n      scales: {\n        x: { stacked: true, grid: { display: false }, ticks: { color: '#cbd5e1', font: { size: 10 } } },\n        y: { stacked: true, grid: { color: '#1e293b' }, ticks: { color: '#cbd5e1', font: { size: 10 }, callback: v => '$' + v.toFixed(1) } }\n      }\n    }\n  });\n}\n\nfunction renderSourceChart(code, desktop) {\n  const ctx = document.getElementById('sourceChart');\n  if (charts.source) charts.source.destroy();\n  charts.source = new Chart(ctx, {\n    type: 'doughnut',\n    data: {\n      labels: ['Claude Code', 'Claude Desktop'],\n      datasets: [{ data: [code, desktop], backgroundColor: ['#3b82f6', '#8b5cf6'], borderColor: '#1e293b', borderWidth: 3 }]\n    },\n    options: {\n      responsive: true, maintainAspectRatio: false, cutout: '65%',\n      plugins: {\n        legend: { position: 'bottom', labels: { color: '#e2e8f0', padding: 16, font: { size: 11 } } },\n        tooltip: { callbacks: { label: c => { const t = code+desktop; return c.label + ': $' + c.raw.toFixed(2) + ' (' + (t>0?(c.raw/t*100).toFixed(1):'0') + '%)'; } } },\n        datalabels: { display: true, color: '#f8fafc', font: { size: 11, weight: 'bold' }, formatter: (v, ctx) => { const t = code+desktop; return '$' + v.toFixed(2) + '\\n' + (t>0?(v/t*100).toFixed(0):'0') + '%'; } }\n      }\n    }\n  });\n}\n\nfunction renderModelCharts(models) {\n  const COLORS = ['#a78bfa','#34d399','#fbbf24','#f87171','#38bdf8','#fb923c','#e879f9','#4ade80'];\n\n  // Model cost chart\n  const ctx1 = document.getElementById('modelCostChart');\n  if (charts.modelCost) charts.modelCost.destroy();\n  if (!models.length) { charts.modelCost = null; return; }\n  charts.modelCost = new Chart(ctx1, {\n    type: 'bar',\n    data: {\n      labels: models.map(m => m.name.replace(/^anthropic\\./, '').replace(/-\\d{8}.*/, '')),\n      datasets: [{ data: models.map(m => m.costUsd), backgroundColor: COLORS.slice(0, models.length), borderRadius: 4 }]\n    },\n    options: {\n      responsive: true, maintainAspectRatio: false, indexAxis: 'y',\n      plugins: {\n        legend: { display: false },\n        tooltip: { callbacks: { label: c => '$' + c.raw.toFixed(2) } },\n        datalabels: { display: true, color: '#e2e8f0', anchor: 'end', align: 'right', font: { size: 10 }, formatter: v => '$' + v.toFixed(2) }\n      },\n      scales: { x: { grid: { color: '#1e293b' }, ticks: { color: '#cbd5e1', callback: v => '$' + v } }, y: { grid: { display: false }, ticks: { color: '#e2e8f0', font: { size: 11 } } } }\n    }\n  });\n\n  // Model invocations chart\n  const ctx2 = document.getElementById('modelCountChart');\n  if (charts.modelCount) charts.modelCount.destroy();\n  charts.modelCount = new Chart(ctx2, {\n    type: 'bar',\n    data: {\n      labels: models.map(m => m.name.replace(/^anthropic\\./, '').replace(/-\\d{8}.*/, '')),\n      datasets: [{ data: models.map(m => m.invocations), backgroundColor: COLORS.slice(0, models.length), borderRadius: 4 }]\n    },\n    options: {\n      responsive: true, maintainAspectRatio: false, indexAxis: 'y',\n      plugins: {\n        legend: { display: false },\n        tooltip: { callbacks: { label: c => c.raw.toLocaleString() + ' 回' } },\n        datalabels: { display: true, color: '#e2e8f0', anchor: 'end', align: 'right', font: { size: 10 }, formatter: v => v.toLocaleString() }\n      },\n      scales: { x: { grid: { color: '#1e293b' }, ticks: { color: '#cbd5e1' } }, y: { grid: { display: false }, ticks: { color: '#e2e8f0', font: { size: 11 } } } }\n    }\n  });\n}\n\nfunction renderUserCards(users, JPY) {\n  let t = '';\n  const sorted = [...users].sort((a, b) => b.spendUsd - a.spendUsd);\n  sorted.forEach(u => {\n    const pct = u.limitUsd ? Math.min(100, u.spendUsd / u.limitUsd * 100) : 0;\n    const cls = pct >= 90 ? 'danger' : pct >= 70 ? 'warning' : '';\n    const color = pct >= 90 ? '#ef4444' : pct >= 70 ? '#f59e0b' : '#3b82f6';\n    t += '<div class=\"user-card ' + cls + '\">';\n    t += '<div class=\"name\"><span>' + u.name + '</span><span>';\n    if (u.excluded) t += '<span class=\"badge\" style=\"background:#7c3aed;color:#e9d5ff;margin-right:4px\">除外</span>';\n    t += '<span class=\"badge\">' + u.kind + '</span></span></div>';\n    if (u.limitUsd) {\n      t += '<div class=\"bar-bg\"><div class=\"bar-fill\" style=\"width:' + pct + '%;background:' + color + '\"></div></div>';\n      t += '<div class=\"pct-label\">' + pct.toFixed(1) + '% 消費</div>';\n    }\n    t += '<div class=\"details\">';\n    t += '<span>利用額</span><span class=\"val\">$' + u.spendUsd.toFixed(2) + ' (&yen;' + Math.round(u.spendUsd * JPY).toLocaleString() + ')</span>';\n    if (u.limitUsd) {\n      t += '<span>上限</span><span class=\"val\">$' + u.limitUsd.toFixed(2) + '</span>';\n      t += '<span>残り</span><span class=\"val\">$' + u.remainingUsd.toFixed(2) + '</span>';\n    }\n    t += '<span>Code</span><span class=\"val\">$' + u.codeUsd.toFixed(2) + '</span>';\n    t += '<span>Desktop</span><span class=\"val\">$' + u.desktopUsd.toFixed(2) + '</span>';\n    if (u.invocations) t += '<span>リクエスト数</span><span class=\"val\">' + u.invocations.toLocaleString() + ' 回</span>';\n    if (u.topModelByCost) t += '<span>コスト最大</span><span class=\"val\">' + shortModel(u.topModelByCost) + '</span>';\n    if (u.topModelByCount && u.topModelByCount !== u.topModelByCost) t += '<span>利用最多</span><span class=\"val\">' + shortModel(u.topModelByCount) + '</span>';\n    t += '</div></div>';\n  });\n  document.getElementById('team').innerHTML = t;\n}\n\nload();\n</script>\n</body>\n</html>\n"
          },
          {
            "key": "callback.html",
            "contentType": "text/html",
            "body": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Redirecting...</title></head><body><script>const hash=location.hash.substring(1);const params=new URLSearchParams(hash);const token=params.get('id_token');if(token){localStorage.setItem('id_token',token);location.href='/'}else{document.body.textContent='Login failed';}</script></body></html>"
          }
        ]

Outputs:
  LogGroupName:
    Value: !If [CreateLogGroup, !Ref LogGroupName, !Ref ExistingLogGroupName]
    Description: Set Bedrock Model Invocation Logging to this log group (CloudWatch Logs destination)
  LoggingRoleArn:
    Value: !GetAtt BedrockLoggingRole.Arn
    Description: Set Bedrock Model Invocation Logging to use this service role ARN
  LogsKmsKeyArn:
    Condition: UseKms
    Value: !GetAtt LogsKmsKey.Arn
    Description: CMK used to encrypt the invocation log S3 bucket
  CostTableName:
    Value: !If [CreateCostTable, !Ref CostTable, !Ref ExistingCostTableName]
    Description: DynamoDB table holding per-user monthly cost (updated every minute)
  SNSTopicArn:
    Value: !If [CreateSnsTopic, !Ref CostReportTopic, !Ref ExistingSnsTopicArn]
  LambdaFunctionName:
    Value: !Ref CostGuardFunction
  DashboardURL:
    Value: !Sub 'https://${AWS::Region}.console.aws.amazon.com/cloudwatch/home?region=${AWS::Region}#dashboards/name=${ResourcePrefix}-dashboard'
    Description: CloudWatch Dashboard URL (includes per-user cost table)
  SelfServiceDashboardURL:
    Value: !Sub 'https://${DashboardDistribution.DomainName}/'
    Description: Self-service dashboard URL (user-facing, Cognito login required)
  DashboardApiEndpoint:
    Value: !Sub 'https://${DashboardApi}.execute-api.${AWS::Region}.amazonaws.com/v1'
    Description: API Gateway base URL for self-service dashboard (set in index.html CONFIG.apiBase)
  CognitoClientId:
    Value: !Ref DashboardUserPoolClient
    Description: Cognito App Client ID (set in index.html CONFIG.clientId)
  CognitoDomain:
    Value: !Sub '${DashboardDomainPrefix}.auth.${AWS::Region}.amazoncognito.com'
    Description: Cognito auth domain (set in index.html CONFIG.domain)
  CognitoUserPoolId:
    Value: !Ref DashboardUserPool
    Description: Cognito User Pool ID for dashboard login

リージョンについて

Bedrockへリクエストを送信するリージョンと同じリージョンにデプロイしてください。

Bedrockのモデル呼び出しログは、リクエストを送信したリージョンのCloudWatch Logsに記録されます。Subscription Filterは同一リージョンのLambdaにしか転送できないため、テンプレートを別リージョンにデプロイするとコスト集計が動きません。

クロスリージョン推論プロファイル利用時の補足:

us.anthropic.*jp.anthropic.* 等のクロスリージョン推論プロファイルを使っている場合、実際の推論は別リージョンで実行されますが、ログとメトリクスはリクエストを発行したリージョンに記録されます。例えば東京リージョン(ap-northeast-1)からグローバルプロファイルで呼び出した場合、推論が米国で実行されてもログは東京のCloudWatch Logsに記録されます。そのためテンプレートはリクエスト送信元リージョンにデプロイすれば問題ありません。

CloudWatchダッシュボードのModelId欄に us.anthropic.claude-... のようなIDが表示されることがありますが、これはクロスリージョン推論プロファイルのモデルID命名であり、メトリクスデータ自体はデプロイリージョンに存在しています。

ステップ1: CloudFormationスタックのデプロイ

テンプレート(bedrock-cost-guard.yaml)をCloudFormationコンソールにアップロードし、パラメータを設定してください。太字のパラメータは初回デプロイ時に必ず確認・設定してください。他はデフォルトのままで動作します。

注意:スタック作成時に「IAMリソースの作成を許可」のチェックボックスへの同意が必要です(CAPABILITY_NAMED_IAM)。ModelPricingは最新の公式料金表をご確認ください。

パラメータ 説明 デフォルト
EmailAddress 日次レポート・アラート送信先。複数人に送る場合はメーリングリストを指定 (必須)
CostLimitUsd ユーザー/ロール別の月額上限。名前:金額のカンマ区切り。0=上限なし 0
SourceTags 集計対象のrequestMetadata.source値(カンマ区切り) claude-code,claude-desktop
ExcludeUsers 上限チェックから除外するユーザー/ロール名(カンマ区切り) (空)
ResourcePrefix 全リソース名のプレフィックス bedrock-cost-guard
LogGroupName Bedrockログ用CloudWatch Logsグループ名 /aws/bedrock/cost-guard
CostTag リソースに付与するコスト配分タグ値 (空)
LogRetentionDays ログ保持期間(日) 365
CapCheckIntervalMinutes コスト上限チェック間隔(分) 1
WarningThresholdPercent 警告通知を出す閾値(%) 80
ReportScheduleHourJst 日次レポート送信時刻(JST、時) 9
ReportScheduleMinuteJst 日次レポート送信時刻(JST、分) 0
JpyRate USD→JPY換算レート 160
ModelPricing モデル別料金(1Mトークンあたり input/output/cache_write/cache_read) sonnet:3.0/15.0/3.75/0.30,opus:5.0/25.0/6.25/0.50,haiku:…
DashboardDomainPrefix Cognitoドメイン(グローバルに一意である必要あり) bedrock-cost-guard
AllowedCidrs ダッシュボードアクセス許可CIDR(カンマ区切り) 0.0.0.0/0
EnableS3Logging S3へのログ保管 false
EnableLogEncryption S3ログのKMS暗号化(S3有効時のみ) false
LogReaderArns S3ログの復号を許可するIAM ARN(カンマ区切り)。空=制限なし (空)
LogTextData テキスト入出力本文をログに含める false
LogImageData 画像データをログに含める false
LogVideoData 動画データをログに含める false
LogEmbeddingData 埋め込みデータをログに含める false
LogAudioData 音声データをログに含める false
ExistingLogGroupName 既存ロググループ名(空=新規作成) (空)
ExistingCostTableName 既存DynamoDBテーブル名(空=新規作成) (空)
ExistingSnsTopicArn 既存SNSトピックARN(空=新規作成) (空)
ExistingLogBucketName 既存S3ログバケット名(空=新規作成) (空)
ExistingDashboardBucketName 既存ダッシュボードバケット名(空=新規作成) (空)

ステップ2: SNSサブスクリプションの確認

デプロイ後、設定したメールアドレスにSNSの確認メールが届きます。「Confirm subscription」をクリックして購読を有効化してください。

ステップ3: Bedrockログ設定の確認

テンプレートがBedrockのモデル呼び出しログ設定を自動で行います。手動設定は不要です。

スタック作成完了後、Bedrockコンソール → Settings → Model invocation logging で以下が設定されていることを確認してください:

  • CloudWatch Logs: 有効(ロググループ名はOutputsのLogGroupName
  • S3(EnableS3Logging=trueの場合): 有効
  • サービスロール: OutputsのLoggingRoleArn

ステップ4: セルフサービスダッシュボードの利用者登録

セルフサービスダッシュボード(CloudFront経由のWebUI)はテンプレートが自動構築します。HTML配置やCONFIG設定は不要です。

ダッシュボードにアクセスさせたい利用者のCognitoユーザーを作成してください。以下はCLIコマンドのサンプルです。
ダッシュボードURLは、スタックOutputsの SelfServiceDashboardURLです。

aws cognito-idp admin-create-user \
  --user-pool-id (OutputsのCognitoUserPoolId) \
  --username user@example.com \
  --user-attributes Name=email,Value=user@example.com Name=email_verified,Value=true

ステップ5: requestMetadata.sourceの設定

requestMetadata.sourceを設定していない利用者の利用はコスト集計対象外です。
全員への設定周知が必須です。利用者がBedrockを呼び出す際にrequestMetadata.sourceを設定しないと、コスト集計の対象になりません。

Claude Code(CLI)の場合:
~/.claude/settings.json に以下を追加:

{
  "env": {
    "ANTHROPIC_CUSTOM_HEADERS": "X-Amzn-Bedrock-Request-Metadata: {\"source\": \"claude-code\"}"
  }
}

Claude Desktop(Chat/Cowork/Code)の場合:
①左下のアイコンをクリックし、推論設定をクリック

②カスタム推論ヘッダーを以下の通り設定して変更を適用

X-Header-Name:X-Amzn-Bedrock-Request-Metadata
値:{“source”:”claude-desktop”}

日次レポートの例

デフォルトでは毎日9:00(JST)に以下のようなメールが届きます(ReportScheduleHourJst/ReportScheduleMinuteJstで変更可能)。

[Bedrock Cost Guard] 日次コストレポート 2026-06-18

AWSアカウント: 123456789012
(1 USD = 160 JPY)

今月 (6/1-6/18): $28.45 (JPY4,552)

---

[今月のユーザー別コスト]

  tanaka: $8.20 (JPY1,312) (上限: $50.00)  [Code $7.10 / Desktop $1.10]
  suzuki: $6.50 (JPY1,040) (上限: $50.00)  [Code $6.50]
  yamada: $3.80 (JPY608) (上限: $30.00)  [Desktop $3.80]
  sato: $1.20 (JPY192) (上限: $30.00)  [Code $0.90 / Desktop $0.30]

[今月のロール別コスト]

  team-a-bedrock-role: $5.50 (JPY880) (上限: $100.00)  [Code $3.20 / Desktop $2.30]
  team-b-bedrock-role: $2.25 (JPY360) (上限: $80.00)  [Code $2.25]
  intern-bedrock-role: $1.00 (JPY160) (上限: $10.00)  [Desktop $1.00]

 

上限設定の仕組み

CostLimitUsdパラメータで、IAMユーザー名またはIAMロール名ごとに月額上限を指定します。

設定例:

tanaka:50,team-a-bedrock-role:100,team-b-bedrock-role:80

上限超過時の動作:

  • 80%到達時 → SNSで警告メール送信(まだ利用可能)
  • 100%超過時 → IAM DenyPolicyを自動アタッチし遮断 + SNSで遮断通知
  • 月初1日 0:00 JST → DenyPolicyを自動デタッチ(全員解除)

 

S3ログ保管(オプション機能)

EnableS3Logging=true を設定すると、Bedrockの呼び出しログがS3バケットにも保管されます。

設定 効果
EnableS3Logging=true S3バケットを作成し、Bedrockログを保管
EnableLogEncryption=true KMSカスタマーキーでバケットを暗号化
LogReaderArns=arn:... 指定したIAMエンティティのみ復号を許可。それ以外はAccessDenied

 

アラートが届いたときの対応

① 「警告: 上限の80%に到達」
当該ユーザーに利用ペースの抑制を周知

② 「利用停止: DenyPolicyアタッチ」
月初に自動解除。緊急時はIAMコンソールから手動デタッチ

③ 「スロットリング発生」(CloudWatch Alarm)
Service Quotas引き上げ申請 or クロスリージョン推論プロファイルで分散

 

制約・留意事項

  • 推定コストは概算です。厳密な上限保証ではなくガードレールです
  • 検知から遮断までに最大約16分の遅延があります(CWLログ配信5-15分 + cap_check間隔1分)
  • requestMetadata.sourceを設定していないユーザーの利用は集計対象外
  • IAM Identity Center(SSO)の権限セットロール(AWSReservedSSO_*)には直接DenyPolicyをアタッチできません。カスタムロールへのAssumeRole運用を推奨
  • Claude Desktop内のChat/Cowork/Codeモードの区別はできません(同一source値)
  • DynamoDBテーブルとロググループはスタック削除後も保持されます(DeletionPolicy: Retain)

 

カスタマイズガイド

やりたいこと 変更箇所
通知先を変更 EmailAddressを変更。複数人に送りたい場合はメーリングリストを指定してください
上限額を変更 CostLimitUsdを変更
特定ユーザーを上限対象外に ExcludeUsersに追加
レポート送信時刻を変更 ReportScheduleHourJst / ReportScheduleMinuteJstを変更
料金単価を更新(料金改定時) ModelPricingを更新してスタック更新
為替レートを変更 JpyRateを変更
既存リソースを再利用 ExistingLogGroupName / ExistingCostTableName等を指定

 

まとめ

  • CloudFormationテンプレート1つで、Claude on Amazon Bedrockのユーザー/チーム単位のコスト管理が完結する
  • 検知から遮断まで最大16分の遅延があるものの、月額$2未満(小規模の場合)でプロキシもコンテナも不要
  • 5〜20名規模の検証・導入フェーズでは十分に実用的。50名超になったらLiteLLM等への移行を検討

公式から利用料の上限設定機能が提供された際には不要な仕組みになりますが、それまでの間にどなたかの参考になりましたら幸いです。

 

×
タイトルとURLをコピーしました