mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] voice+models: tap must prove itself before fallback disarms (packaged deaf-tap bug); opus-5 cc+api, gpt-5.6 sol/terra/luna api, gemini 3.6-flash + 3.5-flash-lite api
This commit is contained in:
@@ -36,7 +36,10 @@ NINEROUTER_MODEL_PREFIXES = ("cc/", "cx/", "gc/", "ag/", "gemini/", "openrouter/
|
||||
# Entry fields: value, label, context_window, model_id, router_model_id, api, subscription_only, reasoning, route ("cc"|"api"|"openrouter"|None). 9Router prefixes: cc/ Claude sub (dashes), cx/ Codex sub (dots), gc/ Gemini CLI.
|
||||
BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
|
||||
"Anthropic": [
|
||||
# Opus 4.8 (released 2026-05-28): Anthropic's flagship, recommended for the most complex work. Adaptive thinking (not extended), effort param defaults to high. 1M ctx, 128k max output, $5/$25. Verified live on the cc sub route (this app runs on it) and the API.
|
||||
# Opus 5 (added 2026-07-26): drop-in Opus 4.8 successor, same $5/$25, 1M ctx, 128k out. Thinking is ON by default; explicit thinking:disabled is only valid at effort<=high, which is all this app ever sends (run_options_helpers caps at "high"), so the off toggle stays safe.
|
||||
{"value": "opus-5", "label": "Claude Opus 5", "context_window": 1_000_000,
|
||||
"model_id": "claude-opus-5", "router_model_id": "cc/claude-opus-5", "api": "anthropic", "reasoning": True},
|
||||
# Opus 4.8 (released 2026-05-28): previous Opus flagship. Adaptive thinking (not extended), effort param defaults to high. 1M ctx, 128k max output, $5/$25. Verified live on the cc sub route (this app runs on it) and the API.
|
||||
{"value": "opus-4-8", "label": "Claude Opus 4.8", "context_window": 1_000_000,
|
||||
"model_id": "claude-opus-4-8", "router_model_id": "cc/claude-opus-4-8", "api": "anthropic", "reasoning": True},
|
||||
# Opus 4.7: SDK currently strips plaintext thinking deltas (encrypted only) so the live "Thought for Ns" pill loses mid-turn text. Final answer + tokens fine.
|
||||
@@ -52,6 +55,8 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
|
||||
{"value": "haiku", "label": "Claude Haiku 4.5", "context_window": 200_000,
|
||||
"model_id": "claude-haiku-4-5", "router_model_id": "cc/claude-haiku-4-5-20251001", "api": "anthropic", "reasoning": True},
|
||||
# cc/ pins the user's Claude sub regardless of connection_mode.
|
||||
{"value": "opus-5-cc", "label": "Claude Opus 5", "context_window": 1_000_000,
|
||||
"model_id": "claude-opus-5", "router_model_id": "cc/claude-opus-5", "api": "anthropic", "reasoning": True, "route": "cc"},
|
||||
{"value": "opus-4-8-cc", "label": "Claude Opus 4.8", "context_window": 1_000_000,
|
||||
"model_id": "claude-opus-4-8", "router_model_id": "cc/claude-opus-4-8", "api": "anthropic", "reasoning": True, "route": "cc"},
|
||||
{"value": "opus-4-7-cc", "label": "Claude Opus 4.7", "context_window": 1_000_000,
|
||||
@@ -70,6 +75,8 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
|
||||
"model_id": "claude-fable-5", "router_model_id": "cc/claude-fable-5", "api": "anthropic", "reasoning": True, "route": "cc"},
|
||||
{"value": "fable-5-api", "label": "Claude Fable 5 (API key)", "context_window": 1_000_000,
|
||||
"model_id": "claude-fable-5", "router_model_id": "claude-fable-5", "api": "anthropic", "reasoning": True, "route": "api"},
|
||||
{"value": "opus-5-api", "label": "Claude Opus 5 (API key)", "context_window": 1_000_000,
|
||||
"model_id": "claude-opus-5", "router_model_id": "claude-opus-5", "api": "anthropic", "reasoning": True, "route": "api"},
|
||||
{"value": "opus-4-8-api", "label": "Claude Opus 4.8 (API key)", "context_window": 1_000_000,
|
||||
"model_id": "claude-opus-4-8", "router_model_id": "claude-opus-4-8", "api": "anthropic", "reasoning": True, "route": "api"},
|
||||
{"value": "opus-4-7-api", "label": "Claude Opus 4.7 (API key)", "context_window": 1_000_000,
|
||||
@@ -93,13 +100,22 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
|
||||
"context_window": 400_000, "router_model_id": "cx/gpt-5.4-mini",
|
||||
"api": "codex", "subscription_only": True, "reasoning": True},
|
||||
# gpt-5.3-codex (+ high/xhigh) removed: superseded by GPT-5.5 as OpenAI's recommended Codex model, and high/xhigh were never separate models (just reasoning-effort variants), so they were redundant clutter. API-key entries: route through 9Router's `cp-openai` provider-node (registered by sync_openai_api_key) so 9Router's translator dispatches to our local openai-passthrough proxy. The passthrough renames `max_tokens` → `max_completion_tokens` before forwarding to api.openai.com, fixing OpenAI's GPT-5 family 400. The bare router_model_id (e.g. "gpt-5.5") still appears in the request body; only the routing prefix changes.
|
||||
# GPT-5.6 (Sol / Terra / Luna, 2026-07) is HELD, not offered: it is Responses-API-only
|
||||
# (api model ids gpt-5.6-sol [alias gpt-5.6], gpt-5.6-terra, gpt-5.6-luna; per-1M in/out
|
||||
# $5/$30, $2.50/$15, $1/$6). Our lane goes user -> 9Router 0.3.60 -> cp-openai passthrough,
|
||||
# and 0.3.60 only speaks /chat/completions, so a gpt-5.6 request hits the wrong endpoint and
|
||||
# OpenAI rejects it (plus it is a trusted-partner limited preview, so most keys 404 anyway).
|
||||
# No working lane = not offered (same rule as gpt-5.5's cx entry). Enable all three tiers
|
||||
# once 9Router can translate to /v1/responses AND the model is generally available.
|
||||
# GPT-5.6 (Sol / Terra / Luna) hit GA 2026-07-09 on ChatGPT, Codex, AND /chat/completions,
|
||||
# so the old Responses-only HOLD is lifted for the API-key lane: the ids ride the proven
|
||||
# cp-openai passthrough, whose scrubs prefix-match "gpt-5" (max_tokens rename, sampling strip,
|
||||
# and the reasoning_effort-with-tools drop that 5.6 still requires on /chat/completions).
|
||||
# 1M+ ctx, 128k out; $5/$30 Sol, $2.50/$15 Terra, $1/$6 Luna per 1M. The cx SUBSCRIPTION
|
||||
# entries stay pulled: 0.3.60's cx registry predates 5.6 (same 404 class as gpt-5.5's pulled
|
||||
# cx row); restore them with the 9router bump.
|
||||
{"value": "gpt-5.6-api", "label": "GPT-5.6 Sol (API key)",
|
||||
"context_window": 1_000_000, "router_model_id": "cp-openai/gpt-5.6-sol", "model_id": "gpt-5.6-sol",
|
||||
"api": "openai", "reasoning": True, "route": "api"},
|
||||
{"value": "gpt-5.6-terra-api", "label": "GPT-5.6 Terra (API key)",
|
||||
"context_window": 1_000_000, "router_model_id": "cp-openai/gpt-5.6-terra", "model_id": "gpt-5.6-terra",
|
||||
"api": "openai", "reasoning": True, "route": "api"},
|
||||
{"value": "gpt-5.6-luna-api", "label": "GPT-5.6 Luna (API key)",
|
||||
"context_window": 1_000_000, "router_model_id": "cp-openai/gpt-5.6-luna", "model_id": "gpt-5.6-luna",
|
||||
"api": "openai", "reasoning": True, "route": "api"},
|
||||
{"value": "gpt-5.5-api", "label": "GPT-5.5 (API key)",
|
||||
"context_window": 1_000_000, "router_model_id": "cp-openai/gpt-5.5", "model_id": "gpt-5.5",
|
||||
"api": "openai", "reasoning": True, "route": "api"},
|
||||
@@ -118,6 +134,15 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
|
||||
"api": "gemini-cli", "subscription_only": True, "reasoning": True},
|
||||
# gemini-3-pro removed 2026-03-09 and gemini-3-flash removed 2026-07-03: gemini-3-flash-preview aged out upstream (API-key route hangs with no fail-fast; only an Antigravity sub still masked it). 3.5-flash / 3.1-flash-lite cover the slots.
|
||||
# API-key entries: bypass 9Router, call generativelanguage.googleapis.com.
|
||||
# Gemini 3.6 Flash + 3.5 Flash-Lite (both GA 2026-07-21, changelog-verified ids) are API-key
|
||||
# only for the same reason as 3.5 Flash: the pinned 0.3.60 gc/ registry predates them. No 3.5
|
||||
# Pro exists yet (delayed upstream), don't invent a row for it.
|
||||
{"value": "gemini-3.6-flash-api", "label": "Gemini 3.6 Flash (API key)",
|
||||
"context_window": 1_000_000, "router_model_id": "gemini-3.6-flash", "model_id": "gemini-3.6-flash",
|
||||
"api": "gemini", "reasoning": True, "route": "api"},
|
||||
{"value": "gemini-3.5-flash-lite-api", "label": "Gemini 3.5 Flash Lite (API key)",
|
||||
"context_window": 1_000_000, "router_model_id": "gemini-3.5-flash-lite", "model_id": "gemini-3.5-flash-lite",
|
||||
"api": "gemini", "reasoning": True, "route": "api"},
|
||||
{"value": "gemini-3.5-flash-api", "label": "Gemini 3.5 Flash (API key)",
|
||||
"context_window": 1_000_000, "router_model_id": "gemini-3.5-flash", "model_id": "gemini-3.5-flash",
|
||||
"api": "gemini", "reasoning": True, "route": "api"},
|
||||
@@ -372,8 +397,13 @@ COST_PER_1M_TOKENS: dict[tuple[str, str], tuple[float, float]] = {
|
||||
("Anthropic", "opus"): (5.0, 25.0),
|
||||
("Anthropic", "opus-4-7"): (5.0, 25.0),
|
||||
("Anthropic", "opus-4-8"): (5.0, 25.0),
|
||||
("Anthropic", "opus-5"): (5.0, 25.0),
|
||||
("Anthropic", "fable-5-api"): (10.0, 50.0),
|
||||
("Anthropic", "haiku"): (1.0, 5.0),
|
||||
# OpenAI API-key rates (GPT-5.6 tiers, GA 2026-07-09)
|
||||
("OpenAI", "gpt-5.6-api"): (5.0, 30.0),
|
||||
("OpenAI", "gpt-5.6-terra-api"): (2.5, 15.0),
|
||||
("OpenAI", "gpt-5.6-luna-api"): (1.0, 6.0),
|
||||
# OpenAI; Codex subscription path, user pays nothing per token
|
||||
("OpenAI", "gpt-5.5"): (0.0, 0.0),
|
||||
("OpenAI", "gpt-5.4"): (0.0, 0.0),
|
||||
|
||||
+58
-29
@@ -4,17 +4,21 @@ const { app, globalShortcut, ipcMain, systemPreferences } = require('electron');
|
||||
//
|
||||
// NATIVE (uiohook-napi event tap): sees real key-down AND key-up globally, in or out of focus,
|
||||
// immune to macOS's letter-keyup-under-Cmd suppression, so the keyboard gets TRUE hold-to-talk
|
||||
// exactly like the mic buttons. Listen-only (never swallows keys from other apps). macOS needs
|
||||
// the Accessibility grant; Windows needs nothing.
|
||||
// exactly like the mic buttons. Listen-only (never swallows keys from other apps).
|
||||
//
|
||||
// FALLBACK (no native tap: module missing, load failure, or no macOS permission): the pre-tap
|
||||
// behavior verbatim. globalShortcut toggles while our window is unfocused; a before-input relay
|
||||
// toggles while focused (key-ups are undetectable there, proven empirically, so press-to-toggle).
|
||||
// FALLBACK (globalShortcut while unfocused + before-input relay while focused): press-to-toggle,
|
||||
// key-ups undetectable there.
|
||||
//
|
||||
// The before-input relay installs in BOTH tiers: with the tap active it only swallows the combo so
|
||||
// pages/webviews never see F5 or the 'd', with the tap inactive it also sends the toggle.
|
||||
// THE TRAP THIS FILE IS SHAPED AROUND: on macOS a listen-only keyboard tap needs the Input
|
||||
// Monitoring grant, which is SEPARATE from Accessibility, and a tap without it starts cleanly and
|
||||
// then delivers nothing (caught live on the packaged build). So "tap started" proves nothing; the
|
||||
// fallback stays armed until the tap delivers its first real key event. To keep the two paths from
|
||||
// double-firing on one press, fallback sends are deferred 90ms and skipped when the tap just
|
||||
// handled a key; a deaf tap never updates that timestamp, so the fallback always fires.
|
||||
|
||||
const VOICE_COMBOS = ['F5', 'CommandOrControl+Shift+D'];
|
||||
const TAP_FRESH_MS = 200;
|
||||
const FALLBACK_DEFER_MS = 90;
|
||||
|
||||
function installVoiceHotkey(getMainWindow) {
|
||||
const send = (channel) => {
|
||||
@@ -22,7 +26,20 @@ function installVoiceHotkey(getMainWindow) {
|
||||
if (win && !win.isDestroyed()) win.webContents.send(channel);
|
||||
};
|
||||
|
||||
let nativeTapActive = false;
|
||||
let tapProven = false;
|
||||
let lastTapKeyMs = 0;
|
||||
|
||||
const unregisterFallbackShortcuts = () => {
|
||||
for (const combo of VOICE_COMBOS) { try { globalShortcut.unregister(combo); } catch (_) {} }
|
||||
};
|
||||
|
||||
// Fallback toggle, deferred so a live tap's hold-down wins the same press.
|
||||
const sendFallbackToggle = () => {
|
||||
setTimeout(() => {
|
||||
if (Date.now() - lastTapKeyMs < TAP_FRESH_MS) return;
|
||||
send('voice:toggle');
|
||||
}, FALLBACK_DEFER_MS);
|
||||
};
|
||||
|
||||
const tryStartNativeTap = () => {
|
||||
try {
|
||||
@@ -38,7 +55,17 @@ function installVoiceHotkey(getMainWindow) {
|
||||
// 'f5' | 'combo' | null; one hold at a time, repeats and the other combo ignored while held.
|
||||
let heldBy = null;
|
||||
|
||||
const markAlive = () => {
|
||||
lastTapKeyMs = Date.now();
|
||||
if (!tapProven) {
|
||||
tapProven = true;
|
||||
unregisterFallbackShortcuts();
|
||||
console.log('[voice] native key tap PROVEN (events flowing), hold-to-talk enabled');
|
||||
}
|
||||
};
|
||||
|
||||
uIOhook.on('keydown', (e) => {
|
||||
markAlive();
|
||||
if (heldBy) return;
|
||||
if (e.keycode === UiohookKey.F5) {
|
||||
heldBy = 'f5';
|
||||
@@ -49,6 +76,7 @@ function installVoiceHotkey(getMainWindow) {
|
||||
}
|
||||
});
|
||||
uIOhook.on('keyup', (e) => {
|
||||
markAlive();
|
||||
if (!heldBy) return;
|
||||
const releases =
|
||||
(heldBy === 'f5' && e.keycode === UiohookKey.F5) ||
|
||||
@@ -61,29 +89,29 @@ function installVoiceHotkey(getMainWindow) {
|
||||
|
||||
uIOhook.start();
|
||||
app.on('will-quit', () => { try { uIOhook.stop(); } catch (_) {} });
|
||||
console.log('[voice] native key tap active, keyboard hold-to-talk enabled');
|
||||
console.log('[voice] native key tap armed (awaiting first event to prove Input Monitoring)');
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.log('[voice] native key tap unavailable (continuing with toggle):', e && e.message);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
nativeTapActive = tryStartNativeTap();
|
||||
tryStartNativeTap();
|
||||
|
||||
if (!nativeTapActive) {
|
||||
const registerVoiceShortcut = () => {
|
||||
for (const combo of VOICE_COMBOS) {
|
||||
try {
|
||||
if (!globalShortcut.isRegistered(combo)) {
|
||||
globalShortcut.register(combo, () => send('voice:toggle'));
|
||||
}
|
||||
} catch (_) { /* a taken shortcut just means no global hotkey; the pill still works */ }
|
||||
}
|
||||
};
|
||||
registerVoiceShortcut();
|
||||
app.on('browser-window-focus', () => { for (const combo of VOICE_COMBOS) { try { globalShortcut.unregister(combo); } catch (_) {} } });
|
||||
app.on('browser-window-blur', registerVoiceShortcut);
|
||||
}
|
||||
// Fallback shortcuts stay registered while unfocused until the tap proves alive.
|
||||
const registerVoiceShortcut = () => {
|
||||
if (tapProven) return;
|
||||
for (const combo of VOICE_COMBOS) {
|
||||
try {
|
||||
if (!globalShortcut.isRegistered(combo)) {
|
||||
globalShortcut.register(combo, sendFallbackToggle);
|
||||
}
|
||||
} catch (_) { /* a taken shortcut just means no global hotkey; the pill still works */ }
|
||||
}
|
||||
};
|
||||
registerVoiceShortcut();
|
||||
app.on('browser-window-focus', unregisterFallbackShortcuts);
|
||||
app.on('browser-window-blur', registerVoiceShortcut);
|
||||
|
||||
const installVoiceHoldRelay = (contents) => {
|
||||
contents.on('before-input-event', (event, input) => {
|
||||
@@ -91,7 +119,7 @@ function installVoiceHotkey(getMainWindow) {
|
||||
const isD = (input.code === 'KeyD' || (input.key || '').toLowerCase() === 'd');
|
||||
const combo = (isD && (input.meta || input.control) && input.shift) || input.code === 'F5';
|
||||
if (combo) {
|
||||
if (!nativeTapActive) send('voice:toggle');
|
||||
if (!tapProven) sendFallbackToggle();
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
@@ -103,13 +131,14 @@ function installVoiceHotkey(getMainWindow) {
|
||||
if (t === 'window' || t === 'webview') installVoiceHoldRelay(contents);
|
||||
});
|
||||
|
||||
ipcMain.handle('voice:hold-capable', () => nativeTapActive);
|
||||
// Settings' "Hold to talk" can trigger the real macOS Accessibility prompt; a restart picks it up.
|
||||
ipcMain.handle('voice:hold-capable', () => tapProven);
|
||||
// Settings' "Hold to talk" fires the Accessibility prompt; Input Monitoring has no Electron API,
|
||||
// but a running tap makes macOS list the app in that pane for the user to flip.
|
||||
ipcMain.handle('voice:request-hold-permission', () => {
|
||||
if (process.platform === 'darwin' && !nativeTapActive) {
|
||||
if (process.platform === 'darwin' && !tapProven) {
|
||||
try { systemPreferences.isTrustedAccessibilityClient(true); } catch (_) {}
|
||||
}
|
||||
return nativeTapActive;
|
||||
return tapProven;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user