Simplify OAuth provider plumbing
Alessandro committed
Jun 1, 2026 at 03:12 UTC
0ef60326e74377237b09aace472ecfe4d85974de
10 files changed
+222
-427
plugins/_oauth/helpers/providers/base.py
-3
@@ -61,9 +61,6 @@ class OAuthProviderMetadata:
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)
64
65
def to_dict(self) -> dict[str, Any]:
66
return asdict(self)
plugins/_oauth/helpers/providers/codex.py
-8
@@ -12,11 +12,6 @@ from plugins._oauth.helpers.providers.base import (
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
-)
15
from plugins._oauth.helpers.state import (
16
get_device_attempt,
17
pop_device_attempt,
@@ -52,9 +47,6 @@ class CodexOAuthProvider:
47
default_models=models,
48
proxy_base_path=cfg["proxy_base_path"],
49
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),
50
)
51
52
def status(self) -> dict[str, Any]:
plugins/_oauth/helpers/providers/common.py
new
+129
@@ -0,0 +1,129 @@
1
+from __future__ import annotations
2
+
3
+import time
4
+from typing import Any
5
+from urllib.parse import parse_qs, urlparse
6
+
7
+from plugins._oauth.helpers import state as state_store
8
+
9
+
10
+def parse_manual_callback(raw: Any) -> dict[str, str | None] | None:
11
+ text = "" if raw is None else str(raw).strip()
12
+ if not text:
13
+ return None
14
+
15
+ if text.startswith("http://") or text.startswith("https://"):
16
+ query = urlparse(text).query
17
+ elif text.startswith("?"):
18
+ query = text[1:]
19
+ elif "=" in text or "&" in text:
20
+ query = text
21
+ else:
22
+ return {
23
+ "code": text,
24
+ "state": None,
25
+ "error": None,
26
+ "error_description": None,
27
+ }
28
+
29
+ parsed = parse_qs(query, keep_blank_values=True)
30
+ return {
31
+ "code": first_query_value(parsed, "code"),
32
+ "state": first_query_value(parsed, "state"),
33
+ "error": first_query_value(parsed, "error"),
34
+ "error_description": first_query_value(parsed, "error_description"),
35
+ }
36
+
37
+
38
+def latest_attempt(provider_id: str):
39
+ state_store.cleanup_expired()
40
+ with state_store._lock:
41
+ attempts = [
42
+ attempt
43
+ for attempt in state_store._attempts.values()
44
+ if attempt.provider_id == provider_id and not attempt.expired()
45
+ ]
46
+ if not attempts:
47
+ return None
48
+ return max(attempts, key=lambda attempt: attempt.created_at)
49
+
50
+
51
+def models_from_payload(payload: Any) -> list[str]:
52
+ values: list[Any]
53
+ if isinstance(payload, dict) and isinstance(payload.get("data"), list):
54
+ values = payload["data"]
55
+ elif isinstance(payload, dict) and isinstance(payload.get("models"), list):
56
+ values = payload["models"]
57
+ elif isinstance(payload, list):
58
+ values = payload
59
+ else:
60
+ return []
61
+
62
+ models: list[str] = []
63
+ seen: set[str] = set()
64
+ for value in values:
65
+ model_id = ""
66
+ if isinstance(value, str):
67
+ model_id = value
68
+ elif isinstance(value, dict):
69
+ model_id = str(value.get("id") or value.get("name") or "")
70
+ model_id = model_id.strip()
71
+ if model_id.startswith("models/"):
72
+ model_id = model_id.split("/", 1)[1]
73
+ if model_id and model_id not in seen:
74
+ seen.add(model_id)
75
+ models.append(model_id)
76
+ return models
77
+
78
+
79
+def json_payload(response: Any) -> dict[str, Any]:
80
+ try:
81
+ payload = response.json()
82
+ except Exception:
83
+ payload = {}
84
+ if not isinstance(payload, dict):
85
+ return {}
86
+ return payload
87
+
88
+
89
+def error_message(payload: dict[str, Any], fallback: str) -> str:
90
+ error = payload.get("error")
91
+ if isinstance(error, dict):
92
+ return str(error.get("message") or error.get("status") or fallback)
93
+ return str(payload.get("error_description") or payload.get("error") or fallback)
94
+
95
+
96
+def first_query_value(parsed: dict[str, list[str]], key: str) -> str | None:
97
+ values = parsed.get(key) or []
98
+ if not values:
99
+ return None
100
+ return values[0]
101
+
102
+
103
+def as_optional_string(value: Any) -> str | None:
104
+ if isinstance(value, list):
105
+ value = value[0] if value else None
106
+ text = "" if value is None else str(value).strip()
107
+ return text or None
108
+
109
+
110
+def expires_ms(payload: dict[str, Any]) -> int:
111
+ if payload.get("expires_at") is not None:
112
+ try:
113
+ value = float(payload["expires_at"])
114
+ if value < 1_000_000_000_000:
115
+ value *= 1000
116
+ return int(value)
117
+ except (TypeError, ValueError):
118
+ pass
119
+ try:
120
+ return int((time.time() + float(payload.get("expires_in") or 0)) * 1000)
121
+ except (TypeError, ValueError):
122
+ return 0
123
+
124
+
125
+def as_int(value: Any, default: int) -> int:
126
+ try:
127
+ return int(value)
128
+ except (TypeError, ValueError):
129
+ return default
plugins/_oauth/helpers/providers/gemini_api.py
+12
-133
@@ -5,7 +5,7 @@ import json
5
import time
6
from pathlib import Path
7
from typing import Any
8
-from urllib.parse import parse_qs, urlencode, urlparse
8
+from urllib.parse import urlencode, urlparse
9
10
from plugins._oauth.helpers.providers.base import (
11
DUMMY_API_KEY,
@@ -19,12 +19,16 @@ from plugins._oauth.helpers.providers.base import (
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,
22
+from plugins._oauth.helpers.providers.common import (
23
+ as_int as _as_int,
24
+ as_optional_string as _as_optional_string,
25
+ error_message as _error_message,
26
+ expires_ms as _expires_ms,
27
+ json_payload as _json_payload,
28
+ latest_attempt,
29
+ models_from_payload as _models_from_payload,
30
+ parse_manual_callback,
31
)
27
-from plugins._oauth.helpers import state as state_store
32
from plugins._oauth.helpers.state import get_attempt, pop_attempt, put_attempt
33
34
@@ -48,34 +52,6 @@ CLIENT_CONFIG_NOTE = (
52
REFRESH_MARGIN_MS = 60_000
53
54
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
-
55
class GeminiApiOAuthProvider:
56
provider_id = GEMINI_API_PROVIDER_ID
57
@@ -106,9 +82,6 @@ class GeminiApiOAuthProvider:
82
supports_oauth_client_config=True,
83
supports_quota_project=True,
84
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),
85
)
86
87
def status(self) -> dict[str, Any]:
@@ -302,7 +275,7 @@ class GeminiApiOAuthProvider:
275
if state:
276
attempt = get_attempt(state)
277
if attempt is None:
305
- if _latest_gemini_attempt() is not None:
278
+ if latest_attempt(GEMINI_API_PROVIDER_ID) is not None:
279
return LoginPollResult(
280
ok=False,
281
provider_id=GEMINI_API_PROVIDER_ID,
@@ -321,7 +294,7 @@ class GeminiApiOAuthProvider:
294
error="OAuth state mismatch. Return to Agent Zero and start a new Google Gemini API connection.",
295
)
296
elif allow_missing_state:
324
- attempt = _latest_gemini_attempt()
297
+ attempt = latest_attempt(GEMINI_API_PROVIDER_ID)
298
if attempt is None:
299
return LoginPollResult(
300
ok=False,
@@ -642,78 +615,6 @@ def _validate_google_token_endpoint(value: str) -> None:
615
)
616
617
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
-
618
def _account_label(auth: dict[str, Any]) -> str:
619
claims = _jwt_claims(str(auth.get("id_token") or ""))
620
return str(claims.get("email") or auth.get("account_label") or "Google Gemini API")
@@ -732,28 +633,6 @@ def _jwt_claims(token: str) -> dict[str, Any]:
633
return parsed if isinstance(parsed, dict) else {}
634
635
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
-
636
def _codex_helper():
637
import importlib
638
plugins/_oauth/helpers/providers/github_copilot.py
-8
@@ -18,11 +18,6 @@ from plugins._oauth.helpers.providers.base import (
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
-)
21
from plugins._oauth.helpers.state import (
22
DeviceAttempt,
23
get_device_attempt,
@@ -213,9 +208,6 @@ class GitHubCopilotOAuthProvider:
208
proxy_base_path="/oauth/github-copilot",
209
supports_enterprise_domain=True,
210
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),
211
)
212
213
def status(self) -> dict[str, Any]:
plugins/_oauth/helpers/providers/xai_grok.py
+12
-128
@@ -5,7 +5,7 @@ import time
5
import importlib
6
from pathlib import Path
7
from typing import Any
8
-from urllib.parse import parse_qs, urlencode, urlparse
8
+from urllib.parse import urlencode, urlparse
9
10
from plugins._oauth.helpers.providers.base import (
11
DUMMY_API_KEY,
@@ -19,12 +19,16 @@ from plugins._oauth.helpers.providers.base import (
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,
22
+from plugins._oauth.helpers.providers.common import (
23
+ as_int as _as_int,
24
+ as_optional_string as _as_optional_string,
25
+ error_message as _error_message,
26
+ expires_ms as _expires_ms,
27
+ json_payload as _json_payload,
28
+ latest_attempt,
29
+ models_from_payload as _models_from_payload,
30
+ parse_manual_callback,
31
)
27
-from plugins._oauth.helpers import state as state_store
32
from plugins._oauth.helpers.state import get_attempt, pop_attempt, put_attempt
33
34
@@ -49,34 +53,6 @@ OAUTH_TIER_WARNING = (
53
REFRESH_MARGIN_MS = 60_000
54
55
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
-
56
class XaiGrokOAuthProvider:
57
provider_id = XAI_GROK_PROVIDER_ID
58
@@ -133,9 +109,6 @@ class XaiGrokOAuthProvider:
109
callback_path="/oauth/xai-grok/callback",
110
supports_manual_callback=True,
111
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),
112
)
113
114
def status(self) -> dict[str, Any]:
@@ -312,7 +285,7 @@ class XaiGrokOAuthProvider:
285
if state:
286
attempt = get_attempt(state)
287
if attempt is None:
315
- if _latest_xai_attempt() is not None:
288
+ if latest_attempt(XAI_GROK_PROVIDER_ID) is not None:
289
return LoginPollResult(
290
ok=False,
291
provider_id=XAI_GROK_PROVIDER_ID,
@@ -331,7 +304,7 @@ class XaiGrokOAuthProvider:
304
error="OAuth state mismatch. Return to Agent Zero and start a new xAI Grok connection.",
305
)
306
elif allow_missing_state:
334
- attempt = _latest_xai_attempt()
307
+ attempt = latest_attempt(XAI_GROK_PROVIDER_ID)
308
if attempt is None:
309
return LoginPollResult(
310
ok=False,
@@ -572,92 +545,3 @@ def _validate_xai_endpoint(value: str, *, code: str = "discovery_invalid_endpoin
545
code=code,
546
status=502,
547
)
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
+66
-118
@@ -3,7 +3,7 @@ from __future__ import annotations
3
import ipaddress
4
import json
5
import time
6
-from typing import Any
6
+from typing import Any, Callable
7
8
from flask import Response, jsonify, request, stream_with_context
9
@@ -357,62 +357,63 @@ def gemini_api_responses():
357
358
359
def _github_copilot_json_proxy(path: str):
360
- denied = _proxy_denied_response()
361
- if denied:
362
- return denied
360
+ from plugins._oauth.helpers.providers.github_copilot import COPILOT_HEADERS, safe_copilot_base_url
361
+
362
+ return _oauth_json_proxy(
363
+ GITHUB_COPILOT_PROVIDER_ID,
364
+ path,
365
+ "GitHub Copilot OAuth is not connected.",
366
+ lambda auth: safe_copilot_base_url(auth.get("base_url"), auth.get("enterprise_domain")),
367
+ lambda auth, access: {
368
+ **COPILOT_HEADERS,
369
+ "Authorization": f"Bearer {access}",
370
+ "Content-Type": "application/json",
371
+ },
372
+ )
373
364
- body = request.get_json(silent=True)
365
- if not isinstance(body, dict):
366
- return _json_error("Request body must be a JSON object.")
374
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
375
+def _xai_grok_json_proxy(path: str):
376
+ from plugins._oauth.helpers.providers.xai_grok import safe_api_base_url
377
390
- from plugins._oauth.helpers.providers.github_copilot import COPILOT_HEADERS, safe_copilot_base_url
378
+ return _oauth_json_proxy(
379
+ XAI_GROK_PROVIDER_ID,
380
+ path,
381
+ "xAI Grok OAuth is not connected.",
382
+ lambda auth: safe_api_base_url(auth.get("base_url")),
383
+ lambda auth, access: {
384
+ "Accept": "application/json",
385
+ "Authorization": f"Bearer {access}",
386
+ "Content-Type": "application/json",
387
+ },
388
+ require_refresh=True,
389
+ )
390
392
- base_url = safe_copilot_base_url(auth.get("base_url"), auth.get("enterprise_domain"))
391
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")
392
+def _gemini_api_json_proxy(path: str):
393
+ from plugins._oauth.helpers.providers.gemini_api import _gemini_headers, safe_api_base_url
394
410
- if wants_stream and upstream.ok:
411
- return _stream_upstream_sse(upstream)
412
- return _copy_upstream_response(upstream)
395
+ return _oauth_json_proxy(
396
+ GEMINI_API_PROVIDER_ID,
397
+ path,
398
+ "Google Gemini API OAuth is not connected.",
399
+ lambda auth: safe_api_base_url(auth.get("base_url")),
400
+ lambda auth, access: {
401
+ **_gemini_headers(auth),
402
+ "Content-Type": "application/json",
403
+ },
404
+ require_refresh=True,
405
+ )
406
407
415
-def _xai_grok_json_proxy(path: str):
408
+def _oauth_json_proxy(
409
+ provider_id: str,
410
+ path: str,
411
+ not_connected_message: str,
412
+ base_url_for: Callable[[dict[str, Any]], str],
413
+ headers_for: Callable[[dict[str, Any], str], dict[str, str]],
414
+ *,
415
+ require_refresh: bool = False,
416
+):
417
denied = _proxy_denied_response()
418
if denied:
419
return denied
@@ -421,16 +422,8 @@ def _xai_grok_json_proxy(path: str):
422
if not isinstance(body, dict):
423
return _json_error("Request body must be a JSON object.")
424
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)
425
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 = {}
426
+ auth = _provider_auth(provider_id)
427
except ProviderError as exc:
428
return _json_error(str(exc), status=exc.status, code=exc.code)
429
except Exception as exc:
@@ -438,27 +431,23 @@ def _xai_grok_json_proxy(path: str):
431
432
access = str(auth.get("access") or "")
433
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
434
+ if not access or (require_refresh and not refresh):
435
+ return _json_error(not_connected_message, status=401, code="not_connected")
436
446
- base_url = safe_api_base_url(auth.get("base_url"))
437
wants_stream = body.get("stream") is True
438
try:
439
import requests
440
441
+ base_url = base_url_for(auth)
442
upstream = requests.post(
443
f"{base_url}{path}",
453
- headers={
454
- "Accept": "application/json",
455
- "Authorization": f"Bearer {access}",
456
- "Content-Type": "application/json",
457
- },
444
+ headers=headers_for(auth, access),
445
json=body,
446
stream=wants_stream,
447
timeout=120,
448
)
449
+ except ProviderError as exc:
450
+ return _json_error(str(exc), status=exc.status, code=exc.code)
451
except Exception as exc:
452
return _json_error(str(exc), status=502, code="upstream_error")
453
@@ -467,58 +456,17 @@ def _xai_grok_json_proxy(path: str):
456
return _copy_upstream_response(upstream)
457
458
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)
459
+def _provider_auth(provider_id: str) -> dict[str, Any]:
460
+ provider = get_provider(provider_id)
461
ensure_fresh_auth = getattr(provider, "ensure_fresh_auth", None)
462
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)
463
+ if callable(ensure_fresh_auth):
464
+ auth = ensure_fresh_auth()
465
+ elif callable(read_auth):
466
+ auth = read_auth()
467
+ else:
468
+ auth = {}
469
+ return auth if isinstance(auth, dict) else {}
470
471
472
def _stream_upstream_sse(upstream):
plugins/_oauth/helpers/usage_plans.py
-22
@@ -102,25 +102,3 @@ USAGE_PLAN_CATALOG: dict[str, dict[str, Any]] = {
102
103
def usage_plan_catalog() -> dict[str, dict[str, Any]]:
104
return deepcopy(USAGE_PLAN_CATALOG)
105
-
106
-
107
-def usage_plan_entry(provider_id: str) -> dict[str, Any]:
108
- return deepcopy(USAGE_PLAN_CATALOG.get(provider_id, {}))
109
-
110
-
111
-def usage_plans_for(provider_id: str) -> list[dict[str, Any]]:
112
- entry = usage_plan_entry(provider_id)
113
- plans = entry.get("plans", [])
114
- return plans if isinstance(plans, list) else []
115
-
116
-
117
-def usage_plan_notes_for(provider_id: str) -> list[str]:
118
- entry = usage_plan_entry(provider_id)
119
- notes = entry.get("notes", [])
120
- return notes if isinstance(notes, list) else []
121
-
122
-
123
-def usage_plan_sources_for(provider_id: str) -> list[dict[str, str]]:
124
- entry = usage_plan_entry(provider_id)
125
- sources = entry.get("sources", [])
126
- return sources if isinstance(sources, list) else []
plugins/_oauth/webui/config.html
+1
-5
@@ -181,7 +181,7 @@
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'">
184
+ <article class="oauth-plan-card">
185
<div class="oauth-plan-head">
186
<strong x-text="entry.display_name"></strong>
187
<span x-text="$store.oauthConfig.usagePlanStatus(entry)"></span>
@@ -695,10 +695,6 @@
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
-
698
.oauth-plan-head {
699
display: flex;
700
min-width: 0;
plugins/_oauth/webui/oauth-config-store.js
+2
-2
@@ -322,8 +322,8 @@ export const store = createStore("oauthConfig", {
322
.filter((entry) => entry && Array.isArray(entry.plans) && entry.plans.length);
323
},
324
325
- usagePlanStatus(entry) {
326
- return entry?.implemented ? "Provider available" : "Metadata only";
325
+ usagePlanStatus() {
326
+ return "Provider available";
327
},
328
329
usagePlanNotes(entry) {