From 3b98490e5e3c20a3c15fb44da3f44a3568307940 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 12 Jun 2026 23:05:52 +0000
Subject: [PATCH 01/14] =?UTF-8?q?Agent=20=E3=83=A2=E3=83=87=E3=83=AB?=
=?UTF-8?q?=E5=88=87=E3=82=8A=E6=9B=BF=E3=81=88=E3=81=A8=E3=83=97=E3=83=AA?=
=?UTF-8?q?=E3=83=9A=E3=82=A4=E3=83=89=E3=82=AF=E3=83=AC=E3=82=B8=E3=83=83?=
=?UTF-8?q?=E3=83=88=E8=AA=B2=E9=87=91=E5=9F=BA=E7=9B=A4=E3=82=92=E8=BF=BD?=
=?UTF-8?q?=E5=8A=A0=EF=BC=88Phase=201=20/=20ADR-0012=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Agent チャットに model エイリアス(haiku: 無料 / sonnet: 有料)を追加。
実モデル ID と課金レートは model_catalog.py(SSoT)でサーバー側解決
- LLMClient.generate() の契約を変更し実トークン使用量(LLMResult)を返却。
リトライ時は 2 回分の使用量を合算して課金
- クレジット基盤: users.credit_balance(キャッシュ)+ credit_transactions(台帳)
+ agent_usage_logs(使用記録)。消費は原子的 UPDATE + 同一トランザクション台帳追記
- sonnet は事前残高チェック(不足時 402 INSUFFICIENT_CREDITS)→ 事後減算。
負残高は有界損失として許容(ADR-0012)
- 課金エンドポイント: GET /api/billing/balance / GET /api/billing/transactions /
POST /api/billing/admin/grant(ADMIN_TOKEN 認証・テスト用付与)
- フロント: AgentChatWidget にモデルセレクタと残高表示、useCreditBalance フック、
エラーコード 3 箇所同期(INSUFFICIENT_CREDITS)、codegen-types 再生成
- マイグレーション 0044(users ADD COLUMN + 課金 2 テーブル、実 SQLite で往復確認済み)
- ADR-0012 追加(モデル切り替え / プリペイド課金 / Stripe Checkout 方針)
Phase 2(Stripe Checkout 購入フロー + Webhook)は別 PR で実装する。
https://claude.ai/code/session_01VHYFnKVfBX64pNdhb5kxVt
---
.../versions/0044_add_credit_billing.py | 88 +++++
backend/app/core/errors.py | 2 +
backend/app/main.py | 2 +
backend/app/messages.json | 4 +
backend/app/models/__init__.py | 3 +
backend/app/models/billing.py | 70 ++++
backend/app/models/user.py | 7 +-
backend/app/repositories/__init__.py | 2 +
backend/app/repositories/billing.py | 91 +++++
backend/app/routers/__init__.py | 2 +
backend/app/routers/agent.py | 21 +-
backend/app/routers/billing.py | 80 ++++
backend/app/schemas/agent.py | 7 +
backend/app/schemas/billing.py | 35 ++
backend/app/services/agent/chat_service.py | 56 ++-
.../services/agent/llm/anthropic_client.py | 23 +-
backend/app/services/agent/llm/base.py | 23 +-
.../app/services/agent/llm/ollama_client.py | 13 +-
backend/app/services/agent/model_catalog.py | 87 +++++
backend/app/services/billing/__init__.py | 1 +
.../app/services/billing/credit_service.py | 100 +++++
backend/tests/test_agent.py | 38 +-
backend/tests/test_billing.py | 353 ++++++++++++++++++
...ent-model-switching-and-prepaid-billing.md | 114 ++++++
docs/api.md | 7 +-
frontend/e2e/agent-chat.spec.ts | 73 ++++
frontend/src/api/billing.ts | 16 +
frontend/src/api/generated.ts | 187 +++++++++-
frontend/src/api/paths.ts | 4 +
frontend/src/api/types.ts | 11 +
.../forms/AgentChatWidget.module.css | 6 +
.../src/components/forms/AgentChatWidget.tsx | 48 ++-
frontend/src/constants/errorCodes.ts | 2 +
frontend/src/constants/errorMessages.ts | 4 +
frontend/src/constants/messages.ts | 14 +
.../src/hooks/career/useAgentChat.test.ts | 22 ++
frontend/src/hooks/career/useAgentChat.ts | 4 +
frontend/src/hooks/useCreditBalance.test.ts | 76 ++++
frontend/src/hooks/useCreditBalance.ts | 38 ++
39 files changed, 1691 insertions(+), 43 deletions(-)
create mode 100644 backend/alembic_migrations/versions/0044_add_credit_billing.py
create mode 100644 backend/app/models/billing.py
create mode 100644 backend/app/repositories/billing.py
create mode 100644 backend/app/routers/billing.py
create mode 100644 backend/app/schemas/billing.py
create mode 100644 backend/app/services/agent/model_catalog.py
create mode 100644 backend/app/services/billing/__init__.py
create mode 100644 backend/app/services/billing/credit_service.py
create mode 100644 backend/tests/test_billing.py
create mode 100644 docs/adr/0012-agent-model-switching-and-prepaid-billing.md
create mode 100644 frontend/src/api/billing.ts
create mode 100644 frontend/src/hooks/useCreditBalance.test.ts
create mode 100644 frontend/src/hooks/useCreditBalance.ts
diff --git a/backend/alembic_migrations/versions/0044_add_credit_billing.py b/backend/alembic_migrations/versions/0044_add_credit_billing.py
new file mode 100644
index 00000000..8057618d
--- /dev/null
+++ b/backend/alembic_migrations/versions/0044_add_credit_billing.py
@@ -0,0 +1,88 @@
+"""プリペイドクレジット課金の基盤テーブルを追加する(ADR-0012)
+
+- users.credit_balance: 残高キャッシュ(正本は credit_transactions 台帳)。
+ 既存行は残高 0 から開始するため server_default="0"
+- credit_transactions: 付与・消費の追記専用台帳。stripe_session_id の UNIQUE 制約は
+ Phase 2(Stripe Webhook)の二重付与防止用に最初から張る
+- agent_usage_logs: Agent チャットの実トークン使用量記録(無料モデル含む)
+
+libSQL (SQLite 互換) は ADD COLUMN を直接サポートするため upgrade は op.add_column を使う。
+users は子テーブルから FK 参照される親テーブルのため batch_alter_table(テーブル再作成)を
+使わない。素のカラムは SQLite/libSQL 3.35+ の ALTER TABLE DROP COLUMN で直接削除できる。
+
+Revision ID: 0044_add_credit_billing
+Revises: 0043_add_contact_to_resumes
+Create Date: 2026-06-12 00:00:00.000000
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0044_add_credit_billing"
+down_revision: Union[str, None] = "0043_add_contact_to_resumes"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "users",
+ sa.Column(
+ "credit_balance", sa.Integer(), nullable=False, server_default="0"
+ ),
+ )
+ op.create_table(
+ "credit_transactions",
+ sa.Column("id", sa.String(length=36), primary_key=True),
+ sa.Column(
+ "user_id",
+ sa.String(length=36),
+ sa.ForeignKey("users.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column("amount", sa.Integer(), nullable=False),
+ sa.Column("balance_after", sa.Integer(), nullable=False),
+ sa.Column("transaction_type", sa.String(length=20), nullable=False),
+ sa.Column("description", sa.String(length=200), nullable=True),
+ sa.Column("stripe_session_id", sa.String(length=255), nullable=True, unique=True),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ server_default=sa.func.now(),
+ nullable=False,
+ ),
+ )
+ op.create_index(
+ "ix_credit_transactions_user_id", "credit_transactions", ["user_id"]
+ )
+ op.create_table(
+ "agent_usage_logs",
+ sa.Column("id", sa.String(length=36), primary_key=True),
+ sa.Column(
+ "user_id",
+ sa.String(length=36),
+ sa.ForeignKey("users.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column("model_alias", sa.String(length=20), nullable=False),
+ sa.Column("input_tokens", sa.Integer(), nullable=False),
+ sa.Column("output_tokens", sa.Integer(), nullable=False),
+ sa.Column("credit_cost", sa.Integer(), nullable=False),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ server_default=sa.func.now(),
+ nullable=False,
+ ),
+ )
+ op.create_index("ix_agent_usage_logs_user_id", "agent_usage_logs", ["user_id"])
+
+
+def downgrade() -> None:
+ op.drop_index("ix_agent_usage_logs_user_id", table_name="agent_usage_logs")
+ op.drop_table("agent_usage_logs")
+ op.drop_index("ix_credit_transactions_user_id", table_name="credit_transactions")
+ op.drop_table("credit_transactions")
+ op.drop_column("users", "credit_balance")
diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py
index f18c27ee..cb54bcb3 100644
--- a/backend/app/core/errors.py
+++ b/backend/app/core/errors.py
@@ -33,6 +33,8 @@ class ErrorCode(str, Enum):
# Agent(LLM チャット / ADR-0010)
AGENT_LLM_ERROR = "AGENT_LLM_ERROR"
AGENT_PARSE_ERROR = "AGENT_PARSE_ERROR"
+ # 課金(プリペイドクレジット / ADR-0012)
+ INSUFFICIENT_CREDITS = "INSUFFICIENT_CREDITS"
# アプリケーション全体
RATE_LIMITED = "RATE_LIMITED"
# サーバー
diff --git a/backend/app/main.py b/backend/app/main.py
index f4f48f30..1ce8101b 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -31,6 +31,7 @@
from .routers import ( # noqa: E402
agent_router,
auth_router,
+ billing_router,
blog_router,
github_link_router,
health_router,
@@ -215,3 +216,4 @@ async def dispatch(self, request: Request, call_next) -> Response:
app.include_router(notifications_router)
app.include_router(internal_router)
app.include_router(agent_router)
+app.include_router(billing_router)
diff --git a/backend/app/messages.json b/backend/app/messages.json
index 4ce0d7b3..3fff65a8 100644
--- a/backend/app/messages.json
+++ b/backend/app/messages.json
@@ -73,6 +73,10 @@
"parse_failed": "AI の応答を解釈できませんでした。もう一度お試しください。",
"target_required": "このスコープでは対象の指定が必要です。",
"target_not_found": "指定された対象が見つかりません。"
+ },
+ "billing": {
+ "insufficient_credits": "クレジット残高が不足しています。Haiku(無料)に切り替えるか、クレジットを追加してください。",
+ "grant_user_not_found": "付与対象のユーザーが見つかりません。"
}
},
"notification": {
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index abf4a70d..6b89451f 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -1,5 +1,6 @@
"""SQLAlchemy モデル。"""
+from .billing import AgentUsageLog, CreditTransaction
from .blog import BlogAccount, BlogArticle, BlogArticleTag
from .cache import GitHubLinkCache
from .master_data import MQualification, MTechnologyStack
@@ -18,9 +19,11 @@
from .user import User
__all__ = [
+ "AgentUsageLog",
"BlogAccount",
"BlogArticle",
"BlogArticleTag",
+ "CreditTransaction",
"GitHubLinkCache",
"MQualification",
"MTechnologyStack",
diff --git a/backend/app/models/billing.py b/backend/app/models/billing.py
new file mode 100644
index 00000000..ecb3dbfd
--- /dev/null
+++ b/backend/app/models/billing.py
@@ -0,0 +1,70 @@
+"""クレジット課金モデル(ADR-0012)。
+
+``credit_transactions`` は付与・消費の全履歴を持つ追記専用の台帳(正本)。
+``users.credit_balance`` はそのキャッシュで、台帳と同一トランザクション内で更新する。
+``agent_usage_logs`` はモデル別の実トークン使用量の記録(無料モデルも記録する)。
+"""
+
+import uuid
+from datetime import datetime
+
+from sqlalchemy import DateTime, ForeignKey, Integer, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from ..db import Base
+
+
+class CreditTransaction(Base):
+ """クレジットの付与・消費 1 件分の台帳エントリ(追記専用)。"""
+
+ __tablename__ = "credit_transactions"
+
+ id: Mapped[str] = mapped_column(
+ String(36), primary_key=True, default=lambda: str(uuid.uuid4())
+ )
+ user_id: Mapped[str] = mapped_column(
+ String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
+ )
+ # 符号付きの増減量(付与: 正 / 消費: 負)
+ amount: Mapped[int] = mapped_column(Integer, nullable=False)
+ # 本トランザクション適用後の残高(監査・デバッグ用のスナップショット)
+ balance_after: Mapped[int] = mapped_column(Integer, nullable=False)
+ # 種別: consumption(チャット消費)/ admin_grant(管理者付与)/ purchase(Stripe 購入)
+ transaction_type: Mapped[str] = mapped_column(String(20), nullable=False)
+ description: Mapped[str | None] = mapped_column(String(200), nullable=True, default=None)
+ # Stripe Checkout Session ID。UNIQUE 制約により Webhook 再送時の二重付与を防ぐ
+ # (Phase 2 で使用。Phase 1 では常に NULL / ADR-0012)
+ stripe_session_id: Mapped[str | None] = mapped_column(
+ String(255), nullable=True, unique=True, default=None
+ )
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ default=func.now(),
+ server_default=func.now(),
+ nullable=False,
+ )
+
+
+class AgentUsageLog(Base):
+ """Agent チャット 1 回分の実トークン使用量の記録(コスト分析用)。"""
+
+ __tablename__ = "agent_usage_logs"
+
+ id: Mapped[str] = mapped_column(
+ String(36), primary_key=True, default=lambda: str(uuid.uuid4())
+ )
+ user_id: Mapped[str] = mapped_column(
+ String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
+ )
+ # model_catalog.py のエイリアス(haiku / sonnet)
+ model_alias: Mapped[str] = mapped_column(String(20), nullable=False)
+ input_tokens: Mapped[int] = mapped_column(Integer, nullable=False)
+ output_tokens: Mapped[int] = mapped_column(Integer, nullable=False)
+ # 消費クレジット(無料モデルは 0)
+ credit_cost: Mapped[int] = mapped_column(Integer, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ default=func.now(),
+ server_default=func.now(),
+ nullable=False,
+ )
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
index c5d3a11a..f932f332 100644
--- a/backend/app/models/user.py
+++ b/backend/app/models/user.py
@@ -1,7 +1,7 @@
import uuid
from datetime import datetime
-from sqlalchemy import DateTime, String, func
+from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from ..db import Base
@@ -16,6 +16,11 @@ class User(Base):
github_id: Mapped[int | None] = mapped_column(nullable=True, unique=True, default=None)
github_token: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
refresh_jti: Mapped[str | None] = mapped_column(String(36), nullable=True, default=None)
+ # プリペイドクレジット残高のキャッシュ(正本は credit_transactions 台帳 / ADR-0012)。
+ # 事前チェック通過後の事後減算により一時的に負になりうる(有界損失を許容)
+ credit_balance: Mapped[int] = mapped_column(
+ Integer, nullable=False, default=0, server_default="0"
+ )
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py
index 41f3b72c..aecbc272 100644
--- a/backend/app/repositories/__init__.py
+++ b/backend/app/repositories/__init__.py
@@ -1,6 +1,7 @@
"""Repository 層。"""
from .base import BaseMasterRepository, SingleUserDocumentRepository
+from .billing import BillingRepository
from .blog import BlogAccountRepository, BlogArticleRepository
from .master_data import MQualificationRepository, MTechnologyStackRepository
from .resume import ResumeRepository
@@ -8,6 +9,7 @@
__all__ = [
"BaseMasterRepository",
+ "BillingRepository",
"BlogAccountRepository",
"BlogArticleRepository",
"MQualificationRepository",
diff --git a/backend/app/repositories/billing.py b/backend/app/repositories/billing.py
new file mode 100644
index 00000000..9e57ddfc
--- /dev/null
+++ b/backend/app/repositories/billing.py
@@ -0,0 +1,91 @@
+"""クレジット課金リポジトリ(ADR-0012)。
+
+残高更新は ``UPDATE users SET credit_balance = credit_balance + :delta`` の
+原子的 UPDATE で行い、同一トランザクション内で台帳(credit_transactions)へ
+追記してから commit する。Cloud Run 単一インスタンス(ADR-0005)前提のため
+分散ロックは持たない。
+"""
+
+from sqlalchemy import select, update
+from sqlalchemy.orm import Session
+
+from ..models.billing import AgentUsageLog, CreditTransaction
+from ..models.user import User
+
+
+class BillingRepository:
+ """クレジット残高・台帳・使用ログのデータアクセス層。"""
+
+ def __init__(self, db: Session, user_id: str):
+ self.db = db
+ self.user_id = user_id
+
+ def get_balance(self) -> int:
+ """現在のクレジット残高を返す。"""
+ balance = self.db.scalar(
+ select(User.credit_balance).where(User.id == self.user_id)
+ )
+ return balance or 0
+
+ def apply_transaction(
+ self,
+ *,
+ amount: int,
+ transaction_type: str,
+ description: str | None = None,
+ stripe_session_id: str | None = None,
+ ) -> int:
+ """残高を原子的に増減し、台帳へ追記して適用後残高を返す。
+
+ amount は符号付き(付与: 正 / 消費: 負)。残高更新と台帳追記を
+ 同一トランザクションで commit する(片方だけ反映される状態を作らない)。
+ """
+ self.db.execute(
+ update(User)
+ .where(User.id == self.user_id)
+ .values(credit_balance=User.credit_balance + amount)
+ )
+ balance_after = self.db.scalar(
+ select(User.credit_balance).where(User.id == self.user_id)
+ )
+ if balance_after is None:
+ # CASCADE 削除等でユーザーが消えた直後の競合。残高不明のまま進めない
+ self.db.rollback()
+ raise RuntimeError(f"残高更新対象のユーザーが存在しません: {self.user_id}")
+ self.db.add(
+ CreditTransaction(
+ user_id=self.user_id,
+ amount=amount,
+ balance_after=balance_after,
+ transaction_type=transaction_type,
+ description=description,
+ stripe_session_id=stripe_session_id,
+ )
+ )
+ self.db.commit()
+ return balance_after
+
+ def add_usage_log(
+ self, *, model_alias: str, input_tokens: int, output_tokens: int, credit_cost: int
+ ) -> None:
+ """Agent チャット 1 回分の使用ログを記録する(無料モデルも記録する)。"""
+ self.db.add(
+ AgentUsageLog(
+ user_id=self.user_id,
+ model_alias=model_alias,
+ input_tokens=input_tokens,
+ output_tokens=output_tokens,
+ credit_cost=credit_cost,
+ )
+ )
+ self.db.commit()
+
+ def list_transactions(self, limit: int = 50) -> list[CreditTransaction]:
+ """台帳履歴を新しい順に返す。"""
+ stmt = (
+ select(CreditTransaction)
+ .where(CreditTransaction.user_id == self.user_id)
+ .order_by(CreditTransaction.created_at.desc(), CreditTransaction.id)
+ .limit(limit)
+ )
+ return list(self.db.scalars(stmt).all())
diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py
index 0875771b..2f2dbcd1 100644
--- a/backend/app/routers/__init__.py
+++ b/backend/app/routers/__init__.py
@@ -1,5 +1,6 @@
from .agent import router as agent_router
from .auth import router as auth_router
+from .billing import router as billing_router
from .blog import router as blog_router
from .github_link import router as github_link_router
from .health import router as health_router
@@ -11,6 +12,7 @@
__all__ = [
"agent_router",
"auth_router",
+ "billing_router",
"blog_router",
"github_link_router",
"health_router",
diff --git a/backend/app/routers/agent.py b/backend/app/routers/agent.py
index 703ee784..7b53c1ad 100644
--- a/backend/app/routers/agent.py
+++ b/backend/app/routers/agent.py
@@ -24,6 +24,8 @@
)
from ..services.agent.context_builder import build_reference_context
from ..services.agent.llm.base import LLMError
+from ..services.billing import credit_service
+from ..services.billing.credit_service import InsufficientCreditsError
logger = logging.getLogger(__name__)
@@ -41,12 +43,23 @@ async def agent_chat(
"""選択スコープの内容とプロンプトをもとに、職務経歴書への差分 operations を返す。
career_summary / self_pr スコープでは GitHub・ブログ分析サマリーを参照情報として付与する。
- レスポンスはフロントの state にのみ適用され、DB は更新しない。
+ レスポンスはフロントの state にのみ適用され、DB は更新しない
+ (クレジット消費・使用ログの記録は除く / ADR-0012)。
ユーザーが確認して「適用」した時点で既存の保存 API が呼ばれる。
"""
+ # 有料モデル(sonnet)は LLM を呼ぶ前に残高をチェックする。実コストは応答後に
+ # 確定するため事後減算とし、チェック通過後の負残高は許容する(ADR-0012)
+ try:
+ credit_service.ensure_can_use_model(db, user.id, body.model)
+ except InsufficientCreditsError:
+ raise_app_error(
+ status_code=402,
+ code=ErrorCode.INSUFFICIENT_CREDITS,
+ message=get_error("billing.insufficient_credits"),
+ )
try:
reference = build_reference_context(db, user.id, body.scope)
- return await chat_service.run_agent_chat(body, reference)
+ result = await chat_service.run_agent_chat(body, reference)
except AgentTargetNotFoundError:
raise_app_error(
status_code=422,
@@ -65,3 +78,7 @@ async def agent_chat(
code=ErrorCode.AGENT_PARSE_ERROR,
message=get_error("agent.parse_failed"),
)
+ # 実トークン量に基づくクレジット消費 + 使用ログ記録(haiku はログのみ)。
+ # 記録失敗は応答を返さず 500 にする(課金漏れを黙って通さない / ADR-0012)
+ credit_service.record_chat_usage(db, user.id, result.usage)
+ return result.response
diff --git a/backend/app/routers/billing.py b/backend/app/routers/billing.py
new file mode 100644
index 00000000..527e3095
--- /dev/null
+++ b/backend/app/routers/billing.py
@@ -0,0 +1,80 @@
+"""クレジット課金エンドポイント(ADR-0012)。
+
+Phase 1: 残高照会・台帳履歴・管理者付与のみ。
+Phase 2 で Stripe Checkout(購入セッション作成・Webhook 入金)を追加する。
+"""
+
+import logging
+
+from fastapi import APIRouter, Depends
+from sqlalchemy.orm import Session
+
+from ..core.errors import ErrorCode, raise_app_error
+from ..core.messages import get_error
+from ..core.security.auth import get_current_user
+from ..core.security.dependencies import verify_admin_token
+from ..db import get_db
+from ..models import User
+from ..repositories import BillingRepository, UserRepository
+from ..schemas.billing import (
+ AdminCreditGrantRequest,
+ CreditBalanceResponse,
+ CreditTransactionResponse,
+)
+from ..services.billing import credit_service
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api/billing", tags=["billing"])
+
+
+@router.get("/balance", response_model=CreditBalanceResponse)
+def get_credit_balance(
+ user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> CreditBalanceResponse:
+ """ログインユーザーのクレジット残高を返す。"""
+ return CreditBalanceResponse(balance=BillingRepository(db, user.id).get_balance())
+
+
+@router.get("/transactions", response_model=list[CreditTransactionResponse])
+def list_credit_transactions(
+ user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> list[CreditTransactionResponse]:
+ """クレジット台帳履歴(付与・消費)を新しい順に返す。"""
+ transactions = BillingRepository(db, user.id).list_transactions()
+ return [CreditTransactionResponse.model_validate(t) for t in transactions]
+
+
+@router.post("/admin/grant", response_model=CreditBalanceResponse)
+def admin_grant_credits(
+ body: AdminCreditGrantRequest,
+ _: None = Depends(verify_admin_token),
+ db: Session = Depends(get_db),
+) -> CreditBalanceResponse:
+ """管理者がユーザーへクレジットを付与する(Phase 1 の残高調整・テスト用)。
+
+ ADMIN_TOKEN(Bearer)認証。Stripe 導入後も返金・補填時の残高調整用に残す。
+ """
+ target = UserRepository(db).get_by_username(body.username)
+ if target is None:
+ raise_app_error(
+ status_code=404,
+ code=ErrorCode.VALIDATION_ERROR,
+ message=get_error("billing.grant_user_not_found"),
+ )
+ balance_after = credit_service.grant_credits(
+ db,
+ target.id,
+ body.amount,
+ transaction_type=credit_service.TRANSACTION_TYPE_ADMIN_GRANT,
+ description=body.description,
+ )
+ logger.info(
+ "管理者クレジット付与: username=%s amount=%d balance_after=%d",
+ body.username,
+ body.amount,
+ balance_after,
+ )
+ return CreditBalanceResponse(balance=balance_after)
diff --git a/backend/app/schemas/agent.py b/backend/app/schemas/agent.py
index 64002214..4f8db8f7 100644
--- a/backend/app/schemas/agent.py
+++ b/backend/app/schemas/agent.py
@@ -18,6 +18,10 @@
AgentScope = Literal["project", "career_summary", "self_pr", "experience"]
+# 選択可能な LLM モデルのエイリアス(ADR-0012)。実モデル ID・課金レートは
+# services/agent/model_catalog.py の MODEL_CATALOG が正本(キー集合を一致させる)
+AgentModelAlias = Literal["haiku", "sonnet"]
+
class AgentTechnologyStack(BaseModel):
"""LLM コンテキスト用の技術スタック(保存契約より緩い)。"""
@@ -105,6 +109,9 @@ class AgentChatRequest(BaseModel):
scope: AgentScope
prompt: str = Field(min_length=1, max_length=2000)
+ # 使用モデル。haiku は無料・使い放題、sonnet は有料(クレジット消費 / ADR-0012)。
+ # デフォルト haiku で既存クライアントと後方互換
+ model: AgentModelAlias = "haiku"
resume: AgentResumeContext
# project は ProjectTarget | ExperienceTarget | None の union(ProjectTarget を先に配置)。
# ExperienceTarget は extra="forbid" により 3 キー payload がマッチしないため
diff --git a/backend/app/schemas/billing.py b/backend/app/schemas/billing.py
new file mode 100644
index 00000000..220d19c8
--- /dev/null
+++ b/backend/app/schemas/billing.py
@@ -0,0 +1,35 @@
+"""クレジット課金の Pydantic スキーマ(ADR-0012)。"""
+
+from datetime import datetime
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class CreditBalanceResponse(BaseModel):
+ """クレジット残高。"""
+
+ balance: int
+
+
+class CreditTransactionResponse(BaseModel):
+ """クレジット台帳エントリ 1 件(履歴表示用)。"""
+
+ model_config = ConfigDict(from_attributes=True)
+
+ id: str
+ # 符号付きの増減量(付与: 正 / 消費: 負)
+ amount: int
+ balance_after: int
+ # consumption / admin_grant / purchase
+ transaction_type: str
+ description: str | None = None
+ created_at: datetime
+
+
+class AdminCreditGrantRequest(BaseModel):
+ """管理者によるクレジット付与(Phase 1 の残高調整・テスト用)。"""
+
+ username: str = Field(min_length=1, max_length=120)
+ # 1 回の付与上限は 1,000 万クレジット($1,000 相当)。誤入力の桁あふれを防ぐ
+ amount: int = Field(gt=0, le=10_000_000)
+ description: str | None = Field(default=None, max_length=200)
diff --git a/backend/app/services/agent/chat_service.py b/backend/app/services/agent/chat_service.py
index 2c33e009..cc9ecdde 100644
--- a/backend/app/services/agent/chat_service.py
+++ b/backend/app/services/agent/chat_service.py
@@ -8,6 +8,7 @@
import json
import logging
+from dataclasses import dataclass
from pathlib import Path
from pydantic import ValidationError
@@ -16,11 +17,13 @@
AgentChatRequest,
AgentChatResponse,
AgentExperienceContext,
+ AgentModelAlias,
AgentOperation,
AgentProjectContext,
ExperienceTarget,
)
from .llm.factory import get_llm_client
+from .model_catalog import get_model_spec
from .output_schema import (
MAX_SUGGESTION_LENGTH,
MAX_SUGGESTIONS,
@@ -38,6 +41,27 @@ class AgentTargetNotFoundError(Exception):
class AgentResponseParseError(Exception):
"""LLM 応答の JSON パースまたはスキーマ検証に失敗。"""
+
+@dataclass(frozen=True)
+class AgentUsage:
+ """チャット 1 回分の実トークン使用量(リトライ分を含む合算 / ADR-0012)。
+
+ router がクレジット消費・使用ログ記録(services/billing)へ渡す。
+ 本モジュールは DB に触れない原則を維持するため、記録自体は行わない。
+ """
+
+ model: AgentModelAlias
+ input_tokens: int
+ output_tokens: int
+
+
+@dataclass(frozen=True)
+class AgentChatResult:
+ """run_agent_chat の戻り値(API レスポンス + 課金用の使用量)。"""
+
+ response: AgentChatResponse
+ usage: AgentUsage
+
# 許可外の field 名を返された時の正規化先。スコープ選択で編集対象は確定しているため、
# 小型 LLM が「自己PR」等の field 名を返しても既定 field の提案として救済する。
# project / experience は複数候補だが、自由記述の実体は description なので description に倒す
@@ -212,8 +236,8 @@ def _parse_response(raw: str, scope: str) -> AgentChatResponse:
async def run_agent_chat(
request: AgentChatRequest, reference: dict | None = None
-) -> AgentChatResponse:
- """Agent チャットを実行する。
+) -> AgentChatResult:
+ """Agent チャットを実行し、レスポンスと実トークン使用量を返す。
Args:
reference: GitHub/ブログ参照コンテキスト(career_summary / self_pr スコープのみ有効)。
@@ -225,6 +249,8 @@ async def run_agent_chat(
LLMError: LLM 呼び出しの失敗(llm.base 参照)。
"""
system_prompt = _SCOPE_PROMPTS[request.scope]
+ # エイリアス → 実モデル ID の解決はサーバー側で行う(クライアントに実 ID を持たせない)
+ model_id = get_model_spec(request.model).model_id
user_prompt = (
f"# 編集対象スコープ\n{request.scope}\n\n"
f"# 現在の内容\n{_build_context(request, reference)}\n\n"
@@ -233,8 +259,9 @@ async def run_agent_chat(
# 調査用ログはメタデータのみ出す。レジュメ本文・プロンプト本文は個人情報を含むため
# DEBUG でもログに載せない(.claude/rules/security.md「ログへの秘密情報出力禁止」)
logger.debug(
- "Agent LLM 入力: scope=%s target=%s history=%d resume_len=%d user_prompt_len=%d",
+ "Agent LLM 入力: scope=%s model=%s target=%s history=%d resume_len=%d user_prompt_len=%d",
request.scope,
+ request.model,
request.target,
len(request.history),
len(request.resume.model_dump_json()),
@@ -246,10 +273,13 @@ async def run_agent_chat(
messages.append({"role": "user", "content": user_prompt})
client = get_llm_client()
output_schema = _SCOPE_SCHEMAS[request.scope]
- raw = await client.generate(system_prompt, messages, output_schema)
- logger.debug("Agent LLM 生応答(パース前): len=%d", len(raw))
+ result = await client.generate(system_prompt, messages, output_schema, model_id)
+ # リトライしても 1 回目の API 原価は発生しているため、使用量は全呼び出しの合算で課金する
+ input_tokens = result.input_tokens
+ output_tokens = result.output_tokens
+ logger.debug("Agent LLM 生応答(パース前): len=%d", len(result.text))
try:
- return _parse_response(raw, request.scope)
+ response = _parse_response(result.text, request.scope)
except AgentResponseParseError as exc:
# スキーマ違反応答は 1 回だけリトライする。バリデーションエラーの内容を
# フィードバックして再生成させ、2 回目も失敗したらそのまま raise
@@ -257,7 +287,7 @@ async def run_agent_chat(
logger.warning("LLM 応答が出力契約に違反したためリトライ: %s", type(exc).__name__)
retry_messages = [
*messages,
- {"role": "assistant", "content": raw},
+ {"role": "assistant", "content": result.text},
{
"role": "user",
"content": (
@@ -267,6 +297,12 @@ async def run_agent_chat(
),
},
]
- raw = await client.generate(system_prompt, retry_messages, output_schema)
- logger.debug("Agent LLM リトライ応答(パース前): len=%d", len(raw))
- return _parse_response(raw, request.scope)
+ result = await client.generate(system_prompt, retry_messages, output_schema, model_id)
+ input_tokens += result.input_tokens
+ output_tokens += result.output_tokens
+ logger.debug("Agent LLM リトライ応答(パース前): len=%d", len(result.text))
+ response = _parse_response(result.text, request.scope)
+ usage = AgentUsage(
+ model=request.model, input_tokens=input_tokens, output_tokens=output_tokens
+ )
+ return AgentChatResult(response=response, usage=usage)
diff --git a/backend/app/services/agent/llm/anthropic_client.py b/backend/app/services/agent/llm/anthropic_client.py
index cc165875..6a9f2c2b 100644
--- a/backend/app/services/agent/llm/anthropic_client.py
+++ b/backend/app/services/agent/llm/anthropic_client.py
@@ -1,4 +1,4 @@
-"""Anthropic API クライアント(本番用。モデル: Claude Haiku 4.5)。"""
+"""Anthropic API クライアント(本番用。モデルは model_catalog.py で解決)。"""
import json
import logging
@@ -7,13 +7,10 @@
from ....core import settings
from ..output_schema import TOOL_NAME, build_tool_definition
-from .base import LLMClient, LLMError
+from .base import LLMClient, LLMError, LLMResult
logger = logging.getLogger(__name__)
-# 差分 operations の小さい JSON を返す用途のため Haiku を採用(ADR-0010)。
-# 精度不足が判明した場合は claude-sonnet-4-6 へ切り替える。
-_MODEL = "claude-haiku-4-5"
# operations JSON(最大 4500 文字のテキスト置換 + 説明文)に十分な上限
_MAX_TOKENS = 4096
_TIMEOUT_SECONDS = 60.0
@@ -38,11 +35,12 @@ async def generate(
system_prompt: str,
messages: list[dict[str, str]],
output_schema: dict,
- ) -> str:
- """tool use 強制で Anthropic API を呼び出し、tool input を JSON 文字列で返す。"""
+ model_id: str,
+ ) -> LLMResult:
+ """tool use 強制で Anthropic API を呼び出し、tool input と実使用量を返す。"""
try:
response = await self._client.messages.create(
- model=_MODEL,
+ model=model_id,
max_tokens=_MAX_TOKENS,
temperature=_TEMPERATURE,
system=system_prompt,
@@ -66,5 +64,10 @@ async def generate(
)
if block is None:
raise LLMError("Anthropic API が tool_use 応答を返しませんでした")
- # 履歴契約(前回応答を JSON 文字列で持ち回す)を維持するため再シリアライズして返す
- return json.dumps(block.input, ensure_ascii=False)
+ # 履歴契約(前回応答を JSON 文字列で持ち回す)を維持するため再シリアライズして返す。
+ # usage はクレジット課金(ADR-0012)の根拠となるため実測値をそのまま返す
+ return LLMResult(
+ text=json.dumps(block.input, ensure_ascii=False),
+ input_tokens=response.usage.input_tokens,
+ output_tokens=response.usage.output_tokens,
+ )
diff --git a/backend/app/services/agent/llm/base.py b/backend/app/services/agent/llm/base.py
index 397f8813..b1e308f9 100644
--- a/backend/app/services/agent/llm/base.py
+++ b/backend/app/services/agent/llm/base.py
@@ -1,6 +1,7 @@
"""LLM クライアントの抽象基底クラスと共通例外。"""
from abc import ABC, abstractmethod
+from dataclasses import dataclass
class LLMError(Exception):
@@ -11,6 +12,19 @@ class LLMError(Exception):
"""
+@dataclass(frozen=True)
+class LLMResult:
+ """LLM 1 回呼び出しの結果(応答テキスト + 実トークン使用量)。
+
+ トークン数はクレジット課金(ADR-0012)の根拠となる実測値。
+ 使用量を返せないプロバイダ(Ollama 等のローカル実装)は 0 を返す。
+ """
+
+ text: str
+ input_tokens: int = 0
+ output_tokens: int = 0
+
+
class LLMClient(ABC):
"""LLM プロバイダの共通インターフェース。
@@ -24,14 +38,17 @@ async def generate(
system_prompt: str,
messages: list[dict[str, str]],
output_schema: dict,
- ) -> str:
- """system プロンプトと会話 messages を渡して構造化応答(JSON 文字列)を返す。
+ model_id: str,
+ ) -> LLMResult:
+ """system プロンプトと会話 messages を渡して構造化応答と使用量を返す。
messages は ``[{"role": "user" | "assistant", "content": str}, ...]`` で、
末尾が今回の user プロンプト(マルチターン時は先頭側に履歴が並ぶ)。
output_schema は応答が従うべき JSON Schema(output_schema.py で構築)。
各プロバイダの構造化出力機構(Anthropic: tool use 強制 / Ollama: format)
- に渡し、戻り値はスキーマに従う JSON のシリアライズ文字列とする。
+ に渡し、``LLMResult.text`` はスキーマに従う JSON のシリアライズ文字列とする。
+ model_id は model_catalog.py が解決した実モデル ID(Anthropic 用)。
+ 自前のモデル設定を持つプロバイダ(Ollama)は無視してよい。
Raises:
LLMError: タイムアウト・接続失敗・API エラー時。
diff --git a/backend/app/services/agent/llm/ollama_client.py b/backend/app/services/agent/llm/ollama_client.py
index 1e122a7d..dc8a341a 100644
--- a/backend/app/services/agent/llm/ollama_client.py
+++ b/backend/app/services/agent/llm/ollama_client.py
@@ -6,7 +6,7 @@
import httpx
from ....core import settings
-from .base import LLMClient, LLMError
+from .base import LLMClient, LLMError, LLMResult
logger = logging.getLogger(__name__)
@@ -31,8 +31,13 @@ async def generate(
system_prompt: str,
messages: list[dict[str, str]],
output_schema: dict,
- ) -> str:
- """Ollama /api/chat に JSON Schema 形式の format を付与して呼び出し、応答テキストを返す。"""
+ model_id: str,
+ ) -> LLMResult:
+ """Ollama /api/chat に JSON Schema 形式の format を付与して呼び出し、応答テキストを返す。
+
+ model_id(Anthropic 用の実モデル ID)は使わず、ローカル設定(OLLAMA_MODEL)の
+ モデルを使う。トークン使用量は返せないため 0 とする(ローカルは無料扱い / ADR-0012)。
+ """
payload = {
"model": self._model,
"messages": [{"role": "system", "content": system_prompt}, *messages],
@@ -64,4 +69,4 @@ async def generate(
text = data.get("message", {}).get("content", "")
if not text:
raise LLMError("Ollama から空の応答が返されました")
- return text
+ return LLMResult(text=text)
diff --git a/backend/app/services/agent/model_catalog.py b/backend/app/services/agent/model_catalog.py
new file mode 100644
index 00000000..79e60a50
--- /dev/null
+++ b/backend/app/services/agent/model_catalog.py
@@ -0,0 +1,87 @@
+"""Agent で選択可能な LLM モデルのカタログ(SSoT / ADR-0012)。
+
+クライアントはエイリアス("haiku" / "sonnet")のみを指定し、実モデル ID と
+課金レートは本モジュールでマップする。任意のモデル文字列をクライアントから
+受け付けない(コスト爆発・未検証モデルの注入を防ぐ)。
+
+クレジット単位は「1 クレジット = $0.0001」(USD ペッグ)。消費レートは
+API 原価 × マージン係数で算出する。定数の根拠は ADR-0012 を参照。
+"""
+
+import math
+from dataclasses import dataclass
+from typing import get_args
+
+from ...schemas.agent import AgentModelAlias
+
+# 1 USD あたりのクレジット数(1 クレジット = $0.0001)
+CREDITS_PER_USD = 10_000
+# API 原価に乗せるマージン係数(為替・キャッシュ未ヒット・運用コストのバッファ)
+MARGIN_MULTIPLIER = 1.5
+
+# Anthropic API の公表原価(USD / 100 万トークン)。改定時はここだけ直す
+_HAIKU_INPUT_USD_PER_MTOK = 1.0
+_HAIKU_OUTPUT_USD_PER_MTOK = 5.0
+_SONNET_INPUT_USD_PER_MTOK = 3.0
+_SONNET_OUTPUT_USD_PER_MTOK = 15.0
+
+
+def _credits_per_mtok(usd_per_mtok: float) -> int:
+ """USD/MTok の原価をマージン込みのクレジット/MTok レートに換算する。"""
+ return int(usd_per_mtok * CREDITS_PER_USD * MARGIN_MULTIPLIER)
+
+
+@dataclass(frozen=True)
+class ModelSpec:
+ """エイリアス 1 件分のモデル定義。"""
+
+ # Anthropic API に渡す実モデル ID
+ model_id: str
+ # True なら残高チェック・クレジット消費を行わない(使用ログのみ記録)
+ is_free: bool
+ # マージン込みの消費レート(クレジット / 100 万トークン)
+ input_credits_per_mtok: int
+ output_credits_per_mtok: int
+
+
+# エイリアス → モデル定義(キー集合は schemas/agent.py の AgentModelAlias と
+# 一致させる。drift は test_model_catalog_matches_schema_alias で検出する)
+MODEL_CATALOG: dict[str, ModelSpec] = {
+ "haiku": ModelSpec(
+ model_id="claude-haiku-4-5",
+ is_free=True,
+ input_credits_per_mtok=_credits_per_mtok(_HAIKU_INPUT_USD_PER_MTOK),
+ output_credits_per_mtok=_credits_per_mtok(_HAIKU_OUTPUT_USD_PER_MTOK),
+ ),
+ "sonnet": ModelSpec(
+ model_id="claude-sonnet-4-6",
+ is_free=False,
+ input_credits_per_mtok=_credits_per_mtok(_SONNET_INPUT_USD_PER_MTOK),
+ output_credits_per_mtok=_credits_per_mtok(_SONNET_OUTPUT_USD_PER_MTOK),
+ ),
+}
+
+# モジュールロード時にスキーマの Literal とカタログの drift を fail fast で検出する
+assert set(MODEL_CATALOG) == set(get_args(AgentModelAlias)), (
+ "MODEL_CATALOG のキーは schemas/agent.py の AgentModelAlias と一致させること"
+)
+
+
+def get_model_spec(alias: str) -> ModelSpec:
+ """エイリアスからモデル定義を返す。未知のエイリアスは KeyError(スキーマで検証済み前提)。"""
+ return MODEL_CATALOG[alias]
+
+
+def calculate_credit_cost(alias: str, input_tokens: int, output_tokens: int) -> int:
+ """実トークン数からクレジット消費量を算出する。無料モデルは常に 0。
+
+ 端数は切り上げ(ceil)とし、課金対象の利用で 0 クレジットにならないようにする。
+ """
+ spec = get_model_spec(alias)
+ if spec.is_free:
+ return 0
+ raw = (
+ input_tokens * spec.input_credits_per_mtok
+ + output_tokens * spec.output_credits_per_mtok
+ )
+ return math.ceil(raw / 1_000_000)
diff --git a/backend/app/services/billing/__init__.py b/backend/app/services/billing/__init__.py
new file mode 100644
index 00000000..594ac1bb
--- /dev/null
+++ b/backend/app/services/billing/__init__.py
@@ -0,0 +1 @@
+"""クレジット課金サービス(ADR-0012)。"""
diff --git a/backend/app/services/billing/credit_service.py b/backend/app/services/billing/credit_service.py
new file mode 100644
index 00000000..5e466e88
--- /dev/null
+++ b/backend/app/services/billing/credit_service.py
@@ -0,0 +1,100 @@
+"""クレジット残高の検証・消費・付与のビジネスロジック(ADR-0012)。
+
+HTTP 知識(ステータスコード・HTTPException)は持ち込まない。
+router 側で ``InsufficientCreditsError`` → 402 ``INSUFFICIENT_CREDITS`` に変換する。
+"""
+
+import logging
+
+from sqlalchemy.orm import Session
+
+from ...repositories.billing import BillingRepository
+from ..agent.chat_service import AgentUsage
+from ..agent.model_catalog import calculate_credit_cost, get_model_spec
+
+logger = logging.getLogger(__name__)
+
+# 台帳の transaction_type(models/billing.py のコメントと同期)
+TRANSACTION_TYPE_CONSUMPTION = "consumption"
+TRANSACTION_TYPE_ADMIN_GRANT = "admin_grant"
+TRANSACTION_TYPE_PURCHASE = "purchase"
+
+
+class InsufficientCreditsError(Exception):
+ """有料モデルの利用に必要なクレジット残高が不足している。"""
+
+
+def ensure_can_use_model(db: Session, user_id: str, model_alias: str) -> None:
+ """有料モデル利用前の残高チェック。無料モデルは常に通す。
+
+ 残高 > 0 を要求する。実コストは LLM 応答後にしか確定しないため、
+ チェック通過後の消費で残高が負になることは許容する(有界損失 / ADR-0012)。
+
+ Raises:
+ InsufficientCreditsError: 有料モデルで残高が 0 以下。
+ """
+ if get_model_spec(model_alias).is_free:
+ return
+ balance = BillingRepository(db, user_id).get_balance()
+ if balance <= 0:
+ raise InsufficientCreditsError(
+ f"クレジット残高が不足しています: balance={balance}"
+ )
+
+
+def record_chat_usage(db: Session, user_id: str, usage: AgentUsage) -> int | None:
+ """チャット 1 回分の使用量を記録し、有料モデルならクレジットを消費する。
+
+ 無料モデルは使用ログのみ記録して None を返す。有料モデルは原子的に残高を
+ 減算し、適用後残高を返す。記録の失敗は呼び出し元へ伝播させる
+ (課金漏れを黙って通さない方針 / ADR-0012)。
+ """
+ cost = calculate_credit_cost(usage.model, usage.input_tokens, usage.output_tokens)
+ repo = BillingRepository(db, user_id)
+ balance_after: int | None = None
+ if cost > 0:
+ balance_after = repo.apply_transaction(
+ amount=-cost,
+ transaction_type=TRANSACTION_TYPE_CONSUMPTION,
+ description=f"Agent チャット({usage.model})",
+ )
+ logger.info(
+ "クレジット消費: model=%s in=%d out=%d cost=%d balance_after=%d",
+ usage.model,
+ usage.input_tokens,
+ usage.output_tokens,
+ cost,
+ balance_after,
+ )
+ repo.add_usage_log(
+ model_alias=usage.model,
+ input_tokens=usage.input_tokens,
+ output_tokens=usage.output_tokens,
+ credit_cost=cost,
+ )
+ return balance_after
+
+
+def grant_credits(
+ db: Session,
+ user_id: str,
+ amount: int,
+ *,
+ transaction_type: str,
+ description: str | None = None,
+ stripe_session_id: str | None = None,
+) -> int:
+ """クレジットを付与し、適用後残高を返す。
+
+ Phase 1 は管理者付与(admin_grant)のみ。Phase 2 で Stripe 購入(purchase)からも
+ 呼ばれる(stripe_session_id の UNIQUE 制約で二重付与を防ぐ / ADR-0012)。
+ """
+ if amount <= 0:
+ # スキーマ検証をすり抜けた負値付与(実質消費)を防ぐ最終ガード
+ raise ValueError(f"付与額は正の値であること: amount={amount}")
+ return BillingRepository(db, user_id).apply_transaction(
+ amount=amount,
+ transaction_type=transaction_type,
+ description=description,
+ stripe_session_id=stripe_session_id,
+ )
diff --git a/backend/tests/test_agent.py b/backend/tests/test_agent.py
index ad0309fb..787d3845 100644
--- a/backend/tests/test_agent.py
+++ b/backend/tests/test_agent.py
@@ -15,7 +15,7 @@
AgentTargetNotFoundError,
_parse_response,
)
-from app.services.agent.llm.base import LLMClient, LLMError
+from app.services.agent.llm.base import LLMClient, LLMError, LLMResult
from app.services.agent.output_schema import (
MAX_SUGGESTION_LENGTH,
MAX_SUGGESTIONS,
@@ -32,28 +32,43 @@
class _FakeLLM(LLMClient):
"""テスト用の LLM クライアント(固定応答 or 例外)。受信した入力を記録する。"""
- def __init__(self, response: str | None = None, error: Exception | None = None):
+ def __init__(
+ self,
+ response: str | None = None,
+ error: Exception | None = None,
+ input_tokens: int = 0,
+ output_tokens: int = 0,
+ ):
"""固定応答または送出例外をセットし、受信内容記録フィールドを初期化する。"""
self._response = response
self._error = error
+ self._input_tokens = input_tokens
+ self._output_tokens = output_tokens
self.received_system_prompt: str | None = None
self.received_messages: list[dict[str, str]] | None = None
self.received_output_schema: dict | None = None
+ self.received_model_id: str | None = None
async def generate(
self,
system_prompt: str,
messages: list[dict[str, str]],
output_schema: dict,
- ) -> str:
+ model_id: str,
+ ) -> LLMResult:
"""受信引数を記録し、設定済みの例外を raise するか固定応答を返す。"""
self.received_system_prompt = system_prompt
self.received_messages = messages
self.received_output_schema = output_schema
+ self.received_model_id = model_id
if self._error:
raise self._error
assert self._response is not None
- return self._response
+ return LLMResult(
+ text=self._response,
+ input_tokens=self._input_tokens,
+ output_tokens=self._output_tokens,
+ )
def _mock_llm(monkeypatch, *, response: str | None = None, error: Exception | None = None):
@@ -66,9 +81,13 @@ def _mock_llm(monkeypatch, *, response: str | None = None, error: Exception | No
class _SequentialFakeLLM(LLMClient):
"""呼び出しごとに用意した応答を順に返す LLM クライアント(リトライ検証用)。"""
- def __init__(self, responses: list[str]):
+ def __init__(
+ self, responses: list[str], input_tokens: int = 0, output_tokens: int = 0
+ ):
"""順に返すべき応答リストを受け取り、コール記録リストを初期化する。"""
self._responses = list(responses)
+ self._input_tokens = input_tokens
+ self._output_tokens = output_tokens
self.calls: list[list[dict[str, str]]] = []
async def generate(
@@ -76,10 +95,15 @@ async def generate(
system_prompt: str,
messages: list[dict[str, str]],
output_schema: dict,
- ) -> str:
+ model_id: str,
+ ) -> LLMResult:
"""受信 messages を記録し、呼び出し順に対応した応答を返す。"""
self.calls.append(messages)
- return self._responses[len(self.calls) - 1]
+ return LLMResult(
+ text=self._responses[len(self.calls) - 1],
+ input_tokens=self._input_tokens,
+ output_tokens=self._output_tokens,
+ )
def _resume_payload() -> dict:
diff --git a/backend/tests/test_billing.py b/backend/tests/test_billing.py
new file mode 100644
index 00000000..24d02a99
--- /dev/null
+++ b/backend/tests/test_billing.py
@@ -0,0 +1,353 @@
+"""クレジット課金(ADR-0012)の統合テスト。
+
+LLM(外部 API)のみモックし、残高チェック・消費・台帳記録・使用ログは
+実 DB(テスト用 SQLite セッション)を通す。
+"""
+
+from typing import get_args
+
+import pytest
+from app.models import AgentUsageLog
+from app.repositories import BillingRepository, UserRepository
+from app.schemas.agent import AgentModelAlias
+from app.services.agent import chat_service
+from app.services.agent.model_catalog import (
+ CREDITS_PER_USD,
+ MARGIN_MULTIPLIER,
+ MODEL_CATALOG,
+ calculate_credit_cost,
+)
+from app.services.billing import credit_service
+from fastapi.testclient import TestClient
+
+from conftest import auth_header
+from test_agent import _FakeLLM, _llm_json, _resume_payload, _SequentialFakeLLM
+
+_ADMIN_HEADERS = {"Authorization": "Bearer test-admin-token"}
+
+
+def _chat_payload(model: str | None = None) -> dict:
+ """self_pr スコープの最小チャットペイロードを返す。"""
+ payload = {
+ "scope": "self_pr",
+ "prompt": "もっと魅力的にしてください",
+ "resume": _resume_payload(),
+ }
+ if model is not None:
+ payload["model"] = model
+ return payload
+
+
+def _get_user_id(client: TestClient, username: str) -> str:
+ """auth_header で作成済みのユーザー ID を返す。"""
+ user = UserRepository(client._db_session).get_by_username(username)
+ assert user is not None
+ return user.id
+
+
+# --- ユニットテスト(model_catalog: エイリアス・課金レート) ---
+
+
+def test_model_catalog_matches_schema_alias() -> None:
+ """MODEL_CATALOG のキー集合はスキーマの AgentModelAlias と一致する(drift 防止)。"""
+ assert set(MODEL_CATALOG) == set(get_args(AgentModelAlias))
+
+
+def test_model_catalog_resolves_real_model_ids() -> None:
+ """エイリアスから実モデル ID と無料/有料フラグが解決できる。"""
+ assert MODEL_CATALOG["haiku"].model_id == "claude-haiku-4-5"
+ assert MODEL_CATALOG["haiku"].is_free is True
+ assert MODEL_CATALOG["sonnet"].model_id == "claude-sonnet-4-6"
+ assert MODEL_CATALOG["sonnet"].is_free is False
+
+
+def test_credit_rates_include_margin() -> None:
+ """消費レートは API 原価(USD/MTok)× マージン係数で算出される(ADR-0012)。"""
+ sonnet = MODEL_CATALOG["sonnet"]
+ assert sonnet.input_credits_per_mtok == int(3.0 * CREDITS_PER_USD * MARGIN_MULTIPLIER)
+ assert sonnet.output_credits_per_mtok == int(15.0 * CREDITS_PER_USD * MARGIN_MULTIPLIER)
+
+
+def test_calculate_credit_cost_free_model_is_zero() -> None:
+ """無料モデル(haiku)はトークン数に関わらずコスト 0。"""
+ assert calculate_credit_cost("haiku", 100_000, 100_000) == 0
+
+
+def test_calculate_credit_cost_sonnet_exact() -> None:
+ """sonnet のコストは入出力レートの合算(クレジット/MTok)から算出される。"""
+ # input: 1000 tok × 45,000/MTok = 45 / output: 1000 tok × 225,000/MTok = 225
+ assert calculate_credit_cost("sonnet", 1000, 1000) == 270
+
+
+def test_calculate_credit_cost_rounds_up() -> None:
+ """端数は切り上げ、課金対象の利用が 0 クレジットにならない。"""
+ # input 1 tok = 45000 / 1e6 = 0.045 → 1 クレジットに切り上げ
+ assert calculate_credit_cost("sonnet", 1, 0) == 1
+
+
+def test_grant_credits_rejects_non_positive(db_session) -> None:
+ """付与額 0 以下は ValueError(実質消費になる付与を防ぐ最終ガード)。"""
+ with pytest.raises(ValueError, match="付与額"):
+ credit_service.grant_credits(
+ db_session,
+ "dummy-user-id",
+ 0,
+ transaction_type=credit_service.TRANSACTION_TYPE_ADMIN_GRANT,
+ )
+
+
+# --- 統合テスト(チャット課金フロー) ---
+
+
+def test_chat_haiku_works_with_zero_balance(client: TestClient, monkeypatch) -> None:
+ """haiku(無料)は残高 0 でも利用でき、使用ログのみ記録される。"""
+ fake = _FakeLLM(
+ response=_llm_json("self_pr", "改善した自己PR"),
+ input_tokens=1200,
+ output_tokens=300,
+ )
+ monkeypatch.setattr(chat_service, "get_llm_client", lambda: fake)
+ headers = auth_header(client, "billing-haiku")
+
+ resp = client.post("/api/agent/chat", json=_chat_payload(), headers=headers)
+
+ assert resp.status_code == 200
+ assert fake.received_model_id == "claude-haiku-4-5"
+ user_id = _get_user_id(client, "billing-haiku")
+ repo = BillingRepository(client._db_session, user_id)
+ assert repo.get_balance() == 0
+ assert repo.list_transactions() == []
+ logs = client._db_session.query(AgentUsageLog).filter_by(user_id=user_id).all()
+ assert len(logs) == 1
+ assert logs[0].model_alias == "haiku"
+ assert logs[0].input_tokens == 1200
+ assert logs[0].output_tokens == 300
+ assert logs[0].credit_cost == 0
+
+
+def test_chat_sonnet_consumes_credits(client: TestClient, monkeypatch) -> None:
+ """sonnet は実トークン量からコストを算出し、残高減算 + 台帳 + 使用ログを記録する。"""
+ fake = _FakeLLM(
+ response=_llm_json("self_pr", "改善した自己PR"),
+ input_tokens=1000,
+ output_tokens=1000,
+ )
+ monkeypatch.setattr(chat_service, "get_llm_client", lambda: fake)
+ headers = auth_header(client, "billing-sonnet")
+ user_id = _get_user_id(client, "billing-sonnet")
+ credit_service.grant_credits(
+ client._db_session,
+ user_id,
+ 10_000,
+ transaction_type=credit_service.TRANSACTION_TYPE_ADMIN_GRANT,
+ )
+
+ resp = client.post("/api/agent/chat", json=_chat_payload("sonnet"), headers=headers)
+
+ assert resp.status_code == 200
+ assert fake.received_model_id == "claude-sonnet-4-6"
+ repo = BillingRepository(client._db_session, user_id)
+ # コスト: 1000×45,000/MTok + 1000×225,000/MTok = 270
+ assert repo.get_balance() == 10_000 - 270
+ transactions = repo.list_transactions()
+ consumption = [
+ t for t in transactions
+ if t.transaction_type == credit_service.TRANSACTION_TYPE_CONSUMPTION
+ ]
+ assert len(consumption) == 1
+ assert consumption[0].amount == -270
+ assert consumption[0].balance_after == 10_000 - 270
+
+
+def test_chat_sonnet_with_zero_balance_returns_402(client: TestClient, monkeypatch) -> None:
+ """sonnet は残高 0 で 402 INSUFFICIENT_CREDITS。LLM は呼ばれない。"""
+ fake = _FakeLLM(response=_llm_json("self_pr", "提案"))
+ monkeypatch.setattr(chat_service, "get_llm_client", lambda: fake)
+ headers = auth_header(client, "billing-empty")
+
+ resp = client.post("/api/agent/chat", json=_chat_payload("sonnet"), headers=headers)
+
+ assert resp.status_code == 402
+ # AppErrorResponse はトップレベルに code / message を持つ(main.py の例外ハンドラ)
+ assert resp.json()["code"] == "INSUFFICIENT_CREDITS"
+ assert fake.received_messages is None
+
+
+def test_chat_sonnet_allows_negative_balance(client: TestClient, monkeypatch) -> None:
+ """事前チェック通過後の実コストが残高を超えた場合は負残高で確定する(ADR-0012)。"""
+ fake = _FakeLLM(
+ response=_llm_json("self_pr", "提案"),
+ input_tokens=1000,
+ output_tokens=1000,
+ )
+ monkeypatch.setattr(chat_service, "get_llm_client", lambda: fake)
+ headers = auth_header(client, "billing-negative")
+ user_id = _get_user_id(client, "billing-negative")
+ credit_service.grant_credits(
+ client._db_session,
+ user_id,
+ 10,
+ transaction_type=credit_service.TRANSACTION_TYPE_ADMIN_GRANT,
+ )
+
+ resp = client.post("/api/agent/chat", json=_chat_payload("sonnet"), headers=headers)
+
+ assert resp.status_code == 200
+ assert BillingRepository(client._db_session, user_id).get_balance() == 10 - 270
+
+
+def test_chat_sonnet_retry_usage_is_summed(client: TestClient, monkeypatch) -> None:
+ """パース失敗 → リトライ成功時は 2 回分の実トークン量を合算して課金する。"""
+ fake = _SequentialFakeLLM(
+ ["JSON ではない応答", _llm_json("self_pr", "再生成の提案")],
+ input_tokens=1000,
+ output_tokens=1000,
+ )
+ monkeypatch.setattr(chat_service, "get_llm_client", lambda: fake)
+ headers = auth_header(client, "billing-retry")
+ user_id = _get_user_id(client, "billing-retry")
+ credit_service.grant_credits(
+ client._db_session,
+ user_id,
+ 10_000,
+ transaction_type=credit_service.TRANSACTION_TYPE_ADMIN_GRANT,
+ )
+
+ resp = client.post("/api/agent/chat", json=_chat_payload("sonnet"), headers=headers)
+
+ assert resp.status_code == 200
+ assert len(fake.calls) == 2
+ # 2 呼び出し合算: (2000×45,000 + 2000×225,000) / 1e6 = 540
+ assert BillingRepository(client._db_session, user_id).get_balance() == 10_000 - 540
+
+
+def test_chat_rejects_unknown_model_alias(client: TestClient, monkeypatch) -> None:
+ """カタログ外のモデル指定はスキーマ検証で 422(実モデル ID の注入を防ぐ)。"""
+ fake = _FakeLLM(response=_llm_json("self_pr", "提案"))
+ monkeypatch.setattr(chat_service, "get_llm_client", lambda: fake)
+ headers = auth_header(client, "billing-unknown-model")
+
+ resp = client.post(
+ "/api/agent/chat",
+ json=_chat_payload("claude-opus-4-8"),
+ headers=headers,
+ )
+
+ assert resp.status_code == 422
+ assert fake.received_messages is None
+
+
+# --- 統合テスト(残高・台帳エンドポイント) ---
+
+
+def test_get_balance_returns_current_balance(client: TestClient) -> None:
+ """GET /api/billing/balance は現在残高を返す。"""
+ auth_header(client, "billing-balance")
+ user_id = _get_user_id(client, "billing-balance")
+ credit_service.grant_credits(
+ client._db_session,
+ user_id,
+ 5_000,
+ transaction_type=credit_service.TRANSACTION_TYPE_ADMIN_GRANT,
+ )
+
+ resp = client.get("/api/billing/balance")
+
+ assert resp.status_code == 200
+ assert resp.json() == {"balance": 5_000}
+
+
+def test_get_balance_requires_auth(client: TestClient) -> None:
+ """未ログインの残高照会は 401。"""
+ resp = client.get("/api/billing/balance")
+ assert resp.status_code == 401
+
+
+def test_list_transactions_returns_history(client: TestClient, monkeypatch) -> None:
+ """GET /api/billing/transactions は付与・消費の履歴を返す。"""
+ fake = _FakeLLM(
+ response=_llm_json("self_pr", "提案"), input_tokens=1000, output_tokens=1000
+ )
+ monkeypatch.setattr(chat_service, "get_llm_client", lambda: fake)
+ headers = auth_header(client, "billing-history")
+ user_id = _get_user_id(client, "billing-history")
+ credit_service.grant_credits(
+ client._db_session,
+ user_id,
+ 1_000,
+ transaction_type=credit_service.TRANSACTION_TYPE_ADMIN_GRANT,
+ description="テスト付与",
+ )
+ resp = client.post("/api/agent/chat", json=_chat_payload("sonnet"), headers=headers)
+ assert resp.status_code == 200
+
+ resp = client.get("/api/billing/transactions")
+
+ assert resp.status_code == 200
+ body = resp.json()
+ assert len(body) == 2
+ types = {entry["transaction_type"] for entry in body}
+ assert types == {"admin_grant", "consumption"}
+ # 台帳エントリは金額と適用後残高のスナップショットを持つ
+ consumption = next(e for e in body if e["transaction_type"] == "consumption")
+ assert consumption["amount"] == -270
+ assert consumption["balance_after"] == 1_000 - 270
+
+
+# --- 統合テスト(管理者付与) ---
+
+
+def test_admin_grant_adds_credits(client: TestClient) -> None:
+ """ADMIN_TOKEN 認証でユーザーへクレジットを付与できる。"""
+ auth_header(client, "billing-grant-target")
+ client.cookies.clear() # Bearer 認証のみで呼ぶ(CSRF チェック対象外であることも担保)
+
+ resp = client.post(
+ "/api/billing/admin/grant",
+ json={"username": "billing-grant-target", "amount": 30_000, "description": "テスト"},
+ headers=_ADMIN_HEADERS,
+ )
+
+ assert resp.status_code == 200
+ assert resp.json() == {"balance": 30_000}
+
+
+def test_admin_grant_requires_bearer_token(client: TestClient) -> None:
+ """Bearer トークンなしの付与は 401。"""
+ resp = client.post(
+ "/api/billing/admin/grant",
+ json={"username": "anyone", "amount": 100},
+ )
+ assert resp.status_code == 401
+
+
+def test_admin_grant_rejects_wrong_token(client: TestClient) -> None:
+ """不正な Bearer トークンは 403。"""
+ resp = client.post(
+ "/api/billing/admin/grant",
+ json={"username": "anyone", "amount": 100},
+ headers={"Authorization": "Bearer wrong-token"},
+ )
+ assert resp.status_code == 403
+
+
+def test_admin_grant_unknown_user_returns_404(client: TestClient) -> None:
+ """存在しないユーザーへの付与は 404。"""
+ resp = client.post(
+ "/api/billing/admin/grant",
+ json={"username": "no-such-user", "amount": 100},
+ headers=_ADMIN_HEADERS,
+ )
+ assert resp.status_code == 404
+
+
+def test_admin_grant_rejects_non_positive_amount(client: TestClient) -> None:
+ """付与額 0 以下はスキーマ検証で 422。"""
+ auth_header(client, "billing-grant-zero")
+ client.cookies.clear()
+ resp = client.post(
+ "/api/billing/admin/grant",
+ json={"username": "billing-grant-zero", "amount": 0},
+ headers=_ADMIN_HEADERS,
+ )
+ assert resp.status_code == 422
diff --git a/docs/adr/0012-agent-model-switching-and-prepaid-billing.md b/docs/adr/0012-agent-model-switching-and-prepaid-billing.md
new file mode 100644
index 00000000..80ce2974
--- /dev/null
+++ b/docs/adr/0012-agent-model-switching-and-prepaid-billing.md
@@ -0,0 +1,114 @@
+# ADR-0012: Agent モデル切り替えとプリペイドクレジット課金
+
+## ステータス
+
+Accepted
+
+## コンテキスト
+
+DevForge Agent(ADR-0010)の LLM モデルは `claude-haiku-4-5` をハードコードしている。
+精度が欲しい場面で上位モデル(Sonnet)を使いたいが、上位モデルは API 原価が高く
+(Haiku: $1/$5、Sonnet: $3/$15 per 1M トークン入力/出力)、無制限に開放すると
+運用コストが青天井になる。そこで「Haiku は無料・使い放題、Sonnet は有料」とする
+モデル切り替え + 課金機能を導入する。
+
+制約:
+
+- Agent チャットの既存契約(`AgentChatResponse` の構造、DB 非更新原則、エラー契約)は
+ 崩さない(ADR-0010 / `.claude/rules/backend/agent.md`)
+- Cloud Run 単一インスタンス(ADR-0005)+ Turso (libSQL)。分散ロックは不要
+- 決済機能の運用は初経験のため、未払い・請求トラブルのリスクを最小化したい
+
+## 決定内容
+
+### 1. モデル切り替えは「エイリアス方式」
+
+- クライアントは `AgentChatRequest.model: Literal["haiku", "sonnet"]`(デフォルト `"haiku"`)で
+ モデルを指定する。実モデル ID(`claude-haiku-4-5` 等)はサーバー側の
+ `services/agent/model_catalog.py`(SSoT)でマップする
+- 任意のモデル文字列をクライアントから受け付けない(コスト爆発・未検証モデルの注入を防ぐ)
+- `LLMClient.generate()` の契約を変更し、応答テキストに加えて実トークン使用量
+ (`input_tokens` / `output_tokens`)を返す(従来は `response.usage` を捨てていた)
+
+### 2. 課金はプリペイドクレジット方式
+
+- ユーザーは事前にクレジットを購入し、Sonnet 利用時に実トークン量に応じて消費する
+- **1 クレジット = $0.0001**(USD ペッグ)。消費レートは API 原価 × **マージン係数 1.5**
+ (為替・キャッシュ未ヒット・運用コストのバッファ)
+- 課金フロー(1 リクエスト):
+ 1. 事前チェック: 残高 > 0(不足なら 402 `INSUFFICIENT_CREDITS`)
+ 2. LLM 呼び出し → 実トークン数取得
+ 3. 事後減算: 原子的 UPDATE で残高から実コストを引き、台帳 + 使用ログを記録
+- **負残高を許容する**: 事前チェック通過後の実コストが残高を上回った場合、残高は負になる。
+ 1 リクエストの最大コストは `max_tokens=4096` で有界(〜1,200 クレジット ≈ $0.12)であり、
+ 予約(reserve-then-settle)方式の複雑さに見合わないと判断した
+- **消費記録の失敗は 500 エラーにする**: LLM 応答が得られても課金記録に失敗した場合は
+ 応答を返さない(課金漏れを黙って通すより、ユーザーに再試行させる方を選ぶ)
+- Haiku(無料モデル)は残高チェック・減算なし。使用ログのみ記録する(コスト分析用)
+
+### 3. データモデルは「台帳 + キャッシュ残高」
+
+- `credit_transactions`(台帳・追記専用): 全ての付与/消費を符号付き `amount` と
+ `balance_after` で記録する。監査とデバッグの正本
+- `users.credit_balance`(キャッシュ残高): 事前チェックと表示用。台帳と同一トランザクション内で
+ 原子的 UPDATE(`credit_balance = credit_balance - :cost`)により更新する
+- `agent_usage_logs`: モデル別の実トークン数・コストを記録(無料モデル含む)
+
+### 4. 決済は Stripe Checkout(Phase 2)
+
+- Stripe Checkout Session(`mode="payment"`)でクレジットパックを販売する。
+ カード情報は Stripe ホストの決済ページで入力され、自サーバーは一切扱わない
+ (PCI DSS 対応不要)
+- **入金確定は Webhook(`checkout.session.completed`)が正**。リダイレクト戻り
+ (success_url)はユーザーがブラウザを閉じると発生しないため、付与処理に使わない
+- Webhook は `Stripe-Signature` の署名検証必須。冪等性は
+ `credit_transactions.stripe_session_id` の UNIQUE 制約で担保する(同一イベント再送でも
+ 二重付与しない)
+- パック定義(価格・クレジット数)はコード(`services/billing/pricing.py`)を SSoT とし、
+ Checkout には `price_data` で動的に渡す(Stripe ダッシュボードの Price オブジェクトに
+ 依存しない)
+
+### 5. リリースは 2 フェーズ
+
+- **Phase 1**: モデル切り替え + 使用量記録 + クレジット基盤(残高は ADMIN_TOKEN 認証の
+ 付与エンドポイントでテスト)
+- **Phase 2**: Stripe Checkout 購入フロー + Webhook 入金処理 + 課金ページ
+
+## 代替案
+
+- **月額サブスクリプション(Stripe Billing)**: UX はシンプルだが、更新・解約・支払い失敗
+ リトライ(dunning)の運用が必要で初導入には重い。使い放題は原価リスクも大きい
+- **従量後払い(Stripe Metered Billing)**: 最も柔軟だが未払いリスクと使用量レポートの
+ 正確性担保が必要で、実装・運用難度が最も高い。プリペイドは「残高以上に使われない」
+ ことが構造的に保証される
+- **予約方式(reserve-then-settle)**: 事前に最大コストを引き当てて事後精算する方式。
+ 二重引当の解放漏れ等の障害モードが増えるため、負残高許容(有界損失)を選んだ
+- **残高をテーブル分離(balances テーブル)**: 1 ユーザー 1 行のため `users` カラムで十分
+
+## トレードオフ・既知のリスク
+
+- 負残高: 最大 1 リクエスト分(≈$0.12)の取りはぐれを許容する
+- USD ペッグのクレジットを円建てパックで販売するため、為替変動はパック価格定数の
+ 調整で吸収する(自動連動しない)
+- 返金・チャージバックはスコープ外(Stripe ダッシュボードで手動対応 + 管理者付与で
+ 残高調整)
+- 消費記録失敗を 500 にする方針は「LLM 原価は発生したのに応答を捨てる」ケースを生むが、
+ 頻度は低く(DB 障害時のみ)、課金漏れの常態化より安全側
+- Ollama(ローカル開発)は無料扱い・トークン数 0 で記録される。モデルエイリアスは
+ anthropic プロバイダのときのみ実質的な意味を持つ
+
+## 将来の移行条件
+
+- Opus 等の第 3 のモデル追加: `model_catalog.py` への 1 エントリ追加 + スキーマの
+ Literal 拡張で対応できる設計とする
+- 利用が増え同時実行が問題になったら(マルチインスタンス化)、残高更新を
+ 楽観ロックまたは台帳集計方式へ移行する
+- 法人利用等でポストペイドが必要になったら Stripe Metered Billing を再検討する
+
+## 関連リンク
+
+- ADR-0005: Cloud Run Single Instance(単一インスタンス前提の原子的 UPDATE)
+- ADR-0010: DevForge Agent(チャット契約・DB 非更新原則)
+- `.claude/rules/backend/agent.md`(Agent 実装の不変条件)
+- Stripe Checkout: https://docs.stripe.com/payments/checkout
+- Stripe Webhook 署名検証: https://docs.stripe.com/webhooks#verify-official-libraries
diff --git a/docs/api.md b/docs/api.md
index 7cbe9050..3bd80939 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -70,7 +70,12 @@ REST API エンドポイント一覧と、バックエンド/フロントエ
- `POST /api/notifications/read-all`: 全て既読
### Agent(LLM チャット / ADR-0010)
-- `POST /api/agent/chat`: 選択スコープ(`project` / `career_summary` / `self_pr`)の内容とプロンプトをもとに、職務経歴書への差分 operations を返す。DB は更新せず、適用はフロント側でユーザー確認後に既存保存 API を呼ぶ。rate limit 10/min
+- `POST /api/agent/chat`: 選択スコープ(`project` / `career_summary` / `self_pr`)の内容とプロンプトをもとに、職務経歴書への差分 operations を返す。DB は更新せず、適用はフロント側でユーザー確認後に既存保存 API を呼ぶ。rate limit 10/min。`model`(`haiku`: 無料 / `sonnet`: 有料・クレジット消費)を指定可能。sonnet で残高不足の場合は 402 `INSUFFICIENT_CREDITS`(ADR-0012)
+
+### 課金(プリペイドクレジット / ADR-0012)
+- `GET /api/billing/balance`: ログインユーザーのクレジット残高
+- `GET /api/billing/transactions`: クレジット台帳履歴(付与・消費、新しい順)
+- `POST /api/billing/admin/grant`: ユーザーへのクレジット付与(管理者・`ADMIN_TOKEN` Bearer 認証)
### 内部 API(Cloud Tasks コールバック専用)
- `POST /internal/tasks/{task_type}`: Cloud Tasks からのタスク実行リクエストを受け付ける。`TASK_RUNNER=cloud_tasks` の場合は `X-CloudTasks-QueueName` ヘッダで検証
diff --git a/frontend/e2e/agent-chat.spec.ts b/frontend/e2e/agent-chat.spec.ts
index 9d8ce795..4f92fa36 100644
--- a/frontend/e2e/agent-chat.spec.ts
+++ b/frontend/e2e/agent-chat.spec.ts
@@ -92,6 +92,79 @@ test.describe("Agent チャットウィジェット", () => {
await expect(page.getByRole("button", { name: "フォームに反映" })).toHaveCount(0);
});
+ test("Sonnet(有料)選択で残高が表示され model=sonnet で送信される", async ({ page }) => {
+ await page.route("**/api/billing/balance", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ balance: 12000 }),
+ }),
+ );
+ let chatRequestBody: Record | null = null;
+ await page.route("**/api/agent/chat", async (route) => {
+ chatRequestBody = JSON.parse(route.request().postData() ?? "{}");
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ message: "高精度モデルの提案です。", operations: [] }),
+ });
+ });
+
+ await page.goto("/career");
+ await waitForAuthenticatedLayout(page);
+
+ await page.getByRole("button", { name: "devforge Agent" }).click();
+
+ // モデルセレクタで Sonnet を選ぶと残高が表示される(ADR-0012)
+ await page.getByLabel("モデル").selectOption("sonnet");
+ await expect(page.getByText("残高: 12,000 クレジット")).toBeVisible();
+
+ await page
+ .getByPlaceholder("例: 成果がより伝わる文章にしてください")
+ .fill("プロらしい文章にして");
+ await page.getByRole("button", { name: "送信", exact: true }).click();
+
+ await expect(page.getByText("高精度モデルの提案です。")).toBeVisible();
+ expect(chatRequestBody).toMatchObject({ model: "sonnet" });
+ });
+
+ test("Sonnet で残高不足(402)はエラートーストで通知される", async ({ page }) => {
+ await page.route("**/api/billing/balance", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ balance: 0 }),
+ }),
+ );
+ await page.route("**/api/agent/chat", (route) =>
+ route.fulfill({
+ status: 402,
+ contentType: "application/json",
+ body: JSON.stringify({
+ code: "INSUFFICIENT_CREDITS",
+ message:
+ "クレジット残高が不足しています。Haiku(無料)に切り替えるか、クレジットを追加してください。",
+ }),
+ }),
+ );
+
+ await page.goto("/career");
+ await waitForAuthenticatedLayout(page);
+
+ await page.getByRole("button", { name: "devforge Agent" }).click();
+ await page.getByLabel("モデル").selectOption("sonnet");
+ await page
+ .getByPlaceholder("例: 成果がより伝わる文章にしてください")
+ .fill("改善して");
+ await page.getByRole("button", { name: "送信", exact: true }).click();
+
+ await expect(
+ page.getByText(
+ "クレジット残高が不足しています。Haiku(無料)に切り替えるか、クレジットを追加してください。",
+ ),
+ ).toBeVisible();
+ });
+
test("LLM 失敗(502)はエラートーストで通知される", async ({ page }) => {
await page.route("**/api/agent/chat", (route) =>
route.fulfill({
diff --git a/frontend/src/api/billing.ts b/frontend/src/api/billing.ts
new file mode 100644
index 00000000..14102bbe
--- /dev/null
+++ b/frontend/src/api/billing.ts
@@ -0,0 +1,16 @@
+import { request } from "./client";
+import { PATHS } from "./paths";
+import type { CreditBalanceResponse, CreditTransactionResponse } from "./types";
+
+/**
+ * クレジット残高を取得する(ADR-0012)。
+ * sonnet(有料モデル)選択時の残高表示と、チャット送信後の残高更新に使う。
+ */
+export function getCreditBalance(): Promise {
+ return request(PATHS.billing.balance, { method: "GET" });
+}
+
+/** クレジット台帳履歴(付与・消費)を新しい順に取得する。 */
+export function getCreditTransactions(): Promise {
+ return request(PATHS.billing.transactions, { method: "GET" });
+}
diff --git a/frontend/src/api/generated.ts b/frontend/src/api/generated.ts
index 62b8574b..d0453011 100644
--- a/frontend/src/api/generated.ts
+++ b/frontend/src/api/generated.ts
@@ -21,7 +21,8 @@ export interface paths {
* @description 選択スコープの内容とプロンプトをもとに、職務経歴書への差分 operations を返す。
*
* career_summary / self_pr スコープでは GitHub・ブログ分析サマリーを参照情報として付与する。
- * レスポンスはフロントの state にのみ適用され、DB は更新しない。
+ * レスポンスはフロントの state にのみ適用され、DB は更新しない
+ * (クレジット消費・使用ログの記録は除く / ADR-0012)。
* ユーザーが確認して「適用」した時点で既存の保存 API が呼ばれる。
*/
post: operations["agent_chat_api_agent_chat_post"];
@@ -31,6 +32,68 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/billing/admin/grant": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Admin Grant Credits
+ * @description 管理者がユーザーへクレジットを付与する(Phase 1 の残高調整・テスト用)。
+ *
+ * ADMIN_TOKEN(Bearer)認証。Stripe 導入後も返金・補填時の残高調整用に残す。
+ */
+ post: operations["admin_grant_credits_api_billing_admin_grant_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/billing/balance": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get Credit Balance
+ * @description ログインユーザーのクレジット残高を返す。
+ */
+ get: operations["get_credit_balance_api_billing_balance_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/billing/transactions": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * List Credit Transactions
+ * @description クレジット台帳履歴(付与・消費)を新しい順に返す。
+ */
+ get: operations["list_credit_transactions_api_billing_transactions_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/blog/accounts": {
parameters: {
query?: never;
@@ -718,6 +781,18 @@ export interface paths {
export type webhooks = Record;
export interface components {
schemas: {
+ /**
+ * AdminCreditGrantRequest
+ * @description 管理者によるクレジット付与(Phase 1 の残高調整・テスト用)。
+ */
+ AdminCreditGrantRequest: {
+ /** Amount */
+ amount: number;
+ /** Description */
+ description?: string | null;
+ /** Username */
+ username: string;
+ };
/**
* AgentChatRequest
* @description Agent チャットのリクエスト。スコープ選択は必須。
@@ -725,6 +800,12 @@ export interface components {
AgentChatRequest: {
/** History */
history?: components["schemas"]["AgentHistoryEntry"][];
+ /**
+ * Model
+ * @default haiku
+ * @enum {string}
+ */
+ model: "haiku" | "sonnet";
/** Prompt */
prompt: string;
resume: components["schemas"]["AgentResumeContext"];
@@ -1175,6 +1256,35 @@ export interface components {
*/
level: number;
};
+ /**
+ * CreditBalanceResponse
+ * @description クレジット残高。
+ */
+ CreditBalanceResponse: {
+ /** Balance */
+ balance: number;
+ };
+ /**
+ * CreditTransactionResponse
+ * @description クレジット台帳エントリ 1 件(履歴表示用)。
+ */
+ CreditTransactionResponse: {
+ /** Amount */
+ amount: number;
+ /** Balance After */
+ balance_after: number;
+ /**
+ * Created At
+ * Format: date-time
+ */
+ created_at: string;
+ /** Description */
+ description?: string | null;
+ /** Id */
+ id: string;
+ /** Transaction Type */
+ transaction_type: string;
+ };
/** Experience */
"Experience-Input": {
/** Business Description */
@@ -1803,6 +1913,81 @@ export interface operations {
};
};
};
+ admin_grant_credits_api_billing_admin_grant_post: {
+ parameters: {
+ query?: never;
+ header?: {
+ authorization?: string | null;
+ };
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["AdminCreditGrantRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["CreditBalanceResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ get_credit_balance_api_billing_balance_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["CreditBalanceResponse"];
+ };
+ };
+ };
+ };
+ list_credit_transactions_api_billing_transactions_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["CreditTransactionResponse"][];
+ };
+ };
+ };
+ };
list_accounts_api_blog_accounts_get: {
parameters: {
query?: never;
diff --git a/frontend/src/api/paths.ts b/frontend/src/api/paths.ts
index 3f3bff41..5e3a1d5e 100644
--- a/frontend/src/api/paths.ts
+++ b/frontend/src/api/paths.ts
@@ -30,6 +30,10 @@ export const PATHS = {
agent: {
chat: "/api/agent/chat",
},
+ billing: {
+ balance: "/api/billing/balance",
+ transactions: "/api/billing/transactions",
+ },
resumes: {
base: "/api/resumes",
latest: "/api/resumes/latest",
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
index 564a58ac..96e439e9 100644
--- a/frontend/src/api/types.ts
+++ b/frontend/src/api/types.ts
@@ -141,3 +141,14 @@ export type ProjectTarget = Schemas["ProjectTarget"];
/** experience スコープの対象指定。backend `schemas/agent.py:ExperienceTarget`。 */
export type ExperienceTarget = Schemas["ExperienceTarget"];
+
+/** 選択可能な LLM モデルのエイリアス。backend `schemas/agent.py:AgentModelAlias`(ADR-0012)。 */
+export type AgentModelAlias = NonNullable;
+
+// ── 課金(billing.py / ADR-0012)─────────────────────────────────────────
+
+/** クレジット残高。backend `schemas/billing.py:CreditBalanceResponse`。 */
+export type CreditBalanceResponse = Schemas["CreditBalanceResponse"];
+
+/** クレジット台帳エントリ。backend `schemas/billing.py:CreditTransactionResponse`。 */
+export type CreditTransactionResponse = Schemas["CreditTransactionResponse"];
diff --git a/frontend/src/components/forms/AgentChatWidget.module.css b/frontend/src/components/forms/AgentChatWidget.module.css
index 3eb0fe8f..8789ad6f 100644
--- a/frontend/src/components/forms/AgentChatWidget.module.css
+++ b/frontend/src/components/forms/AgentChatWidget.module.css
@@ -113,6 +113,12 @@
color: var(--warning-text);
}
+.creditBalance {
+ margin: 0;
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+}
+
.messages {
flex: 1;
overflow-y: auto;
diff --git a/frontend/src/components/forms/AgentChatWidget.tsx b/frontend/src/components/forms/AgentChatWidget.tsx
index 36a6ebf3..0e201ae7 100644
--- a/frontend/src/components/forms/AgentChatWidget.tsx
+++ b/frontend/src/components/forms/AgentChatWidget.tsx
@@ -9,9 +9,14 @@
import { useCallback, useMemo, useState } from "react";
-import type { ExperienceTarget, ProjectTarget } from "../../api/types";
-import { AGENT_MESSAGES } from "../../constants/messages";
+import type { AgentModelAlias, ExperienceTarget, ProjectTarget } from "../../api/types";
+import {
+ AGENT_MESSAGES,
+ BILLING_MESSAGES,
+ creditBalanceLabel,
+} from "../../constants/messages";
import { useAgentChat, type AgentChatEntry } from "../../hooks/career/useAgentChat";
+import { useCreditBalance } from "../../hooks/useCreditBalance";
import type { CareerFormState } from "../../payloadBuilders";
import { useMessageToast, useToast } from "../ui/toast";
import { applyAgentOperations, type AgentScope } from "../../utils/agentOperations";
@@ -96,12 +101,16 @@ function buildExperienceOptions(form: CareerFormState): ExperienceOption[] {
export function AgentChatWidget({ form, onApply, isAuthenticated, requestLogin }: Props) {
const [open, setOpen] = useState(false);
const [scope, setScope] = useState("career_summary");
+ const [model, setModel] = useState("haiku");
const [projectTargetIndex, setProjectTargetIndex] = useState(0);
const [experienceTargetIndex, setExperienceTargetIndex] = useState(0);
const [prompt, setPrompt] = useState("");
/** ドラッグでリサイズされた寸法。null の間は CSS のデフォルトサイズに従う */
const [panelSize, setPanelSize] = useState<{ width: number; height: number } | null>(null);
const { entries, sending, error, send, markApplied, clearError } = useAgentChat();
+ // 残高は有料モデル(sonnet)選択時のみ取得・表示する(ADR-0012)
+ const isPaidModel = model === "sonnet";
+ const balanceState = useCreditBalance(open && isPaidModel);
const { showSuccess } = useToast();
useMessageToast(error, "error");
@@ -135,10 +144,22 @@ export function AgentChatWidget({ form, onApply, isAuthenticated, requestLogin }
const handleSend = () => {
if (!canSend) return;
clearError();
- void send(form, scope, getTarget(), prompt.trim());
+ void sendAndRefreshBalance(scope, getTarget(), prompt.trim());
setPrompt("");
};
+ /** 送信後、有料モデルなら残高を最新化する(消費は実トークン量で事後確定するため)。 */
+ const sendAndRefreshBalance = async (
+ sendScope: AgentScope,
+ target: ProjectTarget | ExperienceTarget | null,
+ text: string,
+ ) => {
+ await send(form, sendScope, target, text, model);
+ if (isPaidModel) {
+ void balanceState.refresh();
+ }
+ };
+
// パネル左上のハンドルをドラッグしてリサイズする。パネルは右下固定なので
// ポインタが左上に動くほど大きくなる(差分を加算)
const handleResizeStart = useCallback((e: React.PointerEvent) => {
@@ -226,6 +247,25 @@ export function AgentChatWidget({ form, onApply, isAuthenticated, requestLogin }
+
+ {isPaidModel && (
+
+ {balanceState.balance !== null
+ ? creditBalanceLabel(balanceState.balance)
+ : (balanceState.error ?? BILLING_MESSAGES.BALANCE_LOADING)}
+
+ )}
{scope === "experience" &&
(experienceOptions.length === 0 ? (
{AGENT_MESSAGES.TARGET_EXPERIENCE_EMPTY}
@@ -283,7 +323,7 @@ export function AgentChatWidget({ form, onApply, isAuthenticated, requestLogin }
onSelect={(suggestion) => {
if (sending) return;
clearError();
- void send(form, entry.scope, entry.target, suggestion);
+ void sendAndRefreshBalance(entry.scope, entry.target, suggestion);
}}
/>
)}
diff --git a/frontend/src/constants/errorCodes.ts b/frontend/src/constants/errorCodes.ts
index 746f268e..9311e8ef 100644
--- a/frontend/src/constants/errorCodes.ts
+++ b/frontend/src/constants/errorCodes.ts
@@ -30,6 +30,8 @@ export const ERROR_CODES = [
// Agent(LLM チャット / ADR-0010)
"AGENT_LLM_ERROR",
"AGENT_PARSE_ERROR",
+ // 課金(プリペイドクレジット / ADR-0012)
+ "INSUFFICIENT_CREDITS",
// アプリケーション全体
"RATE_LIMITED",
// サーバー
diff --git a/frontend/src/constants/errorMessages.ts b/frontend/src/constants/errorMessages.ts
index bf107d59..8bb8730e 100644
--- a/frontend/src/constants/errorMessages.ts
+++ b/frontend/src/constants/errorMessages.ts
@@ -47,6 +47,10 @@ export const ERROR_CONFIG: Record<
message: "AI の応答を解釈できませんでした",
recovery: { label: "もう一度試す", fn: null },
},
+ INSUFFICIENT_CREDITS: {
+ message: "クレジット残高が不足しています",
+ recovery: { label: "Haiku(無料)に切り替える", fn: null },
+ },
RATE_LIMITED: {
message: "リクエストが集中しています",
recovery: { label: "少し待って再試行", fn: null },
diff --git a/frontend/src/constants/messages.ts b/frontend/src/constants/messages.ts
index 6d4316f7..5d38dc11 100644
--- a/frontend/src/constants/messages.ts
+++ b/frontend/src/constants/messages.ts
@@ -67,6 +67,7 @@ export const FALLBACK_MESSAGES = {
GITHUB_OAUTH_START: "GitHub OAuth の開始に失敗しました",
GITHUB_LINK: "連携に失敗しました",
AGENT_CHAT: "AI への送信に失敗しました",
+ CREDIT_BALANCE: "クレジット残高の取得に失敗しました",
} as const;
/**
@@ -390,4 +391,17 @@ export const AGENT_MESSAGES = {
APPLIED_TOAST: "AI の提案をフォームに反映しました。内容を確認して保存してください。",
EMPTY_STATE: "編集対象を選んで、改善したい内容を AI に依頼してください。反映後も保存するまで DB は変更されません。",
LOGIN_REQUIRED: "devforge Agent を使うにはログインが必要です。",
+ MODEL_LABEL: "モデル",
+ MODEL_HAIKU: "Haiku(無料)",
+ MODEL_SONNET: "Sonnet(有料・高精度)",
} as const;
+
+/** クレジット課金(ADR-0012)の UI 文言。 */
+export const BILLING_MESSAGES = {
+ BALANCE_LOADING: "残高を確認中...",
+} as const;
+
+/** クレジット残高の表示文言(sonnet 選択時にウィジェットへ表示)。 */
+export function creditBalanceLabel(balance: number): string {
+ return `残高: ${balance.toLocaleString("ja-JP")} クレジット`;
+}
diff --git a/frontend/src/hooks/career/useAgentChat.test.ts b/frontend/src/hooks/career/useAgentChat.test.ts
index 0cd7d8a9..c8012405 100644
--- a/frontend/src/hooks/career/useAgentChat.test.ts
+++ b/frontend/src/hooks/career/useAgentChat.test.ts
@@ -202,6 +202,28 @@ describe("useAgentChat", () => {
expect(lastCall.history[0]).toEqual({ role: "user", text: "依頼2" });
});
+ it("model 未指定の送信では haiku(無料)を送る", async () => {
+ postAgentChatMock.mockResolvedValue({ message: "提案です", operations: [] });
+ const { result } = renderHook(() => useAgentChat());
+
+ await act(async () => {
+ await result.current.send(form, "self_pr", null, "改善して");
+ });
+
+ expect(postAgentChatMock).toHaveBeenCalledWith(expect.objectContaining({ model: "haiku" }));
+ });
+
+ it("sonnet 指定の送信では model=sonnet を送る(有料モデル / ADR-0012)", async () => {
+ postAgentChatMock.mockResolvedValue({ message: "提案です", operations: [] });
+ const { result } = renderHook(() => useAgentChat());
+
+ await act(async () => {
+ await result.current.send(form, "self_pr", null, "改善して", "sonnet");
+ });
+
+ expect(postAgentChatMock).toHaveBeenCalledWith(expect.objectContaining({ model: "sonnet" }));
+ });
+
it("experience スコープでは target を送る", async () => {
postAgentChatMock.mockResolvedValue({ message: "提案です", operations: [] });
const { result } = renderHook(() => useAgentChat());
diff --git a/frontend/src/hooks/career/useAgentChat.ts b/frontend/src/hooks/career/useAgentChat.ts
index 4ff1bf18..36a801b6 100644
--- a/frontend/src/hooks/career/useAgentChat.ts
+++ b/frontend/src/hooks/career/useAgentChat.ts
@@ -11,6 +11,7 @@ import { useCallback, useState } from "react";
import { postAgentChat } from "../../api/agent";
import type {
AgentHistoryEntry,
+ AgentModelAlias,
AgentOperation,
ExperienceTarget,
ProjectTarget,
@@ -69,6 +70,7 @@ export function useAgentChat() {
scope: AgentScope,
target: ProjectTarget | ExperienceTarget | null,
prompt: string,
+ model: AgentModelAlias = "haiku",
) => {
setError(null);
setSending(true);
@@ -88,6 +90,8 @@ export function useAgentChat() {
const response = await postAgentChat({
scope,
prompt,
+ // 使用モデル(haiku: 無料 / sonnet: 有料・クレジット消費 / ADR-0012)
+ model,
resume: buildAgentResumeContext(form),
target: scope === "project" || scope === "experience" ? target : null,
history: buildHistory(entries),
diff --git a/frontend/src/hooks/useCreditBalance.test.ts b/frontend/src/hooks/useCreditBalance.test.ts
new file mode 100644
index 00000000..a2cde6f7
--- /dev/null
+++ b/frontend/src/hooks/useCreditBalance.test.ts
@@ -0,0 +1,76 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { FALLBACK_MESSAGES } from "../constants/messages";
+import { useCreditBalance } from "./useCreditBalance";
+
+const getCreditBalanceMock = vi.fn();
+
+vi.mock("../api/billing", () => ({
+ getCreditBalance: (...args: unknown[]) => getCreditBalanceMock(...args),
+}));
+
+beforeEach(() => {
+ getCreditBalanceMock.mockReset();
+});
+
+describe("useCreditBalance", () => {
+ it("enabled=true で残高を取得し balance に保持する", async () => {
+ getCreditBalanceMock.mockResolvedValue({ balance: 12_000 });
+
+ const { result } = renderHook(() => useCreditBalance(true));
+
+ await waitFor(() => {
+ expect(result.current.balance).toBe(12_000);
+ });
+ expect(result.current.loading).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+
+ it("enabled=false の間は取得しない(無料モデル選択中は残高 API を呼ばない)", () => {
+ getCreditBalanceMock.mockResolvedValue({ balance: 100 });
+
+ const { result } = renderHook(() => useCreditBalance(false));
+
+ expect(getCreditBalanceMock).not.toHaveBeenCalled();
+ expect(result.current.balance).toBeNull();
+ });
+
+ it("取得失敗時は error にメッセージが入り loading が解除される", async () => {
+ getCreditBalanceMock.mockRejectedValue(new Error("クレジット残高が不足しています"));
+
+ const { result } = renderHook(() => useCreditBalance(true));
+
+ await waitFor(() => {
+ expect(result.current.error).toBe("クレジット残高が不足しています");
+ });
+ expect(result.current.loading).toBe(false);
+ expect(result.current.balance).toBeNull();
+ });
+
+ it("Error 以外の reject では fallback メッセージを使う", async () => {
+ getCreditBalanceMock.mockRejectedValue("unexpected");
+
+ const { result } = renderHook(() => useCreditBalance(true));
+
+ await waitFor(() => {
+ expect(result.current.error).toBe(FALLBACK_MESSAGES.CREDIT_BALANCE);
+ });
+ });
+
+ it("refresh で最新残高に更新される", async () => {
+ getCreditBalanceMock.mockResolvedValueOnce({ balance: 1_000 });
+ getCreditBalanceMock.mockResolvedValueOnce({ balance: 730 });
+
+ const { result } = renderHook(() => useCreditBalance(true));
+ await waitFor(() => {
+ expect(result.current.balance).toBe(1_000);
+ });
+
+ await act(async () => {
+ await result.current.refresh();
+ });
+
+ expect(result.current.balance).toBe(730);
+ });
+});
diff --git a/frontend/src/hooks/useCreditBalance.ts b/frontend/src/hooks/useCreditBalance.ts
new file mode 100644
index 00000000..f26935f2
--- /dev/null
+++ b/frontend/src/hooks/useCreditBalance.ts
@@ -0,0 +1,38 @@
+/**
+ * クレジット残高(ADR-0012)の取得・更新フック。
+ *
+ * sonnet(有料モデル)選択時のみ取得する(enabled フラグ)。
+ * チャット送信後は呼び出し側が refresh() で最新残高に更新する。
+ */
+
+import { useCallback, useEffect, useState } from "react";
+
+import { getCreditBalance } from "../api/billing";
+import { FALLBACK_MESSAGES } from "../constants/messages";
+
+export function useCreditBalance(enabled: boolean) {
+ const [balance, setBalance] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const refresh = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const response = await getCreditBalance();
+ setBalance(response.balance);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : FALLBACK_MESSAGES.CREDIT_BALANCE);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (enabled) {
+ void refresh();
+ }
+ }, [enabled, refresh]);
+
+ return { balance, loading, error, refresh };
+}
From 13a54ccce1115a571b3ff10b036eeddc70f69c39 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 13 Jun 2026 03:53:07 +0000
Subject: [PATCH 02/14] =?UTF-8?q?=E3=82=AF=E3=83=AC=E3=82=B8=E3=83=83?=
=?UTF-8?q?=E3=83=88=E6=AE=8B=E9=AB=98=E8=A1=A8=E7=A4=BA=E3=82=92=E3=82=B5?=
=?UTF-8?q?=E3=82=A4=E3=83=89=E3=83=90=E3=83=BC=E3=81=B8=E7=A7=BB=E7=AE=A1?=
=?UTF-8?q?=EF=BC=88ADR-0012=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
残高は「Agent の機能」ではなくアカウント属性のため、Agent ウィジェット内の
インライン表示を撤去し、サイドバーフッターに常時表示する。
- CreditBalanceProvider / creditBalanceContext を新設し、サイドバー(表示)と
ウィジェット(消費後の refresh)で残高状態を共有
- SidebarLayout のフッターに CreditBalanceBadge を追加(認証済みのみ)。
認証済み全ページで残高を取得(通知ベルと同じ常時表示パターン)
- AgentChatWidget はインライン残高表示を削除し、Sonnet 消費後に
Context の refresh を呼んでサイドバーの数値を最新化
- messages: creditBalanceLabel を撤去し SIDEBAR_LABEL + formatCreditAmount に再編
- E2E: 残高をサイドバー表示前提に更新。認証済み全ページが /api/billing/balance を
叩くため helpers/auth.ts にデフォルトモック追加
- 残高 Provider のユニットテスト追加、Context 必須化に伴い CareerResumeForm テストを
Provider でラップ
入口(課金ページへの遷移)は /settings/billing を作る Phase 2 で接続する。
https://claude.ai/code/session_01VHYFnKVfBX64pNdhb5kxVt
---
frontend/e2e/agent-chat.spec.ts | 17 ++----
frontend/e2e/helpers/auth.ts | 9 +++
frontend/src/components/SidebarLayout.tsx | 8 +++
.../billing/CreditBalanceBadge.module.css | 24 ++++++++
.../components/billing/CreditBalanceBadge.tsx | 27 +++++++++
.../billing/CreditBalanceProvider.test.tsx | 58 +++++++++++++++++++
.../billing/CreditBalanceProvider.tsx | 24 ++++++++
.../billing/creditBalanceContext.ts | 29 ++++++++++
.../forms/AgentChatWidget.module.css | 6 --
.../src/components/forms/AgentChatWidget.tsx | 22 ++-----
.../forms/CareerResumeForm.test.tsx | 10 +++-
frontend/src/constants/messages.ts | 7 ++-
12 files changed, 202 insertions(+), 39 deletions(-)
create mode 100644 frontend/src/components/billing/CreditBalanceBadge.module.css
create mode 100644 frontend/src/components/billing/CreditBalanceBadge.tsx
create mode 100644 frontend/src/components/billing/CreditBalanceProvider.test.tsx
create mode 100644 frontend/src/components/billing/CreditBalanceProvider.tsx
create mode 100644 frontend/src/components/billing/creditBalanceContext.ts
diff --git a/frontend/e2e/agent-chat.spec.ts b/frontend/e2e/agent-chat.spec.ts
index 4f92fa36..013ac66c 100644
--- a/frontend/e2e/agent-chat.spec.ts
+++ b/frontend/e2e/agent-chat.spec.ts
@@ -92,7 +92,8 @@ test.describe("Agent チャットウィジェット", () => {
await expect(page.getByRole("button", { name: "フォームに反映" })).toHaveCount(0);
});
- test("Sonnet(有料)選択で残高が表示され model=sonnet で送信される", async ({ page }) => {
+ test("残高はサイドバーに表示され、Sonnet 送信で model=sonnet が送られる", async ({ page }) => {
+ // サイドバーの残高(ADR-0012)。setupAuth のデフォルトより後に登録して上書きする
await page.route("**/api/billing/balance", (route) =>
route.fulfill({
status: 200,
@@ -113,11 +114,12 @@ test.describe("Agent チャットウィジェット", () => {
await page.goto("/career");
await waitForAuthenticatedLayout(page);
- await page.getByRole("button", { name: "devforge Agent" }).click();
+ // 残高はサイドバー(ユーザーステータス)に常時表示される(ウィジェットを開かなくても見える)
+ await expect(page.getByText("クレジット残高")).toBeVisible();
+ await expect(page.getByText("12,000")).toBeVisible();
- // モデルセレクタで Sonnet を選ぶと残高が表示される(ADR-0012)
+ await page.getByRole("button", { name: "devforge Agent" }).click();
await page.getByLabel("モデル").selectOption("sonnet");
- await expect(page.getByText("残高: 12,000 クレジット")).toBeVisible();
await page
.getByPlaceholder("例: 成果がより伝わる文章にしてください")
@@ -129,13 +131,6 @@ test.describe("Agent チャットウィジェット", () => {
});
test("Sonnet で残高不足(402)はエラートーストで通知される", async ({ page }) => {
- await page.route("**/api/billing/balance", (route) =>
- route.fulfill({
- status: 200,
- contentType: "application/json",
- body: JSON.stringify({ balance: 0 }),
- }),
- );
await page.route("**/api/agent/chat", (route) =>
route.fulfill({
status: 402,
diff --git a/frontend/e2e/helpers/auth.ts b/frontend/e2e/helpers/auth.ts
index 7d9ce6c3..551968d3 100644
--- a/frontend/e2e/helpers/auth.ts
+++ b/frontend/e2e/helpers/auth.ts
@@ -45,6 +45,15 @@ export async function setupAuth(page: Page) {
}),
);
+ // クレジット残高をモック(サイドバーが認証済み全ページで取得する / ADR-0012。デフォルト: 0)
+ await page.route("**/api/billing/balance", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ balance: 0 }),
+ }),
+ );
+
// 認証 API をモック(最高優先)
await page.route("**/auth/me", (route) =>
route.fulfill({
diff --git a/frontend/src/components/SidebarLayout.tsx b/frontend/src/components/SidebarLayout.tsx
index 4f971c46..69828203 100644
--- a/frontend/src/components/SidebarLayout.tsx
+++ b/frontend/src/components/SidebarLayout.tsx
@@ -8,6 +8,8 @@ import type { Theme } from "../hooks/useTheme";
import { AUTH_PROMPT_MESSAGES, UI_MESSAGES } from "../constants/messages";
import { NotificationBell } from "./NotificationBell";
import { UserMenu } from "./UserMenu";
+import { CreditBalanceBadge } from "./billing/CreditBalanceBadge";
+import { CreditBalanceProvider } from "./billing/CreditBalanceProvider";
import { useLoginPrompt } from "./auth/loginPromptContext";
import { ChevronDownIcon } from "./icons/ChevronDownIcon";
import shared from "../styles/shared.module.css";
@@ -66,6 +68,9 @@ export function SidebarLayout({
return (
+ {/* 残高(アカウント属性)をサイドバーと Agent ウィジェットで共有する(ADR-0012)。
+ 認証済みの間だけ取得し、ウィジェットの有料モデル消費後に refresh で更新される。 */}
+
@@ -181,6 +186,8 @@ export function SidebarLayout({
)}
+ {/* クレジット残高(ユーザーステータス)は認証済みのみ表示。 */}
+ {isAuthenticated && }
{/* 通知ベルは認証済みのみ(未認証はポーリングで 401 を連発しないよう出さない)。 */}
{isAuthenticated && }
{children ?? }
+
);
}
diff --git a/frontend/src/components/billing/CreditBalanceBadge.module.css b/frontend/src/components/billing/CreditBalanceBadge.module.css
new file mode 100644
index 00000000..6d50e8a9
--- /dev/null
+++ b/frontend/src/components/billing/CreditBalanceBadge.module.css
@@ -0,0 +1,24 @@
+.badge {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 0.4rem 0.75rem;
+ margin-bottom: 0.4rem;
+ font-size: 0.78rem;
+ color: var(--text-secondary);
+ border-bottom: 1px solid var(--sidebar-border);
+}
+
+.label {
+ color: var(--text-secondary);
+}
+
+.value {
+ font-weight: 600;
+ color: var(--text-primary);
+ font-variant-numeric: tabular-nums;
+}
+
+.error {
+ color: var(--warning-text);
+}
diff --git a/frontend/src/components/billing/CreditBalanceBadge.tsx b/frontend/src/components/billing/CreditBalanceBadge.tsx
new file mode 100644
index 00000000..6b0f320a
--- /dev/null
+++ b/frontend/src/components/billing/CreditBalanceBadge.tsx
@@ -0,0 +1,27 @@
+import { BILLING_MESSAGES, formatCreditAmount } from "../../constants/messages";
+import { useCreditBalanceContext } from "./creditBalanceContext";
+import styles from "./CreditBalanceBadge.module.css";
+
+/**
+ * サイドバーフッターに常時表示するクレジット残高(ADR-0012)。
+ * 残高はアカウント属性のため、Agent ウィジェットではなくサイドバーで表示する。
+ * 値の正本は CreditBalanceProvider(消費後はウィジェットが refresh して更新する)。
+ */
+export function CreditBalanceBadge() {
+ const { balance, error } = useCreditBalanceContext();
+
+ return (
+
+ {balance !== null ? (
+ <>
+ {BILLING_MESSAGES.SIDEBAR_LABEL}
+ {formatCreditAmount(balance)}
+ >
+ ) : (
+
+ {error ?? BILLING_MESSAGES.BALANCE_LOADING}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/billing/CreditBalanceProvider.test.tsx b/frontend/src/components/billing/CreditBalanceProvider.test.tsx
new file mode 100644
index 00000000..4f437d31
--- /dev/null
+++ b/frontend/src/components/billing/CreditBalanceProvider.test.tsx
@@ -0,0 +1,58 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { CreditBalanceProvider } from "./CreditBalanceProvider";
+import { useCreditBalanceContext } from "./creditBalanceContext";
+
+const getCreditBalanceMock = vi.fn();
+
+vi.mock("../../api/billing", () => ({
+ getCreditBalance: (...args: unknown[]) => getCreditBalanceMock(...args),
+}));
+
+function BalanceConsumer() {
+ const { balance } = useCreditBalanceContext();
+ return balance={balance === null ? "null" : balance};
+}
+
+beforeEach(() => {
+ getCreditBalanceMock.mockReset();
+});
+
+describe("CreditBalanceProvider / useCreditBalanceContext", () => {
+ it("enabled=true で取得した残高を配下へ配布する", async () => {
+ getCreditBalanceMock.mockResolvedValue({ balance: 8500 });
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("balance=8500")).toBeTruthy();
+ });
+ });
+
+ it("enabled=false(未認証)では取得せず balance は null のまま", () => {
+ getCreditBalanceMock.mockResolvedValue({ balance: 8500 });
+
+ render(
+
+
+ ,
+ );
+
+ expect(getCreditBalanceMock).not.toHaveBeenCalled();
+ expect(screen.getByText("balance=null")).toBeTruthy();
+ });
+
+ it("Provider 外で useCreditBalanceContext を使うと例外(配線ミスを握りつぶさない)", () => {
+ // render 中の throw をコンソールへ吐かせないため一時的に握る
+ const spy = vi.spyOn(console, "error").mockImplementation(() => {});
+ expect(() => render()).toThrow(
+ "useCreditBalanceContext must be used within a CreditBalanceProvider",
+ );
+ spy.mockRestore();
+ });
+});
diff --git a/frontend/src/components/billing/CreditBalanceProvider.tsx b/frontend/src/components/billing/CreditBalanceProvider.tsx
new file mode 100644
index 00000000..ff96f321
--- /dev/null
+++ b/frontend/src/components/billing/CreditBalanceProvider.tsx
@@ -0,0 +1,24 @@
+import type { ReactNode } from "react";
+
+import { useCreditBalance } from "../../hooks/useCreditBalance";
+import { CreditBalanceContext } from "./creditBalanceContext";
+
+/**
+ * クレジット残高(ADR-0012)を 1 つだけ保持し、配下のどこからでも
+ * `useCreditBalanceContext()` で参照できるようにする Provider。
+ *
+ * サイドバー(残高表示)と Agent ウィジェット(消費後の `refresh`)が同じ状態を共有する。
+ * `enabled`(= 認証済み)の間だけ残高を取得する。未認証では取得しない(401 連発を防ぐ)。
+ */
+export function CreditBalanceProvider({
+ enabled,
+ children,
+}: {
+ enabled: boolean;
+ children: ReactNode;
+}) {
+ const state = useCreditBalance(enabled);
+ return (
+ {children}
+ );
+}
diff --git a/frontend/src/components/billing/creditBalanceContext.ts b/frontend/src/components/billing/creditBalanceContext.ts
new file mode 100644
index 00000000..fe8e5259
--- /dev/null
+++ b/frontend/src/components/billing/creditBalanceContext.ts
@@ -0,0 +1,29 @@
+import { createContext, useContext } from "react";
+
+/**
+ * クレジット残高(ADR-0012)をアプリ横断で配布するコンテキスト。
+ *
+ * 残高は「Agent の機能」ではなく「アカウントの属性」のため、サイドバー(常時表示)と
+ * Agent ウィジェット(消費後の再取得)の双方が同じ 1 つの状態を共有する。
+ * Provider 本体は {@link ./CreditBalanceProvider} を参照。
+ */
+export type CreditBalanceContextValue = {
+ /** 現在残高。未取得・無効化中は null。 */
+ balance: number | null;
+ loading: boolean;
+ error: string | null;
+ /** 最新残高を取り直す(有料モデル消費後に呼ぶ)。 */
+ refresh: () => Promise;
+};
+
+export const CreditBalanceContext = createContext(null);
+
+/** クレジット残高の状態を取得する。Provider 配下でのみ利用できる。 */
+export function useCreditBalanceContext(): CreditBalanceContextValue {
+ const ctx = useContext(CreditBalanceContext);
+ if (!ctx) {
+ // 配線ミスを握りつぶさず即座に気付けるようにする(開発者向け内部エラー)。
+ throw new Error("useCreditBalanceContext must be used within a CreditBalanceProvider");
+ }
+ return ctx;
+}
diff --git a/frontend/src/components/forms/AgentChatWidget.module.css b/frontend/src/components/forms/AgentChatWidget.module.css
index 8789ad6f..3eb0fe8f 100644
--- a/frontend/src/components/forms/AgentChatWidget.module.css
+++ b/frontend/src/components/forms/AgentChatWidget.module.css
@@ -113,12 +113,6 @@
color: var(--warning-text);
}
-.creditBalance {
- margin: 0;
- font-size: 0.8rem;
- color: var(--text-secondary);
-}
-
.messages {
flex: 1;
overflow-y: auto;
diff --git a/frontend/src/components/forms/AgentChatWidget.tsx b/frontend/src/components/forms/AgentChatWidget.tsx
index 0e201ae7..73efc8fe 100644
--- a/frontend/src/components/forms/AgentChatWidget.tsx
+++ b/frontend/src/components/forms/AgentChatWidget.tsx
@@ -10,13 +10,9 @@
import { useCallback, useMemo, useState } from "react";
import type { AgentModelAlias, ExperienceTarget, ProjectTarget } from "../../api/types";
-import {
- AGENT_MESSAGES,
- BILLING_MESSAGES,
- creditBalanceLabel,
-} from "../../constants/messages";
+import { AGENT_MESSAGES } from "../../constants/messages";
import { useAgentChat, type AgentChatEntry } from "../../hooks/career/useAgentChat";
-import { useCreditBalance } from "../../hooks/useCreditBalance";
+import { useCreditBalanceContext } from "../billing/creditBalanceContext";
import type { CareerFormState } from "../../payloadBuilders";
import { useMessageToast, useToast } from "../ui/toast";
import { applyAgentOperations, type AgentScope } from "../../utils/agentOperations";
@@ -108,9 +104,10 @@ export function AgentChatWidget({ form, onApply, isAuthenticated, requestLogin }
/** ドラッグでリサイズされた寸法。null の間は CSS のデフォルトサイズに従う */
const [panelSize, setPanelSize] = useState<{ width: number; height: number } | null>(null);
const { entries, sending, error, send, markApplied, clearError } = useAgentChat();
- // 残高は有料モデル(sonnet)選択時のみ取得・表示する(ADR-0012)
+ // 残高はサイドバーに常時表示する(ADR-0012)。ウィジェットは有料モデル消費後に
+ // サイドバーの数値を最新化するため refresh のみ利用する
const isPaidModel = model === "sonnet";
- const balanceState = useCreditBalance(open && isPaidModel);
+ const { refresh: refreshBalance } = useCreditBalanceContext();
const { showSuccess } = useToast();
useMessageToast(error, "error");
@@ -156,7 +153,7 @@ export function AgentChatWidget({ form, onApply, isAuthenticated, requestLogin }
) => {
await send(form, sendScope, target, text, model);
if (isPaidModel) {
- void balanceState.refresh();
+ void refreshBalance();
}
};
@@ -259,13 +256,6 @@ export function AgentChatWidget({ form, onApply, isAuthenticated, requestLogin }
- {isPaidModel && (
-
- {balanceState.balance !== null
- ? creditBalanceLabel(balanceState.balance)
- : (balanceState.error ?? BILLING_MESSAGES.BALANCE_LOADING)}
-
- )}
{scope === "experience" &&
(experienceOptions.length === 0 ? (
{AGENT_MESSAGES.TARGET_EXPERIENCE_EMPTY}
diff --git a/frontend/src/components/forms/CareerResumeForm.test.tsx b/frontend/src/components/forms/CareerResumeForm.test.tsx
index 8d5f0f18..5b0494ff 100644
--- a/frontend/src/components/forms/CareerResumeForm.test.tsx
+++ b/frontend/src/components/forms/CareerResumeForm.test.tsx
@@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { CareerResumeForm } from "./CareerResumeForm";
import { LoginPromptContext } from "../auth/loginPromptContext";
+import { CreditBalanceProvider } from "../billing/CreditBalanceProvider";
import { ToastProvider } from "../ui/toast";
import formCacheReducer from "../../store/formCacheSlice";
import { UI_MESSAGES, VALIDATION_MESSAGES } from "../../constants/messages";
@@ -31,9 +32,12 @@ function renderAnonymousForm() {
render(
-
-
-
+ {/* 本番ではフォームは常に SidebarLayout(残高 Provider)配下。未認証は enabled=false で取得しない */}
+
+
+
+
+
,
);
diff --git a/frontend/src/constants/messages.ts b/frontend/src/constants/messages.ts
index 5d38dc11..cd02e7f5 100644
--- a/frontend/src/constants/messages.ts
+++ b/frontend/src/constants/messages.ts
@@ -399,9 +399,10 @@ export const AGENT_MESSAGES = {
/** クレジット課金(ADR-0012)の UI 文言。 */
export const BILLING_MESSAGES = {
BALANCE_LOADING: "残高を確認中...",
+ SIDEBAR_LABEL: "クレジット残高",
} as const;
-/** クレジット残高の表示文言(sonnet 選択時にウィジェットへ表示)。 */
-export function creditBalanceLabel(balance: number): string {
- return `残高: ${balance.toLocaleString("ja-JP")} クレジット`;
+/** クレジット残高の数値を 3 桁区切りで整形する(単位ラベルは別途付与)。 */
+export function formatCreditAmount(balance: number): string {
+ return balance.toLocaleString("ja-JP");
}
From 1fc5989a6d51038854e8ed7e13a3c6b520d52894 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 13 Jun 2026 07:57:59 +0000
Subject: [PATCH 03/14] =?UTF-8?q?=E3=83=A2=E3=83=87=E3=83=AB=E9=81=B8?=
=?UTF-8?q?=E6=8A=9E=E3=82=92=E3=82=B0=E3=83=AD=E3=83=BC=E3=83=90=E3=83=AB?=
=?UTF-8?q?=E5=8C=96=E3=81=97=E3=83=97=E3=83=A9=E3=83=B3=E3=82=AB=E3=83=BC?=
=?UTF-8?q?=E3=83=89=E3=81=A7=E5=88=87=E3=82=8A=E6=9B=BF=E3=81=88=EF=BC=88?=
=?UTF-8?q?ADR-0012=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
モデル選択は「Agent ウィジェットのローカル状態」ではなく「アカウント横断の設定」
として扱い、ウィジェット内のセレクタを撤去してサイドバー常時表示 + UserMenu の
モデル選択モーダルに移す。
- store/agentModelSlice: 使用モデルを redux-persist で端末に永続化(PII なし)。
既定は無料の haiku(誤課金を防ぐ安全側)
- constants/agentModels: 表示メタデータ(製品名・無料/有料・説明・コスト目安)の
フロント SSoT。モデル追加時はこの配列の 1 エントリ追加で UI が追従する
- components/agent/AgentModelBadge: サイドバーに「使用モデル」を表示専用で常時表示
- components/agent/ModelSelectModal: Claude のプラン選択風カード UI。残高不足の
Sonnet には警告(選択自体は許可し送信時 402 で誘導)
- UserMenu に「AI モデルを切り替え」項目を追加(認証済みのみ)
- AgentChatWidget はモデルセレクタを撤去し redux のモデルを読むだけに変更
- スライス/モーダルのユニットテスト追加、E2E をモーダル切り替えフローに更新
https://claude.ai/code/session_01VHYFnKVfBX64pNdhb5kxVt
---
frontend/e2e/agent-chat.spec.ts | 30 +++-
frontend/src/components/SidebarLayout.tsx | 12 +-
frontend/src/components/UserMenu.tsx | 26 +++-
.../agent/AgentModelBadge.module.css | 27 ++++
.../src/components/agent/AgentModelBadge.tsx | 21 +++
.../agent/ModelSelectModal.module.css | 132 ++++++++++++++++++
.../agent/ModelSelectModal.test.tsx | 81 +++++++++++
.../src/components/agent/ModelSelectModal.tsx | 84 +++++++++++
.../src/components/forms/AgentChatWidget.tsx | 22 +--
.../forms/CareerResumeForm.test.tsx | 5 +-
frontend/src/constants/agentModels.ts | 42 ++++++
frontend/src/constants/messages.ts | 21 ++-
frontend/src/store/agentModelSlice.test.ts | 20 +++
frontend/src/store/agentModelSlice.ts | 30 ++++
frontend/src/store/index.ts | 3 +
15 files changed, 527 insertions(+), 29 deletions(-)
create mode 100644 frontend/src/components/agent/AgentModelBadge.module.css
create mode 100644 frontend/src/components/agent/AgentModelBadge.tsx
create mode 100644 frontend/src/components/agent/ModelSelectModal.module.css
create mode 100644 frontend/src/components/agent/ModelSelectModal.test.tsx
create mode 100644 frontend/src/components/agent/ModelSelectModal.tsx
create mode 100644 frontend/src/constants/agentModels.ts
create mode 100644 frontend/src/store/agentModelSlice.test.ts
create mode 100644 frontend/src/store/agentModelSlice.ts
diff --git a/frontend/e2e/agent-chat.spec.ts b/frontend/e2e/agent-chat.spec.ts
index 013ac66c..04352c50 100644
--- a/frontend/e2e/agent-chat.spec.ts
+++ b/frontend/e2e/agent-chat.spec.ts
@@ -38,6 +38,14 @@ async function setupResumeApi(page: Page) {
);
}
+/** UserMenu → モデル選択モーダル経由で使用モデルを切り替える(ADR-0012)。 */
+async function selectModel(page: Page, modelName: "Haiku" | "Sonnet") {
+ await page.getByRole("button", { name: "e2e-test-user" }).click();
+ await page.getByRole("button", { name: "AI モデルを切り替え" }).click();
+ const dialog = page.getByRole("dialog");
+ await dialog.getByRole("button", { name: new RegExp(`^${modelName}`) }).click();
+}
+
test.describe("Agent チャットウィジェット", () => {
test.beforeEach(async ({ page }) => {
await setupAuth(page);
@@ -92,7 +100,9 @@ test.describe("Agent チャットウィジェット", () => {
await expect(page.getByRole("button", { name: "フォームに反映" })).toHaveCount(0);
});
- test("残高はサイドバーに表示され、Sonnet 送信で model=sonnet が送られる", async ({ page }) => {
+ test("使用モデルはサイドバーに表示され、UserMenu のモーダルで Sonnet に切り替えられる", async ({
+ page,
+ }) => {
// サイドバーの残高(ADR-0012)。setupAuth のデフォルトより後に登録して上書きする
await page.route("**/api/billing/balance", (route) =>
route.fulfill({
@@ -114,13 +124,18 @@ test.describe("Agent チャットウィジェット", () => {
await page.goto("/career");
await waitForAuthenticatedLayout(page);
- // 残高はサイドバー(ユーザーステータス)に常時表示される(ウィジェットを開かなくても見える)
- await expect(page.getByText("クレジット残高")).toBeVisible();
- await expect(page.getByText("12,000")).toBeVisible();
+ const sidebar = page.locator("aside").first();
+ // 使用モデル・残高はサイドバーに常時表示される(既定は Haiku)
+ await expect(sidebar.getByText("使用モデル")).toBeVisible();
+ await expect(sidebar.getByText("Haiku")).toBeVisible();
+ await expect(sidebar.getByText("クレジット残高")).toBeVisible();
+ await expect(sidebar.getByText("12,000")).toBeVisible();
- await page.getByRole("button", { name: "devforge Agent" }).click();
- await page.getByLabel("モデル").selectOption("sonnet");
+ // UserMenu → モデル選択モーダル → Sonnet カードで切り替え
+ await selectModel(page, "Sonnet");
+ await expect(sidebar.getByText("Sonnet")).toBeVisible();
+ await page.getByRole("button", { name: "devforge Agent" }).click();
await page
.getByPlaceholder("例: 成果がより伝わる文章にしてください")
.fill("プロらしい文章にして");
@@ -146,8 +161,9 @@ test.describe("Agent チャットウィジェット", () => {
await page.goto("/career");
await waitForAuthenticatedLayout(page);
+ // 残高 0(setupAuth の既定)で Sonnet に切り替えてから送信
+ await selectModel(page, "Sonnet");
await page.getByRole("button", { name: "devforge Agent" }).click();
- await page.getByLabel("モデル").selectOption("sonnet");
await page
.getByPlaceholder("例: 成果がより伝わる文章にしてください")
.fill("改善して");
diff --git a/frontend/src/components/SidebarLayout.tsx b/frontend/src/components/SidebarLayout.tsx
index 69828203..22a5d63c 100644
--- a/frontend/src/components/SidebarLayout.tsx
+++ b/frontend/src/components/SidebarLayout.tsx
@@ -8,6 +8,8 @@ import type { Theme } from "../hooks/useTheme";
import { AUTH_PROMPT_MESSAGES, UI_MESSAGES } from "../constants/messages";
import { NotificationBell } from "./NotificationBell";
import { UserMenu } from "./UserMenu";
+import { AgentModelBadge } from "./agent/AgentModelBadge";
+import { ModelSelectModal } from "./agent/ModelSelectModal";
import { CreditBalanceBadge } from "./billing/CreditBalanceBadge";
import { CreditBalanceProvider } from "./billing/CreditBalanceProvider";
import { useLoginPrompt } from "./auth/loginPromptContext";
@@ -45,6 +47,8 @@ export function SidebarLayout({
const [includeForks, setIncludeForks] = useState(false);
// サイドバーの折りたたみ状態。折りたたむと本文領域が全幅に広がる。
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
+ // AI モデル選択モーダルの開閉(UserMenu から開く / ADR-0012)。
+ const [modelSelectOpen, setModelSelectOpen] = useState(false);
const isAuthenticated = user !== null;
@@ -186,7 +190,8 @@ export function SidebarLayout({
)}
- {/* クレジット残高(ユーザーステータス)は認証済みのみ表示。 */}
+ {/* AI ステータス(使用モデル + クレジット残高)は認証済みのみ表示。 */}
+ {isAuthenticated &&
}
{isAuthenticated &&
}
{/* 通知ベルは認証済みのみ(未認証はポーリングで 401 を連発しないよう出さない)。 */}
{isAuthenticated &&
}
@@ -197,12 +202,17 @@ export function SidebarLayout({
onToggleTheme={onToggleTheme}
onLogout={onLogout}
onLogin={handleLogin}
+ onOpenModelSelect={isAuthenticated ? () => setModelSelectOpen(true) : undefined}
/>
{children ?? }
+ {/* モデル選択モーダルは残高 Provider 配下(残高不足の警告表示に balance を使う)。 */}
+ {isAuthenticated && modelSelectOpen && (
+ setModelSelectOpen(false)} />
+ )}
);
diff --git a/frontend/src/components/UserMenu.tsx b/frontend/src/components/UserMenu.tsx
index c9e8ee13..fca675ac 100644
--- a/frontend/src/components/UserMenu.tsx
+++ b/frontend/src/components/UserMenu.tsx
@@ -1,6 +1,11 @@
import { useEffect, useRef, useState } from "react";
-import { AUTH_PROMPT_MESSAGES, EXTERNAL_LINKS, UI_MESSAGES } from "../constants/messages";
+import {
+ AGENT_MODEL_MESSAGES,
+ AUTH_PROMPT_MESSAGES,
+ EXTERNAL_LINKS,
+ UI_MESSAGES,
+} from "../constants/messages";
import type { Theme } from "../hooks/useTheme";
import { GitHubMarkIcon } from "./icons/GitHubMarkIcon";
import styles from "./UserMenu.module.css";
@@ -18,6 +23,7 @@ export function UserMenu({
onToggleTheme,
onLogout,
onLogin,
+ onOpenModelSelect,
}: {
isAuthenticated: boolean;
username: string | null;
@@ -25,6 +31,8 @@ export function UserMenu({
onToggleTheme: () => void;
onLogout: () => void;
onLogin: () => void;
+ /** AI モデル選択モーダルを開く(認証済みのみ表示 / ADR-0012)。 */
+ onOpenModelSelect?: () => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
@@ -53,6 +61,22 @@ export function UserMenu({
+ {/* AI モデル切り替えは認証済みのみ(モデル選択はログインユーザーの設定 / ADR-0012)。 */}
+ {isAuthenticated && onOpenModelSelect && (
+ <>
+
+
+ >
+ )}
{/* 未認証ではログインはトリガー側のボタンに集約するため、メニューには出さない。 */}
{isAuthenticated && (
<>
diff --git a/frontend/src/components/agent/AgentModelBadge.module.css b/frontend/src/components/agent/AgentModelBadge.module.css
new file mode 100644
index 00000000..8e19d1e0
--- /dev/null
+++ b/frontend/src/components/agent/AgentModelBadge.module.css
@@ -0,0 +1,27 @@
+.badge {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 0.4rem 0.75rem;
+ font-size: 0.78rem;
+ color: var(--text-secondary);
+}
+
+.label {
+ color: var(--text-secondary);
+}
+
+.value {
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.paid {
+ margin-left: auto;
+ padding: 0.05rem 0.4rem;
+ font-size: 0.68rem;
+ font-weight: 600;
+ color: var(--accent);
+ border: 1px solid var(--accent);
+ border-radius: 999px;
+}
diff --git a/frontend/src/components/agent/AgentModelBadge.tsx b/frontend/src/components/agent/AgentModelBadge.tsx
new file mode 100644
index 00000000..76da88ef
--- /dev/null
+++ b/frontend/src/components/agent/AgentModelBadge.tsx
@@ -0,0 +1,21 @@
+import { getModelOption } from "../../constants/agentModels";
+import { AGENT_MODEL_MESSAGES } from "../../constants/messages";
+import { useAppSelector } from "../../store";
+import styles from "./AgentModelBadge.module.css";
+
+/**
+ * サイドバーに常時表示する「使用モデル」(ADR-0012)。表示専用。
+ * 切り替えは UserMenu → モデル選択モーダルで行う(ここからは切り替えない)。
+ */
+export function AgentModelBadge() {
+ const model = useAppSelector((state) => state.agentModel.model);
+ const option = getModelOption(model);
+
+ return (
+
+ {AGENT_MODEL_MESSAGES.SIDEBAR_LABEL}
+ {option.name}
+ {option.isPaid && {AGENT_MODEL_MESSAGES.PAID_BADGE}}
+
+ );
+}
diff --git a/frontend/src/components/agent/ModelSelectModal.module.css b/frontend/src/components/agent/ModelSelectModal.module.css
new file mode 100644
index 00000000..1beee201
--- /dev/null
+++ b/frontend/src/components/agent/ModelSelectModal.module.css
@@ -0,0 +1,132 @@
+.overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 300;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1rem;
+ background: rgba(0, 0, 0, 0.45);
+}
+
+.dialog {
+ width: 100%;
+ max-width: 460px;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 1.25rem;
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35);
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.title {
+ margin: 0;
+ font-size: 1.05rem;
+ color: var(--text-primary);
+}
+
+.close {
+ background: none;
+ border: none;
+ font-size: 1.4rem;
+ line-height: 1;
+ color: var(--text-secondary);
+ cursor: pointer;
+}
+
+.description {
+ margin: 0.5rem 0 1rem;
+ font-size: 0.85rem;
+ color: var(--text-secondary);
+}
+
+.cards {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.card {
+ display: block;
+ width: 100%;
+ text-align: left;
+ background: var(--bg-input);
+ border: 1px solid var(--border-input);
+ border-radius: 10px;
+ padding: 0.9rem 1rem;
+ cursor: pointer;
+ transition: border-color 0.15s, box-shadow 0.15s;
+}
+
+.card:hover {
+ border-color: var(--accent);
+}
+
+.cardCurrent {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 1px var(--accent) inset;
+}
+
+.cardHead {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.cardName {
+ font-size: 1rem;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.freeBadge,
+.paidBadge {
+ padding: 0.05rem 0.45rem;
+ font-size: 0.68rem;
+ font-weight: 600;
+ border-radius: 999px;
+}
+
+.freeBadge {
+ color: var(--text-secondary);
+ border: 1px solid var(--border);
+}
+
+.paidBadge {
+ color: var(--accent);
+ border: 1px solid var(--accent);
+}
+
+.currentBadge {
+ margin-left: auto;
+ padding: 0.05rem 0.45rem;
+ font-size: 0.68rem;
+ font-weight: 600;
+ color: #fff;
+ background: var(--accent);
+ border-radius: 999px;
+}
+
+.cardTagline {
+ margin: 0.45rem 0 0.25rem;
+ font-size: 0.85rem;
+ color: var(--text-primary);
+}
+
+.cardCost {
+ margin: 0;
+ font-size: 0.78rem;
+ color: var(--text-secondary);
+}
+
+.insufficient {
+ margin: 0.5rem 0 0;
+ font-size: 0.78rem;
+ color: var(--warning-text);
+}
diff --git a/frontend/src/components/agent/ModelSelectModal.test.tsx b/frontend/src/components/agent/ModelSelectModal.test.tsx
new file mode 100644
index 00000000..216e7641
--- /dev/null
+++ b/frontend/src/components/agent/ModelSelectModal.test.tsx
@@ -0,0 +1,81 @@
+import { configureStore } from "@reduxjs/toolkit";
+import { fireEvent, render, screen, within } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { Provider } from "react-redux";
+import { describe, expect, it, vi } from "vitest";
+
+import agentModelReducer from "../../store/agentModelSlice";
+import {
+ CreditBalanceContext,
+ type CreditBalanceContextValue,
+} from "../billing/creditBalanceContext";
+import { ModelSelectModal } from "./ModelSelectModal";
+
+type Store = ReturnType;
+
+function makeStore(model: "haiku" | "sonnet" = "haiku") {
+ return configureStore({
+ reducer: { agentModel: agentModelReducer },
+ preloadedState: { agentModel: { model } },
+ });
+}
+
+function renderModal(opts: {
+ store?: Store;
+ balance?: number | null;
+ onClose?: () => void;
+}) {
+ const store = opts.store ?? makeStore();
+ const balanceValue: CreditBalanceContextValue = {
+ balance: opts.balance ?? null,
+ loading: false,
+ error: null,
+ refresh: vi.fn(),
+ };
+ const wrapper = (ui: ReactNode) => (
+
+ {ui}
+
+ );
+ render(wrapper());
+ return store;
+}
+
+describe("ModelSelectModal", () => {
+ it("Haiku / Sonnet のカードが表示される", () => {
+ renderModal({});
+ expect(screen.getByRole("button", { name: /^Haiku/ })).toBeTruthy();
+ expect(screen.getByRole("button", { name: /^Sonnet/ })).toBeTruthy();
+ });
+
+ it("現在選択中のモデルカードに『選択中』が付く", () => {
+ renderModal({ store: makeStore("sonnet") });
+ const sonnetCard = screen.getByRole("button", { name: /^Sonnet/ });
+ expect(within(sonnetCard).getByText("選択中")).toBeTruthy();
+ });
+
+ it("カードを押すとグローバル設定が切り替わり、モーダルが閉じる", () => {
+ const onClose = vi.fn();
+ const store = renderModal({ onClose });
+
+ fireEvent.click(screen.getByRole("button", { name: /^Sonnet/ }));
+
+ expect(store.getState().agentModel.model).toBe("sonnet");
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("残高 0 で Sonnet カードに残高不足の警告を出す(選択自体は許可)", () => {
+ renderModal({ balance: 0 });
+ const sonnetCard = screen.getByRole("button", { name: /^Sonnet/ });
+ expect(within(sonnetCard).getByText(/残高が不足/)).toBeTruthy();
+ // 無料カードには警告を出さない
+ const haikuCard = screen.getByRole("button", { name: /^Haiku/ });
+ expect(within(haikuCard).queryByText(/残高が不足/)).toBeNull();
+ });
+
+ it("残高が十分なら Sonnet カードに警告を出さない", () => {
+ renderModal({ balance: 5000 });
+ const sonnetCard = screen.getByRole("button", { name: /^Sonnet/ });
+ expect(within(sonnetCard).queryByText(/残高が不足/)).toBeNull();
+ });
+});
diff --git a/frontend/src/components/agent/ModelSelectModal.tsx b/frontend/src/components/agent/ModelSelectModal.tsx
new file mode 100644
index 00000000..ae37b30e
--- /dev/null
+++ b/frontend/src/components/agent/ModelSelectModal.tsx
@@ -0,0 +1,84 @@
+import type { AgentModelAlias } from "../../api/types";
+import { AGENT_MODEL_OPTIONS } from "../../constants/agentModels";
+import { AGENT_MODEL_MESSAGES } from "../../constants/messages";
+import { useAppDispatch, useAppSelector } from "../../store";
+import { setAgentModel } from "../../store/agentModelSlice";
+import { useCreditBalanceContext } from "../billing/creditBalanceContext";
+import styles from "./ModelSelectModal.module.css";
+
+/**
+ * AI モデル選択モーダル(ADR-0012)。Claude のプラン選択のようなカード UI。
+ *
+ * UserMenu から開く。カード選択で即時にグローバル設定(redux)へ反映して閉じる。
+ * 有料(Sonnet)は残高不足のとき警告を出すが、選択自体は許可する
+ * (送信時に 402 で課金導線へ誘導する設計 / ADR-0012)。
+ */
+export function ModelSelectModal({ onClose }: { onClose: () => void }) {
+ const dispatch = useAppDispatch();
+ const currentModel = useAppSelector((state) => state.agentModel.model);
+ const { balance } = useCreditBalanceContext();
+
+ const select = (alias: AgentModelAlias) => {
+ dispatch(setAgentModel(alias));
+ onClose();
+ };
+
+ return (
+
+
e.stopPropagation()}
+ >
+
+ {AGENT_MODEL_MESSAGES.MODAL_TITLE}
+
+
+
{AGENT_MODEL_MESSAGES.MODAL_DESCRIPTION}
+
+ {AGENT_MODEL_OPTIONS.map((option) => {
+ const isCurrent = option.alias === currentModel;
+ const insufficient = option.isPaid && (balance ?? 0) <= 0;
+ return (
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/frontend/src/components/forms/AgentChatWidget.tsx b/frontend/src/components/forms/AgentChatWidget.tsx
index 73efc8fe..0282cbef 100644
--- a/frontend/src/components/forms/AgentChatWidget.tsx
+++ b/frontend/src/components/forms/AgentChatWidget.tsx
@@ -9,9 +9,10 @@
import { useCallback, useMemo, useState } from "react";
-import type { AgentModelAlias, ExperienceTarget, ProjectTarget } from "../../api/types";
+import type { ExperienceTarget, ProjectTarget } from "../../api/types";
import { AGENT_MESSAGES } from "../../constants/messages";
import { useAgentChat, type AgentChatEntry } from "../../hooks/career/useAgentChat";
+import { useAppSelector } from "../../store";
import { useCreditBalanceContext } from "../billing/creditBalanceContext";
import type { CareerFormState } from "../../payloadBuilders";
import { useMessageToast, useToast } from "../ui/toast";
@@ -97,15 +98,16 @@ function buildExperienceOptions(form: CareerFormState): ExperienceOption[] {
export function AgentChatWidget({ form, onApply, isAuthenticated, requestLogin }: Props) {
const [open, setOpen] = useState(false);
const [scope, setScope] = useState("career_summary");
- const [model, setModel] = useState("haiku");
+ // 使用モデルはグローバル設定(サイドバー表示 / UserMenu で切り替え)。ここでは読むだけ
+ const model = useAppSelector((state) => state.agentModel.model);
const [projectTargetIndex, setProjectTargetIndex] = useState(0);
const [experienceTargetIndex, setExperienceTargetIndex] = useState(0);
const [prompt, setPrompt] = useState("");
/** ドラッグでリサイズされた寸法。null の間は CSS のデフォルトサイズに従う */
const [panelSize, setPanelSize] = useState<{ width: number; height: number } | null>(null);
const { entries, sending, error, send, markApplied, clearError } = useAgentChat();
- // 残高はサイドバーに常時表示する(ADR-0012)。ウィジェットは有料モデル消費後に
- // サイドバーの数値を最新化するため refresh のみ利用する
+ // モデル・残高はともにサイドバーで表示・切り替えする(ADR-0012)。ウィジェットは
+ // 有料モデル消費後にサイドバーの残高を最新化するため refresh のみ利用する
const isPaidModel = model === "sonnet";
const { refresh: refreshBalance } = useCreditBalanceContext();
const { showSuccess } = useToast();
@@ -244,18 +246,6 @@ export function AgentChatWidget({ form, onApply, isAuthenticated, requestLogin }
-
{scope === "experience" &&
(experienceOptions.length === 0 ? (
{AGENT_MESSAGES.TARGET_EXPERIENCE_EMPTY}
diff --git a/frontend/src/components/forms/CareerResumeForm.test.tsx b/frontend/src/components/forms/CareerResumeForm.test.tsx
index 5b0494ff..4d74caa8 100644
--- a/frontend/src/components/forms/CareerResumeForm.test.tsx
+++ b/frontend/src/components/forms/CareerResumeForm.test.tsx
@@ -7,6 +7,7 @@ import { CareerResumeForm } from "./CareerResumeForm";
import { LoginPromptContext } from "../auth/loginPromptContext";
import { CreditBalanceProvider } from "../billing/CreditBalanceProvider";
import { ToastProvider } from "../ui/toast";
+import agentModelReducer from "../../store/agentModelSlice";
import formCacheReducer from "../../store/formCacheSlice";
import { UI_MESSAGES, VALIDATION_MESSAGES } from "../../constants/messages";
@@ -23,7 +24,9 @@ vi.mock("../../api/master-data", () => ({
/** formCache だけを持つ最小ストアを作る。 */
function makeStore() {
- return configureStore({ reducer: { formCache: formCacheReducer } });
+ return configureStore({
+ reducer: { formCache: formCacheReducer, agentModel: agentModelReducer },
+ });
}
/** 未ログイン状態の CareerResumeForm を描画し、requestLogin スパイを返す。 */
diff --git a/frontend/src/constants/agentModels.ts b/frontend/src/constants/agentModels.ts
new file mode 100644
index 00000000..0d7cf9d7
--- /dev/null
+++ b/frontend/src/constants/agentModels.ts
@@ -0,0 +1,42 @@
+/**
+ * 選択可能な AI モデル(ADR-0012)の表示メタデータ。
+ *
+ * エイリアスの正本は backend `services/agent/model_catalog.py`(実モデル ID・課金レート)。
+ * ここはフロントの表示用(製品名・説明・コスト目安)の SSoT で、プランカード UI と
+ * サイドバーの使用モデル表示が参照する。
+ */
+
+import type { AgentModelAlias } from "../api/types";
+import { AGENT_MODEL_MESSAGES } from "./messages";
+
+export type AgentModelOption = {
+ alias: AgentModelAlias;
+ /** 製品名(翻訳しない) */
+ name: string;
+ /** クレジットを消費するか(有料バッジ・残高チェックの表示に使う) */
+ isPaid: boolean;
+ tagline: string;
+ costHint: string;
+};
+
+export const AGENT_MODEL_OPTIONS: readonly AgentModelOption[] = [
+ {
+ alias: "haiku",
+ name: "Haiku",
+ isPaid: false,
+ tagline: AGENT_MODEL_MESSAGES.HAIKU_TAGLINE,
+ costHint: AGENT_MODEL_MESSAGES.HAIKU_COST,
+ },
+ {
+ alias: "sonnet",
+ name: "Sonnet",
+ isPaid: true,
+ tagline: AGENT_MODEL_MESSAGES.SONNET_TAGLINE,
+ costHint: AGENT_MODEL_MESSAGES.SONNET_COST,
+ },
+];
+
+/** エイリアスから表示メタデータを引く。未知の値は先頭(haiku)にフォールバック。 */
+export function getModelOption(alias: AgentModelAlias): AgentModelOption {
+ return AGENT_MODEL_OPTIONS.find((option) => option.alias === alias) ?? AGENT_MODEL_OPTIONS[0];
+}
diff --git a/frontend/src/constants/messages.ts b/frontend/src/constants/messages.ts
index cd02e7f5..4ef2a71e 100644
--- a/frontend/src/constants/messages.ts
+++ b/frontend/src/constants/messages.ts
@@ -391,9 +391,24 @@ export const AGENT_MESSAGES = {
APPLIED_TOAST: "AI の提案をフォームに反映しました。内容を確認して保存してください。",
EMPTY_STATE: "編集対象を選んで、改善したい内容を AI に依頼してください。反映後も保存するまで DB は変更されません。",
LOGIN_REQUIRED: "devforge Agent を使うにはログインが必要です。",
- MODEL_LABEL: "モデル",
- MODEL_HAIKU: "Haiku(無料)",
- MODEL_SONNET: "Sonnet(有料・高精度)",
+} as const;
+
+/** AI モデル選択(ADR-0012)の UI 文言。製品名(Haiku / Sonnet)は constants/agentModels.ts。 */
+export const AGENT_MODEL_MESSAGES = {
+ SIDEBAR_LABEL: "使用モデル",
+ MENU_ITEM: "AI モデルを切り替え",
+ MODAL_TITLE: "AI モデルを選択",
+ MODAL_DESCRIPTION:
+ "依頼の精度とコストに合わせて選べます。設定はこの端末に保存され、すべての AI 機能で使われます。",
+ CLOSE_LABEL: "閉じる",
+ CURRENT_BADGE: "選択中",
+ FREE_BADGE: "無料",
+ PAID_BADGE: "有料",
+ INSUFFICIENT_HINT: "残高が不足しています。チャージするか Haiku をご利用ください。",
+ HAIKU_TAGLINE: "高速・標準精度。無料で使い放題。",
+ HAIKU_COST: "消費クレジット: なし",
+ SONNET_TAGLINE: "高精度。重要な仕上げや複雑な依頼に。",
+ SONNET_COST: "消費クレジット: 利用したトークン量に応じて",
} as const;
/** クレジット課金(ADR-0012)の UI 文言。 */
diff --git a/frontend/src/store/agentModelSlice.test.ts b/frontend/src/store/agentModelSlice.test.ts
new file mode 100644
index 00000000..959c48d4
--- /dev/null
+++ b/frontend/src/store/agentModelSlice.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+
+import reducer, { setAgentModel } from "./agentModelSlice";
+
+describe("agentModelSlice", () => {
+ it("初期状態は無料の haiku(誤課金を防ぐ安全側の既定)", () => {
+ const state = reducer(undefined, { type: "@@INIT" });
+ expect(state.model).toBe("haiku");
+ });
+
+ it("setAgentModel で sonnet に切り替わる", () => {
+ const state = reducer({ model: "haiku" }, setAgentModel("sonnet"));
+ expect(state.model).toBe("sonnet");
+ });
+
+ it("setAgentModel で haiku に戻せる", () => {
+ const state = reducer({ model: "sonnet" }, setAgentModel("haiku"));
+ expect(state.model).toBe("haiku");
+ });
+});
diff --git a/frontend/src/store/agentModelSlice.ts b/frontend/src/store/agentModelSlice.ts
new file mode 100644
index 00000000..3c9274d6
--- /dev/null
+++ b/frontend/src/store/agentModelSlice.ts
@@ -0,0 +1,30 @@
+import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
+
+import type { AgentModelAlias } from "../api/types";
+
+/**
+ * 使用する AI モデル(ADR-0012)のグローバル設定。
+ *
+ * モデル選択は「Agent ウィジェットのローカル状態」ではなく「アカウント横断の UI 設定」。
+ * サイドバーに常時表示し、UserMenu のモデル選択モーダルから切り替える。
+ * redux-persist で端末に永続化する(ドメインの事実ではないため DB には持たない)。
+ */
+interface AgentModelState {
+ model: AgentModelAlias;
+}
+
+// 既定は無料の haiku(誤課金を防ぐ安全側の初期値)
+const initialState: AgentModelState = { model: "haiku" };
+
+const agentModelSlice = createSlice({
+ name: "agentModel",
+ initialState,
+ reducers: {
+ setAgentModel(state, action: PayloadAction) {
+ state.model = action.payload;
+ },
+ },
+});
+
+export const { setAgentModel } = agentModelSlice.actions;
+export default agentModelSlice.reducer;
diff --git a/frontend/src/store/index.ts b/frontend/src/store/index.ts
index c4e07871..a35bc82e 100644
--- a/frontend/src/store/index.ts
+++ b/frontend/src/store/index.ts
@@ -1,11 +1,14 @@
import { combineReducers, configureStore } from "@reduxjs/toolkit";
import { persistReducer, persistStore } from "redux-persist";
import { useDispatch, useSelector, type TypedUseSelectorHook } from "react-redux";
+import agentModelReducer from "./agentModelSlice";
import formCacheReducer from "./formCacheSlice";
import { persistConfig } from "./persistConfig";
const rootReducer = combineReducers({
formCache: formCacheReducer,
+ // 使用モデルのグローバル設定(永続化する。PII を含まない / ADR-0012)
+ agentModel: agentModelReducer,
});
/** persistConfig の blacklist に基づき機密スライスを除外した永続化リデューサー */
From 627e42a8334c8ff2210c853e4ebed4ae03c7842e Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 13 Jun 2026 08:08:44 +0000
Subject: [PATCH 04/14] =?UTF-8?q?=E3=83=A2=E3=83=87=E3=83=AB=E5=88=A5?=
=?UTF-8?q?=E3=81=AE=E4=BD=BF=E7=94=A8=E9=87=8F=E3=82=B5=E3=83=9E=E3=83=AA?=
=?UTF-8?q?=E3=82=92=E3=83=A2=E3=83=87=E3=83=AB=E9=81=B8=E6=8A=9E=E3=83=A2?=
=?UTF-8?q?=E3=83=BC=E3=83=80=E3=83=AB=E3=81=AB=E8=A1=A8=E7=A4=BA=EF=BC=88?=
=?UTF-8?q?ADR-0012=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
モデルごとの利用回数・消費クレジットと、残高からの残回数目安をプランカードに
表示する。購入(チャージ)導線はモデル切り替え UI に混ぜず分離する方針に合わせ、
残高不足ヒントから「チャージ」文言を外した。
- backend: GET /api/billing/usage-summary。agent_usage_logs をモデル別に集計
(チャット回数・入出力トークン・消費クレジット)。利用なしモデルは行に出さず
表示側が補完する
- frontend: useAgentUsageSummary フック + ModelSelectModal で各カードに
「これまで N 回・約X クレジット消費・残高であと約N回」を表示
- 残回数目安は平均消費(累計消費 / 回数)と残高から算出
- ModelSelectModal の残高不足ヒントを警告のみに変更(購入導線は Phase 2 に分離)
- backend/ frontend テスト追加、codegen 再生成、E2E の usage-summary モック追加
https://claude.ai/code/session_01VHYFnKVfBX64pNdhb5kxVt
---
backend/app/repositories/billing.py | 21 ++++++-
backend/app/routers/billing.py | 24 ++++++++
backend/app/schemas/billing.py | 12 ++++
backend/tests/test_billing.py | 52 ++++++++++++++++
frontend/e2e/helpers/auth.ts | 9 +++
frontend/src/api/billing.ts | 11 +++-
frontend/src/api/generated.ts | 59 +++++++++++++++++++
frontend/src/api/paths.ts | 1 +
frontend/src/api/types.ts | 3 +
.../agent/ModelSelectModal.module.css | 7 +++
.../agent/ModelSelectModal.test.tsx | 42 ++++++++++++-
.../src/components/agent/ModelSelectModal.tsx | 28 ++++++++-
frontend/src/constants/messages.ts | 19 +++++-
.../src/hooks/useAgentUsageSummary.test.ts | 50 ++++++++++++++++
frontend/src/hooks/useAgentUsageSummary.ts | 52 ++++++++++++++++
15 files changed, 384 insertions(+), 6 deletions(-)
create mode 100644 frontend/src/hooks/useAgentUsageSummary.test.ts
create mode 100644 frontend/src/hooks/useAgentUsageSummary.ts
diff --git a/backend/app/repositories/billing.py b/backend/app/repositories/billing.py
index 9e57ddfc..7732d57f 100644
--- a/backend/app/repositories/billing.py
+++ b/backend/app/repositories/billing.py
@@ -6,7 +6,8 @@
分散ロックは持たない。
"""
-from sqlalchemy import select, update
+from sqlalchemy import func, select, update
+from sqlalchemy.engine import Row
from sqlalchemy.orm import Session
from ..models.billing import AgentUsageLog, CreditTransaction
@@ -89,3 +90,21 @@ def list_transactions(self, limit: int = 50) -> list[CreditTransaction]:
.limit(limit)
)
return list(self.db.scalars(stmt).all())
+
+ def usage_summary(self) -> list[Row]:
+ """使用ログをモデル別に集計し、(model_alias, chat_count, input/output tokens,
+ credit_cost) の行を返す。利用実績のないモデルは行に現れない
+ (表示側がモデル一覧の正本を持ち、0 件は表示側で補う / ADR-0012)。
+ """
+ stmt = (
+ select(
+ AgentUsageLog.model_alias.label("model_alias"),
+ func.count().label("chat_count"),
+ func.coalesce(func.sum(AgentUsageLog.input_tokens), 0).label("input_tokens"),
+ func.coalesce(func.sum(AgentUsageLog.output_tokens), 0).label("output_tokens"),
+ func.coalesce(func.sum(AgentUsageLog.credit_cost), 0).label("credit_cost"),
+ )
+ .where(AgentUsageLog.user_id == self.user_id)
+ .group_by(AgentUsageLog.model_alias)
+ )
+ return list(self.db.execute(stmt).all())
diff --git a/backend/app/routers/billing.py b/backend/app/routers/billing.py
index 527e3095..ea92b923 100644
--- a/backend/app/routers/billing.py
+++ b/backend/app/routers/billing.py
@@ -18,6 +18,7 @@
from ..repositories import BillingRepository, UserRepository
from ..schemas.billing import (
AdminCreditGrantRequest,
+ AgentUsageSummaryEntry,
CreditBalanceResponse,
CreditTransactionResponse,
)
@@ -47,6 +48,29 @@ def list_credit_transactions(
return [CreditTransactionResponse.model_validate(t) for t in transactions]
+@router.get("/usage-summary", response_model=list[AgentUsageSummaryEntry])
+def get_usage_summary(
+ user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> list[AgentUsageSummaryEntry]:
+ """モデル別の使用量サマリ(チャット回数・トークン・消費クレジット)を返す。
+
+ モデル選択モーダルで「あなたの利用実績」を表示するために使う。残りチャット回数の
+ 目安は残高と組み合わせてフロントで算出する。
+ """
+ rows = BillingRepository(db, user.id).usage_summary()
+ return [
+ AgentUsageSummaryEntry(
+ model=row.model_alias,
+ chat_count=row.chat_count,
+ input_tokens=row.input_tokens,
+ output_tokens=row.output_tokens,
+ credit_cost=row.credit_cost,
+ )
+ for row in rows
+ ]
+
+
@router.post("/admin/grant", response_model=CreditBalanceResponse)
def admin_grant_credits(
body: AdminCreditGrantRequest,
diff --git a/backend/app/schemas/billing.py b/backend/app/schemas/billing.py
index 220d19c8..d05aab95 100644
--- a/backend/app/schemas/billing.py
+++ b/backend/app/schemas/billing.py
@@ -26,6 +26,18 @@ class CreditTransactionResponse(BaseModel):
created_at: datetime
+class AgentUsageSummaryEntry(BaseModel):
+ """モデル別の使用量サマリ 1 件(モデル選択モーダルの利用実績表示用 / ADR-0012)。"""
+
+ # model_catalog.py のエイリアス(haiku / sonnet)
+ model: str
+ chat_count: int
+ input_tokens: int
+ output_tokens: int
+ # 消費クレジット合計(無料モデルは 0)
+ credit_cost: int
+
+
class AdminCreditGrantRequest(BaseModel):
"""管理者によるクレジット付与(Phase 1 の残高調整・テスト用)。"""
diff --git a/backend/tests/test_billing.py b/backend/tests/test_billing.py
index 24d02a99..c54508d7 100644
--- a/backend/tests/test_billing.py
+++ b/backend/tests/test_billing.py
@@ -294,6 +294,58 @@ def test_list_transactions_returns_history(client: TestClient, monkeypatch) -> N
assert consumption["balance_after"] == 1_000 - 270
+# --- 統合テスト(使用量サマリ) ---
+
+
+def test_usage_summary_aggregates_per_model(client: TestClient, monkeypatch) -> None:
+ """GET /api/billing/usage-summary はモデル別にチャット回数・トークン・消費を集計する。"""
+ headers = auth_header(client, "billing-usage")
+ user_id = _get_user_id(client, "billing-usage")
+ credit_service.grant_credits(
+ client._db_session,
+ user_id,
+ 10_000,
+ transaction_type=credit_service.TRANSACTION_TYPE_ADMIN_GRANT,
+ )
+ # haiku 2 回(無料)・sonnet 1 回(有料)
+ haiku = _FakeLLM(
+ response=_llm_json("self_pr", "提案"), input_tokens=500, output_tokens=200
+ )
+ monkeypatch.setattr(chat_service, "get_llm_client", lambda: haiku)
+ client.post("/api/agent/chat", json=_chat_payload(), headers=headers)
+ client.post("/api/agent/chat", json=_chat_payload(), headers=headers)
+ sonnet = _FakeLLM(
+ response=_llm_json("self_pr", "提案"), input_tokens=1000, output_tokens=1000
+ )
+ monkeypatch.setattr(chat_service, "get_llm_client", lambda: sonnet)
+ client.post("/api/agent/chat", json=_chat_payload("sonnet"), headers=headers)
+
+ resp = client.get("/api/billing/usage-summary")
+
+ assert resp.status_code == 200
+ by_model = {entry["model"]: entry for entry in resp.json()}
+ assert by_model["haiku"]["chat_count"] == 2
+ assert by_model["haiku"]["input_tokens"] == 1000
+ assert by_model["haiku"]["output_tokens"] == 400
+ assert by_model["haiku"]["credit_cost"] == 0
+ assert by_model["sonnet"]["chat_count"] == 1
+ assert by_model["sonnet"]["credit_cost"] == 270
+
+
+def test_usage_summary_empty_when_no_usage(client: TestClient) -> None:
+ """利用実績が無ければ空配列(モデル一覧の補完は表示側が担う)。"""
+ auth_header(client, "billing-no-usage")
+ resp = client.get("/api/billing/usage-summary")
+ assert resp.status_code == 200
+ assert resp.json() == []
+
+
+def test_usage_summary_requires_auth(client: TestClient) -> None:
+ """未ログインの使用量サマリ取得は 401。"""
+ resp = client.get("/api/billing/usage-summary")
+ assert resp.status_code == 401
+
+
# --- 統合テスト(管理者付与) ---
diff --git a/frontend/e2e/helpers/auth.ts b/frontend/e2e/helpers/auth.ts
index 551968d3..69eddd6a 100644
--- a/frontend/e2e/helpers/auth.ts
+++ b/frontend/e2e/helpers/auth.ts
@@ -54,6 +54,15 @@ export async function setupAuth(page: Page) {
}),
);
+ // モデル別使用量サマリをモック(モデル選択モーダルが開くと取得する / ADR-0012。デフォルト: 空)
+ await page.route("**/api/billing/usage-summary", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify([]),
+ }),
+ );
+
// 認証 API をモック(最高優先)
await page.route("**/auth/me", (route) =>
route.fulfill({
diff --git a/frontend/src/api/billing.ts b/frontend/src/api/billing.ts
index 14102bbe..c19b9801 100644
--- a/frontend/src/api/billing.ts
+++ b/frontend/src/api/billing.ts
@@ -1,6 +1,10 @@
import { request } from "./client";
import { PATHS } from "./paths";
-import type { CreditBalanceResponse, CreditTransactionResponse } from "./types";
+import type {
+ AgentUsageSummaryEntry,
+ CreditBalanceResponse,
+ CreditTransactionResponse,
+} from "./types";
/**
* クレジット残高を取得する(ADR-0012)。
@@ -14,3 +18,8 @@ export function getCreditBalance(): Promise {
export function getCreditTransactions(): Promise {
return request(PATHS.billing.transactions, { method: "GET" });
}
+
+/** モデル別の使用量サマリ(チャット回数・トークン・消費クレジット)を取得する。 */
+export function getAgentUsageSummary(): Promise {
+ return request(PATHS.billing.usageSummary, { method: "GET" });
+}
diff --git a/frontend/src/api/generated.ts b/frontend/src/api/generated.ts
index d0453011..af0885e6 100644
--- a/frontend/src/api/generated.ts
+++ b/frontend/src/api/generated.ts
@@ -94,6 +94,29 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/billing/usage-summary": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get Usage Summary
+ * @description モデル別の使用量サマリ(チャット回数・トークン・消費クレジット)を返す。
+ *
+ * モデル選択モーダルで「あなたの利用実績」を表示するために使う。残りチャット回数の
+ * 目安は残高と組み合わせてフロントで算出する。
+ */
+ get: operations["get_usage_summary_api_billing_usage_summary_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/blog/accounts": {
parameters: {
query?: never;
@@ -972,6 +995,22 @@ export interface components {
*/
name: string;
};
+ /**
+ * AgentUsageSummaryEntry
+ * @description モデル別の使用量サマリ 1 件(モデル選択モーダルの利用実績表示用 / ADR-0012)。
+ */
+ AgentUsageSummaryEntry: {
+ /** Chat Count */
+ chat_count: number;
+ /** Credit Cost */
+ credit_cost: number;
+ /** Input Tokens */
+ input_tokens: number;
+ /** Model */
+ model: string;
+ /** Output Tokens */
+ output_tokens: number;
+ };
/**
* BlogAccountCreate
* @description ブログ連携アカウントの作成リクエスト。
@@ -1988,6 +2027,26 @@ export interface operations {
};
};
};
+ get_usage_summary_api_billing_usage_summary_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["AgentUsageSummaryEntry"][];
+ };
+ };
+ };
+ };
list_accounts_api_blog_accounts_get: {
parameters: {
query?: never;
diff --git a/frontend/src/api/paths.ts b/frontend/src/api/paths.ts
index 5e3a1d5e..c9cfade8 100644
--- a/frontend/src/api/paths.ts
+++ b/frontend/src/api/paths.ts
@@ -33,6 +33,7 @@ export const PATHS = {
billing: {
balance: "/api/billing/balance",
transactions: "/api/billing/transactions",
+ usageSummary: "/api/billing/usage-summary",
},
resumes: {
base: "/api/resumes",
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
index 96e439e9..32fb04f0 100644
--- a/frontend/src/api/types.ts
+++ b/frontend/src/api/types.ts
@@ -152,3 +152,6 @@ export type CreditBalanceResponse = Schemas["CreditBalanceResponse"];
/** クレジット台帳エントリ。backend `schemas/billing.py:CreditTransactionResponse`。 */
export type CreditTransactionResponse = Schemas["CreditTransactionResponse"];
+
+/** モデル別の使用量サマリ。backend `schemas/billing.py:AgentUsageSummaryEntry`。 */
+export type AgentUsageSummaryEntry = Schemas["AgentUsageSummaryEntry"];
diff --git a/frontend/src/components/agent/ModelSelectModal.module.css b/frontend/src/components/agent/ModelSelectModal.module.css
index 1beee201..28543a05 100644
--- a/frontend/src/components/agent/ModelSelectModal.module.css
+++ b/frontend/src/components/agent/ModelSelectModal.module.css
@@ -125,6 +125,13 @@
color: var(--text-secondary);
}
+.cardUsage {
+ margin: 0.35rem 0 0;
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ opacity: 0.85;
+}
+
.insufficient {
margin: 0.5rem 0 0;
font-size: 0.78rem;
diff --git a/frontend/src/components/agent/ModelSelectModal.test.tsx b/frontend/src/components/agent/ModelSelectModal.test.tsx
index 216e7641..7a121877 100644
--- a/frontend/src/components/agent/ModelSelectModal.test.tsx
+++ b/frontend/src/components/agent/ModelSelectModal.test.tsx
@@ -1,8 +1,8 @@
import { configureStore } from "@reduxjs/toolkit";
-import { fireEvent, render, screen, within } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import type { ReactNode } from "react";
import { Provider } from "react-redux";
-import { describe, expect, it, vi } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
import agentModelReducer from "../../store/agentModelSlice";
import {
@@ -11,6 +11,12 @@ import {
} from "../billing/creditBalanceContext";
import { ModelSelectModal } from "./ModelSelectModal";
+const getAgentUsageSummaryMock = vi.fn();
+
+vi.mock("../../api/billing", () => ({
+ getAgentUsageSummary: (...args: unknown[]) => getAgentUsageSummaryMock(...args),
+}));
+
type Store = ReturnType;
function makeStore(model: "haiku" | "sonnet" = "haiku") {
@@ -24,7 +30,9 @@ function renderModal(opts: {
store?: Store;
balance?: number | null;
onClose?: () => void;
+ usage?: { model: string; chat_count: number; input_tokens: number; output_tokens: number; credit_cost: number }[];
}) {
+ getAgentUsageSummaryMock.mockResolvedValue(opts.usage ?? []);
const store = opts.store ?? makeStore();
const balanceValue: CreditBalanceContextValue = {
balance: opts.balance ?? null,
@@ -41,6 +49,11 @@ function renderModal(opts: {
return store;
}
+beforeEach(() => {
+ getAgentUsageSummaryMock.mockReset();
+ getAgentUsageSummaryMock.mockResolvedValue([]);
+});
+
describe("ModelSelectModal", () => {
it("Haiku / Sonnet のカードが表示される", () => {
renderModal({});
@@ -78,4 +91,29 @@ describe("ModelSelectModal", () => {
const sonnetCard = screen.getByRole("button", { name: /^Sonnet/ });
expect(within(sonnetCard).queryByText(/残高が不足/)).toBeNull();
});
+
+ it("利用実績があるとカードに利用回数・消費クレジットと残回数目安を表示する", async () => {
+ renderModal({
+ balance: 2700,
+ usage: [
+ { model: "sonnet", chat_count: 4, input_tokens: 4000, output_tokens: 4000, credit_cost: 1080 },
+ ],
+ });
+
+ const sonnetCard = screen.getByRole("button", { name: /^Sonnet/ });
+ // これまで 4 回・約 1,080 クレジット消費
+ await waitFor(() => {
+ expect(within(sonnetCard).getByText(/これまで 4 回/)).toBeTruthy();
+ });
+ // 平均 270/回・残高 2700 → あと約 10 回
+ expect(within(sonnetCard).getByText(/あと約 10 回/)).toBeTruthy();
+ });
+
+ it("利用実績が無いカードは『まだ利用していません』を表示する", async () => {
+ renderModal({ balance: 1000 });
+ const haikuCard = screen.getByRole("button", { name: /^Haiku/ });
+ await waitFor(() => {
+ expect(within(haikuCard).getByText("まだ利用していません")).toBeTruthy();
+ });
+ });
});
diff --git a/frontend/src/components/agent/ModelSelectModal.tsx b/frontend/src/components/agent/ModelSelectModal.tsx
index ae37b30e..dfac9549 100644
--- a/frontend/src/components/agent/ModelSelectModal.tsx
+++ b/frontend/src/components/agent/ModelSelectModal.tsx
@@ -1,6 +1,11 @@
import type { AgentModelAlias } from "../../api/types";
import { AGENT_MODEL_OPTIONS } from "../../constants/agentModels";
-import { AGENT_MODEL_MESSAGES } from "../../constants/messages";
+import {
+ AGENT_MODEL_MESSAGES,
+ modelUsageLabel,
+ remainingChatsLabel,
+} from "../../constants/messages";
+import { useAgentUsageSummary } from "../../hooks/useAgentUsageSummary";
import { useAppDispatch, useAppSelector } from "../../store";
import { setAgentModel } from "../../store/agentModelSlice";
import { useCreditBalanceContext } from "../billing/creditBalanceContext";
@@ -17,12 +22,21 @@ export function ModelSelectModal({ onClose }: { onClose: () => void }) {
const dispatch = useAppDispatch();
const currentModel = useAppSelector((state) => state.agentModel.model);
const { balance } = useCreditBalanceContext();
+ // モーダルが開いている間だけ利用実績を取得する
+ const { getUsage } = useAgentUsageSummary(true);
const select = (alias: AgentModelAlias) => {
dispatch(setAgentModel(alias));
onClose();
};
+ /** 有料モデルの残回数目安(平均消費 = 累計消費 / 回数 と残高から算出)。 */
+ const estimateRemaining = (creditCost: number, chatCount: number): number | null => {
+ if (balance === null || chatCount <= 0 || creditCost <= 0) return null;
+ const avgPerChat = creditCost / chatCount;
+ return Math.floor(balance / avgPerChat);
+ };
+
return (
void }) {
{AGENT_MODEL_OPTIONS.map((option) => {
const isCurrent = option.alias === currentModel;
const insufficient = option.isPaid && (balance ?? 0) <= 0;
+ const usage = getUsage(option.alias);
+ const chatCount = usage?.chat_count ?? 0;
+ const creditCost = usage?.credit_cost ?? 0;
+ const remaining = option.isPaid
+ ? estimateRemaining(creditCost, chatCount)
+ : null;
return (
{option.tagline}
{option.costHint}
+
+ {chatCount > 0
+ ? modelUsageLabel(chatCount, creditCost)
+ : AGENT_MODEL_MESSAGES.USAGE_NONE}
+ {remaining !== null && ・{remainingChatsLabel(remaining)}}
+
{insufficient && (
{AGENT_MODEL_MESSAGES.INSUFFICIENT_HINT}
)}
diff --git a/frontend/src/constants/messages.ts b/frontend/src/constants/messages.ts
index 4ef2a71e..44db3ba9 100644
--- a/frontend/src/constants/messages.ts
+++ b/frontend/src/constants/messages.ts
@@ -68,6 +68,7 @@ export const FALLBACK_MESSAGES = {
GITHUB_LINK: "連携に失敗しました",
AGENT_CHAT: "AI への送信に失敗しました",
CREDIT_BALANCE: "クレジット残高の取得に失敗しました",
+ USAGE_SUMMARY: "利用状況の取得に失敗しました",
} as const;
/**
@@ -404,13 +405,29 @@ export const AGENT_MODEL_MESSAGES = {
CURRENT_BADGE: "選択中",
FREE_BADGE: "無料",
PAID_BADGE: "有料",
- INSUFFICIENT_HINT: "残高が不足しています。チャージするか Haiku をご利用ください。",
+ // 課金(チャージ)導線はここに混ぜず、購入は専用サーフェスに分離する(ADR-0012)
+ INSUFFICIENT_HINT: "残高が不足しています。Haiku をご利用ください。",
+ USAGE_NONE: "まだ利用していません",
HAIKU_TAGLINE: "高速・標準精度。無料で使い放題。",
HAIKU_COST: "消費クレジット: なし",
SONNET_TAGLINE: "高精度。重要な仕上げや複雑な依頼に。",
SONNET_COST: "消費クレジット: 利用したトークン量に応じて",
} as const;
+/** モデルカードの利用実績ラベル(これまでの回数・消費クレジット)。 */
+export function modelUsageLabel(chatCount: number, creditCost: number): string {
+ const count = chatCount.toLocaleString("ja-JP");
+ if (creditCost > 0) {
+ return `これまで ${count} 回・約 ${creditCost.toLocaleString("ja-JP")} クレジット消費`;
+ }
+ return `これまで ${count} 回利用`;
+}
+
+/** 残高で実行できる有料チャットの残回数目安ラベル。 */
+export function remainingChatsLabel(remaining: number): string {
+ return `残高であと約 ${remaining.toLocaleString("ja-JP")} 回`;
+}
+
/** クレジット課金(ADR-0012)の UI 文言。 */
export const BILLING_MESSAGES = {
BALANCE_LOADING: "残高を確認中...",
diff --git a/frontend/src/hooks/useAgentUsageSummary.test.ts b/frontend/src/hooks/useAgentUsageSummary.test.ts
new file mode 100644
index 00000000..e02e23d9
--- /dev/null
+++ b/frontend/src/hooks/useAgentUsageSummary.test.ts
@@ -0,0 +1,50 @@
+import { renderHook, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { FALLBACK_MESSAGES } from "../constants/messages";
+import { useAgentUsageSummary } from "./useAgentUsageSummary";
+
+const getAgentUsageSummaryMock = vi.fn();
+
+vi.mock("../api/billing", () => ({
+ getAgentUsageSummary: (...args: unknown[]) => getAgentUsageSummaryMock(...args),
+}));
+
+beforeEach(() => {
+ getAgentUsageSummaryMock.mockReset();
+});
+
+describe("useAgentUsageSummary", () => {
+ it("enabled=true でモデル別サマリを取得し getUsage で引ける", async () => {
+ getAgentUsageSummaryMock.mockResolvedValue([
+ { model: "sonnet", chat_count: 3, input_tokens: 100, output_tokens: 200, credit_cost: 810 },
+ ]);
+
+ const { result } = renderHook(() => useAgentUsageSummary(true));
+
+ await waitFor(() => {
+ expect(result.current.getUsage("sonnet")?.chat_count).toBe(3);
+ });
+ // 未利用モデルは undefined
+ expect(result.current.getUsage("haiku")).toBeUndefined();
+ });
+
+ it("enabled=false の間は取得しない", () => {
+ getAgentUsageSummaryMock.mockResolvedValue([]);
+
+ const { result } = renderHook(() => useAgentUsageSummary(false));
+
+ expect(getAgentUsageSummaryMock).not.toHaveBeenCalled();
+ expect(result.current.getUsage("sonnet")).toBeUndefined();
+ });
+
+ it("取得失敗時は error にメッセージが入る", async () => {
+ getAgentUsageSummaryMock.mockRejectedValue("network down");
+
+ const { result } = renderHook(() => useAgentUsageSummary(true));
+
+ await waitFor(() => {
+ expect(result.current.error).toBe(FALLBACK_MESSAGES.USAGE_SUMMARY);
+ });
+ });
+});
diff --git a/frontend/src/hooks/useAgentUsageSummary.ts b/frontend/src/hooks/useAgentUsageSummary.ts
new file mode 100644
index 00000000..ff735b60
--- /dev/null
+++ b/frontend/src/hooks/useAgentUsageSummary.ts
@@ -0,0 +1,52 @@
+/**
+ * モデル別の使用量サマリ(ADR-0012)を取得するフック。
+ *
+ * モデル選択モーダルが開いたときに取得し、各モデルカードへ
+ * 「これまでの利用回数・消費クレジット」と残回数目安を表示する。
+ */
+
+import { useCallback, useEffect, useState } from "react";
+
+import { getAgentUsageSummary } from "../api/billing";
+import type { AgentModelAlias, AgentUsageSummaryEntry } from "../api/types";
+import { FALLBACK_MESSAGES } from "../constants/messages";
+
+/** エイリアス → サマリの引きやすい Map に変換した状態。 */
+export type UsageByModel = Record
;
+
+export function useAgentUsageSummary(enabled: boolean) {
+ const [usageByModel, setUsageByModel] = useState({});
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const refresh = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const entries = await getAgentUsageSummary();
+ const map: UsageByModel = {};
+ for (const entry of entries) {
+ map[entry.model] = entry;
+ }
+ setUsageByModel(map);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : FALLBACK_MESSAGES.USAGE_SUMMARY);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (enabled) {
+ void refresh();
+ }
+ }, [enabled, refresh]);
+
+ /** 指定モデルのサマリを返す(未利用なら undefined)。 */
+ const getUsage = useCallback(
+ (alias: AgentModelAlias): AgentUsageSummaryEntry | undefined => usageByModel[alias],
+ [usageByModel],
+ );
+
+ return { usageByModel, getUsage, loading, error, refresh };
+}
From dc0fb19371825248084ec2bc4024e26bcffb674f Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 13 Jun 2026 12:11:10 +0000
Subject: [PATCH 05/14] =?UTF-8?q?=E3=83=A2=E3=83=87=E3=83=AB=E7=9B=AE?=
=?UTF-8?q?=E5=AE=89=E3=82=92=E3=80=8CN=20=E3=82=AF=E3=83=AC=E3=82=B8?=
=?UTF-8?q?=E3=83=83=E3=83=88=E3=81=A7=E5=B9=B3=E5=9D=87M=E5=9B=9E?=
=?UTF-8?q?=E3=80=8D=E3=81=AB=E5=A4=89=E6=9B=B4=E3=81=97=E3=83=88=E3=83=BC?=
=?UTF-8?q?=E3=82=AF=E3=83=B3=E8=B3=BC=E5=85=A5=E7=94=BB=E9=9D=A2=E3=82=92?=
=?UTF-8?q?=E8=BF=BD=E5=8A=A0=EF=BC=88ADR-0012=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- モデルカードの目安を残高ベースの残回数から「10,000 クレジットで平均N回」に変更
(残高に依存しない比率表示。estimatePerReference で算出)
- トークン購入画面(/billing)を追加: 残高・購入パック・利用/購入履歴を表示
- backend: services/billing/pricing.py(パック定義の正本)+ GET /api/billing/packs
- frontend: BillingView / useBillingPage フック / BillingPage、UserMenu に
「クレジットを購入」項目(モデル切り替えとは別項目で分離)
- 購入導線(Stripe Checkout)は Phase 2。現状は購入ボタン押下で準備中を通知
- フック/エンドポイントのテスト追加、E2E(billing.spec)追加、codegen 再生成
https://claude.ai/code/session_01VHYFnKVfBX64pNdhb5kxVt
---
backend/app/routers/billing.py | 18 ++
backend/app/schemas/billing.py | 9 +
backend/app/services/billing/pricing.py | 36 +++
backend/tests/test_billing.py | 22 ++
frontend/e2e/billing.spec.ts | 81 +++++++
frontend/src/api/billing.ts | 6 +
frontend/src/api/generated.ts | 56 +++++
frontend/src/api/paths.ts | 1 +
frontend/src/api/types.ts | 3 +
frontend/src/components/SidebarLayout.tsx | 1 +
frontend/src/components/UserMenu.tsx | 43 ++--
.../agent/ModelSelectModal.test.tsx | 6 +-
.../src/components/agent/ModelSelectModal.tsx | 21 +-
.../components/billing/BillingView.module.css | 206 ++++++++++++++++++
.../src/components/billing/BillingView.tsx | 93 ++++++++
frontend/src/constants/agentModels.ts | 3 +
frontend/src/constants/messages.ts | 45 +++-
frontend/src/hooks/useBillingPage.test.ts | 72 ++++++
frontend/src/hooks/useBillingPage.ts | 48 ++++
frontend/src/pages/BillingPage.tsx | 5 +
frontend/src/router/routes.tsx | 2 +
21 files changed, 749 insertions(+), 28 deletions(-)
create mode 100644 backend/app/services/billing/pricing.py
create mode 100644 frontend/e2e/billing.spec.ts
create mode 100644 frontend/src/components/billing/BillingView.module.css
create mode 100644 frontend/src/components/billing/BillingView.tsx
create mode 100644 frontend/src/hooks/useBillingPage.test.ts
create mode 100644 frontend/src/hooks/useBillingPage.ts
create mode 100644 frontend/src/pages/BillingPage.tsx
diff --git a/backend/app/routers/billing.py b/backend/app/routers/billing.py
index ea92b923..899d6fc7 100644
--- a/backend/app/routers/billing.py
+++ b/backend/app/routers/billing.py
@@ -20,9 +20,11 @@
AdminCreditGrantRequest,
AgentUsageSummaryEntry,
CreditBalanceResponse,
+ CreditPackResponse,
CreditTransactionResponse,
)
from ..services.billing import credit_service
+from ..services.billing.pricing import CREDIT_PACKS
logger = logging.getLogger(__name__)
@@ -48,6 +50,22 @@ def list_credit_transactions(
return [CreditTransactionResponse.model_validate(t) for t in transactions]
+@router.get("/packs", response_model=list[CreditPackResponse])
+def list_credit_packs(
+ _: User = Depends(get_current_user),
+) -> list[CreditPackResponse]:
+ """購入可能なクレジットパック一覧を返す(トークン購入画面用 / ADR-0012)。
+
+ 価格・付与クレジットの正本は services/billing/pricing.py。
+ """
+ return [
+ CreditPackResponse(
+ id=pack.id, name=pack.name, price_jpy=pack.price_jpy, credits=pack.credits
+ )
+ for pack in CREDIT_PACKS
+ ]
+
+
@router.get("/usage-summary", response_model=list[AgentUsageSummaryEntry])
def get_usage_summary(
user: User = Depends(get_current_user),
diff --git a/backend/app/schemas/billing.py b/backend/app/schemas/billing.py
index d05aab95..9f6010a3 100644
--- a/backend/app/schemas/billing.py
+++ b/backend/app/schemas/billing.py
@@ -26,6 +26,15 @@ class CreditTransactionResponse(BaseModel):
created_at: datetime
+class CreditPackResponse(BaseModel):
+ """購入可能なクレジットパック 1 種(トークン購入画面用 / ADR-0012)。"""
+
+ id: str
+ name: str
+ price_jpy: int
+ credits: int
+
+
class AgentUsageSummaryEntry(BaseModel):
"""モデル別の使用量サマリ 1 件(モデル選択モーダルの利用実績表示用 / ADR-0012)。"""
diff --git a/backend/app/services/billing/pricing.py b/backend/app/services/billing/pricing.py
new file mode 100644
index 00000000..e2f8a446
--- /dev/null
+++ b/backend/app/services/billing/pricing.py
@@ -0,0 +1,36 @@
+"""クレジットパック(購入単位)の定義(ADR-0012)。
+
+価格・付与クレジットの正本。トークン購入画面のパック一覧と、Phase 2 の
+Stripe Checkout(`price_data` 動的生成)の双方がここを参照する。
+為替変動は本ファイルの値の調整で吸収する(自動連動はしない)。
+"""
+
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class CreditPack:
+ """購入クレジットパック 1 種。"""
+
+ # Checkout の metadata で受け渡す安定 ID(変更しない)
+ id: str
+ name: str
+ # 販売価格(日本円・税込想定)
+ price_jpy: int
+ # 付与クレジット(ボーナス込み)
+ credits: int
+
+
+# 目安: Sonnet 1 回 ≒ 270 クレジット。スタンダード以上はボーナス分を上乗せ
+CREDIT_PACKS: list[CreditPack] = [
+ CreditPack(id="starter", name="スターター", price_jpy=500, credits=30_000),
+ CreditPack(id="standard", name="スタンダード", price_jpy=1_000, credits=65_000),
+ CreditPack(id="pro", name="プロ", price_jpy=3_000, credits=210_000),
+]
+
+_PACKS_BY_ID = {pack.id: pack for pack in CREDIT_PACKS}
+
+
+def get_pack(pack_id: str) -> CreditPack | None:
+ """ID からパックを引く(未知の ID は None)。Phase 2 の Checkout 作成で使う。"""
+ return _PACKS_BY_ID.get(pack_id)
diff --git a/backend/tests/test_billing.py b/backend/tests/test_billing.py
index c54508d7..a3ae3392 100644
--- a/backend/tests/test_billing.py
+++ b/backend/tests/test_billing.py
@@ -346,6 +346,28 @@ def test_usage_summary_requires_auth(client: TestClient) -> None:
assert resp.status_code == 401
+# --- 統合テスト(クレジットパック) ---
+
+
+def test_list_credit_packs(client: TestClient) -> None:
+ """GET /api/billing/packs は pricing.py のパック一覧を返す。"""
+ auth_header(client, "billing-packs")
+ resp = client.get("/api/billing/packs")
+ assert resp.status_code == 200
+ packs = resp.json()
+ ids = [p["id"] for p in packs]
+ assert ids == ["starter", "standard", "pro"]
+ starter = packs[0]
+ assert starter["price_jpy"] == 500
+ assert starter["credits"] == 30_000
+
+
+def test_list_credit_packs_requires_auth(client: TestClient) -> None:
+ """未ログインのパック一覧取得は 401。"""
+ resp = client.get("/api/billing/packs")
+ assert resp.status_code == 401
+
+
# --- 統合テスト(管理者付与) ---
diff --git a/frontend/e2e/billing.spec.ts b/frontend/e2e/billing.spec.ts
new file mode 100644
index 00000000..d1711c5b
--- /dev/null
+++ b/frontend/e2e/billing.spec.ts
@@ -0,0 +1,81 @@
+import { expect, test, type Page } from "@playwright/test";
+import { setupAuth, waitForAuthenticatedLayout } from "./helpers/auth";
+
+/**
+ * トークン購入画面(ADR-0012)E2E。
+ * UserMenu の「クレジットを購入」→ /billing。残高・パック・履歴が表示され、
+ * 購入ボタン押下で準備中トーストが出る(Stripe 連携は Phase 2)。
+ */
+
+async function setupBillingApi(page: Page) {
+ await page.route("**/api/billing/balance", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ balance: 12000 }),
+ }),
+ );
+ await page.route("**/api/billing/packs", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify([
+ { id: "starter", name: "スターター", price_jpy: 500, credits: 30000 },
+ { id: "standard", name: "スタンダード", price_jpy: 1000, credits: 65000 },
+ { id: "pro", name: "プロ", price_jpy: 3000, credits: 210000 },
+ ]),
+ }),
+ );
+ await page.route("**/api/billing/transactions", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify([
+ {
+ id: "t1",
+ amount: -270,
+ balance_after: 12000,
+ transaction_type: "consumption",
+ description: "Agent チャット(sonnet)",
+ created_at: "2026-06-13T02:00:00Z",
+ },
+ {
+ id: "t2",
+ amount: 30000,
+ balance_after: 12270,
+ transaction_type: "admin_grant",
+ description: "テスト付与",
+ created_at: "2026-06-12T09:00:00Z",
+ },
+ ]),
+ }),
+ );
+}
+
+test("UserMenu からトークン購入画面を開き、残高・パック・履歴が表示される", async ({ page }) => {
+ await setupAuth(page);
+ await setupBillingApi(page);
+
+ await page.goto("/career");
+ await waitForAuthenticatedLayout(page);
+
+ await page.getByRole("button", { name: "e2e-test-user" }).click();
+ await page.getByRole("button", { name: "クレジットを購入" }).click();
+
+ await expect(page).toHaveURL(/\/billing$/);
+ // 残高(サイドバーの残高バッジと区別するため main 内に限定)
+ const main = page.locator("main");
+ await expect(main.getByText("現在の残高")).toBeVisible();
+ await expect(main.getByText("12,000")).toBeVisible();
+ // パック
+ await expect(main.getByText("スターター")).toBeVisible();
+ await expect(main.getByText("¥500")).toBeVisible();
+ // 履歴
+ await expect(main.getByText("Agent チャット(sonnet)")).toBeVisible();
+
+ // 購入ボタン → 準備中トースト(Stripe は Phase 2)
+ await page.getByRole("button", { name: "購入する" }).first().click();
+ await expect(
+ page.getByText("クレジット購入(Stripe 決済)は現在準備中です。"),
+ ).toBeVisible();
+});
diff --git a/frontend/src/api/billing.ts b/frontend/src/api/billing.ts
index c19b9801..260c1ac9 100644
--- a/frontend/src/api/billing.ts
+++ b/frontend/src/api/billing.ts
@@ -3,6 +3,7 @@ import { PATHS } from "./paths";
import type {
AgentUsageSummaryEntry,
CreditBalanceResponse,
+ CreditPackResponse,
CreditTransactionResponse,
} from "./types";
@@ -23,3 +24,8 @@ export function getCreditTransactions(): Promise {
export function getAgentUsageSummary(): Promise {
return request(PATHS.billing.usageSummary, { method: "GET" });
}
+
+/** 購入可能なクレジットパック一覧を取得する。 */
+export function getCreditPacks(): Promise {
+ return request(PATHS.billing.packs, { method: "GET" });
+}
diff --git a/frontend/src/api/generated.ts b/frontend/src/api/generated.ts
index af0885e6..3a28c48a 100644
--- a/frontend/src/api/generated.ts
+++ b/frontend/src/api/generated.ts
@@ -74,6 +74,28 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/billing/packs": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * List Credit Packs
+ * @description 購入可能なクレジットパック一覧を返す(トークン購入画面用 / ADR-0012)。
+ *
+ * 価格・付与クレジットの正本は services/billing/pricing.py。
+ */
+ get: operations["list_credit_packs_api_billing_packs_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/billing/transactions": {
parameters: {
query?: never;
@@ -1303,6 +1325,20 @@ export interface components {
/** Balance */
balance: number;
};
+ /**
+ * CreditPackResponse
+ * @description 購入可能なクレジットパック 1 種(トークン購入画面用 / ADR-0012)。
+ */
+ CreditPackResponse: {
+ /** Credits */
+ credits: number;
+ /** Id */
+ id: string;
+ /** Name */
+ name: string;
+ /** Price Jpy */
+ price_jpy: number;
+ };
/**
* CreditTransactionResponse
* @description クレジット台帳エントリ 1 件(履歴表示用)。
@@ -2007,6 +2043,26 @@ export interface operations {
};
};
};
+ list_credit_packs_api_billing_packs_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["CreditPackResponse"][];
+ };
+ };
+ };
+ };
list_credit_transactions_api_billing_transactions_get: {
parameters: {
query?: never;
diff --git a/frontend/src/api/paths.ts b/frontend/src/api/paths.ts
index c9cfade8..820636f2 100644
--- a/frontend/src/api/paths.ts
+++ b/frontend/src/api/paths.ts
@@ -34,6 +34,7 @@ export const PATHS = {
balance: "/api/billing/balance",
transactions: "/api/billing/transactions",
usageSummary: "/api/billing/usage-summary",
+ packs: "/api/billing/packs",
},
resumes: {
base: "/api/resumes",
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
index 32fb04f0..e94c0cf0 100644
--- a/frontend/src/api/types.ts
+++ b/frontend/src/api/types.ts
@@ -155,3 +155,6 @@ export type CreditTransactionResponse = Schemas["CreditTransactionResponse"];
/** モデル別の使用量サマリ。backend `schemas/billing.py:AgentUsageSummaryEntry`。 */
export type AgentUsageSummaryEntry = Schemas["AgentUsageSummaryEntry"];
+
+/** 購入可能なクレジットパック。backend `schemas/billing.py:CreditPackResponse`。 */
+export type CreditPackResponse = Schemas["CreditPackResponse"];
diff --git a/frontend/src/components/SidebarLayout.tsx b/frontend/src/components/SidebarLayout.tsx
index 22a5d63c..ff89b475 100644
--- a/frontend/src/components/SidebarLayout.tsx
+++ b/frontend/src/components/SidebarLayout.tsx
@@ -203,6 +203,7 @@ export function SidebarLayout({
onLogout={onLogout}
onLogin={handleLogin}
onOpenModelSelect={isAuthenticated ? () => setModelSelectOpen(true) : undefined}
+ onOpenBilling={isAuthenticated ? () => navigate("/billing") : undefined}
/>
diff --git a/frontend/src/components/UserMenu.tsx b/frontend/src/components/UserMenu.tsx
index fca675ac..040656fa 100644
--- a/frontend/src/components/UserMenu.tsx
+++ b/frontend/src/components/UserMenu.tsx
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react";
import {
AGENT_MODEL_MESSAGES,
AUTH_PROMPT_MESSAGES,
+ BILLING_PAGE_MESSAGES,
EXTERNAL_LINKS,
UI_MESSAGES,
} from "../constants/messages";
@@ -24,6 +25,7 @@ export function UserMenu({
onLogout,
onLogin,
onOpenModelSelect,
+ onOpenBilling,
}: {
isAuthenticated: boolean;
username: string | null;
@@ -33,6 +35,8 @@ export function UserMenu({
onLogin: () => void;
/** AI モデル選択モーダルを開く(認証済みのみ表示 / ADR-0012)。 */
onOpenModelSelect?: () => void;
+ /** トークン購入画面へ遷移する(認証済みのみ表示。モデル切り替えとは分離 / ADR-0012)。 */
+ onOpenBilling?: () => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
@@ -63,19 +67,32 @@ export function UserMenu({
{/* AI モデル切り替えは認証済みのみ(モデル選択はログインユーザーの設定 / ADR-0012)。 */}
{isAuthenticated && onOpenModelSelect && (
- <>
-
-
- >
+
+ )}
+ {/* トークン購入はモデル切り替えとは別項目(購入と切り替えを分離 / ADR-0012)。 */}
+ {isAuthenticated && onOpenBilling && (
+
+ )}
+ {(onOpenModelSelect || onOpenBilling) && isAuthenticated && (
+
)}
{/* 未認証ではログインはトリガー側のボタンに集約するため、メニューには出さない。 */}
{isAuthenticated && (
diff --git a/frontend/src/components/agent/ModelSelectModal.test.tsx b/frontend/src/components/agent/ModelSelectModal.test.tsx
index 7a121877..98698a98 100644
--- a/frontend/src/components/agent/ModelSelectModal.test.tsx
+++ b/frontend/src/components/agent/ModelSelectModal.test.tsx
@@ -92,7 +92,7 @@ describe("ModelSelectModal", () => {
expect(within(sonnetCard).queryByText(/残高が不足/)).toBeNull();
});
- it("利用実績があるとカードに利用回数・消費クレジットと残回数目安を表示する", async () => {
+ it("利用実績があると利用回数と『10,000クレジットで平均N回』の目安を表示する", async () => {
renderModal({
balance: 2700,
usage: [
@@ -105,8 +105,8 @@ describe("ModelSelectModal", () => {
await waitFor(() => {
expect(within(sonnetCard).getByText(/これまで 4 回/)).toBeTruthy();
});
- // 平均 270/回・残高 2700 → あと約 10 回
- expect(within(sonnetCard).getByText(/あと約 10 回/)).toBeTruthy();
+ // 平均 270/回 → 10,000 クレジットで平均 37 回(残高には依存しない)
+ expect(within(sonnetCard).getByText(/10,000 クレジットで平均 37 回/)).toBeTruthy();
});
it("利用実績が無いカードは『まだ利用していません』を表示する", async () => {
diff --git a/frontend/src/components/agent/ModelSelectModal.tsx b/frontend/src/components/agent/ModelSelectModal.tsx
index dfac9549..b4abaa92 100644
--- a/frontend/src/components/agent/ModelSelectModal.tsx
+++ b/frontend/src/components/agent/ModelSelectModal.tsx
@@ -1,9 +1,9 @@
import type { AgentModelAlias } from "../../api/types";
-import { AGENT_MODEL_OPTIONS } from "../../constants/agentModels";
+import { AGENT_MODEL_OPTIONS, CREDIT_ESTIMATE_REFERENCE } from "../../constants/agentModels";
import {
AGENT_MODEL_MESSAGES,
+ creditsForChatsLabel,
modelUsageLabel,
- remainingChatsLabel,
} from "../../constants/messages";
import { useAgentUsageSummary } from "../../hooks/useAgentUsageSummary";
import { useAppDispatch, useAppSelector } from "../../store";
@@ -30,11 +30,12 @@ export function ModelSelectModal({ onClose }: { onClose: () => void }) {
onClose();
};
- /** 有料モデルの残回数目安(平均消費 = 累計消費 / 回数 と残高から算出)。 */
- const estimateRemaining = (creditCost: number, chatCount: number): number | null => {
- if (balance === null || chatCount <= 0 || creditCost <= 0) return null;
+ /** 基準クレジット(10,000)あたりの平均利用回数の目安。残高には依存しない。
+ * 平均消費 = 累計消費 / 回数。利用実績が無ければ算出しない。 */
+ const estimatePerReference = (creditCost: number, chatCount: number): number | null => {
+ if (chatCount <= 0 || creditCost <= 0) return null;
const avgPerChat = creditCost / chatCount;
- return Math.floor(balance / avgPerChat);
+ return Math.floor(CREDIT_ESTIMATE_REFERENCE / avgPerChat);
};
return (
@@ -65,8 +66,8 @@ export function ModelSelectModal({ onClose }: { onClose: () => void }) {
const usage = getUsage(option.alias);
const chatCount = usage?.chat_count ?? 0;
const creditCost = usage?.credit_cost ?? 0;
- const remaining = option.isPaid
- ? estimateRemaining(creditCost, chatCount)
+ const perReference = option.isPaid
+ ? estimatePerReference(creditCost, chatCount)
: null;
return (
{insufficient && (
{AGENT_MODEL_MESSAGES.INSUFFICIENT_HINT}
diff --git a/frontend/src/components/billing/BillingView.module.css b/frontend/src/components/billing/BillingView.module.css
new file mode 100644
index 00000000..940d7179
--- /dev/null
+++ b/frontend/src/components/billing/BillingView.module.css
@@ -0,0 +1,206 @@
+.page {
+ max-width: 880px;
+ margin: 0 auto;
+ padding: 2rem 1.5rem 4rem;
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ flex-wrap: wrap;
+ margin-bottom: 1.5rem;
+}
+
+.title {
+ margin: 0;
+ font-size: 1.4rem;
+ color: var(--text-primary);
+}
+
+.balanceCard {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ padding: 0.6rem 1.1rem;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+}
+
+.balanceLabel {
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+}
+
+.balanceValue {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: var(--text-primary);
+ font-variant-numeric: tabular-nums;
+}
+
+.balanceUnit {
+ margin-left: 0.3rem;
+ font-size: 0.8rem;
+ font-weight: 500;
+ color: var(--text-secondary);
+}
+
+.error {
+ color: var(--warning-text);
+ font-size: 0.85rem;
+}
+
+.loading {
+ color: var(--text-secondary);
+ font-size: 0.85rem;
+}
+
+.section {
+ margin-top: 2rem;
+}
+
+.sectionHead {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+}
+
+.sectionTitle {
+ margin: 0;
+ font-size: 1.05rem;
+ color: var(--text-primary);
+}
+
+.preparingBadge {
+ padding: 0.1rem 0.5rem;
+ font-size: 0.7rem;
+ font-weight: 600;
+ color: var(--text-secondary);
+ border: 1px solid var(--border);
+ border-radius: 999px;
+}
+
+.sectionNote {
+ margin: 0.4rem 0 1rem;
+ font-size: 0.85rem;
+ color: var(--text-secondary);
+}
+
+.packs {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 1rem;
+}
+
+.packCard {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.4rem;
+ padding: 1.25rem 1rem;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+}
+
+.packName {
+ font-size: 0.9rem;
+ font-weight: 600;
+ color: var(--text-secondary);
+}
+
+.packCredits {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: var(--text-primary);
+ font-variant-numeric: tabular-nums;
+}
+
+.packCreditsUnit {
+ margin-left: 0.25rem;
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: var(--text-secondary);
+}
+
+.packPrice {
+ font-size: 1.05rem;
+ font-weight: 600;
+ color: var(--accent);
+}
+
+.purchaseButton {
+ margin-top: 0.5rem;
+ width: 100%;
+ padding: 0.55rem 0.75rem;
+ background: var(--accent);
+ color: #fff;
+ border: none;
+ border-radius: 8px;
+ font-size: 0.9rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: opacity 0.15s;
+}
+
+.purchaseButton:hover {
+ opacity: 0.9;
+}
+
+.history {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ overflow: hidden;
+}
+
+.historyRow {
+ display: grid;
+ grid-template-columns: 4rem 1fr auto 6rem;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.7rem 1rem;
+ font-size: 0.85rem;
+ border-bottom: 1px solid var(--border);
+}
+
+.historyRow:last-child {
+ border-bottom: none;
+}
+
+.historyType {
+ font-weight: 600;
+ color: var(--text-secondary);
+}
+
+.historyDesc {
+ color: var(--text-primary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.historyPlus {
+ font-weight: 700;
+ color: var(--accent);
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+}
+
+.historyMinus {
+ font-weight: 700;
+ color: var(--text-secondary);
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+}
+
+.historyDate {
+ color: var(--text-secondary);
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+}
diff --git a/frontend/src/components/billing/BillingView.tsx b/frontend/src/components/billing/BillingView.tsx
new file mode 100644
index 00000000..2dce4743
--- /dev/null
+++ b/frontend/src/components/billing/BillingView.tsx
@@ -0,0 +1,93 @@
+import {
+ BILLING_PAGE_MESSAGES,
+ formatCreditAmount,
+ formatYen,
+ transactionAmountLabel,
+ transactionTypeLabel,
+} from "../../constants/messages";
+import { useBillingPage } from "../../hooks/useBillingPage";
+import { useToast } from "../ui/toast";
+import styles from "./BillingView.module.css";
+
+/**
+ * トークン購入画面(ADR-0012)。残高・購入パック・取引履歴を表示する。
+ *
+ * 購入導線(Stripe Checkout)は Phase 2 で実装する。現状はパックと購入ボタンを
+ * 表示し、押下時に準備中である旨を通知する(画面は確認できる状態に保つ)。
+ */
+export function BillingView() {
+ const { balance, packs, transactions, loading, error } = useBillingPage();
+ const { showSuccess } = useToast();
+
+ const handlePurchase = () => {
+ // Phase 2 で POST /api/billing/checkout → Stripe Checkout へ遷移させる
+ showSuccess(BILLING_PAGE_MESSAGES.CHECKOUT_PREPARING);
+ };
+
+ return (
+
+
+
+ {error &&
{error}
}
+ {loading &&
{BILLING_PAGE_MESSAGES.LOADING}
}
+
+
+
+
{BILLING_PAGE_MESSAGES.PACKS_TITLE}
+ {BILLING_PAGE_MESSAGES.PREPARING_BADGE}
+
+ {BILLING_PAGE_MESSAGES.PACKS_NOTE}
+
+ {packs.map((pack) => (
+
+ {pack.name}
+
+ {formatCreditAmount(pack.credits)}
+
+ {BILLING_PAGE_MESSAGES.CREDITS_UNIT}
+
+
+ {formatYen(pack.price_jpy)}
+
+
+ ))}
+
+
+
+
+ {BILLING_PAGE_MESSAGES.HISTORY_TITLE}
+ {transactions.length === 0 ? (
+ {BILLING_PAGE_MESSAGES.HISTORY_EMPTY}
+ ) : (
+
+ {transactions.map((tx) => (
+ -
+ {transactionTypeLabel(tx.transaction_type)}
+ {tx.description ?? ""}
+
+ {transactionAmountLabel(tx.amount)}
+
+
+ {new Date(tx.created_at).toLocaleDateString("ja-JP")}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/constants/agentModels.ts b/frontend/src/constants/agentModels.ts
index 0d7cf9d7..b194f825 100644
--- a/frontend/src/constants/agentModels.ts
+++ b/frontend/src/constants/agentModels.ts
@@ -19,6 +19,9 @@ export type AgentModelOption = {
costHint: string;
};
+// 「N クレジットで平均M回」の目安に使う基準クレジット量(残高に依存しない比率表示)
+export const CREDIT_ESTIMATE_REFERENCE = 10_000;
+
export const AGENT_MODEL_OPTIONS: readonly AgentModelOption[] = [
{
alias: "haiku",
diff --git a/frontend/src/constants/messages.ts b/frontend/src/constants/messages.ts
index 44db3ba9..6ed39193 100644
--- a/frontend/src/constants/messages.ts
+++ b/frontend/src/constants/messages.ts
@@ -423,9 +423,11 @@ export function modelUsageLabel(chatCount: number, creditCost: number): string {
return `これまで ${count} 回利用`;
}
-/** 残高で実行できる有料チャットの残回数目安ラベル。 */
-export function remainingChatsLabel(remaining: number): string {
- return `残高であと約 ${remaining.toLocaleString("ja-JP")} 回`;
+/** 一定クレジットあたりの平均利用回数の目安ラベル(残高に依存しない比率表示)。 */
+export function creditsForChatsLabel(referenceCredits: number, chats: number): string {
+ return `${referenceCredits.toLocaleString("ja-JP")} クレジットで平均 ${chats.toLocaleString(
+ "ja-JP",
+ )} 回`;
}
/** クレジット課金(ADR-0012)の UI 文言。 */
@@ -438,3 +440,40 @@ export const BILLING_MESSAGES = {
export function formatCreditAmount(balance: number): string {
return balance.toLocaleString("ja-JP");
}
+
+/** トークン購入画面(ADR-0012)の UI 文言。 */
+export const BILLING_PAGE_MESSAGES = {
+ TITLE: "クレジット",
+ MENU_ITEM: "クレジットを購入",
+ BALANCE_LABEL: "現在の残高",
+ CREDITS_UNIT: "クレジット",
+ PACKS_TITLE: "クレジットを購入",
+ PACKS_NOTE: "Sonnet など有料モデルの利用に使えます。Haiku は無料で使い放題です。",
+ PURCHASE_BUTTON: "購入する",
+ CHECKOUT_PREPARING: "クレジット購入(Stripe 決済)は現在準備中です。",
+ PREPARING_BADGE: "準備中",
+ HISTORY_TITLE: "利用・購入履歴",
+ HISTORY_EMPTY: "まだ履歴はありません。",
+ LOADING: "読み込み中...",
+ TYPE_PURCHASE: "購入",
+ TYPE_CONSUMPTION: "消費",
+ TYPE_ADMIN_GRANT: "付与",
+} as const;
+
+/** 金額を日本円表記に整形する。 */
+export function formatYen(amount: number): string {
+ return `¥${amount.toLocaleString("ja-JP")}`;
+}
+
+/** 台帳の種別コードを表示ラベルに変換する。 */
+export function transactionTypeLabel(type: string): string {
+ if (type === "purchase") return BILLING_PAGE_MESSAGES.TYPE_PURCHASE;
+ if (type === "consumption") return BILLING_PAGE_MESSAGES.TYPE_CONSUMPTION;
+ return BILLING_PAGE_MESSAGES.TYPE_ADMIN_GRANT;
+}
+
+/** 台帳の符号付き増減量を表示文字列に整形する(付与/購入は +、消費は -)。 */
+export function transactionAmountLabel(amount: number): string {
+ const sign = amount > 0 ? "+" : "";
+ return `${sign}${amount.toLocaleString("ja-JP")}`;
+}
diff --git a/frontend/src/hooks/useBillingPage.test.ts b/frontend/src/hooks/useBillingPage.test.ts
new file mode 100644
index 00000000..986557f4
--- /dev/null
+++ b/frontend/src/hooks/useBillingPage.test.ts
@@ -0,0 +1,72 @@
+import { renderHook, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { useBillingPage } from "./useBillingPage";
+
+const getCreditBalanceMock = vi.fn();
+const getCreditPacksMock = vi.fn();
+const getCreditTransactionsMock = vi.fn();
+
+vi.mock("../api/billing", () => ({
+ getCreditBalance: () => getCreditBalanceMock(),
+ getCreditPacks: () => getCreditPacksMock(),
+ getCreditTransactions: () => getCreditTransactionsMock(),
+}));
+
+beforeEach(() => {
+ getCreditBalanceMock.mockReset();
+ getCreditPacksMock.mockReset();
+ getCreditTransactionsMock.mockReset();
+});
+
+describe("useBillingPage", () => {
+ it("残高・パック・履歴をまとめて取得する", async () => {
+ getCreditBalanceMock.mockResolvedValue({ balance: 12000 });
+ getCreditPacksMock.mockResolvedValue([
+ { id: "starter", name: "スターター", price_jpy: 500, credits: 30000 },
+ ]);
+ getCreditTransactionsMock.mockResolvedValue([
+ {
+ id: "t1",
+ amount: 30000,
+ balance_after: 30000,
+ transaction_type: "purchase",
+ description: null,
+ created_at: "2026-06-13T00:00:00Z",
+ },
+ ]);
+
+ const { result } = renderHook(() => useBillingPage());
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+ expect(result.current.balance).toBe(12000);
+ expect(result.current.packs).toHaveLength(1);
+ expect(result.current.transactions).toHaveLength(1);
+ expect(result.current.error).toBeNull();
+ });
+
+ it("いずれかの取得に失敗したら error を立てる", async () => {
+ getCreditBalanceMock.mockResolvedValue({ balance: 0 });
+ getCreditPacksMock.mockRejectedValue(new Error("パック取得失敗"));
+ getCreditTransactionsMock.mockResolvedValue([]);
+
+ const { result } = renderHook(() => useBillingPage());
+
+ await waitFor(() => {
+ expect(result.current.error).toBe("パック取得失敗");
+ });
+ expect(result.current.loading).toBe(false);
+ });
+
+ it("初期状態は loading=true", () => {
+ getCreditBalanceMock.mockResolvedValue({ balance: 0 });
+ getCreditPacksMock.mockResolvedValue([]);
+ getCreditTransactionsMock.mockResolvedValue([]);
+
+ const { result } = renderHook(() => useBillingPage());
+
+ expect(result.current.loading).toBe(true);
+ });
+});
diff --git a/frontend/src/hooks/useBillingPage.ts b/frontend/src/hooks/useBillingPage.ts
new file mode 100644
index 00000000..95ffaa16
--- /dev/null
+++ b/frontend/src/hooks/useBillingPage.ts
@@ -0,0 +1,48 @@
+/**
+ * トークン購入画面(ADR-0012)のデータ取得フック。
+ *
+ * 残高・購入パック・取引履歴をまとめて取得する。購入後は呼び出し側が refresh する。
+ */
+
+import { useCallback, useEffect, useState } from "react";
+
+import {
+ getCreditBalance,
+ getCreditPacks,
+ getCreditTransactions,
+} from "../api/billing";
+import type { CreditPackResponse, CreditTransactionResponse } from "../api/types";
+import { FALLBACK_MESSAGES } from "../constants/messages";
+
+export function useBillingPage() {
+ const [balance, setBalance] = useState(null);
+ const [packs, setPacks] = useState([]);
+ const [transactions, setTransactions] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const refresh = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const [balanceRes, packsRes, transactionsRes] = await Promise.all([
+ getCreditBalance(),
+ getCreditPacks(),
+ getCreditTransactions(),
+ ]);
+ setBalance(balanceRes.balance);
+ setPacks(packsRes);
+ setTransactions(transactionsRes);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : FALLBACK_MESSAGES.CREDIT_BALANCE);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void refresh();
+ }, [refresh]);
+
+ return { balance, packs, transactions, loading, error, refresh };
+}
diff --git a/frontend/src/pages/BillingPage.tsx b/frontend/src/pages/BillingPage.tsx
new file mode 100644
index 00000000..f2a484e2
--- /dev/null
+++ b/frontend/src/pages/BillingPage.tsx
@@ -0,0 +1,5 @@
+import { BillingView } from "../components/billing/BillingView";
+
+export default function BillingPage() {
+ return ;
+}
diff --git a/frontend/src/router/routes.tsx b/frontend/src/router/routes.tsx
index 6d30c773..7f89c963 100644
--- a/frontend/src/router/routes.tsx
+++ b/frontend/src/router/routes.tsx
@@ -7,6 +7,7 @@ import { PrivateRoute, PublicRoute, type AuthUser } from "./guards";
import CareerPage from "../pages/CareerPage";
import GitHubLinkPage from "../pages/GitHubLinkPage";
import BlogPage from "../pages/BlogPage";
+import BillingPage from "../pages/BillingPage";
import GitHubCallbackPage from "../pages/GitHubCallbackPage";
import LoginPage from "../pages/LoginPage";
import NotFoundPage from "../pages/NotFoundPage";
@@ -101,6 +102,7 @@ export default function AppRoutes({
>
} />
} />
+ } />
From a64f950e00d236207e06dd6df8b62accc29d3a94 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 13 Jun 2026 12:35:26 +0000
Subject: [PATCH 06/14] =?UTF-8?q?=E3=82=AF=E3=83=AC=E3=82=B8=E3=83=83?=
=?UTF-8?q?=E3=83=88=E3=82=92=E5=86=86=E3=83=9A=E3=83=83=E3=82=B0=EF=BC=88?=
=?UTF-8?q?1cr=3D=C2=A51=EF=BC=89=E5=8C=96=E3=81=97=E5=9B=9E=E6=95=B0?=
=?UTF-8?q?=E3=82=A2=E3=83=B3=E3=82=AB=E3=83=BC=E3=82=92=E5=85=A8=E7=AE=87?=
=?UTF-8?q?=E6=89=80=E3=81=AB=E8=BF=BD=E5=8A=A0=EF=BC=88ADR-0012=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- model_catalog: クレジット単位を USD ペッグ($0.0001)から円ペッグ(1cr=¥1)へ。
消費レートは API 原価(USD)×想定為替(¥150/$)×マージン1.5で算出
(Sonnet 入力675 / 出力3375 クレジット/MTok)。標準消費 baseline_credits_per_chat
を追加(新規ユーザーの回数目安フォールバック)
- pricing: パックを円ペッグに再定義(¥500→500 / ¥1,000→1,100 / ¥3,000→3,500、
上位はボーナス付き)
- GET /api/billing/model-rates 追加(モデル別の標準消費レート)
- 回数アンカー「Sonnet 約N回」を残高(サイドバー・購入画面)・各パック・
モデルカードに併記。useModelRates フック + creditEstimate ユーティリティ追加
- モデルカードの目安基準を 10,000→1,000 クレジット(¥1,000)に変更し、
利用実績が無くてもベースラインで「平均N回」を出す
- 消費額変更に伴う backend テスト更新、frontend テスト/E2E モック追加
https://claude.ai/code/session_01VHYFnKVfBX64pNdhb5kxVt
---
backend/app/routers/billing.py | 22 ++++++
backend/app/schemas/billing.py | 12 ++++
backend/app/services/agent/model_catalog.py | 34 +++++++--
backend/app/services/billing/pricing.py | 9 +--
backend/tests/test_billing.py | 72 +++++++++++++------
frontend/e2e/agent-chat.spec.ts | 3 +-
frontend/e2e/billing.spec.ts | 9 +--
frontend/e2e/helpers/auth.ts | 12 ++++
frontend/src/api/billing.ts | 6 ++
frontend/src/api/generated.ts | 59 +++++++++++++++
frontend/src/api/paths.ts | 1 +
frontend/src/api/types.ts | 3 +
.../agent/ModelSelectModal.test.tsx | 34 +++++++--
.../src/components/agent/ModelSelectModal.tsx | 23 +++---
.../components/billing/BillingView.module.css | 10 +++
.../src/components/billing/BillingView.tsx | 50 +++++++++----
.../billing/CreditBalanceBadge.module.css | 16 ++++-
.../components/billing/CreditBalanceBadge.tsx | 37 +++++++---
frontend/src/constants/agentModels.ts | 5 +-
frontend/src/constants/messages.ts | 9 +++
frontend/src/hooks/useBillingPage.test.ts | 8 +++
frontend/src/hooks/useBillingPage.ts | 19 ++++-
frontend/src/hooks/useModelRates.test.ts | 50 +++++++++++++
frontend/src/hooks/useModelRates.ts | 55 ++++++++++++++
frontend/src/utils/creditEstimate.ts | 20 ++++++
25 files changed, 496 insertions(+), 82 deletions(-)
create mode 100644 frontend/src/hooks/useModelRates.test.ts
create mode 100644 frontend/src/hooks/useModelRates.ts
create mode 100644 frontend/src/utils/creditEstimate.ts
diff --git a/backend/app/routers/billing.py b/backend/app/routers/billing.py
index 899d6fc7..5a47d1a9 100644
--- a/backend/app/routers/billing.py
+++ b/backend/app/routers/billing.py
@@ -22,7 +22,9 @@
CreditBalanceResponse,
CreditPackResponse,
CreditTransactionResponse,
+ ModelRateEntry,
)
+from ..services.agent.model_catalog import MODEL_CATALOG, baseline_credits_per_chat
from ..services.billing import credit_service
from ..services.billing.pricing import CREDIT_PACKS
@@ -66,6 +68,26 @@ def list_credit_packs(
]
+@router.get("/model-rates", response_model=list[ModelRateEntry])
+def list_model_rates(
+ _: User = Depends(get_current_user),
+) -> list[ModelRateEntry]:
+ """モデル別の標準消費レート(回数目安の算出用 / ADR-0012)を返す。
+
+ フロントは残高・パック・モデルカードを「Sonnet 約N回」に換算するのに使う。
+ 利用実績のあるユーザーは usage-summary の実測平均を優先し、本値は新規ユーザーの
+ フォールバックとして使う。
+ """
+ return [
+ ModelRateEntry(
+ model=alias,
+ is_free=spec.is_free,
+ baseline_credits_per_chat=baseline_credits_per_chat(alias),
+ )
+ for alias, spec in MODEL_CATALOG.items()
+ ]
+
+
@router.get("/usage-summary", response_model=list[AgentUsageSummaryEntry])
def get_usage_summary(
user: User = Depends(get_current_user),
diff --git a/backend/app/schemas/billing.py b/backend/app/schemas/billing.py
index 9f6010a3..833654bb 100644
--- a/backend/app/schemas/billing.py
+++ b/backend/app/schemas/billing.py
@@ -35,6 +35,18 @@ class CreditPackResponse(BaseModel):
credits: int
+class ModelRateEntry(BaseModel):
+ """モデル別の標準消費レート(回数目安の算出用 / ADR-0012)。
+
+ 1 クレジット = ¥1。``baseline_credits_per_chat`` は標準的な 1 回の消費の概算で、
+ フロントが残高・パックを「Sonnet 約N回」に換算するのに使う(無料モデルは 0)。
+ """
+
+ model: str
+ is_free: bool
+ baseline_credits_per_chat: int
+
+
class AgentUsageSummaryEntry(BaseModel):
"""モデル別の使用量サマリ 1 件(モデル選択モーダルの利用実績表示用 / ADR-0012)。"""
diff --git a/backend/app/services/agent/model_catalog.py b/backend/app/services/agent/model_catalog.py
index 79e60a50..c444a120 100644
--- a/backend/app/services/agent/model_catalog.py
+++ b/backend/app/services/agent/model_catalog.py
@@ -4,8 +4,9 @@
課金レートは本モジュールでマップする。任意のモデル文字列をクライアントから
受け付けない(コスト爆発・未検証モデルの注入を防ぐ)。
-クレジット単位は「1 クレジット = $0.0001」(USD ペッグ)。消費レートは
-API 原価 × マージン係数で算出する。定数の根拠は ADR-0012 を参照。
+クレジット単位は「1 クレジット = ¥1」(円ペッグ)。消費レートは API 原価(USD)を
+円換算し、マージン係数を乗じて算出する。為替変動は YEN_PER_USD の調整で吸収する。
+定数の根拠は ADR-0012 を参照。
"""
import math
@@ -14,9 +15,11 @@
from ...schemas.agent import AgentModelAlias
-# 1 USD あたりのクレジット数(1 クレジット = $0.0001)
-CREDITS_PER_USD = 10_000
-# API 原価に乗せるマージン係数(為替・キャッシュ未ヒット・運用コストのバッファ)
+# 1 クレジットあたりの円換算(1 クレジット = ¥1)。クライアント表示の正本
+YEN_PER_CREDIT = 1
+# 円換算に使う想定為替(USD→JPY)。為替変動はここの調整で吸収する
+YEN_PER_USD = 150
+# API 原価に乗せるマージン係数(為替変動・キャッシュ未ヒット・運用コストのバッファ)
MARGIN_MULTIPLIER = 1.5
# Anthropic API の公表原価(USD / 100 万トークン)。改定時はここだけ直す
@@ -25,10 +28,18 @@
_SONNET_INPUT_USD_PER_MTOK = 3.0
_SONNET_OUTPUT_USD_PER_MTOK = 15.0
+# 「N クレジットで平均M回」「約N回」の目安に使う標準的な 1 回の消費トークン。
+# Agent はシステムプロンプト + レジュメ + 参照 + 履歴で入力が大きいため、入力多めの
+# 概算を置く。利用実績ゼロの新規ユーザー向けのフォールバック(実消費は
+# agent_usage_logs の実測値で算出。本値は概算で、実データに合わせて調整可)
+BASELINE_INPUT_TOKENS = 10_000
+BASELINE_OUTPUT_TOKENS = 1_500
+
def _credits_per_mtok(usd_per_mtok: float) -> int:
- """USD/MTok の原価をマージン込みのクレジット/MTok レートに換算する。"""
- return int(usd_per_mtok * CREDITS_PER_USD * MARGIN_MULTIPLIER)
+ """USD/MTok の原価を、マージン込みのクレジット/MTok レート(円ペッグ)に換算する。"""
+ yen_per_mtok = usd_per_mtok * YEN_PER_USD * MARGIN_MULTIPLIER
+ return round(yen_per_mtok / YEN_PER_CREDIT)
@dataclass(frozen=True)
@@ -85,3 +96,12 @@ def calculate_credit_cost(alias: str, input_tokens: int, output_tokens: int) ->
+ output_tokens * spec.output_credits_per_mtok
)
return math.ceil(raw / 1_000_000)
+
+
+def baseline_credits_per_chat(alias: str) -> int:
+ """標準的な 1 回のチャットの消費クレジット(回数目安用 / ADR-0012)。
+
+ 利用実績ゼロの新規ユーザーでも「N クレジットで約M回」を出すための概算。
+ 無料モデルは 0。
+ """
+ return calculate_credit_cost(alias, BASELINE_INPUT_TOKENS, BASELINE_OUTPUT_TOKENS)
diff --git a/backend/app/services/billing/pricing.py b/backend/app/services/billing/pricing.py
index e2f8a446..e03d277b 100644
--- a/backend/app/services/billing/pricing.py
+++ b/backend/app/services/billing/pricing.py
@@ -21,11 +21,12 @@ class CreditPack:
credits: int
-# 目安: Sonnet 1 回 ≒ 270 クレジット。スタンダード以上はボーナス分を上乗せ
+# 1 クレジット = ¥1(ADR-0012)。スターターは等価、スタンダード以上はボーナス上乗せ。
+# 目安: Sonnet 1 回 ≒ 9 クレジット(standard 1,100 クレジット ≒ 120 回)
CREDIT_PACKS: list[CreditPack] = [
- CreditPack(id="starter", name="スターター", price_jpy=500, credits=30_000),
- CreditPack(id="standard", name="スタンダード", price_jpy=1_000, credits=65_000),
- CreditPack(id="pro", name="プロ", price_jpy=3_000, credits=210_000),
+ CreditPack(id="starter", name="スターター", price_jpy=500, credits=500),
+ CreditPack(id="standard", name="スタンダード", price_jpy=1_000, credits=1_100),
+ CreditPack(id="pro", name="プロ", price_jpy=3_000, credits=3_500),
]
_PACKS_BY_ID = {pack.id: pack for pack in CREDIT_PACKS}
diff --git a/backend/tests/test_billing.py b/backend/tests/test_billing.py
index a3ae3392..cdc5b09d 100644
--- a/backend/tests/test_billing.py
+++ b/backend/tests/test_billing.py
@@ -12,9 +12,11 @@
from app.schemas.agent import AgentModelAlias
from app.services.agent import chat_service
from app.services.agent.model_catalog import (
- CREDITS_PER_USD,
MARGIN_MULTIPLIER,
MODEL_CATALOG,
+ YEN_PER_CREDIT,
+ YEN_PER_USD,
+ baseline_credits_per_chat,
calculate_credit_cost,
)
from app.services.billing import credit_service
@@ -61,11 +63,14 @@ def test_model_catalog_resolves_real_model_ids() -> None:
assert MODEL_CATALOG["sonnet"].is_free is False
-def test_credit_rates_include_margin() -> None:
- """消費レートは API 原価(USD/MTok)× マージン係数で算出される(ADR-0012)。"""
+def test_credit_rates_are_yen_pegged_with_margin() -> None:
+ """消費レートは API 原価(USD/MTok)を円換算しマージンを乗せて算出される(1cr=¥1 / ADR-0012)。"""
sonnet = MODEL_CATALOG["sonnet"]
- assert sonnet.input_credits_per_mtok == int(3.0 * CREDITS_PER_USD * MARGIN_MULTIPLIER)
- assert sonnet.output_credits_per_mtok == int(15.0 * CREDITS_PER_USD * MARGIN_MULTIPLIER)
+ assert sonnet.input_credits_per_mtok == round(3.0 * YEN_PER_USD * MARGIN_MULTIPLIER / YEN_PER_CREDIT)
+ assert sonnet.output_credits_per_mtok == round(15.0 * YEN_PER_USD * MARGIN_MULTIPLIER / YEN_PER_CREDIT)
+ # 1 クレジット = ¥1 なので Sonnet 入力は 675 クレジット/MTok(= $3 × ¥150 × 1.5)
+ assert sonnet.input_credits_per_mtok == 675
+ assert sonnet.output_credits_per_mtok == 3375
def test_calculate_credit_cost_free_model_is_zero() -> None:
@@ -75,16 +80,23 @@ def test_calculate_credit_cost_free_model_is_zero() -> None:
def test_calculate_credit_cost_sonnet_exact() -> None:
"""sonnet のコストは入出力レートの合算(クレジット/MTok)から算出される。"""
- # input: 1000 tok × 45,000/MTok = 45 / output: 1000 tok × 225,000/MTok = 225
- assert calculate_credit_cost("sonnet", 1000, 1000) == 270
+ # input 1000 tok × 675/MTok = 0.675 / output 1000 tok × 3375/MTok = 3.375 → 計 4.05 → ceil 5
+ assert calculate_credit_cost("sonnet", 1000, 1000) == 5
def test_calculate_credit_cost_rounds_up() -> None:
"""端数は切り上げ、課金対象の利用が 0 クレジットにならない。"""
- # input 1 tok = 45000 / 1e6 = 0.045 → 1 クレジットに切り上げ
+ # input 1 tok = 675 / 1e6 = 0.000675 → 1 クレジットに切り上げ
assert calculate_credit_cost("sonnet", 1, 0) == 1
+def test_baseline_credits_per_chat() -> None:
+ """標準的な 1 回の消費(回数目安用)。Sonnet は概算 12 クレジット、Haiku は 0。"""
+ # 10000×675/1e6 + 1500×3375/1e6 = 6.75 + 5.0625 = 11.8125 → ceil 12
+ assert baseline_credits_per_chat("sonnet") == 12
+ assert baseline_credits_per_chat("haiku") == 0
+
+
def test_grant_credits_rejects_non_positive(db_session) -> None:
"""付与額 0 以下は ValueError(実質消費になる付与を防ぐ最終ガード)。"""
with pytest.raises(ValueError, match="付与額"):
@@ -147,16 +159,16 @@ def test_chat_sonnet_consumes_credits(client: TestClient, monkeypatch) -> None:
assert resp.status_code == 200
assert fake.received_model_id == "claude-sonnet-4-6"
repo = BillingRepository(client._db_session, user_id)
- # コスト: 1000×45,000/MTok + 1000×225,000/MTok = 270
- assert repo.get_balance() == 10_000 - 270
+ # コスト: 1000×675/MTok + 1000×3375/MTok = 4.05 → ceil 5(1cr=¥1)
+ assert repo.get_balance() == 10_000 - 5
transactions = repo.list_transactions()
consumption = [
t for t in transactions
if t.transaction_type == credit_service.TRANSACTION_TYPE_CONSUMPTION
]
assert len(consumption) == 1
- assert consumption[0].amount == -270
- assert consumption[0].balance_after == 10_000 - 270
+ assert consumption[0].amount == -5
+ assert consumption[0].balance_after == 10_000 - 5
def test_chat_sonnet_with_zero_balance_returns_402(client: TestClient, monkeypatch) -> None:
@@ -183,17 +195,18 @@ def test_chat_sonnet_allows_negative_balance(client: TestClient, monkeypatch) ->
monkeypatch.setattr(chat_service, "get_llm_client", lambda: fake)
headers = auth_header(client, "billing-negative")
user_id = _get_user_id(client, "billing-negative")
+ # 残高 3 で事前チェックは通るが、実コスト 5 が上回り負残高になる
credit_service.grant_credits(
client._db_session,
user_id,
- 10,
+ 3,
transaction_type=credit_service.TRANSACTION_TYPE_ADMIN_GRANT,
)
resp = client.post("/api/agent/chat", json=_chat_payload("sonnet"), headers=headers)
assert resp.status_code == 200
- assert BillingRepository(client._db_session, user_id).get_balance() == 10 - 270
+ assert BillingRepository(client._db_session, user_id).get_balance() == 3 - 5
def test_chat_sonnet_retry_usage_is_summed(client: TestClient, monkeypatch) -> None:
@@ -217,8 +230,8 @@ def test_chat_sonnet_retry_usage_is_summed(client: TestClient, monkeypatch) -> N
assert resp.status_code == 200
assert len(fake.calls) == 2
- # 2 呼び出し合算: (2000×45,000 + 2000×225,000) / 1e6 = 540
- assert BillingRepository(client._db_session, user_id).get_balance() == 10_000 - 540
+ # 2 呼び出し合算 2000/2000 トークン: (2000×675 + 2000×3375)/1e6 = 8.1 → ceil 9
+ assert BillingRepository(client._db_session, user_id).get_balance() == 10_000 - 9
def test_chat_rejects_unknown_model_alias(client: TestClient, monkeypatch) -> None:
@@ -290,8 +303,8 @@ def test_list_transactions_returns_history(client: TestClient, monkeypatch) -> N
assert types == {"admin_grant", "consumption"}
# 台帳エントリは金額と適用後残高のスナップショットを持つ
consumption = next(e for e in body if e["transaction_type"] == "consumption")
- assert consumption["amount"] == -270
- assert consumption["balance_after"] == 1_000 - 270
+ assert consumption["amount"] == -5
+ assert consumption["balance_after"] == 1_000 - 5
# --- 統合テスト(使用量サマリ) ---
@@ -329,7 +342,7 @@ def test_usage_summary_aggregates_per_model(client: TestClient, monkeypatch) ->
assert by_model["haiku"]["output_tokens"] == 400
assert by_model["haiku"]["credit_cost"] == 0
assert by_model["sonnet"]["chat_count"] == 1
- assert by_model["sonnet"]["credit_cost"] == 270
+ assert by_model["sonnet"]["credit_cost"] == 5
def test_usage_summary_empty_when_no_usage(client: TestClient) -> None:
@@ -359,7 +372,26 @@ def test_list_credit_packs(client: TestClient) -> None:
assert ids == ["starter", "standard", "pro"]
starter = packs[0]
assert starter["price_jpy"] == 500
- assert starter["credits"] == 30_000
+ # 1 クレジット = ¥1。スターターは等価
+ assert starter["credits"] == 500
+
+
+def test_list_model_rates(client: TestClient) -> None:
+ """GET /api/billing/model-rates はモデル別の標準消費レートを返す。"""
+ auth_header(client, "billing-rates")
+ resp = client.get("/api/billing/model-rates")
+ assert resp.status_code == 200
+ by_model = {entry["model"]: entry for entry in resp.json()}
+ assert by_model["haiku"]["is_free"] is True
+ assert by_model["haiku"]["baseline_credits_per_chat"] == 0
+ assert by_model["sonnet"]["is_free"] is False
+ assert by_model["sonnet"]["baseline_credits_per_chat"] == 12
+
+
+def test_list_model_rates_requires_auth(client: TestClient) -> None:
+ """未ログインのレート取得は 401。"""
+ resp = client.get("/api/billing/model-rates")
+ assert resp.status_code == 401
def test_list_credit_packs_requires_auth(client: TestClient) -> None:
diff --git a/frontend/e2e/agent-chat.spec.ts b/frontend/e2e/agent-chat.spec.ts
index 04352c50..b56621c3 100644
--- a/frontend/e2e/agent-chat.spec.ts
+++ b/frontend/e2e/agent-chat.spec.ts
@@ -132,8 +132,9 @@ test.describe("Agent チャットウィジェット", () => {
await expect(sidebar.getByText("12,000")).toBeVisible();
// UserMenu → モデル選択モーダル → Sonnet カードで切り替え
+ // (残高バッジの「Sonnet 約N回」と区別するため使用モデル値を厳密一致で確認)
await selectModel(page, "Sonnet");
- await expect(sidebar.getByText("Sonnet")).toBeVisible();
+ await expect(sidebar.getByText("Sonnet", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "devforge Agent" }).click();
await page
diff --git a/frontend/e2e/billing.spec.ts b/frontend/e2e/billing.spec.ts
index d1711c5b..62b58995 100644
--- a/frontend/e2e/billing.spec.ts
+++ b/frontend/e2e/billing.spec.ts
@@ -20,9 +20,9 @@ async function setupBillingApi(page: Page) {
status: 200,
contentType: "application/json",
body: JSON.stringify([
- { id: "starter", name: "スターター", price_jpy: 500, credits: 30000 },
- { id: "standard", name: "スタンダード", price_jpy: 1000, credits: 65000 },
- { id: "pro", name: "プロ", price_jpy: 3000, credits: 210000 },
+ { id: "starter", name: "スターター", price_jpy: 500, credits: 500 },
+ { id: "standard", name: "スタンダード", price_jpy: 1000, credits: 1100 },
+ { id: "pro", name: "プロ", price_jpy: 3000, credits: 3500 },
]),
}),
);
@@ -67,9 +67,10 @@ test("UserMenu からトークン購入画面を開き、残高・パック・
const main = page.locator("main");
await expect(main.getByText("現在の残高")).toBeVisible();
await expect(main.getByText("12,000")).toBeVisible();
- // パック
+ // パック(回数アンカー付き。残高12,000 / Sonnet 標準12クレジット → 約1,000回)
await expect(main.getByText("スターター")).toBeVisible();
await expect(main.getByText("¥500")).toBeVisible();
+ await expect(main.getByText(/Sonnet 約1,000回/)).toBeVisible();
// 履歴
await expect(main.getByText("Agent チャット(sonnet)")).toBeVisible();
diff --git a/frontend/e2e/helpers/auth.ts b/frontend/e2e/helpers/auth.ts
index 69eddd6a..05e38c90 100644
--- a/frontend/e2e/helpers/auth.ts
+++ b/frontend/e2e/helpers/auth.ts
@@ -63,6 +63,18 @@ export async function setupAuth(page: Page) {
}),
);
+ // モデル別の標準消費レートをモック(サイドバー残高バッジ・モーダルが取得する / ADR-0012)
+ await page.route("**/api/billing/model-rates", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify([
+ { model: "haiku", is_free: true, baseline_credits_per_chat: 0 },
+ { model: "sonnet", is_free: false, baseline_credits_per_chat: 12 },
+ ]),
+ }),
+ );
+
// 認証 API をモック(最高優先)
await page.route("**/auth/me", (route) =>
route.fulfill({
diff --git a/frontend/src/api/billing.ts b/frontend/src/api/billing.ts
index 260c1ac9..e0017237 100644
--- a/frontend/src/api/billing.ts
+++ b/frontend/src/api/billing.ts
@@ -5,6 +5,7 @@ import type {
CreditBalanceResponse,
CreditPackResponse,
CreditTransactionResponse,
+ ModelRateEntry,
} from "./types";
/**
@@ -29,3 +30,8 @@ export function getAgentUsageSummary(): Promise {
export function getCreditPacks(): Promise {
return request(PATHS.billing.packs, { method: "GET" });
}
+
+/** モデル別の標準消費レート(回数目安用)を取得する。 */
+export function getModelRates(): Promise {
+ return request(PATHS.billing.modelRates, { method: "GET" });
+}
diff --git a/frontend/src/api/generated.ts b/frontend/src/api/generated.ts
index 3a28c48a..30cd2b9e 100644
--- a/frontend/src/api/generated.ts
+++ b/frontend/src/api/generated.ts
@@ -74,6 +74,30 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/billing/model-rates": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * List Model Rates
+ * @description モデル別の標準消費レート(回数目安の算出用 / ADR-0012)を返す。
+ *
+ * フロントは残高・パック・モデルカードを「Sonnet 約N回」に換算するのに使う。
+ * 利用実績のあるユーザーは usage-summary の実測平均を優先し、本値は新規ユーザーの
+ * フォールバックとして使う。
+ */
+ get: operations["list_model_rates_api_billing_model_rates_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/billing/packs": {
parameters: {
query?: never;
@@ -1577,6 +1601,21 @@ export interface components {
*/
sort_order: number;
};
+ /**
+ * ModelRateEntry
+ * @description モデル別の標準消費レート(回数目安の算出用 / ADR-0012)。
+ *
+ * 1 クレジット = ¥1。``baseline_credits_per_chat`` は標準的な 1 回の消費の概算で、
+ * フロントが残高・パックを「Sonnet 約N回」に換算するのに使う(無料モデルは 0)。
+ */
+ ModelRateEntry: {
+ /** Baseline Credits Per Chat */
+ baseline_credits_per_chat: number;
+ /** Is Free */
+ is_free: boolean;
+ /** Model */
+ model: string;
+ };
/**
* NotificationResponse
* @description 通知レスポンス。
@@ -2043,6 +2082,26 @@ export interface operations {
};
};
};
+ list_model_rates_api_billing_model_rates_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ModelRateEntry"][];
+ };
+ };
+ };
+ };
list_credit_packs_api_billing_packs_get: {
parameters: {
query?: never;
diff --git a/frontend/src/api/paths.ts b/frontend/src/api/paths.ts
index 820636f2..bbe8d6c6 100644
--- a/frontend/src/api/paths.ts
+++ b/frontend/src/api/paths.ts
@@ -35,6 +35,7 @@ export const PATHS = {
transactions: "/api/billing/transactions",
usageSummary: "/api/billing/usage-summary",
packs: "/api/billing/packs",
+ modelRates: "/api/billing/model-rates",
},
resumes: {
base: "/api/resumes",
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
index e94c0cf0..1e69e720 100644
--- a/frontend/src/api/types.ts
+++ b/frontend/src/api/types.ts
@@ -158,3 +158,6 @@ export type AgentUsageSummaryEntry = Schemas["AgentUsageSummaryEntry"];
/** 購入可能なクレジットパック。backend `schemas/billing.py:CreditPackResponse`。 */
export type CreditPackResponse = Schemas["CreditPackResponse"];
+
+/** モデル別の標準消費レート(回数目安用)。backend `schemas/billing.py:ModelRateEntry`。 */
+export type ModelRateEntry = Schemas["ModelRateEntry"];
diff --git a/frontend/src/components/agent/ModelSelectModal.test.tsx b/frontend/src/components/agent/ModelSelectModal.test.tsx
index 98698a98..4bde812f 100644
--- a/frontend/src/components/agent/ModelSelectModal.test.tsx
+++ b/frontend/src/components/agent/ModelSelectModal.test.tsx
@@ -12,11 +12,18 @@ import {
import { ModelSelectModal } from "./ModelSelectModal";
const getAgentUsageSummaryMock = vi.fn();
+const getModelRatesMock = vi.fn();
vi.mock("../../api/billing", () => ({
getAgentUsageSummary: (...args: unknown[]) => getAgentUsageSummaryMock(...args),
+ getModelRates: (...args: unknown[]) => getModelRatesMock(...args),
}));
+const DEFAULT_RATES = [
+ { model: "haiku", is_free: true, baseline_credits_per_chat: 0 },
+ { model: "sonnet", is_free: false, baseline_credits_per_chat: 12 },
+];
+
type Store = ReturnType;
function makeStore(model: "haiku" | "sonnet" = "haiku") {
@@ -33,6 +40,7 @@ function renderModal(opts: {
usage?: { model: string; chat_count: number; input_tokens: number; output_tokens: number; credit_cost: number }[];
}) {
getAgentUsageSummaryMock.mockResolvedValue(opts.usage ?? []);
+ getModelRatesMock.mockResolvedValue(DEFAULT_RATES);
const store = opts.store ?? makeStore();
const balanceValue: CreditBalanceContextValue = {
balance: opts.balance ?? null,
@@ -52,6 +60,8 @@ function renderModal(opts: {
beforeEach(() => {
getAgentUsageSummaryMock.mockReset();
getAgentUsageSummaryMock.mockResolvedValue([]);
+ getModelRatesMock.mockReset();
+ getModelRatesMock.mockResolvedValue(DEFAULT_RATES);
});
describe("ModelSelectModal", () => {
@@ -92,28 +102,40 @@ describe("ModelSelectModal", () => {
expect(within(sonnetCard).queryByText(/残高が不足/)).toBeNull();
});
- it("利用実績があると利用回数と『10,000クレジットで平均N回』の目安を表示する", async () => {
+ it("利用実績があると実測平均から『1,000クレジットで平均N回』を表示する", async () => {
renderModal({
balance: 2700,
usage: [
- { model: "sonnet", chat_count: 4, input_tokens: 4000, output_tokens: 4000, credit_cost: 1080 },
+ { model: "sonnet", chat_count: 4, input_tokens: 4000, output_tokens: 4000, credit_cost: 20 },
],
});
const sonnetCard = screen.getByRole("button", { name: /^Sonnet/ });
- // これまで 4 回・約 1,080 クレジット消費
+ // これまで 4 回・約 20 クレジット消費(実測平均 5/回)
await waitFor(() => {
expect(within(sonnetCard).getByText(/これまで 4 回/)).toBeTruthy();
});
- // 平均 270/回 → 10,000 クレジットで平均 37 回(残高には依存しない)
- expect(within(sonnetCard).getByText(/10,000 クレジットで平均 37 回/)).toBeTruthy();
+ // 平均 5/回 → 1,000 クレジットで平均 200 回(残高には依存しない)
+ expect(within(sonnetCard).getByText(/1,000 クレジットで平均 200 回/)).toBeTruthy();
+ });
+
+ it("利用実績が無くてもベースラインレートで平均回数を出す", async () => {
+ // usage 空・rates baseline sonnet=12 → 1,000 / 12 = 83
+ renderModal({ balance: 1000 });
+ const sonnetCard = screen.getByRole("button", { name: /^Sonnet/ });
+ await waitFor(() => {
+ expect(within(sonnetCard).getByText(/1,000 クレジットで平均 83 回/)).toBeTruthy();
+ });
+ // 利用実績のテキストは「まだ利用していません」
+ expect(within(sonnetCard).getByText("まだ利用していません")).toBeTruthy();
});
- it("利用実績が無いカードは『まだ利用していません』を表示する", async () => {
+ it("無料モデル(Haiku)には回数目安を出さない", async () => {
renderModal({ balance: 1000 });
const haikuCard = screen.getByRole("button", { name: /^Haiku/ });
await waitFor(() => {
expect(within(haikuCard).getByText("まだ利用していません")).toBeTruthy();
});
+ expect(within(haikuCard).queryByText(/クレジットで平均/)).toBeNull();
});
});
diff --git a/frontend/src/components/agent/ModelSelectModal.tsx b/frontend/src/components/agent/ModelSelectModal.tsx
index b4abaa92..6c284221 100644
--- a/frontend/src/components/agent/ModelSelectModal.tsx
+++ b/frontend/src/components/agent/ModelSelectModal.tsx
@@ -6,6 +6,7 @@ import {
modelUsageLabel,
} from "../../constants/messages";
import { useAgentUsageSummary } from "../../hooks/useAgentUsageSummary";
+import { useModelRates } from "../../hooks/useModelRates";
import { useAppDispatch, useAppSelector } from "../../store";
import { setAgentModel } from "../../store/agentModelSlice";
import { useCreditBalanceContext } from "../billing/creditBalanceContext";
@@ -22,20 +23,26 @@ export function ModelSelectModal({ onClose }: { onClose: () => void }) {
const dispatch = useAppDispatch();
const currentModel = useAppSelector((state) => state.agentModel.model);
const { balance } = useCreditBalanceContext();
- // モーダルが開いている間だけ利用実績を取得する
+ // モーダルが開いている間だけ利用実績と標準レートを取得する
const { getUsage } = useAgentUsageSummary(true);
+ const { getBaselineRate } = useModelRates(true);
const select = (alias: AgentModelAlias) => {
dispatch(setAgentModel(alias));
onClose();
};
- /** 基準クレジット(10,000)あたりの平均利用回数の目安。残高には依存しない。
- * 平均消費 = 累計消費 / 回数。利用実績が無ければ算出しない。 */
- const estimatePerReference = (creditCost: number, chatCount: number): number | null => {
- if (chatCount <= 0 || creditCost <= 0) return null;
- const avgPerChat = creditCost / chatCount;
- return Math.floor(CREDIT_ESTIMATE_REFERENCE / avgPerChat);
+ /** 基準クレジット(1,000)あたりの平均利用回数の目安。残高には依存しない。
+ * 1 回あたりの消費 = 実績があれば実測平均(累計消費 / 回数)、無ければ標準レート。 */
+ const estimatePerReference = (
+ alias: AgentModelAlias,
+ creditCost: number,
+ chatCount: number,
+ ): number | null => {
+ const perChat =
+ chatCount > 0 && creditCost > 0 ? creditCost / chatCount : getBaselineRate(alias);
+ if (!perChat || perChat <= 0) return null;
+ return Math.floor(CREDIT_ESTIMATE_REFERENCE / perChat);
};
return (
@@ -67,7 +74,7 @@ export function ModelSelectModal({ onClose }: { onClose: () => void }) {
const chatCount = usage?.chat_count ?? 0;
const creditCost = usage?.credit_cost ?? 0;
const perReference = option.isPaid
- ? estimatePerReference(creditCost, chatCount)
+ ? estimatePerReference(option.alias, creditCost, chatCount)
: null;
return (