Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 1 addition & 41 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,41 +1 @@
# DevForge - AI エージェント向けガイド(Codex 用エントリポイント)

このファイルは Codex など `AGENTS.md` を読む AI エージェント向けのエントリポイント。
Claude Code は `.claude/CLAUDE.md` を自動で読むが、**Codex は CLAUDE.md やパス連動の rule 自動ロードを行わない**ため、ここに読むべきファイルを明示する。

## 最初に必ず読む

- [.claude/CLAUDE.md](./.claude/CLAUDE.md) — 全体ルールの索引(実行方法・コーディング規約・コミット/PR フロー・命名・環境変数・ADR)。**作業前に必読。**

## 作業領域に応じて読む(該当するものを必ず読む)

Claude Code では対象パス編集時に自動ロードされる領域別ルール。Codex は自動ロードされないので、触る領域のものを手動で開くこと。

### 共通(全領域)

- [.claude/rules/common/duplication.md](./.claude/rules/common/duplication.md) — DRY / コード重複ポリシー(Rule of Three・抽出先ヒエラルキー)
- [.claude/rules/security.md](./.claude/rules/security.md) — セキュリティ規約(秘密情報・入力検証・認可・IAM 等)

### backend(`backend/` を触るとき)

- [.claude/rules/backend/architecture.md](./.claude/rules/backend/architecture.md) — ディレクトリ構成・責務分離
- [.claude/rules/backend/python.md](./.claude/rules/backend/python.md) — Python コーディング規約
- [.claude/rules/backend/database.md](./.claude/rules/backend/database.md) — DB / マイグレーション
- [.claude/rules/backend/auth-security.md](./.claude/rules/backend/auth-security.md) — 認証・認可
- [.claude/rules/backend/test.md](./.claude/rules/backend/test.md) — テスト方針

### web(`web/` を触るとき)

- [.claude/rules/web/architecture.md](./.claude/rules/web/architecture.md) — ディレクトリ構成・責務分離
- [.claude/rules/web/typescript.md](./.claude/rules/web/typescript.md) — TypeScript コーディング規約
- [.claude/rules/web/messages.md](./.claude/rules/web/messages.md) — メッセージ管理(リテラル禁止・SSoT)
- [.claude/rules/web/test.md](./.claude/rules/web/test.md) — テスト方針

### infra(`infra/` を触るとき)

- [.claude/rules/infra/opentofu.md](./.claude/rules/infra/opentofu.md) — OpenTofu / modules / environments
- [.claude/rules/infra/test.md](./.claude/rules/infra/test.md) — infra validate / テスト

## メンテナンス

`.claude/rules/` にファイルを追加・削除したら、この一覧も更新すること(Claude Code 側はパス連動の自動ロードなので一覧不要だが、Codex 側はこの明示リストが正本)。
.claude/CLAUDE.md
18 changes: 11 additions & 7 deletions backend/app/core/env_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,19 +88,23 @@
LOG_FORMAT = "LOG_FORMAT"
LOG_LEVEL = "LOG_LEVEL"

# --- LLM(DevForge Agent / ADR-0010・ADR-0013) ---
# --- LLM(DevForge Agent / ADR-0010・ADR-0013・ADR-0015) ---

