FreeLattice · Celeste reading home

Public, free to read, reuse, and adapt — Kirk Patrick Miller's notice for this edition. Not a patent grant.

FLINT_NEXT_TREE_GATHERING_API_KEYS_v0.1.txt · c05

44d8662586 · words 1200 · characters 40772–49917

Previous section · Next section

Exact section text · Full source · Source index

Archive pages whose extracted text matched this section: 1421, 1422, 1423. Unmatched pages stay on the source record with the checked page method. They are not guessed into a section.

No topic tag was earned for this page under the recorded evidence rule.

@@ -740,6 +788,8 @@
           renderMessages(list);
           setStatus('', true);
         }
+        // v-tree-gathering-api-keys-v0: a cloud chair brings its own honest line
+        if (err && err.reason === 'cloud' && err.cloudText) { say(String(err.cloudText)); return; }
         // v-tree-honest-reasons-v0: a missing model is named; a quiet door gets one
         // no-cors knock (after this Send only) so "stopped" and "shut" are told apart.
         if (err && err.reason === 'model-missing') { say(heartModelMissing(err.model)); return; }
@@ -888,6 +938,8 @@
     unmount: unmount,
     listener: listener,
     sendToMind: sendToMind,
+    isCloudMind: isCloudMind,
+    cloudSafeContent: cloudSafeContent,
     hostIs: function (el) { return hostEl === el; },
     HISTORY_KEY: HISTORY_KEY,
     EXPORT_KIND: EXPORT_KIND,
