@gafj/gafj 0.1.8 → 0.1.11

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/bridge/byo_key.js CHANGED
@@ -33,18 +33,48 @@ async function testProvider(homeDir, id, fetchImpl) {
33
33
  }
34
34
  }
35
35
 
36
+ /** The provider's live model list and the id the rule would pick; the stored choice, if any, is echoed so the form can show it. */
37
+ async function listModels(homeDir, id, fetchImpl) {
38
+ const p = providerById(homeDir, id);
39
+ const adapter = providers.adapterFor(p.kind);
40
+ try {
41
+ const models = await adapter.listModels({ provider: p, fetch: fetchImpl });
42
+ return { ok: true, models, suggested: providers.pickDefault(models.map((m) => m.id)), chosen: p.model || null };
43
+ } catch (e) {
44
+ return { ok: false, message: scrub(e.message, p.api_key), models: [], suggested: null, chosen: p.model || null };
45
+ }
46
+ }
47
+
36
48
  /** Run one packet through the provider; returns what ingest needs plus attempt metadata. */
49
+ const autoCache = new Map(); // provider id -> { model, at }
50
+ async function autoModel(p, fetchImpl) {
51
+ const hit = autoCache.get(p.id);
52
+ if (hit && Date.now() - hit.at < 3600 * 1000) return hit.model;
53
+ try {
54
+ const models = await providers.adapterFor(p.kind).listModels({ provider: p, fetch: fetchImpl });
55
+ const model = providers.pickDefault(models.map((m) => m.id));
56
+ if (model) { autoCache.set(p.id, { model, at: Date.now() }); return model; }
57
+ } catch (_) { /* the adapter's own default stands */ }
58
+ return null;
59
+ }
60
+
61
+ /** Reasoning depth per operation: quoting and classifying need little; the writing operations keep the provider's default. */
62
+ const EFFORT = { extract: "low", categories: "low", discover: "medium" };
63
+ const MAX_OUT = { extract: 32000, discover: 12000 };
64
+
37
65
  async function runPacket(homeDir, id, { packet, fetchImpl }) {
38
- const p = providerById(homeDir, id);
66
+ const stored = providerById(homeDir, id);
67
+ const p = stored.model ? stored : { ...stored, model: (await autoModel(stored, fetchImpl)) || "" };
39
68
  const adapter = providers.adapterFor(p.kind);
40
69
  const { renderPasteText } = require("./paste");
41
70
  const started = Date.now();
42
71
  try {
43
- const r = await adapter.complete({ provider: p, system: "You are a careful writing engine. Reply with exactly one fenced json block matching OUTPUT SCHEMA and nothing else.", user: renderPasteText(packet), max_tokens: 16000, fetch: fetchImpl });
72
+ const op = packet.operation || packet.op;
73
+ const r = await adapter.complete({ provider: p, system: "You are a careful writing engine. Reply with exactly one fenced json block matching OUTPUT SCHEMA and nothing else.", user: renderPasteText(packet), max_tokens: MAX_OUT[op] || 16000, effort: EFFORT[op], fetch: fetchImpl });
44
74
  return { content: r.text, provider: p.kind, model: r.model || p.model, tokens_in: r.tokens_in, tokens_out: r.tokens_out, latency_ms: Date.now() - started, stop_reason: r.stop_reason || null };
45
75
  } catch (e) {
46
76
  throw Object.assign(new Error(scrub(e.message, p.api_key)), { status: 502 });
47
77
  }
48
78
  }
49
79
 
50
- module.exports = { testProvider, runPacket, providerById };
80
+ module.exports = { testProvider, runPacket, listModels, providerById };
@@ -1,16 +1,32 @@
1
1
  "use strict";
2
2
 
3
3
  const DEFAULT_URL = "https://api.anthropic.com";
4
- const DEFAULT_MODEL = "claude-fable-5-1";
4
+ const DEFAULT_MODEL = "claude-sonnet-5";
5
5
 
