main
py 222 lines 5.37 KB
Raw
1 from __future__ import annotations
2
3 import json
4 import os
5 import tempfile
6 import importlib
7 from dataclasses import asdict, dataclass, field
8 from pathlib import Path
9 from typing import Any, Protocol
10
11
12 CODEX_PROVIDER_ID = "codex_oauth"
13 GITHUB_COPILOT_PROVIDER_ID = "github_copilot_oauth"
14 GEMINI_API_PROVIDER_ID = "gemini_api_oauth"
15 XAI_GROK_PROVIDER_ID = "xai_grok_oauth"
16 DUMMY_API_KEY = "oauth"
17
18
19 class ProviderError(Exception):
20 def __init__(
21 self,
22 message: str,
23 *,
24 code: str = "provider_error",
25 status: int = 400,
26 details: dict[str, Any] | None = None,
27 ) -> None:
28 super().__init__(message)
29 self.message = message
30 self.code = code
31 self.status = status
32 self.details = dict(details or {})
33
34 def to_dict(self) -> dict[str, Any]:
35 result: dict[str, Any] = {
36 "ok": False,
37 "error": self.message,
38 "code": self.code,
39 "status": self.status,
40 }
41 if self.details:
42 result["details"] = self.details
43 return result
44
45
46 @dataclass(frozen=True)
47 class OAuthProviderMetadata:
48 provider_id: str
49 display_name: str
50 short_name: str
51 model_provider_id: str
52 icon: str
53 auth_flow: str
54 default_model: str
55 default_models: list[str] = field(default_factory=list)
56 proxy_base_path: str = ""
57 callback_path: str = ""
58 supports_manual_callback: bool = False
59 supports_enterprise_domain: bool = False
60 supports_oauth_client_config: bool = False
61 supports_quota_project: bool = False
62 note: str = ""
63 warning: str = ""
64
65 def to_dict(self) -> dict[str, Any]:
66 return asdict(self)
67
68
69 @dataclass(frozen=True)
70 class LoginStartResult:
71 ok: bool
72 provider_id: str
73 flow: str
74 auth_url: str = ""
75 redirect_uri: str = ""
76 attempt_id: str = ""
77 verification_url: str = ""
78 user_code: str = ""
79 interval: int = 5
80 expires_at: float = 0
81 message: str = ""
82 error: str = ""
83
84 def to_dict(self) -> dict[str, Any]:
85 return asdict(self)
86
87
88 @dataclass(frozen=True)
89 class LoginPollResult:
90 ok: bool
91 provider_id: str
92 completed: bool = False
93 account_label: str = ""
94 account_id: str = ""
95 interval: int = 5
96 expires_at: float = 0
97 expired: bool = False
98 error: str = ""
99 warning: str = ""
100
101 def to_dict(self) -> dict[str, Any]:
102 return asdict(self)
103
104
105 @dataclass(frozen=True)
106 class CallbackResult:
107 ok: bool
108 provider_id: str
109 account_label: str = ""
110 account_id: str = ""
111 error: str = ""
112
113 def to_dict(self) -> dict[str, Any]:
114 return asdict(self)
115
116
117 class OAuthProvider(Protocol):
118 provider_id: str
119
120 def metadata(self) -> OAuthProviderMetadata:
121 ...
122
123 def status(self) -> dict[str, Any]:
124 ...
125
126 def start_login(self, input: dict[str, Any] | None = None, request: Any = None) -> LoginStartResult:
127 ...
128
129 def poll_login(self, input: dict[str, Any] | None = None, request: Any = None) -> LoginPollResult:
130 ...
131
132 def complete_callback(self, args: dict[str, Any], request: Any) -> CallbackResult:
133 ...
134
135 def manual_callback(self, input: dict[str, Any], request: Any) -> LoginPollResult:
136 ...
137
138 def models(self) -> list[str]:
139 ...
140
141 def disconnect(self) -> dict[str, Any]:
142 ...
143
144 def api_key(self) -> str:
145 ...
146
147 def register_routes(self, app: Any) -> None:
148 ...
149
150
151 def provider_data_dir(provider_slug: str) -> Path:
152 if not _valid_provider_slug(provider_slug):
153 raise ProviderError(
154 "Invalid OAuth provider storage slug.",
155 code="invalid_provider_slug",
156 )
157
158 files = importlib.import_module("helpers.files")
159
160 path = Path(
161 files.get_abs_path(
162 files.USER_DIR,
163 files.PLUGINS_DIR,
164 "_oauth",
165 provider_slug,
166 )
167 )
168 path.mkdir(parents=True, exist_ok=True)
169 return path
170
171
172 def provider_auth_path(provider_slug: str) -> Path:
173 return provider_data_dir(provider_slug) / "auth.json"
174
175
176 def _valid_provider_slug(provider_slug: str) -> bool:
177 if not isinstance(provider_slug, str):
178 return False
179 if not provider_slug or provider_slug in {".", ".."}:
180 return False
181 if "/" in provider_slug or "\\" in provider_slug:
182 return False
183 return all(char.isalnum() or char in {"_", "-"} for char in provider_slug)
184
185
186 def read_json_file(path: Path) -> dict[str, Any]:
187 try:
188 with path.open("r", encoding="utf-8") as handle:
189 payload = json.load(handle)
190 except FileNotFoundError:
191 return {}
192 if not isinstance(payload, dict):
193 return {}
194 return payload
195
196
197 def write_private_json(path: Path, data: dict[str, Any]) -> None:
198 path.parent.mkdir(parents=True, exist_ok=True)
199 tmp_name = ""
200 try:
201 fd, tmp_name = tempfile.mkstemp(
202 prefix=f".{path.name}.",
203 suffix=".tmp",
204 dir=str(path.parent),
205 )
206 os.chmod(tmp_name, 0o600)
207 with os.fdopen(fd, "w", encoding="utf-8") as handle:
208 json.dump(data, handle, indent=2)
209 handle.write("\n")
210 os.replace(tmp_name, path)
211 except Exception:
212 if tmp_name:
213 Path(tmp_name).unlink(missing_ok=True)
214 raise
215 try:
216 path.chmod(0o600)
217 except OSError:
218 pass
219
220
221 def public_error(exc: Exception) -> str:
222 return str(exc) or exc.__class__.__name__