diff --git a/docs/modules/tree-cloud-minds.js b/docs/modules/tree-cloud-minds.js
new file mode 100644
index 0000000..b666031
--- /dev/null
+++ b/docs/modules/tree-cloud-minds.js
@@ -0,0 +1,436 @@
+// ═══════════════════════════════════════════════════════════════
+// tree-cloud-minds.js · bring your own key, seat a cloud mind
+// Marker: v-tree-gathering-api-keys-v0
+//
+// Layer, never delete. Local minds stay 127.0.0.1 only. This module adds a
+// second, chosen road: a mind you pay for or got free from a provider, with
+// YOUR key, seated in a Gathering chair next to local ones, any order.
+//
+// The key stays in this browser (localStorage, or sessionStorage for "this
+// visit only"). It is sent only to the one provider you picked, in that
+// provider's own auth header, and only when that provider's chair speaks.
+// Never in a URL. Never in gatheringBinds. Never in the thread history or an
+// export. theLatticeTree has no server; nothing passes through us.
+// The provider list is fixed (https only). No custom hosts in v0.1.
+// No confirm(). No innerHTML. Every word goes in with textContent.
+// Mirror of FreeLattice docs/app.html PROVIDERS (fc2f09f) for urls and shapes.
+// ═══════════════════════════════════════════════════════════════
+
+(function () {
+  'use strict';
+
+  var MARK = 'v-tree-gathering-api-keys-v0';
+  var KEYS_STORE = 'fl_tree_cloud_keys';   // { v:1, keys: { providerId: key } }
+  var SEATS_STORE = 'fl_tree_cloud_seats'; // [ { provider, model } ]  (no keys here, ever)
+  var SEAT_CAP = 16;
+  var TIMEOUT_MS = 120000;
+
+  // type: 'openai' (Bearer, /chat/completions), 'anthropic', 'google'
+  var PROVIDERS = {
+    groq: { name: 'Groq', type: 'openai', url: 'https://api.groq.com/openai/v1/chat/completions',
+      keyUrl: 'https://console.groq.com/keys', models: ['llama-3.3-70b-versatile', 'qwen/qwen3-32b'] },
+    openrouter: { name: 'OpenRouter', type: 'openai', url: 'https://openrouter.ai/api/v1/chat/completions',
+      keyUrl: 'https://openrouter.ai/keys',
+      models: ['meta-llama/llama-3.3-70b-instruct', 'qwen/qwen-2.5-72b-instruct', 'deepseek/deepseek-r1'] },
+    openai: { name: 'OpenAI', type: 'openai', url: 'https://api.openai.com/v1/chat/completions',
+      keyUrl: 'https://platform.openai.com/api-keys', models: ['gpt-4.1-mini', 'gpt-4.1', 'gpt-4.1-nano', 'o4-mini'] },
+    anthropic: { name: 'Anthropic (Claude)', type: 'anthropic', url: 'https://api.anthropic.com/v1/messages',
+      keyUrl: 'https://console.anthropic.com/settings/keys',
+      models: ['claude-sonnet-4-20250514', 'claude-haiku-4-5-20251001', 'claude-opus-4-20250514'] },
+    google: { name: 'Google (Gemini)', type: 'google', url: 'https://generativelanguage.googleapis.com/v1beta/models/',
+      keyUrl: 'https://aistudio.google.com/apikey', models: ['gemini-2.5-flash', 'gemini-2.5-pro'] },
+    xai: { name: 'xAI (Grok)', type: 'openai', url: 'https://api.x.ai/v1/chat/completions',
+      keyUrl: 'https://console.x.ai', models: ['grok-3-mini-fast'] },
+    mistral: { name: 'Mistral AI', type: 'openai', url: 'https://api.mistral.ai/v1/chat/completions',
+      keyUrl: 'https://console.mistral.ai/api-keys',
+      models: ['mistral-large-latest', 'mistral-small-latest', 'codestral-latest'] },
+    deepseek: { name: 'DeepSeek', type: 'openai', url: 'https://api.deepseek.com/v1/chat/completions',
+      keyUrl: 'https://platform.deepseek.com/api_keys', models: ['deepseek-chat', 'deepseek-reasoner'] },
+    together: { name: 'Together AI', type: 'openai', url: 'https://api.together.xyz/v1/chat/completions',
+      keyUrl: 'https://api.together.xyz/settings/api-keys',
+      models: ['meta-llama/Llama-3.3-70B-Instruct-Turbo', 'Qwen/Qwen2.5-7B-Instruct-Turbo', 'deepseek-ai/DeepSeek-R1'] }
+  };
+  var ORDER = ['groq', 'openrouter', 'openai', 'anthropic', 'google', 'xai', 'mistral', 'deepseek', 'together'];
+
+  var MODEL_RE = /^[A-Za-z0-9._:\/@+-]{1,120}$/;
+
+  function store(kind) {
+    try { return kind === 'session' ? window.sessionStorage : window.localStorage; } catch (e) { return null; }
+  }
+  function readJson(s, name, fallback) {
+    try {
+      var raw = s && s.getItem(name);
+      if (!raw) return fallback;
+      var v = JSON.parse(raw);
+      return v == null ? fallback : v;
+    } catch (e) { return fallback; }
+  }
+  function writeJson(s, name, value) {
+    try { if (s) s.setItem(name, JSON.stringify(value)); return true; } catch (e) { return false; }
+  }
+  function keyBag(kind) {
+    var bag = readJson(store(kind), KEYS_STORE, null);
+    if (!bag || typeof bag !== 'object' || !bag.keys || typeof bag.keys !== 'object') bag = { v: 1, keys: {} };
+    return bag;
+  }
+
+  function provider(id) {
+    return Object.prototype.hasOwnProperty.call(PROVIDERS, String(id || '')) ? PROVIDERS[id] : null;
+  }
+  function providerIdOf(mind) {
+    var u = String((mind && mind.url) || '');
+    var m = u.match(/^cloud:([a-z0-9-]+)$/i);
+    return m ? m[1].toLowerCase() : '';
+  }
+  function isCloud(mind) { return !!provider(providerIdOf(mind)); }
+  function providerName(mindOrId) {
+    var id = typeof mindOrId === 'string' ? mindOrId : providerIdOf(mindOrId);
+    var p = provider(id);
+    return p ? p.name : 'a cloud provider';
+  }
+
+  function cleanKey(raw) {
+    var k = String(raw || '').trim();
+    if (k.length < 8 || k.length > 400 || /\s/.test(k)) return '';
+    return k;
+  }
+  function cleanModel(raw) {
+    var m = String(raw || '').trim();
+    return MODEL_RE.test(m) ? m : '';
+  }
+
+  // Where is this provider's key? 'device' | 'visit' | ''
+  function keyPlace(id) {
+    if (keyBag('session').keys[id]) return 'visit';
+    if (keyBag('local').keys[id]) return 'device';
+    return '';
+  }
+  function getKey(id) {
+    return String(keyBag('session').keys[id] || keyBag('local').keys[id] || '');
+  }
+  function keyTail(id) {
+    var k = getKey(id);
+    return k ? k.slice(-4) : '';
+  }
+  function saveKey(id, raw, onlyThisVisit) {
+    if (!provider(id)) return { ok: false, reason: 'provider' };
+    var k = cleanKey(raw);
+    if (!k) return { ok: false, reason: 'key' };
+    forgetKey(id);
+    var kind = onlyThisVisit ? 'session' : 'local';
+    var bag = keyBag(kind);
+    bag.keys[id] = k;
+    if (!writeJson(store(kind), KEYS_STORE, bag)) return { ok: false, reason: 'storage' };
+    return { ok: true, place: onlyThisVisit ? 'visit' : 'device' };
+  }
+  function forgetKey(id) {
+    ['local', 'session'].forEach(function (kind) {
+      var bag = keyBag(kind);
+      if (bag.keys[id]) { delete bag.keys[id]; writeJson(store(kind), KEYS_STORE, bag); }
+    });
+    return true;
+  }
+  function savedProviders() {
+    return ORDER.filter(function (id) { return !!getKey(id); });
+  }
+
+  function getSeats() {
+    var list = readJson(store('local'), SEATS_STORE, []);
+    if (!Array.isArray(list)) return [];
+    return list.filter(function (s) { return s && provider(s.provider) && cleanModel(s.model); }).slice(0, SEAT_CAP);
+  }
+  function addSeat(id, model) {
+    var m = cleanModel(model);
+    if (!provider(id) || !m) return null;
+    var list = getSeats().filter(function (s) { return !(s.provider === id && s.model === m); });
+    list.unshift({ provider: id, model: m });
+    writeJson(store('local'), SEATS_STORE, list.slice(0, SEAT_CAP));
+    return seatFor(id, m);
+  }
+  function seatFor(id, model) {
+    return { tag: 'general', model: String(model), url: 'cloud:' + id, cloud: true, providerName: providerName(id) };
+  }
+  // Seats for the Gathering picker: only providers whose key is in this browser. No key inside.
+  function seatOptions() {
+    return getSeats().filter(function (s) { return !!getKey(s.provider); })
+      .map(function (s) { return seatFor(s.provider, s.model); });
+  }
+
+  // ---------- speaking ----------
+  function cloudErr(text) { return { reason: 'cloud', cloudText: String(text) }; }
+
+  function buildRequest(id, model, msgs) {
+    var p = provider(id);
+    var key = getKey(id);
+    var headers = { 'Content-Type': 'application/json' };
+    var url = p.url;
+    var body;
+    var list = (msgs || []).map(function (m) {
+      return { role: m.role === 'assistant' ? 'assistant' : (m.role === 'system' ? 'system' : 'user'), content: String(m.content ||