Skip to content

Commit c51e59f

Browse files
Copilotnotfolder
andauthored
Implement user management REST API for multi-user LLM configuration (#33)
* Initial plan * Implement user management API server and client integration Co-authored-by: notfolder <20558197+notfolder@users.noreply.github.com> * Add testing documentation for user config API Co-authored-by: notfolder <20558197+notfolder@users.noreply.github.com> * Address code review feedback: use lifespan and app.state, move imports to top Co-authored-by: notfolder <20558197+notfolder@users.noreply.github.com> * Add comprehensive README for user_config_api Co-authored-by: notfolder <20558197+notfolder@users.noreply.github.com> * Use task author username instead of config owner for API calls Modified _fetch_config_from_api to accept username parameter and added fetch_user_config function to extract username from issue/PR/MR author. TaskHandler now fetches per-user config before processing each task. Co-authored-by: notfolder <20558197+notfolder@users.noreply.github.com> * 実際に動かして修正 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: notfolder <20558197+notfolder@users.noreply.github.com> Co-authored-by: notfolder <notfolder@gmail.com>
1 parent 753df68 commit c51e59f

14 files changed

Lines changed: 833 additions & 12 deletions

config.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ mcp_servers:
2424
- "-m"
2525
- "mcp_server_fetch"
2626

27+
# APIサーバー設定
28+
api_server:
29+
api_key: "your-secret-api-key-here"
30+
2731
llm:
2832
provider: "openai" # "ollama" | "openai"
2933
function_calling: true

docker-compose.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
version: '3.6'
22
services:
3+
user-config-api:
4+
build: ./user_config_api
5+
container_name: user-config-api
6+
# Docker内部ネットワークのみで使用(外部公開不要)
7+
# テスト用に外部アクセスが必要な場合のみ、以下のコメントを外す
8+
ports:
9+
- "8081:8080"
10+
environment:
11+
API_SERVER_KEY: ${API_SERVER_KEY:-your-secret-api-key-here}
12+
313
web:
414
image: 'gitlab/gitlab-ce:latest'
515
restart: always

handlers/task.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,12 @@ def get_task_key(self) -> "TaskKey":
7979
タスクの一意なキーオブジェクト
8080
8181
"""
82+
83+
@abstractmethod
84+
def get_user(self) -> str | None:
85+
"""タスクの作成者のユーザー名を取得する.
86+
87+
Returns:
88+
ユーザー名、取得できない場合はNone
89+
90+
"""

handlers/task_getter_github.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ def get_task_key(self) -> GitHubIssueTaskKey:
9999
def check(self) -> bool:
100100
return self.config["github"]["processing_label"] in self.labels
101101

102+
def get_user(self) -> str | None:
103+
"""Issueの作成者のユーザー名を取得する."""
104+
return self.issue.get("user", {}).get("login")
105+
102106

103107
class TaskGitHubPullRequest(Task):
104108
def __init__(
@@ -177,6 +181,10 @@ def get_task_key(self) -> GitHubPullRequestTaskKey:
177181
def check(self) -> bool:
178182
return self.config["github"]["processing_label"] in self.labels
179183

184+
def get_user(self) -> str | None:
185+
"""Pull Requestの作成者のユーザー名を取得する."""
186+
return self.pr.get("user", {}).get("login")
187+
180188

181189
class TaskGetterFromGitHub(TaskGetter):
182190
def __init__(self, config: dict[str, Any], mcp_clients: dict[str, MCPToolClient]) -> None:

handlers/task_getter_gitlab.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ def get_task_key(self) -> GitLabIssueTaskKey:
8787
def check(self) -> bool:
8888
return self.config["gitlab"]["processing_label"] in self.issue.get("labels", [])
8989

90+
def get_user(self) -> str | None:
91+
"""Issueの作成者のユーザー名を取得する."""
92+
return self.issue.get("author", {}).get("username")
93+
9094

9195
class TaskGitLabMergeRequest(Task):
9296
def __init__(
@@ -163,6 +167,10 @@ def get_task_key(self) -> GitLabMergeRequestTaskKey:
163167
def check(self) -> bool:
164168
return self.config["gitlab"]["processing_label"] in self.labels
165169

170+
def get_user(self) -> str | None:
171+
"""Merge Requestの作成者のユーザー名を取得する."""
172+
return self.mr.get("author", {}).get("username")
173+
166174

167175
class TaskGetterFromGitLab(TaskGetter):
168176
def __init__(self, config: dict[str, Any], mcp_clients: dict[str, MCPToolClient]) -> None:

handlers/task_handler.py

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -94,12 +94,15 @@ def handle(self, task: Task) -> None:
9494
task: 処理対象のタスクオブジェクト
9595
9696
"""
97+
# タスク固有の設定を取得
98+
task_config = self._get_task_config(task)
99+
97100
# 初期設定
98-
self._setup_task_handling(task)
101+
self._setup_task_handling(task, task_config)
99102

100103
# 処理ループの初期化
101104
count = 0
102-
max_count = self.config.get("max_llm_process_num", 1000)
105+
max_count = task_config.get("max_llm_process_num", 1000)
103106

104107
# 連続ツールエラー管理用の状態
105108
error_state = {"last_tool": None, "tool_error_count": 0}
@@ -110,12 +113,47 @@ def handle(self, task: Task) -> None:
110113
break
111114
count += 1
112115

113-
def _setup_task_handling(self, task: Task) -> None:
114-
"""タスク処理の初期設定を行う."""
116+
def _get_task_config(self, task: Task) -> dict[str, Any]:
117+
"""タスクに応じた設定を取得する.
118+
119+
USE_USER_CONFIG_API環境変数がtrueの場合、タスクのユーザーに基づいて
120+
API経由で設定を取得します。
121+
122+
Args:
123+
task: タスクオブジェクト
124+
125+
Returns:
126+
タスク用の設定辞書
127+
"""
128+
import os
129+
130+
# API使用フラグをチェック
131+
use_api = os.environ.get("USE_USER_CONFIG_API", "false").lower() == "true"
132+
if not use_api:
133+
return self.config
134+
135+
# main.pyのfetch_user_configを使用
136+
try:
137+
from main import fetch_user_config
138+
return fetch_user_config(task, self.config)
139+
except Exception as e:
140+
self.logger.warning(f"ユーザー設定の取得に失敗しました: {e}。デフォルト設定を使用します。")
141+
return self.config
142+
143+
def _setup_task_handling(self, task: Task, task_config: dict[str, Any] | None = None) -> None:
144+
"""タスク処理の初期設定を行う.
145+
146+
Args:
147+
task: タスクオブジェクト
148+
task_config: タスク固有の設定(Noneの場合はself.configを使用)
149+
"""
150+
if task_config is None:
151+
task_config = self.config
152+
115153
prompt = task.get_prompt()
116154
self.logger.info("LLMに送信するプロンプト: %s", prompt)
117155

118-
self.llm_client.send_system_prompt(self._make_system_prompt())
156+
self.llm_client.send_system_prompt(self._make_system_prompt(task_config))
119157
self.llm_client.send_user_message(prompt)
120158

121159
def _process_llm_interaction(self, task: Task, count: int, error_state: dict) -> bool:
@@ -336,20 +374,26 @@ def _process_done_field(self, task: Task, data: dict) -> None:
336374

337375
def get_system_prompt(self) -> str:
338376
"""システムプロンプトを取得する(テスト用の公開メソッド)."""
339-
return self._make_system_prompt()
377+
return self._make_system_prompt(self.config)
340378

341-
def _make_system_prompt(self) -> str:
379+
def _make_system_prompt(self, task_config: dict[str, Any] | None = None) -> str:
342380
"""システムプロンプトを生成する.
343381
344382
設定に基づいてfunction callingの有無を判定し、
345383
適切なシステムプロンプトファイルを読み込んで、
346-
MCPプロンプトを埋め込んで返します。
384+
MCPプロンプトを埋め込んで返します.
385+
386+
Args:
387+
task_config: タスク固有の設定(Noneの場合はself.configを使用)
347388
348389
Returns:
349390
生成されたシステムプロンプト文字列
350391
351392
"""
352-
if self.config.get("llm", {}).get("function_calling", True):
393+
if task_config is None:
394+
task_config = self.config
395+
396+
if task_config.get("llm", {}).get("function_calling", True):
353397
# function callingが有効な場合
354398
with Path("system_prompt_function_call.txt").open() as f:
355399
prompt = f.read()

main.py

Lines changed: 126 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from pathlib import Path
1515
from typing import Any
1616

17+
import requests
1718
import yaml
1819

1920
from clients.lm_client import get_llm_client
@@ -24,6 +25,7 @@
2425
from queueing import InMemoryTaskQueue, RabbitMQTaskQueue
2526

2627

28+
2729
def setup_logger() -> None:
2830
"""ログ設定を初期化する.
2931
@@ -50,6 +52,8 @@ def load_config(config_file: str = "config.yaml") -> dict[str, Any]:
5052
5153
指定された設定ファイルを読み込み、環境変数で定義された値で
5254
設定を上書きします。LLM、MCP、RabbitMQ等の設定が対象です。
55+
56+
USE_USER_CONFIG_API環境変数がtrueの場合、API経由で設定を取得します。
5357
5458
Args:
5559
config_file: 読み込む設定ファイルのパス
@@ -62,19 +66,138 @@ def load_config(config_file: str = "config.yaml") -> dict[str, Any]:
6266
yaml.YAMLError: YAMLの解析に失敗した場合
6367
6468
"""
69+
# ロガー取得
70+
logger = logging.getLogger(__name__)
71+
6572
# 設定ファイルを読み込み
6673
with Path(config_file).open() as f:
6774
config = yaml.safe_load(f)
68-
69-
# 各種設定の上書き処理
70-
_override_llm_config(config)
75+
76+
# 環境変数による設定上書きを先に実行
7177
_override_mcp_config(config)
7278
_override_rabbitmq_config(config)
7379
_override_bot_config(config)
80+
81+
# API経由でLLM設定を取得するかチェック
82+
use_api = os.environ.get("USE_USER_CONFIG_API", "false").lower() == "true"
83+
84+
if use_api:
85+
try:
86+
config = _fetch_config_from_api(config, logger)
87+
except (requests.ConnectionError, requests.Timeout) as e:
88+
logger.exception(f"API接続エラー、設定ファイルを使用{e}")
89+
except (ValueError, requests.HTTPError) as e:
90+
logger.exception(f"API設定取得エラー、設定ファイルを使用{e}")
91+
except Exception as e:
92+
logger.exception(f"予期しないエラー、設定ファイルを使用{e}")
93+
94+
# LLM設定を環境変数で最終的に上書き
95+
_override_llm_config(config)
7496

7597
return config
7698

7799

100+
def _fetch_config_from_api(
101+
config: dict[str, Any], logger: logging.Logger, username: str | None = None
102+
) -> dict[str, Any]:
103+
"""API経由で設定を取得する.
104+
105+
Args:
106+
config: ベースとなる設定辞書
107+
logger: ロガー
108+
username: ユーザー名(Noneの場合はconfig.yamlから取得)
109+
110+
Returns:
111+
API設定でマージされた設定辞書
112+
113+
Raises:
114+
ValueError: 設定エラーまたはAPI呼び出しエラー
115+
"""
116+
# タスクソースを取得
117+
task_source = os.environ.get("TASK_SOURCE", "github")
118+
119+
# ユーザー名が指定されていない場合はconfig.yamlから取得
120+
if username is None:
121+
if task_source == "github":
122+
username = config.get("github", {}).get("owner", "")
123+
elif task_source == "gitlab":
124+
username = config.get("gitlab", {}).get("owner", "")
125+
else:
126+
raise ValueError(f"Unknown task source: {task_source}")
127+
128+
# APIエンドポイントとAPIキー
129+
api_url = os.environ.get("USER_CONFIG_API_URL", "http://user-config-api:8080")
130+
api_key = os.environ.get("USER_CONFIG_API_KEY", "")
131+
132+
if not api_key:
133+
raise ValueError("USER_CONFIG_API_KEY is not set")
134+
135+
url = f"{api_url}/config/{task_source}/{username}"
136+
137+
# Bearer トークンとしてAPIキーをヘッダーに含めて呼び出し
138+
headers = {"Authorization": f"Bearer {api_key}"}
139+
response = requests.get(url, headers=headers, timeout=5)
140+
response.raise_for_status()
141+
142+
data = response.json()
143+
if data.get("status") == "success":
144+
# API設定を取得
145+
api_data = data["data"]
146+
147+
# LLM設定を上書き
148+
config["llm"] = api_data["llm"]
149+
150+
# システムプロンプトを上書き(環境変数で上書きされていない場合のみ)
151+
if "system_prompt" in api_data:
152+
config["system_prompt"] = api_data["system_prompt"]
153+
154+
logger.info(f"API経由でLLM設定を取得: {task_source}:{username}")
155+
else:
156+
raise ValueError(f"API returned error: {data.get('message')}")
157+
158+
return config
159+
160+
161+
def fetch_user_config(task: Any, base_config: dict[str, Any]) -> dict[str, Any]:
162+
"""タスクのユーザーに基づいて設定を取得する.
163+
164+
USE_USER_CONFIG_API環境変数がtrueの場合、タスクの作成者のユーザー名を使用して
165+
API経由で設定を取得します。そうでない場合はbase_configをそのまま返します。
166+
167+
Args:
168+
task: タスクオブジェクト(issue/PR/MRの情報を含む)
169+
base_config: ベースとなる設定辞書
170+
171+
Returns:
172+
ユーザー設定でマージされた設定辞書
173+
"""
174+
# API使用フラグをチェック
175+
use_api = os.environ.get("USE_USER_CONFIG_API", "false").lower() == "true"
176+
if not use_api:
177+
return base_config
178+
179+
# タスクからユーザー名を取得
180+
try:
181+
username = task.get_user()
182+
183+
if not username:
184+
# ユーザー名が取得できない場合はベース設定を使用
185+
logger = logging.getLogger(__name__)
186+
logger.warning("タスクからユーザー名を取得できませんでした。デフォルト設定を使用します。")
187+
return base_config
188+
189+
# API経由で設定を取得
190+
logger = logging.getLogger(__name__)
191+
return _fetch_config_from_api(base_config.copy(), logger, username)
192+
193+
except (ValueError, TypeError, AttributeError) as e:
194+
# エラーが発生した場合はベース設定を使用
195+
logger = logging.getLogger(__name__)
196+
logger.warning("ユーザー設定の取得に失敗しました: %s。デフォルト設定を使用します。", e)
197+
return base_config
198+
199+
200+
78201
def _override_llm_config(config: dict[str, Any]) -> None:
79202
"""LLM設定を環境変数で上書きする."""
80203
# function_calling設定の処理

sample_api.env

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# ユーザー設定API設定の例
2+
# このファイルをコピーして .env として使用してください
3+
4+
# 既存の設定
5+
TASK_SOURCE=github
6+
GITHUB_PERSONAL_ACCESS_TOKEN=your_github_token_here
7+
8+
# ユーザー設定API設定(オプション)
9+
# API経由で設定を取得する場合はtrueに設定
10+
USE_USER_CONFIG_API=false
11+
12+
# ユーザー設定APIのURL(デフォルト: http://user-config-api:8080)
13+
USER_CONFIG_API_URL=http://user-config-api:8080
14+
15+
# ユーザー設定APIのAPIキー(USE_USER_CONFIG_API=trueの場合は必須)
16+
USER_CONFIG_API_KEY=your-secret-api-key
17+
18+
# APIサーバー用のAPIキー(環境変数で上書き可能)
19+
API_SERVER_KEY=your-secret-api-key-here

user_config_api/Dockerfile

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
FROM python:3.13-slim
2+
3+
WORKDIR /app
4+
5+
COPY requirements.txt .
6+
RUN pip install --no-cache-dir -r requirements.txt
7+
8+
COPY server.py .
9+
COPY config.yaml .
10+
11+
EXPOSE 8080
12+
13+
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8080"]

0 commit comments

Comments
 (0)