NTT DATA TECH
📈

OpenAI APIコストをiPhoneホーム画面で監視する仕組みを作ってみた(Cloud Run + Scriptable)

に公開

はじめに

AIを活用した開発が一般化するにつれて、API利用料を日常的に意識する場面が増えてきました。

最近では、ノートPC上で動作する自律型 AI エージェント(例:OpenClaw など)や開発支援ツールを常時稼働させ、外出先からスマートフォン経由で指示を与えるような利用スタイルも増えつつあります。

このような環境では、API利用料はバックグラウンドで継続的に増加するため、
「気づいたら想定以上の課金が発生していた」 という状況が起こり得ます。

しかし実際の運用では、以下のような課題があります。

  • API利用料を確認するために OpenAI Platform(管理コンソール)を毎回開く必要がある
  • OpenAI Platformにはメール通知機能は存在するものの、短時間のスパイクや異常増加をリアルタイムに把握することは難しい

そこで本記事では、

  • OpenAI Organization Costs API
  • Cloud Run Proxy
  • Secret Manager
  • iPhone Widget (Scriptable)

を組み合わせ、

OpenAI API利用料をiPhoneのホーム画面から可視化する仕組み

を構築します。

最終的に以下の情報をWidget(ウィジェット)として表示します。

  • MTD(Month To Date:今月累計利用額)
  • 直近30日間の利用額(Rolling 30日)
  • 予算消費率
  • 日次平均コスト

本記事では、個人環境でも再現可能な最小構成で、セキュアかつ低コストにコスト監視を実現する方法を紹介します。


本記事の対象者

  • OpenAI API を日常的に利用している方
  • API利用料コスト監視をしたい方
  • Cloud Run を使った軽量 Proxy 構成に興味がある方

記事の構成

本記事は以下の流れで進めます。

  1. 全体アーキテクチャ
  2. OpenAI Admin API Key(Costs API用)の作成
  3. Google Cloud の初期設定
  4. Cloud Run にデプロイする Proxy サービスの実装
  5. Cloud Run デプロイ
  6. iPhone Widget 作成
  7. 運用コスト
  8. 今後の展望
  9. 最後に

1. 全体アーキテクチャ

今回の構成は非常にシンプルです。

ポイントは以下です。

  • OpenAI API Key を端末に置かない
  • Cloud Run を安全な中継点にする
  • Widget は読み取り専用

つまり、

Secret をクラウド側に閉じ込める設計

になっています。

この構成に必要なファイル構成は3つだけで完結します。
本記事では、短時間で実装することを念頭に置いたため、Google Cloud リソースについてはIaC管理していません。

.
├── Dockerfile
├── package.json
└── server.js

2. OpenAI Admin API Key(Costs API用)の作成

Organization単位で利用する管理APIであるCosts API (※1)は、OpenAI Platform の Organization settings から Admin keys を作成する必要があります。
LLMを呼び出すAPI Keysとは別物です。

※ 本記事では検証簡略化のため Admin Key に All 権限を付与しています。
実運用では最小権限での発行を推奨します。


3. Google Cloud の初期設定

本構成では、OpenAI API Key を安全に扱うために
Google Cloud Secret Manager + Service Account を使用します。

ここでは実際に Cloud Run から Secret を安全に参照できるように設定します。

また、本記事では以下の環境変数を使用します。

SERVICE : Cloud Run サービス名
PROJECT_ID : GCPプロジェクトID
REGION : デプロイリージョン
WIDGET_TOKEN : 共有トークン

STEP 1 — API有効化

まず対象プロジェクトを設定します。

gcloud config set project ${PROJECT_ID}

続いて、本構成で使用するAPIを有効化します。

gcloud services enable \
 run.googleapis.com \
 secretmanager.googleapis.com \
 artifactregistry.googleapis.com \
 cloudbuild.googleapis.com

有効化しているサービス

