lab-cost-tracker 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 University of Auckland
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,254 @@
1
+ Metadata-Version: 2.4
2
+ Name: lab-cost-tracker
3
+ Version: 0.1.0
4
+ Summary: Lightweight API cost tracker for research labs
5
+ License: MIT
6
+ Project-URL: Repository, https://github.com/your-org/lab-cost-tracker
7
+ Keywords: llm,cost-tracking,openai,gemini,anthropic
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Science/Research
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: pyyaml>=6.0
17
+ Requires-Dist: requests>=2.31
18
+ Provides-Extra: openai
19
+ Requires-Dist: openai>=1.0; extra == "openai"
20
+ Provides-Extra: gemini
21
+ Requires-Dist: google-genai>=1.0; extra == "gemini"
22
+ Provides-Extra: anthropic
23
+ Requires-Dist: anthropic>=0.30; extra == "anthropic"
24
+ Provides-Extra: google
25
+ Requires-Dist: google-auth>=2.0; extra == "google"
26
+ Requires-Dist: google-auth-oauthlib>=1.0; extra == "google"
27
+ Provides-Extra: all
28
+ Requires-Dist: openai>=1.0; extra == "all"
29
+ Requires-Dist: google-genai>=1.0; extra == "all"
30
+ Requires-Dist: anthropic>=0.30; extra == "all"
31
+ Provides-Extra: dev
32
+ Requires-Dist: pytest>=7.0; extra == "dev"
33
+ Requires-Dist: pytest-mock; extra == "dev"
34
+ Dynamic: license-file
35
+
36
+ # Lab Cost Tracker SDK
37
+
38
+ 轻量级 Python SDK,为研究组透明追踪 LLM API 调用费用。一行 `tracker.wrap()` 包裹现有 client,自动记录每次调用的模型、token 数和费用。
39
+
40
+ ## 支持的 Provider
41
+
42
+ | Provider | Client 类型 | 拦截方法 |
43
+ |----------|------------|---------|
44
+ | OpenAI 官方 | `openai.OpenAI` | `chat.completions.create()` |
45
+ | Google Gemini | `google.genai.Client` | `models.generate_content()` |
46
+ | Anthropic | `anthropic.Anthropic` | `messages.create()` |
47
+ | 第三方代理 | `openai.OpenAI(base_url=...)` | 同 OpenAI(自动记录 `base_url`) |
48
+
49
+ ## 安装
50
+
51
+ ```bash
52
+ # 基础安装(仅需 pyyaml + requests)
53
+ pip install -e .
54
+
55
+ # 安装 OpenAI 支持
56
+ pip install -e ".[openai]"
57
+
58
+ # 安装 Google OAuth 支持(可选,用于身份验证)
59
+ pip install -e ".[google]"
60
+
61
+ # 全部安装
62
+ pip install -e ".[openai,google]"
63
+ ```
64
+
65
+ ## 快速开始
66
+
67
+ ### 1. OpenAI
68
+
69
+ ```python
70
+ from openai import OpenAI
71
+ from lab_cost_tracker import CostTracker
72
+
73
+ tracker = CostTracker(
74
+ project="DentalVLM",
75
+ user="kyle@aucklanduni.ac.nz",
76
+ storage_dir="./.cost-tracker", # 费用数据保存目录(默认值,可省略)
77
+ )
78
+
79
+ client = tracker.wrap(OpenAI(api_key="sk-..."))
80
+
81
+ # 正常使用,费用自动记录
82
+ response = client.chat.completions.create(
83
+ model="gpt-4o-mini",
84
+ messages=[{"role": "user", "content": "Hello!"}],
85
+ )
86
+
87
+ print(tracker.summary())
88
+ ```
89
+
90
+ ### 2. Google Gemini
91
+
92
+ ```python
93
+ from google import genai
94
+ from lab_cost_tracker import CostTracker
95
+
96
+ tracker = CostTracker(project="DentalVLM", user="kyle@aucklanduni.ac.nz")
97
+
98
+ client = tracker.wrap(genai.Client(api_key="..."))
99
+
100
+ response = client.models.generate_content(
101
+ model="gemini-2.5-flash",
102
+ contents="用一句话介绍新西兰奥克兰大学。",
103
+ )
104
+
105
+ print(tracker.summary())
106
+ ```
107
+
108
+ ### 3. Anthropic
109
+
110
+ ```python
111
+ import anthropic
112
+ from lab_cost_tracker import CostTracker
113
+
114
+ tracker = CostTracker(project="DentalVLM", user="kyle@aucklanduni.ac.nz")
115
+
116
+ client = tracker.wrap(anthropic.Anthropic(api_key="..."))
117
+
118
+ response = client.messages.create(
119
+ model="claude-3-5-sonnet-20241022",
120
+ max_tokens=1024,
121
+ messages=[{"role": "user", "content": "Hello!"}],
122
+ )
123
+
124
+ print(tracker.summary())
125
+ ```
126
+
127
+ ### 4. 第三方代理(如 apiyihe.org)
128
+
129
+ ```python
130
+ from openai import OpenAI
131
+ from lab_cost_tracker import CostTracker
132
+
133
+ tracker = CostTracker(project="DentalVLM", user="kyle@aucklanduni.ac.nz")
134
+
135
+ # 使用 OpenAI SDK + 自定义 base_url
136
+ client = tracker.wrap(OpenAI(
137
+ api_key="...",
138
+ base_url="https://z.apiyihe.org/v1",
139
+ ))
140
+
141
+ # 可以调用任何模型,base_url 会自动记录在 detail.jsonl 中
142
+ response = client.chat.completions.create(
143
+ model="gemini-2.5-flash-lite-preview-06-17",
144
+ messages=[{"role": "user", "content": "Hello!"}],
145
+ )
146
+ ```
147
+
148
+ ### 5. 手动记录(不支持的 Provider)
149
+
150
+ ```python
151
+ tracker.record(
152
+ model="llama-3-8b",
153
+ prompt_tokens=1000,
154
+ completion_tokens=500,
155
+ task="inference",
156
+ dataset="DentalBench",
157
+ )
158
+ ```
159
+
160
+ ## 数据存储
161
+
162
+ 运行后会在 `storage_dir`(默认 `{项目根目录}/.cost-tracker/`)下生成两个文件:
163
+
164
+ ### `detail.jsonl` — 逐条调用记录
165
+
166
+ ```json
167
+ {"id": "42ae...", "timestamp": "2026-05-19T...", "project": "DentalVLM", "user": "kyle@aucklanduni.ac.nz", "model": "gpt-4o-mini-2024-07-18", "prompt_tokens": 19, "completion_tokens": 33, "cost_usd": 0.00059, "metadata": {"provider": "openai", "base_url": "https://api.openai.com/v1"}, "synced": false}
168
+ ```
169
+
170
+ ### `summary.json` — 聚合统计
171
+
172
+ ```json
173
+ {
174
+ "project": "DentalVLM",
175
+ "total_cost_usd": 0.012,
176
+ "total_requests": 5,
177
+ "by_model": {
178
+ "gpt-4o-mini": {"cost_usd": 0.0006, "requests": 2},
179
+ "gemini-2.5-flash": {"cost_usd": 0.0005, "requests": 1}
180
+ },
181
+ "by_user": {
182
+ "kyle@aucklanduni.ac.nz": {"cost_usd": 0.012, "requests": 5}
183
+ }
184
+ }
185
+ ```
186
+
187
+ ## 内置定价表
188
+
189
+ SDK 内置了常用模型的定价(USD / 1M tokens):
190
+
191
+ | 模型 | 输入价格 | 输出价格 |
192
+ |------|---------|---------|
193
+ | gpt-4o | $5.00 | $15.00 |
194
+ | gpt-4o-mini | $0.15 | $0.60 |
195
+ | o1 | $15.00 | $60.00 |
196
+ | o3-mini | $1.10 | $4.40 |
197
+ | gemini-2.5-pro | $1.25 | $10.00 |
198
+ | gemini-2.5-flash | $0.15 | $0.60 |
199
+ | claude-3-5-sonnet | $3.00 | $15.00 |
200
+ | claude-3-5-haiku | $0.80 | $4.00 |
201
+
202
+ 自定义定价:
203
+
204
+ ```python
205
+ tracker.pricing.set("my-local-model", "ollama", input_per_1m=0.0, output_per_1m=0.0)
206
+ ```
207
+
208
+ 或通过 `.cost-tracker.yaml` 配置文件:
209
+
210
+ ```yaml
211
+ project: DentalVLM
212
+ costs_dir: .cost-tracker
213
+ pricing:
214
+ my-local-model:
215
+ provider: ollama
216
+ input_per_1m: 0.0
217
+ output_per_1m: 0.0
218
+ ```
219
+
220
+ ## 配置优先级
221
+
222
+ ```
223
+ 显式参数 > 环境变量 > .cost-tracker.yaml > 默认值
224
+ ```
225
+
226
+ | 环境变量 | 说明 |
227
+ |----------|------|
228
+ | `COST_TRACKER_PROJECT` | 项目名称 |
229
+ | `COST_TRACKER_USER` | 用户邮箱 |
230
+ | `COST_TRACKER_REMOTE_URL` | 远端 API 地址 |
231
+
232
+ ## 项目结构
233
+
234
+ ```
235
+ sdk/
236
+ ├── lab_cost_tracker/ # SDK 包(发布到 PyPI 的内容)
237
+ │ ├── __init__.py # 公共 API: CostTracker, PricingCatalog
238
+ │ ├── tracker.py # 主入口,wrap() / record() / summary()
239
+ │ ├── wrapper.py # 多 Provider 适配器(自动检测 + monkey-patch)
240
+ │ ├── pricing.py # 内置定价表 + 自定义覆盖
241
+ │ ├── storage.py # 本地双文件存储(detail.jsonl + summary.json)
242
+ │ ├── sync.py # 后台同步到远端 API(可选)
243
+ │ ├── config.py # YAML / env 配置加载
244
+ │ └── auth.py # Google OAuth 登录(可选)
245
+ ├── tests/ # 自动化测试(Mock,不需要 API key)
246
+ │ └── test_all_providers.py
247
+ ├── examples/ # 使用示例(需要 API key)
248
+ │ ├── openai_demo.py
249
+ │ ├── gemini_demo.py
250
+ │ └── proxy_demo.py
251
+ ├── pyproject.toml # 包配置
252
+ ├── README.md
253
+ └── LICENSE
254
+ ```
@@ -0,0 +1,219 @@
1
+ # Lab Cost Tracker SDK
2
+
3
+ 轻量级 Python SDK,为研究组透明追踪 LLM API 调用费用。一行 `tracker.wrap()` 包裹现有 client,自动记录每次调用的模型、token 数和费用。
4
+
5
+ ## 支持的 Provider
6
+
7
+ | Provider | Client 类型 | 拦截方法 |
8
+ |----------|------------|---------|
9
+ | OpenAI 官方 | `openai.OpenAI` | `chat.completions.create()` |
10
+ | Google Gemini | `google.genai.Client` | `models.generate_content()` |
11
+ | Anthropic | `anthropic.Anthropic` | `messages.create()` |
12
+ | 第三方代理 | `openai.OpenAI(base_url=...)` | 同 OpenAI(自动记录 `base_url`) |
13
+
14
+ ## 安装
15
+
16
+ ```bash
17
+ # 基础安装(仅需 pyyaml + requests)
18
+ pip install -e .
19
+
20
+ # 安装 OpenAI 支持
21
+ pip install -e ".[openai]"
22
+
23
+ # 安装 Google OAuth 支持(可选,用于身份验证)
24
+ pip install -e ".[google]"
25
+
26
+ # 全部安装
27
+ pip install -e ".[openai,google]"
28
+ ```
29
+
30
+ ## 快速开始
31
+
32
+ ### 1. OpenAI
33
+
34
+ ```python
35
+ from openai import OpenAI
36
+ from lab_cost_tracker import CostTracker
37
+
38
+ tracker = CostTracker(
39
+ project="DentalVLM",
40
+ user="kyle@aucklanduni.ac.nz",
41
+ storage_dir="./.cost-tracker", # 费用数据保存目录(默认值,可省略)
42
+ )
43
+
44
+ client = tracker.wrap(OpenAI(api_key="sk-..."))
45
+
46
+ # 正常使用,费用自动记录
47
+ response = client.chat.completions.create(
48
+ model="gpt-4o-mini",
49
+ messages=[{"role": "user", "content": "Hello!"}],
50
+ )
51
+
52
+ print(tracker.summary())
53
+ ```
54
+
55
+ ### 2. Google Gemini
56
+
57
+ ```python
58
+ from google import genai
59
+ from lab_cost_tracker import CostTracker
60
+
61
+ tracker = CostTracker(project="DentalVLM", user="kyle@aucklanduni.ac.nz")
62
+
63
+ client = tracker.wrap(genai.Client(api_key="..."))
64
+
65
+ response = client.models.generate_content(
66
+ model="gemini-2.5-flash",
67
+ contents="用一句话介绍新西兰奥克兰大学。",
68
+ )
69
+
70
+ print(tracker.summary())
71
+ ```
72
+
73
+ ### 3. Anthropic
74
+
75
+ ```python
76
+ import anthropic
77
+ from lab_cost_tracker import CostTracker
78
+
79
+ tracker = CostTracker(project="DentalVLM", user="kyle@aucklanduni.ac.nz")
80
+
81
+ client = tracker.wrap(anthropic.Anthropic(api_key="..."))
82
+
83
+ response = client.messages.create(
84
+ model="claude-3-5-sonnet-20241022",
85
+ max_tokens=1024,
86
+ messages=[{"role": "user", "content": "Hello!"}],
87
+ )
88
+
89
+ print(tracker.summary())
90
+ ```
91
+
92
+ ### 4. 第三方代理(如 apiyihe.org)
93
+
94
+ ```python
95
+ from openai import OpenAI
96
+ from lab_cost_tracker import CostTracker
97
+
98
+ tracker = CostTracker(project="DentalVLM", user="kyle@aucklanduni.ac.nz")
99
+
100
+ # 使用 OpenAI SDK + 自定义 base_url
101
+ client = tracker.wrap(OpenAI(
102
+ api_key="...",
103
+ base_url="https://z.apiyihe.org/v1",
104
+ ))
105
+
106
+ # 可以调用任何模型,base_url 会自动记录在 detail.jsonl 中
107
+ response = client.chat.completions.create(
108
+ model="gemini-2.5-flash-lite-preview-06-17",
109
+ messages=[{"role": "user", "content": "Hello!"}],
110
+ )
111
+ ```
112
+
113
+ ### 5. 手动记录(不支持的 Provider)
114
+
115
+ ```python
116
+ tracker.record(
117
+ model="llama-3-8b",
118
+ prompt_tokens=1000,
119
+ completion_tokens=500,
120
+ task="inference",
121
+ dataset="DentalBench",
122
+ )
123
+ ```
124
+
125
+ ## 数据存储
126
+
127
+ 运行后会在 `storage_dir`(默认 `{项目根目录}/.cost-tracker/`)下生成两个文件:
128
+
129
+ ### `detail.jsonl` — 逐条调用记录
130
+
131
+ ```json
132
+ {"id": "42ae...", "timestamp": "2026-05-19T...", "project": "DentalVLM", "user": "kyle@aucklanduni.ac.nz", "model": "gpt-4o-mini-2024-07-18", "prompt_tokens": 19, "completion_tokens": 33, "cost_usd": 0.00059, "metadata": {"provider": "openai", "base_url": "https://api.openai.com/v1"}, "synced": false}
133
+ ```
134
+
135
+ ### `summary.json` — 聚合统计
136
+
137
+ ```json
138
+ {
139
+ "project": "DentalVLM",
140
+ "total_cost_usd": 0.012,
141
+ "total_requests": 5,
142
+ "by_model": {
143
+ "gpt-4o-mini": {"cost_usd": 0.0006, "requests": 2},
144
+ "gemini-2.5-flash": {"cost_usd": 0.0005, "requests": 1}
145
+ },
146
+ "by_user": {
147
+ "kyle@aucklanduni.ac.nz": {"cost_usd": 0.012, "requests": 5}
148
+ }
149
+ }
150
+ ```
151
+
152
+ ## 内置定价表
153
+
154
+ SDK 内置了常用模型的定价(USD / 1M tokens):
155
+
156
+ | 模型 | 输入价格 | 输出价格 |
157
+ |------|---------|---------|
158
+ | gpt-4o | $5.00 | $15.00 |
159
+ | gpt-4o-mini | $0.15 | $0.60 |
160
+ | o1 | $15.00 | $60.00 |
161
+ | o3-mini | $1.10 | $4.40 |
162
+ | gemini-2.5-pro | $1.25 | $10.00 |
163
+ | gemini-2.5-flash | $0.15 | $0.60 |
164
+ | claude-3-5-sonnet | $3.00 | $15.00 |
165
+ | claude-3-5-haiku | $0.80 | $4.00 |
166
+
167
+ 自定义定价:
168
+
169
+ ```python
170
+ tracker.pricing.set("my-local-model", "ollama", input_per_1m=0.0, output_per_1m=0.0)
171
+ ```
172
+
173
+ 或通过 `.cost-tracker.yaml` 配置文件:
174
+
175
+ ```yaml
176
+ project: DentalVLM
177
+ costs_dir: .cost-tracker
178
+ pricing:
179
+ my-local-model:
180
+ provider: ollama
181
+ input_per_1m: 0.0
182
+ output_per_1m: 0.0
183
+ ```
184
+
185
+ ## 配置优先级
186
+
187
+ ```
188
+ 显式参数 > 环境变量 > .cost-tracker.yaml > 默认值
189
+ ```
190
+
191
+ | 环境变量 | 说明 |
192
+ |----------|------|
193
+ | `COST_TRACKER_PROJECT` | 项目名称 |
194
+ | `COST_TRACKER_USER` | 用户邮箱 |
195
+ | `COST_TRACKER_REMOTE_URL` | 远端 API 地址 |
196
+
197
+ ## 项目结构
198
+
199
+ ```
200
+ sdk/
201
+ ├── lab_cost_tracker/ # SDK 包(发布到 PyPI 的内容)
202
+ │ ├── __init__.py # 公共 API: CostTracker, PricingCatalog
203
+ │ ├── tracker.py # 主入口,wrap() / record() / summary()
204
+ │ ├── wrapper.py # 多 Provider 适配器(自动检测 + monkey-patch)
205
+ │ ├── pricing.py # 内置定价表 + 自定义覆盖
206
+ │ ├── storage.py # 本地双文件存储(detail.jsonl + summary.json)
207
+ │ ├── sync.py # 后台同步到远端 API(可选)
208
+ │ ├── config.py # YAML / env 配置加载
209
+ │ └── auth.py # Google OAuth 登录(可选)
210
+ ├── tests/ # 自动化测试(Mock,不需要 API key)
211
+ │ └── test_all_providers.py
212
+ ├── examples/ # 使用示例(需要 API key)
213
+ │ ├── openai_demo.py
214
+ │ ├── gemini_demo.py
215
+ │ └── proxy_demo.py
216
+ ├── pyproject.toml # 包配置
217
+ ├── README.md
218
+ └── LICENSE
219
+ ```
@@ -0,0 +1,7 @@
1
+ """lab_cost_tracker — lightweight API cost tracking SDK."""
2
+
3
+ from lab_cost_tracker.tracker import CostTracker
4
+ from lab_cost_tracker.pricing import PricingCatalog
5
+
6
+ __all__ = ["CostTracker", "PricingCatalog"]
7
+ __version__ = "0.1.0"
@@ -0,0 +1,162 @@
1
+ """
2
+ Google OAuth authentication for lab members.
3
+
4
+ Uses Google OAuth 2.0 to authenticate users with their university email.
5
+ Tokens are cached locally to avoid repeated login prompts.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ _TOKEN_CACHE_DIR = Path.home() / ".config" / "lab-cost-tracker"
16
+ _TOKEN_CACHE_FILE = _TOKEN_CACHE_DIR / "credentials.json"
17
+
18
+ # Default OAuth client — users can override via env or config
19
+ _DEFAULT_CLIENT_ID = os.getenv("COST_TRACKER_GOOGLE_CLIENT_ID", "")
20
+ _DEFAULT_CLIENT_SECRET = os.getenv("COST_TRACKER_GOOGLE_CLIENT_SECRET", "")
21
+ _SCOPES = ["openid", "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile"]
22
+
23
+
24
+ class AuthInfo:
25
+ """Authenticated user info."""
26
+ def __init__(self, email: str, name: str, id_token: str, access_token: str):
27
+ self.email = email
28
+ self.name = name
29
+ self.id_token = id_token
30
+ self.access_token = access_token
31
+
32
+ def to_dict(self) -> dict:
33
+ return {"email": self.email, "name": self.name}
34
+
35
+
36
+ def login(
37
+ client_id: Optional[str] = None,
38
+ client_secret: Optional[str] = None,
39
+ allowed_domains: Optional[list[str]] = None,
40
+ ) -> AuthInfo:
41
+ """
42
+ Perform Google OAuth login. Opens a browser for consent on first use,
43
+ then caches the refresh token for subsequent runs.
44
+
45
+ Args:
46
+ client_id: Google OAuth client ID (or set COST_TRACKER_GOOGLE_CLIENT_ID)
47
+ client_secret: Google OAuth client secret (or set COST_TRACKER_GOOGLE_CLIENT_SECRET)
48
+ allowed_domains: If set, reject emails not matching these domains (e.g. ["aucklanduni.ac.nz"])
49
+
50
+ Returns:
51
+ AuthInfo with email, name, and tokens.
52
+ """
53
+ try:
54
+ from google_auth_oauthlib.flow import InstalledAppFlow
55
+ from google.oauth2.credentials import Credentials
56
+ from google.auth.transport.requests import Request
57
+ except ImportError:
58
+ raise ImportError(
59
+ "Google auth libraries required. Install with: "
60
+ "pip install google-auth google-auth-oauthlib"
61
+ )
62
+
63
+ cid = client_id or _DEFAULT_CLIENT_ID
64
+ csecret = client_secret or _DEFAULT_CLIENT_SECRET
65
+ if not cid or not csecret:
66
+ raise ValueError(
67
+ "Google OAuth client_id and client_secret are required. "
68
+ "Set COST_TRACKER_GOOGLE_CLIENT_ID and COST_TRACKER_GOOGLE_CLIENT_SECRET "
69
+ "environment variables, or pass them directly."
70
+ )
71
+
72
+ creds = _load_cached_credentials()
73
+
74
+ # Try to refresh existing credentials
75
+ if creds and creds.expired and creds.refresh_token:
76
+ try:
77
+ creds.refresh(Request())
78
+ except Exception:
79
+ creds = None
80
+
81
+ # No valid credentials — run OAuth flow
82
+ if not creds or not creds.valid:
83
+ client_config = {
84
+ "installed": {
85
+ "client_id": cid,
86
+ "client_secret": csecret,
87
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
88
+ "token_uri": "https://oauth2.googleapis.com/token",
89
+ "redirect_uris": ["http://localhost"],
90
+ }
91
+ }
92
+ flow = InstalledAppFlow.from_client_config(client_config, _SCOPES)
93
+ creds = flow.run_local_server(port=0, prompt="consent")
94
+ _save_cached_credentials(creds)
95
+
96
+ # Get user info
97
+ import requests as req
98
+ resp = req.get(
99
+ "https://www.googleapis.com/oauth2/v3/userinfo",
100
+ headers={"Authorization": f"Bearer {creds.token}"},
101
+ )
102
+ resp.raise_for_status()
103
+ user_info = resp.json()
104
+
105
+ email = user_info.get("email", "")
106
+ name = user_info.get("name", email.split("@")[0])
107
+
108
+ # Domain check
109
+ if allowed_domains:
110
+ domain = email.split("@")[-1] if "@" in email else ""
111
+ if domain not in allowed_domains:
112
+ raise PermissionError(
113
+ f"Email domain '{domain}' is not allowed. "
114
+ f"Allowed domains: {allowed_domains}"
115
+ )
116
+
117
+ return AuthInfo(
118
+ email=email,
119
+ name=name,
120
+ id_token=creds.token,
121
+ access_token=creds.token,
122
+ )
123
+
124
+
125
+ def _load_cached_credentials():
126
+ """Load cached Google OAuth credentials from disk."""
127
+ try:
128
+ from google.oauth2.credentials import Credentials
129
+ except ImportError:
130
+ return None
131
+
132
+ if not _TOKEN_CACHE_FILE.exists():
133
+ return None
134
+ try:
135
+ with open(_TOKEN_CACHE_FILE, "r") as f:
136
+ data = json.load(f)
137
+ return Credentials(
138
+ token=data.get("token"),
139
+ refresh_token=data.get("refresh_token"),
140
+ token_uri=data.get("token_uri", "https://oauth2.googleapis.com/token"),
141
+ client_id=data.get("client_id"),
142
+ client_secret=data.get("client_secret"),
143
+ scopes=data.get("scopes"),
144
+ )
145
+ except Exception:
146
+ return None
147
+
148
+
149
+ def _save_cached_credentials(creds):
150
+ """Cache credentials to disk for reuse."""
151
+ _TOKEN_CACHE_DIR.mkdir(parents=True, exist_ok=True)
152
+ data = {
153
+ "token": creds.token,
154
+ "refresh_token": creds.refresh_token,
155
+ "token_uri": creds.token_uri,
156
+ "client_id": creds.client_id,
157
+ "client_secret": creds.client_secret,
158
+ "scopes": list(creds.scopes) if creds.scopes else _SCOPES,
159
+ }
160
+ with open(_TOKEN_CACHE_FILE, "w") as f:
161
+ json.dump(data, f, indent=2)
162
+ os.chmod(_TOKEN_CACHE_FILE, 0o600) # restrict permissions