# ローカル Ollama 上書き("1"/"true"/"yes" で有効)。選択モデルに関わらず全リクエストを
# ローカル Ollama に通す無料パス。本番(Cloud Run)では未設定=無効。
# プロバイダ選択はモデルエイリアスに紐づくため、グローバルな LLM_PROVIDER は廃止(ADR-0013)
LLM_LOCAL_OLLAMA = "LLM_LOCAL_OLLAMA"
# Gemini / Anthropic は Vertex AI(Cloud Run の SA → ADC)経由で叩く(ADR-0015)。
# 認証は SA + GCP_PROJECT_ID(Cloud Tasks と共用)で行い、API キーは持たない。
# ロケーションは provider 別: Gemini=asia-northeast1、Claude=asia-southeast1(Tokyo に
# Claude が無いため)。infra(cloud_run)が plaintext env として注入する。
VERTEX_LOCATION = "VERTEX_LOCATION"
VERTEX_ANTHROPIC_LOCATION = "VERTEX_ANTHROPIC_LOCATION"
# OpenAI のみ GCP に存在しないため API キーを継続使用(ADR-0015)。
# 本番(Cloud Run)では Secret Manager から注入する。ログ出力禁止。
# 注: LLM API キー(ANTHROPIC/GOOGLE/OPENAI)はテストがプロバイダを _FakeLLM で
# モックするため CI(.github/workflows/ci.yml)には注入不要。env_keys の 5 箇所同期
# のうち ci.yml だけは意図的に対象外とする(実 API を CI から呼ばないため)。
ANTHROPIC_API_KEY = "ANTHROPIC_API_KEY"
# Google Gemini / OpenAI GPT の API キー(ADR-0013)。Secret Manager 注入・ログ出力禁止
GOOGLE_API_KEY = "GOOGLE_API_KEY"
# 注: OPENAI_API_KEY はテストがプロバイダを _FakeLLM でモックするため CI
# (.github/workflows/ci.yml)には注入不要。env_keys の 5 箇所同期のうち ci.yml
# だけは意図的に対象外とする(実 API を CI から呼ばないため)。
OPENAI_API_KEY = "OPENAI_API_KEY"
OLLAMA_BASE_URL = "OLLAMA_BASE_URL"
OLLAMA_MODEL = "OLLAMA_MODEL"
Expand Down
23 changes: 17 additions & 6 deletions backend/app/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,14 +180,25 @@ def use_local_ollama() -> bool:
return os.getenv(env_keys.LLM_LOCAL_OLLAMA, "").strip().lower() in {"1", "true", "yes"}


def get_anthropic_api_key() -> str:
"""Anthropic API キーを取得する。値はログや例外メッセージに含めないこと。"""
return os.getenv(env_keys.ANTHROPIC_API_KEY, "").strip()
def get_gcp_project_id() -> str:
"""Vertex AI(Gemini / Anthropic)が使う GCP プロジェクト ID を取得する(ADR-0015)。

Cloud Tasks と同じ ``GCP_PROJECT_ID`` を再利用する。Cloud Run へは infra が注入する。
"""
return os.getenv(env_keys.GCP_PROJECT_ID, "").strip()


def get_vertex_location() -> str:
"""Gemini を叩く Vertex AI のロケーションを取得する(既定: asia-northeast1 / ADR-0015)。"""
return os.getenv(env_keys.VERTEX_LOCATION, "asia-northeast1").strip()

def get_google_api_key() -> str:
"""Google Generative AI(Gemini)API キーを取得する。値はログに含めないこと。"""
return os.getenv(env_keys.GOOGLE_API_KEY, "").strip()

def get_vertex_anthropic_location() -> str:
"""Claude を叩く Vertex AI のロケーションを取得する(既定: asia-southeast1 / ADR-0015)。

Claude は Tokyo(asia-northeast1)で提供されないため、最寄りの Singapore を既定にする。
"""
return os.getenv(env_keys.VERTEX_ANTHROPIC_LOCATION, "asia-southeast1").strip()


def get_openai_api_key() -> str:
Expand Down
20 changes: 14 additions & 6 deletions backend/app/services/agent/llm/anthropic_client.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
"""Anthropic API クライアント(本番用。モデルは model_catalog.py で解決)。"""
"""Anthropic API クライアント(本番用。モデルは model_catalog.py で解決)。

認証は Vertex AI Model Garden(Cloud Run の SA → ADC)経由で、API キーは使わない
(ADR-0015)。Messages API のロジック(tool use 強制 / usage 抽出)は Vertex
クライアントでも共通のため `generate()` は据え置く。
"""

import json

import anthropic
from anthropic import AsyncAnthropicVertex

from ....core import settings
from ..output_schema import TOOL_NAME, build_tool_definition
from .base import LLMClient, LLMError, LLMResult, require_api_key, wrap_api_error
from .base import LLMClient, LLMError, LLMResult, require_gcp_project, wrap_api_error