サービス 目的
Cloud Run コンテナ実行基盤
Secret Manager APIキーの安全管理
Artifact Registry コンテナ保存
Cloud Build 自動ビルド

STEP 2 — Service Account 作成

Cloud Run から Secret を取得するため、専用の Service Account を作成します。

gcloud iam service-accounts create ${SERVICE}

作成されるサービスアカウント例:

${SERVICE}@${PROJECT_ID}.iam.gserviceaccount.com

STEP 3 — Secret Manager 参照権限の付与

Cloud Run が Secret を読み取れるようにSecret Accessor 権限のみを付与します。

gcloud projects add-iam-policy-binding ${PROJECT_ID} \
  --member="serviceAccount:${SERVICE}@${PROJECT_ID}.iam.gserviceaccount.com" \
  --role="roles/secretmanager.secretAccessor"

STEP 4 — Secret Manager にAPIキー登録

OpenAI API Key を Secret Manager に保存します。

gcloud secrets create OPENAI_API_KEY
echo "sk-xxxxx" | \
gcloud secrets versions add OPENAI_API_KEY --data-file=-

APIキーはここでのみ入力します。

以降:

  • GitHub に置かない

  • 環境変数に直書きしない

  • ローカルにも保存しない

つまり、

Secret は常にクラウド側に閉じ込める

という設計になります。


4. Cloud Run にデプロイする Proxy サービスの実装

ここからは、OpenAIのコスト情報を取得する Proxy API を実装し、
Cloud Run 上にデプロイします。

本記事では以下の最小構成を採用します。

.
├── Dockerfile
├── package.json
└── server.js

GitHubにもコードを公開しているため、必要に応じてご確認ください。(※2)

Dockerfile

Cloud Run はコンテナ実行基盤のため、
Node.js アプリをコンテナ化します。

詳細
FROM node:20-slim

WORKDIR /app

COPY package.json .
RUN npm install

COPY . .

CMD ["node", "server.js"]

package.json

詳細
{
  "type": "module",
  "dependencies": {
    "express": "^4.18.2"
  }
}

server.js

このAPIの集計表示はUTC基準であるため、日本時間では日付が前後する場合があります。

詳細
import express from "express";

const app = express();

const unix = (d) => Math.floor(d.getTime() / 1000);

function utcMidnight(date) {
  return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
}

function utcTomorrowMidnight() {
  const end = utcMidnight(new Date());
  end.setUTCDate(end.getUTCDate() + 1);
  return end;
}

function utcMonthStart() {
  const now = new Date();
  return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0, 0));
}

function utcRolling30Start() {
  const start = utcMidnight(new Date());
  start.setUTCDate(start.getUTCDate() - 30);
  return start;
}

// ===== Shared-secret auth (Widget token) =====
function requireWidgetToken(req, res) {
  const expected = process.env.WIDGET_TOKEN;
  const got = req.header("X-Widget-Token");

  if (!expected) {
    res.status(500).json({ ok: false, error: "WIDGET_TOKEN not configured" });
    return false;
  }
  if (!got || got !== expected) {
    res.status(403).json({ ok: false, error: "Forbidden" });
    return false;
  }
  return true;
}

async function fetchCostsUSD(start_time, end_time) {
  const url = new URL("https://api.openai.com/v1/organization/costs");
  url.searchParams.set("start_time", String(start_time));
  url.searchParams.set("end_time", String(end_time)); // exclusive
  url.searchParams.set("bucket_width", "1d");
  url.searchParams.set("limit", "180");

  const r = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
  });

  const body = await r.json();
  if (!r.ok) {
    const msg = body?.error?.message ?? JSON.stringify(body);
    throw new Error(`Costs API ${r.status}: ${msg}`);
  }

  const total = (body?.data ?? [])
    .flatMap((b) => b?.results ?? [])
    .reduce((sum, item) => {
      const v = Number(item?.amount?.value ?? 0);
      return sum + (Number.isFinite(v) ? v : 0);
    }, 0);

  return Math.round(total * 100) / 100;
}

