feat: OpenAI-compatible orchestrator + backend auto-routing
- openai_orchestrator.py — new ReAct tool loop engine for any OpenAI-compatible endpoint (OpenRouter, Open WebUI, Ollama, LiteLLM); model handles both tool loop and final response, no Claude handoff needed - tools/__init__.py — auto-derive OpenAI JSON Schema from existing Gemini FunctionDeclarations so tool definitions have a single source of truth - routers/orchestrator.py — route to openai_orchestrator when model registry "orchestrator" role resolves to a local_openai type host - routers/chat.py — pass role to _backend_label(); fix fallback_used logic (only meaningful for explicit backend overrides, not auto-routing) - static/app.js — add null/"auto" to backend cycle; fetch local model hint without overriding the auto default on page load - model_registry.py — _normalize() back-fills host_type on old registry files - requirements.txt — add openai>=1.0.0 - ARCH__BACKENDS.md — document OpenAI-compat backend and routing logic Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -84,7 +84,7 @@
|
||||
if (helpLink) helpLink.href = `/help?persona=${encodeURIComponent(CORTEX_PERSONA)}`;
|
||||
|
||||
let sessionId = null;
|
||||
let primaryBackend = 'claude';
|
||||
let primaryBackend = null; // null = auto / role-based routing
|
||||
let activeController = null;
|
||||
let currentHistory = []; // mirrors backend session [{role, content}, ...]
|
||||
let talkThinkingDiv = null; // pending "thinking…" bubble for live Talk updates
|
||||
@@ -340,23 +340,30 @@
|
||||
}
|
||||
|
||||
// ── Backend toggle ───────────────────────────────────────────
|
||||
// null = "auto" — uses role-based routing from model registry
|
||||
// 'claude' / 'gemini' / 'local' = explicit override
|
||||
|
||||
fetch('/backend').then(r => r.json()).then(d => setBackendUI(d));
|
||||
// On load only fetch local_model hint; don't override primaryBackend default (null)
|
||||
fetch('/backend').then(r => r.json()).then(d => {
|
||||
if (backendModelHint && d.local_model) {
|
||||
// Pre-fill hint in case user is already in local mode
|
||||
backendModelHint.textContent = d.local_model.label || d.local_model.model_name;
|
||||
}
|
||||
});
|
||||
|
||||
const BACKEND_CYCLE = ['claude', 'gemini', 'local'];
|
||||
const BACKEND_CYCLE = [null, 'claude', 'gemini', 'local'];
|
||||
const BACKEND_CLASS = { claude: '', gemini: 'mem-on', local: 'local-on' };
|
||||
const backendModelHint = document.getElementById('backend-model-hint');
|
||||
|
||||
function setBackendUI(d) {
|
||||
const backend = d.primary || d; // accept full response obj or bare string
|
||||
function setBackendUI(backend, localModel) {
|
||||
primaryBackend = backend;
|
||||
backendToggle.textContent = backend;
|
||||
const extra = BACKEND_CLASS[backend] || '';
|
||||
backendToggle.textContent = backend === null ? 'auto' : backend;
|
||||
const extra = backend === null ? '' : (BACKEND_CLASS[backend] || '');
|
||||
backendToggle.className = 'ctx-btn' + (extra ? ' ' + extra : '');
|
||||
|
||||
if (backendModelHint) {
|
||||
if (backend === 'local' && d.local_model) {
|
||||
backendModelHint.textContent = d.local_model.label || d.local_model.model_name;
|
||||
if (backend === 'local' && localModel) {
|
||||
backendModelHint.textContent = localModel.label || localModel.model_name;
|
||||
backendModelHint.style.display = '';
|
||||
} else {
|
||||
backendModelHint.textContent = '';
|
||||
@@ -365,17 +372,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize to auto mode
|
||||
setBackendUI(null, null);
|
||||
|
||||
backendToggle.addEventListener('click', async () => {
|
||||
const idx = BACKEND_CYCLE.indexOf(primaryBackend);
|
||||
const next = BACKEND_CYCLE[(idx + 1) % BACKEND_CYCLE.length];
|
||||
const res = await fetch('/backend', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ primary: next }),
|
||||
});
|
||||
const d = await res.json();
|
||||
setBackendUI(d);
|
||||
addMessage('system', `Backend: ${d.primary} (fallback: ${d.fallback})`);
|
||||
if (next === null) {
|
||||
// Auto: role-based routing — no server call needed
|
||||
setBackendUI(null, null);
|
||||
addMessage('system', 'Backend: auto (role-based routing)');
|
||||
} else {
|
||||
const res = await fetch('/backend', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ primary: next }),
|
||||
});
|
||||
const d = await res.json();
|
||||
setBackendUI(next, d.local_model);
|
||||
addMessage('system', `Backend: ${next} (fallback: ${d.fallback})`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Sessions panel ───────────────────────────────────────────
|
||||
@@ -917,42 +933,15 @@
|
||||
if (activeController) activeController.abort();
|
||||
});
|
||||
|
||||
async function sendMessage() {
|
||||
const text = inputEl.value.trim();
|
||||
if (!text || activeController) return;
|
||||
|
||||
inputEl.value = '';
|
||||
syncHeight();
|
||||
sendBtn.style.display = 'none';
|
||||
stopBtn.style.display = 'flex';
|
||||
headerEmoji.classList.add('processing');
|
||||
|
||||
activeController = new AbortController();
|
||||
|
||||
const userHistIdx = currentHistory.length;
|
||||
currentHistory.push({ role: 'user', content: text });
|
||||
const userMsgDiv = addMessage('user', text);
|
||||
attachHistoryControls(userMsgDiv, userHistIdx);
|
||||
scrollToBottom();
|
||||
|
||||
const thinkingDiv = addMessage('assistant thinking', '✨ thinking…');
|
||||
|
||||
// ── Chat fetch + SSE handler ─────────────────────────────────
|
||||
// Extracted so the retry button can call it without re-adding the
|
||||
// user message to the DOM or currentHistory.
|
||||
async function _doSend(payload, thinkingDiv) {
|
||||
try {
|
||||
const res = await fetch('/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: text,
|
||||
session_id: sessionId,
|
||||
tier: currentTier,
|
||||
include_long: memLong,
|
||||
include_mid: memMid,
|
||||
include_short: memShort,
|
||||
off_record: current_mode === 'otr',
|
||||
model: primaryBackend,
|
||||
user: CORTEX_USER,
|
||||
persona: CORTEX_PERSONA,
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
signal: activeController.signal,
|
||||
});
|
||||
|
||||
@@ -1004,10 +993,77 @@
|
||||
thinkingDiv.className = 'message system';
|
||||
thinkingDiv.textContent = 'Stopped.';
|
||||
} else {
|
||||
// Show error + retry button
|
||||
thinkingDiv.className = 'message error';
|
||||
thinkingDiv.textContent = `Error: ${err.message}`;
|
||||
thinkingDiv.innerHTML = '';
|
||||
|
||||
const errSpan = document.createElement('span');
|
||||
errSpan.textContent = `Error: ${err.message}`;
|
||||
thinkingDiv.appendChild(errSpan);
|
||||
|
||||
const retryBtn = document.createElement('button');
|
||||
retryBtn.className = 'retry-btn';
|
||||
retryBtn.textContent = '↺ Retry';
|
||||
retryBtn.addEventListener('click', async () => {
|
||||
// Roll back the failed user push, re-push, and try again
|
||||
if (currentHistory.at(-1)?.role === 'user') currentHistory.pop();
|
||||
currentHistory.push({ role: 'user', content: payload.message });
|
||||
|
||||
thinkingDiv.className = 'message assistant thinking';
|
||||
thinkingDiv.textContent = '✨ thinking…';
|
||||
|
||||
activeController = new AbortController();
|
||||
sendBtn.style.display = 'none';
|
||||
stopBtn.style.display = 'flex';
|
||||
headerEmoji.classList.add('processing');
|
||||
|
||||
await _doSend(payload, thinkingDiv);
|
||||
|
||||
activeController = null;
|
||||
headerEmoji.classList.remove('processing');
|
||||
sendBtn.style.display = 'block';
|
||||
stopBtn.style.display = 'none';
|
||||
inputEl.focus();
|
||||
});
|
||||
thinkingDiv.appendChild(retryBtn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const text = inputEl.value.trim();
|
||||
if (!text || activeController) return;
|
||||
|
||||
inputEl.value = '';
|
||||
syncHeight();
|
||||
sendBtn.style.display = 'none';
|
||||
stopBtn.style.display = 'flex';
|
||||
headerEmoji.classList.add('processing');
|
||||
|
||||
activeController = new AbortController();
|
||||
|
||||
const userHistIdx = currentHistory.length;
|
||||
currentHistory.push({ role: 'user', content: text });
|
||||
const userMsgDiv = addMessage('user', text);
|
||||
attachHistoryControls(userMsgDiv, userHistIdx);
|
||||
scrollToBottom();
|
||||
|
||||
const thinkingDiv = addMessage('assistant thinking', '✨ thinking…');
|
||||
|
||||
const payload = {
|
||||
message: text,
|
||||
session_id: sessionId,
|
||||
tier: currentTier,
|
||||
include_long: memLong,
|
||||
include_mid: memMid,
|
||||
include_short: memShort,
|
||||
off_record: current_mode === 'otr',
|
||||
model: primaryBackend,
|
||||
user: CORTEX_USER,
|
||||
persona: CORTEX_PERSONA,
|
||||
};
|
||||
|
||||
await _doSend(payload, thinkingDiv);
|
||||
|
||||
activeController = null;
|
||||
headerEmoji.classList.remove('processing');
|
||||
|
||||
@@ -565,6 +565,26 @@
|
||||
}
|
||||
.model-tag.fallback { color: #f59e0b; }
|
||||
|
||||
/* Retry button — shown in error message bubbles */
|
||||
.retry-btn {
|
||||
display: inline-block;
|
||||
margin-top: 0.6rem;
|
||||
margin-left: 0.15rem;
|
||||
padding: 0.25rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
background: transparent;
|
||||
color: var(--error-text);
|
||||
border: 1px solid var(--error-border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.retry-btn:hover {
|
||||
background: var(--error-border);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Note messages */
|
||||
.message.note-private {
|
||||
align-self: flex-end;
|
||||
|
||||
Reference in New Issue
Block a user