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) {