Add OAuth provider registry and Gemini API OAuth
Alessandro committed
Jun 1, 2026 at 02:53 UTC
e6885e6f7be3941cbd6572e14c22e94c4628249c
30 files changed
+6502
-339
plugins/_model_config/provider_metadata.yaml
+6
@@ -1,12 +1,18 @@
1
chat:
2
codex_oauth:
3
api_key_mode: oauth
4
+ github_copilot_oauth:
5
+ api_key_mode: oauth
6
+ gemini_api_oauth:
7
+ api_key_mode: oauth
8
lm_studio:
9
api_key_mode: none
10
ollama:
11
api_key_mode: none
12
other:
13
api_key_mode: optional
14
+ xai_grok_oauth:
15
+ api_key_mode: oauth
16
17
embedding:
18
huggingface:
plugins/_oauth/README.md
+48
-6
@@ -2,11 +2,53 @@
2
3
Generic local OAuth bridge for Agent Zero.
4
5
-The first provider is `Codex/ChatGPT Account`:
5
+Tokens in `auth.json` are password-equivalent credentials. Keep this plugin on trusted local machines only. Do not configure `auth_file_path` to share a rotating refresh-token file with Codex CLI or another client.
6
7
-- signs in with OpenAI's Codex device-code flow
8
-- writes credentials to an Agent Zero-owned `auth.json` file
9
-- refreshes local tokens when needed
10
-- exposes a loopback OpenAI-compatible wrapper at `/oauth/codex/v1`
7
+## Providers
8
12
-Tokens in `auth.json` are password-equivalent credentials. Keep this plugin on trusted local machines only. Do not configure `auth_file_path` to share a rotating refresh-token file with Codex CLI or another client.
9
+### Codex/ChatGPT (`codex_oauth`)
10
+
11
+- Uses the existing Codex device-code flow.
12
+- Writes Codex-compatible credentials to an Agent Zero-owned `auth.json` file.
13
+- Refreshes local tokens when needed.
14
+- Exposes the local OpenAI-compatible wrapper at `/oauth/codex/v1`.
15
+
16
+### GitHub Copilot (`github_copilot_oauth`)
17
+
18
+- Uses GitHub's OAuth device flow.
19
+- Exchanges the GitHub access token for a Copilot API token.
20
+- Stores credentials under `usr/plugins/_oauth/github_copilot/auth.json`.
21
+
22
+### Google Gemini API (`gemini_api_oauth`)
23
+
24
+- Uses Google's OAuth authorization-code flow with PKCE.
25
+- Requires a user-provided Google Cloud OAuth client with the Generative Language API enabled.
26
+- Proxies the official Gemini OpenAI-compatible endpoint at `/oauth/gemini-api/v1`.
27
+- Stores credentials under `usr/plugins/_oauth/gemini_api/auth.json`.
28
+- Uses Gemini API billing and quotas. It does not use Antigravity, Gemini Code Assist, Gemini CLI, Google AI Pro, or Google AI Ultra subscription quota.
29
+
30
+### xAI Grok (`xai_grok_oauth`)
31
+
32
+- Uses xAI's browser-based PKCE flow.
33
+- Supports manual callback paste for remote hosts where the browser cannot reach the local callback directly.
34
+- Stores credentials under `usr/plugins/_oauth/xai_grok/auth.json`.
35
+
36
+### Claude Code and Antigravity Product OAuth
37
+
38
+Claude Code subscription OAuth and Antigravity product OAuth are intentionally metadata-only. They are product-specific login flows rather than third-party provider contracts for Agent Zero to route model traffic through.
39
+
40
+## Usage Plan Metadata
41
+
42
+The status API exposes `usage_plan_catalog` for subscription and billing context. It covers the implemented Codex, GitHub Copilot, Google Gemini API, and xAI providers, plus nearby Claude Code and Google Gemini / Antigravity plan families.
43
+
44
+Google Gemini API OAuth is implemented through a user-provided Google Cloud OAuth client. Google Gemini / Antigravity subscription metadata remains separate because Antigravity and Gemini Code Assist product OAuth are not third-party model-provider contracts.
45
+
46
+## Remote xAI Callback
47
+
48
+When Agent Zero is running on a remote host, the browser may complete the xAI authorization step somewhere other than the machine serving the local callback route. In that case, paste the callback value into the xAI card.
49
+
50
+The xAI card accepts any of these formats:
51
+
52
+- Full callback URL.
53
+- Query string such as `?code=...&state=...`.
54
+- Bare authorization code.
plugins/_oauth/api/disconnect.py
+33
-5
@@ -1,17 +1,45 @@
1
from __future__ import annotations
2
3
from helpers.api import ApiHandler, Request
4
-from plugins._oauth.helpers import codex
4
+from plugins._oauth.helpers.providers import CODEX_PROVIDER_ID, get_provider
5
6
7
class Disconnect(ApiHandler):
8
async def process(self, input: dict, request: Request) -> dict:
9
+ del request
10
+ raw_provider_id = _provider_id(input)
11
try:
10
- result = codex.disconnect_auth()
11
- return {
12
+ provider = get_provider(raw_provider_id)
13
+ result = provider.disconnect()
14
+ response = {
15
"ok": True,
16
+ "provider_id": provider.provider_id,
17
+ "result": result,
18
+ "provider": provider.status(),
19
**result,
14
- "codex": codex.status(),
20
}
21
+ if provider.provider_id == CODEX_PROVIDER_ID:
22
+ response["codex"] = response["provider"]
23
+ return response
24
except Exception as exc:
17
- return {"ok": False, "error": str(exc)}
25
+ return {
26
+ "ok": False,
27
+ "provider_id": _provider_id_label(raw_provider_id),
28
+ "error": str(exc),
29
+ }
30
+
31
+
32
+def _provider_id(input: dict) -> object:
33
+ if "provider_id" not in input or input.get("provider_id") is None:
34
+ return CODEX_PROVIDER_ID
35
+ value = input.get("provider_id")
36
+ if isinstance(value, str) and not value.strip():
37
+ return CODEX_PROVIDER_ID
38
+ return value
39
+
40
+
41
+def _provider_id_label(value: object) -> str:
42
+ if value is None:
43
+ return CODEX_PROVIDER_ID
44
+ text = str(value).strip()
45
+ return text or CODEX_PROVIDER_ID
plugins/_oauth/api/manual_callback.py
new
+33
@@ -0,0 +1,33 @@
1
+from __future__ import annotations
2
+
3
+from helpers.api import ApiHandler, Request
4
+from plugins._oauth.helpers.providers import CODEX_PROVIDER_ID, get_provider
5
+
6
+
7
+class ManualCallback(ApiHandler):
8
+ async def process(self, input: dict, request: Request) -> dict:
9
+ raw_provider_id = _provider_id(input)
10
+ try:
11
+ return get_provider(raw_provider_id).manual_callback(input, request).to_dict()
12
+ except Exception as exc:
13
+ return {
14
+ "ok": False,
15
+ "provider_id": _provider_id_label(raw_provider_id),
16
+ "error": str(exc),
17
+ }
18
+
19
+
20
+def _provider_id(input: dict) -> object:
21
+ if "provider_id" not in input or input.get("provider_id") is None:
22
+ return CODEX_PROVIDER_ID
23
+ value = input.get("provider_id")
24
+ if isinstance(value, str) and not value.strip():
25
+ return CODEX_PROVIDER_ID
26
+ return value
27
+
28
+
29
+def _provider_id_label(value: object) -> str:
30
+ if value is None:
31
+ return CODEX_PROVIDER_ID
32
+ text = str(value).strip()
33
+ return text or CODEX_PROVIDER_ID
plugins/_oauth/api/models.py
+28
-4
@@ -1,13 +1,37 @@
1
from __future__ import annotations
2
3
from helpers.api import ApiHandler, Request
4
-from plugins._oauth.helpers import codex
4
+from plugins._oauth.helpers.providers import CODEX_PROVIDER_ID, get_provider
5
6
7
class Models(ApiHandler):
8
async def process(self, input: dict, request: Request) -> dict:
9
+ del request
10
+ raw_provider_id = _provider_id(input)
11
try:
10
- models = codex.fetch_models()
11
- return {"ok": True, "models": models}
12
+ provider = get_provider(raw_provider_id)
13
+ models = provider.models()
14
+ return {"ok": True, "provider_id": provider.provider_id, "models": models}
15
except Exception as exc:
13
- return {"ok": False, "error": str(exc), "models": []}
16
+ return {
17
+ "ok": False,
18
+ "provider_id": _provider_id_label(raw_provider_id),
19
+ "error": str(exc),
20
+ "models": [],
21
+ }
22
+
23
+
24
+def _provider_id(input: dict) -> object:
25
+ if "provider_id" not in input or input.get("provider_id") is None:
26
+ return CODEX_PROVIDER_ID
27
+ value = input.get("provider_id")
28
+ if isinstance(value, str) and not value.strip():
29
+ return CODEX_PROVIDER_ID
30
+ return value
31
+
32
+
33
+def _provider_id_label(value: object) -> str:
34
+ if value is None:
35
+ return CODEX_PROVIDER_ID
36
+ text = str(value).strip()
37
+ return text or CODEX_PROVIDER_ID
plugins/_oauth/api/poll_device_login.py
+22
-24
@@ -1,35 +1,33 @@
1
from __future__ import annotations
2
3
from helpers.api import ApiHandler, Request
4
-from plugins._oauth.helpers import codex
5
-from plugins._oauth.helpers.state import get_device_attempt, pop_device_attempt
4
+from plugins._oauth.helpers.providers import CODEX_PROVIDER_ID, get_provider
5
6
7
class PollDeviceLogin(ApiHandler):
8
async def process(self, input: dict, request: Request) -> dict:
10
- attempt_id = str(input.get("attempt_id") or "").strip()
11
- if not attempt_id:
12
- return {"ok": False, "error": "Missing device authorization attempt."}
13
-
14
- attempt = get_device_attempt(attempt_id)
15
- if attempt is None:
16
- return {"ok": False, "expired": True, "error": "Device authorization expired."}
17
-
9
+ raw_provider_id = _provider_id(input)
10
try:
19
- result = codex.poll_device_authorization(
20
- attempt.device_auth_id,
21
- attempt.user_code,
22
- )
11
+ return get_provider(raw_provider_id).poll_login(input, request).to_dict()
12
except Exception as exc:
24
- return {"ok": False, "error": str(exc)}
13
+ return {
14
+ "ok": False,
15
+ "provider_id": _provider_id_label(raw_provider_id),
16
+ "error": str(exc),
17
+ }
18
+
19
+
20
+def _provider_id(input: dict) -> object:
21
+ if "provider_id" not in input or input.get("provider_id") is None:
22
+ return CODEX_PROVIDER_ID
23
+ value = input.get("provider_id")
24
+ if isinstance(value, str) and not value.strip():
25
+ return CODEX_PROVIDER_ID
26
+ return value
27
26
- if result.get("completed"):
27
- pop_device_attempt(attempt_id)
28
- return {"ok": True, "completed": True, "account_id": result.get("account_id", "")}
28
30
- return {
31
- "ok": True,
32
- "completed": False,
33
- "interval": attempt.interval,
34
- "expires_at": attempt.expires_at,
35
- }
29
+def _provider_id_label(value: object) -> str:
30
+ if value is None:
31
+ return CODEX_PROVIDER_ID
32
+ text = str(value).strip()
33
+ return text or CODEX_PROVIDER_ID
plugins/_oauth/api/start_device_login.py
+2
-29
@@ -1,36 +1,9 @@
1
from __future__ import annotations
2
3
-import secrets
4
-
3
from helpers.api import ApiHandler, Request
6
-from plugins._oauth.helpers import codex
7
-from plugins._oauth.helpers.config import codex_config
8
-from plugins._oauth.helpers.state import put_device_attempt
4
+from plugins._oauth.helpers.providers import CODEX_PROVIDER_ID, get_provider
5
6
7
class StartDeviceLogin(ApiHandler):
8
async def process(self, input: dict, request: Request) -> dict:
13
- cfg = codex_config()
14
- if not cfg["enabled"]:
15
- return {"ok": False, "error": "Codex/ChatGPT account connection is disabled."}
16
-
17
- try:
18
- device = codex.request_device_code()
19
- attempt_id = secrets.token_urlsafe(24)
20
- attempt = put_device_attempt(
21
- attempt_id,
22
- device["device_auth_id"],
23
- device["user_code"],
24
- device["interval"],
25
- device["expires_at"],
26
- )
27
- return {
28
- "ok": True,
29
- "attempt_id": attempt.attempt_id,
30
- "verification_url": device["verification_url"],
31
- "user_code": attempt.user_code,
32
- "interval": attempt.interval,
33
- "expires_at": attempt.expires_at,
34
- }
35
- except Exception as exc:
36
- return {"ok": False, "error": str(exc)}
9
+ return get_provider(CODEX_PROVIDER_ID).start_login(input, request).to_dict()
plugins/_oauth/api/start_login.py
+36
-46
@@ -1,54 +1,44 @@
1
from __future__ import annotations
2
3
-import webbrowser
4
-
3
from helpers.api import ApiHandler, Request
6
-from plugins._oauth.helpers import codex
7
-from plugins._oauth.helpers.config import codex_config
8
-from plugins._oauth.helpers.state import put_attempt
4
+from plugins._oauth.helpers.providers import CODEX_PROVIDER_ID, get_provider
5
6
7
class StartLogin(ApiHandler):
8
async def process(self, input: dict, request: Request) -> dict:
13
- cfg = codex_config()
14
- if not cfg["enabled"]:
15
- return {"ok": False, "error": "Codex/ChatGPT account connection is disabled."}
16
-
17
- redirect_uri = _redirect_uri(request, cfg["callback_path"])
18
- pkce = codex.generate_pkce()
19
- state = codex.generate_state()
20
- attempt = put_attempt(state, pkce.verifier, redirect_uri)
21
- auth_url = codex.build_authorize_url(redirect_uri, state, pkce)
22
-
23
- if cfg["open_browser_from_server"]:
9
+ if "provider_id" not in input:
10
+ raw_provider_id = CODEX_PROVIDER_ID
11
try:
25
- webbrowser.open(auth_url)
26
- except Exception:
27
- pass
28
-
29
- return {
30
- "ok": True,
31
- "auth_url": auth_url,
32
- "redirect_uri": redirect_uri,
33
- "expires_at": attempt.expires_at,
34
- }
35
-
36
-
37
-def _redirect_uri(request: Request, callback_path: str) -> str:
38
- origin = (request.headers.get("Origin") or "").rstrip("/")
39
- if not _is_local_origin(origin):
40
- origin = request.url_root.rstrip("/")
41
- return f"{origin}{callback_path}"
42
-
43
-
44
-def _is_local_origin(origin: str) -> bool:
45
- if not origin:
46
- return False
47
- return (
48
- origin.startswith("http://localhost:")
49
- or origin == "http://localhost"
50
- or origin.startswith("http://127.0.0.1:")
51
- or origin == "http://127.0.0.1"
52
- or origin.startswith("http://[::1]:")
53
- or origin == "http://[::1]"
54
- )
12
+ provider = get_provider(CODEX_PROVIDER_ID)
13
+ start_browser_login = getattr(provider, "start_browser_login", None)
14
+ if callable(start_browser_login):
15
+ return start_browser_login(input, request).to_dict()
16
+ return provider.start_login(input, request).to_dict()
17
+ except Exception as exc:
18
+ return {"ok": False, "provider_id": CODEX_PROVIDER_ID, "error": str(exc)}
19
+
20
+ raw_provider_id = _provider_id(input)
21
+ try:
22
+ return get_provider(raw_provider_id).start_login(input, request).to_dict()
23
+ except Exception as exc:
24
+ return {
25
+ "ok": False,
26
+ "provider_id": _provider_id_label(raw_provider_id),
27
+ "error": str(exc),
28
+ }
29
+
30
+
31
+def _provider_id(input: dict) -> object:
32
+ if "provider_id" not in input or input.get("provider_id") is None:
33
+ return CODEX_PROVIDER_ID
34
+ value = input.get("provider_id")
35
+ if isinstance(value, str) and not value.strip():
36
+ return CODEX_PROVIDER_ID
37
+ return value
38
+
39
+
40
+def _provider_id_label(value: object) -> str:
41
+ if value is None:
42
+ return CODEX_PROVIDER_ID
43
+ text = str(value).strip()
44
+ return text or CODEX_PROVIDER_ID
plugins/_oauth/api/status.py
+20
-10
@@ -1,22 +1,32 @@
1
from __future__ import annotations
2
3
from helpers.api import ApiHandler, Request
4
-from plugins._oauth.helpers import codex
5
-from plugins._oauth.helpers.config import codex_config
4
from plugins._oauth.helpers.route_bootstrap import is_installed
5
+from plugins._oauth.helpers.providers import CODEX_PROVIDER_ID, provider_registry
6
+from plugins._oauth.helpers.usage_plans import usage_plan_catalog
7
8
9
class Status(ApiHandler):
10
async def process(self, input: dict, request: Request) -> dict:
11
- cfg = codex_config()
11
+ del input, request
12
+ providers = [_provider_status(provider) for provider in provider_registry().values()]
13
+ provider_map = {provider["provider_id"]: provider for provider in providers}
14
return {
15
"ok": True,
16
"routes_installed": is_installed(),
15
- "codex": {
16
- **codex.status(),
17
- "enabled": cfg["enabled"],
18
- "proxy_base_path": cfg["proxy_base_path"],
19
- "callback_path": cfg["callback_path"],
20
- "v1_base_path": f'{cfg["proxy_base_path"]}/v1',
21
- },
17
+ "providers": providers,
18
+ "provider_map": provider_map,
19
+ "usage_plan_catalog": usage_plan_catalog(),
20
+ "codex": provider_map.get(CODEX_PROVIDER_ID, {}),
21
+ }
22
+
23
+
24
+def _provider_status(provider) -> dict:
25
+ try:
26
+ return provider.status()
27
+ except Exception as exc:
28
+ return {
29
+ "provider_id": str(getattr(provider, "provider_id", "")),
30
+ "connected": False,
31
+ "error": str(exc),
32
}
plugins/_oauth/conf/model_providers.yaml
+24
@@ -7,3 +7,27 @@ chat:
7
kwargs:
8
api_base: "http://127.0.0.1/oauth/codex/v1"
9
api_key: "oauth"
10
+ github_copilot_oauth:
11
+ name: GitHub Copilot Account
12
+ litellm_provider: openai
13
+ models_list:
14
+ endpoint_url: "/models"
15
+ kwargs:
16
+ api_base: "http://127.0.0.1/oauth/github-copilot/v1"
17
+ api_key: "oauth"
18
+ gemini_api_oauth:
19
+ name: Google Gemini API Account
20
+ litellm_provider: openai
21
+ models_list:
22
+ endpoint_url: "/models"
23
+ kwargs:
24
+ api_base: "http://127.0.0.1/oauth/gemini-api/v1"
25
+ api_key: "oauth"
26
+ xai_grok_oauth:
27
+ name: xAI Grok Account
28
+ litellm_provider: openai
29
+ models_list:
30
+ endpoint_url: "/models"
31
+ kwargs:
32
+ api_base: "http://127.0.0.1/oauth/xai-grok/v1"
33
+ api_key: "oauth"
plugins/_oauth/default_config.yaml
+23
-1
@@ -1,4 +1,4 @@
1
-# Generic OAuth connection settings. Only Codex is implemented for now.
1
+# Generic OAuth connection settings.
2
codex:
3
enabled: true
4
@@ -31,3 +31,25 @@ codex:
31
callback_path: "/auth/callback"
32
require_proxy_token: false
33
proxy_token: ""
34
+
35
+github_copilot:
36
+ enabled: true
37
+ enterprise_domain: ""
38
+
39
+gemini_api:
40
+ enabled: true
41
+ client_id: ""
42
+ client_secret: ""
43
+ quota_project_id: ""
44
+ scopes:
45
+ - openid
46
+ - email
47
+ - profile
48
+ - https://www.googleapis.com/auth/cloud-platform
49
+ - https://www.googleapis.com/auth/generative-language.retriever
50
+ api_base_url: "https://generativelanguage.googleapis.com/v1beta/openai"
51
+ proxy_base_path: "/oauth/gemini-api"
52
+ callback_path: "/oauth/gemini-api/callback"
53
+
54
+xai_grok:
55
+ enabled: true
plugins/_oauth/extensions/python/_functions/models/get_api_key/end/_20_codex_account_dummy_key.py
+4
-6
@@ -1,26 +1,24 @@
1
from __future__ import annotations
2
3
from helpers.extension import Extension
4
-
5
-
6
-DUMMY_API_KEY = "oauth"
7
-PROVIDERS = {"codex_oauth"}
4
+from plugins._oauth.helpers.providers import DUMMY_API_KEY, oauth_provider_ids
5
6
7
class CodexAccountDummyKey(Extension):
8
def execute(self, data: dict | None = None, **kwargs):
9
+ del kwargs
10
if not isinstance(data, dict):
11
return
12
13
args = data.get("args")
14
call_kwargs = data.get("kwargs")
15
service = ""
18
- if isinstance(args, tuple) and args:
16
+ if isinstance(args, (list, tuple)) and args:
17
service = str(args[0] or "")
18
elif isinstance(call_kwargs, dict):
19
service = str(call_kwargs.get("service") or "")
20
23
- if service.lower() not in PROVIDERS:
21
+ if service.lower() not in oauth_provider_ids():
22
return
23
24
result = str(data.get("result") or "").strip()
plugins/_oauth/helpers/config.py
+25
@@ -19,6 +19,14 @@ DEFAULT_CODEX_SCOPES = [
19
"api.connectors.read",
20
"api.connectors.invoke",
21
]
22
+DEFAULT_GEMINI_API_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai"
23
+DEFAULT_GEMINI_API_SCOPES = [
24
+ "openid",
25
+ "email",
26
+ "profile",
27
+ "https://www.googleapis.com/auth/cloud-platform",
28
+ "https://www.googleapis.com/auth/generative-language.retriever",
29
+]
30
31
32
def oauth_config() -> dict[str, Any]:
@@ -51,6 +59,23 @@ def codex_config(config: dict[str, Any] | None = None) -> dict[str, Any]:
59
}
60
61
62
+def gemini_api_config(config: dict[str, Any] | None = None) -> dict[str, Any]:
63
+ source = config if isinstance(config, dict) else oauth_config()
64
+ raw = source.get("gemini_api", {}) if isinstance(source, dict) else {}
65
+ raw = raw if isinstance(raw, dict) else {}
66
+
67
+ return {
68
+ "enabled": _as_bool(raw.get("enabled"), True),
69
+ "client_id": _as_str(raw.get("client_id")),
70
+ "client_secret": _as_str(raw.get("client_secret")),
71
+ "scopes": _as_str_list(raw.get("scopes")) or DEFAULT_GEMINI_API_SCOPES,
72
+ "quota_project_id": _as_str(raw.get("quota_project_id")),
73
+ "api_base_url": _trim_url(raw.get("api_base_url"), DEFAULT_GEMINI_API_BASE_URL),
74
+ "proxy_base_path": _normalize_base_path(raw.get("proxy_base_path"), "/oauth/gemini-api"),
75
+ "callback_path": _normalize_base_path(raw.get("callback_path"), "/oauth/gemini-api/callback"),
76
+ }
77
+
78
+
79
def _as_str(value: Any) -> str:
80
if value is None:
81
return ""
plugins/_oauth/helpers/providers/__init__.py
new
+47
@@ -0,0 +1,47 @@
1
+from __future__ import annotations
2
+
3
+from plugins._oauth.helpers.providers.base import (
4
+ CODEX_PROVIDER_ID,
5
+ DUMMY_API_KEY,
6
+ GEMINI_API_PROVIDER_ID,
7
+ GITHUB_COPILOT_PROVIDER_ID,
8
+ XAI_GROK_PROVIDER_ID,
9
+ CallbackResult,
10
+ LoginPollResult,
11
+ LoginStartResult,
12
+ OAuthProvider,
13
+ OAuthProviderMetadata,
14
+ ProviderError,
15
+ provider_auth_path,
16
+ provider_data_dir,
17
+ public_error,
18
+ read_json_file,
19
+ write_private_json,
20
+)
21
+from plugins._oauth.helpers.providers.registry import (
22
+ get_provider,
23
+ oauth_provider_ids,
24
+ provider_registry,
25
+)
26
+
27
+__all__ = [
28
+ "CODEX_PROVIDER_ID",
29
+ "DUMMY_API_KEY",
30
+ "GEMINI_API_PROVIDER_ID",
31
+ "GITHUB_COPILOT_PROVIDER_ID",
32
+ "XAI_GROK_PROVIDER_ID",
33
+ "CallbackResult",
34
+ "LoginPollResult",
35
+ "LoginStartResult",
36
+ "OAuthProvider",
37
+ "OAuthProviderMetadata",
38
+ "ProviderError",
39
+ "get_provider",
40
+ "oauth_provider_ids",
41
+ "provider_auth_path",
42
+ "provider_data_dir",
43
+ "provider_registry",
44
+ "public_error",
45
+ "read_json_file",
46
+ "write_private_json",
47
+]
plugins/_oauth/helpers/providers/base.py
new
+225
@@ -0,0 +1,225 @@
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
+ usage_plans: list[dict[str, Any]] = field(default_factory=list)
65
+ usage_plan_notes: list[str] = field(default_factory=list)
66
+ usage_plan_sources: list[dict[str, str]] = field(default_factory=list)
67
+
68
+ def to_dict(self) -> dict[str, Any]:
69
+ return asdict(self)
70
+
71
+
72
+@dataclass(frozen=True)
73
+class LoginStartResult:
74
+ ok: bool
75
+ provider_id: str
76
+ flow: str
77
+ auth_url: str = ""
78
+ redirect_uri: str = ""
79
+ attempt_id: str = ""
80
+ verification_url: str = ""
81
+ user_code: str = ""
82
+ interval: int = 5
83
+ expires_at: float = 0
84
+ message: str = ""
85
+ error: str = ""
86
+
87
+ def to_dict(self) -> dict[str, Any]:
88
+ return asdict(self)
89
+
90
+
91
+@dataclass(frozen=True)
92
+class LoginPollResult:
93
+ ok: bool
94
+ provider_id: str
95
+ completed: bool = False
96
+ account_label: str = ""
97
+ account_id: str = ""
98
+ interval: int = 5
99
+ expires_at: float = 0
100
+ expired: bool = False
101
+ error: str = ""
102
+ warning: str = ""
103
+
104
+ def to_dict(self) -> dict[str, Any]:
105
+ return asdict(self)
106
+
107
+
108
+@dataclass(frozen=True)
109
+class CallbackResult:
110
+ ok: bool
111
+ provider_id: str
112
+ account_label: str = ""
113
+ account_id: str = ""
114
+ error: str = ""
115
+
116
+ def to_dict(self) -> dict[str, Any]:
117
+ return asdict(self)
118
+
119
+
120
+class OAuthProvider(Protocol):
121
+ provider_id: str
122
+
123
+ def metadata(self) -> OAuthProviderMetadata:
124
+ ...
125
+
126
+ def status(self) -> dict[str, Any]:
127
+ ...
128
+
129
+ def start_login(self, input: dict[str, Any] | None = None, request: Any = None) -> LoginStartResult:
130
+ ...
131
+
132
+ def poll_login(self, input: dict[str, Any] | None = None, request: Any = None) -> LoginPollResult:
133
+ ...
134
+
135
+ def complete_callback(self, args: dict[str, Any], request: Any) -> CallbackResult:
136
+ ...
137
+
138
+ def manual_callback(self, input: dict[str, Any], request: Any) -> LoginPollResult:
139
+ ...
140
+
141
+ def models(self) -> list[str]:
142
+ ...
143
+
144
+ def disconnect(self) -> dict[str, Any]:
145
+ ...
146
+
147
+ def api_key(self) -> str:
148
+ ...
149
+
150
+ def register_routes(self, app: Any) -> None:
151
+ ...
152
+
153
+
154
+def provider_data_dir(provider_slug: str) -> Path:
155
+ if not _valid_provider_slug(provider_slug):
156
+ raise ProviderError(
157
+ "Invalid OAuth provider storage slug.",
158
+ code="invalid_provider_slug",
159
+ )
160
+
161
+ files = importlib.import_module("helpers.files")
162
+
163
+ path = Path(
164
+ files.get_abs_path(
165
+ files.USER_DIR,
166
+ files.PLUGINS_DIR,
167
+ "_oauth",
168
+ provider_slug,
169
+ )
170
+ )
171
+ path.mkdir(parents=True, exist_ok=True)
172
+ return path
173
+
174
+
175
+def provider_auth_path(provider_slug: str) -> Path:
176
+ return provider_data_dir(provider_slug) / "auth.json"
177
+
178
+
179
+def _valid_provider_slug(provider_slug: str) -> bool:
180
+ if not isinstance(provider_slug, str):
181
+ return False
182
+ if not provider_slug or provider_slug in {".", ".."}:
183
+ return False
184
+ if "/" in provider_slug or "\\" in provider_slug:
185
+ return False
186
+ return all(char.isalnum() or char in {"_", "-"} for char in provider_slug)
187
+
188
+
189
+def read_json_file(path: Path) -> dict[str, Any]:
190
+ try:
191
+ with path.open("r", encoding="utf-8") as handle:
192
+ payload = json.load(handle)
193
+ except FileNotFoundError:
194
+ return {}
195
+ if not isinstance(payload, dict):
196
+ return {}
197
+ return payload
198
+
199
+
200
+def write_private_json(path: Path, data: dict[str, Any]) -> None:
201
+ path.parent.mkdir(parents=True, exist_ok=True)
202
+ tmp_name = ""
203
+ try:
204
+ fd, tmp_name = tempfile.mkstemp(
205
+ prefix=f".{path.name}.",
206
+ suffix=".tmp",
207
+ dir=str(path.parent),
208
+ )
209
+ os.chmod(tmp_name, 0o600)
210
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
211
+ json.dump(data, handle, indent=2)
212
+ handle.write("\n")
213
+ os.replace(tmp_name, path)
214
+ except Exception:
215
+ if tmp_name:
216
+ Path(tmp_name).unlink(missing_ok=True)
217
+ raise
218
+ try:
219
+ path.chmod(0o600)
220
+ except OSError:
221
+ pass
222
+
223
+
224
+def public_error(exc: Exception) -> str:
225
+ return str(exc) or exc.__class__.__name__
plugins/_oauth/helpers/providers/codex.py
new
+286
@@ -0,0 +1,286 @@
1
+from __future__ import annotations
2
+
3
+import secrets
4
+import webbrowser
5
+from typing import Any
6
+
7
+from plugins._oauth.helpers.providers.base import (
8
+ CODEX_PROVIDER_ID,
9
+ DUMMY_API_KEY,
10
+ CallbackResult,
11
+ LoginPollResult,
12
+ LoginStartResult,
13
+ OAuthProviderMetadata,
14
+)
15
+from plugins._oauth.helpers.usage_plans import (
16
+ usage_plan_notes_for,
17
+ usage_plan_sources_for,
18
+ usage_plans_for,
19
+)
20
+from plugins._oauth.helpers.state import (
21
+ get_device_attempt,
22
+ pop_device_attempt,
23
+ put_attempt,
24
+ put_device_attempt,
25
+)
26
+
27
+
28
+CODEX_DEFAULT_MODELS = ["gpt-5.2-codex", "gpt-5.2"]
29
+CODEX_FALLBACK_CONFIG = {
30
+ "enabled": True,
31
+ "models": [],
32
+ "proxy_base_path": "/oauth/codex",
33
+ "callback_path": "/auth/callback",
34
+ "open_browser_from_server": False,
35
+}
36
+
37
+
38
+class CodexOAuthProvider:
39
+ provider_id = CODEX_PROVIDER_ID
40
+
41
+ def metadata(self) -> OAuthProviderMetadata:
42
+ cfg = _codex_config()
43
+ models = cfg["models"] or CODEX_DEFAULT_MODELS
44
+ return OAuthProviderMetadata(
45
+ provider_id=CODEX_PROVIDER_ID,
46
+ display_name="Codex/ChatGPT",
47
+ short_name="Codex",
48
+ model_provider_id=CODEX_PROVIDER_ID,
49
+ icon="openai",
50
+ auth_flow="device_code",
51
+ default_model=models[0] if models else "gpt-5.2-codex",
52
+ default_models=models,
53
+ proxy_base_path=cfg["proxy_base_path"],
54
+ callback_path=cfg["callback_path"],
55
+ usage_plans=usage_plans_for(CODEX_PROVIDER_ID),
56
+ usage_plan_notes=usage_plan_notes_for(CODEX_PROVIDER_ID),
57
+ usage_plan_sources=usage_plan_sources_for(CODEX_PROVIDER_ID),
58
+ )
59
+
60
+ def status(self) -> dict[str, Any]:
61
+ from plugins._oauth.helpers import codex
62
+
63
+ cfg = _codex_config()
64
+ return {
65
+ **self.metadata().to_dict(),
66
+ **codex.status(),
67
+ "enabled": cfg["enabled"],
68
+ "proxy_base_path": cfg["proxy_base_path"],
69
+ "callback_path": cfg["callback_path"],
70
+ "v1_base_path": f'{cfg["proxy_base_path"]}/v1',
71
+ }
72
+
73
+ def start_login(self, input: dict[str, Any] | None = None, request: Any = None) -> LoginStartResult:
74
+ del input, request
75
+ from plugins._oauth.helpers import codex
76
+
77
+ cfg = _codex_config()
78
+ if not cfg["enabled"]:
79
+ return LoginStartResult(
80
+ ok=False,
81
+ provider_id=CODEX_PROVIDER_ID,
82
+ flow="device_code",
83
+ error="Codex/ChatGPT account connection is disabled.",
84
+ )
85
+
86
+ try:
87
+ device = codex.request_device_code()
88
+ attempt_id = secrets.token_urlsafe(24)
89
+ attempt = put_device_attempt(
90
+ attempt_id,
91
+ device["device_auth_id"],
92
+ device["user_code"],
93
+ device["interval"],
94
+ device["expires_at"],
95
+ provider_id=CODEX_PROVIDER_ID,
96
+ )
97
+ except Exception as exc:
98
+ return LoginStartResult(
99
+ ok=False,
100
+ provider_id=CODEX_PROVIDER_ID,
101
+ flow="device_code",
102
+ error=str(exc),
103
+ )
104
+
105
+ return LoginStartResult(
106
+ ok=True,
107
+ provider_id=CODEX_PROVIDER_ID,
108
+ flow="device_code",
109
+ attempt_id=attempt.attempt_id,
110
+ verification_url=device["verification_url"],
111
+ user_code=attempt.user_code,
112
+ interval=attempt.interval,
113
+ expires_at=attempt.expires_at,
114
+ )
115
+
116
+ def start_browser_login(
117
+ self,
118
+ input: dict[str, Any] | None = None,
119
+ request: Any = None,
120
+ ) -> LoginStartResult:
121
+ del input
122
+ cfg = _codex_config()
123
+ if not cfg["enabled"]:
124
+ return LoginStartResult(
125
+ ok=False,
126
+ provider_id=CODEX_PROVIDER_ID,
127
+ flow="browser_pkce",
128
+ error="Codex/ChatGPT account connection is disabled.",
129
+ )
130
+
131
+ from plugins._oauth.helpers import codex
132
+
133
+ redirect_uri = _redirect_uri(request, cfg["callback_path"])
134
+ pkce = codex.generate_pkce()
135
+ state = codex.generate_state()
136
+ attempt = put_attempt(
137
+ state,
138
+ pkce.verifier,
139
+ redirect_uri,
140
+ provider_id=CODEX_PROVIDER_ID,
141
+ )
142
+ auth_url = codex.build_authorize_url(redirect_uri, state, pkce)
143
+
144
+ if cfg["open_browser_from_server"]:
145
+ try:
146
+ webbrowser.open(auth_url)
147
+ except Exception:
148
+ pass
149
+
150
+ return LoginStartResult(
151
+ ok=True,
152
+ provider_id=CODEX_PROVIDER_ID,
153
+ flow="browser_pkce",
154
+ auth_url=auth_url,
155
+ redirect_uri=redirect_uri,
156
+ expires_at=attempt.expires_at,
157
+ )
158
+
159
+ def poll_login(self, input: dict[str, Any] | None = None, request: Any = None) -> LoginPollResult:
160
+ del request
161
+ data = input or {}
162
+ attempt_id = str(data.get("attempt_id") or "").strip()
163
+ if not attempt_id:
164
+ return LoginPollResult(
165
+ ok=False,
166
+ provider_id=CODEX_PROVIDER_ID,
167
+ error="Missing device authorization attempt.",
168
+ )
169
+
170
+ attempt = get_device_attempt(attempt_id)
171
+ if attempt is None:
172
+ return LoginPollResult(
173
+ ok=False,
174
+ provider_id=CODEX_PROVIDER_ID,
175
+ expired=True,
176
+ error="Device authorization expired.",
177
+ )
178
+ if attempt.provider_id != CODEX_PROVIDER_ID:
179
+ return LoginPollResult(
180
+ ok=False,
181
+ provider_id=CODEX_PROVIDER_ID,
182
+ error="Device authorization provider mismatch.",
183
+ )
184
+
185
+ try:
186
+ from plugins._oauth.helpers import codex
187
+
188
+ result = codex.poll_device_authorization(
189
+ attempt.device_auth_id,
190
+ attempt.user_code,
191
+ )
192
+ except Exception as exc:
193
+ return LoginPollResult(ok=False, provider_id=CODEX_PROVIDER_ID, error=str(exc))
194
+
195
+ if result.get("completed"):
196
+ pop_device_attempt(attempt_id)
197
+ account_id = str(result.get("account_id") or "")
198
+ return LoginPollResult(
199
+ ok=True,
200
+ provider_id=CODEX_PROVIDER_ID,
201
+ completed=True,
202
+ account_label=str(result.get("account_label") or account_id),
203
+ account_id=account_id,
204
+ )
205
+
206
+ return LoginPollResult(
207
+ ok=True,
208
+ provider_id=CODEX_PROVIDER_ID,
209
+ completed=False,
210
+ interval=attempt.interval,
211
+ expires_at=attempt.expires_at,
212
+ )
213
+
214
+ def complete_callback(
215
+ self,
216
+ args: dict[str, Any],
217
+ request: Any = None,
218
+ ) -> CallbackResult:
219
+ del args, request
220
+ return CallbackResult(
221
+ ok=False,
222
+ provider_id=CODEX_PROVIDER_ID,
223
+ error="Codex OAuth callback is handled by compatibility routes.",
224
+ )
225
+
226
+ def manual_callback(
227
+ self,
228
+ input: dict[str, Any],
229
+ request: Any = None,
230
+ ) -> LoginPollResult:
231
+ del input, request
232
+ return LoginPollResult(
233
+ ok=False,
234
+ provider_id=CODEX_PROVIDER_ID,
235
+ error="Codex uses device-code login in this UI.",
236
+ )
237
+
238
+ def models(self) -> list[str]:
239
+ from plugins._oauth.helpers import codex
240
+
241
+ return codex.fetch_models()
242
+
243
+ def disconnect(self) -> dict[str, Any]:
244
+ from plugins._oauth.helpers import codex
245
+
246
+ return codex.disconnect_auth()
247
+
248
+ def api_key(self) -> str:
249
+ return DUMMY_API_KEY
250
+
251
+ def register_routes(self, app: Any) -> None:
252
+ del app
253
+ return None
254
+
255
+
256
+def _redirect_uri(request: Any, callback_path: str) -> str:
257
+ origin = ""
258
+ if request is not None:
259
+ origin = (getattr(request, "headers", {}).get("Origin") or "").rstrip("/")
260
+ if not _is_local_origin(origin):
261
+ origin = getattr(request, "url_root", "").rstrip("/")
262
+ return f"{origin}{callback_path}"
263
+
264
+
265
+def _codex_config() -> dict[str, Any]:
266
+ try:
267
+ from plugins._oauth.helpers.config import codex_config
268
+
269
+ return codex_config()
270
+ except ModuleNotFoundError as exc:
271
+ if exc.name and exc.name.startswith("plugins._oauth"):
272
+ raise
273
+ return dict(CODEX_FALLBACK_CONFIG)
274
+
275
+
276
+def _is_local_origin(origin: str) -> bool:
277
+ if not origin:
278
+ return False
279
+ return (
280
+ origin.startswith("http://localhost:")
281
+ or origin == "http://localhost"
282
+ or origin.startswith("http://127.0.0.1:")
283
+ or origin == "http://127.0.0.1"
284
+ or origin.startswith("http://[::1]:")
285
+ or origin == "http://[::1]"
286
+ )
plugins/_oauth/helpers/providers/gemini_api.py
new
+786
@@ -0,0 +1,786 @@
1
+from __future__ import annotations
2
+
3
+import base64
4
+import json
5
+import time
6
+from pathlib import Path
7
+from typing import Any
8
+from urllib.parse import parse_qs, urlencode, urlparse
9
+
10
+from plugins._oauth.helpers.providers.base import (
11
+ DUMMY_API_KEY,
12
+ GEMINI_API_PROVIDER_ID,
13
+ CallbackResult,
14
+ LoginPollResult,
15
+ LoginStartResult,
16
+ OAuthProviderMetadata,
17
+ ProviderError,
18
+ provider_auth_path,
19
+ read_json_file,
20
+ write_private_json,
21
+)
22
+from plugins._oauth.helpers.usage_plans import (
23
+ usage_plan_notes_for,
24
+ usage_plan_sources_for,
25
+ usage_plans_for,
26
+)
27
+from plugins._oauth.helpers import state as state_store
28
+from plugins._oauth.helpers.state import get_attempt, pop_attempt, put_attempt
29
+
30
+
31
+GOOGLE_AUTHORIZATION_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"
32
+GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"
33
+GEMINI_OPENAI_API_BASE = "https://generativelanguage.googleapis.com/v1beta/openai"
34
+CURATED_MODELS = [
35
+ "gemini-3.5-flash",
36
+ "gemini-3.1-pro-preview",
37
+ "gemini-3-flash-preview",
38
+ "gemini-3.1-flash-lite",
39
+ "gemini-2.5-pro",
40
+ "gemini-2.5-flash",
41
+ "gemini-2.5-flash-lite",
42
+]
43
+NOT_CONNECTED_MESSAGE = "Google Gemini API OAuth is not connected yet."
44
+CLIENT_CONFIG_NOTE = (
45
+ "Requires a Google Cloud OAuth client with the Generative Language API enabled. "
46
+ "This uses Gemini API billing/quotas, not Antigravity or Gemini Code Assist subscription quota."
47
+)
48
+REFRESH_MARGIN_MS = 60_000
49
+
50
+
51
+def parse_manual_callback(raw: Any) -> dict[str, str | None] | None:
52
+ text = "" if raw is None else str(raw).strip()
53
+ if not text:
54
+ return None
55
+
56
+ if text.startswith("http://") or text.startswith("https://"):
57
+ query = urlparse(text).query
58
+ elif text.startswith("?"):
59
+ query = text[1:]
60
+ elif "=" in text or "&" in text:
61
+ query = text
62
+ else:
63
+ return {
64
+ "code": text,
65
+ "state": None,
66
+ "error": None,
67
+ "error_description": None,
68
+ }
69
+
70
+ parsed = parse_qs(query, keep_blank_values=True)
71
+ return {
72
+ "code": _first_query_value(parsed, "code"),
73
+ "state": _first_query_value(parsed, "state"),
74
+ "error": _first_query_value(parsed, "error"),
75
+ "error_description": _first_query_value(parsed, "error_description"),
76
+ }
77
+
78
+
79
+class GeminiApiOAuthProvider:
80
+ provider_id = GEMINI_API_PROVIDER_ID
81
+
82
+ def auth_path(self) -> Path:
83
+ return provider_auth_path("gemini_api")
84
+
85
+ def read_auth(self) -> dict[str, Any]:
86
+ return read_json_file(self.auth_path())
87
+
88
+ def write_auth(self, data: dict[str, Any]) -> None:
89
+ write_private_json(self.auth_path(), data)
90
+
91
+ def metadata(self) -> OAuthProviderMetadata:
92
+ cfg = _gemini_api_config()
93
+ base_path = cfg["proxy_base_path"]
94
+ return OAuthProviderMetadata(
95
+ provider_id=GEMINI_API_PROVIDER_ID,
96
+ display_name="Google Gemini API",
97
+ short_name="Gemini API",
98
+ model_provider_id=GEMINI_API_PROVIDER_ID,
99
+ icon="google",
100
+ auth_flow="browser_pkce",
101
+ default_model="gemini-3.5-flash",
102
+ default_models=list(CURATED_MODELS),
103
+ proxy_base_path=base_path,
104
+ callback_path=cfg["callback_path"],
105
+ supports_manual_callback=True,
106
+ supports_oauth_client_config=True,
107
+ supports_quota_project=True,
108
+ note=CLIENT_CONFIG_NOTE,
109
+ usage_plans=usage_plans_for(GEMINI_API_PROVIDER_ID),
110
+ usage_plan_notes=usage_plan_notes_for(GEMINI_API_PROVIDER_ID),
111
+ usage_plan_sources=usage_plan_sources_for(GEMINI_API_PROVIDER_ID),
112
+ )
113
+
114
+ def status(self) -> dict[str, Any]:
115
+ cfg = _gemini_api_config()
116
+ auth = self.read_auth()
117
+ access = str(auth.get("access") or "")
118
+ refresh = str(auth.get("refresh") or "")
119
+ client_id = str(cfg.get("client_id") or auth.get("client_id") or "")
120
+ quota_project_id = str(cfg.get("quota_project_id") or auth.get("quota_project_id") or "")
121
+ result = {
122
+ **self.metadata().to_dict(),
123
+ "enabled": cfg["enabled"],
124
+ "connected": bool(access and refresh),
125
+ "account_label": _account_label(auth) if access or refresh else "",
126
+ "client_id": client_id,
127
+ "client_secret_configured": bool(cfg.get("client_secret") or auth.get("client_secret")),
128
+ "quota_project_id": quota_project_id,
129
+ "base_url": safe_api_base_url(auth.get("base_url") or cfg.get("api_base_url")),
130
+ "auth_file_path": str(self.auth_path()),
131
+ "v1_base_path": f'{cfg["proxy_base_path"]}/v1',
132
+ }
133
+ if access and refresh and _as_int(auth.get("expires"), 0) <= int(time.time() * 1000):
134
+ result["warning"] = "Google Gemini API OAuth access token is expired and will be refreshed on the next request."
135
+ return result
136
+
137
+ def start_login(self, input: dict[str, Any] | None = None, request: Any = None) -> LoginStartResult:
138
+ data = input or {}
139
+ cfg = _gemini_api_config()
140
+ if not cfg["enabled"]:
141
+ return LoginStartResult(
142
+ ok=False,
143
+ provider_id=GEMINI_API_PROVIDER_ID,
144
+ flow="browser_pkce",
145
+ error="Google Gemini API OAuth connection is disabled.",
146
+ )
147
+
148
+ try:
149
+ client = _client_config(data, cfg)
150
+ codex = _codex_helper()
151
+ pkce = codex.generate_pkce()
152
+ state = codex.generate_state()
153
+ redirect_uri = _redirect_uri(request, cfg["callback_path"])
154
+ attempt = put_attempt(
155
+ state,
156
+ pkce.verifier,
157
+ redirect_uri,
158
+ provider_id=GEMINI_API_PROVIDER_ID,
159
+ extra={
160
+ "client_id": client["client_id"],
161
+ "client_secret": client["client_secret"],
162
+ "quota_project_id": client["quota_project_id"],
163
+ "scope": " ".join(cfg["scopes"]),
164
+ "code_challenge": pkce.challenge,
165
+ "token_endpoint": GOOGLE_TOKEN_ENDPOINT,
166
+ "api_base_url": safe_api_base_url(cfg["api_base_url"]),
167
+ },
168
+ )
169
+ query = {
170
+ "response_type": "code",
171
+ "client_id": client["client_id"],
172
+ "redirect_uri": redirect_uri,
173
+ "scope": " ".join(cfg["scopes"]),
174
+ "code_challenge": pkce.challenge,
175
+ "code_challenge_method": "S256",
176
+ "state": state,
177
+ "access_type": "offline",
178
+ "prompt": "consent",
179
+ "include_granted_scopes": "true",
180
+ }
181
+ auth_url = f"{GOOGLE_AUTHORIZATION_ENDPOINT}?{urlencode(query)}"
182
+ except Exception as exc:
183
+ return LoginStartResult(
184
+ ok=False,
185
+ provider_id=GEMINI_API_PROVIDER_ID,
186
+ flow="browser_pkce",
187
+ error=str(exc),
188
+ message=str(exc),
189
+ )
190
+
191
+ return LoginStartResult(
192
+ ok=True,
193
+ provider_id=GEMINI_API_PROVIDER_ID,
194
+ flow="browser_pkce",
195
+ auth_url=auth_url,
196
+ redirect_uri=redirect_uri,
197
+ expires_at=attempt.expires_at,
198
+ )
199
+
200
+ def poll_login(self, input: dict[str, Any] | None = None, request: Any = None) -> LoginPollResult:
201
+ del input, request
202
+ return LoginPollResult(
203
+ ok=False,
204
+ provider_id=GEMINI_API_PROVIDER_ID,
205
+ error="Google Gemini API OAuth uses browser callback login.",
206
+ )
207
+
208
+ def exchange_code(
209
+ self,
210
+ token_endpoint: str,
211
+ code: str,
212
+ redirect_uri: str,
213
+ code_verifier: str,
214
+ client_id: str,
215
+ client_secret: str,
216
+ ) -> dict[str, Any]:
217
+ import requests
218
+
219
+ _validate_google_token_endpoint(token_endpoint)
220
+ data = {
221
+ "grant_type": "authorization_code",
222
+ "code": code,
223
+ "redirect_uri": redirect_uri,
224
+ "client_id": client_id,
225
+ "code_verifier": code_verifier,
226
+ }
227
+ if client_secret:
228
+ data["client_secret"] = client_secret
229
+
230
+ response = requests.post(
231
+ token_endpoint,
232
+ headers={
233
+ "Accept": "application/json",
234
+ "Content-Type": "application/x-www-form-urlencoded",
235
+ },
236
+ data=data,
237
+ timeout=30,
238
+ )
239
+ payload = _json_payload(response)
240
+ if not response.ok:
241
+ raise ProviderError(
242
+ _error_message(payload, f"Google Gemini API token exchange failed with status {response.status_code}."),
243
+ code="token_exchange_failed",
244
+ status=response.status_code,
245
+ )
246
+ _validate_token_payload(payload, require_refresh=True)
247
+ return payload
248
+
249
+ def manual_callback(self, input: dict[str, Any], request: Any = None) -> LoginPollResult:
250
+ del request
251
+ raw = input.get("callback")
252
+ if raw is None:
253
+ raw = input.get("callback_url")
254
+ return self._complete_from_callback(parse_manual_callback(raw), allow_missing_state=True)
255
+
256
+ def complete_callback(
257
+ self,
258
+ args: dict[str, Any],
259
+ request: Any = None,
260
+ ) -> CallbackResult:
261
+ del request
262
+ callback = {
263
+ "code": _as_optional_string(args.get("code")),
264
+ "state": _as_optional_string(args.get("state")),
265
+ "error": _as_optional_string(args.get("error")),
266
+ "error_description": _as_optional_string(args.get("error_description")),
267
+ }
268
+ result = self._complete_from_callback(callback, allow_missing_state=False)
269
+ return CallbackResult(
270
+ ok=result.ok,
271
+ provider_id=GEMINI_API_PROVIDER_ID,
272
+ account_label=result.account_label,
273
+ account_id=result.account_id,
274
+ error=result.error,
275
+ )
276
+
277
+ def _complete_from_callback(
278
+ self,
279
+ callback: dict[str, str | None] | None,
280
+ *,
281
+ allow_missing_state: bool,
282
+ ) -> LoginPollResult:
283
+ if not callback:
284
+ return LoginPollResult(ok=False, provider_id=GEMINI_API_PROVIDER_ID, error="Missing OAuth callback.")
285
+ if callback.get("error"):
286
+ return LoginPollResult(
287
+ ok=False,
288
+ provider_id=GEMINI_API_PROVIDER_ID,
289
+ error=str(callback.get("error_description") or callback.get("error")),
290
+ )
291
+
292
+ code = str(callback.get("code") or "").strip()
293
+ if not code:
294
+ return LoginPollResult(
295
+ ok=False,
296
+ provider_id=GEMINI_API_PROVIDER_ID,
297
+ error="The OAuth callback did not include an authorization code.",
298
+ )
299
+
300
+ state = str(callback.get("state") or "").strip()
301
+ attempt = None
302
+ if state:
303
+ attempt = get_attempt(state)
304
+ if attempt is None:
305
+ if _latest_gemini_attempt() is not None:
306
+ return LoginPollResult(
307
+ ok=False,
308
+ provider_id=GEMINI_API_PROVIDER_ID,
309
+ error="OAuth state mismatch. Return to Agent Zero and start a new Google Gemini API connection.",
310
+ )
311
+ return LoginPollResult(
312
+ ok=False,
313
+ provider_id=GEMINI_API_PROVIDER_ID,
314
+ expired=True,
315
+ error="OAuth sign-in expired. Return to Agent Zero and start a new Google Gemini API connection.",
316
+ )
317
+ if attempt.provider_id != GEMINI_API_PROVIDER_ID:
318
+ return LoginPollResult(
319
+ ok=False,
320
+ provider_id=GEMINI_API_PROVIDER_ID,
321
+ error="OAuth state mismatch. Return to Agent Zero and start a new Google Gemini API connection.",
322
+ )
323
+ elif allow_missing_state:
324
+ attempt = _latest_gemini_attempt()
325
+ if attempt is None:
326
+ return LoginPollResult(
327
+ ok=False,
328
+ provider_id=GEMINI_API_PROVIDER_ID,
329
+ error="No active Google Gemini API sign-in attempt was found.",
330
+ )
331
+ state = attempt.state
332
+ else:
333
+ return LoginPollResult(
334
+ ok=False,
335
+ provider_id=GEMINI_API_PROVIDER_ID,
336
+ error="The OAuth callback did not include state.",
337
+ )
338
+
339
+ token_endpoint = str(attempt.extra.get("token_endpoint") or GOOGLE_TOKEN_ENDPOINT)
340
+ client_id = str(attempt.extra.get("client_id") or "")
341
+ client_secret = str(attempt.extra.get("client_secret") or "")
342
+ try:
343
+ payload = self.exchange_code(
344
+ token_endpoint,
345
+ code,
346
+ attempt.redirect_uri,
347
+ attempt.verifier,
348
+ client_id,
349
+ client_secret,
350
+ )
351
+ auth = _auth_from_token_payload(
352
+ payload,
353
+ token_endpoint,
354
+ client_id=client_id,
355
+ client_secret=client_secret,
356
+ quota_project_id=str(attempt.extra.get("quota_project_id") or ""),
357
+ api_base_url=str(attempt.extra.get("api_base_url") or GEMINI_OPENAI_API_BASE),
358
+ )
359
+ self.write_auth(auth)
360
+ pop_attempt(state)
361
+ except Exception as exc:
362
+ return LoginPollResult(
363
+ ok=False,
364
+ provider_id=GEMINI_API_PROVIDER_ID,
365
+ error=str(exc),
366
+ )
367
+
368
+ label = _account_label(auth)
369
+ return LoginPollResult(
370
+ ok=True,
371
+ provider_id=GEMINI_API_PROVIDER_ID,
372
+ completed=True,
373
+ account_label=label,
374
+ account_id=label,
375
+ )
376
+
377
+ def ensure_fresh_auth(self) -> dict[str, Any]:
378
+ auth = self.read_auth()
379
+ refresh = str(auth.get("refresh") or "")
380
+ if not refresh:
381
+ return auth
382
+
383
+ access = str(auth.get("access") or "")
384
+ expires = _as_int(auth.get("expires"), 0)
385
+ if access and expires and expires > int(time.time() * 1000) + REFRESH_MARGIN_MS:
386
+ return auth
387
+
388
+ token_endpoint = str(auth.get("token_endpoint") or GOOGLE_TOKEN_ENDPOINT)
389
+ _validate_google_token_endpoint(token_endpoint)
390
+ client_id = str(auth.get("client_id") or "")
391
+ client_secret = str(auth.get("client_secret") or "")
392
+ cfg = _gemini_api_config()
393
+ if cfg.get("client_id"):
394
+ client_id = str(cfg["client_id"])
395
+ if cfg.get("client_secret"):
396
+ client_secret = str(cfg["client_secret"])
397
+ if not client_id:
398
+ raise ProviderError(
399
+ "Google Gemini API OAuth refresh requires the original OAuth client ID.",
400
+ code="missing_oauth_client",
401
+ status=401,
402
+ )
403
+
404
+ refreshed = self._refresh_tokens(token_endpoint, refresh, client_id, client_secret, auth)
405
+ self.write_auth(refreshed)
406
+ return refreshed
407
+
408
+ def _refresh_tokens(
409
+ self,
410
+ token_endpoint: str,
411
+ refresh: str,
412
+ client_id: str,
413
+ client_secret: str,
414
+ existing: dict[str, Any],
415
+ ) -> dict[str, Any]:
416
+ import requests
417
+
418
+ data = {
419
+ "grant_type": "refresh_token",
420
+ "refresh_token": refresh,
421
+ "client_id": client_id,
422
+ }
423
+ if client_secret:
424
+ data["client_secret"] = client_secret
425
+ response = requests.post(
426
+ token_endpoint,
427
+ headers={
428
+ "Accept": "application/json",
429
+ "Content-Type": "application/x-www-form-urlencoded",
430
+ },
431
+ data=data,
432
+ timeout=30,
433
+ )
434
+ payload = _json_payload(response)
435
+ if not response.ok:
436
+ raise ProviderError(
437
+ _error_message(payload, f"Google Gemini API token refresh failed with status {response.status_code}."),
438
+ code="token_refresh_failed",
439
+ status=response.status_code,
440
+ )
441
+ _validate_token_payload(payload, require_refresh=False)
442
+ merged = dict(existing)
443
+ merged.update(
444
+ _auth_from_token_payload(
445
+ payload,
446
+ token_endpoint,
447
+ fallback_refresh=refresh,
448
+ client_id=client_id,
449
+ client_secret=client_secret,
450
+ quota_project_id=str(existing.get("quota_project_id") or ""),
451
+ api_base_url=str(existing.get("base_url") or GEMINI_OPENAI_API_BASE),
452
+ )
453
+ )
454
+ if not payload.get("id_token") and existing.get("id_token"):
455
+ merged["id_token"] = existing["id_token"]
456
+ return merged
457
+
458
+ def models(self) -> list[str]:
459
+ if not self.read_auth():
460
+ return list(CURATED_MODELS)
461
+ try:
462
+ auth = self.ensure_fresh_auth()
463
+ except Exception:
464
+ return list(CURATED_MODELS)
465
+ access = str(auth.get("access") or "")
466
+ if not access:
467
+ return list(CURATED_MODELS)
468
+
469
+ base_url = safe_api_base_url(auth.get("base_url"))
470
+ try:
471
+ import requests
472
+
473
+ response = requests.get(
474
+ f"{base_url}/models",
475
+ headers=_gemini_headers(auth),
476
+ timeout=30,
477
+ )
478
+ if not response.ok:
479
+ return list(CURATED_MODELS)
480
+ parsed = _models_from_payload(response.json())
481
+ return parsed or list(CURATED_MODELS)
482
+ except Exception:
483
+ return list(CURATED_MODELS)
484
+
485
+ def disconnect(self) -> dict[str, Any]:
486
+ path = self.auth_path()
487
+ existed = path.exists()
488
+ try:
489
+ path.unlink(missing_ok=True)
490
+ except FileNotFoundError:
491
+ pass
492
+ return {
493
+ "disconnected": existed,
494
+ "removed_auth_files": [str(path)] if existed else [],
495
+ }
496
+
497
+ def api_key(self) -> str:
498
+ return DUMMY_API_KEY
499
+
500
+ def register_routes(self, app: Any) -> None:
501
+ from plugins._oauth.helpers import routes
502
+
503
+ route_defs = [
504
+ ("/oauth/gemini-api/health", "oauth_gemini_api_health", routes.gemini_api_health, ["GET"]),
505
+ ("/oauth/gemini-api/callback", "oauth_gemini_api_callback", routes.gemini_api_callback, ["GET"]),
506
+ (
507
+ "/oauth/gemini-api/v1/models",
508
+ "oauth_gemini_api_models",
509
+ routes.gemini_api_models,
510
+ ["GET", "OPTIONS"],
511
+ ),
512
+ (
513
+ "/oauth/gemini-api/v1/chat/completions",
514
+ "oauth_gemini_api_chat_completions",
515
+ routes.gemini_api_chat_completions,
516
+ ["POST", "OPTIONS"],
517
+ ),
518
+ (
519
+ "/oauth/gemini-api/v1/responses",
520
+ "oauth_gemini_api_responses",
521
+ routes.gemini_api_responses,
522
+ ["POST", "OPTIONS"],
523
+ ),
524
+ ]
525
+ for rule, endpoint, view_func, methods in route_defs:
526
+ if endpoint in app.view_functions:
527
+ continue
528
+ app.add_url_rule(rule, endpoint, view_func, methods=methods)
529
+
530
+
531
+def _auth_from_token_payload(
532
+ payload: dict[str, Any],
533
+ token_endpoint: str,
534
+ *,
535
+ fallback_refresh: str = "",
536
+ client_id: str = "",
537
+ client_secret: str = "",
538
+ quota_project_id: str = "",
539
+ api_base_url: str = GEMINI_OPENAI_API_BASE,
540
+) -> dict[str, Any]:
541
+ return {
542
+ "provider": GEMINI_API_PROVIDER_ID,
543
+ "type": "oauth",
544
+ "access": str(payload.get("access_token") or ""),
545
+ "refresh": str(payload.get("refresh_token") or fallback_refresh or ""),
546
+ "expires": _expires_ms(payload),
547
+ "id_token": str(payload.get("id_token") or ""),
548
+ "token_type": str(payload.get("token_type") or "Bearer"),
549
+ "scope": str(payload.get("scope") or ""),
550
+ "token_endpoint": token_endpoint,
551
+ "base_url": safe_api_base_url(api_base_url),
552
+ "client_id": client_id,
553
+ "client_secret": client_secret,
554
+ "quota_project_id": quota_project_id,
555
+ }
556
+
557
+
558
+def _validate_token_payload(payload: dict[str, Any], *, require_refresh: bool) -> None:
559
+ if not isinstance(payload, dict):
560
+ raise ProviderError("Google Gemini API token endpoint returned a malformed response.", code="token_malformed", status=502)
561
+ missing = []
562
+ if not str(payload.get("access_token") or ""):
563
+ missing.append("access_token")
564
+ if require_refresh and not str(payload.get("refresh_token") or ""):
565
+ missing.append("refresh_token")
566
+ if missing:
567
+ raise ProviderError(
568
+ f"Google Gemini API token response is missing: {', '.join(missing)}",
569
+ code="token_malformed",
570
+ status=502,
571
+ )
572
+
573
+
574
+def safe_api_base_url(value: Any) -> str:
575
+ text = str(value or "").strip().rstrip("/")
576
+ if not text:
577
+ return GEMINI_OPENAI_API_BASE
578
+ parsed = urlparse(text)
579
+ host = (parsed.hostname or "").lower()
580
+ if parsed.scheme == "https" and host == "generativelanguage.googleapis.com":
581
+ return text
582
+ return GEMINI_OPENAI_API_BASE
583
+
584
+
585
+def _gemini_headers(auth: dict[str, Any]) -> dict[str, str]:
586
+ headers = {
587
+ "Accept": "application/json",
588
+ "Authorization": f'Bearer {str(auth.get("access") or "")}',
589
+ }
590
+ quota_project_id = str(auth.get("quota_project_id") or "").strip()
591
+ if quota_project_id:
592
+ headers["x-goog-user-project"] = quota_project_id
593
+ return headers
594
+
595
+
596
+def _client_config(data: dict[str, Any], cfg: dict[str, Any]) -> dict[str, str]:
597
+ client_id = str(data.get("client_id") or cfg.get("client_id") or "").strip()
598
+ client_secret = str(data.get("client_secret") or cfg.get("client_secret") or "").strip()
599
+ quota_project_id = str(data.get("quota_project_id") or cfg.get("quota_project_id") or "").strip()
600
+ if not client_id or not client_secret:
601
+ raise ProviderError(
602
+ "Configure a Google OAuth client ID and client secret before connecting Gemini API OAuth.",
603
+ code="missing_oauth_client",
604
+ )
605
+ return {
606
+ "client_id": client_id,
607
+ "client_secret": client_secret,
608
+ "quota_project_id": quota_project_id,
609
+ }
610
+
611
+
612
+def _redirect_uri(request: Any, callback_path: str) -> str:
613
+ origin = ""
614
+ if request is not None:
615
+ origin = (getattr(request, "headers", {}).get("Origin") or "").rstrip("/")
616
+ if not _is_local_origin(origin):
617
+ origin = getattr(request, "url_root", "").rstrip("/")
618
+ return f"{origin}{callback_path}"
619
+
620
+
621
+def _is_local_origin(origin: str) -> bool:
622
+ if not origin:
623
+ return False
624
+ return (
625
+ origin.startswith("http://localhost:")
626
+ or origin == "http://localhost"
627
+ or origin.startswith("http://127.0.0.1:")
628
+ or origin == "http://127.0.0.1"
629
+ or origin.startswith("http://[::1]:")
630
+ or origin == "http://[::1]"
631
+ )
632
+
633
+
634
+def _validate_google_token_endpoint(value: str) -> None:
635
+ parsed = urlparse(value)
636
+ host = (parsed.hostname or "").lower()
637
+ if parsed.scheme != "https" or host != "oauth2.googleapis.com":
638
+ raise ProviderError(
639
+ "Google Gemini API OAuth token endpoint is invalid.",
640
+ code="invalid_token_endpoint",
641
+ status=502,
642
+ )
643
+
644
+
645
+def _latest_gemini_attempt():
646
+ state_store.cleanup_expired()
647
+ with state_store._lock:
648
+ attempts = [
649
+ attempt
650
+ for attempt in state_store._attempts.values()
651
+ if attempt.provider_id == GEMINI_API_PROVIDER_ID and not attempt.expired()
652
+ ]
653
+ if not attempts:
654
+ return None
655
+ return max(attempts, key=lambda attempt: attempt.created_at)
656
+
657
+
658
+def _models_from_payload(payload: Any) -> list[str]:
659
+ values: list[Any]
660
+ if isinstance(payload, dict) and isinstance(payload.get("data"), list):
661
+ values = payload["data"]
662
+ elif isinstance(payload, dict) and isinstance(payload.get("models"), list):
663
+ values = payload["models"]
664
+ elif isinstance(payload, list):
665
+ values = payload
666
+ else:
667
+ return []
668
+
669
+ models: list[str] = []
670
+ seen: set[str] = set()
671
+ for value in values:
672
+ model_id = ""
673
+ if isinstance(value, str):
674
+ model_id = value
675
+ elif isinstance(value, dict):
676
+ model_id = str(value.get("id") or value.get("name") or "")
677
+ model_id = model_id.strip()
678
+ if model_id.startswith("models/"):
679
+ model_id = model_id.split("/", 1)[1]
680
+ if model_id and model_id not in seen:
681
+ seen.add(model_id)
682
+ models.append(model_id)
683
+ return models
684
+
685
+
686
+def _json_payload(response: Any) -> dict[str, Any]:
687
+ try:
688
+ payload = response.json()
689
+ except Exception:
690
+ payload = {}
691
+ if not isinstance(payload, dict):
692
+ return {}
693
+ return payload
694
+
695
+
696
+def _error_message(payload: dict[str, Any], fallback: str) -> str:
697
+ error = payload.get("error")
698
+ if isinstance(error, dict):
699
+ return str(error.get("message") or error.get("status") or fallback)
700
+ return str(payload.get("error_description") or payload.get("error") or fallback)
701
+
702
+
703
+def _first_query_value(parsed: dict[str, list[str]], key: str) -> str | None:
704
+ values = parsed.get(key) or []
705
+ if not values:
706
+ return None
707
+ return values[0]
708
+
709
+
710
+def _as_optional_string(value: Any) -> str | None:
711
+ if isinstance(value, list):
712
+ value = value[0] if value else None
713
+ text = "" if value is None else str(value).strip()
714
+ return text or None
715
+
716
+
717
+def _account_label(auth: dict[str, Any]) -> str:
718
+ claims = _jwt_claims(str(auth.get("id_token") or ""))
719
+ return str(claims.get("email") or auth.get("account_label") or "Google Gemini API")
720
+
721
+
722
+def _jwt_claims(token: str) -> dict[str, Any]:
723
+ parts = token.split(".")
724
+ if len(parts) < 2:
725
+ return {}
726
+ try:
727
+ payload = parts[1] + "=" * (-len(parts[1]) % 4)
728
+ decoded = base64.urlsafe_b64decode(payload.encode("utf-8"))
729
+ parsed = json.loads(decoded.decode("utf-8"))
730
+ except Exception:
731
+ return {}
732
+ return parsed if isinstance(parsed, dict) else {}
733
+
734
+
735
+def _expires_ms(payload: dict[str, Any]) -> int:
736
+ if payload.get("expires_at") is not None:
737
+ try:
738
+ value = float(payload["expires_at"])
739
+ if value < 1_000_000_000_000:
740
+ value *= 1000
741
+ return int(value)
742
+ except (TypeError, ValueError):
743
+ pass
744
+ try:
745
+ return int((time.time() + float(payload.get("expires_in") or 0)) * 1000)
746
+ except (TypeError, ValueError):
747
+ return 0
748
+
749
+
750
+def _as_int(value: Any, default: int) -> int:
751
+ try:
752
+ return int(value)
753
+ except (TypeError, ValueError):
754
+ return default
755
+
756
+
757
+def _codex_helper():
758
+ import importlib
759
+
760
+ return importlib.import_module("plugins._oauth.helpers.codex")
761
+
762
+
763
+def _gemini_api_config() -> dict[str, Any]:
764
+ try:
765
+ from plugins._oauth.helpers.config import gemini_api_config
766
+
767
+ return gemini_api_config()
768
+ except ModuleNotFoundError as exc:
769
+ if exc.name and exc.name.startswith("plugins._oauth"):
770
+ raise
771
+ return {
772
+ "enabled": True,
773
+ "client_id": "",
774
+ "client_secret": "",
775
+ "scopes": [
776
+ "openid",
777
+ "email",
778
+ "profile",
779
+ "https://www.googleapis.com/auth/cloud-platform",
780
+ "https://www.googleapis.com/auth/generative-language.retriever",
781
+ ],
782
+ "quota_project_id": "",
783
+ "api_base_url": GEMINI_OPENAI_API_BASE,
784
+ "proxy_base_path": "/oauth/gemini-api",
785
+ "callback_path": "/oauth/gemini-api/callback",
786
+ }
plugins/_oauth/helpers/providers/github_copilot.py
new
+673
@@ -0,0 +1,673 @@
1
+from __future__ import annotations
2
+
3
+import secrets
4
+import time
5
+from pathlib import Path
6
+from typing import Any
7
+from urllib.parse import urlparse
8
+
9
+from plugins._oauth.helpers.providers.base import (
10
+ DUMMY_API_KEY,
11
+ GITHUB_COPILOT_PROVIDER_ID,
12
+ CallbackResult,
13
+ LoginPollResult,
14
+ LoginStartResult,
15
+ OAuthProviderMetadata,
16
+ ProviderError,
17
+ provider_auth_path,
18
+ read_json_file,
19
+ write_private_json,
20
+)
21
+from plugins._oauth.helpers.usage_plans import (
22
+ usage_plan_notes_for,
23
+ usage_plan_sources_for,
24
+ usage_plans_for,
25
+)
26
+from plugins._oauth.helpers.state import (
27
+ DeviceAttempt,
28
+ get_device_attempt,
29
+ pop_device_attempt,
30
+ put_device_attempt,
31
+)
32
+
33
+
34
+CLIENT_ID = "Iv1.b507a08c87ecfe98"
35
+DEFAULT_DOMAIN = "github.com"
36
+COPILOT_HEADERS = {
37
+ "User-Agent": "GitHubCopilotChat/0.35.0",
38
+ "Editor-Version": "vscode/1.107.0",
39
+ "Editor-Plugin-Version": "copilot-chat/0.35.0",
40
+ "Copilot-Integration-Id": "vscode-chat",
41
+}
42
+CURATED_MODELS = [
43
+ "gpt-5.2-codex",
44
+ "gpt-5.2",
45
+ "claude-sonnet-4.5",
46
+ "claude-opus-4.5",
47
+ "gemini-2.5-pro",
48
+ "grok-code-fast-1",
49
+ "gpt-4.1",
50
+ "gpt-4o",
51
+]
52
+NOT_CONNECTED_MESSAGE = "GitHub Copilot OAuth is not connected yet."
53
+ENTERPRISE_NOTE = "Leave Enterprise domain blank for github.com, or enter a GitHub Enterprise domain."
54
+DEFAULT_COPILOT_BASE_URL = "https://api.individual.githubcopilot.com"
55
+DEFAULT_COPILOT_HOST = "api.individual.githubcopilot.com"
56
+GITHUB_COPILOT_API_HOSTS = {
57
+ DEFAULT_COPILOT_HOST,
58
+ "api.githubcopilot.com",
59
+}
60
+REFRESH_MARGIN_MS = 60_000
61
+
62
+
63
+def normalize_enterprise_domain(value: Any) -> str:
64
+ text = "" if value is None else str(value).strip()
65
+ if not text:
66
+ return DEFAULT_DOMAIN
67
+ if any(char.isspace() for char in text):
68
+ raise ValueError("Invalid GitHub Enterprise URL/domain.")
69
+
70
+ parsed = urlparse(text if "://" in text else f"//{text}")
71
+ if parsed.scheme and parsed.scheme not in {"http", "https"}:
72
+ raise ValueError("Invalid GitHub Enterprise URL/domain.")
73
+ if "@" in parsed.netloc:
74
+ raise ValueError("Invalid GitHub Enterprise URL/domain.")
75
+
76
+ host = (parsed.hostname or "").strip().lower().rstrip(".")
77
+ if not host or "/" in host or "\\" in host:
78
+ raise ValueError("Invalid GitHub Enterprise URL/domain.")
79
+ if host.startswith("-") or host.endswith("-") or ".." in host:
80
+ raise ValueError("Invalid GitHub Enterprise URL/domain.")
81
+ return host
82
+
83
+
84
+def github_urls(domain: Any) -> dict[str, str]:
85
+ normalized = normalize_enterprise_domain(domain)
86
+ web_base = f"https://{normalized}"
87
+ if normalized == DEFAULT_DOMAIN:
88
+ copilot_token = "https://api.github.com/copilot_internal/v2/token"
89
+ else:
90
+ copilot_token = f"https://{normalized}/api/v3/copilot_internal/v2/token"
91
+ return {
92
+ "device_code": f"{web_base}/login/device/code",
93
+ "access_token": f"{web_base}/login/oauth/access_token",
94
+ "copilot_token": copilot_token,
95
+ }
96
+
97
+
98
+def copilot_base_url_from_token(token: str, enterprise_domain: Any = "") -> str:
99
+ domain = normalize_enterprise_domain(enterprise_domain)
100
+ for part in str(token or "").split(";"):
101
+ key, separator, value = part.partition("=")
102
+ if separator and key.strip() == "proxy-ep":
103
+ try:
104
+ return safe_copilot_base_url_from_proxy_endpoint(value, domain)
105
+ except ProviderError:
106
+ return DEFAULT_COPILOT_BASE_URL
107
+ if domain != DEFAULT_DOMAIN:
108
+ return f"https://copilot-api.{domain}"
109
+ return DEFAULT_COPILOT_BASE_URL
110
+
111
+
112
+def safe_copilot_base_url(value: Any, enterprise_domain: Any = "") -> str:
113
+ text = str(value or "").strip().rstrip("/")
114
+ domain = normalize_enterprise_domain(enterprise_domain)
115
+ if not text:
116
+ return _default_copilot_base_url_for_domain(domain)
117
+
118
+ parsed = urlparse(text)
119
+ host = (parsed.hostname or "").strip().lower().rstrip(".")
120
+ if parsed.scheme == "https" and _is_allowed_copilot_api_host(host, domain):
121
+ return f"https://{host}"
122
+ return _default_copilot_base_url_for_domain(domain)
123
+
124
+
125
+def safe_copilot_base_url_from_proxy_endpoint(value: Any, enterprise_domain: Any = "") -> str:
126
+ text = str(value or "").strip()
127
+ if not text:
128
+ raise ProviderError("GitHub Copilot token returned an invalid proxy endpoint.", code="invalid_proxy_endpoint", status=502)
129
+
130
+ parsed = urlparse(text if "://" in text else f"//{text}")
131
+ if parsed.scheme and parsed.scheme != "https":
132
+ raise ProviderError("GitHub Copilot token returned an invalid proxy endpoint.", code="invalid_proxy_endpoint", status=502)
133
+ try:
134
+ port = parsed.port
135
+ except ValueError as exc:
136
+ raise ProviderError(
137
+ "GitHub Copilot token returned an invalid proxy endpoint.",
138
+ code="invalid_proxy_endpoint",
139
+ status=502,
140
+ ) from exc
141
+ if parsed.username or parsed.password or port is not None:
142
+ raise ProviderError("GitHub Copilot token returned an invalid proxy endpoint.", code="invalid_proxy_endpoint", status=502)
143
+ if parsed.path not in {"", "/"} or parsed.params or parsed.query or parsed.fragment:
144
+ raise ProviderError("GitHub Copilot token returned an invalid proxy endpoint.", code="invalid_proxy_endpoint", status=502)
145
+
146
+ domain = normalize_enterprise_domain(enterprise_domain)
147
+ host = (parsed.hostname or "").strip().lower().rstrip(".")
148
+ if host.startswith("proxy."):
149
+ host = f"api.{host.removeprefix('proxy.')}"
150
+ if not _is_allowed_proxy_endpoint_host(host, domain):
151
+ raise ProviderError("GitHub Copilot token returned an invalid proxy endpoint.", code="invalid_proxy_endpoint", status=502)
152
+ return f"https://{host}"
153
+
154
+
155
+def _default_copilot_base_url_for_domain(domain: str) -> str:
156
+ if domain != DEFAULT_DOMAIN:
157
+ return f"https://copilot-api.{domain}"
158
+ return DEFAULT_COPILOT_BASE_URL
159
+
160
+
161
+def _is_allowed_copilot_api_host(host: str, enterprise_domain: str) -> bool:
162
+ if host in GITHUB_COPILOT_API_HOSTS:
163
+ return True
164
+ return enterprise_domain != DEFAULT_DOMAIN and host == f"copilot-api.{enterprise_domain}"
165
+
166
+
167
+def _is_allowed_proxy_endpoint_host(host: str, enterprise_domain: str) -> bool:
168
+ if host.endswith(".githubcopilot.com"):
169
+ return True
170
+ return enterprise_domain != DEFAULT_DOMAIN and host == f"copilot-api.{enterprise_domain}"
171
+
172
+
173
+class GitHubCopilotOAuthProvider:
174
+ provider_id = GITHUB_COPILOT_PROVIDER_ID
175
+
176
+ def auth_path(self) -> Path:
177
+ return provider_auth_path("github_copilot")
178
+
179
+ def read_auth(self) -> dict[str, Any]:
180
+ return read_json_file(self.auth_path())
181
+
182
+ def write_auth(self, data: dict[str, Any]) -> None:
183
+ write_private_json(self.auth_path(), data)
184
+
185
+ def ensure_fresh_auth(self) -> dict[str, Any]:
186
+ auth = self.read_auth()
187
+ refresh = str(auth.get("refresh") or "")
188
+ access = str(auth.get("access") or "")
189
+ if not refresh or not access:
190
+ return auth
191
+
192
+ expires = _as_int(auth.get("expires"), 0)
193
+ if not expires or expires > int(time.time() * 1000) + REFRESH_MARGIN_MS:
194
+ return auth
195
+
196
+ domain = auth.get("enterprise_domain") or DEFAULT_DOMAIN
197
+ refreshed = refresh_copilot_token(refresh, domain)
198
+ if auth.get("models_warning") and not refreshed.get("models_warning"):
199
+ refreshed["models_warning"] = auth["models_warning"]
200
+ self.write_auth(refreshed)
201
+ return refreshed
202
+
203
+ def metadata(self) -> OAuthProviderMetadata:
204
+ return OAuthProviderMetadata(
205
+ provider_id=GITHUB_COPILOT_PROVIDER_ID,
206
+ display_name="GitHub Copilot",
207
+ short_name="GitHub Copilot",
208
+ model_provider_id=GITHUB_COPILOT_PROVIDER_ID,
209
+ icon="github",
210
+ auth_flow="device_code",
211
+ default_model="gpt-5.2-codex",
212
+ default_models=list(CURATED_MODELS),
213
+ proxy_base_path="/oauth/github-copilot",
214
+ supports_enterprise_domain=True,
215
+ note=ENTERPRISE_NOTE,
216
+ usage_plans=usage_plans_for(GITHUB_COPILOT_PROVIDER_ID),
217
+ usage_plan_notes=usage_plan_notes_for(GITHUB_COPILOT_PROVIDER_ID),
218
+ usage_plan_sources=usage_plan_sources_for(GITHUB_COPILOT_PROVIDER_ID),
219
+ )
220
+
221
+ def status(self) -> dict[str, Any]:
222
+ auth = self.read_auth()
223
+ access = str(auth.get("access") or "")
224
+ refresh = str(auth.get("refresh") or "")
225
+ enterprise_domain = str(auth.get("enterprise_domain") or "")
226
+ domain = enterprise_domain or DEFAULT_DOMAIN
227
+ result = {
228
+ **self.metadata().to_dict(),
229
+ "connected": bool(access and refresh),
230
+ "account_label": domain,
231
+ "enterprise_domain": enterprise_domain,
232
+ "base_url": str(auth.get("base_url") or ""),
233
+ "auth_file_path": str(self.auth_path()),
234
+ }
235
+ if auth.get("models_warning"):
236
+ result["models_warning"] = str(auth["models_warning"])
237
+ return result
238
+
239
+ def start_login(self, input: dict[str, Any] | None = None, request: Any = None) -> LoginStartResult:
240
+ del request
241
+ data = input or {}
242
+ try:
243
+ domain = normalize_enterprise_domain(data.get("enterprise_domain", ""))
244
+ device = _post_device_code(domain)
245
+ device_code = str(device.get("device_code") or "")
246
+ user_code = str(device.get("user_code") or "")
247
+ verification_url = str(
248
+ device.get("verification_uri")
249
+ or device.get("verification_url")
250
+ or "https://github.com/login/device"
251
+ )
252
+ if not device_code or not user_code:
253
+ raise RuntimeError("GitHub device-code response was malformed.")
254
+ interval = _as_positive_int(device.get("interval"), 5)
255
+ expires_in = _as_positive_int(device.get("expires_in"), 900)
256
+ attempt = put_device_attempt(
257
+ secrets.token_urlsafe(24),
258
+ device_code,
259
+ user_code,
260
+ interval,
261
+ time.time() + expires_in,
262
+ provider_id=GITHUB_COPILOT_PROVIDER_ID,
263
+ extra={"domain": domain},
264
+ )
265
+ except Exception as exc:
266
+ message = str(exc)
267
+ return LoginStartResult(
268
+ ok=False,
269
+ provider_id=GITHUB_COPILOT_PROVIDER_ID,
270
+ flow="device_code",
271
+ message=message,
272
+ error=message,
273
+ )
274
+
275
+ return LoginStartResult(
276
+ ok=True,
277
+ provider_id=GITHUB_COPILOT_PROVIDER_ID,
278
+ flow="device_code",
279
+ attempt_id=attempt.attempt_id,
280
+ verification_url=verification_url,
281
+ user_code=attempt.user_code,
282
+ interval=attempt.interval,
283
+ expires_at=attempt.expires_at,
284
+ )
285
+
286
+ def poll_login(self, input: dict[str, Any] | None = None, request: Any = None) -> LoginPollResult:
287
+ del request
288
+ data = input or {}
289
+ attempt_id = str(data.get("attempt_id") or "").strip()
290
+ if not attempt_id:
291
+ return LoginPollResult(
292
+ ok=False,
293
+ provider_id=GITHUB_COPILOT_PROVIDER_ID,
294
+ error="Missing device authorization attempt.",
295
+ )
296
+
297
+ attempt = get_device_attempt(attempt_id)
298
+ if attempt is None:
299
+ return LoginPollResult(
300
+ ok=False,
301
+ provider_id=GITHUB_COPILOT_PROVIDER_ID,
302
+ expired=True,
303
+ error="Device authorization expired.",
304
+ )
305
+ if attempt.provider_id != GITHUB_COPILOT_PROVIDER_ID:
306
+ return LoginPollResult(
307
+ ok=False,
308
+ provider_id=GITHUB_COPILOT_PROVIDER_ID,
309
+ error="Device authorization provider mismatch.",
310
+ )
311
+
312
+ domain = normalize_enterprise_domain(attempt.extra.get("domain", ""))
313
+ try:
314
+ payload = _post_device_poll(domain, attempt.device_auth_id)
315
+ except Exception as exc:
316
+ return LoginPollResult(ok=False, provider_id=GITHUB_COPILOT_PROVIDER_ID, error=str(exc))
317
+
318
+ error = str(payload.get("error") or "")
319
+ if error == "authorization_pending":
320
+ return LoginPollResult(
321
+ ok=True,
322
+ provider_id=GITHUB_COPILOT_PROVIDER_ID,
323
+ completed=False,
324
+ interval=attempt.interval,
325
+ expires_at=attempt.expires_at,
326
+ )
327
+ if error == "slow_down":
328
+ interval = attempt.interval + 5
329
+ put_device_attempt(
330
+ attempt.attempt_id,
331
+ attempt.device_auth_id,
332
+ attempt.user_code,
333
+ interval,
334
+ attempt.expires_at,
335
+ provider_id=GITHUB_COPILOT_PROVIDER_ID,
336
+ extra=attempt.extra,
337
+ )
338
+ return LoginPollResult(
339
+ ok=True,
340
+ provider_id=GITHUB_COPILOT_PROVIDER_ID,
341
+ completed=False,
342
+ interval=interval,
343
+ expires_at=attempt.expires_at,
344
+ )
345
+ if error in {"expired_token", "access_denied"}:
346
+ pop_device_attempt(attempt_id)
347
+ return LoginPollResult(
348
+ ok=False,
349
+ provider_id=GITHUB_COPILOT_PROVIDER_ID,
350
+ expired=error == "expired_token",
351
+ error=error,
352
+ )
353
+ if error:
354
+ return LoginPollResult(ok=False, provider_id=GITHUB_COPILOT_PROVIDER_ID, error=error)
355
+
356
+ github_access_token = str(payload.get("access_token") or "")
357
+ if not github_access_token:
358
+ return LoginPollResult(
359
+ ok=False,
360
+ provider_id=GITHUB_COPILOT_PROVIDER_ID,
361
+ error="GitHub device poll response was malformed.",
362
+ )
363
+
364
+ try:
365
+ auth = refresh_copilot_token(github_access_token, domain)
366
+ except Exception as exc:
367
+ return LoginPollResult(ok=False, provider_id=GITHUB_COPILOT_PROVIDER_ID, error=str(exc))
368
+
369
+ warning = ""
370
+ try:
371
+ summary = enable_known_models(auth["access"], domain)
372
+ if summary.get("failed"):
373
+ auth["models_warning"] = "Some Copilot models could not be enabled."
374
+ warning = auth["models_warning"]
375
+ except Exception as exc:
376
+ auth["models_warning"] = str(exc)
377
+ warning = str(exc)
378
+
379
+ self.write_auth(auth)
380
+ pop_device_attempt(attempt_id)
381
+ return LoginPollResult(
382
+ ok=True,
383
+ provider_id=GITHUB_COPILOT_PROVIDER_ID,
384
+ completed=True,
385
+ account_label=domain,
386
+ warning=warning,
387
+ )
388
+
389
+ def complete_callback(
390
+ self,
391
+ args: dict[str, Any],
392
+ request: Any = None,
393
+ ) -> CallbackResult:
394
+ del args, request
395
+ return CallbackResult(
396
+ ok=False,
397
+ provider_id=GITHUB_COPILOT_PROVIDER_ID,
398
+ error="GitHub Copilot uses device-code login.",
399
+ )
400
+
401
+ def manual_callback(
402
+ self,
403
+ input: dict[str, Any],
404
+ request: Any = None,
405
+ ) -> LoginPollResult:
406
+ del input, request
407
+ return LoginPollResult(
408
+ ok=False,
409
+ provider_id=GITHUB_COPILOT_PROVIDER_ID,
410
+ error="GitHub Copilot uses device-code login.",
411
+ )
412
+
413
+ def models(self) -> list[str]:
414
+ try:
415
+ auth = self.ensure_fresh_auth()
416
+ except Exception:
417
+ return list(CURATED_MODELS)
418
+ access = str(auth.get("access") or "")
419
+ if not access:
420
+ return list(CURATED_MODELS)
421
+
422
+ try:
423
+ import requests
424
+
425
+ base_url = safe_copilot_base_url(auth.get("base_url"), auth.get("enterprise_domain"))
426
+ response = requests.get(
427
+ f"{base_url}/models",
428
+ headers={
429
+ **COPILOT_HEADERS,
430
+ "Accept": "application/json",
431
+ "Authorization": f"Bearer {access}",
432
+ },
433
+ timeout=30,
434
+ )
435
+ if not response.ok:
436
+ return list(CURATED_MODELS)
437
+ parsed = _models_from_payload(response.json())
438
+ return parsed or list(CURATED_MODELS)
439
+ except Exception:
440
+ return list(CURATED_MODELS)
441
+
442
+ def disconnect(self) -> dict[str, Any]:
443
+ path = self.auth_path()
444
+ existed = path.exists()
445
+ try:
446
+ path.unlink(missing_ok=True)
447
+ except FileNotFoundError:
448
+ pass
449
+ return {
450
+ "disconnected": existed,
451
+ "removed_auth_files": [str(path)] if existed else [],
452
+ }
453
+
454
+ def api_key(self) -> str:
455
+ return DUMMY_API_KEY
456
+
457
+ def register_routes(self, app: Any) -> None:
458
+ from plugins._oauth.helpers import routes
459
+
460
+ route_defs = [
461
+ ("/oauth/github-copilot/health", "oauth_github_copilot_health", routes.github_copilot_health, ["GET"]),
462
+ (
463
+ "/oauth/github-copilot/v1/models",
464
+ "oauth_github_copilot_models",
465
+ routes.github_copilot_models,
466
+ ["GET", "OPTIONS"],
467
+ ),
468
+ (
469
+ "/oauth/github-copilot/v1/chat/completions",
470
+ "oauth_github_copilot_chat_completions",
471
+ routes.github_copilot_chat_completions,
472
+ ["POST", "OPTIONS"],
473
+ ),
474
+ (
475
+ "/oauth/github-copilot/v1/responses",
476
+ "oauth_github_copilot_responses",
477
+ routes.github_copilot_responses,
478
+ ["POST", "OPTIONS"],
479
+ ),
480
+ ]
481
+ for rule, endpoint, view_func, methods in route_defs:
482
+ if endpoint in app.view_functions:
483
+ continue
484
+ app.add_url_rule(rule, endpoint, view_func, methods=methods)
485
+
486
+ def _store_device_attempt_for_test(
487
+ self,
488
+ domain: str,
489
+ device_code: str,
490
+ user_code: str,
491
+ interval: int,
492
+ ) -> DeviceAttempt:
493
+ return put_device_attempt(
494
+ secrets.token_urlsafe(24),
495
+ device_code,
496
+ user_code,
497
+ interval,
498
+ time.time() + 900,
499
+ provider_id=GITHUB_COPILOT_PROVIDER_ID,
500
+ extra={"domain": normalize_enterprise_domain(domain)},
501
+ )
502
+
503
+
504
+def refresh_copilot_token(refresh: str, domain: Any) -> dict[str, Any]:
505
+ normalized = normalize_enterprise_domain(domain)
506
+ import requests
507
+
508
+ response = requests.get(
509
+ github_urls(normalized)["copilot_token"],
510
+ headers={
511
+ **COPILOT_HEADERS,
512
+ "Accept": "application/json",
513
+ "Authorization": f"Bearer {refresh}",
514
+ },
515
+ timeout=30,
516
+ )
517
+ if not response.ok:
518
+ raise RuntimeError(f"GitHub Copilot token refresh failed with status {response.status_code}.")
519
+
520
+ payload = response.json()
521
+ if not isinstance(payload, dict):
522
+ raise RuntimeError("GitHub Copilot token response was malformed.")
523
+ token = str(payload.get("token") or payload.get("access_token") or "")
524
+ expires_at = _expires_ms(payload)
525
+ if not token or not expires_at:
526
+ raise RuntimeError("GitHub Copilot token response was missing token data.")
527
+
528
+ return {
529
+ "provider": GITHUB_COPILOT_PROVIDER_ID,
530
+ "type": "oauth",
531
+ "refresh": refresh,
532
+ "access": token,
533
+ "expires": max(0, expires_at - 300_000),
534
+ "enterprise_domain": "" if normalized == DEFAULT_DOMAIN else normalized,
535
+ "base_url": copilot_base_url_from_token(token, normalized),
536
+ }
537
+
538
+
539
+def enable_known_models(token: str, domain: Any) -> dict[str, Any]:
540
+ base_url = copilot_base_url_from_token(token, domain).rstrip("/")
541
+ summary: dict[str, Any] = {"attempted": len(CURATED_MODELS), "enabled": 0, "failed": []}
542
+ try:
543
+ import requests
544
+ except Exception as exc:
545
+ summary["failed"] = [{"model": model, "error": str(exc)} for model in CURATED_MODELS]
546
+ return summary
547
+
548
+ for model in CURATED_MODELS:
549
+ try:
550
+ response = requests.post(
551
+ f"{base_url}/models/{model}/policy",
552
+ headers={
553
+ **COPILOT_HEADERS,
554
+ "Accept": "application/json",
555
+ "Authorization": f"Bearer {token}",
556
+ "Content-Type": "application/json",
557
+ },
558
+ json={"state": "enabled"},
559
+ timeout=30,
560
+ )
561
+ if response.ok:
562
+ summary["enabled"] += 1
563
+ else:
564
+ summary["failed"].append({"model": model, "status": response.status_code})
565
+ except Exception as exc:
566
+ summary["failed"].append({"model": model, "error": str(exc)})
567
+ return summary
568
+
569
+
570
+def _post_device_code(domain: str) -> dict[str, Any]:
571
+ import requests
572
+
573
+ response = requests.post(
574
+ github_urls(domain)["device_code"],
575
+ headers={
576
+ "Accept": "application/json",
577
+ "Content-Type": "application/x-www-form-urlencoded",
578
+ "User-Agent": COPILOT_HEADERS["User-Agent"],
579
+ },
580
+ data={"client_id": CLIENT_ID, "scope": "read:user"},
581
+ timeout=30,
582
+ )
583
+ payload = _json_payload(response)
584
+ if not response.ok:
585
+ raise RuntimeError(str(payload.get("error_description") or payload.get("error") or "GitHub device-code request failed."))
586
+ return payload
587
+
588
+
589
+def _post_device_poll(domain: str, device_code: str) -> dict[str, Any]:
590
+ import requests
591
+
592
+ response = requests.post(
593
+ github_urls(domain)["access_token"],
594
+ headers={
595
+ "Accept": "application/json",
596
+ "Content-Type": "application/x-www-form-urlencoded",
597
+ "User-Agent": COPILOT_HEADERS["User-Agent"],
598
+ },
599
+ data={
600
+ "client_id": CLIENT_ID,
601
+ "device_code": device_code,
602
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
603
+ },
604
+ timeout=30,
605
+ )
606
+ payload = _json_payload(response)
607
+ if response.ok or payload.get("error"):
608
+ return payload
609
+ raise RuntimeError("GitHub device poll request failed.")
610
+
611
+
612
+def _json_payload(response: Any) -> dict[str, Any]:
613
+ try:
614
+ payload = response.json()
615
+ except Exception:
616
+ payload = {}
617
+ if not isinstance(payload, dict):
618
+ return {}
619
+ return payload
620
+
621
+
622
+def _models_from_payload(payload: Any) -> list[str]:
623
+ values: list[Any]
624
+ if isinstance(payload, dict) and isinstance(payload.get("data"), list):
625
+ values = payload["data"]
626
+ elif isinstance(payload, dict) and isinstance(payload.get("models"), list):
627
+ values = payload["models"]
628
+ elif isinstance(payload, list):
629
+ values = payload
630
+ else:
631
+ return []
632
+
633
+ models: list[str] = []
634
+ seen: set[str] = set()
635
+ for value in values:
636
+ model_id = ""
637
+ if isinstance(value, str):
638
+ model_id = value
639
+ elif isinstance(value, dict):
640
+ model_id = str(value.get("id") or value.get("name") or "")
641
+ model_id = model_id.strip()
642
+ if model_id and model_id not in seen:
643
+ seen.add(model_id)
644
+ models.append(model_id)
645
+ return models
646
+
647
+
648
+def _expires_ms(payload: dict[str, Any]) -> int:
649
+ raw = payload.get("expires_at")
650
+ if raw is None and payload.get("expires_in") is not None:
651
+ return int((time.time() + float(payload["expires_in"])) * 1000)
652
+ try:
653
+ value = float(raw)
654
+ except (TypeError, ValueError):
655
+ return 0
656
+ if value < 1_000_000_000_000:
657
+ value *= 1000
658
+ return int(value)
659
+
660
+
661
+def _as_positive_int(value: Any, default: int) -> int:
662
+ try:
663
+ parsed = int(value)
664
+ except (TypeError, ValueError):
665
+ return default
666
+ return parsed if parsed > 0 else default
667
+
668
+
669
+def _as_int(value: Any, default: int) -> int:
670
+ try:
671
+ return int(value)
672
+ except (TypeError, ValueError):
673
+ return default
plugins/_oauth/helpers/providers/registry.py
new
+39
@@ -0,0 +1,39 @@
1
+from __future__ import annotations
2
+
3
+from plugins._oauth.helpers.providers.base import (
4
+ CODEX_PROVIDER_ID,
5
+ OAuthProvider,
6
+)
7
+from plugins._oauth.helpers.providers.codex import CodexOAuthProvider
8
+
9
+
10
+def provider_registry() -> dict[str, OAuthProvider]:
11
+ from plugins._oauth.helpers.providers.gemini_api import GeminiApiOAuthProvider
12
+ from plugins._oauth.helpers.providers.github_copilot import GitHubCopilotOAuthProvider
13
+ from plugins._oauth.helpers.providers.xai_grok import XaiGrokOAuthProvider
14
+
15
+ providers: list[OAuthProvider] = [
16
+ CodexOAuthProvider(),
17
+ GitHubCopilotOAuthProvider(),
18
+ GeminiApiOAuthProvider(),
19
+ XaiGrokOAuthProvider(),
20
+ ]
21
+ return {provider.provider_id: provider for provider in providers}
22
+
23
+
24
+def get_provider(provider_id: str | None = None) -> OAuthProvider:
25
+ if provider_id is None:
26
+ normalized = CODEX_PROVIDER_ID
27
+ else:
28
+ normalized = str(provider_id).strip()
29
+ if not normalized:
30
+ normalized = CODEX_PROVIDER_ID
31
+ registry = provider_registry()
32
+ try:
33
+ return registry[normalized]
34
+ except KeyError as exc:
35
+ raise KeyError(f"Unknown OAuth provider: {normalized}") from exc
36
+
37
+
38
+def oauth_provider_ids() -> set[str]:
39
+ return set(provider_registry())
plugins/_oauth/helpers/providers/xai_grok.py
new
+663
@@ -0,0 +1,663 @@
1
+from __future__ import annotations
2
+
3
+import secrets
4
+import time
5
+import importlib
6
+from pathlib import Path
7
+from typing import Any
8
+from urllib.parse import parse_qs, urlencode, urlparse
9
+
10
+from plugins._oauth.helpers.providers.base import (
11
+ DUMMY_API_KEY,
12
+ XAI_GROK_PROVIDER_ID,
13
+ CallbackResult,
14
+ LoginPollResult,
15
+ LoginStartResult,
16
+ OAuthProviderMetadata,
17
+ ProviderError,
18
+ provider_auth_path,
19
+ read_json_file,
20
+ write_private_json,
21
+)
22
+from plugins._oauth.helpers.usage_plans import (
23
+ usage_plan_notes_for,
24
+ usage_plan_sources_for,
25
+ usage_plans_for,
26
+)
27
+from plugins._oauth.helpers import state as state_store
28
+from plugins._oauth.helpers.state import get_attempt, pop_attempt, put_attempt
29
+
30
+
31
+XAI_ISSUER = "https://auth.x.ai"
32
+XAI_DISCOVERY_URL = f"{XAI_ISSUER}/.well-known/openid-configuration"
33
+XAI_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
34
+XAI_SCOPE = "openid profile email offline_access grok-cli:access api:access"
35
+XAI_REDIRECT_URI = "http://127.0.0.1:56121/callback"
36
+XAI_API_BASE = "https://api.x.ai/v1"
37
+CURATED_MODELS = [
38
+ "grok-4.3",
39
+ "grok-4.20-0309-reasoning",
40
+ "grok-4.20-0309-non-reasoning",
41
+ "grok-4.20-multi-agent-0309",
42
+ "grok-code-fast-1",
43
+]
44
+NOT_CONNECTED_MESSAGE = "xAI Grok OAuth is not connected yet."
45
+OAUTH_TIER_WARNING = (
46
+ "xAI Grok OAuth API access may be restricted by tier. "
47
+ "If OAuth token exchange is denied, the separate API-key `xai` provider may work."
48
+)
49
+REFRESH_MARGIN_MS = 60_000
50
+
51
+
52
+def parse_manual_callback(raw: Any) -> dict[str, str | None] | None:
53
+ text = "" if raw is None else str(raw).strip()
54
+ if not text:
55
+ return None
56
+
57
+ if text.startswith("http://") or text.startswith("https://"):
58
+ query = urlparse(text).query
59
+ elif text.startswith("?"):
60
+ query = text[1:]
61
+ elif "=" in text or "&" in text:
62
+ query = text
63
+ else:
64
+ return {
65
+ "code": text,
66
+ "state": None,
67
+ "error": None,
68
+ "error_description": None,
69
+ }
70
+
71
+ parsed = parse_qs(query, keep_blank_values=True)
72
+ return {
73
+ "code": _first_query_value(parsed, "code"),
74
+ "state": _first_query_value(parsed, "state"),
75
+ "error": _first_query_value(parsed, "error"),
76
+ "error_description": _first_query_value(parsed, "error_description"),
77
+ }
78
+
79
+
80
+class XaiGrokOAuthProvider:
81
+ provider_id = XAI_GROK_PROVIDER_ID
82
+
83
+ def auth_path(self) -> Path:
84
+ return provider_auth_path("xai_grok")
85
+
86
+ def read_auth(self) -> dict[str, Any]:
87
+ return read_json_file(self.auth_path())
88
+
89
+ def write_auth(self, data: dict[str, Any]) -> None:
90
+ write_private_json(self.auth_path(), data)
91
+
92
+ def discovery(self) -> dict[str, str]:
93
+ import requests
94
+
95
+ response = requests.get(
96
+ XAI_DISCOVERY_URL,
97
+ headers={"Accept": "application/json"},
98
+ timeout=30,
99
+ )
100
+ payload = _json_payload(response)
101
+ if not response.ok:
102
+ raise ProviderError(
103
+ _error_message(payload, f"xAI Grok discovery failed with status {response.status_code}."),
104
+ code="discovery_failed",
105
+ status=response.status_code,
106
+ )
107
+ authorization_endpoint = str(payload.get("authorization_endpoint") or "").strip()
108
+ token_endpoint = str(payload.get("token_endpoint") or "").strip()
109
+ if not authorization_endpoint or not token_endpoint:
110
+ raise ProviderError(
111
+ "xAI Grok discovery response was missing OAuth endpoints.",
112
+ code="discovery_malformed",
113
+ status=502,
114
+ )
115
+ _validate_xai_endpoint(authorization_endpoint)
116
+ _validate_xai_endpoint(token_endpoint)
117
+ return {
118
+ "authorization_endpoint": authorization_endpoint,
119
+ "token_endpoint": token_endpoint,
120
+ }
121
+
122
+ def metadata(self) -> OAuthProviderMetadata:
123
+ return OAuthProviderMetadata(
124
+ provider_id=XAI_GROK_PROVIDER_ID,
125
+ display_name="xAI Grok",
126
+ short_name="Grok",
127
+ model_provider_id=XAI_GROK_PROVIDER_ID,
128
+ icon="xai",
129
+ auth_flow="browser_pkce",
130
+ default_model="grok-4.3",
131
+ default_models=list(CURATED_MODELS),
132
+ proxy_base_path="/oauth/xai-grok",
133
+ callback_path="/oauth/xai-grok/callback",
134
+ supports_manual_callback=True,
135
+ warning=OAUTH_TIER_WARNING,
136
+ usage_plans=usage_plans_for(XAI_GROK_PROVIDER_ID),
137
+ usage_plan_notes=usage_plan_notes_for(XAI_GROK_PROVIDER_ID),
138
+ usage_plan_sources=usage_plan_sources_for(XAI_GROK_PROVIDER_ID),
139
+ )
140
+
141
+ def status(self) -> dict[str, Any]:
142
+ auth = self.read_auth()
143
+ access = str(auth.get("access") or "")
144
+ refresh = str(auth.get("refresh") or "")
145
+ result = {
146
+ **self.metadata().to_dict(),
147
+ "connected": bool(access and refresh),
148
+ "account_label": "xAI Grok" if access or refresh else "",
149
+ "base_url": str(auth.get("base_url") or XAI_API_BASE),
150
+ "auth_file_path": str(self.auth_path()),
151
+ }
152
+ warning = str(auth.get("warning") or "")
153
+ if warning:
154
+ result["warning"] = warning
155
+ elif access and refresh and _as_int(auth.get("expires"), 0) <= int(time.time() * 1000):
156
+ result["warning"] = "xAI Grok OAuth access token is expired and will be refreshed on the next request."
157
+ return result
158
+
159
+ def start_login(self, input: dict[str, Any] | None = None, request: Any = None) -> LoginStartResult:
160
+ del input, request
161
+ try:
162
+ codex = importlib.import_module("plugins._oauth.helpers.codex")
163
+ metadata = self.discovery()
164
+ pkce = codex.generate_pkce()
165
+ state = codex.generate_state()
166
+ nonce = secrets.token_urlsafe(24)
167
+ attempt = put_attempt(
168
+ state,
169
+ pkce.verifier,
170
+ XAI_REDIRECT_URI,
171
+ provider_id=XAI_GROK_PROVIDER_ID,
172
+ extra={
173
+ "nonce": nonce,
174
+ "code_challenge": pkce.challenge,
175
+ "token_endpoint": metadata["token_endpoint"],
176
+ },
177
+ )
178
+ query = {
179
+ "response_type": "code",
180
+ "client_id": XAI_CLIENT_ID,
181
+ "redirect_uri": XAI_REDIRECT_URI,
182
+ "scope": XAI_SCOPE,
183
+ "code_challenge": pkce.challenge,
184
+ "code_challenge_method": "S256",
185
+ "state": state,
186
+ "nonce": nonce,
187
+ "plan": "generic",
188
+ "referrer": "agent-zero",
189
+ }
190
+ auth_url = f'{metadata["authorization_endpoint"]}?{urlencode(query)}'
191
+ except Exception as exc:
192
+ return LoginStartResult(
193
+ ok=False,
194
+ provider_id=XAI_GROK_PROVIDER_ID,
195
+ flow="browser_pkce",
196
+ error=str(exc),
197
+ message=str(exc),
198
+ )
199
+
200
+ return LoginStartResult(
201
+ ok=True,
202
+ provider_id=XAI_GROK_PROVIDER_ID,
203
+ flow="browser_pkce",
204
+ auth_url=auth_url,
205
+ redirect_uri=XAI_REDIRECT_URI,
206
+ expires_at=attempt.expires_at,
207
+ )
208
+
209
+ def poll_login(self, input: dict[str, Any] | None = None, request: Any = None) -> LoginPollResult:
210
+ del input, request
211
+ return LoginPollResult(
212
+ ok=False,
213
+ provider_id=XAI_GROK_PROVIDER_ID,
214
+ error="xAI Grok uses browser callback login.",
215
+ )
216
+
217
+ def exchange_code(
218
+ self,
219
+ token_endpoint: str,
220
+ code: str,
221
+ redirect_uri: str,
222
+ code_verifier: str,
223
+ code_challenge: str,
224
+ ) -> dict[str, Any]:
225
+ import requests
226
+
227
+ response = requests.post(
228
+ token_endpoint,
229
+ headers={
230
+ "Accept": "application/json",
231
+ "Content-Type": "application/x-www-form-urlencoded",
232
+ },
233
+ data={
234
+ "grant_type": "authorization_code",
235
+ "code": code,
236
+ "redirect_uri": redirect_uri,
237
+ "client_id": XAI_CLIENT_ID,
238
+ "code_verifier": code_verifier,
239
+ "code_challenge": code_challenge,
240
+ "code_challenge_method": "S256",
241
+ },
242
+ timeout=30,
243
+ )
244
+ payload = _json_payload(response)
245
+ if not response.ok:
246
+ if response.status_code == 403:
247
+ raise ProviderError(
248
+ OAUTH_TIER_WARNING,
249
+ code="oauth_tier_restricted",
250
+ status=403,
251
+ )
252
+ raise ProviderError(
253
+ _error_message(payload, f"xAI Grok token exchange failed with status {response.status_code}."),
254
+ code="token_exchange_failed",
255
+ status=response.status_code,
256
+ )
257
+ _validate_token_payload(payload, require_refresh=True)
258
+ return payload
259
+
260
+ def manual_callback(self, input: dict[str, Any], request: Any = None) -> LoginPollResult:
261
+ del request
262
+ raw = input.get("callback")
263
+ if raw is None:
264
+ raw = input.get("callback_url")
265
+ return self._complete_from_callback(parse_manual_callback(raw), allow_missing_state=True)
266
+
267
+ def complete_callback(
268
+ self,
269
+ args: dict[str, Any],
270
+ request: Any = None,
271
+ ) -> CallbackResult:
272
+ del request
273
+ callback = {
274
+ "code": _as_optional_string(args.get("code")),
275
+ "state": _as_optional_string(args.get("state")),
276
+ "error": _as_optional_string(args.get("error")),
277
+ "error_description": _as_optional_string(args.get("error_description")),
278
+ }
279
+ result = self._complete_from_callback(callback, allow_missing_state=False)
280
+ return CallbackResult(
281
+ ok=result.ok,
282
+ provider_id=XAI_GROK_PROVIDER_ID,
283
+ account_label=result.account_label,
284
+ error=result.error,
285
+ )
286
+
287
+ def _complete_from_callback(
288
+ self,
289
+ callback: dict[str, str | None] | None,
290
+ *,
291
+ allow_missing_state: bool,
292
+ ) -> LoginPollResult:
293
+ if not callback:
294
+ return LoginPollResult(ok=False, provider_id=XAI_GROK_PROVIDER_ID, error="Missing OAuth callback.")
295
+ if callback.get("error"):
296
+ return LoginPollResult(
297
+ ok=False,
298
+ provider_id=XAI_GROK_PROVIDER_ID,
299
+ error=str(callback.get("error_description") or callback.get("error")),
300
+ )
301
+
302
+ code = str(callback.get("code") or "").strip()
303
+ if not code:
304
+ return LoginPollResult(
305
+ ok=False,
306
+ provider_id=XAI_GROK_PROVIDER_ID,
307
+ error="The OAuth callback did not include an authorization code.",
308
+ )
309
+
310
+ state = str(callback.get("state") or "").strip()
311
+ attempt = None
312
+ if state:
313
+ attempt = get_attempt(state)
314
+ if attempt is None:
315
+ if _latest_xai_attempt() is not None:
316
+ return LoginPollResult(
317
+ ok=False,
318
+ provider_id=XAI_GROK_PROVIDER_ID,
319
+ error="OAuth state mismatch. Return to Agent Zero and start a new xAI Grok connection.",
320
+ )
321
+ return LoginPollResult(
322
+ ok=False,
323
+ provider_id=XAI_GROK_PROVIDER_ID,
324
+ expired=True,
325
+ error="OAuth sign-in expired. Return to Agent Zero and start a new xAI Grok connection.",
326
+ )
327
+ if attempt.provider_id != XAI_GROK_PROVIDER_ID:
328
+ return LoginPollResult(
329
+ ok=False,
330
+ provider_id=XAI_GROK_PROVIDER_ID,
331
+ error="OAuth state mismatch. Return to Agent Zero and start a new xAI Grok connection.",
332
+ )
333
+ elif allow_missing_state:
334
+ attempt = _latest_xai_attempt()
335
+ if attempt is None:
336
+ return LoginPollResult(
337
+ ok=False,
338
+ provider_id=XAI_GROK_PROVIDER_ID,
339
+ error="No active xAI Grok sign-in attempt was found.",
340
+ )
341
+ state = attempt.state
342
+ else:
343
+ return LoginPollResult(
344
+ ok=False,
345
+ provider_id=XAI_GROK_PROVIDER_ID,
346
+ error="The OAuth callback did not include state.",
347
+ )
348
+
349
+ token_endpoint = str(attempt.extra.get("token_endpoint") or "")
350
+ if not token_endpoint:
351
+ token_endpoint = self.discovery()["token_endpoint"]
352
+ code_challenge = str(attempt.extra.get("code_challenge") or "")
353
+ try:
354
+ payload = self.exchange_code(
355
+ token_endpoint,
356
+ code,
357
+ attempt.redirect_uri,
358
+ attempt.verifier,
359
+ code_challenge,
360
+ )
361
+ auth = _auth_from_token_payload(payload, token_endpoint)
362
+ self.write_auth(auth)
363
+ pop_attempt(state)
364
+ except Exception as exc:
365
+ return LoginPollResult(
366
+ ok=False,
367
+ provider_id=XAI_GROK_PROVIDER_ID,
368
+ error=str(exc),
369
+ )
370
+
371
+ return LoginPollResult(
372
+ ok=True,
373
+ provider_id=XAI_GROK_PROVIDER_ID,
374
+ completed=True,
375
+ account_label="xAI Grok",
376
+ )
377
+
378
+ def ensure_fresh_auth(self) -> dict[str, Any]:
379
+ auth = self.read_auth()
380
+ access = str(auth.get("access") or "")
381
+ refresh = str(auth.get("refresh") or "")
382
+ if not access or not refresh:
383
+ return auth
384
+
385
+ expires = _as_int(auth.get("expires"), 0)
386
+ if expires and expires > int(time.time() * 1000) + REFRESH_MARGIN_MS:
387
+ return auth
388
+
389
+ token_endpoint = str(auth.get("token_endpoint") or "")
390
+ if not token_endpoint:
391
+ token_endpoint = self.discovery()["token_endpoint"]
392
+ _validate_xai_endpoint(token_endpoint, code="invalid_token_endpoint")
393
+ try:
394
+ refreshed = self._refresh_tokens(token_endpoint, refresh, auth)
395
+ except ProviderError:
396
+ raise
397
+ except Exception as exc:
398
+ raise ProviderError(
399
+ f"xAI Grok OAuth refresh failed: {exc}",
400
+ code="auth_refresh_failed",
401
+ status=401,
402
+ ) from exc
403
+ self.write_auth(refreshed)
404
+ return refreshed
405
+
406
+ def _refresh_tokens(self, token_endpoint: str, refresh: str, existing: dict[str, Any]) -> dict[str, Any]:
407
+ import requests
408
+
409
+ response = requests.post(
410
+ token_endpoint,
411
+ headers={
412
+ "Accept": "application/json",
413
+ "Content-Type": "application/x-www-form-urlencoded",
414
+ },
415
+ data={
416
+ "grant_type": "refresh_token",
417
+ "refresh_token": refresh,
418
+ "client_id": XAI_CLIENT_ID,
419
+ },
420
+ timeout=30,
421
+ )
422
+ payload = _json_payload(response)
423
+ if not response.ok:
424
+ if response.status_code == 403:
425
+ raise ProviderError(
426
+ OAUTH_TIER_WARNING,
427
+ code="oauth_tier_restricted",
428
+ status=403,
429
+ )
430
+ raise ProviderError(
431
+ _error_message(payload, f"xAI Grok token refresh failed with status {response.status_code}."),
432
+ code="token_refresh_failed",
433
+ status=response.status_code,
434
+ )
435
+ _validate_token_payload(payload, require_refresh=False)
436
+ merged = dict(existing)
437
+ merged.update(_auth_from_token_payload(payload, token_endpoint, fallback_refresh=refresh))
438
+ if not payload.get("id_token") and existing.get("id_token"):
439
+ merged["id_token"] = existing["id_token"]
440
+ if not payload.get("token_type") and existing.get("token_type"):
441
+ merged["token_type"] = existing["token_type"]
442
+ return merged
443
+
444
+ def models(self) -> list[str]:
445
+ if not self.read_auth():
446
+ return list(CURATED_MODELS)
447
+ try:
448
+ auth = self.ensure_fresh_auth()
449
+ except Exception:
450
+ return list(CURATED_MODELS)
451
+ access = str(auth.get("access") or "")
452
+ if not access:
453
+ return list(CURATED_MODELS)
454
+
455
+ base_url = safe_api_base_url(auth.get("base_url"))
456
+ try:
457
+ import requests
458
+
459
+ response = requests.get(
460
+ f"{base_url}/models",
461
+ headers={
462
+ "Accept": "application/json",
463
+ "Authorization": f"Bearer {access}",
464
+ },
465
+ timeout=30,
466
+ )
467
+ if not response.ok:
468
+ return list(CURATED_MODELS)
469
+ parsed = _models_from_payload(response.json())
470
+ return parsed or list(CURATED_MODELS)
471
+ except Exception:
472
+ return list(CURATED_MODELS)
473
+
474
+ def disconnect(self) -> dict[str, Any]:
475
+ path = self.auth_path()
476
+ existed = path.exists()
477
+ try:
478
+ path.unlink(missing_ok=True)
479
+ except FileNotFoundError:
480
+ pass
481
+ return {
482
+ "disconnected": existed,
483
+ "removed_auth_files": [str(path)] if existed else [],
484
+ }
485
+
486
+ def api_key(self) -> str:
487
+ return DUMMY_API_KEY
488
+
489
+ def register_routes(self, app: Any) -> None:
490
+ from plugins._oauth.helpers import routes
491
+
492
+ route_defs = [
493
+ ("/oauth/xai-grok/health", "oauth_xai_grok_health", routes.xai_grok_health, ["GET"]),
494
+ ("/oauth/xai-grok/callback", "oauth_xai_grok_callback", routes.xai_grok_callback, ["GET"]),
495
+ (
496
+ "/oauth/xai-grok/v1/models",
497
+ "oauth_xai_grok_models",
498
+ routes.xai_grok_models,
499
+ ["GET", "OPTIONS"],
500
+ ),
501
+ (
502
+ "/oauth/xai-grok/v1/chat/completions",
503
+ "oauth_xai_grok_chat_completions",
504
+ routes.xai_grok_chat_completions,
505
+ ["POST", "OPTIONS"],
506
+ ),
507
+ (
508
+ "/oauth/xai-grok/v1/responses",
509
+ "oauth_xai_grok_responses",
510
+ routes.xai_grok_responses,
511
+ ["POST", "OPTIONS"],
512
+ ),
513
+ ]
514
+ for rule, endpoint, view_func, methods in route_defs:
515
+ if endpoint in app.view_functions:
516
+ continue
517
+ app.add_url_rule(rule, endpoint, view_func, methods=methods)
518
+
519
+
520
+def _auth_from_token_payload(
521
+ payload: dict[str, Any],
522
+ token_endpoint: str,
523
+ *,
524
+ fallback_refresh: str = "",
525
+) -> dict[str, Any]:
526
+ return {
527
+ "provider": XAI_GROK_PROVIDER_ID,
528
+ "type": "oauth",
529
+ "access": str(payload.get("access_token") or ""),
530
+ "refresh": str(payload.get("refresh_token") or fallback_refresh or ""),
531
+ "expires": _expires_ms(payload),
532
+ "id_token": str(payload.get("id_token") or ""),
533
+ "token_type": str(payload.get("token_type") or "Bearer"),
534
+ "token_endpoint": token_endpoint,
535
+ "base_url": XAI_API_BASE,
536
+ }
537
+
538
+
539
+def _validate_token_payload(payload: dict[str, Any], *, require_refresh: bool) -> None:
540
+ if not isinstance(payload, dict):
541
+ raise ProviderError("xAI Grok token endpoint returned a malformed response.", code="token_malformed", status=502)
542
+ missing = []
543
+ if not str(payload.get("access_token") or ""):
544
+ missing.append("access_token")
545
+ if require_refresh and not str(payload.get("refresh_token") or ""):
546
+ missing.append("refresh_token")
547
+ if missing:
548
+ raise ProviderError(
549
+ f"xAI Grok token response is missing: {', '.join(missing)}",
550
+ code="token_malformed",
551
+ status=502,
552
+ )
553
+
554
+
555
+def safe_api_base_url(value: Any) -> str:
556
+ text = str(value or "").strip().rstrip("/")
557
+ if not text:
558
+ return XAI_API_BASE
559
+ parsed = urlparse(text)
560
+ host = (parsed.hostname or "").lower()
561
+ if parsed.scheme == "https" and (host == "api.x.ai" or host.endswith(".api.x.ai")):
562
+ return text
563
+ return XAI_API_BASE
564
+
565
+
566
+def _validate_xai_endpoint(value: str, *, code: str = "discovery_invalid_endpoint") -> None:
567
+ parsed = urlparse(value)
568
+ host = (parsed.hostname or "").lower()
569
+ if parsed.scheme != "https" or not (host == "x.ai" or host.endswith(".x.ai")):
570
+ raise ProviderError(
571
+ "xAI Grok discovery returned an invalid OAuth endpoint.",
572
+ code=code,
573
+ status=502,
574
+ )
575
+
576
+
577
+def _latest_xai_attempt():
578
+ state_store.cleanup_expired()
579
+ with state_store._lock:
580
+ attempts = [
581
+ attempt
582
+ for attempt in state_store._attempts.values()
583
+ if attempt.provider_id == XAI_GROK_PROVIDER_ID and not attempt.expired()
584
+ ]
585
+ if not attempts:
586
+ return None
587
+ return max(attempts, key=lambda attempt: attempt.created_at)
588
+
589
+
590
+def _models_from_payload(payload: Any) -> list[str]:
591
+ values: list[Any]
592
+ if isinstance(payload, dict) and isinstance(payload.get("data"), list):
593
+ values = payload["data"]
594
+ elif isinstance(payload, dict) and isinstance(payload.get("models"), list):
595
+ values = payload["models"]
596
+ elif isinstance(payload, list):
597
+ values = payload
598
+ else:
599
+ return []
600
+
601
+ models: list[str] = []
602
+ seen: set[str] = set()
603
+ for value in values:
604
+ model_id = ""
605
+ if isinstance(value, str):
606
+ model_id = value
607
+ elif isinstance(value, dict):
608
+ model_id = str(value.get("id") or value.get("name") or "")
609
+ model_id = model_id.strip()
610
+ if model_id and model_id not in seen:
611
+ seen.add(model_id)
612
+ models.append(model_id)
613
+ return models
614
+
615
+
616
+def _json_payload(response: Any) -> dict[str, Any]:
617
+ try:
618
+ payload = response.json()
619
+ except Exception:
620
+ payload = {}
621
+ if not isinstance(payload, dict):
622
+ return {}
623
+ return payload
624
+
625
+
626
+def _error_message(payload: dict[str, Any], fallback: str) -> str:
627
+ return str(payload.get("error_description") or payload.get("error") or fallback)
628
+
629
+
630
+def _first_query_value(parsed: dict[str, list[str]], key: str) -> str | None:
631
+ values = parsed.get(key) or []
632
+ if not values:
633
+ return None
634
+ return values[0]
635
+
636
+
637
+def _as_optional_string(value: Any) -> str | None:
638
+ if isinstance(value, list):
639
+ value = value[0] if value else None
640
+ text = "" if value is None else str(value).strip()
641
+ return text or None
642
+
643
+
644
+def _expires_ms(payload: dict[str, Any]) -> int:
645
+ if payload.get("expires_at") is not None:
646
+ try:
647
+ value = float(payload["expires_at"])
648
+ if value < 1_000_000_000_000:
649
+ value *= 1000
650
+ return int(value)
651
+ except (TypeError, ValueError):
652
+ pass
653
+ try:
654
+ return int((time.time() + float(payload.get("expires_in") or 0)) * 1000)
655
+ except (TypeError, ValueError):
656
+ return 0
657
+
658
+
659
+def _as_int(value: Any, default: int) -> int:
660
+ try:
661
+ return int(value)
662
+ except (TypeError, ValueError):
663
+ return default
plugins/_oauth/helpers/routes.py
+333
-1
@@ -9,6 +9,14 @@ from flask import Response, jsonify, request, stream_with_context
9
10
from plugins._oauth.helpers import codex
11
from plugins._oauth.helpers.config import codex_config
12
+from plugins._oauth.helpers.providers import (
13
+ GEMINI_API_PROVIDER_ID,
14
+ GITHUB_COPILOT_PROVIDER_ID,
15
+ XAI_GROK_PROVIDER_ID,
16
+ ProviderError,
17
+ get_provider,
18
+ provider_registry,
19
+)
20
from plugins._oauth.helpers.state import pop_attempt
21
22
@@ -39,6 +47,9 @@ def register_oauth_routes(app) -> None:
47
continue
48
app.add_url_rule(rule, endpoint, view_func, methods=methods)
49
50
+ for provider in provider_registry().values():
51
+ provider.register_routes(app)
52
+
53
54
def codex_health():
55
return jsonify({"ok": True, "provider": "codex", "base_path": codex_config()["proxy_base_path"]})
@@ -91,6 +102,8 @@ def codex_models():
102
],
103
}
104
)
105
+ except ProviderError as exc:
106
+ return _json_error(str(exc), status=exc.status, code=exc.code)
107
except Exception as exc:
108
return _json_error(str(exc), status=502, code="upstream_error")
109
@@ -189,6 +202,325 @@ def codex_chat_completions():
202
)
203
204
205
+def github_copilot_health():
206
+ return jsonify(
207
+ {
208
+ "ok": True,
209
+ "provider": GITHUB_COPILOT_PROVIDER_ID,
210
+ "base_path": "/oauth/github-copilot",
211
+ }
212
+ )
213
+
214
+
215
+def github_copilot_models():
216
+ if request.method == "OPTIONS":
217
+ return _options_response()
218
+ denied = _proxy_denied_response()
219
+ if denied:
220
+ return denied
221
+
222
+ provider = get_provider(GITHUB_COPILOT_PROVIDER_ID)
223
+ return jsonify(
224
+ {
225
+ "object": "list",
226
+ "data": [
227
+ {
228
+ "id": model,
229
+ "object": "model",
230
+ "created": 0,
231
+ "owned_by": "github-copilot-oauth",
232
+ }
233
+ for model in provider.models()
234
+ ],
235
+ }
236
+ )
237
+
238
+
239
+def github_copilot_chat_completions():
240
+ if request.method == "OPTIONS":
241
+ return _options_response()
242
+ return _github_copilot_json_proxy("/chat/completions")
243
+
244
+
245
+def github_copilot_responses():
246
+ if request.method == "OPTIONS":
247
+ return _options_response()
248
+ return _github_copilot_json_proxy("/responses")
249
+
250
+
251
+def xai_grok_health():
252
+ return jsonify(
253
+ {
254
+ "ok": True,
255
+ "provider": XAI_GROK_PROVIDER_ID,
256
+ "base_path": "/oauth/xai-grok",
257
+ }
258
+ )
259
+
260
+
261
+def xai_grok_callback():
262
+ provider = get_provider(XAI_GROK_PROVIDER_ID)
263
+ result = provider.complete_callback(dict(request.args), request)
264
+ if result.ok:
265
+ return _html_page("xAI Grok Connected", result.account_label or "Connected")
266
+ return _html_page("xAI Grok Sign-In Failed", result.error or "The OAuth callback failed."), 400
267
+
268
+
269
+def xai_grok_models():
270
+ if request.method == "OPTIONS":
271
+ return _options_response()
272
+ denied = _proxy_denied_response()
273
+ if denied:
274
+ return denied
275
+
276
+ provider = get_provider(XAI_GROK_PROVIDER_ID)
277
+ return jsonify(
278
+ {
279
+ "object": "list",
280
+ "data": [
281
+ {
282
+ "id": model,
283
+ "object": "model",
284
+ "created": 0,
285
+ "owned_by": "xai-grok-oauth",
286
+ }
287
+ for model in provider.models()
288
+ ],
289
+ }
290
+ )
291
+
292
+
293
+def xai_grok_chat_completions():
294
+ if request.method == "OPTIONS":
295
+ return _options_response()
296
+ return _xai_grok_json_proxy("/chat/completions")
297
+
298
+
299
+def xai_grok_responses():
300
+ if request.method == "OPTIONS":
301
+ return _options_response()
302
+ return _xai_grok_json_proxy("/responses")
303
+
304
+
305
+def gemini_api_health():
306
+ return jsonify(
307
+ {
308
+ "ok": True,
309
+ "provider": GEMINI_API_PROVIDER_ID,
310
+ "base_path": "/oauth/gemini-api",
311
+ }
312
+ )
313
+
314
+
315
+def gemini_api_callback():
316
+ provider = get_provider(GEMINI_API_PROVIDER_ID)
317
+ result = provider.complete_callback(dict(request.args), request)
318
+ if result.ok:
319
+ return _html_page("Google Gemini API Connected", result.account_label or "Connected")
320
+ return _html_page("Google Gemini API Sign-In Failed", result.error or "The OAuth callback failed."), 400
321
+
322
+
323
+def gemini_api_models():
324
+ if request.method == "OPTIONS":
325
+ return _options_response()
326
+ denied = _proxy_denied_response()
327
+ if denied:
328
+ return denied
329
+
330
+ provider = get_provider(GEMINI_API_PROVIDER_ID)
331
+ return jsonify(
332
+ {
333
+ "object": "list",
334
+ "data": [
335
+ {
336
+ "id": model,
337
+ "object": "model",
338
+ "created": 0,
339
+ "owned_by": "gemini-api-oauth",
340
+ }
341
+ for model in provider.models()
342
+ ],
343
+ }
344
+ )
345
+
346
+
347
+def gemini_api_chat_completions():
348
+ if request.method == "OPTIONS":
349
+ return _options_response()
350
+ return _gemini_api_json_proxy("/chat/completions")
351
+
352
+
353
+def gemini_api_responses():
354
+ if request.method == "OPTIONS":
355
+ return _options_response()
356
+ return _gemini_api_json_proxy("/responses")
357
+
358
+
359
+def _github_copilot_json_proxy(path: str):
360
+ denied = _proxy_denied_response()
361
+ if denied:
362
+ return denied
363
+
364
+ body = request.get_json(silent=True)
365
+ if not isinstance(body, dict):
366
+ return _json_error("Request body must be a JSON object.")
367
+
368
+ provider = get_provider(GITHUB_COPILOT_PROVIDER_ID)
369
+ ensure_fresh_auth = getattr(provider, "ensure_fresh_auth", None)
370
+ read_auth = getattr(provider, "read_auth", None)
371
+ try:
372
+ if callable(ensure_fresh_auth):
373
+ auth = ensure_fresh_auth()
374
+ elif callable(read_auth):
375
+ auth = read_auth()
376
+ else:
377
+ auth = {}
378
+ except ProviderError as exc:
379
+ return _json_error(str(exc), status=exc.status, code=exc.code)
380
+ except Exception as exc:
381
+ return _json_error(str(exc), status=502, code="upstream_error")
382
+ access = str(auth.get("access") or "")
383
+ if not access:
384
+ return _json_error("GitHub Copilot OAuth is not connected.", status=401, code="not_connected")
385
+
386
+ wants_stream = body.get("stream") is True
387
+ try:
388
+ import requests
389
+
390
+ from plugins._oauth.helpers.providers.github_copilot import COPILOT_HEADERS, safe_copilot_base_url
391
+
392
+ base_url = safe_copilot_base_url(auth.get("base_url"), auth.get("enterprise_domain"))
393
+
394
+ upstream = requests.post(
395
+ f"{base_url}{path}",
396
+ headers={
397
+ **COPILOT_HEADERS,
398
+ "Authorization": f"Bearer {access}",
399
+ "Content-Type": "application/json",
400
+ },
401
+ json=body,
402
+ stream=wants_stream,
403
+ timeout=120,
404
+ )
405
+ except ProviderError as exc:
406
+ return _json_error(str(exc), status=exc.status, code=exc.code)
407
+ except Exception as exc:
408
+ return _json_error(str(exc), status=502, code="upstream_error")
409
+
410
+ if wants_stream and upstream.ok:
411
+ return _stream_upstream_sse(upstream)
412
+ return _copy_upstream_response(upstream)
413
+
414
+
415
+def _xai_grok_json_proxy(path: str):
416
+ denied = _proxy_denied_response()
417
+ if denied:
418
+ return denied
419
+
420
+ body = request.get_json(silent=True)
421
+ if not isinstance(body, dict):
422
+ return _json_error("Request body must be a JSON object.")
423
+
424
+ provider = get_provider(XAI_GROK_PROVIDER_ID)
425
+ ensure_fresh_auth = getattr(provider, "ensure_fresh_auth", None)
426
+ read_auth = getattr(provider, "read_auth", None)
427
+ try:
428
+ if callable(ensure_fresh_auth):
429
+ auth = ensure_fresh_auth()
430
+ elif callable(read_auth):
431
+ auth = read_auth()
432
+ else:
433
+ auth = {}
434
+ except ProviderError as exc:
435
+ return _json_error(str(exc), status=exc.status, code=exc.code)
436
+ except Exception as exc:
437
+ return _json_error(str(exc), status=502, code="upstream_error")
438
+
439
+ access = str(auth.get("access") or "")
440
+ refresh = str(auth.get("refresh") or "")
441
+ if not access or not refresh:
442
+ return _json_error("xAI Grok OAuth is not connected.", status=401, code="not_connected")
443
+
444
+ from plugins._oauth.helpers.providers.xai_grok import safe_api_base_url
445
+
446
+ base_url = safe_api_base_url(auth.get("base_url"))
447
+ wants_stream = body.get("stream") is True
448
+ try:
449
+ import requests
450
+
451
+ upstream = requests.post(
452
+ f"{base_url}{path}",
453
+ headers={
454
+ "Accept": "application/json",
455
+ "Authorization": f"Bearer {access}",
456
+ "Content-Type": "application/json",
457
+ },
458
+ json=body,
459
+ stream=wants_stream,
460
+ timeout=120,
461
+ )
462
+ except Exception as exc:
463
+ return _json_error(str(exc), status=502, code="upstream_error")
464
+
465
+ if wants_stream and upstream.ok:
466
+ return _stream_upstream_sse(upstream)
467
+ return _copy_upstream_response(upstream)
468
+
469
+
470
+def _gemini_api_json_proxy(path: str):
471
+ denied = _proxy_denied_response()
472
+ if denied:
473
+ return denied
474
+
475
+ body = request.get_json(silent=True)
476
+ if not isinstance(body, dict):
477
+ return _json_error("Request body must be a JSON object.")
478
+
479
+ provider = get_provider(GEMINI_API_PROVIDER_ID)
480
+ ensure_fresh_auth = getattr(provider, "ensure_fresh_auth", None)
481
+ read_auth = getattr(provider, "read_auth", None)
482
+ try:
483
+ if callable(ensure_fresh_auth):
484
+ auth = ensure_fresh_auth()
485
+ elif callable(read_auth):
486
+ auth = read_auth()
487
+ else:
488
+ auth = {}
489
+ except ProviderError as exc:
490
+ return _json_error(str(exc), status=exc.status, code=exc.code)
491
+ except Exception as exc:
492
+ return _json_error(str(exc), status=502, code="upstream_error")
493
+
494
+ access = str(auth.get("access") or "")
495
+ refresh = str(auth.get("refresh") or "")
496
+ if not access or not refresh:
497
+ return _json_error("Google Gemini API OAuth is not connected.", status=401, code="not_connected")
498
+
499
+ from plugins._oauth.helpers.providers.gemini_api import _gemini_headers, safe_api_base_url
500
+
501
+ base_url = safe_api_base_url(auth.get("base_url"))
502
+ wants_stream = body.get("stream") is True
503
+ try:
504
+ import requests
505
+
506
+ upstream = requests.post(
507
+ f"{base_url}{path}",
508
+ headers={
509
+ **_gemini_headers(auth),
510
+ "Content-Type": "application/json",
511
+ },
512
+ json=body,
513
+ stream=wants_stream,
514
+ timeout=120,
515
+ )
516
+ except Exception as exc:
517
+ return _json_error(str(exc), status=502, code="upstream_error")
518
+
519
+ if wants_stream and upstream.ok:
520
+ return _stream_upstream_sse(upstream)
521
+ return _copy_upstream_response(upstream)
522
+
523
+
524
def _stream_upstream_sse(upstream):
525
headers = codex.response_headers(upstream)
526
headers.setdefault("Content-Type", "text/event-stream")
@@ -279,7 +611,7 @@ def _proxy_authorized() -> bool:
611
return True
612
if cfg["require_proxy_token"]:
613
return False
282
- return _host_is_local(request.host) or _remote_is_loopback(request.remote_addr)
614
+ return _remote_is_loopback(request.remote_addr)
615
616
617
def _supplied_proxy_token() -> str:
plugins/_oauth/helpers/state.py
+30
-2
@@ -2,7 +2,8 @@ from __future__ import annotations
2
3
import threading
4
import time
5
-from dataclasses import dataclass
5
+from dataclasses import dataclass, field
6
+from typing import Any
7
8
9
LOGIN_TTL_SECONDS = 10 * 60
@@ -14,6 +15,8 @@ class LoginAttempt:
15
verifier: str
16
redirect_uri: str
17
created_at: float
18
+ provider_id: str = "codex_oauth"
19
+ extra: dict[str, Any] = field(default_factory=dict)
20
21
@property
22
def expires_at(self) -> float:
@@ -30,6 +33,8 @@ class DeviceAttempt:
33
user_code: str
34
interval: int
35
expires_at_value: float
36
+ provider_id: str = "codex_oauth"
37
+ extra: dict[str, Any] = field(default_factory=dict)
38
39
@property
40
def expires_at(self) -> float:
@@ -44,19 +49,37 @@ _attempts: dict[str, LoginAttempt] = {}
49
_device_attempts: dict[str, DeviceAttempt] = {}
50
51
47
-def put_attempt(state: str, verifier: str, redirect_uri: str) -> LoginAttempt:
52
+def put_attempt(
53
+ state: str,
54
+ verifier: str,
55
+ redirect_uri: str,
56
+ *,
57
+ provider_id: str = "codex_oauth",
58
+ extra: dict[str, Any] | None = None,
59
+) -> LoginAttempt:
60
cleanup_expired()
61
attempt = LoginAttempt(
62
state=state,
63
verifier=verifier,
64
redirect_uri=redirect_uri,
65
created_at=time.time(),
66
+ provider_id=provider_id,
67
+ extra=dict(extra or {}),
68
)
69
with _lock:
70
_attempts[state] = attempt
71
return attempt
72
73
74
+def get_attempt(state: str) -> LoginAttempt | None:
75
+ cleanup_expired()
76
+ with _lock:
77
+ attempt = _attempts.get(state)
78
+ if attempt is None or attempt.expired():
79
+ return None
80
+ return attempt
81
+
82
+
83
def pop_attempt(state: str) -> LoginAttempt | None:
84
cleanup_expired()
85
with _lock:
@@ -72,6 +95,9 @@ def put_device_attempt(
95
user_code: str,
96
interval: int,
97
expires_at: float,
98
+ *,
99
+ provider_id: str = "codex_oauth",
100
+ extra: dict[str, Any] | None = None,
101
) -> DeviceAttempt:
102
cleanup_expired()
103
attempt = DeviceAttempt(
@@ -80,6 +106,8 @@ def put_device_attempt(
106
user_code=user_code,
107
interval=interval,
108
expires_at_value=expires_at,
109
+ provider_id=provider_id,
110
+ extra=dict(extra or {}),
111
)
112
with _lock:
113
_device_attempts[attempt_id] = attempt
plugins/_oauth/helpers/usage_plans.py
new
+194
@@ -0,0 +1,194 @@
1
+from __future__ import annotations
2
+
3
+from copy import deepcopy
4
+from typing import Any
5
+
6
+CODEX_PROVIDER_ID = "codex_oauth"
7
+GITHUB_COPILOT_PROVIDER_ID = "github_copilot_oauth"
8
+CLAUDE_CODE_PROVIDER_ID = "claude_code_oauth"
9
+GEMINI_API_PROVIDER_ID = "gemini_api_oauth"
10
+GEMINI_CODE_ASSIST_PROVIDER_ID = "gemini_code_assist_oauth"
11
+XAI_GROK_PROVIDER_ID = "xai_grok_oauth"
12
+
13
+
14
+USAGE_PLAN_CATALOG: dict[str, dict[str, Any]] = {
15
+ CODEX_PROVIDER_ID: {
16
+ "provider_id": CODEX_PROVIDER_ID,
17
+ "display_name": "OpenAI Codex",
18
+ "implemented": True,
19
+ "plans": [
20
+ {"id": "free", "label": "Free", "billing": "$0/month", "usage": "Quick coding tasks with plan-specific limits."},
21
+ {"id": "go", "label": "Go", "billing": "$8/month", "usage": "Lightweight coding tasks."},
22
+ {"id": "plus", "label": "Plus", "billing": "$20/month", "usage": "Focused coding sessions, latest Codex models, and credit extension."},
23
+ {"id": "pro", "label": "Pro", "billing": "From $100/month", "usage": "Higher Codex limits than Plus, including Pro tiers."},
24
+ {"id": "business", "label": "Business", "billing": "Pay as you go", "usage": "Standard or usage-based Codex seats, credits, admin controls, and no training by default."},
25
+ {"id": "enterprise_edu", "label": "Enterprise / Edu", "billing": "Contact sales", "usage": "Enterprise controls, priority processing, audit and usage monitoring."},
26
+ {"id": "api_key", "label": "API Key", "billing": "Token-based API pricing", "usage": "CLI, SDK, or IDE automation without ChatGPT cloud features."},
27
+ ],
28
+ "notes": [
29
+ "Codex is included across ChatGPT Free, Go, Plus, Pro, Business, Edu, and Enterprise plans.",
30
+ "The Pro 2x/boost promotion ended on 2026-05-31.",
31
+ "API-key usage is separate from ChatGPT subscription usage and may receive delayed access to some new Codex models.",
32
+ ],
33
+ "sources": [
34
+ {"label": "OpenAI Codex pricing", "url": "https://developers.openai.com/codex/pricing"},
35
+ {"label": "Using Codex with your ChatGPT plan", "url": "https://help.openai.com/en/articles/11369540"},
36
+ ],
37
+ },
38
+ GITHUB_COPILOT_PROVIDER_ID: {
39
+ "provider_id": GITHUB_COPILOT_PROVIDER_ID,
40
+ "display_name": "GitHub Copilot",
41
+ "implemented": True,
42
+ "plans": [
43
+ {"id": "free", "label": "Copilot Free", "billing": "Free", "usage": "Entry Copilot access with monthly completion limits."},
44
+ {"id": "student", "label": "Copilot Student", "billing": "Free for eligible users", "usage": "Student-entitled Copilot access."},
45
+ {"id": "pro", "label": "Copilot Pro", "billing": "$10/month", "usage": "Individual Copilot access with paid entitlements."},
46
+ {"id": "pro_plus", "label": "Copilot Pro+", "billing": "$39/month", "usage": "Larger individual AI-credit pool."},
47
+ {"id": "max", "label": "Copilot Max", "billing": "$100/month", "usage": "Highest individual monthly AI-credit allowance and priority model access."},
48
+ {"id": "business", "label": "Copilot Business", "billing": "$19/seat/month", "usage": "Team and organization plan with centralized management."},
49
+ {"id": "enterprise", "label": "Copilot Enterprise", "billing": "$39/seat/month", "usage": "Enterprise Cloud plan with larger AI-credit pool and enterprise controls."},
50
+ ],
51
+ "notes": [
52
+ "AI-credit availability and model access can differ by seat and organization policy.",
53
+ "GitHub docs list new signup pauses for several Copilot plans beginning in April 2026.",
54
+ "Beginning 2026-06-01, GitHub docs describe Copilot Max as upgrade-only for users with existing Copilot plans.",
55
+ ],
56
+ "sources": [
57
+ {"label": "GitHub Copilot plans", "url": "https://docs.github.com/en/copilot/get-started/plans"},
58
+ ],
59
+ },
60
+ CLAUDE_CODE_PROVIDER_ID: {
61
+ "provider_id": CLAUDE_CODE_PROVIDER_ID,
62
+ "display_name": "Claude Code",
63
+ "implemented": False,
64
+ "plans": [
65
+ {"id": "pro", "label": "Pro", "billing": "Subscription", "usage": "Shared Claude and Claude Code usage limits."},
66
+ {"id": "max_5x", "label": "Max 5x", "billing": "Subscription", "usage": "Higher included usage than Pro."},
67
+ {"id": "max_20x", "label": "Max 20x", "billing": "Subscription", "usage": "Highest individual Claude Code subscription allocation."},
68
+ {"id": "team", "label": "Team", "billing": "Seat-based subscription", "usage": "Claude Code included with every Team seat; premium seats add more usage."},
69
+ {"id": "enterprise_premium", "label": "Enterprise Premium", "billing": "Seat-based subscription", "usage": "Enterprise seat type with Claude Code access."},
70
+ {"id": "enterprise_usage_based", "label": "Enterprise usage-based", "billing": "API-rate consumption", "usage": "Usage billed by consumption instead of per-seat caps."},
71
+ ],
72
+ "notes": [
73
+ "Claude subscription usage and Anthropic API-key billing are distinct systems.",
74
+ "An ANTHROPIC_API_KEY can make Claude Code use API billing instead of subscription allocation.",
75
+ ],
76
+ "sources": [
77
+ {"label": "Claude Code with Pro or Max", "url": "https://support.claude.com/en/articles/11145838-use-claude-code-with-your-pro-or-max-plan"},
78
+ {"label": "Claude Code with Team or Enterprise", "url": "https://support.claude.com/en/articles/11845131-use-claude-code-with-your-team-or-enterprise-plan"},
79
+ ],
80
+ },
81
+ GEMINI_API_PROVIDER_ID: {
82
+ "provider_id": GEMINI_API_PROVIDER_ID,
83
+ "display_name": "Google Gemini API",
84
+ "implemented": True,
85
+ "plans": [
86
+ {"id": "oauth_cloud_project", "label": "OAuth Cloud project", "billing": "Google Cloud project", "usage": "Official OAuth access using a user-provided Google Cloud OAuth client."},
87
+ {"id": "api_key", "label": "API key", "billing": "Gemini API pricing", "usage": "Standard Gemini API key access through Google AI Studio or Google Cloud."},
88
+ {"id": "vertex_ai", "label": "Vertex AI", "billing": "Google Cloud", "usage": "Enterprise Gemini API access through Vertex AI projects and IAM."},
89
+ ],
90
+ "notes": [
91
+ "Gemini API OAuth requires the Google Generative Language API to be enabled on the user's Cloud project.",
92
+ "OAuth Gemini API usage is separate from Antigravity, Gemini Code Assist, Gemini CLI, Google AI Pro, and Google AI Ultra subscription quotas.",
93
+ "A quota project may be required so Google can attribute billing and quota to the correct Cloud project.",
94
+ ],
95
+ "sources": [
96
+ {"label": "Gemini API OAuth", "url": "https://ai.google.dev/gemini-api/docs/oauth"},
97
+ {"label": "Gemini OpenAI compatibility", "url": "https://ai.google.dev/gemini-api/docs/openai"},
98
+ {"label": "Gemini API billing", "url": "https://ai.google.dev/gemini-api/docs/billing"},
99
+ ],
100
+ },
101
+ GEMINI_CODE_ASSIST_PROVIDER_ID: {
102
+ "provider_id": GEMINI_CODE_ASSIST_PROVIDER_ID,
103
+ "display_name": "Google Gemini / Antigravity",
104
+ "implemented": False,
105
+ "implementation_status": "metadata_only",
106
+ "plans": [
107
+ {"id": "individual", "label": "Antigravity Individual", "billing": "$0/month", "usage": "Baseline Antigravity quota, unlimited tab completions, and CLI access."},
108
+ {"id": "google_ai_pro", "label": "Google AI Pro", "billing": "Subscription", "usage": "More generous Antigravity rate limits and AI credit overages."},
109
+ {"id": "google_ai_ultra", "label": "Google AI Ultra", "billing": "Subscription", "usage": "Highest Antigravity quota, highest weekly limits, and third-party model access."},
110
+ {"id": "organization", "label": "Organization plan", "billing": "Google Cloud", "usage": "Gemini Enterprise Agent Platform access, Cloud project integration, and consumption pricing."},
111
+ {"id": "code_assist_standard", "label": "Gemini Code Assist Standard", "billing": "Google Cloud subscription", "usage": "Organization plan for code assistance, agent mode, and Gemini CLI quotas."},
112
+ {"id": "code_assist_enterprise", "label": "Gemini Code Assist Enterprise", "billing": "Google Cloud subscription", "usage": "Enterprise plan with customization and broader Google Cloud integrations."},
113
+ {"id": "gemini_api_oauth", "label": "Gemini API OAuth", "billing": "Google Cloud project", "usage": "Official third-party OAuth path with a user-provided Google Cloud OAuth client."},
114
+ ],
115
+ "provider_modes": [
116
+ {
117
+ "id": "gemini_api_oauth",
118
+ "label": "Gemini API OAuth",
119
+ "allowed": True,
120
+ "status": "implemented",
121
+ "note": "Implemented as the separate Google Gemini API OAuth provider. It requires a user-configured Google Cloud OAuth client and does not spend Antigravity or Google AI subscription quota.",
122
+ },
123
+ {
124
+ "id": "antigravity_subscription_oauth",
125
+ "label": "Antigravity subscription OAuth",
126
+ "allowed": False,
127
+ "status": "not_implemented",
128
+ "note": "Google Antigravity terms prohibit using third-party tools to access the Antigravity service through its OAuth flow.",
129
+ },
130
+ ],
131
+ "notes": [
132
+ "Official Gemini API OAuth requires a Google Cloud OAuth client and is separate from Google AI Pro, Google AI Ultra, Antigravity, Gemini Code Assist, and Gemini CLI quotas.",
133
+ "Antigravity and Gemini Code Assist subscription OAuth are metadata-only here because Google does not authorize third-party tools to access the service through those product OAuth flows.",
134
+ "Gemini CLI quotas are provided by Gemini Code Assist editions and shared with Code Assist agent mode; the CLI can also use a Gemini API key for pay-as-you-go usage.",
135
+ ],
136
+ "sources": [
137
+ {"label": "Google Antigravity terms", "url": "https://antigravity.google/terms"},
138
+ {"label": "Google Antigravity plans", "url": "https://antigravity.google/docs/plans?id=GoogleAntigravity"},
139
+ {"label": "Google Antigravity pricing", "url": "https://antigravity.google/pricing?app=antigravity"},
140
+ {"label": "Gemini Code Assist overview", "url": "https://developers.google.com/gemini-code-assist/docs/overview"},
141
+ {"label": "Gemini Code Assist quotas", "url": "https://developers.google.com/gemini-code-assist/resources/quotas"},
142
+ {"label": "Gemini CLI", "url": "https://developers.google.com/gemini-code-assist/docs/gemini-cli"},
143
+ {"label": "Gemini API OAuth", "url": "https://ai.google.dev/gemini-api/docs/oauth"},
144
+ ],
145
+ },
146
+ XAI_GROK_PROVIDER_ID: {
147
+ "provider_id": XAI_GROK_PROVIDER_ID,
148
+ "display_name": "xAI Grok",
149
+ "implemented": True,
150
+ "plans": [
151
+ {"id": "free", "label": "Free", "billing": "$0/month", "usage": "Grok app access with free limits."},
152
+ {"id": "supergrok_lite", "label": "SuperGrok Lite", "billing": "Subscription", "usage": "Intermediate Grok app limits."},
153
+ {"id": "supergrok", "label": "SuperGrok", "billing": "$30/month", "usage": "Higher app limits, Grok 4 model, connectors, image and video generation."},
154
+ {"id": "supergrok_heavy", "label": "SuperGrok Heavy", "billing": "Subscription", "usage": "Highest individual Grok app tier."},
155
+ {"id": "business", "label": "Business", "billing": "Team plan", "usage": "Team seats, billing, role-based access, and admin controls."},
156
+ {"id": "enterprise", "label": "Enterprise", "billing": "Contact sales", "usage": "Custom rate limits, SSO, SCIM, data residency, and volume pricing."},
157
+ {"id": "api_credits", "label": "API credits", "billing": "Prepaid/usage credits", "usage": "xAI API access after account setup and credit loading."},
158
+ ],
159
+ "notes": [
160
+ "xAI API billing is separate from Grok app subscriptions unless xAI explicitly entitles the account.",
161
+ "The API quickstart uses API keys from the xAI console and requires loading credits.",
162
+ ],
163
+ "sources": [
164
+ {"label": "xAI pricing", "url": "https://x.ai/pricing"},
165
+ {"label": "xAI API quickstart", "url": "https://docs.x.ai/developers/quickstart"},
166
+ ],
167
+ },
168
+}
169
+
170
+
171
+def usage_plan_catalog() -> dict[str, dict[str, Any]]:
172
+ return deepcopy(USAGE_PLAN_CATALOG)
173
+
174
+
175
+def usage_plan_entry(provider_id: str) -> dict[str, Any]:
176
+ return deepcopy(USAGE_PLAN_CATALOG.get(provider_id, {}))
177
+
178
+
179
+def usage_plans_for(provider_id: str) -> list[dict[str, Any]]:
180
+ entry = usage_plan_entry(provider_id)
181
+ plans = entry.get("plans", [])
182
+ return plans if isinstance(plans, list) else []
183
+
184
+
185
+def usage_plan_notes_for(provider_id: str) -> list[str]:
186
+ entry = usage_plan_entry(provider_id)
187
+ notes = entry.get("notes", [])
188
+ return notes if isinstance(notes, list) else []
189
+
190
+
191
+def usage_plan_sources_for(provider_id: str) -> list[dict[str, str]]:
192
+ entry = usage_plan_entry(provider_id)
193
+ sources = entry.get("sources", [])
194
+ return sources if isinstance(sources, list) else []
plugins/_oauth/webui/config.html
+375
-115
@@ -15,59 +15,132 @@
15
x-effect="$store.oauthConfig.bindConfig(config)"
16
x-destroy="$store.oauthConfig.cleanup()"
17
>
18
- <section class="oauth-hero" :class="$store.oauthConfig.connected() ? 'is-connected' : ''">
19
- <div class="oauth-mark">
20
- <span class="material-symbols-outlined" x-text="$store.oauthConfig.connected() ? 'check' : 'key'"></span>
21
- </div>
18
+ <section class="oauth-provider-grid">
19
+ <template x-for="card in $store.oauthConfig.providerCards()" :key="card.provider_id">
20
+ <article class="oauth-provider-card" :class="card.connected ? 'is-connected' : ''">
21
+ <div class="oauth-provider-head">
22
+ <div class="oauth-mark">
23
+ <span class="material-symbols-outlined" x-text="card.connected ? 'check' : card.mark"></span>
24
+ </div>
25
+ <div class="oauth-copy">
26
+ <h2 x-text="card.display_name"></h2>
27
+ <p x-text="$store.oauthConfig.providerStatusLabel(card.provider_id)"></p>
28
+ </div>
29
+ </div>
30
23
- <div class="oauth-copy">
24
- <h2>Codex/ChatGPT</h2>
25
- <p x-show="!$store.oauthConfig.connected() && !$store.oauthConfig.connecting">
26
- Connect your account to unlock Codex models locally.
27
- </p>
28
- <p x-show="$store.oauthConfig.connected()">
29
- Connected and ready.
30
- </p>
31
- <p x-show="$store.oauthConfig.connecting">
32
- Finish sign-in in the browser tab.
33
- </p>
34
- </div>
31
+ <label class="oauth-provider-input" x-show="card.supports_enterprise_domain && !card.connected">
32
+ <span>Enterprise domain</span>
33
+ <input
34
+ type="text"
35
+ x-model="$store.oauthConfig.providerUiFor(card.provider_id).enterprise_domain"
36
+ name="enterprise_domain"
37
+ placeholder="github.com"
38
+ />
39
+ </label>
40
+
41
+ <div class="oauth-provider-fields" x-show="card.supports_oauth_client_config && !card.connected">
42
+ <label class="oauth-provider-input">
43
+ <span>OAuth client ID</span>
44
+ <input
45
+ type="text"
46
+ x-model="$store.oauthConfig.providerUiFor(card.provider_id).client_id"
47
+ name="oauth_client_id"
48
+ placeholder="Google Cloud OAuth client ID"
49
+ />
50
+ </label>
51
+ <label class="oauth-provider-input">
52
+ <span>OAuth client secret</span>
53
+ <input
54
+ type="password"
55
+ x-model="$store.oauthConfig.providerUiFor(card.provider_id).client_secret"
56
+ name="oauth_client_secret"
57
+ autocomplete="off"
58
+ placeholder="Google Cloud OAuth client secret"
59
+ />
60
+ </label>
61
+ <label class="oauth-provider-input" x-show="card.supports_quota_project">
62
+ <span>Quota project</span>
63
+ <input
64
+ type="text"
65
+ x-model="$store.oauthConfig.providerUiFor(card.provider_id).quota_project_id"
66
+ name="quota_project_id"
67
+ placeholder="Optional Google Cloud project ID"
68
+ />
69
+ </label>
70
+ </div>
71
36
- <div class="oauth-primary">
37
- <button
38
- class="oauth-connect"
39
- type="button"
40
- @click="$store.oauthConfig.connectCodex()"
41
- :disabled="$store.oauthConfig.connecting"
42
- x-show="!$store.oauthConfig.connected()"
43
- >
44
- <span class="material-symbols-outlined" x-text="$store.oauthConfig.connecting ? 'progress_activity' : 'login'"></span>
45
- <span x-text="$store.oauthConfig.connecting ? 'Waiting' : 'Connect'"></span>
46
- </button>
47
- <button
48
- class="oauth-connect danger"
49
- type="button"
50
- @click="$store.oauthConfig.disconnectCodex()"
51
- :disabled="$store.oauthConfig.disconnecting"
52
- x-show="$store.oauthConfig.connected()"
53
- >
54
- <span class="material-symbols-outlined" x-text="$store.oauthConfig.disconnecting ? 'progress_activity' : 'link_off'"></span>
55
- <span x-text="$store.oauthConfig.disconnecting ? 'Disconnecting' : 'Disconnect'"></span>
56
- </button>
57
- </div>
58
- </section>
72
+ <div class="oauth-device" x-show="$store.oauthConfig.devices[card.provider_id]?.user_code">
73
+ <span>Enter this code</span>
74
+ <strong x-text="$store.oauthConfig.devices[card.provider_id]?.user_code"></strong>
75
+ <button class="text-button" type="button" @click="$store.oauthConfig.cancelConnect(card.provider_id)">
76
+ <span class="material-symbols-outlined">close</span>
77
+ <span>Cancel</span>
78
+ </button>
79
+ </div>
80
60
- <section class="oauth-device" x-show="$store.oauthConfig.device">
61
- <span>Enter this code</span>
62
- <strong x-text="$store.oauthConfig.device?.user_code"></strong>
63
- <button class="text-button" type="button" @click="$store.oauthConfig.cancelConnect()">
64
- <span class="material-symbols-outlined">close</span>
65
- <span>Cancel</span>
66
- </button>
81
+ <div class="oauth-manual-callback" x-show="card.supports_manual_callback && $store.oauthConfig.devices[card.provider_id]?.flow === 'browser_pkce' && !card.connected">
82
+ <input
83
+ type="text"
84
+ x-model="$store.oauthConfig.providerUiFor(card.provider_id).manualCallback"
85
+ placeholder="Paste callback URL, query string, or code"
86
+ />
87
+ <button type="button" class="oauth-connect secondary" @click="$store.oauthConfig.submitManualCallback(card.provider_id)">
88
+ <span class="material-symbols-outlined">check</span>
89
+ <span>Submit</span>
90
+ </button>
91
+ <button type="button" class="oauth-connect secondary" @click="$store.oauthConfig.cancelConnect(card.provider_id)">
92
+ <span class="material-symbols-outlined">close</span>
93
+ <span>Cancel</span>
94
+ </button>
95
+ </div>
96
+
97
+ <div class="oauth-auth-attempt" x-show="$store.oauthConfig.devices[card.provider_id] && !$store.oauthConfig.devices[card.provider_id]?.user_code && !card.supports_manual_callback">
98
+ <span>Sign-in started</span>
99
+ <button class="text-button" type="button" @click="$store.oauthConfig.cancelConnect(card.provider_id)">
100
+ <span class="material-symbols-outlined">close</span>
101
+ <span>Cancel</span>
102
+ </button>
103
+ </div>
104
+
105
+ <p class="oauth-provider-note" x-show="card.warning || card.note" x-text="card.warning || card.note"></p>
106
+
107
+ <div class="oauth-primary">
108
+ <button
109
+ class="oauth-connect"
110
+ type="button"
111
+ @click="$store.oauthConfig.connectProvider(card.provider_id)"
112
+ :disabled="Boolean($store.oauthConfig.connectingProvider) || Boolean($store.oauthConfig.disconnectingProvider)"
113
+ x-show="!card.connected"
114
+ >
115
+ <span class="material-symbols-outlined" x-text="$store.oauthConfig.connectingProvider === card.provider_id ? 'progress_activity' : 'login'"></span>
116
+ <span x-text="$store.oauthConfig.connectingProvider === card.provider_id ? 'Waiting' : 'Connect'"></span>
117
+ </button>
118
+ <button
119
+ class="oauth-connect danger"
120
+ type="button"
121
+ @click="$store.oauthConfig.disconnectProvider(card.provider_id)"
122
+ :disabled="$store.oauthConfig.disconnectingProvider === card.provider_id"
123
+ x-show="card.connected"
124
+ >
125
+ <span class="material-symbols-outlined" x-text="$store.oauthConfig.disconnectingProvider === card.provider_id ? 'progress_activity' : 'link_off'"></span>
126
+ <span x-text="$store.oauthConfig.disconnectingProvider === card.provider_id ? 'Disconnecting' : 'Disconnect'"></span>
127
+ </button>
128
+ <button
129
+ class="oauth-connect secondary"
130
+ type="button"
131
+ @click="$store.oauthConfig.loadModels({ providerId: card.provider_id })"
132
+ :disabled="!card.connected || $store.oauthConfig.loadingModelsProvider === card.provider_id"
133
+ >
134
+ <span class="material-symbols-outlined" x-text="$store.oauthConfig.loadingModelsProvider === card.provider_id ? 'progress_activity' : 'search'"></span>
135
+ <span>Check models</span>
136
+ </button>
137
+ </div>
138
+ </article>
139
+ </template>
140
</section>
141
69
- <section class="oauth-usage" x-show="$store.oauthConfig.connected() && $store.oauthConfig.usageWindows().length">
70
- <template x-for="window in $store.oauthConfig.usageWindows()" :key="window.key">
142
+ <section class="oauth-usage" x-show="$store.oauthConfig.providerConnected('codex_oauth') && $store.oauthConfig.usageWindows('codex_oauth').length">
143
+ <template x-for="window in $store.oauthConfig.usageWindows('codex_oauth')" :key="window.key">
144
<div class="oauth-usage-window">
145
<div class="oauth-usage-head">
146
<span>
@@ -86,23 +159,53 @@
159
160
<section class="oauth-status-row">
161
<div>
89
- <span>Status</span>
90
- <strong x-text="$store.oauthConfig.statusLabel()"></strong>
162
+ <span>OAuth Connections</span>
163
+ <strong x-text="$store.oauthConfig.providerCards().filter((card) => card.connected).length + ' connected'"></strong>
164
</div>
92
- <div x-show="$store.oauthConfig.status?.codex?.email">
93
- <span>Account</span>
94
- <strong x-text="$store.oauthConfig.status?.codex?.email"></strong>
165
+ <div>
166
+ <span>Model provider</span>
167
+ <strong x-text="$store.oauthConfig.providerLabel($store.oauthConfig.activeModelProvider)"></strong>
168
</div>
169
<button class="oauth-icon-button" type="button" @click="$store.oauthConfig.loadStatus()" title="Refresh status" aria-label="Refresh status">
170
<span class="material-symbols-outlined">refresh</span>
171
</button>
172
</section>
173
174
+ <section class="oauth-plan-catalog" x-show="$store.oauthConfig.usagePlanEntries().length">
175
+ <div class="oauth-section-head">
176
+ <div>
177
+ <h3>Usage plans</h3>
178
+ <p>Subscription and billing metadata for account-backed model providers.</p>
179
+ </div>
180
+ </div>
181
+
182
+ <div class="oauth-plan-grid">
183
+ <template x-for="entry in $store.oauthConfig.usagePlanEntries()" :key="entry.provider_id">
184
+ <article class="oauth-plan-card" :class="entry.implemented ? 'is-implemented' : 'is-metadata-only'">
185
+ <div class="oauth-plan-head">
186
+ <strong x-text="entry.display_name"></strong>
187
+ <span x-text="$store.oauthConfig.usagePlanStatus(entry)"></span>
188
+ </div>
189
+
190
+ <div class="oauth-plan-list">
191
+ <template x-for="plan in entry.plans" :key="entry.provider_id + plan.id">
192
+ <span x-text="plan.label"></span>
193
+ </template>
194
+ </div>
195
+
196
+ <template x-for="note in $store.oauthConfig.usagePlanNotes(entry)" :key="entry.provider_id + note">
197
+ <p x-text="note"></p>
198
+ </template>
199
+ </article>
200
+ </template>
201
+ </div>
202
+ </section>
203
+
204
<section class="oauth-model-config">
205
<div class="oauth-section-head">
206
<div>
207
<h3>Agent Zero models</h3>
105
- <p>Select the Codex/ChatGPT models used by the Main and Utility model slots.</p>
208
+ <p>Select OAuth provider models used by the Main model and Utility model slots.</p>
209
</div>
210
<span class="oauth-save-chip" x-show="$store.oauthConfig.modelConfigDirty">Pending changes</span>
211
</div>
@@ -119,27 +222,26 @@
222
<span class="oauth-model-icon material-symbols-outlined" x-text="slot.icon"></span>
223
<div class="oauth-model-title">
224
<strong x-text="slot.title"></strong>
122
- <span
123
- x-show="!$store.oauthConfig.slotUsesCodex(slot.key)"
124
- x-text="$store.oauthConfig.slotStatusLabel(slot.key)"
125
- ></span>
225
+ <span x-text="$store.oauthConfig.slotStatusLabel(slot.key)"></span>
226
</div>
227
<div class="oauth-model-actions">
128
- <button
129
- class="oauth-model-action"
130
- type="button"
131
- x-show="!$store.oauthConfig.slotUsesCodex(slot.key)"
132
- @click="$store.oauthConfig.useCodexForSlot(slot.key)"
133
- >
134
- <span class="material-symbols-outlined">swap_horiz</span>
135
- <span>Use Codex</span>
136
- </button>
228
+ <template x-for="provider in $store.oauthConfig.providerCards().filter((card) => card.connected)" :key="slot.key + provider.provider_id">
229
+ <button
230
+ class="oauth-model-action"
231
+ type="button"
232
+ x-show="!$store.oauthConfig.slotUsesProvider(slot.key, provider.provider_id)"
233
+ @click="$store.oauthConfig.useProviderForSlot(slot.key, provider.provider_id)"
234
+ >
235
+ <span class="material-symbols-outlined">swap_horiz</span>
236
+ <span x-text="$store.oauthConfig.providerUseLabel(provider.provider_id)"></span>
237
+ </button>
238
+ </template>
239
<button
240
class="oauth-model-action icon"
241
type="button"
242
title="Copy Main model"
243
aria-label="Copy Main model"
142
- x-show="slot.key === 'utility_model' && $store.oauthConfig.slotUsesCodex('chat_model')"
244
+ x-show="slot.key === 'utility_model' && $store.oauthConfig.slotUsesOauth('chat_model')"
245
@click="$store.oauthConfig.copyMainToUtility()"
246
>
247
<span class="material-symbols-outlined">content_copy</span>
@@ -156,24 +258,24 @@
258
x-model="$store.oauthConfig.modelSlot(slot.key).name"
259
@input="$store.oauthConfig.markModelDirty(slot.key)"
260
@focus="$store.oauthConfig.openModelDropdown(slot.key)"
159
- :disabled="!$store.oauthConfig.slotUsesCodex(slot.key)"
160
- placeholder="Search or enter a Codex model"
261
+ :disabled="!$store.oauthConfig.slotUsesOauth(slot.key)"
262
+ placeholder="Search or enter a provider model"
263
/>
264
<button
265
class="oauth-model-search"
266
type="button"
165
- title="Search available Codex models"
166
- aria-label="Search available Codex models"
167
- @click="$store.oauthConfig.loadModels({ openDropdown: slot.key })"
168
- :disabled="!$store.oauthConfig.slotUsesCodex(slot.key) || !$store.oauthConfig.connected() || $store.oauthConfig.loadingModels"
267
+ title="Check models"
268
+ aria-label="Check models"
269
+ @click="$store.oauthConfig.loadModels({ providerId: $store.oauthConfig.modelSlot(slot.key).provider, openDropdown: slot.key })"
270
+ :disabled="!$store.oauthConfig.slotUsesOauth(slot.key) || !$store.oauthConfig.providerConnected($store.oauthConfig.modelSlot(slot.key).provider) || $store.oauthConfig.loadingModelsProvider === $store.oauthConfig.modelSlot(slot.key).provider"
271
>
170
- <span class="material-symbols-outlined" x-text="$store.oauthConfig.loadingModels ? 'progress_activity' : 'search'"></span>
272
+ <span class="material-symbols-outlined" x-text="$store.oauthConfig.loadingModelsProvider === $store.oauthConfig.modelSlot(slot.key).provider ? 'progress_activity' : 'search'"></span>
273
</button>
274
</div>
275
276
<div
277
class="oauth-model-dropdown"
176
- x-show="$store.oauthConfig.modelDropdown[slot.key]?.open && !$store.oauthConfig.loadingModels"
278
+ x-show="$store.oauthConfig.modelDropdown[slot.key]?.open && !$store.oauthConfig.loadingModelsProvider"
279
x-transition.opacity
280
>
281
<template x-for="model in $store.oauthConfig.filteredModels(slot.key)" :key="slot.key + model">
@@ -199,7 +301,7 @@
301
<div class="oauth-section-head">
302
<div>
303
<h3>Available models</h3>
202
- <p>Available models from Codex account</p>
304
+ <p>Available models from selected provider</p>
305
</div>
306
</div>
307
<div class="oauth-models">
@@ -214,8 +316,8 @@
316
317
<div class="oauth-details">
318
<div>
217
- <span>Endpoint</span>
218
- <code x-text="$store.oauthConfig.endpointUrl()"></code>
319
+ <span>Codex endpoint</span>
320
+ <code x-text="$store.oauthConfig.providerEndpointUrl('codex_oauth')"></code>
321
</div>
322
<div>
323
<span>Auth file</span>
@@ -230,19 +332,19 @@
332
<small>Use an Agent Zero-owned file. Shared Codex CLI auth files are rejected.</small>
333
</label>
334
<label>
233
- <span>Issuer</span>
335
+ <span>Codex issuer</span>
336
<input type="text" x-model="$store.oauthConfig.codex().issuer" />
337
</label>
338
<label>
237
- <span>Token URL</span>
339
+ <span>Codex token URL</span>
340
<input type="text" x-model="$store.oauthConfig.codex().token_url" />
341
</label>
342
<label>
241
- <span>Base path</span>
343
+ <span>Codex base path</span>
344
<input type="text" x-model="$store.oauthConfig.codex().proxy_base_path" />
345
</label>
346
<label>
245
- <span>Upstream URL</span>
347
+ <span>Codex upstream URL</span>
348
<input type="text" x-model="$store.oauthConfig.codex().upstream_base_url" />
349
</label>
350
<label>
@@ -250,11 +352,11 @@
352
<input type="text" x-model="$store.oauthConfig.codex().codex_version" placeholder="Auto" />
353
</label>
354
<label class="oauth-switch">
253
- <span>Require proxy token</span>
355
+ <span>Codex proxy token required</span>
356
<input type="checkbox" x-model="$store.oauthConfig.codex().require_proxy_token" />
357
</label>
358
<label>
257
- <span>Proxy token</span>
359
+ <span>Codex proxy token</span>
360
<input type="password" x-model="$store.oauthConfig.codex().proxy_token" autocomplete="off" />
361
</label>
362
</div>
@@ -271,53 +373,69 @@
373
color: var(--color-text);
374
}
375
274
- .oauth-hero {
376
+ .oauth-provider-grid {
377
display: grid;
276
- grid-template-columns: 52px minmax(0, 1fr) auto;
277
- align-items: center;
278
- gap: 16px;
279
- min-height: 118px;
280
- padding: 18px;
378
+ grid-template-columns: repeat(3, minmax(0, 1fr));
379
+ gap: 12px;
380
+ }
381
+
382
+ .oauth-provider-card {
383
+ display: grid;
384
+ align-content: start;
385
+ gap: 14px;
386
+ min-width: 0;
387
+ min-height: 188px;
388
+ padding: 16px;
389
border: 1px solid var(--color-border);
390
border-radius: 8px;
391
background: color-mix(in srgb, var(--color-panel) 86%, transparent);
392
}
393
394
+ .oauth-provider-card.is-connected .oauth-mark {
395
+ color: #08120c;
396
+ background: #35d07f;
397
+ }
398
+
399
+ .oauth-provider-head {
400
+ display: grid;
401
+ grid-template-columns: 48px minmax(0, 1fr);
402
+ align-items: center;
403
+ gap: 12px;
404
+ }
405
+
406
.oauth-mark {
407
display: grid;
288
- width: 52px;
289
- height: 52px;
408
+ width: 48px;
409
+ height: 48px;
410
place-items: center;
411
border-radius: 50%;
412
background: color-mix(in srgb, var(--color-border) 52%, transparent);
413
}
414
415
.oauth-mark .material-symbols-outlined {
296
- font-size: 28px;
297
- }
298
-
299
- .oauth-hero.is-connected .oauth-mark {
300
- color: #08120c;
301
- background: #35d07f;
416
+ font-size: 26px;
417
}
418
419
.oauth-copy h2 {
420
margin: 0 0 4px;
306
- font-size: 1.35rem;
421
+ font-size: 1rem;
422
letter-spacing: 0;
423
}
424
425
.oauth-copy p {
426
+ overflow: hidden;
427
margin: 0;
428
color: var(--color-text-secondary);
313
- font-size: 0.88rem;
314
- line-height: 1.4;
429
+ font-size: 0.82rem;
430
+ line-height: 1.35;
431
+ text-overflow: ellipsis;
432
+ white-space: nowrap;
433
}
434
435
.oauth-primary {
436
display: flex;
437
flex-wrap: wrap;
320
- justify-content: flex-end;
438
+ justify-content: flex-start;
439
gap: 8px;
440
}
441
@@ -326,21 +444,22 @@
444
align-items: center;
445
justify-content: center;
446
gap: 8px;
329
- min-width: 124px;
330
- min-height: 40px;
331
- padding: 0 16px;
447
+ min-width: 112px;
448
+ min-height: 38px;
449
+ padding: 0 13px;
450
border: 0;
451
border-radius: 8px;
452
background: #f5f7fa;
453
color: #111418;
454
font: inherit;
337
- font-size: 0.88rem;
455
+ font-size: 0.84rem;
456
font-weight: 750;
457
cursor: pointer;
458
}
459
460
.oauth-connect.secondary {
343
- background: color-mix(in srgb, var(--color-border) 60%, transparent);
461
+ border: 1px solid color-mix(in srgb, var(--color-border) 76%, transparent);
462
+ background: color-mix(in srgb, var(--color-border) 44%, transparent);
463
color: var(--color-text);
464
}
465
@@ -355,12 +474,68 @@
474
opacity: .65;
475
}
476
477
+ .oauth-provider-input,
478
+ .oauth-provider-fields,
479
+ .oauth-manual-callback,
480
+ .oauth-auth-attempt {
481
+ display: grid;
482
+ min-width: 0;
483
+ gap: 6px;
484
+ }
485
+
486
+ .oauth-provider-fields {
487
+ gap: 8px;
488
+ }
489
+
490
+ .oauth-manual-callback {
491
+ grid-template-columns: minmax(0, 1fr) auto auto;
492
+ align-items: center;
493
+ gap: 8px;
494
+ }
495
+
496
+ .oauth-auth-attempt {
497
+ grid-template-columns: minmax(0, 1fr) auto;
498
+ align-items: center;
499
+ padding: 10px 12px;
500
+ border: 1px solid color-mix(in srgb, #f5f7fa 18%, var(--color-border));
501
+ border-radius: 8px;
502
+ background: color-mix(in srgb, #f5f7fa 7%, var(--color-panel));
503
+ }
504
+
505
+ .oauth-provider-input span,
506
+ .oauth-auth-attempt span {
507
+ color: var(--color-text-secondary);
508
+ font-size: 0.76rem;
509
+ font-weight: 700;
510
+ }
511
+
512
+ .oauth-provider-input input,
513
+ .oauth-manual-callback input {
514
+ width: 100%;
515
+ min-width: 0;
516
+ min-height: 36px;
517
+ padding: 7px 10px;
518
+ border: 1px solid color-mix(in srgb, var(--color-border) 74%, transparent);
519
+ border-radius: 8px;
520
+ background: var(--color-input);
521
+ color: var(--color-text);
522
+ font: inherit;
523
+ font-size: 0.82rem;
524
+ }
525
+
526
+ .oauth-provider-note {
527
+ margin: 0;
528
+ color: var(--color-text-secondary);
529
+ font-size: 0.76rem;
530
+ line-height: 1.35;
531
+ }
532
+
533
.oauth-device {
534
display: grid;
535
grid-template-columns: minmax(0, 1fr) auto auto;
536
align-items: center;
362
- gap: 14px;
363
- padding: 14px 16px;
537
+ gap: 10px;
538
+ padding: 10px 12px;
539
border: 1px solid color-mix(in srgb, #f5f7fa 18%, var(--color-border));
540
border-radius: 8px;
541
background: color-mix(in srgb, #f5f7fa 7%, var(--color-panel));
@@ -368,12 +543,12 @@
543
544
.oauth-device span {
545
color: var(--color-text-secondary);
371
- font-size: 0.84rem;
546
+ font-size: 0.78rem;
547
font-weight: 700;
548
}
549
550
.oauth-device strong {
376
- font-size: 1.45rem;
551
+ font-size: 1.12rem;
552
letter-spacing: 0;
553
}
554
@@ -496,6 +671,81 @@
671
border: 0;
672
}
673
674
+ .oauth-plan-catalog {
675
+ display: grid;
676
+ gap: 12px;
677
+ padding: 0;
678
+ border: 0;
679
+ }
680
+
681
+ .oauth-plan-grid {
682
+ display: grid;
683
+ grid-template-columns: repeat(2, minmax(0, 1fr));
684
+ gap: 12px;
685
+ }
686
+
687
+ .oauth-plan-card {
688
+ display: grid;
689
+ min-width: 0;
690
+ align-content: start;
691
+ gap: 10px;
692
+ padding: 12px;
693
+ border: 1px solid var(--color-border);
694
+ border-radius: 8px;
695
+ background: color-mix(in srgb, var(--color-panel) 82%, transparent);
696
+ }
697
+
698
+ .oauth-plan-card.is-metadata-only {
699
+ border-style: dashed;
700
+ }
701
+
702
+ .oauth-plan-head {
703
+ display: flex;
704
+ min-width: 0;
705
+ align-items: center;
706
+ justify-content: space-between;
707
+ gap: 10px;
708
+ }
709
+
710
+ .oauth-plan-head strong {
711
+ overflow: hidden;
712
+ font-size: 0.88rem;
713
+ text-overflow: ellipsis;
714
+ white-space: nowrap;
715
+ }
716
+
717
+ .oauth-plan-head span {
718
+ flex: 0 0 auto;
719
+ padding: 2px 7px;
720
+ border-radius: 999px;
721
+ background: color-mix(in srgb, var(--color-border) 48%, transparent);
722
+ color: var(--color-text-secondary);
723
+ font-size: 0.68rem;
724
+ font-weight: 750;
725
+ }
726
+
727
+ .oauth-plan-list {
728
+ display: flex;
729
+ flex-wrap: wrap;
730
+ gap: 6px;
731
+ }
732
+
733
+ .oauth-plan-list span {
734
+ padding: 4px 7px;
735
+ border-radius: 999px;
736
+ background: color-mix(in srgb, var(--color-border) 34%, transparent);
737
+ color: var(--color-text-secondary);
738
+ font-size: 0.72rem;
739
+ line-height: 1.2;
740
+ }
741
+
742
+ .oauth-plan-card p {
743
+ margin: 0;
744
+ color: var(--color-text-secondary);
745
+ font-size: 0.74rem;
746
+ line-height: 1.35;
747
+ }
748
+
749
.oauth-models-panel {
750
display: grid;
751
gap: 10px;
@@ -592,7 +842,9 @@
842
843
.oauth-model-actions {
844
display: flex;
845
+ flex-wrap: wrap;
846
align-items: center;
847
+ justify-content: flex-end;
848
gap: 6px;
849
}
850
@@ -724,7 +976,7 @@
976
977
.oauth-details div {
978
display: grid;
727
- grid-template-columns: 84px minmax(0, 1fr);
979
+ grid-template-columns: 116px minmax(0, 1fr);
980
align-items: center;
981
gap: 10px;
982
}
@@ -793,15 +1045,23 @@
1045
font-size: 0.76rem;
1046
}
1047
1048
+ @media (max-width: 980px) {
1049
+ .oauth-provider-grid {
1050
+ grid-template-columns: 1fr;
1051
+ }
1052
+ }
1053
+
1054
@media (max-width: 720px) {
797
- .oauth-hero,
1055
.oauth-device,
1056
.oauth-usage,
1057
.oauth-status-row,
1058
+ .oauth-plan-grid,
1059
.oauth-model-grid,
1060
.oauth-model-head,
1061
.oauth-grid,
804
- .oauth-details div {
1062
+ .oauth-details div,
1063
+ .oauth-manual-callback,
1064
+ .oauth-auth-attempt {
1065
grid-template-columns: 1fr;
1066
}
1067
plugins/_oauth/webui/oauth-config-store.js
+473
-84
@@ -9,12 +9,19 @@ import {
9
10
const MODEL_CONFIG_API = "/plugins/_model_config";
11
const STATUS_API = "/plugins/_oauth/status";
12
-const START_DEVICE_LOGIN_API = "/plugins/_oauth/start_device_login";
13
-const POLL_DEVICE_LOGIN_API = "/plugins/_oauth/poll_device_login";
12
+const START_LOGIN_API = "/plugins/_oauth/start_login";
13
+const POLL_LOGIN_API = "/plugins/_oauth/poll_device_login";
14
+const MANUAL_CALLBACK_API = "/plugins/_oauth/manual_callback";
15
const MODELS_API = "/plugins/_oauth/models";
16
const DISCONNECT_API = "/plugins/_oauth/disconnect";
17
const MAX_POLL_MS = 120000;
18
const CODEX_PROVIDER = "codex_oauth";
19
+const PROVIDER_MARKS = {
20
+ github: "terminal",
21
+ google: "cloud",
22
+ openai: "key",
23
+ xai: "neurology",
24
+};
25
const MODEL_SLOTS = [
26
{
27
key: "chat_model",
@@ -46,6 +53,17 @@ function ensureConfig(config) {
53
codex.proxy_token = String(codex.proxy_token || "");
54
codex.codex_version = String(codex.codex_version || "");
55
codex.models = Array.isArray(codex.models) ? codex.models : [];
56
+
57
+ config.gemini_api = config.gemini_api && typeof config.gemini_api === "object" ? config.gemini_api : {};
58
+ const geminiApi = config.gemini_api;
59
+ geminiApi.enabled = geminiApi.enabled !== false;
60
+ geminiApi.client_id = String(geminiApi.client_id || "");
61
+ geminiApi.client_secret = String(geminiApi.client_secret || "");
62
+ geminiApi.quota_project_id = String(geminiApi.quota_project_id || "");
63
+ geminiApi.scopes = Array.isArray(geminiApi.scopes) ? geminiApi.scopes : [];
64
+ geminiApi.api_base_url = String(geminiApi.api_base_url || "https://generativelanguage.googleapis.com/v1beta/openai");
65
+ geminiApi.proxy_base_path = String(geminiApi.proxy_base_path || "/oauth/gemini-api");
66
+ geminiApi.callback_path = String(geminiApi.callback_path || "/oauth/gemini-api/callback");
67
return config;
68
}
69
@@ -77,6 +95,10 @@ function messageOf(error) {
95
return error instanceof Error ? error.message : String(error);
96
}
97
98
+function providerUiDefaults() {
99
+ return {};
100
+}
101
+
102
export const store = createStore("oauthConfig", {
103
config: null,
104
status: null,
@@ -84,6 +106,12 @@ export const store = createStore("oauthConfig", {
106
connecting: false,
107
disconnecting: false,
108
loadingModels: false,
109
+ providerModels: {},
110
+ providerUi: providerUiDefaults(),
111
+ connectingProvider: "",
112
+ disconnectingProvider: "",
113
+ loadingModelsProvider: "",
114
+ activeModelProvider: CODEX_PROVIDER,
115
models: [],
116
modelSlots: MODEL_SLOTS,
117
modelConfig: null,
@@ -98,9 +126,13 @@ export const store = createStore("oauthConfig", {
126
chat_model: { open: false },
127
utility_model: { open: false },
128
},
129
+ devices: {},
130
+ pollTimers: {},
131
+ pollStartedAt: {},
132
+ callbackPollTimers: {},
133
+ callbackPollStartedAt: {},
134
device: null,
135
pollTimer: null,
103
- pollStartedAt: 0,
136
137
async init(config, context = null) {
138
this.bindConfig(config);
@@ -110,8 +142,18 @@ export const store = createStore("oauthConfig", {
142
143
cleanup() {
144
this.stopPolling();
145
+ this.stopCallbackPolling();
146
this.config = null;
147
this.status = null;
148
+ this.connecting = false;
149
+ this.disconnecting = false;
150
+ this.loadingModels = false;
151
+ this.providerModels = {};
152
+ this.providerUi = providerUiDefaults();
153
+ this.connectingProvider = "";
154
+ this.disconnectingProvider = "";
155
+ this.loadingModelsProvider = "";
156
+ this.activeModelProvider = CODEX_PROVIDER;
157
this.models = [];
158
this.modelConfig = null;
159
this.modelConfigLoading = false;
@@ -122,6 +164,10 @@ export const store = createStore("oauthConfig", {
164
chat_model: { open: false },
165
utility_model: { open: false },
166
};
167
+ this.devices = {};
168
+ this.pollStartedAt = {};
169
+ this.callbackPollTimers = {};
170
+ this.callbackPollStartedAt = {};
171
this.device = null;
172
},
173
@@ -136,21 +182,122 @@ export const store = createStore("oauthConfig", {
182
return this.config?.codex || {};
183
},
184
185
+ geminiApi() {
186
+ return this.config?.gemini_api || {};
187
+ },
188
+
189
+ providerMap() {
190
+ const mapped = this.status?.provider_map;
191
+ if (mapped && typeof mapped === "object") return mapped;
192
+ const providers = Array.isArray(this.status?.providers) ? this.status.providers : [];
193
+ return providers.reduce((result, provider) => {
194
+ if (provider?.provider_id) result[provider.provider_id] = provider;
195
+ return result;
196
+ }, {});
197
+ },
198
+
199
+ providerStatus(providerId) {
200
+ return this.providerMap()[providerId] || {};
201
+ },
202
+
203
+ providerUiFor(providerId) {
204
+ const key = String(providerId || "");
205
+ if (!key) return {};
206
+ if (!this.providerUi[key]) {
207
+ this.providerUi = {
208
+ ...this.providerUi,
209
+ [key]: {
210
+ enterprise_domain: "",
211
+ manualCallback: "",
212
+ client_id: "",
213
+ client_secret: "",
214
+ quota_project_id: "",
215
+ },
216
+ };
217
+ }
218
+ return this.providerUi[key];
219
+ },
220
+
221
+ providerCards() {
222
+ const map = this.providerMap();
223
+ const providers = Array.isArray(this.status?.providers)
224
+ ? this.status.providers
225
+ : Object.values(map);
226
+ return providers
227
+ .filter((provider) => provider?.provider_id)
228
+ .map((status) => {
229
+ const providerId = status.provider_id;
230
+ return {
231
+ ...status,
232
+ provider_id: providerId,
233
+ connected: Boolean(status.connected),
234
+ mark: status.mark || PROVIDER_MARKS[status.icon] || "key",
235
+ use_label: status.use_label || `Use ${status.short_name || status.display_name || providerId}`,
236
+ device: this.devices[providerId] || null,
237
+ connecting: this.connectingProvider === providerId,
238
+ disconnecting: this.disconnectingProvider === providerId,
239
+ loadingModels: this.loadingModelsProvider === providerId,
240
+ };
241
+ });
242
+ },
243
+
244
+ providerIds() {
245
+ return this.providerCards().map((card) => card.provider_id);
246
+ },
247
+
248
+ isOauthProvider(providerId) {
249
+ const key = String(providerId || "");
250
+ return Boolean(key && this.providerMap()[key]);
251
+ },
252
+
253
+ providerConnected(providerId) {
254
+ return Boolean(this.providerStatus(providerId)?.connected);
255
+ },
256
+
257
+ providerLabel(providerId) {
258
+ const status = this.providerStatus(providerId);
259
+ return status.display_name || providerId;
260
+ },
261
+
262
+ providerUseLabel(providerId) {
263
+ const status = this.providerStatus(providerId);
264
+ return status.use_label || `Use ${status.short_name || status.display_name || providerId}`;
265
+ },
266
+
267
+ providerStatusLabel(providerId) {
268
+ if (this.loadingStatus) return "Checking";
269
+ const status = this.providerStatus(providerId);
270
+ if (!status.connected) return "Not connected";
271
+ return status.account_label || status.email || "Connected";
272
+ },
273
+
274
+ providerEndpointUrl(providerId) {
275
+ const status = this.providerStatus(providerId);
276
+ const proxyBase = String(status.proxy_base_path || "").replace(/\/$/, "");
277
+ const base = status.v1_base_path || (proxyBase ? `${proxyBase}/v1` : "");
278
+ return base ? `${window.location.origin}${base}` : "";
279
+ },
280
+
281
+ providerCallbackUrl(providerId) {
282
+ const status = this.providerStatus(providerId);
283
+ const path = status.callback_path || "";
284
+ return path ? `${window.location.origin}${path}` : "";
285
+ },
286
+
287
connected() {
140
- return Boolean(this.status?.codex?.connected);
288
+ return this.providerConnected(CODEX_PROVIDER);
289
},
290
291
statusLabel() {
144
- if (this.loadingStatus) return "Checking";
145
- return this.connected() ? "Connected" : "Not connected";
292
+ return this.providerStatusLabel(CODEX_PROVIDER);
293
},
294
148
- usage() {
149
- return this.status?.codex?.usage || null;
295
+ usage(providerId = CODEX_PROVIDER) {
296
+ return this.providerStatus(providerId)?.usage || null;
297
},
298
152
- usageWindows() {
153
- const usage = this.usage();
299
+ usageWindows(providerId = CODEX_PROVIDER) {
300
+ const usage = this.usage(providerId);
301
if (!usage?.available) return [];
302
return [
303
{ key: "primary", title: "Session", ...(usage.primary || {}) },
@@ -158,6 +305,31 @@ export const store = createStore("oauthConfig", {
305
].filter((window) => Number.isFinite(this.remainingPercent(window)));
306
},
307
308
+ usagePlanCatalog() {
309
+ const catalog = this.status?.usage_plan_catalog;
310
+ return catalog && typeof catalog === "object" ? catalog : {};
311
+ },
312
+
313
+ usagePlanEntries() {
314
+ const catalog = this.usagePlanCatalog();
315
+ const providerIds = this.providerIds();
316
+ const ids = [
317
+ ...providerIds,
318
+ ...Object.keys(catalog).filter((providerId) => !providerIds.includes(providerId)),
319
+ ];
320
+ return ids
321
+ .map((providerId) => catalog[providerId])
322
+ .filter((entry) => entry && Array.isArray(entry.plans) && entry.plans.length);
323
+ },
324
+
325
+ usagePlanStatus(entry) {
326
+ return entry?.implemented ? "Provider available" : "Metadata only";
327
+ },
328
+
329
+ usagePlanNotes(entry) {
330
+ return Array.isArray(entry?.notes) ? entry.notes.slice(0, 2) : [];
331
+ },
332
+
333
usageWidth(window) {
334
const value = Math.max(0, Math.min(100, this.remainingPercent(window)));
335
return `${value}%`;
@@ -193,13 +365,11 @@ export const store = createStore("oauthConfig", {
365
},
366
367
endpointUrl() {
196
- const base = this.codex().proxy_base_path || "/oauth/codex";
197
- return `${window.location.origin}${base}/v1`;
368
+ return this.providerEndpointUrl(CODEX_PROVIDER);
369
},
370
371
callbackUrl() {
201
- const path = this.codex().callback_path || "/auth/callback";
202
- return `${window.location.origin}${path}`;
372
+ return this.providerCallbackUrl(CODEX_PROVIDER);
373
},
374
375
installSettingsHooks(context) {
@@ -251,19 +421,27 @@ export const store = createStore("oauthConfig", {
421
return this.modelConfig[key];
422
},
423
424
+ slotUsesOauth(key) {
425
+ return this.isOauthProvider(this.modelSlot(key).provider);
426
+ },
427
+
428
+ slotUsesProvider(key, providerId) {
429
+ return this.modelSlot(key).provider === providerId;
430
+ },
431
+
432
slotUsesCodex(key) {
255
- return this.modelSlot(key).provider === CODEX_PROVIDER;
433
+ return this.slotUsesProvider(key, CODEX_PROVIDER);
434
},
435
436
providerName(provider) {
437
if (!provider) return "Not configured";
438
const found = (modelConfigStore.chatProviders || []).find((item) => item.value === provider);
261
- return found?.label || provider;
439
+ return found?.label || this.providerLabel(provider);
440
},
441
442
slotStatusLabel(key) {
443
const slot = this.modelSlot(key);
266
- if (slot.provider === CODEX_PROVIDER) return "";
444
+ if (this.slotUsesOauth(key)) return `Using ${this.providerLabel(slot.provider)}`;
445
return `Currently ${this.providerName(slot.provider)}`;
446
},
447
@@ -272,39 +450,50 @@ export const store = createStore("oauthConfig", {
450
this.modelSlotDirty = { ...this.modelSlotDirty, [key]: true };
451
},
452
275
- useCodexForSlot(key) {
453
+ useProviderForSlot(key, providerId) {
454
+ if (!this.isOauthProvider(providerId)) return;
455
const slot = this.modelSlot(key);
456
const previousProvider = slot.provider;
278
- slot.provider = CODEX_PROVIDER;
457
+ slot.provider = providerId;
458
slot.api_base = "";
280
- if (previousProvider && previousProvider !== CODEX_PROVIDER) {
459
+ if (previousProvider && previousProvider !== providerId) {
460
slot.name = "";
461
}
462
if (!slot.kwargs || typeof slot.kwargs !== "object") slot.kwargs = {};
463
+ this.activeModelProvider = providerId;
464
+ this.models = this.providerModels[providerId] || [];
465
this.markModelDirty(key);
466
if (this.models.length) {
467
this.openModelDropdown(key);
287
- } else {
288
- void this.loadModels({ openDropdown: key, silent: true });
468
+ } else if (this.providerConnected(providerId)) {
469
+ void this.loadModels({ providerId, openDropdown: key, silent: true });
470
}
471
},
472
473
+ useCodexForSlot(key) {
474
+ this.useProviderForSlot(key, CODEX_PROVIDER);
475
+ },
476
+
477
copyMainToUtility() {
478
if (!this.modelConfig) return;
479
const main = this.modelSlot("chat_model");
480
const utility = this.modelSlot("utility_model");
296
- utility.provider = CODEX_PROVIDER;
481
+ utility.provider = main.provider || "";
482
utility.name = main.name || "";
483
utility.api_base = main.api_base || "";
484
utility.kwargs = clone(main.kwargs || {});
485
+ this.activeModelProvider = utility.provider || this.activeModelProvider;
486
this.markModelDirty("utility_model");
487
},
488
489
openModelDropdown(key) {
304
- if (!this.slotUsesCodex(key)) return;
490
+ if (!this.slotUsesOauth(key)) return;
491
+ const providerId = this.modelSlot(key).provider;
492
+ this.activeModelProvider = providerId;
493
+ this.models = this.providerModels[providerId] || [];
494
this.modelDropdown[key] = { ...this.modelDropdown[key], open: true };
306
- if (!this.models.length && !this.loadingModels) {
307
- void this.loadModels({ openDropdown: key, silent: true });
495
+ if (!this.models.length && !this.loadingModelsProvider && this.providerConnected(providerId)) {
496
+ void this.loadModels({ providerId, openDropdown: key, silent: true });
497
}
498
},
499
@@ -313,8 +502,9 @@ export const store = createStore("oauthConfig", {
502
},
503
504
filteredModels(key) {
316
- const query = String(this.modelSlot(key).name || "").trim().toLowerCase();
317
- const models = this.models || [];
505
+ const slot = this.modelSlot(key);
506
+ const query = String(slot.name || "").trim().toLowerCase();
507
+ const models = this.providerModels[slot.provider] || this.models || [];
508
const filtered = query
509
? models.filter((model) => String(model).toLowerCase().includes(query))
510
: models;
@@ -323,7 +513,12 @@ export const store = createStore("oauthConfig", {
513
514
selectModel(key, model) {
515
const slot = this.modelSlot(key);
326
- slot.provider = CODEX_PROVIDER;
516
+ const providerId = this.isOauthProvider(this.activeModelProvider)
517
+ ? this.activeModelProvider
518
+ : slot.provider;
519
+ if (this.isOauthProvider(providerId)) {
520
+ slot.provider = providerId;
521
+ }
522
slot.name = model;
523
this.markModelDirty(key);
524
this.closeModelDropdown(key);
@@ -334,7 +529,7 @@ export const store = createStore("oauthConfig", {
529
for (const slot of MODEL_SLOTS) {
530
if (!this.modelSlotDirty[slot.key]) continue;
531
const model = this.modelSlot(slot.key);
337
- if (model.provider === CODEX_PROVIDER && !String(model.name || "").trim()) {
532
+ if (this.isOauthProvider(model.provider) && !String(model.name || "").trim()) {
533
throw new Error(`Choose a ${slot.title} before saving.`);
534
}
535
}
@@ -370,6 +565,21 @@ export const store = createStore("oauthConfig", {
565
try {
566
const response = await callJsonApi(STATUS_API, {});
567
this.status = response;
568
+ for (const card of this.providerCards()) {
569
+ const ui = this.providerUiFor(card.provider_id);
570
+ if (card.enterprise_domain && !ui.enterprise_domain) {
571
+ ui.enterprise_domain = card.enterprise_domain;
572
+ }
573
+ if (card.client_id && !ui.client_id) {
574
+ ui.client_id = card.client_id;
575
+ }
576
+ if (card.quota_project_id && !ui.quota_project_id) {
577
+ ui.quota_project_id = card.quota_project_id;
578
+ }
579
+ }
580
+ if (!this.isOauthProvider(this.activeModelProvider)) {
581
+ this.activeModelProvider = this.providerCards()[0]?.provider_id || CODEX_PROVIDER;
582
+ }
583
} catch (error) {
584
void toastFrontendError(messageOf(error), "OAuth Connections");
585
} finally {
@@ -377,115 +587,294 @@ export const store = createStore("oauthConfig", {
587
}
588
},
589
380
- async connectCodex() {
381
- if (this.connecting) return;
590
+ async connectProvider(providerId) {
591
+ if (!this.isOauthProvider(providerId) || this.connectingProvider) return;
592
+ this.connectingProvider = providerId;
593
this.connecting = true;
594
try {
384
- const response = await callJsonApi(START_DEVICE_LOGIN_API, {});
385
- if (!response?.ok || !response.verification_url || !response.attempt_id) {
386
- throw new Error(response?.error || "Could not start Codex sign-in.");
595
+ const payload = { provider_id: providerId };
596
+ const status = this.providerStatus(providerId);
597
+ const ui = this.providerUiFor(providerId);
598
+ if (status.supports_enterprise_domain) {
599
+ payload.enterprise_domain = ui.enterprise_domain || "";
600
+ }
601
+ if (status.supports_oauth_client_config) {
602
+ payload.client_id = ui.client_id || this.geminiApi().client_id || "";
603
+ payload.client_secret = ui.client_secret || this.geminiApi().client_secret || "";
604
+ }
605
+ if (status.supports_quota_project) {
606
+ payload.quota_project_id = ui.quota_project_id || this.geminiApi().quota_project_id || "";
607
+ }
608
+ const response = await callJsonApi(START_LOGIN_API, payload);
609
+ if (!response?.ok) {
610
+ throw new Error(response?.error || `Could not start ${this.providerLabel(providerId)} sign-in.`);
611
}
388
- this.device = response;
389
- window.open(response.verification_url, "_blank", "noopener,noreferrer");
390
- void toastFrontendInfo("Enter the code shown here in the opened browser tab.", "OAuth Connections");
391
- this.startPolling();
612
+
613
+ this.devices = { ...this.devices, [providerId]: response };
614
+ if (providerId === CODEX_PROVIDER) this.device = response;
615
+
616
+ if (response.flow === "device_code" && response.verification_url && response.attempt_id) {
617
+ window.open(response.verification_url, "_blank", "noopener,noreferrer");
618
+ void toastFrontendInfo("Enter the code shown here in the opened browser tab.", "OAuth Connections");
619
+ this.startPolling(providerId);
620
+ return;
621
+ }
622
+
623
+ if (response.flow === "browser_pkce" && response.auth_url) {
624
+ window.open(response.auth_url, "_blank", "noopener,noreferrer");
625
+ void toastFrontendInfo("Finish sign-in in the opened browser tab.", "OAuth Connections");
626
+ this.startCallbackPolling(providerId);
627
+ return;
628
+ }
629
+
630
+ throw new Error(response?.error || `Could not start ${this.providerLabel(providerId)} sign-in.`);
631
} catch (error) {
632
+ this.clearProviderDevice(providerId);
633
+ this.connectingProvider = "";
634
this.connecting = false;
635
void toastFrontendError(messageOf(error), "OAuth Connections");
636
}
637
},
638
398
- startPolling() {
399
- this.stopPolling();
400
- this.pollStartedAt = Date.now();
639
+ connectCodex() {
640
+ return this.connectProvider(CODEX_PROVIDER);
641
+ },
642
+
643
+ startPolling(providerId = CODEX_PROVIDER) {
644
+ this.stopPolling(providerId);
645
+ this.pollStartedAt = { ...this.pollStartedAt, [providerId]: Date.now() };
646
const tick = async () => {
402
- if (!this.device?.attempt_id) return;
647
+ const device = this.devices[providerId];
648
+ if (!device?.attempt_id) return;
649
try {
404
- const response = await callJsonApi(POLL_DEVICE_LOGIN_API, {
405
- attempt_id: this.device.attempt_id,
650
+ const response = await callJsonApi(POLL_LOGIN_API, {
651
+ provider_id: providerId,
652
+ attempt_id: device.attempt_id,
653
});
654
if (!response?.ok) {
655
if (response?.expired) {
409
- this.connecting = false;
410
- this.device = null;
411
- this.stopPolling();
656
+ this.clearProviderDevice(providerId);
657
+ this.stopPolling(providerId);
658
}
413
- throw new Error(response?.error || "Could not finish Codex sign-in.");
659
+ throw new Error(response?.error || `Could not finish ${this.providerLabel(providerId)} sign-in.`);
660
}
661
if (response.completed) {
662
await this.loadStatus();
417
- this.device = null;
418
- this.connecting = false;
419
- this.stopPolling();
420
- void toastFrontendSuccess("Codex account connected.", "OAuth Connections");
663
+ this.clearProviderDevice(providerId);
664
+ this.stopPolling(providerId);
665
+ if (this.connectingProvider === providerId) this.connectingProvider = "";
666
+ this.connecting = Boolean(this.connectingProvider);
667
+ void toastFrontendSuccess(`${this.providerLabel(providerId)} connected.`, "OAuth Connections");
668
return;
669
}
670
} catch (error) {
424
- this.connecting = false;
425
- this.stopPolling();
671
+ if (this.connectingProvider === providerId) this.connectingProvider = "";
672
+ this.connecting = Boolean(this.connectingProvider);
673
+ this.stopPolling(providerId);
674
void toastFrontendError(messageOf(error), "OAuth Connections");
675
return;
676
}
429
- if (Date.now() - this.pollStartedAt > MAX_POLL_MS) {
430
- this.connecting = false;
431
- this.device = null;
432
- this.stopPolling();
677
+ if (Date.now() - Number(this.pollStartedAt[providerId] || 0) > MAX_POLL_MS) {
678
+ if (this.connectingProvider === providerId) this.connectingProvider = "";
679
+ this.connecting = Boolean(this.connectingProvider);
680
+ this.clearProviderDevice(providerId);
681
+ this.stopPolling(providerId);
682
+ }
683
+ };
684
+ const delay = Math.max(1500, Number(this.devices[providerId]?.interval || 5) * 1000);
685
+ this.pollTimers = { ...this.pollTimers, [providerId]: window.setInterval(tick, delay) };
686
+ if (providerId === CODEX_PROVIDER) this.pollTimer = this.pollTimers[providerId];
687
+ void tick();
688
+ },
689
+
690
+ pollProvider(providerId = CODEX_PROVIDER) {
691
+ this.startPolling(providerId);
692
+ },
693
+
694
+ startCallbackPolling(providerId) {
695
+ this.stopCallbackPolling(providerId);
696
+ this.callbackPollStartedAt = { ...this.callbackPollStartedAt, [providerId]: Date.now() };
697
+ const tick = async () => {
698
+ await this.loadStatus();
699
+ if (this.providerConnected(providerId)) {
700
+ this.clearProviderDevice(providerId);
701
+ this.stopCallbackPolling(providerId);
702
+ if (this.connectingProvider === providerId) this.connectingProvider = "";
703
+ this.connecting = Boolean(this.connectingProvider);
704
+ void toastFrontendSuccess(`${this.providerLabel(providerId)} connected.`, "OAuth Connections");
705
return;
706
}
707
+ if (Date.now() - Number(this.callbackPollStartedAt[providerId] || 0) > MAX_POLL_MS) {
708
+ this.stopCallbackPolling(providerId);
709
+ if (this.connectingProvider === providerId) this.connectingProvider = "";
710
+ this.connecting = Boolean(this.connectingProvider);
711
+ }
712
+ };
713
+ this.callbackPollTimers = {
714
+ ...this.callbackPollTimers,
715
+ [providerId]: window.setInterval(tick, 2500),
716
};
717
void tick();
437
- const delay = Math.max(1500, Number(this.device.interval || 5) * 1000);
438
- this.pollTimer = window.setInterval(tick, delay);
718
},
719
441
- stopPolling() {
442
- if (this.pollTimer) window.clearInterval(this.pollTimer);
720
+ stopPolling(providerId = "") {
721
+ if (providerId) {
722
+ if (this.pollTimers[providerId]) window.clearInterval(this.pollTimers[providerId]);
723
+ const timers = { ...this.pollTimers };
724
+ delete timers[providerId];
725
+ this.pollTimers = timers;
726
+ const startedAt = { ...this.pollStartedAt };
727
+ delete startedAt[providerId];
728
+ this.pollStartedAt = startedAt;
729
+ if (providerId === CODEX_PROVIDER) this.pollTimer = null;
730
+ return;
731
+ }
732
+
733
+ for (const timer of Object.values(this.pollTimers || {})) {
734
+ if (timer) window.clearInterval(timer);
735
+ }
736
+ this.pollTimers = {};
737
+ this.pollStartedAt = {};
738
this.pollTimer = null;
739
},
740
446
- async loadModels({ openDropdown = "", silent = false } = {}) {
447
- if (this.loadingModels) return;
741
+ stopCallbackPolling(providerId = "") {
742
+ if (providerId) {
743
+ if (this.callbackPollTimers[providerId]) window.clearInterval(this.callbackPollTimers[providerId]);
744
+ const timers = { ...this.callbackPollTimers };
745
+ delete timers[providerId];
746
+ this.callbackPollTimers = timers;
747
+ const startedAt = { ...this.callbackPollStartedAt };
748
+ delete startedAt[providerId];
749
+ this.callbackPollStartedAt = startedAt;
750
+ return;
751
+ }
752
+
753
+ for (const timer of Object.values(this.callbackPollTimers || {})) {
754
+ if (timer) window.clearInterval(timer);
755
+ }
756
+ this.callbackPollTimers = {};
757
+ this.callbackPollStartedAt = {};
758
+ },
759
+
760
+ clearProviderDevice(providerId = "") {
761
+ if (!providerId) {
762
+ this.devices = {};
763
+ this.device = null;
764
+ return;
765
+ }
766
+ const devices = { ...this.devices };
767
+ delete devices[providerId];
768
+ this.devices = devices;
769
+ if (providerId === CODEX_PROVIDER) this.device = null;
770
+ },
771
+
772
+ async submitManualCallback(providerId) {
773
+ if (!this.isOauthProvider(providerId)) return;
774
+ const callback = String(this.providerUiFor(providerId).manualCallback || "").trim();
775
+ if (!callback) {
776
+ void toastFrontendError("Paste callback URL, query string, or code.", "OAuth Connections");
777
+ return;
778
+ }
779
+ this.connectingProvider = providerId;
780
+ this.connecting = true;
781
+ try {
782
+ const response = await callJsonApi(MANUAL_CALLBACK_API, {
783
+ provider_id: providerId,
784
+ callback,
785
+ });
786
+ if (!response?.ok) {
787
+ throw new Error(response?.error || `Could not finish ${this.providerLabel(providerId)} sign-in.`);
788
+ }
789
+ this.providerUiFor(providerId).manualCallback = "";
790
+ this.clearProviderDevice(providerId);
791
+ this.stopCallbackPolling(providerId);
792
+ await this.loadStatus();
793
+ void toastFrontendSuccess(`${this.providerLabel(providerId)} connected.`, "OAuth Connections");
794
+ } catch (error) {
795
+ void toastFrontendError(messageOf(error), "OAuth Connections");
796
+ } finally {
797
+ if (this.connectingProvider === providerId) this.connectingProvider = "";
798
+ this.connecting = Boolean(this.connectingProvider);
799
+ }
800
+ },
801
+
802
+ async loadModels({ providerId = "", openDropdown = "", silent = false } = {}) {
803
+ const selectedProvider = providerId || this.activeModelProvider || CODEX_PROVIDER;
804
+ if (!this.isOauthProvider(selectedProvider) || this.loadingModelsProvider) return;
805
+ this.loadingModelsProvider = selectedProvider;
806
this.loadingModels = true;
807
+ this.activeModelProvider = selectedProvider;
808
try {
450
- const response = await callJsonApi(MODELS_API, {});
451
- if (!response?.ok) throw new Error(response?.error || "Could not load Codex models.");
452
- this.models = Array.isArray(response.models) ? response.models : [];
809
+ const response = await callJsonApi(MODELS_API, { provider_id: selectedProvider });
810
+ if (!response?.ok) {
811
+ throw new Error(response?.error || `Could not load ${this.providerLabel(selectedProvider)} models.`);
812
+ }
813
+ const models = Array.isArray(response.models) ? response.models : [];
814
+ this.providerModels = { ...this.providerModels, [selectedProvider]: models };
815
+ this.models = models;
816
if (openDropdown) this.openModelDropdown(openDropdown);
454
- if (!silent) void toastFrontendSuccess("Codex models loaded.", "OAuth Connections");
817
+ if (!silent) void toastFrontendSuccess(`${this.providerLabel(selectedProvider)} models loaded.`, "OAuth Connections");
818
} catch (error) {
819
+ this.providerModels = { ...this.providerModels, [selectedProvider]: [] };
820
this.models = [];
821
if (!silent) void toastFrontendError(messageOf(error), "OAuth Connections");
822
} finally {
823
+ this.loadingModelsProvider = "";
824
this.loadingModels = false;
825
}
826
},
827
463
- async disconnectCodex() {
464
- if (this.disconnecting || !this.connected()) return;
465
- const confirmed = window.confirm("Disconnect this OpenAI account and remove stored OAuth tokens?");
828
+ async disconnectProvider(providerId) {
829
+ if (!this.isOauthProvider(providerId) || this.disconnectingProvider || !this.providerConnected(providerId)) return;
830
+ const confirmed = window.confirm(`Disconnect ${this.providerLabel(providerId)} and remove stored OAuth tokens?`);
831
if (!confirmed) return;
832
833
+ this.disconnectingProvider = providerId;
834
this.disconnecting = true;
835
try {
470
- const response = await callJsonApi(DISCONNECT_API, {});
836
+ const response = await callJsonApi(DISCONNECT_API, { provider_id: providerId });
837
if (!response?.ok) throw new Error(response?.error || "Could not disconnect the account.");
472
- this.status = response.codex ? { ok: true, codex: response.codex } : this.status;
473
- this.models = [];
474
- this.device = null;
475
- this.connecting = false;
476
- this.stopPolling();
477
- void toastFrontendSuccess("OpenAI account disconnected.", "OAuth Connections");
838
+ if (response.provider) {
839
+ const providerMap = { ...this.providerMap(), [providerId]: response.provider };
840
+ this.status = { ...(this.status || {}), provider_map: providerMap, providers: Object.values(providerMap) };
841
+ if (providerId === CODEX_PROVIDER) this.status.codex = response.provider;
842
+ }
843
+ const providerModels = { ...this.providerModels };
844
+ delete providerModels[providerId];
845
+ this.providerModels = providerModels;
846
+ if (this.activeModelProvider === providerId) this.models = [];
847
+ this.clearProviderDevice(providerId);
848
+ if (this.connectingProvider === providerId) this.connectingProvider = "";
849
+ this.connecting = Boolean(this.connectingProvider);
850
+ this.stopPolling(providerId);
851
+ this.stopCallbackPolling(providerId);
852
+ void toastFrontendSuccess(`${this.providerLabel(providerId)} disconnected.`, "OAuth Connections");
853
await this.loadStatus();
854
} catch (error) {
855
void toastFrontendError(messageOf(error), "OAuth Connections");
856
} finally {
482
- this.disconnecting = false;
857
+ if (this.disconnectingProvider === providerId) this.disconnectingProvider = "";
858
+ this.disconnecting = Boolean(this.disconnectingProvider);
859
}
860
},
861
486
- cancelConnect() {
487
- this.connecting = false;
488
- this.device = null;
489
- this.stopPolling();
862
+ disconnectCodex() {
863
+ return this.disconnectProvider(CODEX_PROVIDER);
864
+ },
865
+
866
+ cancelConnect(providerId = "") {
867
+ if (providerId) {
868
+ this.stopPolling(providerId);
869
+ this.stopCallbackPolling(providerId);
870
+ this.clearProviderDevice(providerId);
871
+ if (this.connectingProvider === providerId) this.connectingProvider = "";
872
+ } else {
873
+ this.stopPolling();
874
+ this.stopCallbackPolling();
875
+ this.clearProviderDevice();
876
+ this.connectingProvider = "";
877
+ }
878
+ this.connecting = Boolean(this.connectingProvider);
879
},
880
});
tests/test_oauth_gemini_api.py
new
+262
@@ -0,0 +1,262 @@
1
+from __future__ import annotations
2
+
3
+import base64
4
+import json
5
+import sys
6
+import time
7
+import types
8
+from dataclasses import dataclass
9
+from pathlib import Path
10
+from urllib.parse import parse_qs, urlparse
11
+
12
+import pytest
13
+
14
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
15
+
16
+
17
+@dataclass(frozen=True)
18
+class PkcePair:
19
+ verifier: str
20
+ challenge: str
21
+
22
+
23
+from plugins._oauth.helpers.providers import gemini_api as gemini
24
+from plugins._oauth.helpers.providers.base import GEMINI_API_PROVIDER_ID, ProviderError
25
+from plugins._oauth.helpers.state import pop_attempt, put_attempt
26
+
27
+
28
+def _config() -> dict:
29
+ return {
30
+ "enabled": True,
31
+ "client_id": "client-id.apps.googleusercontent.com",
32
+ "client_secret": "client-secret",
33
+ "scopes": [
34
+ "openid",
35
+ "email",
36
+ "profile",
37
+ "https://www.googleapis.com/auth/cloud-platform",
38
+ "https://www.googleapis.com/auth/generative-language.retriever",
39
+ ],
40
+ "quota_project_id": "quota-project",
41
+ "api_base_url": gemini.GEMINI_OPENAI_API_BASE,
42
+ "proxy_base_path": "/oauth/gemini-api",
43
+ "callback_path": "/oauth/gemini-api/callback",
44
+ }
45
+
46
+
47
+class FakeRequest:
48
+ headers = {}
49
+ url_root = "http://localhost:50001/"
50
+
51
+
52
+def _fake_codex_helper():
53
+ fake_codex = types.SimpleNamespace()
54
+ fake_codex.generate_pkce = lambda: PkcePair("verifier", "challenge")
55
+ fake_codex.generate_state = lambda: "state-1"
56
+ return fake_codex
57
+
58
+
59
+def _jwt(claims: dict) -> str:
60
+ header = _b64({"alg": "none", "typ": "JWT"})
61
+ payload = _b64(claims)
62
+ return f"{header}.{payload}.signature"
63
+
64
+
65
+def _b64(payload: dict) -> str:
66
+ raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
67
+ return base64.urlsafe_b64encode(raw).decode("utf-8").rstrip("=")
68
+
69
+
70
+def test_start_login_builds_google_pkce_authorize_url_without_network(monkeypatch):
71
+ provider = gemini.GeminiApiOAuthProvider()
72
+ monkeypatch.setattr(gemini, "_gemini_api_config", _config)
73
+ monkeypatch.setattr(gemini, "_codex_helper", _fake_codex_helper)
74
+
75
+ result = provider.start_login({}, FakeRequest())
76
+
77
+ try:
78
+ assert result.ok is True
79
+ assert result.provider_id == GEMINI_API_PROVIDER_ID
80
+ assert result.flow == "browser_pkce"
81
+ assert result.redirect_uri == "http://localhost:50001/oauth/gemini-api/callback"
82
+
83
+ parsed = urlparse(result.auth_url)
84
+ query = parse_qs(parsed.query)
85
+ assert result.auth_url.startswith("https://accounts.google.com/o/oauth2/v2/auth?")
86
+ assert query["response_type"] == ["code"]
87
+ assert query["client_id"] == ["client-id.apps.googleusercontent.com"]
88
+ assert query["scope"] == [" ".join(_config()["scopes"])]
89
+ assert query["code_challenge"] == ["challenge"]
90
+ assert query["code_challenge_method"] == ["S256"]
91
+ assert query["state"] == ["state-1"]
92
+ assert query["access_type"] == ["offline"]
93
+ assert query["prompt"] == ["consent"]
94
+ assert query["redirect_uri"] == ["http://localhost:50001/oauth/gemini-api/callback"]
95
+ finally:
96
+ pop_attempt("state-1")
97
+
98
+
99
+def test_start_login_requires_user_supplied_oauth_client(monkeypatch):
100
+ provider = gemini.GeminiApiOAuthProvider()
101
+ cfg = _config()
102
+ cfg["client_id"] = ""
103
+ cfg["client_secret"] = ""
104
+ monkeypatch.setattr(gemini, "_gemini_api_config", lambda: cfg)
105
+ monkeypatch.setattr(gemini, "_codex_helper", _fake_codex_helper)
106
+
107
+ result = provider.start_login({}, FakeRequest())
108
+
109
+ assert result.ok is False
110
+ assert result.provider_id == GEMINI_API_PROVIDER_ID
111
+ assert "OAuth client ID and client secret" in result.error
112
+
113
+
114
+@pytest.mark.parametrize(
115
+ ("raw", "expected"),
116
+ [
117
+ (
118
+ "http://localhost:50001/oauth/gemini-api/callback?code=abc&state=state-1",
119
+ {"code": "abc", "state": "state-1", "error": None, "error_description": None},
120
+ ),
121
+ ("?code=abc&state=state-1", {"code": "abc", "state": "state-1", "error": None, "error_description": None}),
122
+ ("code=abc&state=state-1", {"code": "abc", "state": "state-1", "error": None, "error_description": None}),
123
+ ("abc", {"code": "abc", "state": None, "error": None, "error_description": None}),
124
+ ],
125
+)
126
+def test_parse_manual_callback_accepts_url_query_and_bare_code(raw, expected):
127
+ assert gemini.parse_manual_callback(raw) == expected
128
+
129
+
130
+def test_manual_callback_exchanges_code_and_stores_tokens(tmp_path, monkeypatch):
131
+ provider = gemini.GeminiApiOAuthProvider()
132
+ auth_path = tmp_path / "auth.json"
133
+ put_attempt(
134
+ "state-1",
135
+ "verifier-1",
136
+ "http://localhost:50001/oauth/gemini-api/callback",
137
+ provider_id=GEMINI_API_PROVIDER_ID,
138
+ extra={
139
+ "client_id": "client-id.apps.googleusercontent.com",
140
+ "client_secret": "client-secret",
141
+ "quota_project_id": "quota-project",
142
+ "token_endpoint": gemini.GOOGLE_TOKEN_ENDPOINT,
143
+ "api_base_url": gemini.GEMINI_OPENAI_API_BASE,
144
+ },
145
+ )
146
+ calls = []
147
+
148
+ monkeypatch.setattr(provider, "auth_path", lambda: auth_path)
149
+
150
+ def fake_exchange(token_endpoint, code, redirect_uri, code_verifier, client_id, client_secret):
151
+ calls.append((token_endpoint, code, redirect_uri, code_verifier, client_id, client_secret))
152
+ return {
153
+ "access_token": "access-token",
154
+ "refresh_token": "refresh-token",
155
+ "expires_in": 3600,
156
+ "id_token": _jwt({"email": "user@example.com"}),
157
+ "token_type": "Bearer",
158
+ }
159
+
160
+ monkeypatch.setattr(provider, "exchange_code", fake_exchange)
161
+
162
+ result = provider.manual_callback(
163
+ {"callback": "http://localhost:50001/oauth/gemini-api/callback?code=code-1&state=state-1"}
164
+ )
165
+
166
+ assert result.ok is True
167
+ assert result.completed is True
168
+ assert result.account_label == "user@example.com"
169
+ assert calls == [
170
+ (
171
+ gemini.GOOGLE_TOKEN_ENDPOINT,
172
+ "code-1",
173
+ "http://localhost:50001/oauth/gemini-api/callback",
174
+ "verifier-1",
175
+ "client-id.apps.googleusercontent.com",
176
+ "client-secret",
177
+ )
178
+ ]
179
+ saved = json.loads(auth_path.read_text(encoding="utf-8"))
180
+ assert saved["provider"] == GEMINI_API_PROVIDER_ID
181
+ assert saved["access"] == "access-token"
182
+ assert saved["refresh"] == "refresh-token"
183
+ assert saved["quota_project_id"] == "quota-project"
184
+ assert saved["client_id"] == "client-id.apps.googleusercontent.com"
185
+
186
+
187
+def test_ensure_fresh_auth_rejects_malicious_stored_token_endpoint(monkeypatch):
188
+ provider = gemini.GeminiApiOAuthProvider()
189
+ monkeypatch.setattr(
190
+ provider,
191
+ "read_auth",
192
+ lambda: {
193
+ "access": "expired-access-token",
194
+ "refresh": "refresh-token",
195
+ "expires": 1,
196
+ "client_id": "client-id.apps.googleusercontent.com",
197
+ "client_secret": "client-secret",
198
+ "token_endpoint": "https://evil.example/oauth/token",
199
+ },
200
+ )
201
+
202
+ with pytest.raises(ProviderError) as exc_info:
203
+ provider.ensure_fresh_auth()
204
+
205
+ assert exc_info.value.code == "invalid_token_endpoint"
206
+
207
+
208
+def test_models_returns_curated_list_without_network(monkeypatch):
209
+ provider = gemini.GeminiApiOAuthProvider()
210
+ monkeypatch.setattr(provider, "read_auth", lambda: {})
211
+
212
+ models = provider.models()
213
+
214
+ assert models[:3] == [
215
+ "gemini-3.5-flash",
216
+ "gemini-3.1-pro-preview",
217
+ "gemini-3-flash-preview",
218
+ ]
219
+
220
+
221
+def test_models_does_not_send_bearer_token_to_malicious_base_url(monkeypatch):
222
+ calls = []
223
+
224
+ class FakeResponse:
225
+ ok = True
226
+
227
+ def json(self):
228
+ return {"data": [{"id": "models/gemini-3.5-flash"}]}
229
+
230
+ class FakeRequests:
231
+ @staticmethod
232
+ def get(url, headers, timeout):
233
+ calls.append((url, headers, timeout))
234
+ return FakeResponse()
235
+
236
+ monkeypatch.setitem(sys.modules, "requests", FakeRequests)
237
+
238
+ provider = gemini.GeminiApiOAuthProvider()
239
+ monkeypatch.setattr(
240
+ provider,
241
+ "read_auth",
242
+ lambda: {
243
+ "access": "access-token",
244
+ "refresh": "refresh-token",
245
+ "expires": int(time.time() * 1000) + 3_600_000,
246
+ "base_url": "https://evil.example/v1",
247
+ "quota_project_id": "quota-project",
248
+ },
249
+ )
250
+
251
+ assert provider.models() == ["gemini-3.5-flash"]
252
+ assert calls == [
253
+ (
254
+ "https://generativelanguage.googleapis.com/v1beta/openai/models",
255
+ {
256
+ "Accept": "application/json",
257
+ "Authorization": "Bearer access-token",
258
+ "x-goog-user-project": "quota-project",
259
+ },
260
+ 30,
261
+ )
262
+ ]
tests/test_oauth_github_copilot.py
new
+252
@@ -0,0 +1,252 @@
1
+from __future__ import annotations
2
+
3
+import json
4
+import sys
5
+import time
6
+from pathlib import Path
7
+
8
+import pytest
9
+
10
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
11
+
12
+from plugins._oauth.helpers.providers import github_copilot as copilot
13
+from plugins._oauth.helpers.providers.base import GITHUB_COPILOT_PROVIDER_ID
14
+from plugins._oauth.helpers.state import pop_device_attempt
15
+
16
+
17
+def test_normalize_enterprise_domain_defaults_blank_to_github_dot_com():
18
+ assert copilot.normalize_enterprise_domain("") == "github.com"
19
+
20
+
21
+@pytest.mark.parametrize(
22
+ ("value", "expected"),
23
+ [
24
+ ("github.example.com", "github.example.com"),
25
+ ("https://github.example.com/org", "github.example.com"),
26
+ ],
27
+)
28
+def test_normalize_enterprise_domain_accepts_domain_or_url(value, expected):
29
+ assert copilot.normalize_enterprise_domain(value) == expected
30
+
31
+
32
+def test_normalize_enterprise_domain_rejects_invalid_url():
33
+ with pytest.raises(ValueError, match="Invalid GitHub Enterprise"):
34
+ copilot.normalize_enterprise_domain("http://")
35
+
36
+
37
+def test_copilot_base_url_from_token_uses_proxy_endpoint():
38
+ token = "tid=1;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;sku=monthly"
39
+
40
+ assert copilot.copilot_base_url_from_token(token, "") == "https://api.individual.githubcopilot.com"
41
+
42
+
43
+def test_copilot_base_url_from_token_falls_back_for_malicious_proxy_endpoint():
44
+ token = "tid=1;exp=9999999999;proxy-ep=evil.example.com;sku=monthly"
45
+
46
+ assert copilot.copilot_base_url_from_token(token, "") == "https://api.individual.githubcopilot.com"
47
+
48
+
49
+def test_poll_pending_and_slow_down_do_not_complete(monkeypatch):
50
+ provider = copilot.GitHubCopilotOAuthProvider()
51
+ pending = provider._store_device_attempt_for_test("github.com", "device-pending", "PENDING", 5)
52
+ slowed = provider._store_device_attempt_for_test("github.com", "device-slow", "SLOW", 5)
53
+
54
+ def fake_poll(domain, device_code):
55
+ if device_code == "device-slow":
56
+ return {"error": "slow_down"}
57
+ return {"error": "authorization_pending"}
58
+
59
+ monkeypatch.setattr(copilot, "_post_device_poll", fake_poll)
60
+
61
+ try:
62
+ pending_result = provider.poll_login({"attempt_id": pending.attempt_id})
63
+ slowed_result = provider.poll_login({"attempt_id": slowed.attempt_id})
64
+
65
+ assert pending_result.ok is True
66
+ assert pending_result.completed is False
67
+ assert pending_result.interval == 5
68
+ assert slowed_result.ok is True
69
+ assert slowed_result.completed is False
70
+ assert slowed_result.interval == 10
71
+ finally:
72
+ pop_device_attempt(pending.attempt_id)
73
+ pop_device_attempt(slowed.attempt_id)
74
+
75
+
76
+def test_poll_success_stores_copilot_credentials(tmp_path, monkeypatch):
77
+ provider = copilot.GitHubCopilotOAuthProvider()
78
+ auth_path = tmp_path / "auth.json"
79
+ attempt = provider._store_device_attempt_for_test("github.com", "device-ok", "OK", 5)
80
+
81
+ monkeypatch.setattr(provider, "auth_path", lambda: auth_path)
82
+ monkeypatch.setattr(
83
+ copilot,
84
+ "_post_device_poll",
85
+ lambda domain, device_code: {"access_token": "github-access-token"},
86
+ )
87
+ monkeypatch.setattr(
88
+ copilot,
89
+ "refresh_copilot_token",
90
+ lambda refresh, domain: {
91
+ "provider": GITHUB_COPILOT_PROVIDER_ID,
92
+ "type": "oauth",
93
+ "refresh": refresh,
94
+ "access": "copilot-access-token",
95
+ "expires": 9_999_999_000,
96
+ "enterprise_domain": "",
97
+ "base_url": "https://api.individual.githubcopilot.com",
98
+ },
99
+ )
100
+ monkeypatch.setattr(
101
+ copilot,
102
+ "enable_known_models",
103
+ lambda token, domain: {"attempted": 8, "enabled": 8, "failed": []},
104
+ )
105
+
106
+ result = provider.poll_login({"attempt_id": attempt.attempt_id})
107
+
108
+ assert result.ok is True
109
+ assert result.completed is True
110
+ saved = json.loads(auth_path.read_text(encoding="utf-8"))
111
+ assert saved["provider"] == GITHUB_COPILOT_PROVIDER_ID
112
+ assert saved["refresh"] == "github-access-token"
113
+ assert saved["access"] == "copilot-access-token"
114
+ assert saved["base_url"] == "https://api.individual.githubcopilot.com"
115
+
116
+
117
+def test_models_returns_curated_list_without_network(monkeypatch):
118
+ provider = copilot.GitHubCopilotOAuthProvider()
119
+ monkeypatch.setattr(provider, "read_auth", lambda: {})
120
+
121
+ models = provider.models()
122
+
123
+ assert models[:3] == ["gpt-5.2-codex", "gpt-5.2", "claude-sonnet-4.5"]
124
+ assert "grok-code-fast-1" in models
125
+
126
+
127
+def test_ensure_fresh_auth_refreshes_expired_credentials(tmp_path, monkeypatch):
128
+ provider = copilot.GitHubCopilotOAuthProvider()
129
+ auth_path = tmp_path / "auth.json"
130
+ provider.write_auth = lambda data: copilot.write_private_json(auth_path, data)
131
+ provider.read_auth = lambda: copilot.read_json_file(auth_path)
132
+ provider.write_auth(
133
+ {
134
+ "provider": GITHUB_COPILOT_PROVIDER_ID,
135
+ "type": "oauth",
136
+ "refresh": "github-refresh-token",
137
+ "access": "expired-access-token",
138
+ "expires": 1,
139
+ "enterprise_domain": "",
140
+ "base_url": "https://api.individual.githubcopilot.com",
141
+ "models_warning": "existing warning",
142
+ }
143
+ )
144
+
145
+ monkeypatch.setattr(
146
+ copilot,
147
+ "refresh_copilot_token",
148
+ lambda refresh, domain: {
149
+ "provider": GITHUB_COPILOT_PROVIDER_ID,
150
+ "type": "oauth",
151
+ "refresh": refresh,
152
+ "access": "fresh-access-token",
153
+ "expires": int(time.time() * 1000) + 3_600_000,
154
+ "enterprise_domain": "",
155
+ "base_url": "https://api.individual.githubcopilot.com",
156
+ },
157
+ )
158
+
159
+ auth = provider.ensure_fresh_auth()
160
+
161
+ assert auth["refresh"] == "github-refresh-token"
162
+ assert auth["access"] == "fresh-access-token"
163
+ assert auth["models_warning"] == "existing warning"
164
+ saved = json.loads(auth_path.read_text(encoding="utf-8"))
165
+ assert saved["access"] == "fresh-access-token"
166
+
167
+
168
+def test_models_uses_refreshed_auth_without_live_network(monkeypatch):
169
+ provider = copilot.GitHubCopilotOAuthProvider()
170
+ calls = []
171
+
172
+ monkeypatch.setattr(
173
+ provider,
174
+ "ensure_fresh_auth",
175
+ lambda: {
176
+ "access": "fresh-access-token",
177
+ "base_url": "https://api.individual.githubcopilot.com",
178
+ },
179
+ )
180
+
181
+ class FakeResponse:
182
+ ok = True
183
+
184
+ def json(self):
185
+ return {"data": [{"id": "fresh-model"}]}
186
+
187
+ class FakeRequests:
188
+ @staticmethod
189
+ def get(url, headers, timeout):
190
+ calls.append((url, headers, timeout))
191
+ return FakeResponse()
192
+
193
+ monkeypatch.setitem(sys.modules, "requests", FakeRequests)
194
+
195
+ assert provider.models() == ["fresh-model"]
196
+ assert calls[0][1]["Authorization"] == "Bearer fresh-access-token"
197
+
198
+
199
+def test_models_does_not_send_bearer_token_to_malicious_base_url(monkeypatch):
200
+ provider = copilot.GitHubCopilotOAuthProvider()
201
+ calls = []
202
+
203
+ monkeypatch.setattr(
204
+ provider,
205
+ "ensure_fresh_auth",
206
+ lambda: {
207
+ "access": "fresh-access-token",
208
+ "base_url": "https://evil.example.com/v1",
209
+ },
210
+ )
211
+
212
+ class FakeResponse:
213
+ ok = True
214
+
215
+ def json(self):
216
+ return {"data": [{"id": "safe-model"}]}
217
+
218
+ class FakeRequests:
219
+ @staticmethod
220
+ def get(url, headers, timeout):
221
+ calls.append((url, headers, timeout))
222
+ return FakeResponse()
223
+
224
+ monkeypatch.setitem(sys.modules, "requests", FakeRequests)
225
+
226
+ assert provider.models() == ["safe-model"]
227
+ assert calls[0][0] == "https://api.individual.githubcopilot.com/models"
228
+ assert not calls[0][0].startswith("https://evil.example.com")
229
+ assert calls[0][1]["Authorization"] == "Bearer fresh-access-token"
230
+
231
+
232
+def test_refresh_copilot_token_normalizes_malicious_proxy_endpoint(monkeypatch):
233
+ class FakeResponse:
234
+ ok = True
235
+ status_code = 200
236
+
237
+ def json(self):
238
+ return {
239
+ "token": "tid=1;exp=9999999999;proxy-ep=evil.example.com;sku=monthly",
240
+ "expires_at": int(time.time()) + 3600,
241
+ }
242
+
243
+ class FakeRequests:
244
+ @staticmethod
245
+ def get(url, headers, timeout):
246
+ return FakeResponse()
247
+
248
+ monkeypatch.setitem(sys.modules, "requests", FakeRequests)
249
+
250
+ auth = copilot.refresh_copilot_token("github-refresh-token", "")
251
+
252
+ assert auth["base_url"] == "https://api.individual.githubcopilot.com"
tests/test_oauth_providers.py
new
+1200
@@ -0,0 +1,1200 @@
1
+from __future__ import annotations
2
+
3
+import asyncio
4
+import importlib
5
+import json
6
+import os
7
+import sys
8
+import stat
9
+import types
10
+from pathlib import Path
11
+
12
+import pytest
13
+import yaml
14
+
15
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
16
+
17
+try:
18
+ import helpers.api # noqa: F401
19
+except ModuleNotFoundError as exc:
20
+ if exc.name != "flask":
21
+ raise
22
+ fake_api = types.ModuleType("helpers.api")
23
+
24
+ class ApiHandler:
25
+ def __init__(self, app=None, thread_lock=None):
26
+ self.app = app
27
+ self.thread_lock = thread_lock
28
+
29
+ class Request:
30
+ pass
31
+
32
+ fake_api.ApiHandler = ApiHandler
33
+ fake_api.Request = Request
34
+ sys.modules["helpers.api"] = fake_api
35
+
36
+try:
37
+ import helpers.extension # noqa: F401
38
+except ModuleNotFoundError as exc:
39
+ if exc.name not in {"regex", "simpleeval"}:
40
+ raise
41
+ fake_extension = types.ModuleType("helpers.extension")
42
+
43
+ class Extension:
44
+ def __init__(self, agent=None, **kwargs):
45
+ self.agent = agent
46
+ self.kwargs = kwargs
47
+
48
+ fake_extension.Extension = Extension
49
+ sys.modules["helpers.extension"] = fake_extension
50
+
51
+from plugins._oauth.api import status as status_api
52
+from plugins._oauth.api import disconnect as disconnect_api
53
+from plugins._oauth.api import manual_callback as manual_callback_api
54
+from plugins._oauth.api import poll_device_login as poll_device_login_api
55
+from plugins._oauth.api import start_device_login as start_device_login_api
56
+from plugins._oauth.api import start_login as start_login_api
57
+from plugins._oauth.api.models import Models
58
+from plugins._oauth.extensions.python._functions.models.get_api_key.end._20_codex_account_dummy_key import (
59
+ CodexAccountDummyKey,
60
+)
61
+from plugins._oauth.helpers import state
62
+from plugins._oauth.helpers.providers import base as provider_base
63
+from plugins._oauth.helpers.providers.base import (
64
+ CODEX_PROVIDER_ID,
65
+ DUMMY_API_KEY,
66
+ GEMINI_API_PROVIDER_ID,
67
+ GITHUB_COPILOT_PROVIDER_ID,
68
+ XAI_GROK_PROVIDER_ID,
69
+ CallbackResult,
70
+ LoginPollResult,
71
+ LoginStartResult,
72
+ ProviderError,
73
+ provider_data_dir,
74
+ public_error,
75
+ write_private_json,
76
+)
77
+from plugins._oauth.helpers.providers.registry import get_provider, provider_registry
78
+from plugins._oauth.helpers.usage_plans import (
79
+ CLAUDE_CODE_PROVIDER_ID,
80
+ GEMINI_CODE_ASSIST_PROVIDER_ID,
81
+ usage_plan_catalog,
82
+)
83
+
84
+
85
+class FakeRequest:
86
+ headers = {}
87
+ url_root = "http://localhost:50001/"
88
+
89
+
90
+def test_registry_exposes_initial_oauth_providers():
91
+ registry = provider_registry()
92
+
93
+ assert list(registry) == [
94
+ CODEX_PROVIDER_ID,
95
+ GITHUB_COPILOT_PROVIDER_ID,
96
+ GEMINI_API_PROVIDER_ID,
97
+ XAI_GROK_PROVIDER_ID,
98
+ ]
99
+ assert registry[CODEX_PROVIDER_ID].metadata().display_name == "Codex/ChatGPT"
100
+ assert registry[GITHUB_COPILOT_PROVIDER_ID].metadata().model_provider_id == GITHUB_COPILOT_PROVIDER_ID
101
+ assert registry[GEMINI_API_PROVIDER_ID].metadata().supports_oauth_client_config is True
102
+ assert registry[XAI_GROK_PROVIDER_ID].metadata().auth_flow == "browser_pkce"
103
+
104
+
105
+def test_get_provider_rejects_unknown_provider_id():
106
+ with pytest.raises(KeyError, match="Unknown OAuth provider"):
107
+ get_provider("missing")
108
+
109
+
110
+def test_get_provider_coerces_non_string_provider_id():
111
+ with pytest.raises(KeyError, match="Unknown OAuth provider: 123"):
112
+ get_provider(123)
113
+
114
+
115
+@pytest.mark.parametrize("provider_id", [0, False])
116
+def test_get_provider_rejects_falsey_non_string_provider_id(provider_id):
117
+ with pytest.raises(KeyError, match=f"Unknown OAuth provider: {provider_id}"):
118
+ get_provider(provider_id)
119
+
120
+
121
+@pytest.mark.parametrize("provider_id", [None, ""])
122
+def test_get_provider_defaults_empty_provider_id_to_codex(provider_id):
123
+ assert get_provider(provider_id).provider_id == CODEX_PROVIDER_ID
124
+
125
+
126
+def test_state_keeps_login_attempts_provider_scoped():
127
+ attempt = state.put_attempt(
128
+ "state-a",
129
+ "verifier-a",
130
+ "http://127.0.0.1:56121/callback",
131
+ provider_id=XAI_GROK_PROVIDER_ID,
132
+ extra={"nonce": "nonce-a"},
133
+ )
134
+
135
+ loaded = state.get_attempt("state-a")
136
+
137
+ assert loaded == attempt
138
+ assert loaded.provider_id == XAI_GROK_PROVIDER_ID
139
+ assert loaded.extra == {"nonce": "nonce-a"}
140
+ assert state.pop_attempt("state-a") == attempt
141
+ assert state.get_attempt("state-a") is None
142
+
143
+
144
+def test_state_keeps_device_attempts_provider_scoped():
145
+ attempt = state.put_device_attempt(
146
+ "attempt-a",
147
+ "device-a",
148
+ "USER-CODE",
149
+ 5,
150
+ 99_999_999_999,
151
+ provider_id=GITHUB_COPILOT_PROVIDER_ID,
152
+ extra={"domain": "github.com"},
153
+ )
154
+
155
+ loaded = state.get_device_attempt("attempt-a")
156
+
157
+ assert loaded == attempt
158
+ assert loaded.provider_id == GITHUB_COPILOT_PROVIDER_ID
159
+ assert loaded.extra == {"domain": "github.com"}
160
+ assert state.pop_device_attempt("attempt-a") == attempt
161
+ assert state.get_device_attempt("attempt-a") is None
162
+
163
+
164
+def test_starter_providers_report_login_flow_values():
165
+ class FakeProvider:
166
+ def __init__(self, provider_id: str, flow: str):
167
+ self.provider_id = provider_id
168
+ self.flow = flow
169
+
170
+ def start_login(self, input=None, request=None):
171
+ return LoginStartResult(
172
+ ok=False,
173
+ provider_id=self.provider_id,
174
+ flow=self.flow,
175
+ message="not connected",
176
+ )
177
+
178
+ registry = {
179
+ GITHUB_COPILOT_PROVIDER_ID: FakeProvider(GITHUB_COPILOT_PROVIDER_ID, "device_code"),
180
+ XAI_GROK_PROVIDER_ID: FakeProvider(XAI_GROK_PROVIDER_ID, "browser_pkce"),
181
+ }
182
+
183
+ github = registry[GITHUB_COPILOT_PROVIDER_ID].start_login({})
184
+ xai = registry[XAI_GROK_PROVIDER_ID].start_login({})
185
+
186
+ assert github.flow == "device_code"
187
+ assert xai.flow == "browser_pkce"
188
+
189
+
190
+def test_result_contract_preserves_account_id_with_account_label():
191
+ poll = LoginPollResult(
192
+ ok=True,
193
+ provider_id=CODEX_PROVIDER_ID,
194
+ account_label="user@example.com",
195
+ account_id="acct-1",
196
+ )
197
+ callback = CallbackResult(
198
+ ok=True,
199
+ provider_id=CODEX_PROVIDER_ID,
200
+ account_label="user@example.com",
201
+ account_id="acct-1",
202
+ )
203
+
204
+ assert poll.account_label == "user@example.com"
205
+ assert poll.account_id == "acct-1"
206
+ assert callback.account_label == "user@example.com"
207
+ assert callback.account_id == "acct-1"
208
+
209
+
210
+def test_provider_data_dir_creates_directory(tmp_path, monkeypatch):
211
+ fake_files = types.SimpleNamespace(
212
+ USER_DIR="usr",
213
+ PLUGINS_DIR="plugins",
214
+ get_abs_path=lambda *parts: str(tmp_path.joinpath(*parts)),
215
+ )
216
+ monkeypatch.setitem(sys.modules, "helpers.files", fake_files)
217
+
218
+ path = provider_data_dir("provider-a")
219
+
220
+ assert path == tmp_path / "usr" / "plugins" / "_oauth" / "provider-a"
221
+ assert path.is_dir()
222
+
223
+
224
+@pytest.mark.parametrize("provider_slug", ["../codex", "nested/codex", "nested\\codex", "", "."])
225
+def test_provider_data_dir_rejects_unsafe_slugs(provider_slug):
226
+ with pytest.raises(ProviderError, match="Invalid OAuth provider storage slug.") as exc_info:
227
+ provider_data_dir(provider_slug)
228
+
229
+ assert exc_info.value.code == "invalid_provider_slug"
230
+
231
+
232
+def test_write_private_json_does_not_reuse_preexisting_temp_file(tmp_path):
233
+ path = tmp_path / "auth.json"
234
+ stale_tmp = tmp_path / "auth.json.tmp"
235
+ stale_tmp.write_text("stale", encoding="utf-8")
236
+ stale_tmp.chmod(0o666)
237
+ stale_inode = stale_tmp.stat().st_ino
238
+
239
+ write_private_json(path, {"access_token": "secret"})
240
+
241
+ assert stale_tmp.read_text(encoding="utf-8") == "stale"
242
+ assert stale_tmp.stat().st_ino == stale_inode
243
+ assert path.read_text(encoding="utf-8").find("secret") > -1
244
+ assert stat.S_IMODE(path.stat().st_mode) == 0o600
245
+
246
+
247
+def test_write_private_json_cleans_up_generated_temp_file_on_error(tmp_path, monkeypatch):
248
+ def fail_dump(*args, **kwargs):
249
+ raise RuntimeError("write failed")
250
+
251
+ monkeypatch.setattr(provider_base.json, "dump", fail_dump)
252
+
253
+ path = tmp_path / "auth.json"
254
+ with pytest.raises(RuntimeError, match="write failed"):
255
+ write_private_json(path, {"token": "secret"})
256
+
257
+ assert list(tmp_path.glob(".auth.json.*.tmp")) == []
258
+ assert not path.exists()
259
+
260
+
261
+def test_write_private_json_does_not_follow_preexisting_temp_symlink(tmp_path):
262
+ leak_target = tmp_path / "leak-target"
263
+ leak_target.write_text("safe", encoding="utf-8")
264
+ stale_tmp = tmp_path / "auth.json.tmp"
265
+ try:
266
+ stale_tmp.symlink_to(leak_target)
267
+ except (NotImplementedError, OSError) as exc:
268
+ pytest.skip(f"symlink creation is not supported: {exc}")
269
+
270
+ path = tmp_path / "auth.json"
271
+ write_private_json(path, {"token": "secret"})
272
+
273
+ assert leak_target.read_text(encoding="utf-8") == "safe"
274
+ assert stale_tmp.is_symlink()
275
+ assert '"token": "secret"' in path.read_text(encoding="utf-8")
276
+ assert stat.S_IMODE(path.stat().st_mode) == 0o600
277
+
278
+
279
+def test_write_private_json_sets_generated_temp_private_before_dump(tmp_path, monkeypatch):
280
+ observed_modes = []
281
+ real_dump = provider_base.json.dump
282
+
283
+ def inspect_mode_before_dump(data, handle, *args, **kwargs):
284
+ temp_files = list(tmp_path.glob(".auth.json.*.tmp"))
285
+ assert len(temp_files) == 1
286
+ observed_modes.append(stat.S_IMODE(temp_files[0].stat().st_mode))
287
+ return real_dump(data, handle, *args, **kwargs)
288
+
289
+ monkeypatch.setattr(provider_base.json, "dump", inspect_mode_before_dump)
290
+
291
+ path = tmp_path / "auth.json"
292
+ write_private_json(path, {"token": "secret"})
293
+
294
+ assert observed_modes == [0o600]
295
+ assert stat.S_IMODE(path.stat().st_mode) == 0o600
296
+
297
+
298
+def test_public_error_returns_user_facing_string():
299
+ assert public_error(RuntimeError("visible")) == "visible"
300
+ assert public_error(Exception()) == "Exception"
301
+
302
+
303
+def test_status_api_returns_provider_registry_shape(monkeypatch):
304
+ class FakeProvider:
305
+ def __init__(self, provider_id: str):
306
+ self.provider_id = provider_id
307
+
308
+ def status(self):
309
+ return {"provider_id": self.provider_id, "connected": False}
310
+
311
+ fake_registry = {
312
+ provider_id: FakeProvider(provider_id)
313
+ for provider_id in [
314
+ CODEX_PROVIDER_ID,
315
+ GITHUB_COPILOT_PROVIDER_ID,
316
+ GEMINI_API_PROVIDER_ID,
317
+ XAI_GROK_PROVIDER_ID,
318
+ ]
319
+ }
320
+ monkeypatch.setattr(status_api, "provider_registry", lambda: fake_registry)
321
+ monkeypatch.setattr(status_api, "is_installed", lambda: True)
322
+
323
+ response = asyncio.run(status_api.Status(None, None).process({}, FakeRequest()))
324
+
325
+ assert response["ok"] is True
326
+ assert response["routes_installed"] is True
327
+ assert [provider["provider_id"] for provider in response["providers"]] == [
328
+ CODEX_PROVIDER_ID,
329
+ GITHUB_COPILOT_PROVIDER_ID,
330
+ GEMINI_API_PROVIDER_ID,
331
+ XAI_GROK_PROVIDER_ID,
332
+ ]
333
+ assert set(response["provider_map"]) == {
334
+ CODEX_PROVIDER_ID,
335
+ GITHUB_COPILOT_PROVIDER_ID,
336
+ GEMINI_API_PROVIDER_ID,
337
+ XAI_GROK_PROVIDER_ID,
338
+ }
339
+ assert set(response["usage_plan_catalog"]) >= {
340
+ CODEX_PROVIDER_ID,
341
+ GITHUB_COPILOT_PROVIDER_ID,
342
+ GEMINI_API_PROVIDER_ID,
343
+ XAI_GROK_PROVIDER_ID,
344
+ CLAUDE_CODE_PROVIDER_ID,
345
+ GEMINI_CODE_ASSIST_PROVIDER_ID,
346
+ }
347
+ assert response["codex"] == response["provider_map"][CODEX_PROVIDER_ID]
348
+
349
+
350
+def test_status_api_contains_provider_status_exceptions(monkeypatch):
351
+ class GoodProvider:
352
+ provider_id = CODEX_PROVIDER_ID
353
+
354
+ def status(self):
355
+ return {"provider_id": CODEX_PROVIDER_ID, "connected": True}
356
+
357
+ class FailingProvider:
358
+ provider_id = XAI_GROK_PROVIDER_ID
359
+
360
+ def status(self):
361
+ raise RuntimeError("status failed")
362
+
363
+ monkeypatch.setattr(
364
+ status_api,
365
+ "provider_registry",
366
+ lambda: {
367
+ CODEX_PROVIDER_ID: GoodProvider(),
368
+ XAI_GROK_PROVIDER_ID: FailingProvider(),
369
+ },
370
+ )
371
+ monkeypatch.setattr(status_api, "is_installed", lambda: True)
372
+
373
+ response = asyncio.run(status_api.Status(None, None).process({}, FakeRequest()))
374
+
375
+ assert response["ok"] is True
376
+ assert response["provider_map"][CODEX_PROVIDER_ID]["connected"] is True
377
+ assert response["provider_map"][XAI_GROK_PROVIDER_ID] == {
378
+ "provider_id": XAI_GROK_PROVIDER_ID,
379
+ "connected": False,
380
+ "error": "status failed",
381
+ }
382
+
383
+
384
+@pytest.mark.parametrize(
385
+ ("provider_id", "expected_flow"),
386
+ [
387
+ (GITHUB_COPILOT_PROVIDER_ID, "device_code"),
388
+ (GEMINI_API_PROVIDER_ID, "browser_pkce"),
389
+ (XAI_GROK_PROVIDER_ID, "browser_pkce"),
390
+ ],
391
+)
392
+def test_start_login_dispatches_to_selected_starter_provider(monkeypatch, provider_id, expected_flow):
393
+ class FakeProvider:
394
+ def __init__(self, provider_id: str):
395
+ self.provider_id = provider_id
396
+
397
+ def start_login(self, input, request):
398
+ return LoginStartResult(
399
+ ok=False,
400
+ provider_id=self.provider_id,
401
+ flow=expected_flow,
402
+ message="not connected",
403
+ )
404
+
405
+ monkeypatch.setattr(start_login_api, "get_provider", lambda selected: FakeProvider(selected))
406
+
407
+ response = asyncio.run(
408
+ start_login_api.StartLogin(None, None).process(
409
+ {"provider_id": provider_id},
410
+ FakeRequest(),
411
+ )
412
+ )
413
+
414
+ assert response["ok"] is False
415
+ assert response["provider_id"] == provider_id
416
+ assert response["flow"] == expected_flow
417
+ assert response["message"]
418
+
419
+
420
+def test_start_login_without_provider_id_uses_legacy_codex_browser_login(monkeypatch):
421
+ calls = []
422
+
423
+ class FakeCodexProvider:
424
+ def start_browser_login(self, input, request):
425
+ calls.append(("browser", input, request))
426
+ return LoginStartResult(
427
+ ok=True,
428
+ provider_id=CODEX_PROVIDER_ID,
429
+ flow="browser_pkce",
430
+ auth_url="http://auth.example/authorize",
431
+ redirect_uri="http://localhost/auth/callback",
432
+ )
433
+
434
+ def start_login(self, input, request):
435
+ calls.append(("device", input, request))
436
+ return LoginStartResult(ok=True, provider_id=CODEX_PROVIDER_ID, flow="device_code")
437
+
438
+ monkeypatch.setattr(start_login_api, "get_provider", lambda provider_id: FakeCodexProvider())
439
+
440
+ request = FakeRequest()
441
+ response = asyncio.run(start_login_api.StartLogin(None, None).process({}, request))
442
+
443
+ assert calls == [("browser", {}, request)]
444
+ assert response["ok"] is True
445
+ assert response["provider_id"] == CODEX_PROVIDER_ID
446
+ assert response["flow"] == "browser_pkce"
447
+ assert response["auth_url"] == "http://auth.example/authorize"
448
+ assert response["redirect_uri"] == "http://localhost/auth/callback"
449
+
450
+
451
+def test_start_login_with_blank_provider_id_uses_provider_aware_codex_login(monkeypatch):
452
+ calls = []
453
+
454
+ class FakeCodexProvider:
455
+ def start_browser_login(self, input, request):
456
+ calls.append(("browser", input, request))
457
+ return LoginStartResult(ok=True, provider_id=CODEX_PROVIDER_ID, flow="browser_pkce")
458
+
459
+ def start_login(self, input, request):
460
+ calls.append(("device", input, request))
461
+ return LoginStartResult(ok=True, provider_id=CODEX_PROVIDER_ID, flow="device_code")
462
+
463
+ monkeypatch.setattr(start_login_api, "get_provider", lambda provider_id: FakeCodexProvider())
464
+
465
+ request = FakeRequest()
466
+ response = asyncio.run(
467
+ start_login_api.StartLogin(None, None).process({"provider_id": ""}, request)
468
+ )
469
+
470
+ assert calls == [("device", {"provider_id": ""}, request)]
471
+ assert response["ok"] is True
472
+ assert response["provider_id"] == CODEX_PROVIDER_ID
473
+ assert response["flow"] == "device_code"
474
+
475
+
476
+def test_start_login_provider_exception_returns_structured_error(monkeypatch):
477
+ class FailingProvider:
478
+ def start_login(self, input, request):
479
+ raise RuntimeError("login failed")
480
+
481
+ monkeypatch.setattr(start_login_api, "get_provider", lambda provider_id: FailingProvider())
482
+
483
+ response = asyncio.run(
484
+ start_login_api.StartLogin(None, None).process(
485
+ {"provider_id": GITHUB_COPILOT_PROVIDER_ID},
486
+ FakeRequest(),
487
+ )
488
+ )
489
+
490
+ assert response == {
491
+ "ok": False,
492
+ "provider_id": GITHUB_COPILOT_PROVIDER_ID,
493
+ "error": "login failed",
494
+ }
495
+
496
+
497
+def test_manual_callback_dispatches_to_xai_provider_without_active_attempt():
498
+ response = asyncio.run(
499
+ manual_callback_api.ManualCallback(None, None).process(
500
+ {"provider_id": XAI_GROK_PROVIDER_ID, "callback_url": "http://localhost/callback?code=abc"},
501
+ FakeRequest(),
502
+ )
503
+ )
504
+
505
+ assert response["ok"] is False
506
+ assert response["provider_id"] == XAI_GROK_PROVIDER_ID
507
+ assert "no active xai grok sign-in attempt" in response["error"].lower()
508
+
509
+
510
+def test_start_device_login_wrapper_calls_codex_provider(monkeypatch):
511
+ calls = []
512
+
513
+ class FakeProvider:
514
+ def start_login(self, input, request):
515
+ calls.append((input, request))
516
+ return LoginStartResult(
517
+ ok=True,
518
+ provider_id=CODEX_PROVIDER_ID,
519
+ flow="device_code",
520
+ attempt_id="attempt-1",
521
+ )
522
+
523
+ monkeypatch.setattr(
524
+ start_device_login_api,
525
+ "get_provider",
526
+ lambda provider_id: calls.append(("provider_id", provider_id)) or FakeProvider(),
527
+ )
528
+
529
+ request = FakeRequest()
530
+ response = asyncio.run(
531
+ start_device_login_api.StartDeviceLogin(None, None).process(
532
+ {"ignored_provider_id": XAI_GROK_PROVIDER_ID},
533
+ request,
534
+ )
535
+ )
536
+
537
+ assert calls[0] == ("provider_id", CODEX_PROVIDER_ID)
538
+ assert calls[1][0] == {"ignored_provider_id": XAI_GROK_PROVIDER_ID}
539
+ assert calls[1][1] is request
540
+ assert response["ok"] is True
541
+ assert response["provider_id"] == CODEX_PROVIDER_ID
542
+ assert response["flow"] == "device_code"
543
+ assert response["attempt_id"] == "attempt-1"
544
+
545
+
546
+def test_poll_device_login_wrapper_calls_codex_provider(monkeypatch):
547
+ calls = []
548
+
549
+ class FakeProvider:
550
+ def poll_login(self, input, request):
551
+ calls.append((input, request))
552
+ return LoginPollResult(
553
+ ok=True,
554
+ provider_id=CODEX_PROVIDER_ID,
555
+ completed=True,
556
+ account_label="user@example.com",
557
+ account_id="account-1",
558
+ )
559
+
560
+ monkeypatch.setattr(
561
+ poll_device_login_api,
562
+ "get_provider",
563
+ lambda provider_id: calls.append(("provider_id", provider_id)) or FakeProvider(),
564
+ )
565
+
566
+ request = FakeRequest()
567
+ response = asyncio.run(
568
+ poll_device_login_api.PollDeviceLogin(None, None).process(
569
+ {"attempt_id": "attempt-1"},
570
+ request,
571
+ )
572
+ )
573
+
574
+ assert calls[0] == ("provider_id", CODEX_PROVIDER_ID)
575
+ assert calls[1][0] == {"attempt_id": "attempt-1"}
576
+ assert calls[1][1] is request
577
+ assert response["ok"] is True
578
+ assert response["provider_id"] == CODEX_PROVIDER_ID
579
+ assert response["completed"] is True
580
+ assert response["account_label"] == "user@example.com"
581
+ assert response["account_id"] == "account-1"
582
+
583
+
584
+def test_poll_device_login_with_provider_id_calls_github_provider(monkeypatch):
585
+ calls = []
586
+
587
+ class FakeProvider:
588
+ def poll_login(self, input, request):
589
+ calls.append((input, request))
590
+ return LoginPollResult(
591
+ ok=True,
592
+ provider_id=GITHUB_COPILOT_PROVIDER_ID,
593
+ completed=True,
594
+ account_label="github.com",
595
+ )
596
+
597
+ monkeypatch.setattr(
598
+ poll_device_login_api,
599
+ "get_provider",
600
+ lambda provider_id: calls.append(("provider_id", provider_id)) or FakeProvider(),
601
+ )
602
+
603
+ request = FakeRequest()
604
+ payload = {"provider_id": GITHUB_COPILOT_PROVIDER_ID, "attempt_id": "attempt-1"}
605
+ response = asyncio.run(
606
+ poll_device_login_api.PollDeviceLogin(None, None).process(payload, request)
607
+ )
608
+
609
+ assert calls[0] == ("provider_id", GITHUB_COPILOT_PROVIDER_ID)
610
+ assert calls[1] == (payload, request)
611
+ assert response["ok"] is True
612
+ assert response["provider_id"] == GITHUB_COPILOT_PROVIDER_ID
613
+ assert response["completed"] is True
614
+ assert response["account_label"] == "github.com"
615
+
616
+
617
+def test_poll_device_login_unknown_provider_returns_structured_error():
618
+ response = asyncio.run(
619
+ poll_device_login_api.PollDeviceLogin(None, None).process(
620
+ {"provider_id": "missing", "attempt_id": "attempt-1"},
621
+ FakeRequest(),
622
+ )
623
+ )
624
+
625
+ assert response["ok"] is False
626
+ assert response["provider_id"] == "missing"
627
+ assert "Unknown OAuth provider" in response["error"]
628
+
629
+
630
+@pytest.mark.parametrize(
631
+ "provider_id",
632
+ [CODEX_PROVIDER_ID, GITHUB_COPILOT_PROVIDER_ID, GEMINI_API_PROVIDER_ID, XAI_GROK_PROVIDER_ID],
633
+)
634
+@pytest.mark.parametrize("initial", [None, "None"])
635
+def test_oauth_providers_report_dummy_api_key_when_missing(provider_id, initial):
636
+ data = {"args": (provider_id,), "kwargs": {}, "result": initial}
637
+
638
+ CodexAccountDummyKey(agent=None).execute(data=data)
639
+
640
+ assert data["result"] == DUMMY_API_KEY
641
+
642
+
643
+@pytest.mark.parametrize(
644
+ "provider_id",
645
+ [CODEX_PROVIDER_ID, GITHUB_COPILOT_PROVIDER_ID, GEMINI_API_PROVIDER_ID, XAI_GROK_PROVIDER_ID],
646
+)
647
+def test_oauth_providers_report_dummy_api_key_when_result_missing(provider_id):
648
+ data = {"args": (provider_id,), "kwargs": {}}
649
+
650
+ CodexAccountDummyKey(agent=None).execute(data=data)
651
+
652
+ assert data["result"] == DUMMY_API_KEY
653
+
654
+
655
+@pytest.mark.parametrize(
656
+ "provider_id",
657
+ [CODEX_PROVIDER_ID, GITHUB_COPILOT_PROVIDER_ID, GEMINI_API_PROVIDER_ID, XAI_GROK_PROVIDER_ID],
658
+)
659
+def test_oauth_providers_preserve_configured_api_key(provider_id):
660
+ data = {"args": (provider_id,), "kwargs": {}, "result": "configured"}
661
+
662
+ CodexAccountDummyKey(agent=None).execute(data=data)
663
+
664
+ assert data["result"] == "configured"
665
+
666
+
667
+def test_model_provider_config_contains_all_oauth_providers():
668
+ provider_path = Path(__file__).resolve().parents[1] / "plugins/_oauth/conf/model_providers.yaml"
669
+ provider_config = yaml.safe_load(provider_path.read_text(encoding="utf-8"))
670
+ chat = provider_config["chat"]
671
+
672
+ assert set(chat) == {
673
+ CODEX_PROVIDER_ID,
674
+ GITHUB_COPILOT_PROVIDER_ID,
675
+ GEMINI_API_PROVIDER_ID,
676
+ XAI_GROK_PROVIDER_ID,
677
+ }
678
+ assert chat[CODEX_PROVIDER_ID]["kwargs"]["api_key"] == DUMMY_API_KEY
679
+ assert chat[GITHUB_COPILOT_PROVIDER_ID]["kwargs"]["api_key"] == DUMMY_API_KEY
680
+ assert chat[GEMINI_API_PROVIDER_ID]["kwargs"]["api_key"] == DUMMY_API_KEY
681
+ assert chat[XAI_GROK_PROVIDER_ID]["kwargs"]["api_key"] == DUMMY_API_KEY
682
+ assert chat[CODEX_PROVIDER_ID]["kwargs"]["api_base"] == "http://127.0.0.1/oauth/codex/v1"
683
+ assert (
684
+ chat[GITHUB_COPILOT_PROVIDER_ID]["kwargs"]["api_base"]
685
+ == "http://127.0.0.1/oauth/github-copilot/v1"
686
+ )
687
+ assert chat[GEMINI_API_PROVIDER_ID]["kwargs"]["api_base"] == "http://127.0.0.1/oauth/gemini-api/v1"
688
+ assert chat[XAI_GROK_PROVIDER_ID]["kwargs"]["api_base"] == "http://127.0.0.1/oauth/xai-grok/v1"
689
+ assert "50001" not in json.dumps(provider_config)
690
+
691
+
692
+def test_provider_metadata_marks_new_oauth_providers_as_oauth_api_key_mode():
693
+ metadata_path = Path(__file__).resolve().parents[1] / "plugins/_model_config/provider_metadata.yaml"
694
+ metadata = yaml.safe_load(metadata_path.read_text(encoding="utf-8"))
695
+
696
+ assert metadata["chat"][GITHUB_COPILOT_PROVIDER_ID]["api_key_mode"] == "oauth"
697
+ assert metadata["chat"][GEMINI_API_PROVIDER_ID]["api_key_mode"] == "oauth"
698
+ assert metadata["chat"][XAI_GROK_PROVIDER_ID]["api_key_mode"] == "oauth"
699
+
700
+
701
+def test_usage_plan_catalog_covers_current_and_nearby_subscription_providers():
702
+ catalog = usage_plan_catalog()
703
+
704
+ assert {plan["id"] for plan in catalog[CODEX_PROVIDER_ID]["plans"]} >= {
705
+ "free",
706
+ "go",
707
+ "plus",
708
+ "pro",
709
+ "business",
710
+ "enterprise_edu",
711
+ "api_key",
712
+ }
713
+ assert {plan["id"] for plan in catalog[GITHUB_COPILOT_PROVIDER_ID]["plans"]} >= {
714
+ "free",
715
+ "student",
716
+ "pro",
717
+ "pro_plus",
718
+ "max",
719
+ "business",
720
+ "enterprise",
721
+ }
722
+ assert {plan["id"] for plan in catalog[GEMINI_API_PROVIDER_ID]["plans"]} >= {
723
+ "oauth_cloud_project",
724
+ "api_key",
725
+ "vertex_ai",
726
+ }
727
+ assert catalog[GEMINI_API_PROVIDER_ID]["implemented"] is True
728
+ assert {plan["id"] for plan in catalog[CLAUDE_CODE_PROVIDER_ID]["plans"]} >= {
729
+ "pro",
730
+ "max_5x",
731
+ "max_20x",
732
+ "team",
733
+ "enterprise_premium",
734
+ "enterprise_usage_based",
735
+ }
736
+ assert {plan["id"] for plan in catalog[GEMINI_CODE_ASSIST_PROVIDER_ID]["plans"]} >= {
737
+ "individual",
738
+ "google_ai_pro",
739
+ "google_ai_ultra",
740
+ "organization",
741
+ "code_assist_standard",
742
+ "code_assist_enterprise",
743
+ "gemini_api_oauth",
744
+ }
745
+ assert catalog[GEMINI_CODE_ASSIST_PROVIDER_ID]["implementation_status"] == "metadata_only"
746
+ google_modes = {
747
+ mode["id"]: mode
748
+ for mode in catalog[GEMINI_CODE_ASSIST_PROVIDER_ID]["provider_modes"]
749
+ }
750
+ assert google_modes["gemini_api_oauth"]["allowed"] is True
751
+ assert google_modes["antigravity_subscription_oauth"]["allowed"] is False
752
+ assert {plan["id"] for plan in catalog[XAI_GROK_PROVIDER_ID]["plans"]} >= {
753
+ "free",
754
+ "supergrok_lite",
755
+ "supergrok",
756
+ "supergrok_heavy",
757
+ "business",
758
+ "enterprise",
759
+ "api_credits",
760
+ }
761
+ assert catalog[CLAUDE_CODE_PROVIDER_ID]["implemented"] is False
762
+ assert catalog[GEMINI_CODE_ASSIST_PROVIDER_ID]["implemented"] is False
763
+
764
+
765
+def test_disconnect_api_returns_provider_result_contract(monkeypatch):
766
+ class FakeProvider:
767
+ def __init__(self, provider_id: str):
768
+ self.provider_id = provider_id
769
+
770
+ def disconnect(self):
771
+ return {"disconnected": True, "removed_auth_files": ["auth.json"]}
772
+
773
+ def status(self):
774
+ return {"provider_id": self.provider_id, "connected": False}
775
+
776
+ provider = FakeProvider(GITHUB_COPILOT_PROVIDER_ID)
777
+ monkeypatch.setattr(disconnect_api, "get_provider", lambda provider_id: provider)
778
+
779
+ response = asyncio.run(
780
+ disconnect_api.Disconnect(None, None).process(
781
+ {"provider_id": GITHUB_COPILOT_PROVIDER_ID},
782
+ FakeRequest(),
783
+ )
784
+ )
785
+
786
+ assert response["ok"] is True
787
+ assert response["provider_id"] == GITHUB_COPILOT_PROVIDER_ID
788
+ assert response["result"] == {"disconnected": True, "removed_auth_files": ["auth.json"]}
789
+ assert response["provider"] == {"provider_id": GITHUB_COPILOT_PROVIDER_ID, "connected": False}
790
+ assert response["disconnected"] is True
791
+ assert response["removed_auth_files"] == ["auth.json"]
792
+
793
+
794
+def test_disconnect_api_keeps_codex_legacy_field(monkeypatch):
795
+ class FakeProvider:
796
+ provider_id = CODEX_PROVIDER_ID
797
+
798
+ def disconnect(self):
799
+ return {"disconnected": True}
800
+
801
+ def status(self):
802
+ return {"provider_id": CODEX_PROVIDER_ID, "connected": False}
803
+
804
+ provider = FakeProvider()
805
+ monkeypatch.setattr(disconnect_api, "get_provider", lambda provider_id: provider)
806
+
807
+ response = asyncio.run(disconnect_api.Disconnect(None, None).process({}, FakeRequest()))
808
+
809
+ assert response["result"] == {"disconnected": True}
810
+ assert response["provider"] == {"provider_id": CODEX_PROVIDER_ID, "connected": False}
811
+ assert response["codex"] == response["provider"]
812
+
813
+
814
+def test_start_login_unknown_provider_returns_structured_error():
815
+ response = asyncio.run(
816
+ start_login_api.StartLogin(None, None).process({"provider_id": "missing"}, FakeRequest())
817
+ )
818
+
819
+ assert response["ok"] is False
820
+ assert response["provider_id"] == "missing"
821
+ assert "Unknown OAuth provider" in response["error"]
822
+
823
+
824
+@pytest.mark.parametrize("provider_id", [0, False])
825
+@pytest.mark.parametrize(
826
+ "handler",
827
+ [
828
+ start_login_api.StartLogin,
829
+ Models,
830
+ disconnect_api.Disconnect,
831
+ manual_callback_api.ManualCallback,
832
+ ],
833
+)
834
+def test_provider_aware_apis_do_not_default_falsey_non_string_provider_ids(handler, provider_id):
835
+ response = asyncio.run(handler(None, None).process({"provider_id": provider_id}, FakeRequest()))
836
+
837
+ assert response["ok"] is False
838
+ assert response["provider_id"] == str(provider_id)
839
+ assert f"Unknown OAuth provider: {provider_id}" in response["error"]
840
+
841
+
842
+def test_disconnect_unknown_provider_returns_structured_error():
843
+ response = asyncio.run(
844
+ disconnect_api.Disconnect(None, None).process({"provider_id": "missing"}, FakeRequest())
845
+ )
846
+
847
+ assert response["ok"] is False
848
+ assert response["provider_id"] == "missing"
849
+ assert "Unknown OAuth provider" in response["error"]
850
+
851
+
852
+def test_manual_callback_unknown_provider_returns_structured_error():
853
+ response = asyncio.run(
854
+ manual_callback_api.ManualCallback(None, None).process({"provider_id": "missing"}, FakeRequest())
855
+ )
856
+
857
+ assert response["ok"] is False
858
+ assert response["provider_id"] == "missing"
859
+ assert "Unknown OAuth provider" in response["error"]
860
+
861
+
862
+def test_unknown_provider_id_on_provider_aware_api_returns_structured_error():
863
+ response = asyncio.run(Models(None, None).process({"provider_id": "missing"}, FakeRequest()))
864
+
865
+ assert response["ok"] is False
866
+ assert response["provider_id"] == "missing"
867
+ assert response["models"] == []
868
+ assert "Unknown OAuth provider" in response["error"]
869
+
870
+
871
+def test_register_oauth_routes_adds_codex_routes_and_provider_routes(monkeypatch):
872
+ fake_flask = types.ModuleType("flask")
873
+
874
+ class Response:
875
+ def __init__(self, *args, **kwargs):
876
+ self.args = args
877
+ self.kwargs = kwargs
878
+
879
+ fake_flask.Response = Response
880
+ fake_flask.jsonify = lambda *args, **kwargs: {"args": args, "kwargs": kwargs}
881
+ fake_flask.request = types.SimpleNamespace()
882
+ fake_flask.stream_with_context = lambda value: value
883
+
884
+ fake_codex = types.ModuleType("plugins._oauth.helpers.codex")
885
+ fake_config = types.ModuleType("plugins._oauth.helpers.config")
886
+ fake_config.codex_config = lambda: {
887
+ "proxy_base_path": "/oauth/codex",
888
+ "callback_path": "/auth/callback",
889
+ }
890
+
891
+ monkeypatch.setitem(sys.modules, "flask", fake_flask)
892
+ monkeypatch.setitem(sys.modules, "plugins._oauth.helpers.codex", fake_codex)
893
+ monkeypatch.setitem(sys.modules, "plugins._oauth.helpers.config", fake_config)
894
+
895
+ module_name = "plugins._oauth.helpers.routes"
896
+ previous_routes_module = sys.modules.pop(module_name, None)
897
+ try:
898
+ routes_module = importlib.import_module(module_name)
899
+
900
+ class FakeApp:
901
+ def __init__(self):
902
+ self.view_functions = {}
903
+ self.rules = []
904
+
905
+ def add_url_rule(self, rule, endpoint, view_func, methods):
906
+ self.view_functions[endpoint] = view_func
907
+ self.rules.append((rule, endpoint, methods))
908
+
909
+ registered_providers = []
910
+
911
+ class FakeProvider:
912
+ provider_id = "fake_provider"
913
+
914
+ def register_routes(self, app):
915
+ registered_providers.append(app)
916
+
917
+ fake_provider = FakeProvider()
918
+ monkeypatch.setattr(routes_module, "provider_registry", lambda: {"fake_provider": fake_provider})
919
+
920
+ app = FakeApp()
921
+ routes_module.register_oauth_routes(app)
922
+ routes_module.register_oauth_routes(app)
923
+
924
+ assert "oauth_codex_health" in app.view_functions
925
+ assert app.rules.count(("/oauth/codex/health", "oauth_codex_health", ["GET"])) == 1
926
+ assert registered_providers == [app, app]
927
+ finally:
928
+ sys.modules.pop(module_name, None)
929
+ if previous_routes_module is not None:
930
+ sys.modules[module_name] = previous_routes_module
931
+
932
+
933
+def test_github_copilot_streaming_proxy_streams_successful_upstream(monkeypatch):
934
+ fake_flask = types.ModuleType("flask")
935
+
936
+ class Response:
937
+ def __init__(self, *args, **kwargs):
938
+ self.args = args
939
+ self.kwargs = kwargs
940
+
941
+ fake_request = types.SimpleNamespace(
942
+ method="POST",
943
+ host="localhost",
944
+ remote_addr="127.0.0.1",
945
+ headers={},
946
+ args={},
947
+ get_json=lambda silent=True: {"stream": True, "model": "gpt-5.2"},
948
+ )
949
+ fake_flask.Response = Response
950
+ fake_flask.jsonify = lambda *args, **kwargs: {"args": args, "kwargs": kwargs}
951
+ fake_flask.request = fake_request
952
+ fake_flask.stream_with_context = lambda value: value
953
+
954
+ fake_codex = types.ModuleType("plugins._oauth.helpers.codex")
955
+ fake_codex.response_headers = lambda upstream: dict(upstream.headers)
956
+ fake_config = types.ModuleType("plugins._oauth.helpers.config")
957
+ fake_config.codex_config = lambda: {
958
+ "proxy_base_path": "/oauth/codex",
959
+ "callback_path": "/auth/callback",
960
+ "proxy_token": "",
961
+ "require_proxy_token": False,
962
+ }
963
+
964
+ class FakeUpstream:
965
+ ok = True
966
+ status_code = 200
967
+ headers = {}
968
+ content = b"not-streamed"
969
+
970
+ def iter_content(self, chunk_size):
971
+ yield b"data: {}\n\n"
972
+
973
+ fake_requests = types.ModuleType("requests")
974
+ fake_requests.post = lambda *args, **kwargs: FakeUpstream()
975
+
976
+ monkeypatch.setitem(sys.modules, "flask", fake_flask)
977
+ monkeypatch.setitem(sys.modules, "plugins._oauth.helpers.codex", fake_codex)
978
+ monkeypatch.setitem(sys.modules, "plugins._oauth.helpers.config", fake_config)
979
+ monkeypatch.setitem(sys.modules, "requests", fake_requests)
980
+
981
+ module_name = "plugins._oauth.helpers.routes"
982
+ previous_routes_module = sys.modules.pop(module_name, None)
983
+ try:
984
+ routes_module = importlib.import_module(module_name)
985
+
986
+ class FakeProvider:
987
+ def ensure_fresh_auth(self):
988
+ return {
989
+ "access": "fresh-access-token",
990
+ "base_url": "https://api.individual.githubcopilot.com",
991
+ }
992
+
993
+ def read_auth(self):
994
+ return self.ensure_fresh_auth()
995
+
996
+ monkeypatch.setattr(routes_module, "get_provider", lambda provider_id: FakeProvider())
997
+
998
+ response = routes_module.github_copilot_responses()
999
+
1000
+ assert isinstance(response, Response)
1001
+ assert response.kwargs["headers"]["Content-Type"] == "text/event-stream"
1002
+ assert response.kwargs["status"] == 200
1003
+ assert response.args[0] != b"not-streamed"
1004
+ finally:
1005
+ sys.modules.pop(module_name, None)
1006
+ if previous_routes_module is not None:
1007
+ sys.modules[module_name] = previous_routes_module
1008
+
1009
+
1010
+def test_github_copilot_proxy_does_not_send_bearer_token_to_malicious_base_url(monkeypatch):
1011
+ fake_flask = types.ModuleType("flask")
1012
+
1013
+ class Response:
1014
+ def __init__(self, *args, **kwargs):
1015
+ self.args = args
1016
+ self.kwargs = kwargs
1017
+
1018
+ fake_flask.Response = Response
1019
+ fake_flask.jsonify = lambda *args, **kwargs: {"args": args, "kwargs": kwargs}
1020
+ fake_flask.request = types.SimpleNamespace(
1021
+ method="POST",
1022
+ host="localhost",
1023
+ remote_addr="127.0.0.1",
1024
+ headers={},
1025
+ args={},
1026
+ get_json=lambda silent=True: {"stream": False, "model": "gpt-5.2"},
1027
+ )
1028
+ fake_flask.stream_with_context = lambda value: value
1029
+
1030
+ fake_codex = types.ModuleType("plugins._oauth.helpers.codex")
1031
+ fake_codex.response_headers = lambda upstream: dict(upstream.headers)
1032
+ fake_config = types.ModuleType("plugins._oauth.helpers.config")
1033
+ fake_config.codex_config = lambda: {
1034
+ "proxy_base_path": "/oauth/codex",
1035
+ "callback_path": "/auth/callback",
1036
+ "proxy_token": "",
1037
+ "require_proxy_token": False,
1038
+ }
1039
+
1040
+ calls = []
1041
+
1042
+ class FakeUpstream:
1043
+ ok = True
1044
+ status_code = 200
1045
+ headers = {"Content-Type": "application/json"}
1046
+ content = b'{"ok":true}'
1047
+
1048
+ fake_requests = types.ModuleType("requests")
1049
+
1050
+ def fake_post(url, headers, json, stream, timeout):
1051
+ calls.append((url, headers, json, stream, timeout))
1052
+ return FakeUpstream()
1053
+
1054
+ fake_requests.post = fake_post
1055
+
1056
+ monkeypatch.setitem(sys.modules, "flask", fake_flask)
1057
+ monkeypatch.setitem(sys.modules, "plugins._oauth.helpers.codex", fake_codex)
1058
+ monkeypatch.setitem(sys.modules, "plugins._oauth.helpers.config", fake_config)
1059
+ monkeypatch.setitem(sys.modules, "requests", fake_requests)
1060
+
1061
+ module_name = "plugins._oauth.helpers.routes"
1062
+ previous_routes_module = sys.modules.pop(module_name, None)
1063
+ try:
1064
+ routes_module = importlib.import_module(module_name)
1065
+
1066
+ class FakeProvider:
1067
+ def ensure_fresh_auth(self):
1068
+ return {
1069
+ "access": "fresh-access-token",
1070
+ "base_url": "https://evil.example.com/v1",
1071
+ }
1072
+
1073
+ def read_auth(self):
1074
+ return self.ensure_fresh_auth()
1075
+
1076
+ monkeypatch.setattr(routes_module, "get_provider", lambda provider_id: FakeProvider())
1077
+
1078
+ response = routes_module.github_copilot_responses()
1079
+
1080
+ assert isinstance(response, Response)
1081
+ assert calls[0][0] == "https://api.individual.githubcopilot.com/responses"
1082
+ assert calls[0][1]["Authorization"] == "Bearer fresh-access-token"
1083
+ finally:
1084
+ sys.modules.pop(module_name, None)
1085
+ if previous_routes_module is not None:
1086
+ sys.modules[module_name] = previous_routes_module
1087
+
1088
+
1089
+def test_proxy_authorization_does_not_trust_host_header(monkeypatch):
1090
+ fake_flask = types.ModuleType("flask")
1091
+ fake_flask.Response = object
1092
+ fake_flask.jsonify = lambda *args, **kwargs: {"args": args, "kwargs": kwargs}
1093
+ fake_flask.request = types.SimpleNamespace(
1094
+ host="localhost",
1095
+ remote_addr="203.0.113.10",
1096
+ headers={},
1097
+ args={},
1098
+ )
1099
+ fake_flask.stream_with_context = lambda value: value
1100
+
1101
+ fake_codex = types.ModuleType("plugins._oauth.helpers.codex")
1102
+ fake_config = types.ModuleType("plugins._oauth.helpers.config")
1103
+ fake_config.codex_config = lambda: {
1104
+ "proxy_base_path": "/oauth/codex",
1105
+ "callback_path": "/auth/callback",
1106
+ "proxy_token": "",
1107
+ "require_proxy_token": False,
1108
+ }
1109
+
1110
+ monkeypatch.setitem(sys.modules, "flask", fake_flask)
1111
+ monkeypatch.setitem(sys.modules, "plugins._oauth.helpers.codex", fake_codex)
1112
+ monkeypatch.setitem(sys.modules, "plugins._oauth.helpers.config", fake_config)
1113
+
1114
+ module_name = "plugins._oauth.helpers.routes"
1115
+ previous_routes_module = sys.modules.pop(module_name, None)
1116
+ try:
1117
+ routes_module = importlib.import_module(module_name)
1118
+
1119
+ assert routes_module._proxy_authorized() is False
1120
+ finally:
1121
+ sys.modules.pop(module_name, None)
1122
+ if previous_routes_module is not None:
1123
+ sys.modules[module_name] = previous_routes_module
1124
+
1125
+
1126
+def test_xai_proxy_does_not_send_bearer_token_to_malicious_base_url(monkeypatch):
1127
+ fake_flask = types.ModuleType("flask")
1128
+
1129
+ class Response:
1130
+ def __init__(self, *args, **kwargs):
1131
+ self.args = args
1132
+ self.kwargs = kwargs
1133
+
1134
+ fake_flask.Response = Response
1135
+ fake_flask.jsonify = lambda *args, **kwargs: {"args": args, "kwargs": kwargs}
1136
+ fake_flask.request = types.SimpleNamespace(
1137
+ method="POST",
1138
+ host="localhost",
1139
+ remote_addr="127.0.0.1",
1140
+ headers={},
1141
+ args={},
1142
+ get_json=lambda silent=True: {"stream": False, "model": "grok-4.3"},
1143
+ )
1144
+ fake_flask.stream_with_context = lambda value: value
1145
+
1146
+ fake_codex = types.ModuleType("plugins._oauth.helpers.codex")
1147
+ fake_codex.response_headers = lambda upstream: dict(upstream.headers)
1148
+ fake_config = types.ModuleType("plugins._oauth.helpers.config")
1149
+ fake_config.codex_config = lambda: {
1150
+ "proxy_base_path": "/oauth/codex",
1151
+ "callback_path": "/auth/callback",
1152
+ "proxy_token": "",
1153
+ "require_proxy_token": False,
1154
+ }
1155
+
1156
+ calls = []
1157
+
1158
+ class FakeUpstream:
1159
+ ok = True
1160
+ status_code = 200
1161
+ headers = {"Content-Type": "application/json"}
1162
+ content = b'{"ok":true}'
1163
+
1164
+ fake_requests = types.ModuleType("requests")
1165
+
1166
+ def fake_post(url, headers, json, stream, timeout):
1167
+ calls.append((url, headers, json, stream, timeout))
1168
+ return FakeUpstream()
1169
+
1170
+ fake_requests.post = fake_post
1171
+
1172
+ monkeypatch.setitem(sys.modules, "flask", fake_flask)
1173
+ monkeypatch.setitem(sys.modules, "plugins._oauth.helpers.codex", fake_codex)
1174
+ monkeypatch.setitem(sys.modules, "plugins._oauth.helpers.config", fake_config)
1175
+ monkeypatch.setitem(sys.modules, "requests", fake_requests)
1176
+
1177
+ module_name = "plugins._oauth.helpers.routes"
1178
+ previous_routes_module = sys.modules.pop(module_name, None)
1179
+ try:
1180
+ routes_module = importlib.import_module(module_name)
1181
+
1182
+ class FakeProvider:
1183
+ def ensure_fresh_auth(self):
1184
+ return {
1185
+ "access": "access-token",
1186
+ "refresh": "refresh-token",
1187
+ "base_url": "https://evil.example/v1",
1188
+ }
1189
+
1190
+ monkeypatch.setattr(routes_module, "get_provider", lambda provider_id: FakeProvider())
1191
+
1192
+ response = routes_module.xai_grok_responses()
1193
+
1194
+ assert isinstance(response, Response)
1195
+ assert calls[0][0] == "https://api.x.ai/v1/responses"
1196
+ assert calls[0][1]["Authorization"] == "Bearer access-token"
1197
+ finally:
1198
+ sys.modules.pop(module_name, None)
1199
+ if previous_routes_module is not None:
1200
+ sys.modules[module_name] = previous_routes_module
tests/test_oauth_static.py
+60
-6
@@ -4,30 +4,61 @@ from pathlib import Path
4
PROJECT_ROOT = Path(__file__).resolve().parents[1]
5
6
7
-def test_oauth_settings_exposes_codex_model_slots():
7
+def test_oauth_settings_exposes_provider_cards_and_model_slots():
8
config_html = (PROJECT_ROOT / "plugins/_oauth/webui/config.html").read_text(encoding="utf-8")
9
store_js = (PROJECT_ROOT / "plugins/_oauth/webui/oauth-config-store.js").read_text(encoding="utf-8")
10
11
+ assert "providerCards" in config_html + store_js
12
+ assert "OAuth Connections" in config_html + store_js
13
+ assert "OAUTH_PROVIDERS" not in store_js
14
+ assert "PROVIDER_FALLBACKS" not in store_js
15
assert "Agent Zero models" in config_html
16
assert "Main model" in store_js
17
assert "Utility model" in store_js
14
- assert "Use Codex" in config_html
15
- assert "Search available Codex models" in config_html
18
+ assert "provider_map" in store_js
19
+ assert "Usage plans" in config_html
20
+ assert "usagePlanEntries" in store_js
21
+ assert "usage_plan_catalog" in (PROJECT_ROOT / "plugins/_oauth/api/status.py").read_text(encoding="utf-8")
22
assert "copyMainToUtility" in config_html + store_js
23
24
19
-def test_oauth_settings_remove_redundant_model_action_and_account_label():
25
+def test_oauth_settings_exposes_provider_specific_controls_and_generic_copy():
26
config_html = (PROJECT_ROOT / "plugins/_oauth/webui/config.html").read_text(encoding="utf-8")
27
store_js = (PROJECT_ROOT / "plugins/_oauth/webui/oauth-config-store.js").read_text(encoding="utf-8")
28
29
assert "Check Models" not in config_html
30
+ assert "Check models" in config_html
31
+ assert "enterprise_domain" in config_html + store_js
32
+ assert "manualCallback" in config_html + store_js
33
+ assert "Paste callback URL, query string, or code" in config_html + store_js
34
+ assert "supports_enterprise_domain" in config_html + store_js
35
+ assert "supports_manual_callback" in config_html + store_js
36
+ assert "supports_oauth_client_config" in config_html + store_js
37
+ assert "supports_quota_project" in config_html + store_js
38
+ assert "OAuth client ID" in config_html
39
+ assert "quota_project_id" in config_html + store_js
40
+ assert "submitManualCallback(card.provider_id)" in config_html
41
+ assert "cancelConnect(card.provider_id)" in config_html
42
+ assert "oauth-auth-attempt" in config_html
43
assert "Codex/ChatGPT Account" not in config_html + store_js
44
+ assert "Available models from selected provider" in config_html
45
+ assert "Available models from Codex account" not in config_html
46
+
47
+
48
+def test_oauth_connect_buttons_disable_during_any_provider_connection():
49
+ config_html = (PROJECT_ROOT / "plugins/_oauth/webui/config.html").read_text(encoding="utf-8")
50
+ store_js = (PROJECT_ROOT / "plugins/_oauth/webui/oauth-config-store.js").read_text(encoding="utf-8")
51
+
52
+ assert ':disabled="Boolean($store.oauthConfig.connectingProvider) || Boolean($store.oauthConfig.disconnectingProvider)"' in config_html
53
+ assert ':disabled="Boolean($store.oauthConfig.connectingProvider) || $store.oauthConfig.disconnectingProvider"' not in config_html
54
+ assert "cancelConnect(providerId = \"\")" in store_js
55
+ assert "if (this.connectingProvider === providerId) this.connectingProvider = \"\";" in store_js
56
57
58
def test_oauth_available_models_list_sits_above_advanced_without_borders():
59
config_html = (PROJECT_ROOT / "plugins/_oauth/webui/config.html").read_text(encoding="utf-8")
60
30
- assert "Available models from Codex account" in config_html
61
+ assert "Available models from selected provider" in config_html
62
assert config_html.index("Available models") < config_html.index("<summary>Advanced</summary>")
63
assert ".oauth-models-panel {\n display: grid;\n gap: 10px;\n padding: 0;\n border: 0;\n }" in config_html
64
model_chip_rule = config_html.split(".oauth-models span {", 1)[1].split("}", 1)[0]
@@ -41,10 +72,33 @@ def test_oauth_model_slots_reuse_model_config_api():
72
assert 'const MODEL_CONFIG_API = "/plugins/_model_config";' in store_js
73
assert "model_config_get" in store_js
74
assert "model_config_set" in store_js
44
- assert 'const CODEX_PROVIDER = "codex_oauth";' in store_js
75
+ assert "isOauthProvider" in store_js
76
+ assert "providerCards()" in store_js
77
assert "saveModelConfigIfDirty" in store_js
78
79
80
+def test_browser_callback_completion_is_observed_from_modal():
81
+ store_js = (PROJECT_ROOT / "plugins/_oauth/webui/oauth-config-store.js").read_text(encoding="utf-8")
82
+
83
+ assert "startCallbackPolling(providerId)" in store_js
84
+ assert "stopCallbackPolling(providerId)" in store_js
85
+ assert "this.providerConnected(providerId)" in store_js
86
+
87
+
88
+def test_usage_plan_catalog_is_visible_without_provider_cards():
89
+ config_html = (PROJECT_ROOT / "plugins/_oauth/webui/config.html").read_text(encoding="utf-8")
90
+ store_js = (PROJECT_ROOT / "plugins/_oauth/webui/oauth-config-store.js").read_text(encoding="utf-8")
91
+ plans_py = (PROJECT_ROOT / "plugins/_oauth/helpers/usage_plans.py").read_text(encoding="utf-8")
92
+
93
+ assert "oauth-plan-catalog" in config_html
94
+ assert "Metadata only" in store_js
95
+ assert "Google Gemini / Antigravity" in plans_py
96
+ assert "Google Gemini API" in plans_py
97
+ assert "GEMINI_API_PROVIDER_ID" in plans_py
98
+ assert "antigravity_subscription_oauth" in plans_py
99
+ assert '"allowed": False' in plans_py
100
+
101
+
102
def test_oauth_model_wrappers_do_not_add_box_borders_or_lateral_padding():
103
config_html = (PROJECT_ROOT / "plugins/_oauth/webui/config.html").read_text(encoding="utf-8")
104
tests/test_oauth_xai_grok.py
new
+300
@@ -0,0 +1,300 @@
1
+from __future__ import annotations
2
+
3
+import json
4
+import sys
5
+import types
6
+import time
7
+from dataclasses import dataclass
8
+from pathlib import Path
9
+from urllib.parse import parse_qs, urlparse
10
+
11
+import pytest
12
+
13
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
14
+
15
+@dataclass(frozen=True)
16
+class PkcePair:
17
+ verifier: str
18
+ challenge: str
19
+
20
+
21
+from plugins._oauth.helpers.providers.base import ProviderError, XAI_GROK_PROVIDER_ID
22
+from plugins._oauth.helpers.providers import xai_grok as xai
23
+from plugins._oauth.helpers.state import pop_attempt, put_attempt
24
+
25
+
26
+def _discovery() -> dict[str, str]:
27
+ return {
28
+ "authorization_endpoint": "https://auth.x.ai/oauth/authorize",
29
+ "token_endpoint": "https://auth.x.ai/oauth/token",
30
+ }
31
+
32
+
33
+def test_start_login_builds_xai_pkce_authorize_url_without_network(monkeypatch):
34
+ provider = xai.XaiGrokOAuthProvider()
35
+ fake_codex = types.ModuleType("plugins._oauth.helpers.codex")
36
+ fake_codex.PkcePair = PkcePair
37
+ fake_codex.generate_pkce = lambda: PkcePair("verifier", "challenge")
38
+ fake_codex.generate_state = lambda: "state-1"
39
+ monkeypatch.setitem(sys.modules, "plugins._oauth.helpers.codex", fake_codex)
40
+ monkeypatch.setattr(provider, "discovery", _discovery)
41
+
42
+ result = provider.start_login({})
43
+
44
+ try:
45
+ assert result.ok is True
46
+ assert result.provider_id == XAI_GROK_PROVIDER_ID
47
+ assert result.flow == "browser_pkce"
48
+ assert result.redirect_uri == "http://127.0.0.1:56121/callback"
49
+
50
+ parsed = urlparse(result.auth_url)
51
+ query = parse_qs(parsed.query)
52
+ assert result.auth_url.startswith("https://auth.x.ai/oauth/authorize?")
53
+ assert query["response_type"] == ["code"]
54
+ assert query["client_id"] == [xai.XAI_CLIENT_ID]
55
+ assert query["scope"] == [xai.XAI_SCOPE]
56
+ assert query["code_challenge"] == ["challenge"]
57
+ assert query["code_challenge_method"] == ["S256"]
58
+ assert query["state"] == ["state-1"]
59
+ assert query["plan"] == ["generic"]
60
+ assert query["referrer"] == ["agent-zero"]
61
+ assert query["redirect_uri"] == ["http://127.0.0.1:56121/callback"]
62
+ assert query["nonce"]
63
+ finally:
64
+ pop_attempt("state-1")
65
+
66
+
67
+@pytest.mark.parametrize(
68
+ ("raw", "expected"),
69
+ [
70
+ (
71
+ "http://127.0.0.1:56121/callback?code=abc&state=state-1",
72
+ {"code": "abc", "state": "state-1", "error": None, "error_description": None},
73
+ ),
74
+ ("?code=abc&state=state-1", {"code": "abc", "state": "state-1", "error": None, "error_description": None}),
75
+ ("code=abc&state=state-1", {"code": "abc", "state": "state-1", "error": None, "error_description": None}),
76
+ ("abc", {"code": "abc", "state": None, "error": None, "error_description": None}),
77
+ ],
78
+)
79
+def test_parse_manual_callback_accepts_url_query_and_bare_code(raw, expected):
80
+ assert xai.parse_manual_callback(raw) == expected
81
+
82
+
83
+def test_manual_callback_rejects_state_mismatch(monkeypatch):
84
+ provider = xai.XaiGrokOAuthProvider()
85
+ put_attempt(
86
+ "state-good",
87
+ "verifier",
88
+ xai.XAI_REDIRECT_URI,
89
+ provider_id=XAI_GROK_PROVIDER_ID,
90
+ extra={"token_endpoint": "https://auth.x.ai/oauth/token"},
91
+ )
92
+ monkeypatch.setattr(provider, "discovery", _discovery)
93
+
94
+ try:
95
+ result = provider.manual_callback({"callback": "code=abc&state=state-bad"})
96
+
97
+ assert result.ok is False
98
+ assert "state mismatch" in result.error.lower()
99
+ finally:
100
+ pop_attempt("state-good")
101
+ pop_attempt("state-bad")
102
+
103
+
104
+def test_manual_callback_exchanges_code_and_stores_tokens(tmp_path, monkeypatch):
105
+ provider = xai.XaiGrokOAuthProvider()
106
+ auth_path = tmp_path / "auth.json"
107
+ put_attempt(
108
+ "state-1",
109
+ "verifier-1",
110
+ xai.XAI_REDIRECT_URI,
111
+ provider_id=XAI_GROK_PROVIDER_ID,
112
+ extra={"token_endpoint": "https://auth.x.ai/oauth/token", "code_challenge": "challenge-1"},
113
+ )
114
+ calls = []
115
+
116
+ monkeypatch.setattr(provider, "auth_path", lambda: auth_path)
117
+ monkeypatch.setattr(provider, "discovery", _discovery)
118
+
119
+ def fake_exchange(token_endpoint, code, redirect_uri, code_verifier, code_challenge):
120
+ calls.append((token_endpoint, code, redirect_uri, code_verifier, code_challenge))
121
+ return {
122
+ "access_token": "access-token",
123
+ "refresh_token": "refresh-token",
124
+ "expires_in": 3600,
125
+ "id_token": "id-token",
126
+ "token_type": "Bearer",
127
+ }
128
+
129
+ monkeypatch.setattr(provider, "exchange_code", fake_exchange)
130
+
131
+ result = provider.manual_callback(
132
+ {"callback": "http://127.0.0.1:56121/callback?code=code-1&state=state-1"}
133
+ )
134
+
135
+ assert result.ok is True
136
+ assert result.completed is True
137
+ assert result.account_label == "xAI Grok"
138
+ assert calls == [
139
+ (
140
+ "https://auth.x.ai/oauth/token",
141
+ "code-1",
142
+ "http://127.0.0.1:56121/callback",
143
+ "verifier-1",
144
+ "challenge-1",
145
+ )
146
+ ]
147
+ saved = json.loads(auth_path.read_text(encoding="utf-8"))
148
+ assert saved["provider"] == XAI_GROK_PROVIDER_ID
149
+ assert saved["access"] == "access-token"
150
+ assert saved["refresh"] == "refresh-token"
151
+
152
+
153
+def test_models_returns_curated_list_without_network(monkeypatch):
154
+ provider = xai.XaiGrokOAuthProvider()
155
+ monkeypatch.setattr(provider, "read_auth", lambda: {})
156
+
157
+ models = provider.models()
158
+
159
+ assert models[:4] == [
160
+ "grok-4.3",
161
+ "grok-4.20-0309-reasoning",
162
+ "grok-4.20-0309-non-reasoning",
163
+ "grok-4.20-multi-agent-0309",
164
+ ]
165
+
166
+
167
+def test_exchange_code_http_403_explains_oauth_tier_restriction(monkeypatch):
168
+ class FakeResponse:
169
+ ok = False
170
+ status_code = 403
171
+ text = "forbidden"
172
+
173
+ def json(self):
174
+ return {"error": "forbidden"}
175
+
176
+ class FakeRequests:
177
+ @staticmethod
178
+ def post(*args, **kwargs):
179
+ return FakeResponse()
180
+
181
+ monkeypatch.setitem(sys.modules, "requests", FakeRequests)
182
+
183
+ provider = xai.XaiGrokOAuthProvider()
184
+ with pytest.raises(ProviderError) as exc_info:
185
+ provider.exchange_code(
186
+ "https://auth.x.ai/oauth/token",
187
+ "code",
188
+ xai.XAI_REDIRECT_URI,
189
+ "verifier",
190
+ "challenge",
191
+ )
192
+
193
+ assert exc_info.value.status == 403
194
+ assert "restricted by tier" in str(exc_info.value)
195
+ assert "API-key `xai` provider" in str(exc_info.value)
196
+
197
+
198
+def test_refresh_403_preserves_oauth_tier_guidance(monkeypatch):
199
+ class FakeResponse:
200
+ ok = False
201
+ status_code = 403
202
+
203
+ def json(self):
204
+ return {"error": "forbidden"}
205
+
206
+ class FakeRequests:
207
+ @staticmethod
208
+ def post(*args, **kwargs):
209
+ return FakeResponse()
210
+
211
+ monkeypatch.setitem(sys.modules, "requests", FakeRequests)
212
+
213
+ provider = xai.XaiGrokOAuthProvider()
214
+ monkeypatch.setattr(
215
+ provider,
216
+ "read_auth",
217
+ lambda: {
218
+ "access": "expired-access-token",
219
+ "refresh": "refresh-token",
220
+ "expires": 1,
221
+ "token_endpoint": "https://auth.x.ai/oauth/token",
222
+ },
223
+ )
224
+
225
+ with pytest.raises(ProviderError) as exc_info:
226
+ provider.ensure_fresh_auth()
227
+
228
+ assert exc_info.value.status == 403
229
+ assert exc_info.value.code == "oauth_tier_restricted"
230
+ assert "restricted by tier" in str(exc_info.value)
231
+ assert "API-key `xai` provider" in str(exc_info.value)
232
+
233
+
234
+def test_ensure_fresh_auth_rejects_malicious_stored_token_endpoint(monkeypatch):
235
+ calls = []
236
+
237
+ class FakeRequests:
238
+ @staticmethod
239
+ def post(*args, **kwargs):
240
+ calls.append((args, kwargs))
241
+ raise AssertionError("malicious token endpoint must not be called")
242
+
243
+ monkeypatch.setitem(sys.modules, "requests", FakeRequests)
244
+
245
+ provider = xai.XaiGrokOAuthProvider()
246
+ monkeypatch.setattr(
247
+ provider,
248
+ "read_auth",
249
+ lambda: {
250
+ "access": "expired-access-token",
251
+ "refresh": "refresh-token",
252
+ "expires": 1,
253
+ "token_endpoint": "https://evil.example/oauth/token",
254
+ },
255
+ )
256
+
257
+ with pytest.raises(ProviderError) as exc_info:
258
+ provider.ensure_fresh_auth()
259
+
260
+ assert exc_info.value.code == "invalid_token_endpoint"
261
+ assert calls == []
262
+
263
+
264
+def test_models_does_not_send_bearer_token_to_malicious_base_url(monkeypatch):
265
+ calls = []
266
+
267
+ class FakeResponse:
268
+ ok = True
269
+
270
+ def json(self):
271
+ return {"data": [{"id": "safe-model"}]}
272
+
273
+ class FakeRequests:
274
+ @staticmethod
275
+ def get(url, headers, timeout):
276
+ calls.append((url, headers, timeout))
277
+ return FakeResponse()
278
+
279
+ monkeypatch.setitem(sys.modules, "requests", FakeRequests)
280
+
281
+ provider = xai.XaiGrokOAuthProvider()
282
+ monkeypatch.setattr(
283
+ provider,
284
+ "read_auth",
285
+ lambda: {
286
+ "access": "access-token",
287
+ "refresh": "refresh-token",
288
+ "expires": int(time.time() * 1000) + 3_600_000,
289
+ "base_url": "https://evil.example/v1",
290
+ },
291
+ )
292
+
293
+ assert provider.models() == ["safe-model"]
294
+ assert calls == [
295
+ (
296
+ "https://api.x.ai/v1/models",
297
+ {"Accept": "application/json", "Authorization": "Bearer access-token"},
298
+ 30,
299
+ )
300
+ ]