# operations JSON(最大 4500 文字のテキスト置換 + 説明文)に十分な上限
_MAX_TOKENS = 4096
Expand All @@ -19,10 +25,12 @@ class AnthropicClient(LLMClient):
"""Anthropic Messages API を呼び出すクライアント。"""

def __init__(self) -> None:
"""ANTHROPIC_API_KEY を検証し、非同期クライアントを初期化する。"""
api_key = require_api_key(settings.get_anthropic_api_key(), "ANTHROPIC_API_KEY")
self._client = anthropic.AsyncAnthropic(
api_key=api_key, timeout=_TIMEOUT_SECONDS
"""GCP プロジェクトを検証し、Vertex AI 経由の非同期クライアントを初期化する。"""
project = require_gcp_project(settings.get_gcp_project_id())
self._client = AsyncAnthropicVertex(
project_id=project,
region=settings.get_vertex_anthropic_location(),
timeout=_TIMEOUT_SECONDS,
)

async def generate(
Expand Down
12 changes: 12 additions & 0 deletions backend/app/services/agent/llm/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,18 @@ def require_api_key(value: str, label: str) -> str:
return value


def require_gcp_project(value: str) -> str:
"""Vertex AI クライアント(Gemini / Anthropic)の GCP プロジェクト ID を検証する。

Vertex 経由のプロバイダは API キーの代わりに SA(ADC) + プロジェクト ID で認証する
(ADR-0015)。空なら LLMError、非空ならそのまま返す。ローカルは LLM_LOCAL_OLLAMA で
Vertex クライアントを生成しないため、この検証は本番(Cloud Run)経路でのみ効く。
"""
if not value:
raise LLMError("GCP_PROJECT_ID が設定されていません")
return value


def wrap_api_error(provider: str, exc: Exception) -> "LLMError":
"""プロバイダ SDK 例外を LLMError へ変換する(ログ + 整形)。

Expand Down
13 changes: 8 additions & 5 deletions backend/app/services/agent/llm/google_client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Google Generative AI(Gemini)クライアント(ADR-0013)。
"""Google Generative AI(Gemini)クライアント(ADR-0013 / Vertex 化は ADR-0015)。

モデルは model_catalog.py で解決した実 ID(gemini-2.5-flash / gemini-2.5-pro)を使う。
認証は Vertex AI(Cloud Run の SA → ADC)経由で、API キーは使わない(ADR-0015)。
構造化出力は ``response_mime_type=application/json`` + ``response_schema``(controlled
generation)で強制する。Anthropic の tool use とは機構が異なるが、応答テキストは同じく
スキーマに従う JSON のシリアライズ文字列として返す。
Expand All @@ -12,7 +13,7 @@

from ....core import settings
from ..output_schema import to_portable_schema
from .base import LLMClient, LLMResult, require_api_key, require_text, wrap_api_error
from .base import LLMClient, LLMResult, require_gcp_project, require_text, wrap_api_error

# 職務経歴書の改善提案は事実忠実性が最優先のため低温度に固定する
_TEMPERATURE = 0.2
Expand All @@ -23,10 +24,12 @@ class GoogleClient(LLMClient):
"""Gemini API(google-genai SDK)を呼び出すクライアント。"""

def __init__(self) -> None:
"""GOOGLE_API_KEY を検証し、非同期クライアントを初期化する。"""
api_key = require_api_key(settings.get_google_api_key(), "GOOGLE_API_KEY")
"""GCP プロジェクトを検証し、Vertex AI 経由の非同期クライアントを初期化する。"""
project = require_gcp_project(settings.get_gcp_project_id())
self._client = genai.Client(
api_key=api_key,
vertexai=True,
project=project,
location=settings.get_vertex_location(),
# HttpOptions.timeout はミリ秒単位(google-genai 仕様)。60s = 60000ms
http_options=genai_types.HttpOptions(timeout=int(_TIMEOUT_SECONDS * 1000)),
)
Expand Down
4 changes: 3 additions & 1 deletion backend/app/services/agent/model_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ class ModelSpec:
MODEL_CATALOG: dict[str, ModelSpec] = {
"haiku": ModelSpec(
provider=PROVIDER_ANTHROPIC,
model_id="claude-haiku-4-5",
# Vertex AI の Anthropic model id は版指定が要る(@日付)。Sonnet 4.6 は版指定
# 不要だが Haiku 4.5 は @20251001 が必須(ADR-0015 / Vertex Model Garden 正本)
model_id="claude-haiku-4-5@20251001",
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),
Expand Down
4 changes: 3 additions & 1 deletion backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ pytest==9.1.1
python-jose[cryptography]==3.5.0
httpx==0.28.1
# マルチプロバイダ LLM(ADR-0013)。Gemini = google-genai、GPT = openai。
# Gemini / Anthropic は Vertex AI(SA→ADC)経由(ADR-0015)。anthropic[vertex] の
# extra が ADC 用の google-auth を引き込む。google-genai は vertexai=True で Vertex 対応。
# 供給網リスク(レンジ内 yank / 侵害バージョンの混入)を避けるため == で固定する。
# CVE は CI の pip-audit が検出するので、検出時に明示バージョンを引き上げる運用とする。
anthropic==0.111.0
anthropic[vertex]==0.111.0
google-genai==2.9.0
openai==2.43.0
# Stripe Checkout / Webhook 署名検証(ADR-0012 Phase 2)。公式ライブラリを使う
Expand Down
5 changes: 3 additions & 2 deletions backend/tests/test_billing.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ def test_model_catalog_matches_schema_alias() -> None:

def test_model_catalog_resolves_real_model_ids() -> None:
"""エイリアスから実モデル ID と無料/有料フラグが解決できる。"""
assert MODEL_CATALOG["haiku"].model_id == "claude-haiku-4-5"
# Vertex AI の Anthropic model id は版指定が要る(Haiku は @20251001 必須 / ADR-0015)
assert MODEL_CATALOG["haiku"].model_id == "claude-haiku-4-5@20251001"
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
Expand Down Expand Up @@ -177,7 +178,7 @@ def test_chat_haiku_works_with_zero_balance(client: TestClient, monkeypatch) ->
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"
assert fake.received_model_id == "claude-haiku-4-5@20251001"
user_id = _get_user_id(client, "billing-haiku")
repo = BillingRepository(client._db_session, user_id)
assert repo.get_balance() == 0
Expand Down
36 changes: 24 additions & 12 deletions backend/tests/test_llm_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,13 @@ def _patch_google(monkeypatch, *, response=None, error=None) -> MagicMock:
"""google.genai.Client をモックし、aio.models.generate_content を差し替える。"""
from app.services.agent.llm import google_client

monkeypatch.setattr(google_client.settings, "get_google_api_key", lambda: "test-key")
# Vertex 化(ADR-0015)後はキーではなく GCP プロジェクト + ロケーションで初期化する
monkeypatch.setattr(
google_client.settings, "get_gcp_project_id", lambda: "test-project"
)
monkeypatch.setattr(
google_client.settings, "get_vertex_location", lambda: "asia-northeast1"
)
gen = AsyncMock(side_effect=error) if error else AsyncMock(return_value=response)
fake_client = MagicMock()
fake_client.aio.models.generate_content = gen
Expand Down Expand Up @@ -112,13 +118,13 @@ def test_google_client_returns_text_and_usage(monkeypatch) -> None:
assert "additionalProperties" not in str(config.response_schema)


def test_google_client_missing_key_raises(monkeypatch) -> None:
"""GOOGLE_API_KEY 未設定は LLMError。"""
def test_google_client_missing_project_raises(monkeypatch) -> None:
"""GCP_PROJECT_ID 未設定は LLMError(Vertex 認証の前提が無い / ADR-0015)。"""
from app.services.agent.llm import google_client
from app.services.agent.llm.google_client import GoogleClient

monkeypatch.setattr(google_client.settings, "get_google_api_key", lambda: "")
with pytest.raises(LLMError, match="GOOGLE_API_KEY"):
monkeypatch.setattr(google_client.settings, "get_gcp_project_id", lambda: "")
with pytest.raises(LLMError, match="GCP_PROJECT_ID"):
GoogleClient()


Expand Down Expand Up @@ -220,17 +226,23 @@ def test_openai_client_empty_response_raises(monkeypatch) -> None:


def _patch_anthropic(monkeypatch, *, response=None, error=None) -> MagicMock:
"""anthropic.AsyncAnthropic をモックし、messages.create を差し替える。"""
"""anthropic.AsyncAnthropicVertex をモックし、messages.create を差し替える。"""
from app.services.agent.llm import anthropic_client

# Vertex 化(ADR-0015)後はキーではなく GCP プロジェクト + ロケーションで初期化する
monkeypatch.setattr(
anthropic_client.settings, "get_gcp_project_id", lambda: "test-project"
)
monkeypatch.setattr(
anthropic_client.settings, "get_anthropic_api_key", lambda: "test-key"
anthropic_client.settings,
"get_vertex_anthropic_location",
lambda: "asia-southeast1",
)
create = AsyncMock(side_effect=error) if error else AsyncMock(return_value=response)
fake_client = MagicMock()
fake_client.messages.create = create
monkeypatch.setattr(
anthropic_client.anthropic, "AsyncAnthropic", lambda **kwargs: fake_client
anthropic_client, "AsyncAnthropicVertex", lambda **kwargs: fake_client
)
return create

Expand Down Expand Up @@ -258,15 +270,15 @@ def test_anthropic_client_returns_text_and_usage(monkeypatch) -> None:
assert json.loads(result.text) == block.input


def test_anthropic_client_missing_key_raises(monkeypatch) -> None:
"""ANTHROPIC_API_KEY 未設定は LLMError。"""
def test_anthropic_client_missing_project_raises(monkeypatch) -> None:
"""GCP_PROJECT_ID 未設定は LLMError(Vertex 認証の前提が無い / ADR-0015)。"""
from app.services.agent.llm import anthropic_client
from app.services.agent.llm.anthropic_client import AnthropicClient

monkeypatch.setattr(
anthropic_client.settings, "get_anthropic_api_key", lambda: ""
anthropic_client.settings, "get_gcp_project_id", lambda: ""
)
with pytest.raises(LLMError, match="ANTHROPIC_API_KEY"):
with pytest.raises(LLMError, match="GCP_PROJECT_ID"):
AnthropicClient()


Expand Down
10 changes: 7 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,14 @@ services:
CALLBACK_BASE_URL: ${CALLBACK_BASE_URL}
TASK_MAX_ATTEMPTS: ${TASK_MAX_ATTEMPTS}
# DevForge Agent(ADR-0010)。ローカルは Ollama を既定とする
# ローカルは Ollama 上書きで無料動作(ADR-0013)。実 API で確認したい時は 0 にしてキーを設定
# ローカルは Ollama 上書きで無料動作(ADR-0013)。Gemini/Anthropic は本番では
# Vertex AI(SA→ADC)経由(ADR-0015)でキー不要。ローカルで実 Vertex を叩く場合のみ
# gcloud auth application-default login で ADC を用意する(GCP_PROJECT_ID は上で設定済み)。
LLM_LOCAL_OLLAMA: ${LLM_LOCAL_OLLAMA:-1}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
GOOGLE_API_KEY: ${GOOGLE_API_KEY}
# 既定値を付ける(:-)。bare の ${VAR} は未設定時に空文字を注入し、backend 側
# settings.py の os.getenv フォールバックを潰してしまう(Vertex client が空ロケーションで初期化される)
VERTEX_LOCATION: ${VERTEX_LOCATION:-asia-northeast1}
VERTEX_ANTHROPIC_LOCATION: ${VERTEX_ANTHROPIC_LOCATION:-asia-southeast1}
OPENAI_API_KEY: ${OPENAI_API_KEY}
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL}
OLLAMA_MODEL: ${OLLAMA_MODEL}
Expand Down
Loading
Loading