app.get("/usage", async (req, res) => {
  if (!requireWidgetToken(req, res)) return;

  try {
    const end = utcTomorrowMidnight();
    const mtdStart = utcMonthStart();
    const rollingStart = utcRolling30Start();

    const end_time = unix(end);
    const mtd_start_time = unix(mtdStart);
    const rolling_start_time = unix(rollingStart);

    const [mtd_usd, rolling_30d_usd] = await Promise.all([
      fetchCostsUSD(mtd_start_time, end_time),
      fetchCostsUSD(rolling_start_time, end_time),
    ]);

    res.json({
      ok: true,
      mtd_usd,
      rolling_30d_usd,
      range_utc: {
        mtd: { start_time: mtd_start_time, end_time },
        rolling_30d: { start_time: rolling_start_time, end_time },
      },
    });
  } catch (e) {
    res.status(500).json({ ok: false, error: String(e) });
  }
});

app.listen(8080);

WIDGET_TOKEN の役割

server.jsで登場したWIDGET_TOKENについて、

Cloud Run を公開状態にすると、以下のURLを知っている誰でもアクセス可能になってしまうため、
共有トークンを設定します。

https://xxxx.run.app/usage

Secret Manager に 共有トークン 登録

gcloud secrets create WIDGET_TOKEN
echo "${WIDGET_TOKEN}" | gcloud secrets versions add WIDGET_TOKEN --data-file=-

5. Cloud Run デプロイ

Cloud Run は Source Deploy(ソースから直接デプロイ) を使用します。ソースコードからビルドしてデプロイする方法です。

gcloud run deploy ${SERVICE} \
  --source . \
  --region ${REGION} \
  --service-account ${SERVICE}@${PROJECT_ID}.iam.gserviceaccount.com \
  --set-secrets \
OPENAI_API_KEY=OPENAI_API_KEY:latest,\
WIDGET_TOKEN=WIDGET_TOKEN:latest \
  --allow-unauthenticated

より堅牢にするなら Cloud Run の IAM 認証(認証必須)や IAP/Cloud Armor 等も選択肢として考えられますが、本記事では最小構成として共有トークンを設定する方式を採用しています。

デプロイ完了後、URLが発行されます。

動作確認

Cloud Run デプロイ完了後に発行された URL に対して、
Widget 用に設定した認証トークンを付与してアクセスします。

curl https://xxxxx.run.app/usage \
  -H "X-Widget-Token: ${WIDGET_TOKEN}"

レスポンス例:

{
  "ok": true,
  "mtd_usd": 0.42,
  "rolling_30d_usd": 0.42,
  "range_utc": {
    "mtd": {
      "start_time": 1769904000,
      "end_time": 1771891200
    },
    "rolling_30d": {
      "start_time": 1769212800,
      "end_time": 1771891200
    }
  }
}

6. iPhone Widget 作成

ここまでで Cloud Run 上に Costs API Proxy を構築しました。

次に、このAPIを iPhoneのホーム画面から直接確認できる Widget を作成します。

なぜ Scriptable を使うのか

通常 iOS Widget を開発する場合、以下が必要になります。

  • Swift
  • Xcode
  • Apple Developer (配布する場合)

ネイティブ開発は自由度が高い反面、検証用途としては初期セットアップのコストが高くなります。
そこで今回は Scriptable を使用します。
Scriptable は、JavaScript から iOS の WidgetKit を操作できるアプリであり、以下のような構成を実現できます。(※3)

本記事では「本番アプリ開発」ではなく「運用可視化を迅速に行うこと」を目的としているため、実装速度を優先して Scriptable を採用しています。

STEP 1 — Scriptable のインストール

App Store からiPhoneへScriptableをインストールします。

STEP 2 — 新規 Script 作成

Scriptable を開き「+」を押して、以下のWidget スクリプトを貼り付けて、Doneを押します。

