Files
odysseus/tests/test_provider_device_flow_js.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

158 lines
5.5 KiB
Python

"""Node-driven tests for the shared provider device-flow runner."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parent.parent
_HELPER = _REPO / "static" / "js" / "providerDeviceFlow.js"
pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node not on PATH")
def _run_node(script: str):
proc = subprocess.run(
["node", "--input-type=module"],
input=script,
capture_output=True,
text=True,
cwd=str(_REPO),
timeout=30,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout.strip())
def test_copilot_success_uses_complete_verification_uri():
js = f"""
import {{ runProviderDeviceFlow }} from '{_HELPER.as_posix()}';
const calls = [];
const opened = [];
let polls = 0;
const response = (ok, status, payload) => ({{ ok, status, async json() {{ return payload; }} }});
const fetchImpl = async (url) => {{
calls.push(url);
if (url.endsWith('/device/start')) {{
return response(true, 200, {{
poll_id: 'poll-1',
user_code: 'GH-CODE',
verification_uri: 'https://github.com/login/device',
verification_uri_complete: 'https://github.com/login/device?user_code=GH-CODE',
interval: 2,
expires_in: 30,
}});
}}
polls += 1;
return response(true, 200, polls === 1
? {{ status: 'pending' }}
: {{ status: 'authorized', endpoint: {{ id: 'ep1', models: ['gpt-4o'] }} }}
);
}};
const result = await runProviderDeviceFlow('copilot', {{
fetchImpl,
openWindow: (url) => opened.push(url),
sleep: async () => {{}},
now: () => 0,
}});
console.log(JSON.stringify({{ result, calls, opened }}));
"""
out = _run_node(js)
assert out["result"]["status"] == "authorized"
assert out["result"]["endpoint"]["id"] == "ep1"
assert out["opened"] == ["https://github.com/login/device?user_code=GH-CODE"]
assert out["calls"] == ["/api/copilot/device/start", "/api/copilot/device/poll", "/api/copilot/device/poll"]
def test_chatgpt_success_uses_plain_verification_uri():
js = f"""
import {{ runProviderDeviceFlow }} from '{_HELPER.as_posix()}';
const opened = [];
const response = (ok, status, payload) => ({{ ok, status, async json() {{ return payload; }} }});
const fetchImpl = async (url) => {{
if (url.endsWith('/device/start')) {{
return response(true, 200, {{
poll_id: 'poll-1',
user_code: 'OA-CODE',
verification_uri: 'https://auth.openai.com/codex/device',
interval: 2,
expires_in: 30,
}});
}}
return response(true, 200, {{ status: 'authorized', endpoint: {{ id: 'chatgpt', models: ['gpt-5.5'] }} }});
}};
const result = await runProviderDeviceFlow('chatgpt-subscription', {{
fetchImpl,
openWindow: (url) => opened.push(url),
sleep: async () => {{}},
now: () => 0,
}});
console.log(JSON.stringify({{ result, opened }}));
"""
out = _run_node(js)
assert out["result"]["status"] == "authorized"
assert out["opened"] == ["https://auth.openai.com/codex/device"]
def test_start_errors_surface_backend_detail():
js = f"""
import {{ runProviderDeviceFlow }} from '{_HELPER.as_posix()}';
const response = (ok, status, payload) => ({{ ok, status, async json() {{ return payload; }} }});
try {{
await runProviderDeviceFlow('copilot', {{
fetchImpl: async () => response(false, 502, {{ detail: 'GitHub device-code request failed: upstream down' }}),
openWindow: () => {{}},
sleep: async () => {{}},
now: () => 0,
}});
}} catch (err) {{
console.log(JSON.stringify({{ message: err.message }}));
}}
"""
out = _run_node(js)
assert out["message"] == "GitHub device-code request failed: upstream down"
def test_thrown_fetch_errors_are_preserved():
js = f"""
import {{ runProviderDeviceFlow }} from '{_HELPER.as_posix()}';
try {{
await runProviderDeviceFlow('chatgpt-subscription', {{
fetchImpl: async () => {{ throw new Error('network offline'); }},
openWindow: () => {{}},
sleep: async () => {{}},
now: () => 0,
}});
}} catch (err) {{
console.log(JSON.stringify({{ message: err.message }}));
}}
"""
out = _run_node(js)
assert out["message"] == "network offline"
def test_expired_flow_returns_expired_status():
js = f"""
import {{ runProviderDeviceFlow }} from '{_HELPER.as_posix()}';
let currentTime = 0;
const response = (ok, status, payload) => ({{ ok, status, async json() {{ return payload; }} }});
const result = await runProviderDeviceFlow('copilot', {{
fetchImpl: async (url) => url.endsWith('/device/start')
? response(true, 200, {{
poll_id: 'poll-1',
user_code: 'GH-CODE',
verification_uri: 'https://github.com/login/device',
interval: 2,
expires_in: 1,
}})
: response(true, 200, {{ status: 'pending' }}),
openWindow: () => {{}},
sleep: async () => {{ currentTime += 2000; }},
now: () => currentTime,
}});
console.log(JSON.stringify(result));
"""
out = _run_node(js)
assert out == {"status": "expired"}