diff --git a/docs/ai.md b/docs/ai.md
index d4757a8..3ed9ece 100644
--- a/docs/ai.md
+++ b/docs/ai.md
@@ -5,7 +5,16 @@ first; podcasts, news/blogs and projects follow (same building blocks).
## The key: the user's own, end-to-end encrypted, only on the devices
-Decided with the user: the Anthropic API key is entered in the app ("KI-Assistent", the ✦ of a
+Two providers, chosen in the settings (decided 2026-10-25): **Anthropic** (the official SDK) or
+**OpenRouter** (the same Claude model, `anthropic/claude-opus-5.5`). OpenRouter's
+Anthropic-compatible endpoint refuses the headers a browser call needs (`anthropic-version` …,
+checked by a CORS preflight), so diora talks to OpenRouter's chat completions instead: plain
+`fetch`, `response_format` json_schema (strict), `reasoning.effort`; no fallback on refusals, and
+OpenRouter sees what is sent too. The vault keeps `{provider, anthropic_key, openrouter_key}`
+(an old `{anthropic_key}` still reads as Anthropic); a key is checked before it is kept
+(Anthropic: the model's info; OpenRouter: `GET /api/v1/key`).
+
+Decided with the user: the API key is entered in the app ("KI-Assistent", the ✦ of a
feature or the app menu) and stored like the books — encrypted with the book key on the device,
kept by the server as a blob it can't read:
@@ -42,5 +51,7 @@ kept by the server as a blob it can't read:
| area | where | what Claude gets | what it suggests |
|---|---|---|---|
| Bücher, Zeitschriften | ✦ in the library's head (`static/js/app/55-library-tools.js`) | per book: id, title, author, folders, series, magazine mark; the existing folders | folders to add (existing first; new ones only for ≥ 3 books), series with volume, cleaned title/author where broken, magazine mark |
+| Podcasts | ✦ in Abos (diora-android `web-native/static/native-podcast.js`, copied to `static/podcasts/`) | per subscription: title, author, folders, level, heard in 12 months, publishing interval, three newest episode titles | folders to add (≤ 2), level Stamm / Strom / Auf Abruf (only with a clear reason) |
+| Nachrichten, Blogs | ✦ in the feeds' bar (`static/js/feeds.js`; the apps' section bar presses it) | per source: title, address, kind, Ressort or folders, recent titles shown | Ressort for news, folders for blogs, news ↔ blog when clearly wrong |
Applied through `_updateBookMeta` (the encrypted meta, as when editing by hand).
diff --git a/static/js/ai.js b/static/js/ai.js
index 994d901..e18a56f 100644
--- a/static/js/ai.js
+++ b/static/js/ai.js
@@ -1,7 +1,7 @@
// ---------------------------------------------------------------------------
// The "KI" features (docs/ai.md): Claude suggests, the user decides.
//
-// The Anthropic API key is the user's own. It lives end-to-end encrypted with the book key
+// The key (Anthropic's or OpenRouter's) is the user's own. It lives end-to-end encrypted with the book key
// (POST /accounts/vault/ai/, like the books: the server keeps a blob it can't read) and is
// decrypted on the device into memory only. The device calls Claude directly — the diora
// server sees neither the key nor what is sent; Anthropic sees what a feature needs (titles,
@@ -12,11 +12,21 @@
import Anthropic from '/static/vendor/anthropic-sdk-0.133.0.mjs';
-const MODEL = 'claude-opus-5-5';
+// Two ways to Claude, chosen in the settings (docs/ai.md):
+// - anthropic: the Anthropic API with the official SDK (structured output, fallbacks on refusal);
+// - openrouter: OpenRouter's chat completions (its Anthropic-compatible endpoint refuses the
+// headers a browser call needs), the same Claude model, structured output by response_format.
+const PROVIDERS = {
+ anthropic: {label: 'Anthropic', model: 'claude-opus-5-5', help: 'https://console.anthropic.com/settings/keys',
+ helpLabel: 'console.anthropic.com', placeholder: 'sk-ant-…'},
+ openrouter: {label: 'OpenRouter', model: 'anthropic/claude-opus-5.5', help: 'https://openrouter.ai/settings/keys',
+ helpLabel: 'openrouter.ai', placeholder: 'sk-or-…'},
+};
+const OPENROUTER = 'https://openrouter.ai/api/v1';
const VAULT = '/accounts/vault/ai/';
-const KEY_HELP = 'https://console.anthropic.com/settings/keys';
-let apiKey = null; // decrypted, in memory only
+// Decrypted, in memory only: {provider, anthropic_key, openrouter_key}.
+let vault = null;
let loaded = false;
const html = document.documentElement;
@@ -32,38 +42,45 @@ function available() {
bookKeyThere() && typeof getOrCreateEncKey === 'function';
}
-async function loadKey(fresh) {
- if (loaded && !fresh) return apiKey;
+const providerOf = v => (v && PROVIDERS[v.provider] ? v.provider : 'anthropic');
+const keyOf = v => (v ? v[providerOf(v) + '_key'] || null : null);
+
+async function loadVault(fresh) {
+ if (loaded && !fresh) return vault;
if (!available()) return null;
try {
const r = await fetch(VAULT, {cache: 'no-store'});
- if (!r.ok) return apiKey;
+ if (!r.ok) return vault;
const d = await r.json();
- apiKey = null;
+ vault = null;
if (d.ct) {
const plain = await decryptBytes(await getOrCreateEncKey(), d.iv, d.ct);
- apiKey = JSON.parse(new TextDecoder().decode(plain)).anthropic_key || null;
+ vault = JSON.parse(new TextDecoder().decode(plain)) || null;
}
loaded = true;
} catch (e) {
// Offline, or encrypted with a key this device doesn't have (the password changed): ask again.
loaded = false;
}
- return apiKey;
+ return vault;
}
-async function saveKey(key) {
- const enc = await encryptBytes(await getOrCreateEncKey(), new TextEncoder().encode(JSON.stringify({anthropic_key: key})));
+async function loadKey(fresh) {
+ return keyOf(await loadVault(fresh));
+}
+
+async function saveVault(v) {
+ const enc = await encryptBytes(await getOrCreateEncKey(), new TextEncoder().encode(JSON.stringify(v)));
const r = await fetch(VAULT, {method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ct: enc.ciphertext, iv: enc.iv})});
if (!r.ok) throw new Error('Speichern ging nicht (' + r.status + ')');
- apiKey = key;
+ vault = v;
loaded = true;
}
-async function removeKey() {
+async function removeKeys() {
await fetch(VAULT, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ct: ''})});
- apiKey = null;
+ vault = null;
loaded = true;
}
@@ -81,6 +98,7 @@ function explain(e) {
if (e instanceof Anthropic.RateLimitError) return 'Gerade zu viele Anfragen – bitte gleich noch einmal.';
if (e instanceof Anthropic.APIConnectionError) return navigator.onLine ? 'Claude war nicht erreichbar.' : 'Keine Verbindung.';
if (e instanceof Anthropic.APIError) return 'Claude antwortete mit einem Fehler (' + (e.status || '?') + ').';
+ if (e instanceof TypeError) return navigator.onLine ? 'Der KI-Anbieter war nicht erreichbar.' : 'Keine Verbindung.';
return (e && e.message) || 'Unbekannter Fehler.';
}
@@ -90,12 +108,23 @@ function fail(message) {
return e;
}
-// One request with a JSON answer of `schema` (structured outputs). Long lists stream.
+function parseAnswer(text) {
+ try {
+ return JSON.parse(text);
+ } catch (e) {
+ throw fail('Die Antwort ließ sich nicht lesen.');
+ }
+}
+
+// One request with a JSON answer of `schema` (structured outputs).
async function ask({system, prompt, schema, effort = 'low', maxTokens = 64000}) {
- const key = await loadKey();
+ const v = await loadVault();
+ const key = keyOf(v);
if (!key) throw fail('Kein API-Key hinterlegt.');
+ if (providerOf(v) === 'openrouter') return askOpenRouter(key, {system, prompt, schema, effort, maxTokens});
+ // Anthropic: streamed (long lists), a declined request re-runs on Anthropic's fallback model.
const stream = client(key).beta.messages.stream({
- model: MODEL,
+ model: PROVIDERS.anthropic.model,
max_tokens: maxTokens,
betas: ['server-side-fallback-2026-07-01'],
fallbacks: 'default',
@@ -106,12 +135,39 @@ async function ask({system, prompt, schema, effort = 'low', maxTokens = 64000})
const msg = await stream.finalMessage();
if (msg.stop_reason === 'refusal') throw fail('Claude hat diese Anfrage abgelehnt.');
if (msg.stop_reason === 'max_tokens') throw fail('Die Antwort wurde zu lang – bitte einen kleineren Teil ordnen.');
- const text = msg.content.filter(b => b.type === 'text').map(b => b.text).join('');
- try {
- return JSON.parse(text);
- } catch (e) {
- throw fail('Die Antwort ließ sich nicht lesen.');
- }
+ return parseAnswer(msg.content.filter(b => b.type === 'text').map(b => b.text).join(''));
+}
+
+async function openRouterFetch(key, path, init) {
+ const r = await fetch(OPENROUTER + path, Object.assign({}, init, {headers: Object.assign({
+ 'Authorization': 'Bearer ' + key, 'Content-Type': 'application/json', 'X-Title': 'diora',
+ }, (init && init.headers) || {})}));
+ if (r.status === 401 || r.status === 403) throw fail('Der OpenRouter-Key wird nicht angenommen – in den KI-Einstellungen prüfen.');
+ if (r.status === 402) throw fail('Das OpenRouter-Guthaben reicht nicht.');
+ if (r.status === 429) throw fail('Gerade zu viele Anfragen – bitte gleich noch einmal.');
+ if (!r.ok) throw fail('OpenRouter antwortete mit einem Fehler (' + r.status + ').');
+ return r.json();
+}
+
+async function askOpenRouter(key, {system, prompt, schema, effort, maxTokens}) {
+ const d = await openRouterFetch(key, '/chat/completions', {method: 'POST', body: JSON.stringify({
+ model: PROVIDERS.openrouter.model,
+ max_tokens: maxTokens,
+ reasoning: {effort},
+ messages: [{role: 'system', content: system}, {role: 'user', content: prompt}],
+ response_format: {type: 'json_schema', json_schema: {name: 'antwort', strict: true, schema}},
+ })});
+ const choice = d && d.choices && d.choices[0];
+ if (!choice) throw fail('Die Antwort ließ sich nicht lesen.');
+ if (choice.message && choice.message.refusal) throw fail('Claude hat diese Anfrage abgelehnt.');
+ if (choice.finish_reason === 'length') throw fail('Die Antwort wurde zu lang – bitte einen kleineren Teil ordnen.');
+ return parseAnswer((choice.message && choice.message.content) || '');
+}
+
+// Is this key accepted? (Costs nothing: Anthropic's model info, OpenRouter's key info.)
+async function checkKey(provider, key) {
+ if (provider === 'openrouter') await openRouterFetch(key, '/key', {method: 'GET'});
+ else await client(key).models.retrieve(PROVIDERS.anthropic.model);
}
// ---- a sheet of our own (settings, progress, review) --------------------------------------
@@ -139,43 +195,69 @@ function close() {
async function settings(after) {
const body = sheet('KI-Assistent', true);
- const has = !!(await loadKey(true));
- body.innerHTML = `
-
diora kann mit Claude (Anthropic) Vorschläge zum Ordnen machen – Bücher, Podcasts, Nachrichten, Projekte.
- Du entscheidest bei jedem Vorschlag, ob er übernommen wird.
-
Dein eigener API-Key: Er wird mit deinem Buchschlüssel verschlüsselt gespeichert – der diora-Server
- kann ihn nicht lesen. Deine Geräte fragen Claude direkt; Anthropic sieht dabei, was eine Funktion braucht
- (z. B. Titel und Autoren), der diora-Server nichts davon. Die Kosten rechnet Anthropic mit dir ab.
- Einen Key legst du unter console.anthropic.com an.
diora kann mit Claude Vorschläge zum Ordnen machen – Bücher, Podcasts, Nachrichten, Projekte.
+ Du entscheidest bei jedem Vorschlag, ob er übernommen wird.
Dein eigener Key, mit deinem Buchschlüssel verschlüsselt gespeichert – der diora-Server kann ihn nicht
+ lesen. Deine Geräte fragen ${esc(p.label)} direkt; ${provider === 'openrouter' ? 'OpenRouter und Anthropic sehen' : 'Anthropic sieht'}
+ dabei, was eine Funktion braucht (z. B. Titel und Autoren), der diora-Server nichts davon. Die Kosten rechnet
+ ${esc(p.label)} mit dir ab. Einen Key legst du unter ${esc(p.helpLabel)} an.