Files
odysseus/tests/test_endpoint_probing.py
stocky789 1e0d9b92af feat: add ChatGPT Subscription provider (#2876)
* feat: Add ChatGPT Subscription support and related features

- Introduced a new provider option for ChatGPT Subscription in the endpoint selection UI.
- Implemented OAuth flow for ChatGPT Subscription sign-in, including polling for authorization status.
- Updated admin interface to handle ChatGPT Subscription, including disabling API key input and providing user guidance.
- Enhanced cost tracking logic to differentiate between subscription and non-subscription endpoints.
- Added new slash commands for managing skills, including listing, searching, and invoking skills.
- Implemented caching for skill catalog to optimize performance.
- Updated tests to cover new ChatGPT Subscription functionality and ensure proper endpoint probing.
- Refactored existing code to accommodate new features and improve maintainability.

* refactor: share provider device-flow setup

- reuse one device-flow backend for Copilot and ChatGPT Subscription
- add one frontend device-flow helper for Settings and /setup
- put GitHub Copilot back into Add Models, now as a dropdown option
- make provider selection just select; clicking Add starts sign-in
- stop ChatGPT Subscription setup from opening auth tabs automatically
- make /setup copilot and /setup chatgpt-subscription work from chat
- show ChatGPT Subscription in the /setup suggestions
- show the real error message when setup fails
- add focused tests for the shared flow and setup UI

* feat(chatgpt-subscription): harden credential lifecycle and streamline auth UX

Backend:
- Resolve runtime bearer for provider-auth endpoints at probe time via a
  shared _resolve_probe_key() that delegates to resolve_endpoint_runtime,
  applied across all probe/refresh call sites.
- Skip live completion probes and health pings for discovery-only providers
  (centralized behind _is_discovery_only_provider) — the Codex/Responses API
  has no such endpoints, so status is derived from cached models.
- Never persist the short lived ChatGPT bearer to the plaintext sessions
  table; proactively clear any stale bearer left by an earlier code path.
- Revoke orphaned ProviderAuthSession credentials when the last endpoint
  backing them is deleted (_delete_orphaned_provider_auth), surfaced via
  cleared_provider_auth in the delete response.

Frontend (admin.js):
- Auto-start the device-auth flow on provider selection so the authorization
  panel (code + Authorize) shows immediately instead of behind a "Sign in" click.
- Remove the redundant top button for device auth providers, move retry
  into the panel via an inline "Try again".
- Drop the self-evident hint text and add an execCommand clipboard fallback so
  Copy works in non-secure (HTTP/LAN) contexts.

* fix: harden chatgpt subscription provider

* chore: remove PR media from branch

* Fix chatgpt subscription recovery and token handling

---------

Co-authored-by: 5p00kyy <admin@5p00ky.dev>
2026-06-08 10:19:18 +02:00

412 lines
18 KiB
Python

"""Endpoint probing behaviour (REAL routes.model_routes helpers).
ROADMAP "Backend → more tests around endpoint probing and provider setup".
TestSetupProbeSafety in test_model_routes.py already covers the keyed-vs-unkeyed
curated-fallback safety of `_probe_endpoint`. This module pins the rest of the
probe surface that drives endpoint setup and degraded-state reporting:
* `_probe_endpoint` — OpenAI vs native-Ollama model-list parsing, the
/api/tags fallback for Ollama builds without /v1/models, and the
no-models-found result.
* `_ping_endpoint` — reachability classification: 2xx, auth failures,
the "this is Odysseus, not a model server" /login-redirect trap, generic
redirects, transport errors, and the native-Ollama /api/version fallback.
* `_probe_single_model` — ok/fail/timeout status mapping, upstream error-body
extraction, and per-provider (OpenAI / Anthropic) request routing.
* `_classify_endpoint` — the Tailscale CGNAT (100.64.0.0/10) "local" range.
HTTP is faked by monkeypatching `model_routes.httpx.{get,post}`, mirroring the
established pattern in test_model_routes.py — no network, no server.
"""
import sys
import types
from unittest.mock import MagicMock
import httpx
import pytest
from tests.helpers.import_state import clear_fake_endpoint_resolver_modules, preserve_import_state
with preserve_import_state("core.database", "src.database", "core.session_manager", "routes.model_routes"):
# Match test_model_routes.py: if another test stubbed src.endpoint_resolver
# during collection, drop the stub so the real URL helpers load here.
clear_fake_endpoint_resolver_modules()
if "core.database" not in sys.modules:
_core_db = types.ModuleType("core.database")
for _name in [
"SessionLocal", "ModelEndpoint", "Session", "ChatMessage", "Document",
"DocumentVersion", "GalleryImage", "GalleryAlbum", "Note",
"CalendarCal", "CalendarEvent", "ScheduledTask", "TaskRun", "McpServer",
"ProviderAuthSession", "Base",
]:
setattr(_core_db, _name, MagicMock())
_core_db.utcnow_naive = MagicMock()
sys.modules["core.database"] = _core_db
import routes.model_routes as model_routes
import src.endpoint_resolver as endpoint_resolver
from routes.model_routes import (
_probe_endpoint,
_ping_endpoint,
_probe_single_model,
_resolve_probe_key,
_classify_endpoint,
_rewrite_loopback_for_docker,
_PROVIDER_CURATED,
)
def _patch_resolve(monkeypatch):
"""Neutralize DNS/Tailscale resolution and base normalization."""
monkeypatch.setattr(endpoint_resolver, "resolve_url", lambda url: url, raising=False)
monkeypatch.setattr(model_routes, "_normalize_base", lambda url: url.rstrip("/"))
def _resp(status, *, json=None, headers=None, url="https://api.example.com/v1/models"):
"""Build an httpx.Response with a request attached (so raise_for_status works)."""
req = httpx.Request("GET", url)
kwargs = {"request": req}
if json is not None:
kwargs["json"] = json
if headers is not None:
kwargs["headers"] = headers
return httpx.Response(status, **kwargs)
# ── _probe_endpoint: model-list parsing ──
class TestProbeEndpointParsing:
def test_parses_openai_data_format(self, monkeypatch):
_patch_resolve(monkeypatch)
monkeypatch.setattr(
model_routes.httpx, "get",
lambda url, headers=None, timeout=None, verify=None, **kwargs: _resp(
200, json={"data": [{"id": "gpt-4o"}, {"id": "gpt-4o-mini"}]}),
)
assert _probe_endpoint("https://api.example.com/v1", "key") == ["gpt-4o", "gpt-4o-mini"]
def test_parses_ollama_models_format(self, monkeypatch):
_patch_resolve(monkeypatch)
# No OpenAI-style "data"; fall back to the native {"models": [...]} shape,
# honoring both the "name" and "model" keys.
monkeypatch.setattr(
model_routes.httpx, "get",
lambda url, headers=None, timeout=None, verify=None, **kwargs: _resp(
200, json={"models": [{"name": "llama3:8b"}, {"model": "qwen3:4b"}]}),
)
assert _probe_endpoint("https://api.example.com/v1") == ["llama3:8b", "qwen3:4b"]
def test_falls_back_to_native_ollama_tags(self, monkeypatch):
_patch_resolve(monkeypatch)
seen = []
def fake_get(url, headers=None, timeout=None, verify=None, **kwargs):
seen.append(url)
if url.endswith("/api/tags"):
return _resp(200, json={"models": [{"name": "llama3:8b"}]})
# This Ollama build has no OpenAI-compatible /v1/models surface.
return _resp(404)
monkeypatch.setattr(model_routes.httpx, "get", fake_get)
assert _probe_endpoint("http://localhost:11434/v1") == ["llama3:8b"]
assert "http://localhost:11434/v1/models" in seen
assert "http://localhost:11434/api/tags" in seen
def test_empty_list_with_no_curation_returns_empty(self, monkeypatch):
_patch_resolve(monkeypatch)
monkeypatch.setattr(
model_routes.httpx, "get",
lambda url, headers=None, timeout=None, verify=None, **kwargs: _resp(200, json={"data": []}),
)
assert _probe_endpoint("https://api.example.com/v1") == []
def test_chatgpt_subscription_probe_uses_discovery_only(self, monkeypatch):
_patch_resolve(monkeypatch)
calls = []
def fake_fetch(access_token, timeout=5):
calls.append((access_token, timeout))
return ["gpt-5.5"]
monkeypatch.setattr("src.chatgpt_subscription.fetch_available_models", fake_fetch)
assert _probe_endpoint("https://chatgpt.com/backend-api/codex", "ACCESS", timeout=7) == ["gpt-5.5"]
assert calls == [("ACCESS", 7)]
def test_chatgpt_subscription_probe_without_discovery_returns_empty(self, monkeypatch):
_patch_resolve(monkeypatch)
monkeypatch.setattr("src.chatgpt_subscription.fetch_available_models", lambda access_token, timeout=5: [])
assert _probe_endpoint("https://chatgpt.com/backend-api/codex", "ACCESS") == []
assert _probe_endpoint("https://chatgpt.com/backend-api/codex") == []
# ── _ping_endpoint: reachability classification ──
class TestPingEndpoint:
def test_reachable_on_2xx(self, monkeypatch):
_patch_resolve(monkeypatch)
monkeypatch.setattr(
model_routes.httpx, "get",
lambda url, headers=None, timeout=None, verify=None, **kwargs: _resp(200),
)
assert _ping_endpoint("https://api.example.com/v1", "key") == {
"reachable": True, "status_code": 200, "error": None,
}
def test_auth_failure_is_reached_but_not_reachable(self, monkeypatch):
_patch_resolve(monkeypatch)
# A 401 means the server answered — surface the status, not "offline".
monkeypatch.setattr(
model_routes.httpx, "get",
lambda url, headers=None, timeout=None, verify=None, **kwargs: _resp(401),
)
assert _ping_endpoint("https://api.example.com/v1", "bad") == {
"reachable": False, "status_code": 401, "error": "HTTP 401",
}
def test_detects_odysseus_login_redirect(self, monkeypatch):
_patch_resolve(monkeypatch)
def fake_get(url, headers=None, timeout=None, verify=None, **kwargs):
return _resp(302, headers={"location": "/login?next=/"})
monkeypatch.setattr(model_routes.httpx, "get", fake_get)
result = _ping_endpoint("http://localhost:8080/v1")
assert result["reachable"] is False
assert result["status_code"] == 302
assert "not a model server" in result["error"]
def test_generic_redirect_reported(self, monkeypatch):
_patch_resolve(monkeypatch)
def fake_get(url, headers=None, timeout=None, verify=None, **kwargs):
return _resp(301, headers={"location": "https://elsewhere.example/"})
monkeypatch.setattr(model_routes.httpx, "get", fake_get)
assert _ping_endpoint("https://api.example.com/v1") == {
"reachable": False, "status_code": 301, "error": "HTTP 301 redirect",
}
def test_transport_error_is_unreachable(self, monkeypatch):
_patch_resolve(monkeypatch)
def fake_get(url, headers=None, timeout=None, verify=None, **kwargs):
raise httpx.ConnectError("Connection refused")
monkeypatch.setattr(model_routes.httpx, "get", fake_get)
result = _ping_endpoint("https://api.example.com/v1")
assert result["reachable"] is False
assert result["status_code"] is None
assert "Connection refused" in result["error"]
def test_ollama_native_version_fallback(self, monkeypatch):
_patch_resolve(monkeypatch)
def fake_get(url, headers=None, timeout=None, verify=None, **kwargs):
if url.endswith("/api/version"):
return _resp(200)
# The OpenAI-compatible /v1/models surface is down on this build.
return _resp(500)
monkeypatch.setattr(model_routes.httpx, "get", fake_get)
assert _ping_endpoint("http://localhost:11434/v1") == {
"reachable": True, "status_code": 200, "error": None,
}
# ── Docker loopback rewrite ──
class TestDockerLoopbackRewrite:
def test_manual_loopback_rewrites_to_docker_host_when_available(self, monkeypatch):
monkeypatch.setattr(model_routes, "_docker_host_gateway_reachable", lambda: True)
monkeypatch.setattr(model_routes, "_container_loopback_reachable", lambda base_url: False)
assert (
_rewrite_loopback_for_docker("http://localhost:8000/v1")
== "http://host.docker.internal:8000/v1"
)
def test_reachable_container_loopback_stays_local_even_without_container_flag(self, monkeypatch):
monkeypatch.setattr(model_routes, "_docker_host_gateway_reachable", lambda: True)
monkeypatch.setattr(model_routes, "_container_loopback_reachable", lambda base_url: True)
assert (
_rewrite_loopback_for_docker("http://127.0.0.1:8001/v1")
== "http://127.0.0.1:8001/v1"
)
def test_cookbook_container_local_loopback_stays_inside_container(self, monkeypatch):
monkeypatch.setattr(model_routes, "_docker_host_gateway_reachable", lambda: True)
assert (
_rewrite_loopback_for_docker("http://localhost:8000/v1", container_local=True)
== "http://localhost:8000/v1"
)
def test_bind_address_becomes_connectable_loopback_for_container_local(self, monkeypatch):
monkeypatch.setattr(model_routes, "_docker_host_gateway_reachable", lambda: True)
assert (
_rewrite_loopback_for_docker("http://0.0.0.0:8000/v1", container_local=True)
== "http://127.0.0.1:8000/v1"
)
def test_bind_address_becomes_connectable_loopback_on_native_install(self, monkeypatch):
monkeypatch.setattr(model_routes, "_docker_host_gateway_reachable", lambda: False)
assert (
_rewrite_loopback_for_docker("http://0.0.0.0:8000/v1")
== "http://127.0.0.1:8000/v1"
)
# ── _probe_single_model: completion probe ──
class TestProbeSingleModel:
def test_ok_on_success(self, monkeypatch):
_patch_resolve(monkeypatch)
captured = {}
def fake_post(url, headers=None, json=None, timeout=None):
captured["url"] = url
return _resp(200, json={"choices": [{"message": {"content": "OK"}}]})
monkeypatch.setattr(model_routes.httpx, "post", fake_post)
result = _probe_single_model("https://api.example.com/v1", "key", "gpt-4o")
assert result["status"] == "ok"
assert "latency_ms" in result
assert captured["url"] == "https://api.example.com/v1/chat/completions"
def test_extracts_dict_error_message(self, monkeypatch):
_patch_resolve(monkeypatch)
monkeypatch.setattr(
model_routes.httpx, "post",
lambda url, headers=None, json=None, timeout=None: _resp(
400, json={"error": {"message": "model not found"}}),
)
result = _probe_single_model("https://api.example.com/v1", "key", "ghost")
assert result["status"] == "fail"
assert result["error"] == "model not found"
def test_extracts_string_error(self, monkeypatch):
_patch_resolve(monkeypatch)
monkeypatch.setattr(
model_routes.httpx, "post",
lambda url, headers=None, json=None, timeout=None: _resp(
403, json={"error": "forbidden"}),
)
result = _probe_single_model("https://api.example.com/v1", "key", "m")
assert result["status"] == "fail"
assert result["error"] == "forbidden"
def test_timeout(self, monkeypatch):
_patch_resolve(monkeypatch)
def fake_post(url, headers=None, json=None, timeout=None):
raise httpx.TimeoutException("timed out")
monkeypatch.setattr(model_routes.httpx, "post", fake_post)
result = _probe_single_model("https://api.example.com/v1", "key", "m", timeout=7)
assert result["status"] == "timeout"
assert "7s" in result["error"]
def test_transport_error_is_fail(self, monkeypatch):
_patch_resolve(monkeypatch)
def fake_post(url, headers=None, json=None, timeout=None):
raise httpx.ConnectError("refused")
monkeypatch.setattr(model_routes.httpx, "post", fake_post)
result = _probe_single_model("https://api.example.com/v1", "key", "m")
assert result["status"] == "fail"
assert "refused" in result["error"]
def test_routes_anthropic_messages_with_x_api_key(self, monkeypatch):
_patch_resolve(monkeypatch)
captured = {}
def fake_post(url, headers=None, json=None, timeout=None):
captured.update(url=url, headers=headers, payload=json)
return _resp(200, json={"content": [{"type": "text", "text": "OK"}]})
monkeypatch.setattr(model_routes.httpx, "post", fake_post)
result = _probe_single_model("https://api.anthropic.com/v1", "sk-ant", "claude-sonnet-4-5")
assert result["status"] == "ok"
assert captured["url"] == "https://api.anthropic.com/v1/messages"
assert captured["headers"].get("x-api-key") == "sk-ant"
assert captured["payload"]["model"] == "claude-sonnet-4-5"
def test_with_tools_sends_anthropic_tool_schema(self, monkeypatch):
_patch_resolve(monkeypatch)
captured = {}
def fake_post(url, headers=None, json=None, timeout=None):
captured["payload"] = json
return _resp(200, json={"content": []})
monkeypatch.setattr(model_routes.httpx, "post", fake_post)
_probe_single_model("https://api.anthropic.com/v1", "sk-ant", "claude-sonnet-4-5", with_tools=True)
assert "input_schema" in captured["payload"]["tools"][0]
def test_chatgpt_subscription_skips_completion_probe(self, monkeypatch):
# This provider speaks the Responses/Codex API. A chat-completions probe
# would 400 and (via the re-probe flow) hide every model, so it must be
# short-circuited as discovery-only without any HTTP call.
_patch_resolve(monkeypatch)
def boom(*args, **kwargs):
raise AssertionError("must not send a completion probe for chatgpt-subscription")
monkeypatch.setattr(model_routes.httpx, "post", boom)
result = _probe_single_model("https://chatgpt.com/backend-api/codex", None, "gpt-5.1-codex")
assert result["status"] == "ok"
assert result.get("skipped") is True
# Pin the full documented return shape — downstream JSON/UI reads latency_ms.
assert result["latency_ms"] == 0
# ── _resolve_probe_key: static key vs provider-auth runtime token ──
class TestResolveProbeKey:
def test_static_endpoint_uses_api_key(self):
ep = types.SimpleNamespace(id="e1", api_key="sk-static", provider_auth_id=None, owner=None)
assert _resolve_probe_key(ep) == "sk-static"
def test_provider_auth_endpoint_resolves_runtime_token(self, monkeypatch):
ep = types.SimpleNamespace(id="e2", api_key=None, provider_auth_id="auth123", owner="alice")
seen = {}
def fake_runtime(endpoint, owner=None):
seen["owner"] = owner
return ("https://chatgpt.com/backend-api/codex", "live-bearer")
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint_runtime", fake_runtime)
assert _resolve_probe_key(ep) == "live-bearer"
assert seen["owner"] == "alice"
def test_provider_auth_resolution_failure_returns_none(self, monkeypatch):
ep = types.SimpleNamespace(id="e3", api_key=None, provider_auth_id="auth123", owner=None)
def boom(endpoint, owner=None):
raise RuntimeError("reauth required")
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint_runtime", boom)
assert _resolve_probe_key(ep) is None
# ── _classify_endpoint: Tailscale CGNAT range ──
class TestClassifyEndpointTailscale:
@pytest.mark.parametrize("url", [
"http://100.64.0.1:11434/v1", # bottom of 100.64.0.0/10
"http://100.100.50.20:8080/v1",
"http://100.127.255.254/v1", # top of the range
])
def test_cgnat_range_is_local(self, url):
assert _classify_endpoint(url) == "local"
@pytest.mark.parametrize("url", [
"http://100.63.255.255/v1", # just below 100.64.0.0/10
"http://100.128.0.1/v1", # just above
"https://api.openai.com/v1", # public hostname
])
def test_outside_cgnat_is_api(self, url):
assert _classify_endpoint(url) == "api"