mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-25 00:02:25 +02:00
feat(ui): refine subscription model and usage controls
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
"""Tests for Model Picker Endpoint Grouping and ChatGPT Reasoning Effort Control.
|
||||
|
||||
Covers:
|
||||
1. Endpoint/account grouping in modelPicker.js (keyed on endpoint_id, distinct routes, collapsible headers, search auto-expansion, keyboard nav).
|
||||
2. ChatGPT reasoning effort (validation, catalog metadata, session thinking_mode persistence, payload shaping with zero provider tools).
|
||||
3. Composer UI elements and styles.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import pytest
|
||||
|
||||
from src import chatgpt_subscription, llm_core
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# PART 1: FRONTEND MODEL PICKER GROUPING & UI
|
||||
# ============================================================
|
||||
|
||||
def test_model_picker_endpoint_grouping_logic_in_node():
|
||||
"""Verify in node that modelPicker groups models by endpoint_id and maintains distinct routes."""
|
||||
source = (ROOT / "static/js/modelPicker.js").read_text(encoding="utf-8")
|
||||
key_start = source.index("function _pickerModelKey(m)")
|
||||
key_end = source.index("// ── Shared keyboard nav")
|
||||
key_helper = source[key_start:key_end]
|
||||
|
||||
group_start = source.index("const _endpointGroupNames =")
|
||||
group_end = source.index("const _collapsedProviders =")
|
||||
group_snippet = source[group_start:group_end]
|
||||
|
||||
test_js = f"""
|
||||
const _PROVIDER_NAMES = {{}};
|
||||
const _PROVIDER_ALIAS = {{}};
|
||||
{key_helper}
|
||||
{group_snippet}
|
||||
|
||||
const codex00_model = {{
|
||||
mid: 'gpt-5.5',
|
||||
display: 'gpt-5.5',
|
||||
endpointId: 'chatgpt-codex00',
|
||||
epName: 'ChatGPT · codex00',
|
||||
category: 'chatgpt_subscription',
|
||||
url: 'https://chatgpt.com/backend-api/codex'
|
||||
}};
|
||||
|
||||
const codex01_model = {{
|
||||
mid: 'gpt-5.5',
|
||||
display: 'gpt-5.5',
|
||||
endpointId: 'chatgpt-codex01',
|
||||
epName: 'ChatGPT · codex01',
|
||||
category: 'chatgpt_subscription',
|
||||
url: 'https://chatgpt.com/backend-api/codex'
|
||||
}};
|
||||
|
||||
const k0 = _pickerModelKey(codex00_model);
|
||||
const k1 = _pickerModelKey(codex01_model);
|
||||
if (k0 === k1) throw new Error('Same model across accounts must have distinct route keys');
|
||||
|
||||
const g0 = _providerGroupKey(codex00_model);
|
||||
const g1 = _providerGroupKey(codex01_model);
|
||||
if (g0 === g1) throw new Error('Groups must be separate per account endpoint');
|
||||
|
||||
const name0 = _providerGroupName(g0);
|
||||
const name1 = _providerGroupName(g1);
|
||||
if (name0 !== 'ChatGPT · codex00') throw new Error('Unexpected group name for codex00: ' + name0);
|
||||
if (name1 !== 'ChatGPT · codex01') throw new Error('Unexpected group name for codex01: ' + name1);
|
||||
|
||||
console.log(JSON.stringify({{ k0, k1, g0, g1, name0, name1 }}));
|
||||
"""
|
||||
proc = subprocess.run(["node", "-e", test_js], check=True, capture_output=True, text=True)
|
||||
res = json.loads(proc.stdout)
|
||||
assert res["k0"] != res["k1"]
|
||||
assert res["g0"] != res["g1"]
|
||||
assert res["name0"] == "ChatGPT · codex00"
|
||||
assert res["name1"] == "ChatGPT · codex01"
|
||||
|
||||
|
||||
def test_model_picker_source_invariants():
|
||||
"""Verify modelPicker.js contains required UI grouping structures."""
|
||||
src = (ROOT / "static/js/modelPicker.js").read_text(encoding="utf-8")
|
||||
# Grouping keyed on endpoint_id
|
||||
assert "m.endpointId || m.url" in src
|
||||
# Header class and chevron
|
||||
assert "mp-provider-header" in src
|
||||
assert "mp-provider-chevron" in src
|
||||
assert "mp-provider-name" in src
|
||||
assert "mp-provider-count" in src
|
||||
# Group container
|
||||
assert "mp-provider-group" in src
|
||||
# Search mode groups matches
|
||||
assert "isSearch" in src
|
||||
# TextContent used to prevent XSS
|
||||
assert "nameSpan.textContent = _providerGroupName(provider)" in src
|
||||
# Collapsed persistence key
|
||||
assert "odysseus-model-collapsed" in src
|
||||
|
||||
|
||||
def test_composer_reasoning_effort_ui_markup():
|
||||
"""Verify static/index.html and static/style.css include reasoning effort controls."""
|
||||
html = (ROOT / "static/index.html").read_text(encoding="utf-8")
|
||||
css = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
# HTML elements
|
||||
assert 'id="reasoning-effort-wrap"' in html
|
||||
assert 'id="reasoning-effort-btn"' in html
|
||||
assert 'id="reasoning-effort-current"' in html
|
||||
assert 'id="reasoning-effort-menu"' in html
|
||||
assert 'title="Reasoning effort"' in html
|
||||
assert 'class="reasoning-effort-prefix">Effort: </span>' in html
|
||||
# CSS classes
|
||||
assert ".reasoning-effort-wrap" in css
|
||||
assert ".reasoning-effort-btn" in css
|
||||
assert ".reasoning-effort-menu" in css
|
||||
assert ".reasoning-effort-option" in css
|
||||
# Responsive hide of prefix
|
||||
assert ".reasoning-effort-prefix { display: none; }" in css
|
||||
|
||||
|
||||
def test_chat_submit_includes_reasoning_effort():
|
||||
"""Verify static/js/chat.js sends reasoning_effort when active."""
|
||||
src = (ROOT / "static/js/chat.js").read_text(encoding="utf-8")
|
||||
assert "window.__odysseusGetReasoningEffort" in src
|
||||
assert "fd.append('reasoning_effort', effort)" in src
|
||||
|
||||
|
||||
# ============================================================
|
||||
# PART 2: BACKEND REASONING EFFORT CATALOG & VALIDATION
|
||||
# ============================================================
|
||||
|
||||
def test_chatgpt_reasoning_effort_validation():
|
||||
"""Test validate_reasoning_effort against catalog definitions."""
|
||||
# gpt-6-astra supports low, medium, high, xhigh, max, ultra
|
||||
assert chatgpt_subscription.validate_reasoning_effort("gpt-6-astra", "high") == "high"
|
||||
assert chatgpt_subscription.validate_reasoning_effort("gpt-6-astra", "LOW") == "low"
|
||||
assert chatgpt_subscription.validate_reasoning_effort("gpt-6-astra", "medium") == "medium"
|
||||
assert chatgpt_subscription.validate_reasoning_effort("gpt-6-astra", "xhigh") == "xhigh"
|
||||
assert chatgpt_subscription.validate_reasoning_effort("gpt-6-astra", "unsupported_level") is None
|
||||
assert chatgpt_subscription.validate_reasoning_effort("gpt-6-astra", "default") is None
|
||||
assert chatgpt_subscription.validate_reasoning_effort("gpt-6-astra", None) is None
|
||||
|
||||
# Model metadata catalog
|
||||
meta_astra = chatgpt_subscription.get_chatgpt_model_metadata("gpt-6-astra")
|
||||
assert meta_astra["default_reasoning_level"] == "low"
|
||||
levels = meta_astra["supported_reasoning_levels"]
|
||||
assert "low" in levels
|
||||
assert "medium" in levels
|
||||
assert "high" in levels
|
||||
|
||||
meta_55 = chatgpt_subscription.get_chatgpt_model_metadata("gpt-5.5")
|
||||
assert meta_55["default_reasoning_level"] == "medium"
|
||||
|
||||
|
||||
def test_models_metadata_attached_for_chatgpt_catalog():
|
||||
"""Verify get_chatgpt_model_metadata populates metadata for ChatGPT models."""
|
||||
from src.chatgpt_subscription import get_chatgpt_model_metadata
|
||||
models = ["gpt-6-astra", "gpt-5.5", "gpt-5.6-sol", "llama3"]
|
||||
models_metadata = {}
|
||||
for mid in models:
|
||||
meta = get_chatgpt_model_metadata(mid)
|
||||
if meta:
|
||||
models_metadata[mid] = meta
|
||||
assert "gpt-6-astra" in models_metadata
|
||||
assert "gpt-5.5" in models_metadata
|
||||
assert "gpt-5.6-sol" in models_metadata
|
||||
assert "llama3" not in models_metadata
|
||||
assert models_metadata["gpt-6-astra"]["default_reasoning_level"] == "low"
|
||||
assert "high" in models_metadata["gpt-6-astra"]["supported_reasoning_levels"]
|
||||
|
||||
|
||||
def test_model_routes_contains_metadata_population():
|
||||
"""Verify routes/model_routes.py populates models_metadata."""
|
||||
src = (ROOT / "routes/model_routes.py").read_text(encoding="utf-8")
|
||||
assert "from src.chatgpt_subscription import get_chatgpt_model_metadata" in src
|
||||
assert "models_metadata[mid] = meta" in src
|
||||
assert '"models_metadata": models_metadata' in src
|
||||
|
||||
|
||||
def test_history_and_chat_routes_support_reasoning_effort():
|
||||
"""Verify history and chat routes support reasoning_effort and thinking_mode persistence."""
|
||||
hist_src = (ROOT / "routes/history/history_routes.py").read_text(encoding="utf-8")
|
||||
assert "reasoning_effort" in hist_src
|
||||
assert 'mode = f"effort:{clean_effort}"' in hist_src
|
||||
|
||||
chat_src = (ROOT / "routes/chat_routes.py").read_text(encoding="utf-8")
|
||||
assert "validate_reasoning_effort(sess.model, reasoning_effort)" in chat_src
|
||||
assert "session_mode.startswith(\"effort:\")" in chat_src
|
||||
|
||||
sess_src = (ROOT / "routes/session_routes.py").read_text(encoding="utf-8")
|
||||
assert "session.thinking_mode = \"off\"" in sess_src
|
||||
|
||||
|
||||
def test_responses_payload_zero_tools_with_reasoning():
|
||||
"""Verify that reasoning effort does NOT permit any native tool surfaces."""
|
||||
forbidden_tools = [
|
||||
{"type": "function", "function": {"name": "test"}},
|
||||
{"type": "web_search_preview"},
|
||||
]
|
||||
payload = llm_core._build_chatgpt_responses_payload(
|
||||
model="gpt-6-astra",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
temperature=0.7,
|
||||
max_tokens=4096,
|
||||
stream=True,
|
||||
reasoning_effort="high",
|
||||
tools=forbidden_tools,
|
||||
)
|
||||
assert payload["reasoning"] == {"effort": "high"}
|
||||
assert "tools" not in payload
|
||||
assert "tool_choice" not in payload
|
||||
for k in llm_core.CHATGPT_FORBIDDEN_PAYLOAD_KEYS:
|
||||
assert k not in payload
|
||||
assert set(payload.keys()) <= llm_core.CHATGPT_ALLOWED_PAYLOAD_KEYS
|
||||
|
||||
|
||||
def test_session_model_change_revalidates_effort_logic():
|
||||
"""Switching to an unsupported model resets effort to off."""
|
||||
meta_astra = chatgpt_subscription.get_chatgpt_model_metadata("gpt-6-astra")
|
||||
supported = [lvl.lower() for lvl in meta_astra.get("supported_reasoning_levels", [])]
|
||||
|
||||
current_effort = "high"
|
||||
assert current_effort in supported # retained for gpt-6-astra
|
||||
|
||||
meta_local = chatgpt_subscription.get_chatgpt_model_metadata("llama3.2")
|
||||
assert meta_local is None # unsupported -> resets to off
|
||||
@@ -265,3 +265,310 @@ def test_refresh_and_reconnect_handlers_target_only_the_clicked_account():
|
||||
]
|
||||
assert out["operations"] == [{"reconnect_auth_id": "a", "reconnect_endpoint_id": "ep-a"}]
|
||||
assert out["bUnchanged"] is True
|
||||
|
||||
|
||||
def test_admin_renders_chatgpt_usage_collapsible_and_styled():
|
||||
admin_source = (_REPO / "static" / "js" / "admin.js").read_text(encoding="utf-8")
|
||||
style_source = (_REPO / "static" / "style.css").read_text(encoding="utf-8")
|
||||
load_block = admin_source[admin_source.index("async function loadEndpoints()"):admin_source.index("function initEndpointForm()")]
|
||||
assert "adm-chatgpt-controls" in load_block
|
||||
assert "adm-chatgpt-usage-toggle" in load_block
|
||||
assert 'aria-expanded="${isUsageExpanded ? \'true\' : \'false\'}"' in load_block
|
||||
assert 'aria-controls="adm-chatgpt-usage-${esc(ep.id)}"' in load_block
|
||||
assert "adm-chatgpt-usage-chevron" in load_block
|
||||
assert 'class="adm-chatgpt-usage-host${isUsageExpanded ? \'\' : \' hidden\'}"' in load_block
|
||||
assert 'data-adm-chatgpt-usage-host="${esc(ep.provider_auth_id)}" data-chatgpt-endpoint-id="${esc(ep.id)}"' in load_block
|
||||
assert 'data-adm-chatgpt-reconnect="${esc(ep.provider_auth_id)}" data-chatgpt-endpoint-id="${esc(ep.id)}"' in load_block
|
||||
assert ".adm-chatgpt-controls" in style_source
|
||||
assert ".adm-chatgpt-usage-chevron" in style_source
|
||||
|
||||
|
||||
def test_chatgpt_usage_collapsible_behavior():
|
||||
out = _run_node(f"""
|
||||
import fs from 'node:fs';
|
||||
import {{ buildUsageViewModel, renderUsageCardHtml }} from '{_MODULE.as_posix()}';
|
||||
const source = fs.readFileSync('{(_MODULE.parent / 'admin.js').as_posix()}', 'utf8');
|
||||
|
||||
// Extract localStorage helpers
|
||||
const helperStart = source.indexOf('const CHATGPT_USAGE_EXPANDED_KEY');
|
||||
const helperEnd = source.indexOf('async function loadEndpoints()');
|
||||
const helpersCode = source.slice(helperStart, helperEnd);
|
||||
|
||||
// Extract _loadChatgptUsage and _reconnectChatgptAccount
|
||||
const handlerStart = source.indexOf('const _chatgptReconnectInflight');
|
||||
const handlerEnd = source.indexOf('function initEndpointForm()', handlerStart);
|
||||
const handlersCode = source.slice(handlerStart, handlerEnd);
|
||||
|
||||
// Simulated localStorage
|
||||
const storage = {{}};
|
||||
const localStorage = {{
|
||||
getItem: k => storage[k] || null,
|
||||
setItem: (k, v) => {{ storage[k] = String(v); }},
|
||||
removeItem: k => {{ delete storage[k]; }},
|
||||
}};
|
||||
|
||||
const helpers = new Function('localStorage', helpersCode + '; return {{ _loadExpandedUsageEndpoints, _saveExpandedUsageEndpoints, _isChatgptUsageExpanded, _setChatgptUsageExpanded }};')(localStorage);
|
||||
|
||||
// Verify localStorage persistence format: IDs only, no tokens/secrets
|
||||
assertDefaultCollapsed: {{
|
||||
if (helpers._isChatgptUsageExpanded('ep-a', 'auth-a') !== false) throw new Error('should be collapsed by default');
|
||||
}}
|
||||
helpers._setChatgptUsageExpanded('ep-a', 'auth-a', true);
|
||||
const stored = JSON.parse(storage['odysseus-chatgpt-usage-expanded']);
|
||||
if (!stored.includes('ep-a') || !stored.includes('auth-a')) throw new Error('storage should have ep and auth ids');
|
||||
if (storage['odysseus-chatgpt-usage-expanded'].includes('Bearer') || storage['odysseus-chatgpt-usage-expanded'].includes('secret')) throw new Error('storage has credentials');
|
||||
if (helpers._isChatgptUsageExpanded('ep-a', 'auth-a') !== true) throw new Error('should be expanded');
|
||||
if (helpers._isChatgptUsageExpanded('ep-b', 'auth-b') !== false) throw new Error('b should remain collapsed');
|
||||
helpers._setChatgptUsageExpanded('ep-a', 'auth-a', false);
|
||||
if (helpers._isChatgptUsageExpanded('ep-a', 'auth-a') !== false) throw new Error('should be collapsed after removal');
|
||||
|
||||
// Now verify toggle and lazy loading interactions
|
||||
const urls = [], operations = [];
|
||||
const makeEl = (tag = 'div') => ({{
|
||||
tagName: tag,
|
||||
classList: new Set(),
|
||||
style: {{}},
|
||||
dataset: {{}},
|
||||
attributes: {{}},
|
||||
setAttribute(k, v) {{ this.attributes[k] = String(v); }},
|
||||
getAttribute(k) {{ return this.attributes[k]; }},
|
||||
addEventListener(_, fn) {{ this.click = fn; }},
|
||||
querySelector() {{ return null; }},
|
||||
}});
|
||||
|
||||
function createAccountRow(id) {{
|
||||
const row = makeEl('div');
|
||||
row.classList.add('admin-user-row');
|
||||
|
||||
const chevron = makeEl('span');
|
||||
chevron.textContent = '▾';
|
||||
|
||||
const toggleBtn = makeEl('button');
|
||||
toggleBtn.dataset = {{ admChatgptUsageToggle: id, chatgptEndpointId: 'ep-' + id }};
|
||||
toggleBtn.setAttribute('aria-expanded', 'false');
|
||||
toggleBtn.querySelector = sel => sel.includes('chevron') ? chevron : null;
|
||||
toggleBtn.closest = sel => sel.includes('admin-user-row') ? row : null;
|
||||
|
||||
const reconnectBtn = makeEl('button');
|
||||
reconnectBtn.dataset = {{ admChatgptReconnect: id, chatgptEndpointId: 'ep-' + id }};
|
||||
reconnectBtn.closest = sel => sel.includes('admin-user-row') ? row : null;
|
||||
|
||||
const host = makeEl('div');
|
||||
host.classList.add('adm-chatgpt-usage-host', 'hidden');
|
||||
host.style.display = 'none';
|
||||
host.dataset = {{ admChatgptUsageHost: id, chatgptEndpointId: 'ep-' + id }};
|
||||
|
||||
row.querySelector = sel => {{
|
||||
if (sel.includes('adm-chatgpt-usage-host')) return host;
|
||||
if (sel.includes('adm-chatgpt-usage-toggle')) return toggleBtn;
|
||||
if (sel.includes('adm-chatgpt-reconnect')) return reconnectBtn;
|
||||
return null;
|
||||
}};
|
||||
|
||||
return {{ row, toggleBtn, reconnectBtn, host, chevron }};
|
||||
}}
|
||||
|
||||
const handlers = new Function('fetch', 'buildChatgptUsageViewModel', 'renderChatgptUsageCardHtml',
|
||||
'esc', 'runProviderDeviceFlow', 'document', 'loadEndpoints', 'setTimeout',
|
||||
handlersCode + '; return {{ load: _loadChatgptUsage, reconnect: _reconnectChatgptAccount }};'
|
||||
)(
|
||||
async url => {{ urls.push(url); return {{ ok: true, json: async () => ({{available: true, usage: {{limits: []}}}}) }}; }},
|
||||
buildUsageViewModel, renderUsageCardHtml, x => String(x),
|
||||
async (provider, options) => {{ operations.push(Object.fromEntries(options.formData)); return {{ status: 'authorized' }}; }},
|
||||
{{ createElement: () => ({{ replaceWith() {{}} }}) }}, async () => {{}}, () => {{}}
|
||||
);
|
||||
|
||||
const a = createAccountRow('auth-a');
|
||||
const b = createAccountRow('auth-b');
|
||||
|
||||
// Wire toggle listener like in admin.js
|
||||
function wireToggle(rowObj) {{
|
||||
rowObj.toggleBtn.addEventListener('click', async () => {{
|
||||
const epId = rowObj.toggleBtn.dataset.chatgptEndpointId;
|
||||
const authId = rowObj.toggleBtn.dataset.admChatgptUsageToggle;
|
||||
const host = rowObj.host;
|
||||
const isHidden = host.classList.has('hidden') || host.style.display === 'none';
|
||||
if (isHidden) {{
|
||||
host.classList.delete('hidden');
|
||||
host.style.display = '';
|
||||
rowObj.toggleBtn.setAttribute('aria-expanded', 'true');
|
||||
rowObj.chevron.textContent = '▴';
|
||||
helpers._setChatgptUsageExpanded(epId, authId, true);
|
||||
if (!host.dataset.usageLoaded) {{
|
||||
await handlers.load(host, authId, epId);
|
||||
}}
|
||||
}} else {{
|
||||
host.classList.add('hidden');
|
||||
host.style.display = 'none';
|
||||
rowObj.toggleBtn.setAttribute('aria-expanded', 'false');
|
||||
rowObj.chevron.textContent = '▾';
|
||||
helpers._setChatgptUsageExpanded(epId, authId, false);
|
||||
}}
|
||||
}});
|
||||
}}
|
||||
wireToggle(a);
|
||||
wireToggle(b);
|
||||
|
||||
// Step 1: Initial state - 0 fetches before expand
|
||||
const initialFetches = urls.length;
|
||||
|
||||
// Step 2: Expand A -> fetches A only, updates aria-expanded and chevron
|
||||
await a.toggleBtn.click();
|
||||
const aExpandedFetches = urls.slice();
|
||||
const bStateAfterAExpand = {{
|
||||
hidden: b.host.style.display === 'none',
|
||||
ariaExpanded: b.toggleBtn.getAttribute('aria-expanded'),
|
||||
chevron: b.chevron.textContent,
|
||||
}};
|
||||
|
||||
// Step 3: Collapse A -> 0 extra fetches, updates aria-expanded and chevron
|
||||
await a.toggleBtn.click();
|
||||
const aCollapsedFetches = urls.slice();
|
||||
|
||||
// Step 4: Re-open A -> 0 extra fetches (cached DOM reused)
|
||||
await a.toggleBtn.click();
|
||||
const aReopenedFetches = urls.slice();
|
||||
|
||||
// Step 5: Refresh A -> forces fetch with ?refresh=1
|
||||
await handlers.load(a.host, 'auth-a', 'ep-auth-a', {{ refresh: true }});
|
||||
const refreshFetches = urls.slice();
|
||||
|
||||
console.log(JSON.stringify({{
|
||||
initialFetches,
|
||||
aExpandedFetches,
|
||||
bStateAfterAExpand,
|
||||
aCollapsedFetches,
|
||||
aReopenedFetches,
|
||||
refreshFetches,
|
||||
aFinalAriaExpanded: a.toggleBtn.getAttribute('aria-expanded'),
|
||||
aFinalChevron: a.chevron.textContent,
|
||||
}}));
|
||||
""")
|
||||
|
||||
assert out["initialFetches"] == 0
|
||||
assert out["aExpandedFetches"] == ["/api/chatgpt-subscription/accounts/auth-a/usage"]
|
||||
assert out["bStateAfterAExpand"] == {"hidden": True, "ariaExpanded": "false", "chevron": "▾"}
|
||||
assert len(out["aCollapsedFetches"]) == 1 # No extra fetch on collapse
|
||||
assert len(out["aReopenedFetches"]) == 1 # No extra fetch on reopen (cached DOM reused)
|
||||
assert out["refreshFetches"] == [
|
||||
"/api/chatgpt-subscription/accounts/auth-a/usage",
|
||||
"/api/chatgpt-subscription/accounts/auth-a/usage?refresh=1",
|
||||
]
|
||||
assert out["aFinalAriaExpanded"] == "true"
|
||||
assert out["aFinalChevron"] == "▴"
|
||||
|
||||
|
||||
def test_chatgpt_usage_collapsible_reconnect_and_failure():
|
||||
out = _run_node(f"""
|
||||
import fs from 'node:fs';
|
||||
import {{ buildUsageViewModel, renderUsageCardHtml }} from '{_MODULE.as_posix()}';
|
||||
const source = fs.readFileSync('{(_MODULE.parent / 'admin.js').as_posix()}', 'utf8');
|
||||
|
||||
const handlerStart = source.indexOf('const _chatgptReconnectInflight');
|
||||
const handlerEnd = source.indexOf('function initEndpointForm()', handlerStart);
|
||||
const handlersCode = source.slice(handlerStart, handlerEnd);
|
||||
|
||||
const urls = [], operations = [];
|
||||
const makeEl = (tag = 'div') => ({{
|
||||
tagName: tag,
|
||||
classList: new Set(),
|
||||
style: {{}},
|
||||
dataset: {{}},
|
||||
attributes: {{}},
|
||||
appendChild() {{}},
|
||||
setAttribute(k, v) {{ this.attributes[k] = String(v); }},
|
||||
getAttribute(k) {{ return this.attributes[k]; }},
|
||||
addEventListener(_, fn) {{ this.click = fn; }},
|
||||
querySelector() {{ return null; }},
|
||||
}});
|
||||
|
||||
function createAccountRow(id) {{
|
||||
const row = makeEl('div');
|
||||
const chevron = makeEl('span');
|
||||
chevron.textContent = '▾';
|
||||
|
||||
const toggleBtn = makeEl('button');
|
||||
toggleBtn.dataset = {{ admChatgptUsageToggle: id, chatgptEndpointId: 'ep-' + id }};
|
||||
toggleBtn.setAttribute('aria-expanded', 'false');
|
||||
toggleBtn.querySelector = sel => sel.includes('chevron') ? chevron : null;
|
||||
toggleBtn.closest = sel => sel.includes('admin-user-row') ? row : null;
|
||||
|
||||
const reconnectBtn = makeEl('button');
|
||||
reconnectBtn.dataset = {{ admChatgptReconnect: id, chatgptEndpointId: 'ep-' + id }};
|
||||
reconnectBtn.closest = sel => sel.includes('admin-user-row') ? row : null;
|
||||
|
||||
const host = makeEl('div');
|
||||
host.classList.add('adm-chatgpt-usage-host', 'hidden');
|
||||
host.style.display = 'none';
|
||||
host.dataset = {{ admChatgptUsageHost: id, chatgptEndpointId: 'ep-' + id }};
|
||||
|
||||
row.querySelector = sel => {{
|
||||
if (sel.includes('adm-chatgpt-usage-host')) return host;
|
||||
if (sel.includes('adm-chatgpt-usage-toggle')) return toggleBtn;
|
||||
if (sel.includes('adm-chatgpt-reconnect')) return reconnectBtn;
|
||||
return null;
|
||||
}};
|
||||
|
||||
return {{ row, toggleBtn, reconnectBtn, host, chevron }};
|
||||
}}
|
||||
|
||||
let shouldFail = false;
|
||||
const handlers = new Function('fetch', 'buildChatgptUsageViewModel', 'renderChatgptUsageCardHtml',
|
||||
'esc', 'runProviderDeviceFlow', 'document', 'loadEndpoints', 'setTimeout',
|
||||
handlersCode + '; return {{ load: _loadChatgptUsage, reconnect: _reconnectChatgptAccount }};'
|
||||
)(
|
||||
async url => {{
|
||||
urls.push(url);
|
||||
if (shouldFail) throw new Error('network down');
|
||||
return {{ ok: true, json: async () => ({{ available: true, usage: {{ limits: [] }} }}) }};
|
||||
}},
|
||||
buildUsageViewModel, renderUsageCardHtml, x => String(x),
|
||||
async (provider, options) => {{ operations.push(Object.fromEntries(options.formData)); return {{ status: 'authorized' }}; }},
|
||||
{{ createElement: () => ({{ replaceWith() {{}} }}) }}, async () => {{}}, () => {{}}
|
||||
);
|
||||
|
||||
const b = createAccountRow('auth-b');
|
||||
|
||||
// Wire reconnect listener like in admin.js
|
||||
b.reconnectBtn.addEventListener('click', async () => {{
|
||||
const epId = b.reconnectBtn.dataset.chatgptEndpointId;
|
||||
const authId = b.reconnectBtn.dataset.admChatgptReconnect;
|
||||
const host = b.host;
|
||||
host.classList.delete('hidden');
|
||||
host.style.display = '';
|
||||
b.toggleBtn.setAttribute('aria-expanded', 'true');
|
||||
b.chevron.textContent = '▴';
|
||||
await handlers.reconnect(host, authId, epId);
|
||||
}});
|
||||
|
||||
// Reconnect when collapsed -> unhides host, updates aria-expanded, runs reconnect for B
|
||||
await b.reconnectBtn.click();
|
||||
const bReconnectState = {{
|
||||
hostHidden: b.host.style.display === 'none',
|
||||
ariaExpanded: b.toggleBtn.getAttribute('aria-expanded'),
|
||||
chevron: b.chevron.textContent,
|
||||
operations: operations.slice(),
|
||||
}};
|
||||
|
||||
// Failure state test
|
||||
shouldFail = true;
|
||||
const failHost = makeEl('div');
|
||||
failHost.dataset = {{ admChatgptUsageHost: 'auth-f', chatgptEndpointId: 'ep-f' }};
|
||||
await handlers.load(failHost, 'auth-f', 'ep-f');
|
||||
const failHtml = failHost.innerHTML;
|
||||
|
||||
console.log(JSON.stringify({{
|
||||
bReconnectState,
|
||||
failHasUnavailable: failHtml.includes('adm-chatgpt-usage-unavailable'),
|
||||
failHasRefresh: failHtml.includes('data-adm-chatgpt-usage-refresh'),
|
||||
failNoDuplicateReconnect: !failHtml.includes('data-adm-chatgpt-reconnect'),
|
||||
}}));
|
||||
""")
|
||||
|
||||
assert out["bReconnectState"]["hostHidden"] is False
|
||||
assert out["bReconnectState"]["ariaExpanded"] == "true"
|
||||
assert out["bReconnectState"]["chevron"] == "▴"
|
||||
assert out["bReconnectState"]["operations"] == [{"reconnect_auth_id": "auth-b", "reconnect_endpoint_id": "ep-auth-b"}]
|
||||
assert out["failHasUnavailable"] is True
|
||||
assert out["failHasRefresh"] is True
|
||||
assert out["failNoDuplicateReconnect"] is True
|
||||
|
||||
@@ -70,6 +70,27 @@ def test_strip_helper_removes_any_native_tool_key_added_later():
|
||||
assert set(stripped) == {"model", "input"}
|
||||
|
||||
|
||||
def test_responses_payload_includes_reasoning_effort_without_tools():
|
||||
payload = llm_core._build_chatgpt_responses_payload(
|
||||
"gpt-6-astra", _MESSAGES, 0.7, 4096, stream=True, reasoning_effort="high", tools=_TOOLS
|
||||
)
|
||||
assert set(payload) <= (_ALLOWED_KEYS | {"reasoning"})
|
||||
assert payload["reasoning"] == {"effort": "high"}
|
||||
assert "tools" not in payload
|
||||
assert "tool_choice" not in payload
|
||||
for key in llm_core.CHATGPT_FORBIDDEN_PAYLOAD_KEYS:
|
||||
assert key not in payload
|
||||
|
||||
|
||||
def test_responses_payload_default_or_none_omits_reasoning():
|
||||
for effort in [None, "", "default", "Default"]:
|
||||
payload = llm_core._build_chatgpt_responses_payload(
|
||||
"gpt-6-astra", _MESSAGES, 0.7, 4096, stream=True, reasoning_effort=effort
|
||||
)
|
||||
assert set(payload) <= _ALLOWED_KEYS
|
||||
assert "reasoning" not in payload
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, lines):
|
||||
self._lines = lines
|
||||
|
||||
Reference in New Issue
Block a user