@gafj/gafj 0.1.9 → 0.1.13
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 +33 -3
- package/bridge/providers/anthropic.js +20 -4
- package/bridge/providers/gemini.js +9 -1
- package/bridge/providers/index.js +27 -1
- package/bridge/providers/openai_compatible.js +10 -1
- package/http/api.js +5 -0
- package/package.json +1 -1
- package/store/onboarding.js +19 -2
- package/ui/app.css +4 -0
- package/ui/screens/kb.js +12 -4
- package/ui/screens/settings.js +8 -2
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
|
|
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
|
|
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 };
|
|
@@ -3,14 +3,30 @@
|
|
|
3
3
|
const DEFAULT_URL = "https://api.anthropic.com";
|
|
4
4
|
const DEFAULT_MODEL = "claude-sonnet-5";
|
|
5
5
|
|
|
6
|
-
|
|
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
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -184,6 +184,10 @@ route("POST", "/api/settings/providers/:id/test", async (c, p) => {
|
|
|
184
184
|
settings.recordTest(c.home, p.id, { ok: r.ok, at: c.now(), message: r.message });
|
|
185
185
|
return r;
|
|
186
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
|
+
});
|
|
187
191
|
route("GET", "/api/mcp-config", (c) => {
|
|
188
192
|
const cli = path.resolve(__dirname, "..", "bin", "cli.js");
|
|
189
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` };
|
|
@@ -226,6 +230,7 @@ route("POST", "/api/run/:op", async (c, p, b, q) => {
|
|
|
226
230
|
}
|
|
227
231
|
const r = saveReply(c.db, { run_id: opened.run_id, content: reply.content, route: routeName, actor: ACTOR("run"), now: c.now(), provider: reply.provider, model: reply.model, tokens_in: reply.tokens_in, tokens_out: reply.tokens_out, latency_ms: reply.latency_ms });
|
|
228
232
|
if (r.status !== "passed" && reply.stop_reason === "max_tokens") r.errors = [...(r.errors || []), "the reply was cut off at the token limit"];
|
|
233
|
+
Object.assign(r, { tokens_in: reply.tokens_in ?? null, tokens_out: reply.tokens_out ?? null, latency_ms: reply.latency_ms ?? null, model: reply.model || null });
|
|
229
234
|
return reply.credits_left === undefined ? r : { ...r, charged: reply.charged, credits_left: reply.credits_left };
|
|
230
235
|
});
|
|
231
236
|
|
package/package.json
CHANGED
package/store/onboarding.js
CHANGED
|
@@ -96,11 +96,28 @@ function sourceText(db, source_document_id) {
|
|
|
96
96
|
|
|
97
97
|
// ----- propose_from_source -----
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Excerpt or nothing, with the counting done here: a model quotes reliably but cannot count
|
|
101
|
+
* characters, so a span that does not match is relocated to where the quoted text actually
|
|
102
|
+
* sits in the source (the occurrence nearest the claimed start; whitespace runs may differ).
|
|
103
|
+
* A value that appears nowhere in the source, byte for byte and case for case, is refused.
|
|
104
|
+
*/
|
|
99
105
|
function checkAtom(atom, text, label, errors) {
|
|
100
106
|
if (!atom) return;
|
|
101
107
|
const { value, start, end } = atom;
|
|
102
|
-
if (!Number.isInteger(start) || !Number.isInteger(end)
|
|
103
|
-
if (
|
|
108
|
+
if (!Number.isInteger(start) || !Number.isInteger(end)) { errors.push(`${label}: span [${start}, ${end}) is not inside the source`); return; }
|
|
109
|
+
if (start >= 0 && end <= text.length && end > start && text.slice(start, end) === value) return;
|
|
110
|
+
const v = String(value);
|
|
111
|
+
const near = Number.isInteger(start) ? Math.max(0, start) : 0;
|
|
112
|
+
let hits = [];
|
|
113
|
+
if (v.length) for (let i = text.indexOf(v); i !== -1; i = text.indexOf(v, i + 1)) hits.push([i, i + v.length]);
|
|
114
|
+
if (!hits.length && v.trim()) {
|
|
115
|
+
const re = new RegExp(v.trim().split(/\s+/).map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+"), "g");
|
|
116
|
+
for (let m = re.exec(text); m; m = re.exec(text)) { hits.push([m.index, m.index + m[0].length]); if (!m[0].length) break; }
|
|
117
|
+
}
|
|
118
|
+
if (!hits.length) { errors.push(`${label}: "${v.slice(0, 40)}" is not the source text at [${start}, ${end})`); return; }
|
|
119
|
+
const [s, e] = hits.reduce((best, h) => (Math.abs(h[0] - near) < Math.abs(best[0] - near) ? h : best));
|
|
120
|
+
atom.start = s; atom.end = e; atom.value = text.slice(s, e);
|
|
104
121
|
}
|
|
105
122
|
|
|
106
123
|
/** Validate a draft against its source: every atom cited and verbatim, else the whole draft is rejected. */
|
package/ui/app.css
CHANGED
|
@@ -182,3 +182,7 @@ mark { background: var(--good-bg); color: var(--good-bright); border-radius: 3px
|
|
|
182
182
|
.switch input:checked + .track { background: var(--accent); }
|
|
183
183
|
.switch input:checked + .track::after { left: 18px; }
|
|
184
184
|
.switch input:focus-visible + .track { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
185
|
+
/* a small spinner beside anything that is with the model */
|
|
186
|
+
.spin { display: inline-block; width: 10px; height: 10px; border: 2px solid var(--line-2); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; vertical-align: -1px; }
|
|
187
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
188
|
+
@media (prefers-reduced-motion: reduce) { .spin { animation: none; border-top-color: var(--line-2); } }
|
package/ui/screens/kb.js
CHANGED
|
@@ -15,13 +15,21 @@ export function Onboarding({ d, reload, setErr, route, compact }) {
|
|
|
15
15
|
const b = d.batch;
|
|
16
16
|
const direct = route === "byo_key" || route === "credits";
|
|
17
17
|
// with a provider or credits the extraction runs here; otherwise the packet opens for paste
|
|
18
|
+
// a ticking clock while a file is with the model, so a long call is visibly alive
|
|
19
|
+
const [, tick] = useState(0);
|
|
20
|
+
useEffect(() => { const t = setInterval(() => tick((n) => n + 1), 1000); return () => clearInterval(t); }, []);
|
|
21
|
+
const since = {};
|
|
22
|
+
const elapsed = (ms) => { const sec = Math.round(ms / 1000); return `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, "0")}`; };
|
|
18
23
|
const runOne = async (s) => {
|
|
19
|
-
|
|
24
|
+
const started = Date.now();
|
|
25
|
+
setRunning((r) => ({ ...r, [s.source_document_id]: { started } }));
|
|
20
26
|
try {
|
|
21
27
|
const r = await api.post(`/api/run/extract?source_document_id=${s.source_document_id}`, {});
|
|
22
|
-
|
|
28
|
+
const took = `${elapsed(Date.now() - started)}${r.tokens_in ? ` · ${(r.tokens_in + (r.tokens_out || 0)).toLocaleString()} tokens` : ""}`;
|
|
29
|
+
setRunning((x) => ({ ...x, [s.source_document_id]: r.status === "passed" ? `${r.pending_ids.length} records proposed in ${took}` : `refused after ${took}: ${(r.errors || []).slice(0, 2).join("; ")}` }));
|
|
23
30
|
} catch (e) { setRunning((x) => ({ ...x, [s.source_document_id]: "failed: " + e.message })); }
|
|
24
31
|
};
|
|
32
|
+
const isRunning = (id) => running[id] && typeof running[id] === "object";
|
|
25
33
|
const extract = async (s) => {
|
|
26
34
|
if (!direct) { setModal({ source: s }); return; }
|
|
27
35
|
await runOne(s);
|
|
@@ -57,9 +65,9 @@ export function Onboarding({ d, reload, setErr, route, compact }) {
|
|
|
57
65
|
${b ? html`<ul class="list">${b.sources.map((s) => html`<li key=${s.source_document_id}><b>${s.filename}</b> <span class="tag">${s.kind}</span>
|
|
58
66
|
${s.empty ? html`<span class="tag blocked">no text</span>` : html`<span class="muted small">${s.chars} chars</span>`}
|
|
59
67
|
<span class="muted small">${s.pending} pending, ${s.runs} runs</span>
|
|
60
|
-
${!s.empty ? html`<button class="small" disabled=${
|
|
68
|
+
${!s.empty ? html`<button class="small" disabled=${isRunning(s.source_document_id)} onClick=${() => extract(s)}>${isRunning(s.source_document_id) ? html`<span class="spin"></span> extracting · ${elapsed(Date.now() - running[s.source_document_id].started)}` : direct ? "Extract" : "Extract (paste)"}</button>` : ""}
|
|
61
69
|
<button class="small danger" title="remove this file and anything proposed from it; confirmed records stay" onClick=${async () => { if (!confirm(`Remove ${s.filename}? Records already confirmed stay; pending rows from this file go.`)) return; try { await api.del(`/api/sources/${s.source_document_id}`); await reload(); } catch (e) { setErr(e.message); } }}>remove</button>
|
|
62
|
-
${running[s.source_document_id] &&
|
|
70
|
+
${running[s.source_document_id] && !isRunning(s.source_document_id) ? html`<span class="small muted">${running[s.source_document_id]}</span>` : ""}</li>`)}
|
|
63
71
|
${b.sources.length ? "" : html`<li class="muted">upload a resume to start</li>`}</ul>` : ""}
|
|
64
72
|
${b && b.pending.length ? html`<div class="row"><button onClick=${() => post(`/api/onboarding/batches/${b.batch_id}/dedup`)}>Suggest duplicates</button><span class="small muted">same employer, overlapping dates, a shared figure; nothing merges without you</span></div>` : ""}
|
|
65
73
|
${groups.map((g) => html`<div class="block warn" key=${g.id}><b>These may be the same accomplishment</b>
|
package/ui/screens/settings.js
CHANGED
|
@@ -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="
|
|
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">
|