詳細
// ====== Config ======
const url = "Cloud RunのURL";
const TOKEN = "WIDGET_TOKENと同じ値";
const BUDGET_USD = 10;
const REFRESH_MINUTES = 15;

// ====== Helpers ======
const fmt2 = (n) => Number(n).toFixed(2);
const clamp01 = (x) => Math.max(0, Math.min(1, x));

function makeGradient() {
  const g = new LinearGradient();
  g.locations = [0, 1];
  g.colors = [new Color("#0b0f14"), new Color("#15102a")];
  return g;
}

function statusColor(progress) {
  if (progress >= 1.0) return new Color("#ff453a"); // red
  if (progress >= 0.8) return new Color("#ffd60a"); // yellow
  return new Color("#30d158");                      // green
}

function text(stack, s, size, color, bold = false) {
  const t = stack.addText(String(s));
  t.font = bold ? Font.boldSystemFont(size) : Font.systemFont(size);
  t.textColor = color;
  return t;
}

function drawBar(widget, progress, color) {
  const p = clamp01(progress);

  const barBg = widget.addStack();
  barBg.size = new Size(0, 8);
  barBg.backgroundColor = new Color("#2a2a2e");
  barBg.cornerRadius = 6;

  const barFg = barBg.addStack();
  barFg.backgroundColor = color;
  barFg.cornerRadius = 6;

  barFg.size = new Size(Math.floor(260 * p), 8);
  barFg.addSpacer();
}

// ====== Fetch ======
const req = new Request(url);
req.headers = { "X-Widget-Token": TOKEN };

let data;
try {
  data = await req.loadJSON();
} catch (e) {
  data = { ok: false, error: String(e) };
}

// ====== Widget ======
const w = new ListWidget();
w.backgroundGradient = makeGradient();
w.setPadding(14, 14, 14, 14);

if (!data.ok) {
  const header = w.addStack();
  text(header, "OPENAI SPEND", 12, new Color("#8e8e93"), true);

  w.addSpacer(10);
  const err = w.addStack();
  text(err, "ERR", 24, new Color("#ff453a"), true);

  w.addSpacer(6);
  const msg = (data.error || "unknown").slice(0, 120);
  const m = w.addText(msg);
  m.font = Font.systemFont(12);
  m.textColor = new Color("#d1d1d6");

  w.refreshAfterDate = new Date(Date.now() + REFRESH_MINUTES * 60 * 1000);
  Script.setWidget(w);
  Script.complete();
  return;
}

const mtd = Number(data.mtd_usd);
const rolling30 = Number(data.rolling_30d_usd);
const dailyAvg = rolling30 / 30;

const progress = BUDGET_USD > 0 ? (mtd / BUDGET_USD) : 0;
const pColor = statusColor(progress);

// ====== Header ======
const top = w.addStack();
text(top, "OPENAI SPEND", 12, new Color("#8e8e93"), true);
top.addSpacer();

const state = progress >= 1 ? "ALERT" : progress >= 0.8 ? "WARN" : "OK";
text(top, state, 12, pColor, true);

w.addSpacer(8);

// ====== Main number ======
const main = w.addStack();
text(main, `$${fmt2(mtd)}`, 34, pColor, true);

w.addSpacer(6);

// ====== Sub metrics ======
const sub = w.addStack();
text(sub, "MTD", 12, new Color("#d1d1d6"), true);
sub.addSpacer(6);
text(sub, `$${fmt2(mtd)}`, 12, new Color("#d1d1d6"));

sub.addSpacer();
text(sub, "30d", 12, new Color("#d1d1d6"), true);
sub.addSpacer(6);
text(sub, `$${fmt2(rolling30)}`, 12, new Color("#d1d1d6"));

w.addSpacer(10);

// ====== Budget bar ======
const barRow = w.addStack();
text(barRow, "BUDGET", 11, new Color("#8e8e93"), true);
barRow.addSpacer(6);
text(barRow, `${Math.round(progress * 100)}%`, 11, new Color("#8e8e93"));

