Fix(tinfoil): Make the model list dynamic (#1115)

* fetch tinfoil model list from /v1/models instead of hardcoding

* correct tinfoil seed comment: getModelProvider no longer reads the registry

* don't resolve tinfoil token params through the runtime-fetched registry

* fetch tinfoil model list in main and merge it into the registry

Ship three models as an offline fallback; the /v1/models list is source of
truth for everything but the curated descriptions. Only prune or auto-switch
after a successful fetch.

* keep the saved tinfoil list across launches and refresh it per request

Seed the registry from the last list Tinfoil gave us rather than the bundled
models, and pull a fresh one through the tinfoil client so users who never
open settings still get an up-to-date registry.

* refresh the tinfoil catalog in the background, not in the request path

* switch off retired tinfoil models wherever the refresh lands, and say so

The reconcile moves out of the settings picker into refreshTinfoilModels, so a
background refresh no longer leaves a scope pointing at a model Tinfoil 404s
on. Toast the switch instead of changing the user's model silently.

* fetch tinfoil models only when tinfoil is selected, and default to glm-5-2

Gating the fetch on the picker makes the 'Fetching models...' state reachable
instead of dead code. Picking a named default stops Tinfoil's list order and
network timing from deciding which model a new user starts on.

* persist when the tinfoil list was fetched, and show refreshes under the list

The hour now lives with the cached list in localStorage rather than in a
main-process variable, so a restart doesn't refetch a list pulled a minute
ago. The picker renders the known models immediately and reports a refresh
below them only when one actually runs.

* clean up selecting default model

* cleaner redraw code

* attest on the model catalog

* more idiomatic

* clean

* fix(tinfoil): route scoped requests to their configured provider

- look up the model in the registry before falling back to the tinfoil
  cleanup provider in getModelProvider, so other scopes' known models
  keep their own provider
- pass the resolved note-formatting provider (and custom base URL/key)
  through note actions and auto-title generation
- surface the dictation panel before showing the model-retired toast
- guard tinfoil model cache localStorage access behind isBrowser
- trim redundant comments

---------

Co-authored-by: Gabriel Stein <gabrielstein416@gmail.com>
This commit is contained in:
Daniel MS
2026-07-10 12:02:01 -07:00
committed by GitHub
co-authored by Gabriel Stein
parent ce6bd29aa4
commit 593eb3cd7b
31 changed files with 694 additions and 71 deletions
+1
View File
@@ -381,6 +381,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
proxyCortiTranscription: (data) => ipcRenderer.invoke("proxy-corti-transcription", data),
getTinfoilKey: () => ipcRenderer.invoke("get-tinfoil-key"),
saveTinfoilKey: (key) => ipcRenderer.invoke("save-tinfoil-key", key),
getTinfoilChatModels: () => ipcRenderer.invoke("get-tinfoil-chat-models"),
// Custom endpoint API keys
getCustomTranscriptionKey: () => ipcRenderer.invoke("get-custom-transcription-key"),
+49 -21
View File
@@ -16,7 +16,9 @@ import { ProviderTabs } from "./ui/ProviderTabs";
import OpenAICompatiblePanel from "./OpenAICompatiblePanel";
import { API_ENDPOINTS } from "../config/constants";
import logger from "../utils/logger";
import { REASONING_PROVIDERS } from "../models/ModelRegistry";
import { REASONING_PROVIDERS, toReasoningModel } from "../models/ModelRegistry";
import { useTinfoilModels } from "../hooks/useTinfoilModels";
import { pickDefaultTinfoilModel } from "../models/tinfoilModels";
import { modelRegistry } from "../models/ModelRegistry";
import { getProviderIcon, isMonochromeProvider } from "../utils/providerIcons";
import { createExternalLinkHandler } from "../utils/externalLinks";
@@ -325,6 +327,11 @@ export default function ReasoningModelSelector({
const [selectedMode, setSelectedMode] = useState<"cloud" | "local">(mode || "cloud");
const [selectedCloudProvider, setSelectedCloudProvider] = useState("openai");
const [selectedLocalProvider, setSelectedLocalProvider] = useState("qwen");
const {
models: tinfoilModels,
loading: tinfoilModelsLoading,
error: tinfoilModelsError,
} = useTinfoilModels(selectedCloudProvider === "tinfoil");
const effectiveMode = mode || selectedMode;
@@ -369,12 +376,17 @@ export default function ReasoningModelSelector({
if (selectedCloudProvider === "openai") return openaiModelOptions;
if (selectedCloudProvider === "custom") return [];
const provider = REASONING_PROVIDERS[selectedCloudProvider as keyof typeof REASONING_PROVIDERS];
if (!provider?.models) return [];
const iconUrl = getProviderIcon(selectedCloudProvider);
const invertInDark = isMonochromeProvider(selectedCloudProvider);
return provider.models.map((model) => ({
const models =
selectedCloudProvider === "tinfoil"
? tinfoilModels.map(toReasoningModel)
: REASONING_PROVIDERS[selectedCloudProvider as keyof typeof REASONING_PROVIDERS]?.models;
if (!models) return [];
return models.map((model) => ({
...model,
description: model.descriptionKey
? t(model.descriptionKey, { defaultValue: model.description })
@@ -382,7 +394,7 @@ export default function ReasoningModelSelector({
icon: iconUrl,
invertInDark,
}));
}, [selectedCloudProvider, openaiModelOptions, t]);
}, [selectedCloudProvider, openaiModelOptions, tinfoilModels, t]);
useEffect(() => {
const localProviderIds = localProviders.map((p) => p.id);
@@ -419,6 +431,21 @@ export default function ReasoningModelSelector({
loadDownloadedModels();
}, [loadDownloadedModels]);
const selectDefaultModelForProvider = (provider: string) => {
if (provider === "custom") return;
if (provider === "tinfoil") {
const defaultModel = pickDefaultTinfoilModel(tinfoilModels);
if (defaultModel) setReasoningModel(defaultModel.id);
return;
}
const providerData = REASONING_PROVIDERS[provider as keyof typeof REASONING_PROVIDERS];
if (providerData?.models?.length > 0) {
setReasoningModel(providerData.models[0].value);
}
};
const handleModeChange = async (newMode: "cloud" | "local") => {
setSelectedMode(newMode);
setReasoningModeProp?.(newMode === "local" ? "local" : "providers");
@@ -426,14 +453,7 @@ export default function ReasoningModelSelector({
if (newMode === "cloud") {
window.electronAPI?.llamaServerStop?.();
setLocalReasoningProvider(selectedCloudProvider);
if (selectedCloudProvider === "custom") return;
const provider =
REASONING_PROVIDERS[selectedCloudProvider as keyof typeof REASONING_PROVIDERS];
if (provider?.models?.length > 0) {
setReasoningModel(provider.models[0].value);
}
selectDefaultModelForProvider(selectedCloudProvider);
} else {
setLocalReasoningProvider(selectedLocalProvider);
const downloaded = await loadDownloadedModels();
@@ -453,13 +473,7 @@ export default function ReasoningModelSelector({
const handleCloudProviderChange = (provider: string) => {
setSelectedCloudProvider(provider);
setLocalReasoningProvider(provider);
if (provider === "custom") return;
const providerData = REASONING_PROVIDERS[provider as keyof typeof REASONING_PROVIDERS];
if (providerData?.models?.length > 0) {
setReasoningModel(providerData.models[0].value);
}
selectDefaultModelForProvider(provider);
};
const handleLocalProviderChange = async (providerId: string) => {
@@ -660,6 +674,20 @@ export default function ReasoningModelSelector({
selectedModel={reasoningModel}
onModelSelect={setReasoningModel}
/>
{selectedCloudProvider === "tinfoil" && (
<>
{tinfoilModelsLoading && (
<p className="text-xs text-muted-foreground">
{t("reasoning.tinfoil.refreshingModels")}
</p>
)}
{!tinfoilModelsLoading && tinfoilModelsError && (
<p className="text-xs text-destructive">
{t("reasoning.custom.unableToLoadModels")}
</p>
)}
</>
)}
</div>
</>
)}
@@ -0,0 +1,36 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useToast } from "./ui/useToast";
import {
consumeTinfoilModelSwitches,
useTinfoilModelSwitchStore,
} from "../stores/tinfoilModelSwitchStore";
const isDictationPanelWindow = () => {
const { search, pathname } = window.location;
return !pathname.includes("control") && !search.includes("panel=true");
};
/** Alerts the user when a retired Tinfoil model was switched out from under them. */
export default function TinfoilModelSwitchToastListener() {
const { t } = useTranslation();
const { toast } = useToast();
const switchCount = useTinfoilModelSwitchStore((s) => s.events.length);
useEffect(() => {
if (switchCount === 0) return;
// The panel may already be hidden after dictation; surface it so the toast is seen.
if (isDictationPanelWindow()) {
window.electronAPI?.showDictationPanel?.();
}
for (const event of consumeTinfoilModelSwitches()) {
toast({
title: t("reasoning.tinfoil.modelRetiredTitle"),
description: t("reasoning.tinfoil.modelRetired", { from: event.from, to: event.to }),
});
}
}, [switchCount, toast, t]);
return null;
}
+5
View File
@@ -15,6 +15,7 @@ const CortiStreaming = require("./cortiStreaming");
const OpenAIRealtimeStreaming = require("./openaiRealtimeStreaming");
const { getCortiToken } = require("./cortiAuth");
const { createTinfoilRealtimeSocket } = require("./tinfoilSecureClient");
const { getTinfoilChatModels } = require("./tinfoilCatalog");
const AudioStorageManager = require("./audioStorage");
// Tinfoil's only realtime STT model — fallback when the renderer omits one.
@@ -2786,6 +2787,10 @@ class IPCHandlers {
return this.environmentManager.saveTinfoilKey(key);
});
ipcMain.handle("get-tinfoil-chat-models", async () => {
return getTinfoilChatModels();
});
ipcMain.handle("get-custom-transcription-key", async () => {
return this.environmentManager.getCustomTranscriptionKey();
});
+82
View File
@@ -0,0 +1,82 @@
// Tinfoil's model list comes from the attested endpoint.
// Fetching from the main process rather than the renderer lets every window share one request.
const debugLogger = require("./debugLogger");
const { tinfoilSecureFetch } = require("./tinfoilSecureClient");
const TINFOIL_MODELS_PATH = "/v1/models";
const RETRY_AFTER_FAILURE_MS = 60 * 1000;
const REQUEST_TIMEOUT_MS = 10_000;
let lastFailureAt = 0;
let inFlight = null;
/**
* The list mixes chat, audio, tts, embedding and tool models, so narrow it to
* the ones we can send a chat completion to.
*/
function isChatModel(model) {
if (!model || model.type !== "chat") return false;
return Array.isArray(model.endpoints) && model.endpoints.includes("/v1/chat/completions");
}
function toCatalogModel(model) {
return {
id: model.id,
name: typeof model.name === "string" && model.name ? model.name : model.id,
description: typeof model.description === "string" ? model.description : "",
supportsThinking: model.reasoning === true,
};
}
function parseModels(payload) {
const data = payload?.data;
if (!Array.isArray(data)) {
throw new Error("Malformed models list");
}
return data
.filter((model) => typeof model?.id === "string" && model.id && isChatModel(model))
.map(toCatalogModel);
}
async function fetchModels() {
// The first call also attests the enclave, so allow for more than a plain GET.
const response = await tinfoilSecureFetch(TINFOIL_MODELS_PATH, {
method: "GET",
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`Tinfoil models request failed: ${response.status}`);
}
return parseModels(await response.json());
}
/**
* Resolves to Tinfoil's chat models. Rejects when
* the fetch fails so callers can tell "Tinfoil says this model is gone" apart
* from "we couldn't ask".
*/
async function getTinfoilChatModels() {
if (Date.now() - lastFailureAt < RETRY_AFTER_FAILURE_MS) {
throw new Error("Tinfoil models unavailable");
}
if (!inFlight) {
inFlight = fetchModels()
.then((models) => {
lastFailureAt = 0;
return models;
})
.catch((error) => {
lastFailureAt = Date.now();
debugLogger.warn("Failed to fetch Tinfoil models", { error: error.message });
throw error;
})
.finally(() => {
inFlight = null;
});
}
return inFlight;
}
module.exports = { getTinfoilChatModels };
+10 -4
View File
@@ -1,6 +1,6 @@
// WebSocket access to Tinfoil's realtime transcription endpoint. The SDK's
// SecureClient attests the enclave and pins the socket's TLS connection to the
// attested key; one client is held per session so attestation is paid once.
// Attested access to Tinfoil.
// The SDK's SecureClient verifies the enclave and pins the connection to the attested key;
// one client is held per session so attestation is paid once and shared by every caller.
let clientPromise = null;
function getSecureClient() {
@@ -23,4 +23,10 @@ async function createTinfoilRealtimeSocket({ model, apiKey }) {
});
}
module.exports = { createTinfoilRealtimeSocket };
/** Fetches an enclave path over the attested transport. Needs no API key. */
async function tinfoilSecureFetch(path, init) {
const client = await getSecureClient();
return client.fetch(path, init);
}
module.exports = { createTinfoilRealtimeSocket, tinfoilSecureFetch };
+67
View File
@@ -0,0 +1,67 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getTinfoilModels, type CloudModelDefinition } from "../models/ModelRegistry";
import { isTinfoilListFresh, refreshTinfoilModels } from "../models/tinfoilModels";
import logger from "../utils/logger";
interface UseTinfoilModelsResult {
models: CloudModelDefinition[];
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
}
/**
* Refreshes Tinfoil's model list into the registry and re-renders with it. Pass
* `enabled` only while the user is looking at Tinfoil, so nobody else's settings
* visit reaches out to it. The registry keeps its last known list when the fetch
* fails, so the picker stays usable offline.
*/
export function useTinfoilModels(enabled: boolean): UseTinfoilModelsResult {
const [models, setModels] = useState<CloudModelDefinition[]>(() => getTinfoilModels());
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const refresh = useCallback(async () => {
// A list we fetched within the hour resolves without touching the network,
// so don't flash a "refreshing" state the user can't even read.
const willFetch = !isTinfoilListFresh();
if (willFetch && isMountedRef.current) {
setLoading(true);
setError(null);
}
try {
const fresh = await refreshTinfoilModels();
if (isMountedRef.current) {
setModels(fresh);
}
} catch (err) {
// Leave the known list in place: without an answer we can't tell a
// retired model from an unreachable endpoint.
logger.error("Failed to load Tinfoil models", { error: err }, "models");
if (isMountedRef.current) {
setError((err as Error).message || "Unable to load models");
}
} finally {
if (willFetch && isMountedRef.current) {
setLoading(false);
}
}
}, []);
useEffect(() => {
if (!enabled) return;
refresh();
}, [enabled, refresh]);
return { models, loading, error, refresh };
}
+5
View File
@@ -498,6 +498,11 @@
"adcHelp": "Verwendet Application Default Credentials. Führen Sie aus: gcloud auth application-default login",
"apikeyHelp": "API-Schlüssel für Vertex AI Express Mode aus Google AI Studio."
}
},
"tinfoil": {
"modelRetiredTitle": "Modell nicht mehr verfügbar",
"modelRetired": "Tinfoil hat {{from}} eingestellt. Zu {{to}} gewechselt.",
"refreshingModels": "Modelle werden aktualisiert…"
}
},
"transcription": {
+5
View File
@@ -498,6 +498,11 @@
"adcHelp": "Uses Application Default Credentials. Run: gcloud auth application-default login",
"apikeyHelp": "Vertex AI Express Mode API key from Google AI Studio."
}
},
"tinfoil": {
"modelRetiredTitle": "Model no longer available",
"modelRetired": "Tinfoil retired {{from}}. Switched to {{to}}.",
"refreshingModels": "Refreshing models…"
}
},
"transcription": {
+5
View File
@@ -498,6 +498,11 @@
"adcHelp": "Usa Application Default Credentials. Ejecuta: gcloud auth application-default login",
"apikeyHelp": "Clave API de Vertex AI Express Mode obtenida en Google AI Studio."
}
},
"tinfoil": {
"modelRetiredTitle": "Modelo ya no disponible",
"modelRetired": "Tinfoil retiró {{from}}. Se cambió a {{to}}.",
"refreshingModels": "Actualizando modelos…"
}
},
"transcription": {
+5
View File
@@ -498,6 +498,11 @@
"adcHelp": "Utilise Application Default Credentials. Exécutez : gcloud auth application-default login",
"apikeyHelp": "Clé API Vertex AI Express Mode obtenue depuis Google AI Studio."
}
},
"tinfoil": {
"modelRetiredTitle": "Modèle non disponible",
"modelRetired": "Tinfoil a retiré {{from}}. Basculé vers {{to}}.",
"refreshingModels": "Actualisation des modèles…"
}
},
"transcription": {
+5
View File
@@ -498,6 +498,11 @@
"adcHelp": "Usa Application Default Credentials. Esegui: gcloud auth application-default login",
"apikeyHelp": "Chiave API Vertex AI Express Mode da Google AI Studio."
}
},
"tinfoil": {
"modelRetiredTitle": "Modello non più disponibile",
"modelRetired": "Tinfoil ha ritirato {{from}}. Passato a {{to}}.",
"refreshingModels": "Aggiornamento dei modelli…"
}
},
"transcription": {
+5
View File
@@ -498,6 +498,11 @@
"adcHelp": "Application Default Credentials を使用します。次を実行してください: gcloud auth application-default login",
"apikeyHelp": "Google AI Studio で取得した Vertex AI Express Mode の API キー。"
}
},
"tinfoil": {
"modelRetiredTitle": "モデルが利用できません",
"modelRetired": "Tinfoil は {{from}} を提供終了しました。{{to}} に切り替えました。",
"refreshingModels": "モデルを更新しています…"
}
},
"transcription": {
+5
View File
@@ -477,6 +477,11 @@
"adcHelp": "Usa Application Default Credentials. Execute: gcloud auth application-default login",
"apikeyHelp": "Chave API do Vertex AI Express Mode obtida no Google AI Studio."
}
},
"tinfoil": {
"modelRetiredTitle": "Modelo não disponível",
"modelRetired": "A Tinfoil descontinuou {{from}}. Alterado para {{to}}.",
"refreshingModels": "A atualizar modelos…"
}
},
"transcription": {
+5
View File
@@ -498,6 +498,11 @@
"adcHelp": "Использует Application Default Credentials. Выполните: gcloud auth application-default login",
"apikeyHelp": "API-ключ Vertex AI Express Mode из Google AI Studio."
}
},
"tinfoil": {
"modelRetiredTitle": "Модель больше недоступна",
"modelRetired": "Tinfoil прекратил поддержку {{from}}. Выполнен переход на {{to}}.",
"refreshingModels": "Обновление моделей…"
}
},
"transcription": {
+5
View File
@@ -498,6 +498,11 @@
"adcHelp": "使用 Application Default Credentials。请执行:gcloud auth application-default login",
"apikeyHelp": "从 Google AI Studio 获取的 Vertex AI Express Mode API 密钥。"
}
},
"tinfoil": {
"modelRetiredTitle": "模型已不可用",
"modelRetired": "Tinfoil 已停用 {{from}},已切换至 {{to}}。",
"refreshingModels": "正在刷新模型…"
}
},
"transcription": {
+5
View File
@@ -498,6 +498,11 @@
"adcHelp": "使用 Application Default Credentials。請執行:gcloud auth application-default login",
"apikeyHelp": "從 Google AI Studio 取得的 Vertex AI Express Mode API 金鑰。"
}
},
"tinfoil": {
"modelRetiredTitle": "模型已無法使用",
"modelRetired": "Tinfoil 已停用 {{from}},已切換至 {{to}}。",
"refreshingModels": "正在重新整理模型…"
}
},
"transcription": {
+2
View File
@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
import { I18nextProvider } from "react-i18next";
import AppRouter from "./AppRouter.jsx";
import ErrorBoundary from "./components/ErrorBoundary.tsx";
import TinfoilModelSwitchToastListener from "./components/TinfoilModelSwitchToastListener.tsx";
import { ToastProvider } from "./components/ui/Toast.tsx";
import { SettingsProvider } from "./hooks/useSettings";
@@ -16,6 +17,7 @@ root.render(
<I18nextProvider i18n={i18n}>
<SettingsProvider>
<ToastProvider>
<TinfoilModelSwitchToastListener />
<AppRouter />
</ToastProvider>
</SettingsProvider>
+41 -6
View File
@@ -1,5 +1,6 @@
import modelDataRaw from "./modelRegistryData.json";
import { isCloudCleanupMode, getSettings } from "../stores/settingsStore";
import { readCachedTinfoilModels } from "./tinfoilModelCache";
export interface ModelDefinition {
id: string;
@@ -114,6 +115,18 @@ interface ModelRegistryData {
const modelData: ModelRegistryData = modelDataRaw as ModelRegistryData;
function getTinfoilCloudProvider(): CloudProviderData | undefined {
return modelData.cloudProviders.find((provider) => provider.id === "tinfoil");
}
const cachedTinfoilModels = readCachedTinfoilModels().models;
if (cachedTinfoilModels.length > 0) {
const tinfoilProvider = getTinfoilCloudProvider();
if (tinfoilProvider) {
tinfoilProvider.models = cachedTinfoilModels;
}
}
function createPromptFormatter(template: string): (text: string, systemPrompt: string) => string {
return (text: string, systemPrompt: string) => {
return template.replace("{system}", systemPrompt).replace("{user}", text);
@@ -221,18 +234,22 @@ export function isEnterpriseProvider(value: unknown): value is EnterpriseProvide
return typeof value === "string" && (ENTERPRISE_PROVIDERS as readonly string[]).includes(value);
}
export function toReasoningModel(m: CloudModelDefinition): ReasoningModel {
return {
value: m.id,
label: m.name,
description: m.description,
descriptionKey: m.descriptionKey,
};
}
function buildReasoningProviders(): ReasoningProviders {
const providers: ReasoningProviders = {};
for (const cloudProvider of modelRegistry.getCloudProviders()) {
providers[cloudProvider.id] = {
name: cloudProvider.name,
models: cloudProvider.models.map((m) => ({
value: m.id,
label: m.name,
description: m.description,
descriptionKey: m.descriptionKey,
})),
models: cloudProvider.models.map(toReasoningModel),
};
}
@@ -263,6 +280,21 @@ function buildReasoningProviders(): ReasoningProviders {
export const REASONING_PROVIDERS = buildReasoningProviders();
export function getTinfoilModels(): CloudModelDefinition[] {
return getTinfoilCloudProvider()?.models ?? [];
}
export function applyTinfoilModels(models: CloudModelDefinition[]): void {
const provider = getTinfoilCloudProvider();
if (provider) {
provider.models = models;
}
const reasoningProvider = REASONING_PROVIDERS.tinfoil;
if (reasoningProvider) {
reasoningProvider.models = models.map(toReasoningModel);
}
}
export interface ReasoningModelWithProvider extends ReasoningModel {
provider: string;
fullLabel: string;
@@ -301,6 +333,9 @@ export function getModelProvider(modelId: string): string {
const model = getAllReasoningModels().find((m) => m.value === modelId);
if (!model) {
// An unknown model here is one Tinfoil retired or added since our last
// sync — don't let the id heuristics misroute it.
if (storedProvider === "tinfoil") return "tinfoil";
if (modelId.includes("claude")) return "anthropic";
if (modelId.includes("gemini") && !modelId.includes("gemma")) return "gemini";
if ((modelId.includes("gpt-4") || modelId.includes("gpt-5")) && !modelId.includes("gpt-oss"))
+8 -25
View File
@@ -457,12 +457,12 @@
"name": "Tinfoil",
"models": [
{
"id": "deepseek-v4-pro",
"name": "DeepSeek V4 Pro",
"description": "Private long-context reasoning in secure enclaves, 800K context",
"id": "kimi-k2-6",
"name": "Kimi K2.6",
"description": "Private long-horizon coding and multimodal model, 256K context",
"tokenParam": "max_tokens",
"supportsTemperature": true,
"descriptionKey": "models.descriptions.cloud.tinfoil_deepseek_v4_pro",
"descriptionKey": "models.descriptions.cloud.tinfoil_kimi_k2_6",
"supportsThinking": true
},
{
@@ -475,12 +475,12 @@
"supportsThinking": true
},
{
"id": "kimi-k2-6",
"name": "Kimi K2.6",
"description": "Private long-horizon coding and multimodal model, 256K context",
"id": "gpt-oss-120b",
"name": "GPT-OSS 120B",
"description": "Private open-weight reasoning model with tool use, 131K context",
"tokenParam": "max_tokens",
"supportsTemperature": true,
"descriptionKey": "models.descriptions.cloud.tinfoil_kimi_k2_6",
"descriptionKey": "models.descriptions.cloud.tinfoil_gpt_oss_120b",
"supportsThinking": true
},
{
@@ -491,23 +491,6 @@
"supportsTemperature": true,
"descriptionKey": "models.descriptions.cloud.tinfoil_gemma4_31b",
"supportsThinking": true
},
{
"id": "gpt-oss-120b",
"name": "GPT-OSS 120B",
"description": "Private open-weight reasoning model with tool use, 131K context",
"tokenParam": "max_tokens",
"supportsTemperature": true,
"descriptionKey": "models.descriptions.cloud.tinfoil_gpt_oss_120b",
"supportsThinking": true
},
{
"id": "llama3-3-70b",
"name": "Llama 3.3 70B",
"description": "Private conversational model with tool calling, 128K context",
"tokenParam": "max_tokens",
"supportsTemperature": true,
"descriptionKey": "models.descriptions.cloud.tinfoil_llama3_3_70b"
}
]
}
+39
View File
@@ -0,0 +1,39 @@
import type { CloudModelDefinition } from "./ModelRegistry";
const CACHE_KEY = "tinfoilModels";
const MAX_AGE_MS = 60 * 60 * 1000;
const isBrowser = typeof window !== "undefined";
export interface CachedTinfoilModels {
models: CloudModelDefinition[];
/** Epoch ms of the fetch that produced `models`, or 0 if we've never fetched. */
fetchedAt: number;
}
const EMPTY: CachedTinfoilModels = { models: [], fetchedAt: 0 };
export function readCachedTinfoilModels(): CachedTinfoilModels {
if (!isBrowser) return EMPTY;
try {
const raw = localStorage.getItem(CACHE_KEY);
if (!raw) return EMPTY;
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed?.models) || typeof parsed.fetchedAt !== "number") return EMPTY;
return { models: parsed.models, fetchedAt: parsed.fetchedAt };
} catch {
return EMPTY;
}
}
export function writeCachedTinfoilModels(models: CloudModelDefinition[]): void {
if (!isBrowser) return;
try {
localStorage.setItem(CACHE_KEY, JSON.stringify({ models, fetchedAt: Date.now() }));
} catch {}
}
export function isCachedListFresh(cached: CachedTinfoilModels): boolean {
return cached.models.length > 0 && Date.now() - cached.fetchedAt < MAX_AGE_MS;
}
+132
View File
@@ -0,0 +1,132 @@
import { applyTinfoilModels, getTinfoilModels, type CloudModelDefinition } from "./ModelRegistry";
import {
isCachedListFresh,
readCachedTinfoilModels,
writeCachedTinfoilModels,
} from "./tinfoilModelCache";
import { INFERENCE_SCOPES } from "../config/inferenceScopes";
import { getSettings, setStringSetting } from "../stores/settingsStore";
import { recordTinfoilModelSwitch } from "../stores/tinfoilModelSwitchStore";
/** A chat model as reported by Tinfoil's /v1/models, narrowed by the main process. */
export interface TinfoilCatalogModel {
id: string;
name: string;
description: string;
supportsThinking: boolean;
}
/**
* Curated, translated descriptions keyed by model id. The endpoint only returns
* English copy.
*/
const DESCRIPTION_KEYS: Record<string, string> = {
"deepseek-v4-pro": "models.descriptions.cloud.tinfoil_deepseek_v4_pro",
"glm-5-2": "models.descriptions.cloud.tinfoil_glm_5_2",
"kimi-k2-6": "models.descriptions.cloud.tinfoil_kimi_k2_6",
"gemma4-31b": "models.descriptions.cloud.tinfoil_gemma4_31b",
"gpt-oss-120b": "models.descriptions.cloud.tinfoil_gpt_oss_120b",
"llama3-3-70b": "models.descriptions.cloud.tinfoil_llama3_3_70b",
};
function toCloudModels(catalog: TinfoilCatalogModel[]): CloudModelDefinition[] {
return catalog.map((model) => ({
id: model.id,
name: model.name,
description: model.description,
descriptionKey: DESCRIPTION_KEYS[model.id],
supportsThinking: model.supportsThinking,
tokenParam: "max_tokens",
supportsTemperature: true,
}));
}
const DEFAULT_MODEL_ID = "glm-5-2";
export function pickDefaultTinfoilModel(
models: CloudModelDefinition[]
): CloudModelDefinition | undefined {
return models.find((model) => model.id === DEFAULT_MODEL_ID) ?? models[0];
}
/**
* Moves any scope still pointing at a model Tinfoil has retired onto one it
* still serves — otherwise the next request is a 404 the user can't diagnose.
* Only ever called after a successful fetch, so a missing model really is gone.
*/
function reconcileSelectedModels(
previous: CloudModelDefinition[],
models: CloudModelDefinition[]
): void {
const available = new Set(models.map((model) => model.id));
const settings = getSettings() as unknown as Record<string, unknown>;
const replacement = pickDefaultTinfoilModel(models);
if (!replacement) return;
const announced = new Set<string>();
for (const scope of Object.values(INFERENCE_SCOPES)) {
const { provider, model } = scope.storeKeys;
if (settings[provider] !== "tinfoil") continue;
const selected = settings[model];
if (typeof selected !== "string" || !selected || available.has(selected)) continue;
setStringSetting(model, replacement.id);
// Several scopes can share a retired model; say so once.
if (announced.has(selected)) continue;
announced.add(selected);
recordTinfoilModelSwitch({
from: previous.find((m) => m.id === selected)?.name ?? selected,
to: replacement.name,
});
}
}
let inFlight: Promise<CloudModelDefinition[]> | null = null;
async function fetchAndApply(): Promise<CloudModelDefinition[]> {
const fetchModels = window.electronAPI?.getTinfoilChatModels;
if (!fetchModels) {
throw new Error("Tinfoil model list is unavailable");
}
const models = toCloudModels(await fetchModels());
if (models.length === 0) {
// Far more likely something upstream broke, so keep what we already have.
throw new Error("Tinfoil returned no chat models");
}
const previous = getTinfoilModels();
applyTinfoilModels(models);
writeCachedTinfoilModels(models);
reconcileSelectedModels(previous, models);
return models;
}
/** Whether refreshTinfoilModels would hit the network rather than short-circuit. */
export function isTinfoilListFresh(): boolean {
return isCachedListFresh(readCachedTinfoilModels());
}
/**
* Pulls Tinfoil's model list into the registry, at most once an hour. Cheap to
* call before every request: a list we fetched recently short-circuits without
* touching the network, and the timestamp is persisted so a restart doesn't
* refetch a list we pulled a minute ago. Rejects when Tinfoil can't be reached,
* leaving the registry as it was.
*/
export function refreshTinfoilModels(): Promise<CloudModelDefinition[]> {
const cached = readCachedTinfoilModels();
if (isCachedListFresh(cached)) {
// Another window may have fetched this; make sure our registry has it too.
applyTinfoilModels(cached.models);
return Promise.resolve(cached.models);
}
if (!inFlight) {
inFlight = fetchAndApply().finally(() => {
inFlight = null;
});
}
return inFlight;
}
+2 -2
View File
@@ -591,11 +591,11 @@ class ReasoningService extends BaseReasoningService {
baseURL =
provider === "custom" ? config.baseUrl?.trim() || getConfiguredOpenAIBase() : undefined;
}
const apiConfig = getOpenAiApiConfig(model);
const aiProvider = isLocalProvider || isLanCleanup ? "local" : provider;
// Resolving a Tinfoil model refreshes the registry, so read model config after it.
const aiModel = await getAIModel(aiProvider, model, apiKey, baseURL);
const apiConfig = getOpenAiApiConfig(model);
const modelDef = getCloudModel(model);
const userSuppressesThinking = config.disableThinking === true && !!modelDef?.supportsThinking;
const needsGroqDisableThinking =
@@ -1,6 +1,5 @@
import type { InferenceProvider } from "./types";
import { TOKEN_LIMITS } from "../../../config/constants";
import { getOpenAiApiConfig } from "../../../models/ModelRegistry";
import { withRetry, createApiRetryStrategy } from "../../../utils/retry";
import logger from "../../../utils/logger";
import { applyThinkingSuppression } from "../thinkingSuppression";
@@ -26,7 +25,6 @@ export const tinfoilProvider: InferenceProvider = {
{ role: "user", content: userContent },
];
const apiConfig = getOpenAiApiConfig(model);
const maxTokens =
config.maxTokens ||
Math.max(
@@ -42,13 +40,10 @@ export const tinfoilProvider: InferenceProvider = {
const requestBody: Record<string, unknown> = {
model,
messages,
[apiConfig.tokenParam]: maxTokens,
max_tokens: maxTokens,
temperature: config.temperature ?? (config.systemPrompt ? 0.3 : 0),
};
if (apiConfig.supportsTemperature) {
requestBody.temperature = config.temperature ?? (config.systemPrompt ? 0.3 : 0);
}
applyThinkingSuppression(requestBody, model, "tinfoil", config);
// 30s per attempt like sibling providers; SDK-internal retries off so
+8
View File
@@ -1,5 +1,6 @@
import type { LanguageModel } from "ai";
import type { TinfoilAI } from "tinfoil";
import { refreshTinfoilModels } from "../../models/tinfoilModels";
type TinfoilModule = typeof import("tinfoil");
type TinfoilAISDKProvider = Awaited<ReturnType<TinfoilModule["createTinfoilAI"]>>;
@@ -28,8 +29,14 @@ function normalizeApiKey(apiKey: string): string {
return key;
}
/** Tinfoil adds and retires models often; sync the registry in the background. */
function syncTinfoilCatalog(): void {
void refreshTinfoilModels().catch(() => {});
}
export async function getTinfoilChatClient(apiKey: string): Promise<TinfoilAI> {
const key = normalizeApiKey(apiKey);
syncTinfoilCatalog();
const cached = chatClientCache.get(key);
if (cached) return cached;
@@ -50,6 +57,7 @@ export async function getTinfoilChatClient(apiKey: string): Promise<TinfoilAI> {
async function getTinfoilAISDKProvider(apiKey: string): Promise<TinfoilAISDKProvider> {
const key = normalizeApiKey(apiKey);
syncTinfoilCatalog();
const cached = aiSdkProviderCache.get(key);
if (cached) return cached;
+15 -4
View File
@@ -1,6 +1,6 @@
import { create } from "zustand";
import reasoningService from "../services/ReasoningService";
import { getSettings } from "./settingsStore";
import { getSettings, selectResolvedNoteFormatting } from "./settingsStore";
import { appendDictionarySuffix } from "../config/prompts";
import { generateNoteTitle } from "../utils/generateTitle";
import type { ActionItem } from "../types/electron";
@@ -123,7 +123,18 @@ export function runBackgroundAction(
try {
const basePrompt = options.isMeetingNote ? MEETING_SYSTEM_PROMPT : BASE_SYSTEM_PROMPT;
const settings = getSettings();
const provider = options.isCloudMode ? "openwhispr" : undefined;
const noteFormatting = selectResolvedNoteFormatting(settings);
const provider = options.isCloudMode
? "openwhispr"
: noteFormatting.mode === "providers"
? noteFormatting.provider || undefined
: undefined;
const isCustom = provider === "custom";
const providerOverrides = {
provider,
baseUrl: isCustom ? noteFormatting.cloudBaseUrl || undefined : undefined,
customApiKey: isCustom ? settings.noteFormattingCustomApiKey || undefined : undefined,
};
const systemPrompt = appendDictionarySuffix(
basePrompt + action.prompt,
options.isMeetingNote ? settings.customDictionary : undefined,
@@ -133,14 +144,14 @@ export function runBackgroundAction(
systemPrompt,
temperature: 0.3,
disableThinking: settings.noteFormattingDisableThinking,
provider,
...providerOverrides,
});
if (cancelledFlags.get(noteId)) return;
let title: string | undefined;
if (getSettings().autoGenerateNoteTitle) {
const generated = await generateNoteTitle(enhanced, modelId, provider);
const generated = await generateNoteTitle(enhanced, modelId, providerOverrides);
if (generated) title = generated;
}
+5
View File
@@ -680,6 +680,11 @@ function createStringSetter(key: string) {
};
}
/** Writes a string setting whose key is computed rather than known up front. */
export function setStringSetting(key: keyof SettingsState, value: string): void {
createStringSetter(key)(value);
}
function createBooleanSetter(key: string) {
return (value: boolean) => {
if (isBrowser) localStorage.setItem(key, String(value));
+28
View File
@@ -0,0 +1,28 @@
import { create } from "zustand";
export interface TinfoilModelSwitch {
/** Display name of the model Tinfoil retired. */
from: string;
/** Display name of the model we moved the user to. */
to: string;
}
interface TinfoilModelSwitchState {
events: TinfoilModelSwitch[];
}
export const useTinfoilModelSwitchStore = create<TinfoilModelSwitchState>(() => ({
events: [],
}));
export function recordTinfoilModelSwitch(event: TinfoilModelSwitch): void {
useTinfoilModelSwitchStore.setState((state) => ({ events: [...state.events, event] }));
}
export function consumeTinfoilModelSwitches(): TinfoilModelSwitch[] {
const { events } = useTinfoilModelSwitchStore.getState();
if (events.length > 0) {
useTinfoilModelSwitchStore.setState({ events: [] });
}
return events;
}
+3
View File
@@ -1,3 +1,5 @@
import type { TinfoilCatalogModel } from "../models/tinfoilModels";
export type LocalTranscriptionProvider = "whisper" | "nvidia";
export type InferenceMode = "openwhispr" | "providers" | "local" | "self-hosted" | "enterprise";
@@ -1032,6 +1034,7 @@ declare global {
}) => Promise<{ text: string }>;
getTinfoilKey?: () => Promise<string | null>;
saveTinfoilKey?: (key: string) => Promise<void>;
getTinfoilChatModels?: () => Promise<TinfoilCatalogModel[]>;
// Custom endpoint API keys
getCustomTranscriptionKey?: () => Promise<string | null>;
+3 -2
View File
@@ -1,4 +1,5 @@
import reasoningService from "../services/ReasoningService";
import type { ReasoningConfig } from "../services/BaseReasoningService";
import { getSettings } from "../stores/settingsStore";
const TITLE_SYSTEM_PROMPT =
@@ -7,14 +8,14 @@ const TITLE_SYSTEM_PROMPT =
export async function generateNoteTitle(
text: string,
modelId: string,
provider?: string
config?: Pick<ReasoningConfig, "provider" | "baseUrl" | "customApiKey">
): Promise<string> {
try {
const raw = await reasoningService.processText(text.slice(0, 2000), modelId, null, {
systemPrompt: TITLE_SYSTEM_PROMPT,
temperature: 0.3,
disableThinking: getSettings().noteFormattingDisableThinking,
provider,
...config,
});
const cleaned = raw.trim().replace(/^["']|["']$/g, "");
return cleaned.length > 0 && cleaned.length < 100 ? cleaned : "";
+106
View File
@@ -0,0 +1,106 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const Module = require("node:module");
const catalogModulePath = require.resolve("../../src/helpers/tinfoilCatalog.js");
const originalLoad = Module._load;
function loadCatalog(fetchImpl) {
delete require.cache[catalogModulePath];
Module._load = function loadWithMocks(request, parent, isMain) {
if (request === "./tinfoilSecureClient") {
return { tinfoilSecureFetch: fetchImpl };
}
if (request === "./debugLogger") {
return { warn() {} };
}
return originalLoad.call(this, request, parent, isMain);
};
try {
return require(catalogModulePath);
} finally {
Module._load = originalLoad;
}
}
function okResponse(data) {
return { ok: true, status: 200, json: async () => ({ object: "list", data }) };
}
const CHAT_MODEL = {
id: "glm-5-2",
name: "GLM-5.2",
description: "d",
type: "chat",
reasoning: true,
endpoints: ["/v1/chat/completions"],
};
test("keeps only chat models reachable at /v1/chat/completions", async () => {
const { getTinfoilChatModels } = loadCatalog(async () =>
okResponse([
CHAT_MODEL,
{ id: "voxtral", name: "Voxtral", type: "audio", endpoints: ["/v1/audio/transcriptions"] },
{ id: "embed", name: "Embed", type: "embedding", endpoints: ["/v1/embeddings"] },
{ id: "chat-no-endpoint", name: "Odd", type: "chat", endpoints: ["/v1/responses"] },
])
);
assert.deepEqual(await getTinfoilChatModels(), [
{ id: "glm-5-2", name: "GLM-5.2", description: "d", supportsThinking: true },
]);
});
test("reports supportsThinking from the reasoning flag", async () => {
const { getTinfoilChatModels } = loadCatalog(async () =>
okResponse([{ ...CHAT_MODEL, id: "llama3-3-70b", name: "Llama", reasoning: false }])
);
const [model] = await getTinfoilChatModels();
assert.equal(model.supportsThinking, false);
});
test("shares one in-flight request between concurrent callers", async () => {
let calls = 0;
const { getTinfoilChatModels } = loadCatalog(async () => {
calls += 1;
return okResponse([CHAT_MODEL]);
});
const [first, second] = await Promise.all([getTinfoilChatModels(), getTinfoilChatModels()]);
assert.equal(calls, 1);
assert.equal(first, second);
});
test("rejects rather than returning a list when the payload is malformed", async () => {
const { getTinfoilChatModels } = loadCatalog(async () => ({
ok: true,
status: 200,
json: async () => ({ object: "list" }),
}));
await assert.rejects(getTinfoilChatModels(), /Malformed models list/);
});
test("rejects on a non-200 response", async () => {
const { getTinfoilChatModels } = loadCatalog(async () => ({ ok: false, status: 503 }));
await assert.rejects(getTinfoilChatModels(), /503/);
});
test("backs off after a failure instead of refetching on every call", async () => {
let calls = 0;
const { getTinfoilChatModels } = loadCatalog(async () => {
calls += 1;
throw new Error("ENOTFOUND");
});
await assert.rejects(getTinfoilChatModels());
await assert.rejects(getTinfoilChatModels(), /unavailable/);
await assert.rejects(getTinfoilChatModels(), /unavailable/);
assert.equal(calls, 1);
});