6
- async function complete({ provider, system, user, max_tokens = 4000, fetch }) {
6
+ /**
7
+ * Claude 5 models reason before they answer, and that reasoning is billed and counted against
8
+ * max_tokens (on Fable it cannot be switched off). A quoting job does not need it, so the caller
9
+ * passes effort ("low" for extraction) and it goes out as output_config.effort; the thinking
10
+ * parameter itself is never sent, which is the setting every current model accepts.
11
+ */
12
+ async function complete({ provider, system, user, max_tokens = 4000, effort, fetch }) {
7
13
  const { post } = require("./index");
8
14
  const base = (provider.base_url || DEFAULT_URL).replace(/\/$/, "");
9
15
  const model = provider.model || DEFAULT_MODEL;
10
- const data = await post(fetch, `${base}/v1/messages`, { "x-api-key": provider.api_key || "", "anthropic-version": "2023-06-01" },
11
- { model, max_tokens, system, messages: [{ role: "user", content: user }] });
16
+ const body = { model, max_tokens, system, messages: [{ role: "user", content: user }] };
17
+ if (effort) body.output_config = { effort };
18
+ const data = await post(fetch, `${base}/v1/messages`, { "x-api-key": provider.api_key || "", "anthropic-version": "2023-06-01" }, body);
19
+ if (data.stop_reason === "refusal") throw new Error(`the model declined this request${data.stop_details && data.stop_details.category ? ` (${data.stop_details.category})` : ""}`);
12
20
  const text = (data.content || []).filter((c) => c.type === "text").map((c) => c.text).join("\n");
13
21
  return { text, model: data.model || model, tokens_in: data.usage && data.usage.input_tokens, tokens_out: data.usage && data.usage.output_tokens, stop_reason: data.stop_reason || null };
14
22
  }
15
23
 
16
- module.exports = { complete, DEFAULT_URL, DEFAULT_MODEL };
24
+ /** The live model list, newest first as the API orders it; ids only. */
25
+ async function listModels({ provider, fetch }) {
26
+ const { get } = require("./index");
27
+ const base = (provider.base_url || DEFAULT_URL).replace(/\/$/, "");
28
+ const data = await get(fetch, `${base}/v1/models?limit=100`, { "x-api-key": provider.api_key || "", "anthropic-version": "2023-06-01" });
29
+ return (data.data || []).map((m) => ({ id: m.id, name: m.display_name || m.id, created: m.created_at || null }));
30
+ }
31
+
32
+ module.exports = { complete, listModels, DEFAULT_URL, DEFAULT_MODEL };
@@ -12,4 +12,12 @@ async function complete({ provider, system, user, max_tokens = 4000, fetch }) {
12
12
  return { text, model, tokens_in: data.usageMetadata && data.usageMetadata.promptTokenCount, tokens_out: data.usageMetadata && data.usageMetadata.candidatesTokenCount };
13
13
  }
14
14
 
15
- module.exports = { complete, DEFAULT_URL };
15
+ async function listModels({ provider, fetch }) {
16
+ const { get } = require("./index");
17
+ const base = (provider.base_url || DEFAULT_URL).replace(/\/$/, "");
18
+ const data = await get(fetch, `${base}/models?pageSize=100`, { "x-goog-api-key": provider.api_key || "" });
19
+ return (data.models || []).filter((m) => (m.supportedGenerationMethods || []).includes("generateContent"))
20
+ .map((m) => ({ id: String(m.name || "").replace(/^models\//, ""), name: m.displayName || m.name, created: null }));
21
+ }
22
+
23
+ module.exports = { complete, listModels, DEFAULT_URL };
@@ -37,4 +37,30 @@ async function post(fetchImpl, url, headers, body, timeoutMs = 600000) {
37
37
  } finally { clearTimeout(t); }
38
38
  }
39
39
 
40
- module.exports = { adapterFor, post, checkUrl, ADAPTERS };
40
+ /** GET with the same rules as post: no redirects, https unless loopback, a short limit. */
41
+ async function get(fetchImpl, url, headers, timeoutMs = 20000) {
42
+ const f = fetchImpl || globalThis.fetch;
43
+ const ac = new AbortController();
44
+ const t = setTimeout(() => ac.abort(), timeoutMs);
45
+ try {
46
+ const r = await f(checkUrl(url), { method: "GET", headers, redirect: "error", signal: ac.signal });
47
+ const text = await r.text();
48
+ let data;
49
+ try { data = JSON.parse(text); } catch (_) { data = { raw: text.slice(0, 300) }; }
50
+ if (!r.ok) throw new Error(`${r.status} from provider: ${(data.error && (data.error.message || data.error)) || data.raw || r.statusText}`.slice(0, 300));
51
+ return data;
52
+ } finally { clearTimeout(t); }
53
+ }
54
+
55
+ /**
56
+ * The default model is chosen from the live list by rule, not by a name frozen in code: the
57
+ * newest mid-tier model first (sonnet, flash, mini), then the small one, then the first listed.
58
+ * A provider's own "recommended" flag or ordering wins where the API gives one.
59
+ */
60
+ const PREFER = [/sonnet/i, /flash(?!-lite)/i, /-mini\b|\bmini\b/i, /haiku/i, /gpt-\d/i, /pro/i];
61
+ function pickDefault(ids) {
62
+ for (const re of PREFER) { const hit = ids.find((id) => re.test(id)); if (hit) return hit; }
63
+ return ids[0] || null;
64
+ }
65
+
66
+ module.exports = { adapterFor, post, get, checkUrl, pickDefault, ADAPTERS };
@@ -14,4 +14,13 @@ async function complete({ provider, system, user, max_tokens = 4000, fetch }) {
14
14
  return { text, model: data.model || model, tokens_in: data.usage && data.usage.prompt_tokens, tokens_out: data.usage && data.usage.completion_tokens };
15
15
  }
16
16
 
17
- module.exports = { complete, DEFAULT_URL };
17
+ async function listModels({ provider, fetch }) {
18
+ const { get } = require("./index");
19
+ const base = (provider.base_url || DEFAULT_URL).replace(/\/$/, "");
20
+ const headers = provider.api_key ? { authorization: `Bearer ${provider.api_key}` } : {};
21
+ const data = await get(fetch, `${base}/models`, headers);
22
+ return (data.data || []).map((m) => ({ id: m.id, name: m.id, created: m.created ? new Date(m.created * 1000).toISOString() : null }))
23
+ .sort((a, b) => String(b.created || "").localeCompare(String(a.created || "")));
24
+ }
25
+
26
+ module.exports = { complete, listModels, DEFAULT_URL };
package/http/api.js CHANGED
@@ -154,9 +154,14 @@ route("POST", "/api/kb/questions/:id/dismiss", (c, p) => discover.dismissQuestio
154
154
 
155
155
  // usage: tokens per operation from the attempts the app recorded; the pricing test reads this
156
156
  route("GET", "/api/usage", (c) => ({
157
- by_op: c.db.prepare(`SELECT r.operation AS op, count(*) AS attempts, sum(CASE WHEN a.result = 'passed' THEN 1 ELSE 0 END) AS passed,
157
+ by_op: c.db.prepare(`SELECT r.operation AS op, count(*) AS attempts,
158
+ sum(CASE WHEN a.result = 'passed' THEN 1 ELSE 0 END) AS passed, sum(CASE WHEN a.result <> 'passed' THEN 1 ELSE 0 END) AS failed,
159
+ coalesce(sum(CASE WHEN a.result = 'passed' THEN a.tokens_in END), 0) AS in_passed, coalesce(sum(CASE WHEN a.result = 'passed' THEN a.tokens_out END), 0) AS out_passed,
160
+ coalesce(sum(CASE WHEN a.result <> 'passed' THEN a.tokens_in END), 0) AS in_failed, coalesce(sum(CASE WHEN a.result <> 'passed' THEN a.tokens_out END), 0) AS out_failed,
158
161
  coalesce(sum(a.tokens_in), 0) AS tokens_in, coalesce(sum(a.tokens_out), 0) AS tokens_out, coalesce(avg(a.latency_ms), 0) AS avg_ms, max(a.model) AS model
159
162
  FROM ai_attempt a JOIN ai_run r ON r.id = a.ai_run_id WHERE r.candidate_id = ? AND a.tokens_in IS NOT NULL GROUP BY r.operation ORDER BY r.operation`).all(c.candidate_id),
163
+ // calls that never returned (a timeout, a network drop) recorded no tokens here but were still billed by the provider
164
+ unrecorded: c.db.prepare("SELECT count(*) c FROM ai_run r WHERE r.candidate_id = ? AND r.route IN ('byo_key', 'credits') AND NOT EXISTS (SELECT 1 FROM ai_attempt a WHERE a.ai_run_id = r.id)").get(c.candidate_id).c,
160
165
  since: c.db.prepare("SELECT min(a.started_at) AS s FROM ai_attempt a JOIN ai_run r ON r.id = a.ai_run_id WHERE r.candidate_id = ? AND a.tokens_in IS NOT NULL").get(c.candidate_id).s,
161
166
  }));
162
167
 
@@ -179,6 +184,10 @@ route("POST", "/api/settings/providers/:id/test", async (c, p) => {
179
184
  settings.recordTest(c.home, p.id, { ok: r.ok, at: c.now(), message: r.message });
180
185
  return r;
181
186
  });
187
+ route("GET", "/api/settings/providers/:id/models", async (c, p) => {
188
+ const { listModels } = require("../bridge/byo_key");
189
+ return listModels(c.home, p.id, c.fetch);
190
+ });
182
191
  route("GET", "/api/mcp-config", (c) => {
183
192
  const cli = path.resolve(__dirname, "..", "bin", "cli.js");
184
193
  return { desktop: { mcpServers: { gafj: { command: process.execPath, args: [cli, "mcp"], env: { GAFJ_HOME: c.home } } } }, code: `claude mcp add gafj -e GAFJ_HOME=${JSON.stringify(c.home)} -- ${JSON.stringify(process.execPath)} ${JSON.stringify(cli)} mcp` };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gafj/gafj",
3
- "version": "0.1.8",
3
+ "version": "0.1.11",
4
4
  "description": "GAF-J: a local campaign engine for job search. One SQLite file, one ingest door, your own AI subscription.",
5
5
  "homepage": "https://gaf-j.com",
6
6
  "repository": {
@@ -5,7 +5,7 @@ import { api, useInstall } from "../lib.js";
5
5
 
6
6
  const KINDS = ["resume", "cover", "onepager", "email", "prep", "deep_answers", "practice", "cheatsheet"];
7
7
  const KIND_LABEL = { anthropic: "Anthropic (Claude)", openai_compatible: "OpenAI, or any OpenAI-compatible server", gemini: "Google Gemini" };
8
- const KIND_MODEL = { anthropic: "claude-fable-5-1" };
8
+ const KIND_MODEL = { anthropic: "claude-sonnet-5" };
9
9
 
10
10
  export function Settings({ live }) {
11
11
  const [s, setS] = useState(null);
@@ -14,6 +14,7 @@ export function Settings({ live }) {
14
14
  const [prov, setProv] = useState(null);
15
15
  const [mcp, setMcp] = useState(null);
16
16
  const [usage, setUsage] = useState(null);
17
+ const [models, setModels] = useState({});
17
18
  const inst = useInstall();
18
19
  const load = () => Promise.all([api.get("/api/settings"), api.get("/api/usage").catch(() => null)]).then(([x, u]) => { setS(x); setUsage(u); setErr(""); }).catch((e) => setErr(e.message));
19
20
  useEffect(() => { load(); }, [live]);
@@ -48,7 +49,12 @@ export function Settings({ live }) {
48
49
  ${s.providers.length && !s.providers.some((p) => p.id === s.active_provider_id) ? html`<div class="block warn"><b>No provider is switched on.</b> Turn one on below, or nothing can run. Exactly one is on at a time.</div>` : ""}
49
50
  <ul class="list">${s.providers.map((p) => html`<li key=${p.id}>
50
51
  <label class="switch" title=${s.active_provider_id === p.id ? "on: this provider runs your documents" : "off"}><input type="checkbox" checked=${s.active_provider_id === p.id} onChange=${(e) => put({ active_provider_id: e.target.checked ? p.id : null })} /><span class="track"></span><span class="small">${s.active_provider_id === p.id ? "on" : "off"}</span></label>
51
- <b>${p.label}</b> <span class="tag">${p.kind}</span> <span class="muted">${p.model || ""}</span> <span class="mono small">${p.key || "no key"}</span> <span class="muted small">${p.base_url || ""}</span>
52
+ <b>${p.label}</b> <span class="tag">${p.kind}</span> <span class="mono small">${p.key || "no key"}</span> <span class="muted small">${p.base_url || ""}</span>
53
+ ${models[p.id] ? html`<select onChange=${async (e) => { await api.put(`/api/settings/providers/${p.id}`, { kind: p.kind, label: p.label, model: e.target.value === "__auto" ? "" : e.target.value, base_url: p.base_url || "" }); load(); }}>
54
+ <option value="__auto" selected=${!p.model}>auto: ${models[p.id].suggested || "provider default"}</option>
55
+ ${models[p.id].models.map((m) => html`<option key=${m.id} value=${m.id} selected=${p.model === m.id}>${m.id}${m.id === models[p.id].suggested ? " (suggested)" : ""}</option>`)}
56
+ </select>${models[p.id].ok ? "" : html`<span class="small error">${models[p.id].message}</span>`}`
57
+ : html`<span class="muted">${p.model || "model: auto"}</span> <button class="small" onClick=${async () => setModels({ ...models, [p.id]: await api.get(`/api/settings/providers/${p.id}/models`) })}>choose model</button>`}
52
58
  ${p.last_test ? html`<span class=${"tag " + (p.last_test.ok ? "passed" : "blocked")}>${p.last_test.ok ? "ok" : "failed"}</span>` : ""}
53
59
  <button class="small" onClick=${() => post(`/api/settings/providers/${p.id}/test`, {}, "test")}>test</button>
54
60
  <button class="small" onClick=${() => setProv({ id: p.id, kind: p.kind, label: p.label, model: p.model || "", base_url: p.base_url || "", api_key: "" })}>edit</button>
@@ -73,7 +79,7 @@ export function Settings({ live }) {
73
79
  <input placeholder=${"model, default " + (KIND_MODEL[prov.kind] || "the provider's current default")} value=${prov.model} onInput=${(e) => setProv({ ...prov, model: e.target.value })} />
74
80
  ${prov.kind === "openai_compatible" ? html`<input placeholder="base url, blank for api.openai.com; http only on localhost" value=${prov.base_url} onInput=${(e) => setProv({ ...prov, base_url: e.target.value })} />` : ""}
75
81
  </details></form>` : html`<button class="small" onClick=${() => setProv({ id: null, kind: "anthropic", label: "", model: "", base_url: "", api_key: "" })}>+ add</button>`}
76
- <p class="small muted">Keys live in config.json under your user only, never in the store, never in a log. A read shows the last four characters.</p>
82
+ <p class="small muted">Keys live in config.json under your user only, never in the store, never in a log. A read shows the last four characters. "auto" asks the provider for its current models and picks the newest mid-tier one, so a new model is used the day it exists; pick one by name to pin it.</p>
77
83
  </div>`}
78
84
  <h2>Credits</h2>
79
85
  <div class="card">
@@ -92,10 +98,14 @@ export function Settings({ live }) {
92
98
  </div>
93
99
  <h2>Usage</h2>
94
100
  <div class="card">
95
- ${usage && usage.by_op.length ? html`<table class="cats"><thead><tr><th>operation</th><th>runs</th><th>passed</th><th>tokens in</th><th>tokens out</th><th>avg seconds</th><th>model</th></tr></thead><tbody>
96
- ${usage.by_op.map((u) => html`<tr key=${u.op}><td>${u.op}</td><td>${u.attempts}</td><td>${u.passed}</td><td>${u.tokens_in.toLocaleString()}</td><td>${u.tokens_out.toLocaleString()}</td><td>${(u.avg_ms / 1000).toFixed(1)}</td><td class="small muted">${u.model || ""}</td></tr>`)}
97
- <tr><td><b>total</b></td><td>${usage.by_op.reduce((n, u) => n + u.attempts, 0)}</td><td>${usage.by_op.reduce((n, u) => n + u.passed, 0)}</td><td><b>${usage.by_op.reduce((n, u) => n + u.tokens_in, 0).toLocaleString()}</b></td><td><b>${usage.by_op.reduce((n, u) => n + u.tokens_out, 0).toLocaleString()}</b></td><td></td><td></td></tr>
98
- </tbody></table><p class="small muted">Counted from every run through your own provider or credits since ${usage.since ? new Date(usage.since).toLocaleDateString() : "the start"}. Paste runs are not counted; the app never sees those token counts. Multiply by your provider's price per million tokens for the cost.</p>`
101
+ ${usage && usage.by_op.length ? html`<table class="cats"><thead><tr><th>operation</th><th>result</th><th>runs</th><th>tokens in</th><th>tokens out</th><th>model</th></tr></thead><tbody>
102
+ ${usage.by_op.flatMap((u) => [
103
+ html`<tr key=${u.op + "p"}><td rowspan="2"><b>${u.op}</b><div class="small muted">${(u.avg_ms / 1000).toFixed(0)} s avg</div></td><td><span class="tag passed">passed</span></td><td>${u.passed}</td><td>${u.in_passed.toLocaleString()}</td><td>${u.out_passed.toLocaleString()}</td><td rowspan="2" class="small muted">${u.model || ""}</td></tr>`,
104
+ html`<tr key=${u.op + "f"}><td><span class="tag blocked">failed</span></td><td>${u.failed}</td><td>${u.in_failed.toLocaleString()}</td><td>${u.out_failed.toLocaleString()}</td></tr>`,
105
+ ])}
106
+ <tr><td><b>total</b></td><td></td><td>${usage.by_op.reduce((n, u) => n + u.attempts, 0)}</td><td><b>${usage.by_op.reduce((n, u) => n + u.tokens_in, 0).toLocaleString()}</b></td><td><b>${usage.by_op.reduce((n, u) => n + u.tokens_out, 0).toLocaleString()}</b></td><td></td></tr>
107
+ <tr><td colspan="2"><b>of which wasted on failures</b></td><td>${usage.by_op.reduce((n, u) => n + u.failed, 0)}</td><td>${usage.by_op.reduce((n, u) => n + u.in_failed, 0).toLocaleString()}</td><td>${usage.by_op.reduce((n, u) => n + u.out_failed, 0).toLocaleString()}</td><td></td></tr>
108
+ </tbody></table><p class="small muted">Counted from every run through your own provider or credits since ${usage.since ? new Date(usage.since).toLocaleDateString() : "the start"}. "Failed" is a reply that came back but did not pass the gate. ${usage.unrecorded ? `${usage.unrecorded} call${usage.unrecorded === 1 ? "" : "s"} never returned (a timeout or a dropped connection): no tokens recorded here, but the provider still billed the input.` : ""} Paste runs are not counted. Multiply by your provider's price per million tokens for the cost.</p>`
99
109
  : html`<p class="small muted">Nothing counted yet. Runs through your own provider key or credits record their token counts here.</p>`}
100
110
  </div>
101
111
  <h2>Bullet register</h2>