w.addSpacer(6);
drawBar(w, progress, pColor);

w.addSpacer(10);

// ====== Footer ======
const foot = w.addStack();
text(foot, "avg/day", 11, new Color("#8e8e93"));
foot.addSpacer(6);
text(foot, `$${fmt2(dailyAvg)}`, 11, new Color("#8e8e93"));

foot.addSpacer();
text(foot, "refresh", 11, new Color("#8e8e93"));
foot.addSpacer(6);
text(foot, `${REFRESH_MINUTES}m`, 11, new Color("#8e8e93"));

w.refreshAfterDate = new Date(Date.now() + REFRESH_MINUTES * 60 * 1000);

Script.setWidget(w);
Script.complete();

なお、本構成では Widget 側に共有トークン(WIDGET_TOKEN)を設定する必要があります。
本トークンは OpenAI API Key とは異なり、Proxy API へのアクセス制御のみを目的としたものです。

簡易検証ではスクリプト内へ直接記述しても問題ありませんが、
実運用では Scriptable の Keychain 機能などを利用し、コードへ直書きしない運用を推奨します。
Scriptable では、iOS Keychain を利用して情報を安全に保存できる API が提供されています。(※4)

STEP 3 — Widget をホーム画面へ追加

  1. ホーム画面長押し
  2. +ボタン
  3. Scriptable を選択
  4. Widgetサイズ選択
  5. Script を指定

これで完成です。

7. 運用コスト

本構成の月額費用はほぼゼロです。

項目 月間利用量 課金
Cloud Run 約3,000 req 無料枠内
Secret Manager 数十アクセス 数円

Cloud Runのリクエスト数は、Widget更新間隔を15分とした場合、約3,000 req/月に収まり、
東京リージョンの無料枠(200万リクエスト/月)に収まります。(※5)

iOS Widget の更新はOS制御下にあるため、実際のリクエスト回数はこの想定より少なくなる場合があります。

8. 今後の展望

将来的には以下の内容もWidgetに追加したいと考えています。

  • Anthropic / Gemini 利用料金
  • Cloud Billing
  • Forecast

9. 最後に

LLM時代では、

  • CPU
  • メモリ
  • レイテンシ

と同じように、API利用料も運用対象に含める必要性が高まってきました。

本記事で紹介した構成は非常に小さな仕組みですが、

  • Secret管理
  • Proxy設計
  • FinOps

を同時に学べる良い題材だと感じています。

同様の環境を構築する際の参考になれば幸いです。

参考

※1 : OpenAI Developers > API Reference > Organization > Audit Logs > Costs
https://developers.openai.com/api/reference/resources/organization/subresources/audit_logs/methods/get_costs
※2 : GitHub > api-costs-widget Repository
https://github.com/hosohobby/api-costs-widget
※3 : App Store > Scriptable
https://apps.apple.com/jp/app/scriptable/id1405459188
※4 : Scriptable Docs > Keychain
https://docs.scriptable.app/keychain/
※5 : Google Cloud > Cloud Runの料金
https://cloud.google.com/run/pricing?hl=ja

仲間募集中です!

NTTデータクラウド&データセンタ事業部では、以下の職種を募集しています。

  1. プライベートクラウドコンサル/エンジニア
  2. デジタルワークスペース構築/新規ソリューション開発におけるプロジェクトリーダー
  3. IT基盤/パブリッククラウド・プライベートクラウドエンジニア
  4. ミッションクリティカル/プライベートクラウドを用いた大規模プロジェクトをリードするインフラエンジニア

ソリューション紹介

  1. クラウドプロフェッショナルサービス/クラウドインテグレーションサービス/クラウドマネージドサービス/パートナークラウドサービス
  2. OpenCanvas
NTT DATA TECH
NTT DATA TECH
設定によりコメント欄が無効化されています