@tiagojct/subsub 0.11.3 → 0.11.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,7 +46,7 @@ The [Profiles page](https://subsub.tiagojacinto.eu/profiles/) lists the tools an
46
46
 
47
47
  - Zotero 10 or later, running, with Settings > Advanced > "Allow other applications on this computer to communicate with Zotero" on.
48
48
  - Node.js 22.19 or later, and [uv](https://docs.astral.sh/uv/). The installers add both if they are missing.
49
- - An account with a model provider that pi supports (for example OpenCode Go, OpenRouter, Anthropic, OpenAI, Mistral, Google), or a local model.
49
+ - An account with a model provider that pi supports (for example OpenCode Go, OpenRouter, Anthropic, OpenAI, Mistral, Google), or a local model. To try Sub-Sub without paying, a free Google AI Studio key is enough (see [Use Sub-Sub for free](https://subsub.tiagojacinto.eu/models/#use-sub-sub-for-free)).
50
50
  - macOS, Linux or Windows.
51
51
 
52
52
  ## Install
@@ -133,7 +133,7 @@ subsub review apply 13-31
133
133
  | `userName`, `about` | Your name and one line about you, for the replies | none |
134
134
  | `language` | Language of replies and notes, or `auto` for the language you write in | `English` |
135
135
  | `vault` | The Sub-Sub folder | from the server settings |
136
- | `models` | The model for each mode, as provider/id: `{"librarian": "...", "researcher": "..."}`. `{}` means you choose in Sub-Sub | `opencode-go/mimo-v2.6-flash` and `opencode-go/mimo-v2.6-pro` |
136
+ | `models` | The model for each mode, as provider/id: `{"librarian": "...", "researcher": "..."}`. `{}` means you choose in Sub-Sub. The free choice in `subsub init` sets `google/gemini-3.1-flash-lite` for both | `opencode-go/mimo-v2.6-flash` and `opencode-go/mimo-v2.6-pro` |
137
137
  | `defaultMode` | `researcher` or `librarian` | `researcher` |
138
138
  | `addons` | `["starbuck"]` adds reference checks | `[]` |
139
139
  | `serverDir` | A local copy of zotero-local-mcp, for development | the PyPI release |
package/dist/bench.js CHANGED
@@ -80,7 +80,10 @@ export function promptFor(task, t, code, out) {
80
80
  const KEEP = new Set(["tool_execution_start", "tool_execution_end", "message_end", "entry_appended", "agent_end", "agent_settled", "auto_retry_start", "extension_error"]);
81
81
  export function runOne(opts) {
82
82
  mkdirSync(opts.out, { recursive: true });
83
- const args = ["--mode", "rpc", "--no-session", "--provider", opts.provider ?? "opencode-go", "--model", opts.model];
83
+ // "google/gemini-x" or "openrouter/vendor/model:free" name their provider; a bare name is OpenCode Go.
84
+ const slash = opts.model.indexOf("/");
85
+ const [provider, model] = slash > 0 && !opts.provider ? [opts.model.slice(0, slash), opts.model.slice(slash + 1)] : [opts.provider ?? "opencode-go", opts.model];
86
+ const args = ["--mode", "rpc", "--no-session", "--provider", provider, "--model", model];
84
87
  if (opts.role === "librarian")
85
88
  args.push("--librarian");
86
89
  const env = { ...process.env, ...opts.extraEnv, SUBSUB_BENCH_OUT: opts.out, SUBSUB_CONFIG: opts.configFile };
@@ -558,7 +561,9 @@ export async function main(argv = process.argv.slice(2)) {
558
561
  if (!prompt)
559
562
  return;
560
563
  const cf = join(cfgDir, `${j.code}.json`);
561
- writeFileSync(cf, JSON.stringify({ ...userCfg, profile: "editor", models: { librarian: `opencode-go/${j.model}`, researcher: `opencode-go/${j.model}` }, model: undefined, libraryChanges: undefined }));
564
+ // A bare name is an OpenCode Go model; provider/id (google/gemini-..., openrouter/x/y:free) is any provider.
565
+ const spec = j.model.includes("/") ? j.model : `opencode-go/${j.model}`;
566
+ writeFileSync(cf, JSON.stringify({ ...userCfg, profile: "editor", models: { librarian: spec, researcher: spec }, model: undefined, libraryChanges: undefined }));
562
567
  const s = await runOne({ ...j, prompt, out, cwd: cfg.vault, configFile: cf, timeoutSec });
563
568
  n++;
564
569
  console.log(`${n}/${jobs.length} ${j.code} ${j.task} ${s.seconds}s ${s.error ?? (s.settled ? "done" : "?")}`);
package/dist/config.js CHANGED
@@ -31,6 +31,22 @@ export const DEFAULT_MODELS = {
31
31
  librarian: "opencode-go/mimo-v2.6-flash",
32
32
  researcher: "opencode-go/mimo-v2.6-pro",
33
33
  };
34
+ /** The tested free models (Google AI Studio free tier; see the Models page): enough to try Sub-Sub. */
35
+ export const FREE_MODELS = {
36
+ librarian: "google/gemini-3.1-flash-lite",
37
+ researcher: "google/gemini-3.1-flash-lite",
38
+ };
39
+ /**
40
+ * Models marked in the model dialog, from the model tests (see the Models page). `why` is a
41
+ * key into the web view's strings: tested (a default), cheaper (tested, same quality, lower cost),
42
+ * free (tested, works on a free tier).
43
+ */
44
+ export const RECOMMENDED_MODELS = [
45
+ { spec: DEFAULT_MODELS.librarian, modes: ["librarian"], why: "tested" },
46
+ { spec: DEFAULT_MODELS.researcher, modes: ["researcher"], why: "tested" },
47
+ { spec: "opencode-go/qwen3.8-flash", modes: ["researcher"], why: "cheaper" },
48
+ { spec: FREE_MODELS.librarian, modes: ["librarian", "researcher"], why: "free" },
49
+ ];
34
50
  export const PROFILES = ["reader", "scholar", "author", "editor"];
35
51
  /** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
36
52
  export const SERVER_VERSION = "0.5.3";
package/dist/init.js CHANGED
@@ -19,16 +19,17 @@
19
19
  * Flags (for scripts and tests): --yes (take every default), --setup
20
20
  * standard|fmup, --name, --about, --language, --folder (or --notes), --move
21
21
  * yes|no, --profile, --tags health-sciences|health-informatics|any-field,
22
- * --email, --starbuck on|off, --models keep|later|opencode-go.
22
+ * --email, --starbuck on|off, --models keep|later|opencode-go|google-free.
23
23
  */
24
24
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
25
25
  import { homedir } from "node:os";
26
26
  import { dirname, join, resolve } from "node:path";
27
27
  import { createInterface } from "node:readline/promises";
28
28
  import { fileURLToPath } from "node:url";
29
- import { configPath, DEFAULT_MODELS, expand, isProfile, loadConfig, parseEnvFile, PROFILES, readConfigFile, saveConfig, starbuckEnabled, withStarbuck, } from "./config.js";
29
+ import { configPath, DEFAULT_MODELS, FREE_MODELS, expand, isProfile, loadConfig, parseEnvFile, PROFILES, readConfigFile, saveConfig, starbuckEnabled, withStarbuck, } from "./config.js";
30
30
  import { defaultRunner, starbuckCheck } from "./doctor.js";
31
31
  import { applyMoves, FOLDER_NAME, leftBehind, NOTE_DIRS, planMoves, SETTINGS_DIR, SETTINGS_FILES, subsubFolder } from "./layout.js";
32
+ import { KEY_PROVIDERS, saveApiKey } from "./keys.js";
32
33
  import { PROFILE_SPECS } from "./profiles.js";
33
34
  const TEMPLATES = resolve(dirname(fileURLToPath(import.meta.url)), "..", "templates");
34
35
  export const STARTER_TAGS = {
@@ -166,10 +167,17 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
166
167
  ...(hadModel ? [{ value: "keep", label: `Keep: ${show(current.models)}` }] : []),
167
168
  { value: "later", label: "Choose later in Sub-Sub" },
168
169
  { value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the Librarian, ${TESTED_MODELS.researcher} for the Researcher` },
170
+ { value: "google-free", label: `Free, to try Sub-Sub: Google AI Studio, ${FREE_MODELS.researcher} in both modes (a free key, no card; limits per minute and per day; 18 or older)` },
169
171
  ];
170
172
  const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
171
173
  const modelDefault = hadModel ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
172
174
  const models = await io.choose("Models", modelOptions, flags.models ?? modelDefault);
175
+ let googleKey = "";
176
+ if (models === "google-free" && !hasProvider(agentDir, "google")) {
177
+ const url = KEY_PROVIDERS.find((p) => p.id === "google")?.url;
178
+ explain("Free Google key", `Open ${url}, sign in with a Google account, and select "Create API key". Copy the key and paste it here. Sub-Sub saves it in ${join(agentDir, "auth.json")}, readable only by you. In the EU, the UK and Switzerland, Google does not use your prompts to improve its products; elsewhere, on the free tier, it may. When a limit is reached, Sub-Sub says so.`);
179
+ googleKey = (await io.ask("Google API key (Enter to add it later with the model button or /login)", "")).trim();
180
+ }
173
181
  // ---- write
174
182
  if (move === "yes")
175
183
  applyMoves(moves);
@@ -215,7 +223,22 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
215
223
  patch.models = {};
216
224
  if (models === "opencode-go")
217
225
  patch.models = TESTED_MODELS;
226
+ if (models === "google-free")
227
+ patch.models = FREE_MODELS;
218
228
  saveConfig(patch, initEnv);
229
+ let keyNote = "";
230
+ if (googleKey) {
231
+ try {
232
+ saveApiKey(agentDir, "google", googleKey);
233
+ keyNote = `Saved: the Google key, in ${join(agentDir, "auth.json")}`;
234
+ }
235
+ catch (err) {
236
+ keyNote = `The Google key was not saved (${err.message}). Add it in Sub-Sub with the model button or /login.`;
237
+ }
238
+ }
239
+ else if (models === "google-free" && !hasProvider(agentDir, "google")) {
240
+ keyNote = "No Google key yet. Add it in Sub-Sub with the model button (subsub web) or /login (in the terminal), with the provider Google Gemini.";
241
+ }
219
242
  const zotero = await zoteroReachable(deps.fetch);
220
243
  const starbuckNote = starbuck === "on" && deps.prepareStarbuck ? deps.prepareStarbuck(loadConfig(initEnv)) : "";
221
244
  io.say("");
@@ -223,6 +246,8 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
223
246
  io.say(`Saved: ${envFile}`);
224
247
  for (const c of created)
225
248
  io.say(`Created: ${c}`);
249
+ if (keyNote)
250
+ io.say(keyNote);
226
251
  if (move === "yes")
227
252
  io.say(`Moved: ${moves.length} file(s) into ${notes}`);
228
253
  for (const d of leftBehind(from, notes))
package/dist/keys.js ADDED
@@ -0,0 +1,55 @@
1
+ /** Model providers that take an API key, and plain words for their common errors. */
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ export const KEY_PROVIDERS = [
5
+ { id: "opencode-go", label: "OpenCode Go", url: "https://opencode.ai/auth" },
6
+ { id: "openrouter", label: "OpenRouter", url: "https://openrouter.ai/keys" },
7
+ { id: "anthropic", label: "Anthropic", url: "https://console.anthropic.com/settings/keys" },
8
+ { id: "openai", label: "OpenAI", url: "https://platform.openai.com/api-keys" },
9
+ { id: "google", label: "Google Gemini", url: "https://aistudio.google.com/apikey" },
10
+ { id: "mistral", label: "Mistral", url: "https://console.mistral.ai/api-keys" },
11
+ { id: "deepseek", label: "DeepSeek", url: "https://platform.deepseek.com/api_keys" },
12
+ ];
13
+ /** Save an API key the way pi's /login does: auth.json in the agent folder, readable only by the user. */
14
+ export function saveApiKey(agentDir, provider, key) {
15
+ if (!KEY_PROVIDERS.some((p) => p.id === provider))
16
+ throw new Error(`Unknown provider: ${provider}`);
17
+ const clean = key.trim();
18
+ if (!clean || /\s/.test(clean) || clean.length > 400)
19
+ throw new Error("That does not look like an API key.");
20
+ mkdirSync(agentDir, { recursive: true });
21
+ const file = join(agentDir, "auth.json");
22
+ let data = {};
23
+ if (existsSync(file)) {
24
+ try {
25
+ data = JSON.parse(readFileSync(file, "utf8")) ?? {};
26
+ }
27
+ catch {
28
+ throw new Error(`${file} is not valid JSON; fix it or type /login in the terminal.`);
29
+ }
30
+ }
31
+ data[provider] = { type: "api_key", key: clean };
32
+ // Write through a symlink (a shared pi auth.json) rather than replacing it.
33
+ writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
34
+ }
35
+ /** What a provider's error means for the user, or undefined when it is something else. */
36
+ export function providerTrouble(text) {
37
+ if (!text)
38
+ return undefined;
39
+ if (/RESOURCE_EXHAUSTED|\b429\b|quota|rate.?limit|too many requests|usage limit/i.test(text))
40
+ return "quota";
41
+ if (/\b(503|529)\b|UNAVAILABLE|high demand|overloaded/i.test(text))
42
+ return "busy";
43
+ if (/RECITATION|SAFETY|PROHIBITED_CONTENT/.test(text))
44
+ return "blocked";
45
+ if (/\b(401|403)\b|API key not valid|invalid api key|PERMISSION_DENIED|UNAUTHENTICATED/i.test(text))
46
+ return "key";
47
+ return undefined;
48
+ }
49
+ /** The terminal's words for each kind (the web view has its own, in English and Portuguese). */
50
+ export const TROUBLE_TEXT = {
51
+ quota: "The model provider refused the request: a limit of your account was reached. Free tiers limit the text sent each minute and the requests each day. Wait a minute and ask Sub-Sub to continue; if it happens again, the daily limit is used up (it resets the next day). Or use another model (/model).",
52
+ busy: "The model provider is busy at the moment. Try again in a few minutes, or use another model (/model).",
53
+ blocked: "The model provider stopped the reply (for example, because it repeated a source word for word). Ask again in other words, or use another model (/model).",
54
+ key: "The model provider did not accept the API key. Check it, or enter a new one with /login.",
55
+ };
package/dist/subsub.js CHANGED
@@ -25,6 +25,7 @@ import { coerceArgs, loosenArrays, resolvePathArgs } from "./args.js";
25
25
  import { applyEdits, checkLiteratureNote } from "./notes.js";
26
26
  import { buildPolicy, isSecret, judgePath, normalizeToolPath, realish, within } from "./paths.js";
27
27
  import { describeArgs, formatPreview } from "./preview.js";
28
+ import { providerTrouble, TROUBLE_TEXT } from "./keys.js";
28
29
  import { headerLines, paintPreview, QUOTES, schemeFromAnsi, themeName } from "./look.js";
29
30
  import { systemAddition } from "./prompt.js";
30
31
  import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason } from "./roles.js";
@@ -298,6 +299,15 @@ export async function createSubsub(pi, deps = {}) {
298
299
  await applyMode(ctx, m, false, false);
299
300
  });
300
301
  pi.on("model_select", async (_event, ctx) => status(ctx));
302
+ // A provider error in plain words (quota, busy, blocked, key); the web view words its own.
303
+ pi.on("message_end", async (event, ctx) => {
304
+ const m = event.message;
305
+ if (webView || m.role !== "assistant" || m.stopReason !== "error")
306
+ return;
307
+ const kind = providerTrouble(m.errorMessage);
308
+ if (kind)
309
+ ctx.ui.notify(`Sub-Sub: ${TROUBLE_TEXT[kind]}`, "warning");
310
+ });
301
311
  // Prompt commands that the profile does not run (/lit for Reader, /review for Reader and Scholar).
302
312
  // The input event comes before pi expands a prompt template.
303
313
  pi.on("input", async (event, ctx) => {
package/dist/web.js CHANGED
@@ -21,8 +21,9 @@ import { homedir } from "node:os";
21
21
  import { extname, join, normalize, resolve, sep } from "node:path";
22
22
  import { StringDecoder } from "node:string_decoder";
23
23
  import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.js";
24
- import { configPath, expand, loadConfig, readConfigFile, saveConfig, withStarbuck } from "./config.js";
24
+ import { configPath, expand, loadConfig, RECOMMENDED_MODELS, readConfigFile, saveConfig, withStarbuck } from "./config.js";
25
25
  import { TESTED_MODELS } from "./init.js";
26
+ import { KEY_PROVIDERS, saveApiKey } from "./keys.js";
26
27
  import { obsidianRoot } from "./layout.js";
27
28
  import { realish, within } from "./paths.js";
28
29
  export function parseWebArgs(args) {
@@ -51,37 +52,7 @@ export function uiLanguage(language) {
51
52
  return /portug|^pt\b|^pt-/i.test(language ?? "") ? "pt" : "en";
52
53
  }
53
54
  // ---------------------------------------------------------------- providers (API keys)
54
- export const KEY_PROVIDERS = [
55
- { id: "opencode-go", label: "OpenCode Go", url: "https://opencode.ai/auth" },
56
- { id: "openrouter", label: "OpenRouter", url: "https://openrouter.ai/keys" },
57
- { id: "anthropic", label: "Anthropic", url: "https://console.anthropic.com/settings/keys" },
58
- { id: "openai", label: "OpenAI", url: "https://platform.openai.com/api-keys" },
59
- { id: "google", label: "Google Gemini", url: "https://aistudio.google.com/apikey" },
60
- { id: "mistral", label: "Mistral", url: "https://console.mistral.ai/api-keys" },
61
- { id: "deepseek", label: "DeepSeek", url: "https://platform.deepseek.com/api_keys" },
62
- ];
63
- /** Save an API key the way pi's /login does: auth.json in the agent folder, readable only by the user. */
64
- export function saveApiKey(agentDir, provider, key) {
65
- if (!KEY_PROVIDERS.some((p) => p.id === provider))
66
- throw new Error(`Unknown provider: ${provider}`);
67
- const clean = key.trim();
68
- if (!clean || /\s/.test(clean) || clean.length > 400)
69
- throw new Error("That does not look like an API key.");
70
- mkdirSync(agentDir, { recursive: true });
71
- const file = join(agentDir, "auth.json");
72
- let data = {};
73
- if (existsSync(file)) {
74
- try {
75
- data = JSON.parse(readFileSync(file, "utf8")) ?? {};
76
- }
77
- catch {
78
- throw new Error(`${file} is not valid JSON; fix it or type /login in the terminal.`);
79
- }
80
- }
81
- data[provider] = { type: "api_key", key: clean };
82
- // Write through a symlink (a shared pi auth.json) rather than replacing it.
83
- writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
84
- }
55
+ export { KEY_PROVIDERS, saveApiKey } from "./keys.js";
85
56
  /** pi as a child process in RPC mode. Strict JSONL: records end at LF only. */
86
57
  export class Agent {
87
58
  proc;
@@ -582,7 +553,7 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
582
553
  }
583
554
  case "GET /api/models": {
584
555
  const data = (await agent.request({ type: "get_available_models" }));
585
- return send(res, 200, { models: Array.isArray(data) ? data : (data.models ?? []) });
556
+ return send(res, 200, { models: Array.isArray(data) ? data : (data.models ?? []), recommended: RECOMMENDED_MODELS });
586
557
  }
587
558
  case "POST /api/model": {
588
559
  if (busy || switching)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiagojct/subsub",
3
- "version": "0.11.3",
3
+ "version": "0.11.4",
4
4
  "description": "Sub-Sub: a research assistant that works from your own Zotero library, in the browser or the terminal, built on pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/bench.ts CHANGED
@@ -134,7 +134,10 @@ export function runOne(opts: {
134
134
  provider?: string;
135
135
  }): Promise<RunSummary> {
136
136
  mkdirSync(opts.out, { recursive: true });
137
- const args = ["--mode", "rpc", "--no-session", "--provider", opts.provider ?? "opencode-go", "--model", opts.model];
137
+ // "google/gemini-x" or "openrouter/vendor/model:free" name their provider; a bare name is OpenCode Go.
138
+ const slash = opts.model.indexOf("/");
139
+ const [provider, model] = slash > 0 && !opts.provider ? [opts.model.slice(0, slash), opts.model.slice(slash + 1)] : [opts.provider ?? "opencode-go", opts.model];
140
+ const args = ["--mode", "rpc", "--no-session", "--provider", provider, "--model", model];
138
141
  if (opts.role === "librarian") args.push("--librarian");
139
142
  const env = { ...process.env, ...opts.extraEnv, SUBSUB_BENCH_OUT: opts.out, SUBSUB_CONFIG: opts.configFile };
140
143
  const proc = spawn(process.execPath, [opts.cli ?? join(PACKAGE_DIR, "bin", "subsub.js"), ...args], { cwd: opts.cwd, env });
@@ -608,7 +611,9 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
608
611
  const prompt = promptFor(j.task, t, j.code, out);
609
612
  if (!prompt) return;
610
613
  const cf = join(cfgDir, `${j.code}.json`);
611
- writeFileSync(cf, JSON.stringify({ ...userCfg, profile: "editor", models: { librarian: `opencode-go/${j.model}`, researcher: `opencode-go/${j.model}` }, model: undefined, libraryChanges: undefined }));
614
+ // A bare name is an OpenCode Go model; provider/id (google/gemini-..., openrouter/x/y:free) is any provider.
615
+ const spec = j.model.includes("/") ? j.model : `opencode-go/${j.model}`;
616
+ writeFileSync(cf, JSON.stringify({ ...userCfg, profile: "editor", models: { librarian: spec, researcher: spec }, model: undefined, libraryChanges: undefined }));
612
617
  const s = await runOne({ ...j, prompt, out, cwd: cfg.vault!, configFile: cf, timeoutSec });
613
618
  n++;
614
619
  console.log(`${n}/${jobs.length} ${j.code} ${j.task} ${s.seconds}s ${s.error ?? (s.settled ? "done" : "?")}`);
package/src/config.ts CHANGED
@@ -35,6 +35,22 @@ export const DEFAULT_MODELS: Record<Mode, string> = {
35
35
  librarian: "opencode-go/mimo-v2.6-flash",
36
36
  researcher: "opencode-go/mimo-v2.6-pro",
37
37
  };
38
+ /** The tested free models (Google AI Studio free tier; see the Models page): enough to try Sub-Sub. */
39
+ export const FREE_MODELS: Record<Mode, string> = {
40
+ librarian: "google/gemini-3.1-flash-lite",
41
+ researcher: "google/gemini-3.1-flash-lite",
42
+ };
43
+ /**
44
+ * Models marked in the model dialog, from the model tests (see the Models page). `why` is a
45
+ * key into the web view's strings: tested (a default), cheaper (tested, same quality, lower cost),
46
+ * free (tested, works on a free tier).
47
+ */
48
+ export const RECOMMENDED_MODELS: Array<{ spec: string; modes: Mode[]; why: "tested" | "cheaper" | "free" }> = [
49
+ { spec: DEFAULT_MODELS.librarian, modes: ["librarian"], why: "tested" },
50
+ { spec: DEFAULT_MODELS.researcher, modes: ["researcher"], why: "tested" },
51
+ { spec: "opencode-go/qwen3.8-flash", modes: ["researcher"], why: "cheaper" },
52
+ { spec: FREE_MODELS.librarian, modes: ["librarian", "researcher"], why: "free" },
53
+ ];
38
54
  export type Profile = "reader" | "scholar" | "author" | "editor";
39
55
  export const PROFILES: Profile[] = ["reader", "scholar", "author", "editor"];
40
56
 
package/src/init.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  * Flags (for scripts and tests): --yes (take every default), --setup
20
20
  * standard|fmup, --name, --about, --language, --folder (or --notes), --move
21
21
  * yes|no, --profile, --tags health-sciences|health-informatics|any-field,
22
- * --email, --starbuck on|off, --models keep|later|opencode-go.
22
+ * --email, --starbuck on|off, --models keep|later|opencode-go|google-free.
23
23
  */
24
24
 
25
25
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -30,6 +30,7 @@ import { fileURLToPath } from "node:url";
30
30
  import {
31
31
  configPath,
32
32
  DEFAULT_MODELS,
33
+ FREE_MODELS,
33
34
  expand,
34
35
  isProfile,
35
36
  loadConfig,
@@ -43,6 +44,7 @@ import {
43
44
  } from "./config.ts";
44
45
  import { defaultRunner, starbuckCheck } from "./doctor.ts";
45
46
  import { applyMoves, FOLDER_NAME, leftBehind, NOTE_DIRS, planMoves, SETTINGS_DIR, SETTINGS_FILES, subsubFolder } from "./layout.ts";
47
+ import { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
46
48
  import { PROFILE_SPECS } from "./profiles.ts";
47
49
 
48
50
  const TEMPLATES = resolve(dirname(fileURLToPath(import.meta.url)), "..", "templates");
@@ -238,10 +240,20 @@ export async function runInit(
238
240
  ...(hadModel ? [{ value: "keep", label: `Keep: ${show(current.models)}` }] : []),
239
241
  { value: "later", label: "Choose later in Sub-Sub" },
240
242
  { value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the Librarian, ${TESTED_MODELS.researcher} for the Researcher` },
243
+ { value: "google-free", label: `Free, to try Sub-Sub: Google AI Studio, ${FREE_MODELS.researcher} in both modes (a free key, no card; limits per minute and per day; 18 or older)` },
241
244
  ];
242
245
  const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
243
246
  const modelDefault = hadModel ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
244
247
  const models = await io.choose("Models", modelOptions, flags.models ?? modelDefault);
248
+ let googleKey = "";
249
+ if (models === "google-free" && !hasProvider(agentDir, "google")) {
250
+ const url = KEY_PROVIDERS.find((p) => p.id === "google")?.url;
251
+ explain(
252
+ "Free Google key",
253
+ `Open ${url}, sign in with a Google account, and select "Create API key". Copy the key and paste it here. Sub-Sub saves it in ${join(agentDir, "auth.json")}, readable only by you. In the EU, the UK and Switzerland, Google does not use your prompts to improve its products; elsewhere, on the free tier, it may. When a limit is reached, Sub-Sub says so.`,
254
+ );
255
+ googleKey = (await io.ask("Google API key (Enter to add it later with the model button or /login)", "")).trim();
256
+ }
245
257
 
246
258
  // ---- write
247
259
  if (move === "yes") applyMoves(moves);
@@ -285,7 +297,19 @@ export async function runInit(
285
297
  if (models === "keep") patch.models = current.models;
286
298
  if (models === "later") patch.models = {};
287
299
  if (models === "opencode-go") patch.models = TESTED_MODELS;
300
+ if (models === "google-free") patch.models = FREE_MODELS;
288
301
  saveConfig(patch, initEnv);
302
+ let keyNote = "";
303
+ if (googleKey) {
304
+ try {
305
+ saveApiKey(agentDir, "google", googleKey);
306
+ keyNote = `Saved: the Google key, in ${join(agentDir, "auth.json")}`;
307
+ } catch (err) {
308
+ keyNote = `The Google key was not saved (${(err as Error).message}). Add it in Sub-Sub with the model button or /login.`;
309
+ }
310
+ } else if (models === "google-free" && !hasProvider(agentDir, "google")) {
311
+ keyNote = "No Google key yet. Add it in Sub-Sub with the model button (subsub web) or /login (in the terminal), with the provider Google Gemini.";
312
+ }
289
313
 
290
314
  const zotero = await zoteroReachable(deps.fetch);
291
315
  const starbuckNote = starbuck === "on" && deps.prepareStarbuck ? deps.prepareStarbuck(loadConfig(initEnv)) : "";
@@ -293,6 +317,7 @@ export async function runInit(
293
317
  io.say(`Saved: ${configFile}`);
294
318
  io.say(`Saved: ${envFile}`);
295
319
  for (const c of created) io.say(`Created: ${c}`);
320
+ if (keyNote) io.say(keyNote);
296
321
  if (move === "yes") io.say(`Moved: ${moves.length} file(s) into ${notes}`);
297
322
  for (const d of leftBehind(from, notes)) io.say(`Not moved: ${d}. If Sub-Sub wrote these notes, move them into ${notes} yourself.`);
298
323
  io.say(
package/src/keys.ts ADDED
@@ -0,0 +1,55 @@
1
+ /** Model providers that take an API key, and plain words for their common errors. */
2
+
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+
6
+ export const KEY_PROVIDERS: Array<{ id: string; label: string; url: string }> = [
7
+ { id: "opencode-go", label: "OpenCode Go", url: "https://opencode.ai/auth" },
8
+ { id: "openrouter", label: "OpenRouter", url: "https://openrouter.ai/keys" },
9
+ { id: "anthropic", label: "Anthropic", url: "https://console.anthropic.com/settings/keys" },
10
+ { id: "openai", label: "OpenAI", url: "https://platform.openai.com/api-keys" },
11
+ { id: "google", label: "Google Gemini", url: "https://aistudio.google.com/apikey" },
12
+ { id: "mistral", label: "Mistral", url: "https://console.mistral.ai/api-keys" },
13
+ { id: "deepseek", label: "DeepSeek", url: "https://platform.deepseek.com/api_keys" },
14
+ ];
15
+
16
+ /** Save an API key the way pi's /login does: auth.json in the agent folder, readable only by the user. */
17
+ export function saveApiKey(agentDir: string, provider: string, key: string): void {
18
+ if (!KEY_PROVIDERS.some((p) => p.id === provider)) throw new Error(`Unknown provider: ${provider}`);
19
+ const clean = key.trim();
20
+ if (!clean || /\s/.test(clean) || clean.length > 400) throw new Error("That does not look like an API key.");
21
+ mkdirSync(agentDir, { recursive: true });
22
+ const file = join(agentDir, "auth.json");
23
+ let data: Record<string, unknown> = {};
24
+ if (existsSync(file)) {
25
+ try {
26
+ data = JSON.parse(readFileSync(file, "utf8")) ?? {};
27
+ } catch {
28
+ throw new Error(`${file} is not valid JSON; fix it or type /login in the terminal.`);
29
+ }
30
+ }
31
+ data[provider] = { type: "api_key", key: clean };
32
+ // Write through a symlink (a shared pi auth.json) rather than replacing it.
33
+ writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
34
+ }
35
+
36
+
37
+ export type Trouble = "quota" | "busy" | "blocked" | "key";
38
+
39
+ /** What a provider's error means for the user, or undefined when it is something else. */
40
+ export function providerTrouble(text: string | undefined): Trouble | undefined {
41
+ if (!text) return undefined;
42
+ if (/RESOURCE_EXHAUSTED|\b429\b|quota|rate.?limit|too many requests|usage limit/i.test(text)) return "quota";
43
+ if (/\b(503|529)\b|UNAVAILABLE|high demand|overloaded/i.test(text)) return "busy";
44
+ if (/RECITATION|SAFETY|PROHIBITED_CONTENT/.test(text)) return "blocked";
45
+ if (/\b(401|403)\b|API key not valid|invalid api key|PERMISSION_DENIED|UNAUTHENTICATED/i.test(text)) return "key";
46
+ return undefined;
47
+ }
48
+
49
+ /** The terminal's words for each kind (the web view has its own, in English and Portuguese). */
50
+ export const TROUBLE_TEXT: Record<Trouble, string> = {
51
+ quota: "The model provider refused the request: a limit of your account was reached. Free tiers limit the text sent each minute and the requests each day. Wait a minute and ask Sub-Sub to continue; if it happens again, the daily limit is used up (it resets the next day). Or use another model (/model).",
52
+ busy: "The model provider is busy at the moment. Try again in a few minutes, or use another model (/model).",
53
+ blocked: "The model provider stopped the reply (for example, because it repeated a source word for word). Ask again in other words, or use another model (/model).",
54
+ key: "The model provider did not accept the API key. Check it, or enter a new one with /login.",
55
+ };
package/src/subsub.ts CHANGED
@@ -41,6 +41,7 @@ import { coerceArgs, loosenArrays, resolvePathArgs } from "./args.ts";
41
41
  import { applyEdits, checkLiteratureNote } from "./notes.ts";
42
42
  import { buildPolicy, isSecret, judgePath, normalizeToolPath, realish, within } from "./paths.ts";
43
43
  import { describeArgs, formatPreview } from "./preview.ts";
44
+ import { providerTrouble, TROUBLE_TEXT } from "./keys.ts";
44
45
  import { headerLines, type LibraryState, paintPreview, QUOTES, type Scheme, schemeFromAnsi, themeName } from "./look.ts";
45
46
  import { systemAddition } from "./prompt.ts";
46
47
  import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason } from "./roles.ts";
@@ -324,6 +325,14 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
324
325
  });
325
326
 
326
327
  pi.on("model_select", async (_event, ctx) => status(ctx));
328
+ // A provider error in plain words (quota, busy, blocked, key); the web view words its own.
329
+ pi.on("message_end", async (event, ctx) => {
330
+ const m = event.message as { role?: string; stopReason?: string; errorMessage?: string };
331
+ if (webView || m.role !== "assistant" || m.stopReason !== "error") return;
332
+ const kind = providerTrouble(m.errorMessage);
333
+ if (kind) ctx.ui.notify(`Sub-Sub: ${TROUBLE_TEXT[kind]}`, "warning");
334
+ });
335
+
327
336
 
328
337
  // Prompt commands that the profile does not run (/lit for Reader, /review for Reader and Scholar).
329
338
  // The input event comes before pi expands a prompt template.
package/src/web.ts CHANGED
@@ -22,8 +22,9 @@ import { homedir } from "node:os";
22
22
  import { extname, join, normalize, resolve, sep } from "node:path";
23
23
  import { StringDecoder } from "node:string_decoder";
24
24
  import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.ts";
25
- import { configPath, expand, loadConfig, readConfigFile, saveConfig, type SubsubConfig, withStarbuck } from "./config.ts";
25
+ import { configPath, expand, loadConfig, RECOMMENDED_MODELS, readConfigFile, saveConfig, type SubsubConfig, withStarbuck } from "./config.ts";
26
26
  import { TESTED_MODELS } from "./init.ts";
27
+ import { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
27
28
  import { obsidianRoot } from "./layout.ts";
28
29
  import { realish, within } from "./paths.ts";
29
30
 
@@ -62,35 +63,7 @@ export function uiLanguage(language: string | undefined): "pt" | "en" {
62
63
 
63
64
  // ---------------------------------------------------------------- providers (API keys)
64
65
 
65
- export const KEY_PROVIDERS: Array<{ id: string; label: string; url: string }> = [
66
- { id: "opencode-go", label: "OpenCode Go", url: "https://opencode.ai/auth" },
67
- { id: "openrouter", label: "OpenRouter", url: "https://openrouter.ai/keys" },
68
- { id: "anthropic", label: "Anthropic", url: "https://console.anthropic.com/settings/keys" },
69
- { id: "openai", label: "OpenAI", url: "https://platform.openai.com/api-keys" },
70
- { id: "google", label: "Google Gemini", url: "https://aistudio.google.com/apikey" },
71
- { id: "mistral", label: "Mistral", url: "https://console.mistral.ai/api-keys" },
72
- { id: "deepseek", label: "DeepSeek", url: "https://platform.deepseek.com/api_keys" },
73
- ];
74
-
75
- /** Save an API key the way pi's /login does: auth.json in the agent folder, readable only by the user. */
76
- export function saveApiKey(agentDir: string, provider: string, key: string): void {
77
- if (!KEY_PROVIDERS.some((p) => p.id === provider)) throw new Error(`Unknown provider: ${provider}`);
78
- const clean = key.trim();
79
- if (!clean || /\s/.test(clean) || clean.length > 400) throw new Error("That does not look like an API key.");
80
- mkdirSync(agentDir, { recursive: true });
81
- const file = join(agentDir, "auth.json");
82
- let data: Record<string, unknown> = {};
83
- if (existsSync(file)) {
84
- try {
85
- data = JSON.parse(readFileSync(file, "utf8")) ?? {};
86
- } catch {
87
- throw new Error(`${file} is not valid JSON; fix it or type /login in the terminal.`);
88
- }
89
- }
90
- data[provider] = { type: "api_key", key: clean };
91
- // Write through a symlink (a shared pi auth.json) rather than replacing it.
92
- writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
93
- }
66
+ export { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
94
67
 
95
68
  // ---------------------------------------------------------------- pi in RPC mode
96
69
 
@@ -597,7 +570,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
597
570
  }
598
571
  case "GET /api/models": {
599
572
  const data = (await agent.request({ type: "get_available_models" })) as { models?: unknown[] } | unknown[];
600
- return send(res, 200, { models: Array.isArray(data) ? data : (data.models ?? []) });
573
+ return send(res, 200, { models: Array.isArray(data) ? data : (data.models ?? []), recommended: RECOMMENDED_MODELS });
601
574
  }
602
575
  case "POST /api/model": {
603
576
  if (busy || switching) return send(res, 409, { error: "busy" });
package/web/app.css CHANGED
@@ -631,6 +631,11 @@ body.layout-full { --measure: 72rem; }
631
631
  .note.mono { font-family: var(--mono); font-size: 0.76rem; line-height: 1.55; }
632
632
  .note pre { font-family: var(--mono); font-size: 0.74rem; white-space: pre-wrap; }
633
633
  .note button { margin-top: 0.5rem; }
634
+ .note p { margin: 0; }
635
+ .note details { margin-top: 0.4rem; color: var(--muted); }
636
+ .note details summary { cursor: pointer; font-size: 0.78rem; }
637
+ .note details pre { margin: 0.3rem 0 0; overflow-wrap: anywhere; }
638
+ .free-note { border-left: 3px solid var(--accent); padding-left: 0.6rem; }
634
639
 
635
640
  .working {
636
641
  margin-top: 1rem;
@@ -826,6 +831,14 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
826
831
  .models button:hover { background: var(--bg); }
827
832
  .models .current { color: var(--accent); font-weight: 600; background: var(--accent-soft); }
828
833
  .models .empty-models { padding: 0.65rem 0.8rem; color: var(--muted); font-size: 0.88rem; }
834
+ .model-search { width: 100%; margin-bottom: 0.5rem; }
835
+ .models { max-height: 20rem; }
836
+ .models .models-group { padding: 0.45rem 0.8rem 0.3rem; font-size: 0.78rem; font-weight: 600; color: var(--muted); background: var(--surface); }
837
+ .models button { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.25rem 0.6rem; }
838
+ .models .needs-key { opacity: 0.65; }
839
+ .models .model-name { min-width: 0; overflow-wrap: anywhere; }
840
+ .models .model-tag { font-family: var(--sans); font-size: 0.72rem; font-weight: 600; color: var(--accent); border: 1px solid currentColor; border-radius: 999px; padding: 0 0.45rem; }
841
+ .models .model-why { font-family: var(--sans); font-size: 0.75rem; color: var(--muted); }
829
842
 
830
843
  /* ---------------------------------------------------------------- narrow screens */
831
844
 
package/web/app.js CHANGED
@@ -368,6 +368,21 @@ function scrollDown(force = false) {
368
368
  if (force || near) log.scrollTop = log.scrollHeight;
369
369
  }
370
370
 
371
+ // A provider error in plain words (keep in step with providerTrouble in src/keys.ts), with the raw text below.
372
+ function troubleOf(text) {
373
+ if (/RESOURCE_EXHAUSTED|\b429\b|quota|rate.?limit|too many requests|usage limit/i.test(text)) return "quota";
374
+ if (/\b(503|529)\b|UNAVAILABLE|high demand|overloaded/i.test(text)) return "busy";
375
+ if (/RECITATION|SAFETY|PROHIBITED_CONTENT/.test(text)) return "blocked";
376
+ if (/\b(401|403)\b|API key not valid|invalid api key|PERMISSION_DENIED|UNAUTHENTICATED/i.test(text)) return "key";
377
+ return null;
378
+ }
379
+
380
+ function errorNote(text) {
381
+ const kind = troubleOf(text);
382
+ if (!kind) return el("div", { class: "note error", text });
383
+ return el("div", { class: "note error" }, el("p", { text: t(`trouble_${kind}`) }), el("details", {}, el("summary", { text: t("details") }), el("pre", { text })));
384
+ }
385
+
371
386
  function note(text, kind = "info", mono = false) {
372
387
  clearEmpty();
373
388
  const n = el("div", { class: `note ${kind}${mono ? " mono" : ""}`, text });
@@ -596,7 +611,7 @@ function renderAssistantFinal(message, msg = startAssistant()) {
596
611
  );
597
612
  msg.box.append(actions);
598
613
  }
599
- if (message.stopReason === "error" && message.errorMessage) msg.box.append(el("div", { class: "note error", text: message.errorMessage }));
614
+ if (message.stopReason === "error" && message.errorMessage) msg.box.append(errorNote(message.errorMessage));
600
615
  if (message.stopReason === "aborted") msg.box.append(el("div", { class: "note", text: t("stopped") }));
601
616
  if (!msg.box.children.length) msg.box.remove();
602
617
  scrollDown();
@@ -907,44 +922,93 @@ async function openModels() {
907
922
  const setLink = () => {
908
923
  const p = providers.find((x) => x.id === sel.value);
909
924
  link.href = p?.url ?? "#";
925
+ const free = { google: "freeGoogle", openrouter: "freeOpenRouter" }[sel.value];
926
+ $("login-free").hidden = !free;
927
+ $("login-free").textContent = free ? t(free) : "";
910
928
  };
911
929
  sel.onchange = setLink;
912
930
  setLink();
913
931
  if (!dlg.open) dlg.showModal();
914
932
  let models = [];
933
+ let recommended = [];
915
934
  try {
916
- models = (await api("/api/models")).models ?? [];
935
+ const data = await api("/api/models");
936
+ models = data.models ?? [];
937
+ recommended = data.recommended ?? [];
917
938
  } catch (err) {
918
939
  note(err.message, "error");
919
940
  }
920
- ul.replaceChildren();
921
- if (!models.length) ul.append(el("li", { class: "empty-models", text: t("noModels") }));
922
941
  const cur = state?.model ?? session?.model;
942
+ const specOf = (m) => `${m.provider}/${m.id}`;
923
943
  const isCurrent = (m) => cur && cur.provider === m.provider && cur.id === m.id;
924
- models.sort((a, b) => Number(isCurrent(b)) - Number(isCurrent(a)) || `${a.provider}/${a.id}`.localeCompare(`${b.provider}/${b.id}`));
925
- for (const m of models) {
926
- const isCur = cur && cur.provider === m.provider && cur.id === m.id;
927
- ul.append(
944
+ const available = new Set(models.map(specOf));
945
+ // Recommended for this mode first (also when the provider has no key yet), then the rest.
946
+ const mine = recommended.filter((r) => r.modes.includes(mode()));
947
+ const recSpecs = new Set(mine.map((r) => r.spec));
948
+ const others = models
949
+ .filter((m) => !recSpecs.has(specOf(m)))
950
+ .sort((a, b) => Number(isCurrent(b)) - Number(isCurrent(a)) || specOf(a).localeCompare(specOf(b)));
951
+ const choose = async (m) => {
952
+ if (busy) return note(t("busy"), "warning");
953
+ try {
954
+ await api("/api/model", { provider: m.provider, id: m.id, both: $("model-both").checked });
955
+ dlg.close();
956
+ } catch (err) {
957
+ note(err.message, "error");
958
+ }
959
+ };
960
+ const row = (m, rec) => {
961
+ const spec = specOf(m);
962
+ const can = available.has(spec);
963
+ const label = providers.find((p) => p.id === m.provider)?.label ?? m.provider;
964
+ return el(
965
+ "li",
966
+ { "data-spec": spec.toLowerCase() },
928
967
  el(
929
- "li",
930
- {},
931
- el("button", {
968
+ "button",
969
+ {
932
970
  type: "button",
933
- class: isCur ? "current" : "",
934
- text: `${m.provider}/${m.id}`,
935
- onclick: async () => {
936
- if (busy) return note(t("busy"), "warning");
937
- try {
938
- await api("/api/model", { provider: m.provider, id: m.id, both: $("model-both").checked });
939
- dlg.close();
940
- } catch (err) {
941
- note(err.message, "error");
971
+ class: [isCurrent(m) ? "current" : "", rec ? "rec" : "", can ? "" : "needs-key"].join(" ").trim(),
972
+ // Without a key, the click goes to the form below: that provider, and the key field.
973
+ onclick: () => {
974
+ if (can) return choose(m);
975
+ if (providers.some((p) => p.id === m.provider)) {
976
+ sel.value = m.provider;
977
+ setLink();
942
978
  }
979
+ $("login-key").focus();
943
980
  },
944
- }),
981
+ },
982
+ el("span", { class: "model-name", text: spec }),
983
+ rec ? el("span", { class: "model-tag", text: t(`rec_${rec.why}`) }) : null,
984
+ rec && !can ? el("span", { class: "model-why", text: t("needsKey", { x: label }) }) : null,
945
985
  ),
946
986
  );
947
- }
987
+ };
988
+ const search = $("model-search");
989
+ const render = () => {
990
+ const q = search.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
991
+ const hit = (spec) => q.every((w) => spec.toLowerCase().includes(w));
992
+ const recRows = mine.filter((r) => hit(r.spec)).map((r) => {
993
+ const i = r.spec.indexOf("/");
994
+ return row({ provider: r.spec.slice(0, i), id: r.spec.slice(i + 1) }, r);
995
+ });
996
+ const rest = others.filter((m) => hit(specOf(m))).map((m) => row(m, null));
997
+ ul.replaceChildren();
998
+ if (recRows.length) ul.append(el("li", { class: "models-group", text: t("recommended") }), ...recRows);
999
+ if (rest.length) ul.append(el("li", { class: "models-group", text: t(recRows.length ? "otherModels" : "allModels") }), ...rest);
1000
+ if (!recRows.length && !rest.length) ul.append(el("li", { class: "empty-models", text: models.length ? t("noMatch") : t("noModels") }));
1001
+ };
1002
+ search.value = "";
1003
+ search.oninput = render;
1004
+ search.onkeydown = (e) => {
1005
+ if (e.key === "Enter") {
1006
+ e.preventDefault();
1007
+ ul.querySelector("button:not(.needs-key)")?.click();
1008
+ }
1009
+ };
1010
+ render();
1011
+ search.focus();
948
1012
  }
949
1013
 
950
1014
  $("login-form")?.addEventListener("submit", async (e) => {
package/web/i18n.js CHANGED
@@ -45,6 +45,15 @@ export const STRINGS = {
45
45
  modelTitleResearcher: "Model for the Researcher",
46
46
  modelTitleLibrarian: "Model for the Librarian",
47
47
  chooseModel: "Choose a model",
48
+ searchModels: "Search, e.g. flash or openrouter free",
49
+ recommended: "Recommended for this mode",
50
+ otherModels: "Other models",
51
+ allModels: "All models",
52
+ noMatch: "No model matches.",
53
+ rec_tested: "tested default",
54
+ rec_cheaper: "tested, cheaper",
55
+ rec_free: "tested, free",
56
+ needsKey: "needs a key from {x}",
48
57
  noModels: "No models yet. Connect a provider below.",
49
58
  connectProvider: "Connect a provider",
50
59
  provider: "Provider",
@@ -52,6 +61,13 @@ export const STRINGS = {
52
61
  save: "Save",
53
62
  getKey: "Get a key",
54
63
  keyNote: "The key is saved on this computer, in Sub-Sub's settings folder, and is sent only to that provider.",
64
+ freeGoogle: "Free to try: a Google AI Studio key needs no card. The free tier limits the text sent each minute and the requests each day, enough to try Sub-Sub, and gemini-3.1-flash-lite is the tested free model. You must be 18 or older. In the EU, the UK and Switzerland, Google does not use your prompts to improve its products; elsewhere, on the free tier, it may.",
65
+ freeOpenRouter: "Free to try: models whose names end in :free cost nothing, with 50 requests a day (1000 after you buy 10 dollars of credit). The providers of some free models may keep your prompts.",
66
+ trouble_quota: "The model provider refused the request: a limit of your account was reached. Free tiers limit the text sent each minute and the requests each day. Wait a minute and ask Sub-Sub to continue; if it happens again, the daily limit is used up (it resets the next day). Or choose another model with the model button.",
67
+ trouble_busy: "The model provider is busy at the moment. Try again in a few minutes, or choose another model with the model button.",
68
+ trouble_blocked: "The model provider stopped the reply (for example, because it repeated a source word for word). Ask again in other words, or choose another model.",
69
+ trouble_key: "The model provider did not accept the API key. Enter it again with the model button.",
70
+ details: "Details",
55
71
  oauthNote: "To sign in through a provider's website (for example with a subscription), open a terminal, type subsub, then /login.",
56
72
  saving: "Saving",
57
73
  saved: "Saved.",
@@ -140,6 +156,15 @@ export const STRINGS = {
140
156
  modelTitleResearcher: "Modelo do Investigador",
141
157
  modelTitleLibrarian: "Modelo do Bibliotecário",
142
158
  chooseModel: "Escolha um modelo",
159
+ searchModels: "Pesquisar, por exemplo flash ou openrouter free",
160
+ recommended: "Recomendados para este modo",
161
+ otherModels: "Outros modelos",
162
+ allModels: "Todos os modelos",
163
+ noMatch: "Nenhum modelo corresponde.",
164
+ rec_tested: "testado, por omissão",
165
+ rec_cheaper: "testado, mais barato",
166
+ rec_free: "testado, gratuito",
167
+ needsKey: "precisa de uma chave de {x}",
143
168
  noModels: "Ainda não há modelos. Ligue um fornecedor abaixo.",
144
169
  connectProvider: "Ligar um fornecedor",
145
170
  provider: "Fornecedor",
@@ -147,6 +172,13 @@ export const STRINGS = {
147
172
  save: "Guardar",
148
173
  getKey: "Obter uma chave",
149
174
  keyNote: "A chave fica guardada neste computador, na pasta de definições do Sub-Sub, e só é enviada a esse fornecedor.",
175
+ freeGoogle: "Para experimentar sem pagar: uma chave do Google AI Studio não pede cartão. O nível gratuito limita o texto enviado por minuto e os pedidos por dia, o que chega para experimentar o Sub-Sub; o modelo gratuito testado é o gemini-3.1-flash-lite. Tem de ter 18 anos ou mais. Na UE, no Reino Unido e na Suíça, a Google não usa os seus pedidos para melhorar os produtos; noutros países, no nível gratuito, pode usar.",
176
+ freeOpenRouter: "Para experimentar sem pagar: os modelos cujo nome termina em :free não custam nada, com 50 pedidos por dia (1000 depois de comprar 10 dólares de crédito). Os fornecedores de alguns modelos gratuitos podem guardar os seus pedidos.",
177
+ trouble_quota: "O fornecedor do modelo recusou o pedido: atingiu um limite da sua conta. Os níveis gratuitos limitam o texto enviado por minuto e os pedidos por dia. Espere um minuto e peça ao Sub-Sub para continuar; se voltar a acontecer, esgotou o limite diário (renova-se no dia seguinte). Ou escolha outro modelo no botão do modelo.",
178
+ trouble_busy: "O fornecedor do modelo está sobrecarregado neste momento. Tente outra vez dentro de alguns minutos ou escolha outro modelo no botão do modelo.",
179
+ trouble_blocked: "O fornecedor do modelo interrompeu a resposta (por exemplo, por repetir uma fonte palavra por palavra). Peça outra vez por outras palavras ou escolha outro modelo.",
180
+ trouble_key: "O fornecedor do modelo não aceitou a chave da API. Volte a introduzi-la no botão do modelo.",
181
+ details: "Detalhes",
150
182
  oauthNote: "Para entrar através do site de um fornecedor (por exemplo, com uma subscrição), abra um terminal, escreva subsub e depois /login.",
151
183
  saving: "A guardar",
152
184
  saved: "Guardado.",
package/web/index.html CHANGED
@@ -111,6 +111,7 @@
111
111
  </div>
112
112
  <h3 data-i18n="chooseModel">Choose a model</h3>
113
113
  <label class="check"><input type="checkbox" id="model-both"> <span data-i18n="forBoth">Use it in both modes</span></label>
114
+ <input type="search" id="model-search" class="model-search" data-i18n-placeholder="searchModels" placeholder="Search models" autocomplete="off" spellcheck="false">
114
115
  <ul class="models" id="models"></ul>
115
116
  <h3 data-i18n="connectProvider">Connect a provider</h3>
116
117
  <form id="login-form" class="login">
@@ -121,6 +122,7 @@
121
122
  <input type="password" id="login-key" autocomplete="off" spellcheck="false" required>
122
123
  </label>
123
124
  <p class="small"><a id="login-link" href="#" target="_blank" rel="noopener noreferrer" data-i18n="getKey">Get a key</a></p>
125
+ <p class="small free-note" id="login-free" hidden></p>
124
126
  <p class="small muted" data-i18n="keyNote"></p>
125
127
  <div class="modal-buttons"><button type="submit" class="primary" id="login-save" data-i18n="save">Save</button></div>
126
128
  <p class="small muted" data-i18n="oauthNote"></p>