From 6db57532456c336afa0e9a3075d529bfa70ce946 Mon Sep 17 00:00:00 2001 From: Cato <41178744+catomean@users.noreply.github.com> Date: Sat, 26 Sep 2026 07:55:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(model):=20bring=20your=20own=20key=20throu?= =?UTF-8?q?gh=20ai-kit=20=E2=80=94=20every=20vendor,=20the=20key's=20own?= =?UTF-8?q?=20models,=20no=20paid=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the Loki session's fleet-wide request (loki#921 is the reference): - Vendors come from @bitbaum/ai-kit BYOK_VENDORS (^1.17.0): ten instead of Heidi's own five, Anthropic and Gemini included; byokChain delegates to ai-kit's (dailyTokens pinned to 0 — Heidi's free chain shares provider ids). - /api/model/check uses probeByokKey: no tokens spent (it made a real completion), the key's own model list with the strongest suggested, the vendor's words on failure (redacted again here), 'unreachable' kept apart from 'wrong key'. - The sheet: paste -> auto-check (600 ms) -> a select of the key's models, strongest preselected and marked; free text only when no list. The key stays in this browser only. - No per-vendor 'text only' guess: ai-kit routes images on observed capability. - Also: 'no streaks' was still claimed in 7 languages (landing pillar, method claim) after streaks shipped — reworded to what is true. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/[locale]/_components/model-sheet.tsx | 273 ++++++++++++++--------- app/[locale]/_components/use-byok.ts | 13 +- app/api/model/check/route.test.ts | 65 ++++++ app/api/model/check/route.ts | 82 +++---- lib/config/changelog.ts | 26 +++ lib/domain/model/byok.test.ts | 16 +- lib/domain/model/byok.ts | 33 +-- lib/domain/model/providers.ts | 96 +++----- lib/i18n/dictionaries/de.ts | 16 +- lib/i18n/dictionaries/en.ts | 16 +- lib/i18n/dictionaries/fr.ts | 16 +- lib/i18n/dictionaries/gsw.ts | 16 +- lib/i18n/dictionaries/it.ts | 16 +- lib/i18n/dictionaries/rm.ts | 16 +- lib/i18n/dictionaries/ru.ts | 16 +- lib/i18n/i18n.test.ts | 12 - package.json | 2 +- pnpm-lock.yaml | 10 +- 18 files changed, 422 insertions(+), 318 deletions(-) create mode 100644 app/api/model/check/route.test.ts diff --git a/app/[locale]/_components/model-sheet.tsx b/app/[locale]/_components/model-sheet.tsx index ea018c1..ccbd73f 100644 --- a/app/[locale]/_components/model-sheet.tsx +++ b/app/[locale]/_components/model-sheet.tsx @@ -7,6 +7,7 @@ import type { ByokConfig } from "@/lib/domain/model/byok"; import { BYOK_PROVIDERS, findProvider, type ProviderId } from "@/lib/domain/model/providers"; import type { Dictionary } from "@/lib/i18n"; + /** * Connect your own model. * @@ -22,6 +23,18 @@ import type { Dictionary } from "@/lib/i18n"; * 2. It does not hide where the key goes. That is stated plainly above the * field, not linked from a footer. */ +type Probe = { provider: ProviderId; key: string; n: number }; +type Result = { + state: "idle" | "checking" | "ok" | "error"; + models: string[]; + suggested: string | null; + message: string; + reachable: boolean; +}; +const IDLE: Result = { state: "idle", models: [], suggested: null, message: "", reachable: true }; +/** Long enough to finish pasting, short enough to feel immediate. */ +const CHECK_AFTER_MS = 600; + export function ModelSheet({ t, current, @@ -37,67 +50,90 @@ export function ModelSheet({ }) { const [providerId, setProviderId] = useState(current?.provider ?? "openrouter"); const [key, setKey] = useState(current?.key ?? ""); - const [state, setState] = useState<"idle" | "testing" | "ok" | "error">("idle"); - const [detail, setDetail] = useState(""); + const [model, setModel] = useState(current?.model ?? ""); + const [result, setResult] = useState(IDLE); + const [probe, setProbe] = useState(null); + const [saved, setSaved] = useState(false); + const timer = useRef | undefined>(undefined); + const seq = useRef(0); const dialogRef = useRef(null); const provider = findProvider(providerId); - /** - * The model is DERIVED, not synced. Only what the person typed is state; the - * default comes from the chosen provider — its vision model first, since - * reading a picture is why most people are here. - * - * The obvious version is an effect that writes the default into state when - * the provider changes, and it causes a cascading render for a value that - * was always computable. Deriving it also makes "changed provider" reset the - * choice by construction rather than by remembering to clear it. - */ - const [typedModel, setTypedModel] = useState(current?.model ?? null); - const model = typedModel ?? provider?.visionModel ?? provider?.textModel ?? ""; + useDismiss({ open: true, onDismiss: onClose, containerRef: dialogRef, onPointerOutside: false }); + + useEffect(() => { + dialogRef.current?.focus(); + }, []); /** - * Escape closes it. No outside-pointer dismissal: this sheet holds a key the - * person is part-way through typing, and losing it to a stray click on the - * backdrop would mean fetching the credential again. - * - * No `focusRef` either — the control that opened this sheet is frequently - * gone by the time it closes (the attach button belongs to a composer that - * may have been replaced), so focus goes to the dialog on open and the - * closing path lets the caller decide. + * Ask the server to check a key — debounced, from the event that changed + * it. A key already saved is checked once on open, so the model list is + * there without the reader doing anything. */ - useDismiss({ open: true, onDismiss: onClose, containerRef: dialogRef, onPointerOutside: false }); + function schedule(nextProvider: ProviderId, nextKey: string, delay = CHECK_AFTER_MS) { + clearTimeout(timer.current); + setSaved(false); + if (nextKey.trim().length < 8) { + setResult(IDLE); + return; + } + timer.current = setTimeout(() => { + setResult({ ...IDLE, state: "checking" }); + seq.current += 1; + setProbe({ provider: nextProvider, key: nextKey.trim(), n: seq.current }); + }, delay); + } useEffect(() => { - dialogRef.current?.focus(); + if (current) { + const id = setTimeout(() => schedule(current.provider, current.key, 0), 0); + return () => clearTimeout(id); + } + // Once, on open: `current` is the saved config at the moment the sheet opened. + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - async function test() { + useEffect(() => () => clearTimeout(timer.current), []); + + useEffect(() => { + if (!probe) return; + let live = true; + fetch("/api/model/check", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: probe.provider, key: probe.key }), + }) + .then((res) => res.json() as Promise & { ok: boolean }>) + .then((data) => { + if (!live || probe.n !== seq.current) return; + setResult({ + state: data.ok ? "ok" : "error", + models: data.models ?? [], + suggested: data.suggested ?? null, + message: data.message ?? "", + reachable: data.reachable !== false, + }); + // Keep the reader's saved choice when this key can still use it; + // otherwise preselect the strongest model the key reaches. + if (data.ok) { + setModel((m) => ((data.models ?? []).includes(m) ? m : (data.suggested ?? data.models?.[0] ?? m))); + } + }) + .catch(() => live && setResult({ ...IDLE, state: "error", reachable: false })); + return () => { + live = false; + }; + }, [probe]); + + function use() { if (!provider || !key.trim() || !model.trim()) return; - setState("testing"); - setDetail(""); - const config: ByokConfig = { provider: provider.id, key: key.trim(), model: model.trim() }; - try { - const res = await fetch("/api/model/check", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ byok: config }), - }); - const data = await res.json(); - if (data.ok) { - setState("ok"); - setDetail(data.model ?? model); - onSave(config); - } else { - setState("error"); - setDetail(String(data.reason ?? "")); - } - } catch { - setState("error"); - setDetail(""); - } + onSave({ provider: provider.id, key: key.trim(), model: model.trim() }); + setSaved(true); } + const listed = result.state === "ok" && result.models.length > 0; + return (
{t.connected} - + {t.connectedWith} {current.model}

@@ -142,16 +178,13 @@ export function ModelSheet({ id="byok-provider" value={providerId} onChange={(e) => { - setProviderId(e.target.value as ProviderId); - setTypedModel(null); - setState("idle"); + const next = e.target.value as ProviderId; + setProviderId(next); + setModel(""); + schedule(next, key); }} className="mt-1 min-h-11 w-full rounded-control border border-border-strong bg-surface-page px-3 text-base text-fg-primary" > - {/* Label only. The `note` on each provider is English source - copy for maintainers, and putting it here leaked English onto - a German page — the localised badge below already carries the - one fact that decides the choice. */} {BYOK_PROVIDERS.map((p) => (
@@ -187,46 +215,76 @@ export function ModelSheet({ value={key} onChange={(e) => { setKey(e.target.value); - setState("idle"); + schedule(providerId, e.target.value); }} - placeholder={provider?.keyPrefix ? `${provider.keyPrefix}…` : t.keyPlaceholder} + placeholder={provider?.keyHint || t.keyPlaceholder} className="mt-1 min-h-11 w-full rounded-control border border-border-strong bg-surface-page px-3 font-mono text-sm text-fg-primary" /> +

+ {result.state === "checking" ? t.testing : result.state === "idle" ? t.pasteHint : null} +

-
- - { - setTypedModel(e.target.value); - setState("idle"); - }} - spellCheck={false} - className="mt-1 min-h-11 w-full rounded-control border border-border-strong bg-surface-page px-3 font-mono text-sm text-fg-primary" - /> -
+ {result.state === "ok" && ( +
+ + {listed ? ( + + ) : ( + // Only when the vendor answered but listed nothing: then the + // reader types the id, with the vendor's own example as a hint. + { + setModel(e.target.value); + setSaved(false); + }} + placeholder={provider?.modelExample} + spellCheck={false} + className="mt-1 min-h-11 w-full rounded-control border border-border-strong bg-surface-page px-3 font-mono text-sm text-fg-primary" + /> + )} +
+ )}
- + {result.state === "ok" && ( + + )} {current && (
- {state === "ok" && ( -

- {t.connected} · {detail} + {saved && ( +

+ {t.connected} · {model}

)} - {/* The server's own `reason` used to be appended here. Those strings - are written once, in English, for a log — and this sheet is read in - seven languages. The localised sentence stands alone; what actually - went wrong is in the server log, where a reader cannot act on it - anyway. (`detail` on success is a MODEL ID, not prose.) */} - {state === "error" && ( -

- {t.failed} -

+ {result.state === "error" && ( +
+ {/* Our sentence first, in the reader's language; then the vendor's + own words, marked as theirs — usually the part that says what + to do ("insufficient credits"). Never our server's English. */} +

{result.reachable ? t.failed : t.unreachable}

+ {result.reachable && result.message && ( +

+ {t.vendorSaid} {result.message} +

+ )} +
)}
diff --git a/app/[locale]/_components/use-byok.ts b/app/[locale]/_components/use-byok.ts index b2c4ff8..389a9e3 100644 --- a/app/[locale]/_components/use-byok.ts +++ b/app/[locale]/_components/use-byok.ts @@ -58,19 +58,18 @@ export function useByok() { * read pictures" — that was never true of free models, only of a chain * with no vision routing. * - * A CONNECTED key still decides for itself, and this is the case that - * keeps the check alive rather than deleting it: a brought key REPLACES - * the free chain (see `respondInThread`), so someone connected to Groq or - * DeepSeek — text-only, both in the allowlist — genuinely cannot send a - * picture, and telling them to attach one would be a worse lie than the - * old one. + * A CONNECTED key no longer decides from a per-vendor flag. That flag + * was a guess written into a table ("Groq: text only"), and vendors add + * vision models without telling us; ai-kit routes on the model's observed + * capability. So the button is offered, and a text-only model answers + * `blind` in one sentence — the chain's answer, not this file's guess. * * Optimistic where we have no evidence, which matches what the chain does * underneath: if the deployment turns out to have no sighted vendor keyed, * the turn comes back `blind` and says so in one sentence. Refusing up * front would be this file guessing at something the chain answers. */ - canSee: config ? Boolean(provider?.visionModel) : true, + canSee: true, save, clear, }; diff --git a/app/api/model/check/route.test.ts b/app/api/model/check/route.test.ts new file mode 100644 index 0000000..1965590 --- /dev/null +++ b/app/api/model/check/route.test.ts @@ -0,0 +1,65 @@ +import { test, before, mock } from "node:test"; +import assert from "node:assert/strict"; + +/** + * The key check, without a vendor: ai-kit's `probeByokKey` is replaced so the + * test pins what THIS route does with its answer — refuse bad input before any + * call, pass the key's models and the suggestion through, keep "could not + * reach" apart from "wrong key", and never let a credential out. + */ +let lastCall: { vendor: string; key: string } | null = null; +let answer: { ok: boolean; status: number | null; message: string; models: string[]; suggested: string | null }; + +let POST: (r: Request) => Promise; + +before(async () => { + mock.module("@bitbaum/ai-kit/byok-probe", { + namedExports: { + probeByokKey: async (vendor: string, key: string) => { + lastCall = { vendor, key }; + return answer; + }, + }, + }); + ({ POST } = await import("./route.ts")); +}); + +const req = (body: unknown) => + new Request("https://heidi.test/api/model/check", { + method: "POST", + headers: { "content-type": "application/json", "x-forwarded-for": `10.0.0.${Math.floor(Math.random() * 200)}` }, + body: JSON.stringify(body), + }); + +test("an unknown vendor or a malformed key is refused before any call", async () => { + lastCall = null; + assert.equal((await POST(req({ provider: "localhost", key: "sk-EXAMPLENOTREAL" }))).status, 400); + assert.equal((await POST(req({ provider: "openai", key: "sk-EXAMPLE\nNOTREAL" }))).status, 400); + assert.equal((await POST(req({ provider: "openai", key: "" }))).status, 400); + assert.equal(lastCall, null, "no vendor was asked"); +}); + +test("a working key returns its models, best first, and the suggestion", async () => { + answer = { ok: true, status: 200, message: "ok", models: ["claude-opus-5", "claude-haiku-4-5"], suggested: "claude-opus-5" }; + const body = await (await POST(req({ provider: "anthropic", key: "sk-ant-EXAMPLENOTREAL" }))).json(); + assert.deepEqual(lastCall, { vendor: "anthropic", key: "sk-ant-EXAMPLENOTREAL" }); + assert.equal(body.ok, true); + assert.deepEqual(body.models, ["claude-opus-5", "claude-haiku-4-5"]); + assert.equal(body.suggested, "claude-opus-5"); +}); + +test("a refused key carries the vendor's words, with any key removed", async () => { + answer = { ok: false, status: 401, message: "Incorrect API key provided: sk-EXAMPLENOTAREALKEY", models: [], suggested: null }; + const body = await (await POST(req({ provider: "openai", key: "sk-EXAMPLENOTAREALKEY" }))).json(); + assert.equal(body.ok, false); + assert.equal(body.reachable, true); + assert.match(body.message, /Incorrect API key/); + assert.doesNotMatch(JSON.stringify(body), /EXAMPLENOTAREALKEY/); +}); + +test("a vendor that could not be reached is not reported as a wrong key", async () => { + answer = { ok: false, status: null, message: "could not reach", models: [], suggested: null }; + const body = await (await POST(req({ provider: "groq", key: "gsk_EXAMPLENOTREAL" }))).json(); + assert.equal(body.ok, false); + assert.equal(body.reachable, false); +}); diff --git a/app/api/model/check/route.ts b/app/api/model/check/route.ts index f1988f1..32e2b90 100644 --- a/app/api/model/check/route.ts +++ b/app/api/model/check/route.ts @@ -1,26 +1,28 @@ -import { complete } from "@bitbaum/ai-kit"; -import { byokChain, readByok, redact } from "@/lib/domain/model/byok"; -import { findProvider } from "@/lib/domain/model/providers"; -import { callerKey, modelCheck, tooMany } from "@/lib/domain/limits"; +import { probeByokKey } from "@bitbaum/ai-kit/byok-probe"; +import { redact } from "../../../../lib/domain/model/byok.ts"; +import { findProvider } from "../../../../lib/domain/model/providers.ts"; +import { callerKey, modelCheck, tooMany } from "../../../../lib/domain/limits.ts"; export const dynamic = "force-dynamic"; +const MAX_KEY = 400; +/** A picker, not a catalogue dump: the best-ranked first, and plenty of them. */ +const MAX_MODELS = 200; + /** - * Does this key actually work, right now? + * Does this key work, and which models can it use? * - * Saving a credential and showing a green tick without ever using it is the - * standard way to build this, and it is a lie told at the exact moment someone - * is deciding whether to trust you. ai-kit's own doctrine says it plainly: - * absence of failure is not evidence of success. So this makes one real call, - * as small as one can be, and reports what happened. + * ai-kit's `probeByokKey` asks the vendor itself — the key's own models list + * (OpenRouter: its `/key` endpoint, because its `/models` answers 200 to any + * key). It spends no tokens, which the old check here did: one real + * completion per test. It never throws, never echoes the key, and reports a + * vendor it could not reach as "could not check", not as a bad key. * - * The key is used and dropped. It is not stored, not logged, and not echoed - * back — including in the error path, which is where credentials usually leak. + * The answer carries the models, best suggestion first, so the settings sheet + * offers a choice from what this reader can actually use rather than a text + * box and a guess. */ export async function POST(request: Request) { - // Tighter than the chat: this makes a real outbound call with whatever key - // it is handed, which makes it the one endpoint here that could be used to - // test stolen credentials in bulk. const allowed = modelCheck.check(callerKey(request, "model-check")); if (!allowed.allowed) return tooMany(allowed); @@ -28,39 +30,25 @@ export async function POST(request: Request) { try { body = await request.json(); } catch { - return Response.json({ ok: false, reason: "Could not read that request." }, { status: 400 }); + return Response.json({ ok: false, message: "bad request", models: [], suggested: null }, { status: 400 }); } - const parsed = readByok((body as { byok?: unknown })?.byok); - if (!parsed.ok) return Response.json({ ok: false, reason: parsed.reason }, { status: 400 }); - - const links = byokChain(parsed.config); - if (!links) return Response.json({ ok: false, reason: "unknown provider" }, { status: 400 }); - - try { - const { text, id } = await complete({ - chain: links.chain, - env: links.env, - // Small on purpose: this is someone else's money, and one word proves the - // credential, the model id and the endpoint all line up. - maxTokens: 64, - temperature: 0, - timeoutMs: 20_000, - signal: request.signal, - messages: [{ role: "user", content: "Reply with the single word: ok" }], - }); - - return Response.json({ - ok: true, - model: id ?? parsed.config.model, - vision: Boolean(findProvider(parsed.config.provider)?.visionModel), - sample: text.trim().slice(0, 40), - }); - } catch (error) { - const message = redact(error instanceof Error ? error.message : String(error)); - console.error("[heidi/model-check]", message); - // The vendor's own words are genuinely the most useful thing here — "model - // not found", "insufficient credit" — so they are passed on, redacted. - return Response.json({ ok: false, reason: message.slice(0, 300) }, { status: 200 }); + const b = (body ?? {}) as { provider?: unknown; key?: unknown }; + const provider = typeof b.provider === "string" ? findProvider(b.provider) : undefined; + const key = typeof b.key === "string" ? b.key.trim() : ""; + if (!provider || !key || key.length > MAX_KEY || /[\r\n]/.test(key)) { + return Response.json({ ok: false, message: "bad request", models: [], suggested: null }, { status: 400 }); } + + const probe = await probeByokKey(provider.id, key); + return Response.json({ + ok: probe.ok, + // "Could not check" and "wrong key" are different answers to a reader. + reachable: probe.status !== null, + // The vendor's own words, with any credential-shaped string removed again + // on our side — ai-kit redacts, and this is the belt to its braces. + message: redact(probe.message).slice(0, 300), + models: probe.models.slice(0, MAX_MODELS), + suggested: probe.suggested, + }); } diff --git a/lib/config/changelog.ts b/lib/config/changelog.ts index aa53093..363ca44 100644 --- a/lib/config/changelog.ts +++ b/lib/config/changelog.ts @@ -35,6 +35,19 @@ import type { SectorLocale } from "./sectors.ts"; */ export const CHANGELOG: Record = { de: [ + { + date: "2026-09-26", + tag: "improvement", + title: "Das stärkste Modell, das Ihr Schlüssel kann", + summary: + "Wer einen eigenen API-Schlüssel mitbringt, fügt ihn ein — Heidi prüft ihn sofort und zeigt die Modelle, die er erreicht, das stärkste vorausgewählt.", + items: [ + "Zehn Anbieter statt fünf, neu auch Anthropic, Google Gemini, Mistral, xAI und Cerebras.", + "Die Prüfung kostet nichts mehr: Heidi fragt den Anbieter, welche Modelle der Schlüssel kann, statt eine Probeanfrage zu bezahlen.", + "Klappt es nicht, steht dort, was der Anbieter meldet — etwa ein leeres Guthaben. «Nicht erreichbar» heisst nicht «falscher Schlüssel».", + "Der Schlüssel bleibt wie bisher nur in Ihrem Browser.", + ], + }, { date: "2026-09-26", tag: "improvement", @@ -242,6 +255,19 @@ export const CHANGELOG: Record = { ], en: [ + { + date: "2026-09-26", + tag: "improvement", + title: "The strongest model your key can use", + summary: + "Bring your own API key: paste it, Heidi checks it at once and lists the models it can reach, with the strongest preselected.", + items: [ + "Ten providers instead of five, now including Anthropic, Google Gemini, Mistral, xAI and Cerebras.", + "The check no longer costs anything: Heidi asks the provider which models the key can use instead of paying for a test request.", + "When it fails, you see what the provider says — an empty balance, for instance. «Could not be reached» is not «wrong key».", + "The key stays only in your browser, as before.", + ], + }, { date: "2026-09-26", tag: "improvement", diff --git a/lib/domain/model/byok.test.ts b/lib/domain/model/byok.test.ts index 79cf305..9268b12 100644 --- a/lib/domain/model/byok.test.ts +++ b/lib/domain/model/byok.test.ts @@ -30,7 +30,8 @@ test("the endpoint is never taken from the request", () => { }); test("an unlisted provider is refused even if it looks plausible", () => { - assert.equal(readByok({ ...good, provider: "anthropic" }).ok, false); + assert.equal(readByok({ ...good, provider: "localhost" }).ok, false); + assert.equal(readByok({ ...good, provider: "evil.example" }).ok, false); assert.equal(readByok({ ...good, provider: "" }).ok, false); }); @@ -72,6 +73,8 @@ test("someone else's key claims none of our rationed capacity", () => { // dailyTokens feeds the fair-share pool. Their key, their quota — telling // the pool it has capacity it does not own produces the exact wall that // rationing exists to prevent. + // ai-kit marks the link unmetered (Infinity); Heidi's free chain shares + // provider ids with it, so the adapter pins 0. assert.equal(byokChain({ provider: "groq", key: "gsk_EXAMPLENOTREAL", model: "m" })?.chain[0].provider.dailyTokens, 0); }); @@ -79,15 +82,24 @@ test("every allowlisted provider is https and has no trailing slash", () => { for (const p of BYOK_PROVIDERS) { assert.ok(p.baseUrl.startsWith("https://"), `${p.id} is not https`); assert.ok(!p.baseUrl.endsWith("/"), `${p.id} has a trailing slash`); - assert.ok(p.textModel.length > 0, `${p.id} has no default model`); } }); +test("the list is ai-kit's, so a vendor added there reaches Heidi", () => { + // Heidi kept its own five vendors; the shared list has more, Anthropic and + // Google among them. Asserted so a local copy cannot quietly return. + const ids = BYOK_PROVIDERS.map((p) => p.id); + for (const id of ["openrouter", "openai", "anthropic", "google", "groq"]) assert.ok(ids.includes(id as never), id); +}); + test("keys are redacted before anything is logged", () => { // A vendor error can echo the request, and the request carried a credential. assert.match(redact("bad key sk-EXAMPLENOTAREALKEY rejected"), //); assert.match(redact("gsk_EXAMPLENOTAREALKEY is invalid"), //); assert.match(redact("sk-or-EXAMPLENOTAREALKEY expired"), //); + assert.match(redact("sk-ant-EXAMPLENOTAREALKEY expired"), //); + assert.match(redact("key AIzaEXAMPLENOTAREALKEY0123456789 invalid"), //); + assert.match(redact("xai-EXAMPLENOTAREALKEY invalid"), //); assert.doesNotMatch(redact("bad key sk-EXAMPLENOTAREALKEY"), /EXAMPLENOTAREALKEY/); }); diff --git a/lib/domain/model/byok.ts b/lib/domain/model/byok.ts index 52a99fe..88aa7f9 100644 --- a/lib/domain/model/byok.ts +++ b/lib/domain/model/byok.ts @@ -40,6 +40,7 @@ * ready to make. */ +import { byokChain as kitChain } from "@bitbaum/ai-kit/byok"; import { findProvider, type ProviderId } from "./providers.ts"; export type ByokConfig = { @@ -92,28 +93,18 @@ export function readByok(raw: unknown): ByokCheck { * cannot see, and bill our budget for the privilege. Better to fail and say so. */ export function byokChain(config: ByokConfig) { - const provider = findProvider(config.provider); - if (!provider) return null; - return { - env: { HEIDI_BYOK_KEY: config.key }, - chain: [ - { - provider: { - id: provider.id, - baseUrl: provider.baseUrl, - keyEnv: "HEIDI_BYOK_KEY", - models: [config.model], - // Their key, their quota. Nothing of ours is being rationed, so the - // fair-share pool must not think it has capacity it does not own. - dailyTokens: 0, - }, - model: config.model, - }, - ], - }; + if (!findProvider(config.provider)) return null; + // ai-kit builds the link: the host from its allowlist, the key in an env + // var. It marks the link unmetered (`Infinity`); Heidi's own free chain has + // providers with the SAME ids (groq, openrouter…), so an unmetered entry + // could read as capacity in our pool. Their key claims none of it: 0. + const { chain, env } = kitChain({ vendor: config.provider, apiKey: config.key, model: config.model }); + return { chain: chain.map((link) => ({ ...link, provider: { ...link.provider, dailyTokens: 0 } })), env }; } -/** Never let a key reach a log line, an error body, or a bug report. */ export function redact(text: string): string { - return text.replace(/\b(sk-[A-Za-z0-9-_]{8,}|gsk_[A-Za-z0-9]{8,}|sk-or-[A-Za-z0-9-_]{8,})\b/g, ""); + return text.replace( + /\b(sk-[A-Za-z0-9-_]{8,}|gsk_[A-Za-z0-9]{8,}|sk-or-[A-Za-z0-9-_]{8,}|xai-[A-Za-z0-9-_]{8,}|csk-[A-Za-z0-9-_]{8,}|AIza[0-9A-Za-z-_]{20,})\b/g, + "", + ); } diff --git a/lib/domain/model/providers.ts b/lib/domain/model/providers.ts index be83fee..9cf9cc2 100644 --- a/lib/domain/model/providers.ts +++ b/lib/domain/model/providers.ts @@ -16,7 +16,22 @@ * recommended choice: one key, and Claude, Gemini and GPT are all behind it. */ -export type ProviderId = "openrouter" | "openai" | "groq" | "deepseek" | "together"; +import { BYOK_VENDORS, type ByokVendorId } from "@bitbaum/ai-kit/byok"; + +/** + * WHICH VENDORS A KEY MAY BE SENT TO — ai-kit's list, not ours. + * + * Heidi kept its own five-vendor table (no Anthropic, no Gemini) while + * ai-kit's `BYOK_VENDORS` became the one answer every app shares: which hosts + * a server may send a stranger's key to (the SSRF allowlist), where to get a + * key, and how to check one. A vendor added there reaches Heidi with a version + * bump. This file only adapts the names Heidi's code already uses. + * + * No per-vendor "can read pictures" flag and no default model: ai-kit routes + * images on the model's observed capability, and the model is picked from the + * list the reader's own key can reach (`/api/model/check`), not guessed here. + */ +export type ProviderId = ByokVendorId; export type ByokProvider = { id: ProviderId; @@ -25,76 +40,21 @@ export type ByokProvider = { baseUrl: string; /** Where the person goes to create a key. */ keysUrl: string; - /** Shape hint used for a cheap client-side sanity check, never for auth. */ - keyPrefix?: string; - /** A sensible default model that can read images, when the vendor has one. */ - visionModel?: string; - /** A sensible default for text, when they just want better answers. */ - textModel: string; - /** - * Why someone would pick this one — SOURCE COPY for maintainers, in English. - * - * Deliberately not rendered: it leaked English into a German dropdown once. - * What a visitor needs in order to choose is whether the provider can read a - * picture, and that is shown from the dictionary as a localised badge. - */ - note: string; + /** Placeholder hint for the key field. Not validation. */ + keyHint: string; + /** Placeholder for the model field, when the vendor lists no models. */ + modelExample: string; }; -export const BYOK_PROVIDERS: readonly ByokProvider[] = [ - { - id: "openrouter", - label: "OpenRouter", - baseUrl: "https://openrouter.ai/api/v1", - keysUrl: "https://openrouter.ai/keys", - keyPrefix: "sk-or-", - visionModel: "openai/gpt-5-mini", - textModel: "openai/gpt-5-mini", - note: "One key, and Claude, Gemini and GPT are all behind it.", - }, - { - id: "openai", - label: "OpenAI", - baseUrl: "https://api.openai.com/v1", - keysUrl: "https://platform.openai.com/api-keys", - keyPrefix: "sk-", - visionModel: "gpt-5-mini", - textModel: "gpt-5-mini", - note: "Direct, if you already have an account.", - }, - { - id: "groq", - label: "Groq", - baseUrl: "https://api.groq.com/openai/v1", - keysUrl: "https://console.groq.com/keys", - keyPrefix: "gsk_", - // Groq's hosted models are text-only at the time of writing, so no vision - // default is offered rather than one being guessed. - textModel: "openai/gpt-oss-120b", - note: "Very fast. Text only.", - }, - { - id: "deepseek", - label: "DeepSeek", - baseUrl: "https://api.deepseek.com/v1", - keysUrl: "https://platform.deepseek.com/api_keys", - keyPrefix: "sk-", - textModel: "deepseek-chat", - note: "Inexpensive. Text only.", - }, - { - id: "together", - label: "Together", - baseUrl: "https://api.together.xyz/v1", - keysUrl: "https://api.together.ai/settings/api-keys", - textModel: "meta-llama/Llama-4-Scout-17B-16E-Instruct", - note: "Open-weight models.", - }, -]; +export const BYOK_PROVIDERS: readonly ByokProvider[] = BYOK_VENDORS.map((v) => ({ + id: v.id, + label: v.label, + baseUrl: v.baseUrl, + keysUrl: v.keyUrl, + keyHint: v.keyHint, + modelExample: v.modelExample, +})); export function findProvider(id: string): ByokProvider | undefined { return BYOK_PROVIDERS.find((p) => p.id === id); } - -/** Providers that can be given a picture. Drives what the attach button says. */ -export const VISION_PROVIDERS = BYOK_PROVIDERS.filter((p) => p.visionModel); diff --git a/lib/i18n/dictionaries/de.ts b/lib/i18n/dictionaries/de.ts index bcb8d27..7b2aa98 100644 --- a/lib/i18n/dictionaries/de.ts +++ b/lib/i18n/dictionaries/de.ts @@ -268,14 +268,16 @@ export const de = { keyPlaceholder: "sk-…", modelLabel: "Modell", getKey: "Schlüssel holen", - test: "Verbinden und testen", + test: "Dieses Modell verwenden", testing: "Wird geprüft …", connected: "Verbunden", connectedWith: "Verbunden mit", failed: "Das hat nicht geklappt", + pasteHint: "Schlüssel einfügen — Heidi prüft ihn sofort und zeigt, welche Modelle er kann.", + strongest: "stärkstes", + vendorSaid: "Der Anbieter meldet:", + unreachable: "Der Anbieter war gerade nicht erreichbar. Ihr Schlüssel kann trotzdem stimmen — bitte gleich nochmals versuchen.", disconnect: "Schlüssel entfernen", - canSee: "Kann Bilder lesen", - textOnly: "Nur Text", open: "Eigenes Modell", imageTooBig: "Dieses Bild lässt sich nicht verwenden.", imagesLabel: "Angehängt", @@ -292,7 +294,7 @@ export const de = { }, { title: "Gemessen, nicht vergoldet", - body: "Keine Serien, keine Punkte, keine erfundenen Prozentzahlen. Die Zahl, die wir Ihnen zeigen wollen, ist, wie viel Sie von einer unbekannten Zürcher Stimme verstehen — vorher und nachher.", + body: "Keine Punkte, keine erfundenen Prozentzahlen. Die Zahl, die wir Ihnen zeigen wollen, ist, wie viel Sie von einer unbekannten Zürcher Stimme verstehen — vorher und nachher.", }, ], @@ -411,7 +413,7 @@ export const de = { ], decisions: [ "Hören vor Schreiben vor Sprechen — begründet durch die Sprachsituation, nicht nur durch Evidenz.", - "Gemessen statt vergoldet. Keine Serien, keine Punkte.", + "Gemessen statt vergoldet. Keine Punkte, keine erfundenen Zahlen.", "Die Prüfstimme ist immer eine, die Sie nicht gehört haben.", "Echte Zürcher Aufnahmen, weil jedes verfügbare Zürcher Korpus nur für die Forschung lizenziert ist.", "Das Modell urteilt nie über den eigenen Dialekt.", @@ -1228,9 +1230,9 @@ export const de = { source: cite("brunmair-richter-2019"), }, { - claim: "Keine Serien, keine Punkte, keine Prozentzahl.", + claim: "Keine Punkte, keine Prozentzahl — und eine Serie ohne Vorwurf.", detail: - "Eine Serie misst, wie viel Heidi Sie konsumiert haben, und sieht dabei aus wie ein Mass fürs Lernen. Was hier steht, sind Zahlen über das, was Sie getan haben.", + "Punkte messen, wie viel Heidi Sie konsumiert haben, und sehen dabei aus wie ein Mass fürs Lernen. Die Serie zählt nur Tage, an denen Sie geübt haben, und endet nie mit einem «verloren». Was hier steht, sind Zahlen über das, was Sie getan haben.", source: cite("yang-2021"), }, ], diff --git a/lib/i18n/dictionaries/en.ts b/lib/i18n/dictionaries/en.ts index 553844b..6d8adb5 100644 --- a/lib/i18n/dictionaries/en.ts +++ b/lib/i18n/dictionaries/en.ts @@ -235,14 +235,16 @@ export const en: Dictionary = { keyPlaceholder: "sk-…", modelLabel: "Model", getKey: "Get a key", - test: "Connect and test", + test: "Use this model", testing: "Checking …", connected: "Connected", connectedWith: "Connected with", failed: "That did not work", + pasteHint: "Paste a key — Heidi checks it at once and shows which models it can use.", + strongest: "strongest", + vendorSaid: "The provider says:", + unreachable: "The provider could not be reached just now. Your key may well be fine — please try again in a moment.", disconnect: "Remove key", - canSee: "Can read pictures", - textOnly: "Text only", open: "Your own model", imageTooBig: "That picture cannot be used.", imagesLabel: "Attached", @@ -259,7 +261,7 @@ export const en: Dictionary = { }, { title: "Measured, not gamified", - body: "No streaks, no points, no invented percentages. The number we want to show you is how much of an unfamiliar Zurich speaker you understand — before and after.", + body: "No points, no invented percentages. The number we want to show you is how much of an unfamiliar Zurich speaker you understand — before and after.", }, ], @@ -377,7 +379,7 @@ export const en: Dictionary = { ], decisions: [ "Listening before writing before speaking — justified by the language situation, not only by evidence.", - "Measured rather than gamified. No streaks, no points.", + "Measured rather than gamified. No points, no invented numbers.", "The test voice is always one you have not heard.", "Real Zurich recordings, because every available Zurich corpus is licensed for research only.", "The model never judges its own dialect.", @@ -1091,9 +1093,9 @@ export const en: Dictionary = { source: ["brunmair-richter-2019"], }, { - claim: "No streaks, no points, no percentage.", + claim: "No points, no percentage — and a streak without reproach.", detail: - "A streak measures how much Heidi you consumed while looking like a measure of learning. What is here are counts of what you did.", + "Points measure how much Heidi you consumed while looking like a measure of learning. The streak only counts days you practised and never ends with «lost». What is here are counts of what you did.", source: ["yang-2021"], }, ], diff --git a/lib/i18n/dictionaries/fr.ts b/lib/i18n/dictionaries/fr.ts index 69fba8f..9ba2b0b 100644 --- a/lib/i18n/dictionaries/fr.ts +++ b/lib/i18n/dictionaries/fr.ts @@ -235,14 +235,16 @@ export const fr: Dictionary = { keyPlaceholder: "sk-…", modelLabel: "Modèle", getKey: "Obtenir une clé", - test: "Connecter et tester", + test: "Utiliser ce modèle", testing: "Vérification …", connected: "Connecté", connectedWith: "Connecté avec", failed: "Cela n'a pas fonctionné", + pasteHint: "Collez une clé — Heidi la vérifie aussitôt et montre les modèles qu’elle permet.", + strongest: "le plus puissant", + vendorSaid: "Le fournisseur indique :", + unreachable: "Le fournisseur n’a pas pu être joint. Votre clé est peut-être correcte — réessayez dans un instant.", disconnect: "Retirer la clé", - canSee: "Peut lire les images", - textOnly: "Texte seulement", open: "Votre propre modèle", imageTooBig: "Cette image ne peut pas être utilisée.", imagesLabel: "Joint", @@ -259,7 +261,7 @@ export const fr: Dictionary = { }, { title: "Mesuré, pas gamifié", - body: "Pas de séries, pas de points, pas de pourcentages inventés. Le chiffre que nous voulons vous montrer, c'est la part que vous comprenez d'une voix zurichoise inconnue — avant et après.", + body: "Pas de points, pas de pourcentages inventés. Le chiffre que nous voulons vous montrer, c'est la part que vous comprenez d'une voix zurichoise inconnue — avant et après.", }, ], @@ -377,7 +379,7 @@ export const fr: Dictionary = { ], decisions: [ "Écouter avant d'écrire avant de parler — justifié par la situation linguistique, pas seulement par les preuves.", - "Mesuré plutôt que gamifié. Pas de séries, pas de points.", + "Mesuré plutôt que gamifié. Pas de points, pas de chiffres inventés.", "La voix de test est toujours une voix que vous n'avez pas entendue.", "De vrais enregistrements zurichois, car tout corpus zurichois disponible n'est licencié que pour la recherche.", "Le modèle ne juge jamais son propre dialecte.", @@ -1096,9 +1098,9 @@ export const fr: Dictionary = { source: ["brunmair-richter-2019"], }, { - claim: "Pas de séries, pas de points, pas de pourcentage.", + claim: "Pas de points, pas de pourcentage — et une série sans reproche.", detail: - "Une série mesure combien de Heidi vous avez consommé tout en ayant l'air de mesurer l'apprentissage. Ce qui figure ici, ce sont des faits sur ce que vous avez fait.", + "Des points mesurent combien de Heidi vous avez consommé tout en ayant l'air de mesurer l'apprentissage. La série ne compte que les jours où vous vous êtes exercé et ne finit jamais par « perdu ». Ce qui figure ici, ce sont des faits sur ce que vous avez fait.", source: ["yang-2021"], }, ], diff --git a/lib/i18n/dictionaries/gsw.ts b/lib/i18n/dictionaries/gsw.ts index cafffee..7099130 100644 --- a/lib/i18n/dictionaries/gsw.ts +++ b/lib/i18n/dictionaries/gsw.ts @@ -258,14 +258,16 @@ export const gsw: Dictionary = { keyPlaceholder: "sk-…", modelLabel: "Modell", getKey: "Schlüssel hole", - test: "Verbinde und teste", + test: "Das Modell bruuche", testing: "Wird prüeft …", connected: "Verbunde", connectedWith: "Verbunde mit", failed: "Das hät nöd klappet", + pasteHint: "Schlüssel iifüege — Heidi prüeft en sofort und zeigt, weli Modell er chan.", + strongest: "stärchschts", + vendorSaid: "De Aabieter meldet:", + unreachable: "De Aabieter isch grad nöd erreichbar gsi. Ihre Schlüssel chan trotzdem stimme — bitte gli nomal probiere.", disconnect: "Schlüssel ewägnäh", - canSee: "Cha Bilder läse", - textOnly: "Nur Text", open: "Eigens Modell", imageTooBig: "Das Bild laht sich nöd bruuche.", imagesLabel: "Aaghänkt", @@ -343,7 +345,7 @@ export const gsw: Dictionary = { }, { title: "Gmässe, nöd vergoldet", - body: "Kei Serie, kei Pünkt, kei erfundeni Prozäntzahle. D Zahl wo mir Ihne wänd zeige isch, wie vill Si vo ere unbekannte Zürcher Stimm verstönd — vorher und nachher.", + body: "Kei Pünkt, kei erfundeni Prozäntzahle. D Zahl wo mir Ihne wänd zeige isch, wie vill Si vo ere unbekannte Zürcher Stimm verstönd — vorher und nachher.", }, ], @@ -462,7 +464,7 @@ export const gsw: Dictionary = { ], decisions: [ "Lose vor Schriibe vor Rede — begründet dur d Sprachsituation, nöd nur dur Evidenz.", - "Gmässe statt vergoldet. Kei Serie, kei Pünkt.", + "Gmässe statt vergoldet. Kei Pünkt, kei erfundeni Zahle.", "D Prüefstimm isch immer eini wo Si nöd ghört händ.", "Echti Zürcher Ufnahme, wil jedes verfüegbare Zürcher Korpus nur für d Forschig lizenziert isch.", "S Modell urteilt nie über de eigene Dialekt.", @@ -1116,9 +1118,9 @@ export const gsw: Dictionary = { source: ["brunmair-richter-2019"], }, { - claim: "Kei Serie, kei Pünkt, kei Prozentzahl.", + claim: "Kei Pünkt, kei Prozentzahl — und e Serie ohni Vorwurf.", detail: - "E Serie misst, wie viel Heidi Si konsumiert händ, und gseht dobii us wie es Mass fürs Lerne. Was da staht, sind Zahle über das, wo Si gmacht händ.", + "Pünkt mässed, wie viel Heidi Si konsumiert händ, und gsehnd dobii us wie es Mass fürs Lerne. D Serie zellt nur Täg, wo Si güebt händ, und hört nie mit «verlore» uuf. Was da staht, sind Zahle über das, wo Si gmacht händ.", source: ["yang-2021"], }, ], diff --git a/lib/i18n/dictionaries/it.ts b/lib/i18n/dictionaries/it.ts index b1a39a2..466588a 100644 --- a/lib/i18n/dictionaries/it.ts +++ b/lib/i18n/dictionaries/it.ts @@ -235,14 +235,16 @@ export const it: Dictionary = { keyPlaceholder: "sk-…", modelLabel: "Modello", getKey: "Ottenere una chiave", - test: "Collegare e provare", + test: "Usare questo modello", testing: "Verifica in corso …", connected: "Collegato", connectedWith: "Collegato con", failed: "Non ha funzionato", + pasteHint: "Incolli una chiave — Heidi la verifica subito e mostra quali modelli permette.", + strongest: "il più potente", + vendorSaid: "Il fornitore segnala:", + unreachable: "Il fornitore non era raggiungibile. La sua chiave può essere corretta — riprovi tra un momento.", disconnect: "Rimuovere la chiave", - canSee: "Può leggere le immagini", - textOnly: "Solo testo", open: "Il vostro modello", imageTooBig: "Questa immagine non può essere usata.", imagesLabel: "Allegato", @@ -259,7 +261,7 @@ export const it: Dictionary = { }, { title: "Misurato, non gamificato", - body: "Niente serie, niente punti, niente percentuali inventate. Il numero che vogliamo mostrarvi è quanto capite di una voce zurighese sconosciuta — prima e dopo.", + body: "Niente punti, niente percentuali inventate. Il numero che vogliamo mostrarvi è quanto capite di una voce zurighese sconosciuta — prima e dopo.", }, ], @@ -377,7 +379,7 @@ export const it: Dictionary = { ], decisions: [ "Ascoltare prima di scrivere prima di parlare — giustificato dalla situazione linguistica, non solo dalle prove.", - "Misurato anziché gamificato. Niente serie, niente punti.", + "Misurato anziché gamificato. Niente punti, niente numeri inventati.", "La voce di prova è sempre una che non avete sentito.", "Registrazioni zurighesi vere, perché ogni corpus zurighese disponibile è licenziato solo per la ricerca.", "Il modello non giudica mai il proprio dialetto.", @@ -1096,9 +1098,9 @@ export const it: Dictionary = { source: ["brunmair-richter-2019"], }, { - claim: "Niente serie, niente punti, niente percentuali.", + claim: "Niente punti, niente percentuali — e una serie senza rimproveri.", detail: - "Una serie misura quanta Heidi avete consumato mentre sembra misurare l'apprendimento. Qui ci sono numeri su ciò che avete fatto.", + "I punti misurano quanta Heidi avete consumato mentre sembrano misurare l'apprendimento. La serie conta solo i giorni in cui vi siete esercitati e non finisce mai con «persa». Qui ci sono numeri su ciò che avete fatto.", source: ["yang-2021"], }, ], diff --git a/lib/i18n/dictionaries/rm.ts b/lib/i18n/dictionaries/rm.ts index fc78e71..8a95422 100644 --- a/lib/i18n/dictionaries/rm.ts +++ b/lib/i18n/dictionaries/rm.ts @@ -251,14 +251,16 @@ export const rm: Dictionary = { keyPlaceholder: "sk-…", modelLabel: "Model", getKey: "Obtegnair ina clav", - test: "Colliar ed empruvar", + test: "Duvrar quest model", testing: "Vegn controllà …", connected: "Collià", connectedWith: "Collià cun", failed: "Quai n'ha betg funcziunà", + pasteHint: "Tschentai en ina clav — Heidi la controllescha immediat e mussa tge models ch’ella po duvrar.", + strongest: "il pli ferm", + vendorSaid: "Il purschider annunzia:", + unreachable: "Il purschider n’era betg cuntanschibel. Vossa clav po tuttina esser endretg — empruvai anc ina giada en in mument.", disconnect: "Allontanar la clav", - canSee: "Po leger maletgs", - textOnly: "Mo text", open: "Voss agen model", imageTooBig: "Questa maletg na sa betg vegnir duvrada.", imagesLabel: "Agiuntà", @@ -275,7 +277,7 @@ export const rm: Dictionary = { }, { title: "Mesirà, betg gamificà", - body: "Naginas seriras, nagins puncts, naginas pertschientualas inventadas. Il numer che nus vulain Vus mussar è quant ch'e Vus chapis d'ina vusch turitgaisa nunenconuschenta — avant e suenter.", + body: "Nagins puncts, naginas pertschientualas inventadas. Il numer che nus vulain Vus mussar è quant ch'e Vus chapis d'ina vusch turitgaisa nunenconuschenta — avant e suenter.", }, ], @@ -393,7 +395,7 @@ export const rm: Dictionary = { ], decisions: [ "Tadlar avant scriver avant discurrer — motivà da la situaziun linguistica, betg mo da las cumprovas.", - "Mesirà enstagl da gamificà. Naginas seriras, nagins puncts.", + "Mesirà enstagl da gamificà. Nagins puncts, naginas cifras inventadas.", "La vusch da test è adina ina che Vus n'avais betg udì.", "Registraziuns turitgaisas veras, perquai che mintga corpus turitgais disponibel è licenzià mo per la perscrutaziun.", "Il model na giuditgescha mai ses agen dialect.", @@ -1112,9 +1114,9 @@ export const rm: Dictionary = { source: ["brunmair-richter-2019"], }, { - claim: "Nagins seris, nagins puncts, nagina percentuala.", + claim: "Nagins puncts, nagina percentuala — ed ina seria senza reproschas.", detail: - "In seri mesira quant Heidi che Vus avais consumà e para intant da mesirar l'emprender. Qua stattan cifras davart quai che Vus avais fatg.", + "Puncts mesiran quant Heidi che Vus avais consumà e paran intant da mesirar l'emprender. La seria dumbra mo ils dis che Vus avais exercità e na finescha mai cun «pers». Qua stattan cifras davart quai che Vus avais fatg.", source: ["yang-2021"], }, ], diff --git a/lib/i18n/dictionaries/ru.ts b/lib/i18n/dictionaries/ru.ts index 6194eb6..6cd1c06 100644 --- a/lib/i18n/dictionaries/ru.ts +++ b/lib/i18n/dictionaries/ru.ts @@ -243,14 +243,16 @@ export const ru: Dictionary = { keyPlaceholder: "sk-…", modelLabel: "Модель", getKey: "Получить ключ", - test: "Подключить и проверить", + test: "Использовать эту модель", testing: "Проверяем …", connected: "Подключено", connectedWith: "Подключено к", failed: "Не получилось", + pasteHint: "Вставьте ключ — Heidi сразу проверит его и покажет, какие модели он открывает.", + strongest: "самая сильная", + vendorSaid: "Сообщение провайдера:", + unreachable: "Провайдер сейчас недоступен. Ваш ключ, возможно, в порядке — попробуйте через минуту.", disconnect: "Удалить ключ", - canSee: "Читает изображения", - textOnly: "Только текст", open: "Своя модель", imageTooBig: "Это изображение использовать нельзя.", imagesLabel: "Прикреплено", @@ -267,7 +269,7 @@ export const ru: Dictionary = { }, { title: "Измеряем, а не развлекаем", - body: "Никаких серий, очков и выдуманных процентов. Число, которое мы хотим вам показать, — сколько вы понимаете из речи незнакомого цюрихца: до и после.", + body: "Никаких очков и выдуманных процентов. Число, которое мы хотим вам показать, — сколько вы понимаете из речи незнакомого цюрихца: до и после.", }, ], @@ -385,7 +387,7 @@ export const ru: Dictionary = { ], decisions: [ "Слушать раньше, чем писать, и писать раньше, чем говорить — это следует из языковой ситуации, а не только из данных.", - "Измерять, а не развлекать. Никаких серий и очков.", + "Измерять, а не развлекать. Никаких очков и выдуманных чисел.", "Проверочный голос — всегда тот, которого вы не слышали.", "Настоящие цюрихские записи, потому что каждый доступный цюрихский корпус лицензирован только для исследований.", "Модель никогда не судит собственный диалект.", @@ -1105,9 +1107,9 @@ export const ru: Dictionary = { source: ["brunmair-richter-2019"], }, { - claim: "Никаких серий, очков и процентов.", + claim: "Никаких очков и процентов — и серия без упрёков.", detail: - "Серия измеряет, сколько Хайди вы потребили, и при этом выглядит как мера обучения. Здесь — числа о том, что вы сделали.", + "Очки измеряют, сколько Хайди вы потребили, и при этом выглядят как мера обучения. Серия считает только дни, когда вы занимались, и никогда не заканчивается словом «потеряна». Здесь — числа о том, что вы сделали.", source: ["yang-2021"], }, ], diff --git a/lib/i18n/i18n.test.ts b/lib/i18n/i18n.test.ts index 3399c3e..2e05cda 100644 --- a/lib/i18n/i18n.test.ts +++ b/lib/i18n/i18n.test.ts @@ -195,18 +195,6 @@ test("nothing private is in the sitemap", () => { assert.ok(INDEXED_ROUTES.some((r) => r.key === "impressum")); }); -test("no English source copy from the provider list reaches a page", () => { - // The provider `note` fields are maintainer copy. Rendering them put English - // sentences into a German dropdown; anything a visitor reads comes from a - // dictionary instead. This asserts the notes are not smuggled into one. - for (const locale of LOCALES) { - const flat = JSON.stringify(getDictionary(locale)); - for (const provider of BYOK_PROVIDERS) { - assert.ok(!flat.includes(provider.note), `${locale} embeds ${provider.id}'s English note`); - } - } -}); - test("every provider a visitor can choose is named in the allowlist", () => { // The dropdown renders labels only, so a provider with no label would be an // empty option the person cannot reason about. diff --git a/package.json b/package.json index 55be4d3..f891ed0 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "audit:language": "node scripts/audit/language.mjs" }, "dependencies": { - "@bitbaum/ai-kit": "^1.16.0", + "@bitbaum/ai-kit": "^1.17.0", "@bitbaum/speechkit": "^1.0.0", "bip-kit": "^0.4.0", "drizzle-orm": "^0.45.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b80620..70a43cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@bitbaum/ai-kit': - specifier: ^1.16.0 - version: 1.16.0(react@19.2.4) + specifier: ^1.17.0 + version: 1.17.0(react@19.2.4) '@bitbaum/speechkit': specifier: ^1.0.0 version: 1.0.0 @@ -163,8 +163,8 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} - '@bitbaum/ai-kit@1.16.0': - resolution: {integrity: sha512-UmwDb6ED+Buw0H7cT5LYCJ0kN6VmcBwIF2aXCTnRMN6dW4uWwyD56E82EEs3Y59l1GayT0XV+/e521bDv1CCDA==} + '@bitbaum/ai-kit@1.17.0': + resolution: {integrity: sha512-nd3qktLwDCdCl7F4Lws7srJDmajB+8y+8BTmQ92yKpFDUXTM1k7f5MS5n5ZiZ6ooKXgjX56wOgp/Ezm+0wc4/g==} engines: {node: '>=20'} peerDependencies: react: '>=18' @@ -2897,7 +2897,7 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@bitbaum/ai-kit@1.16.0(react@19.2.4)': + '@bitbaum/ai-kit@1.17.0(react@19.2.4)': dependencies: ai-forms: 0.1.2(react@19.2.4) optionalDependencies: