feat(ui): refine subscription model and usage controls

This commit is contained in:
Alexandre Teixeira
2026-09-22 13:12:19 +01:00
parent ed7ccfd584
commit 411559913c
17 changed files with 1258 additions and 103 deletions
@@ -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
+307
View File
@@ -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
+21
View File
@@ -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