diff --git a/kloe.schema.json b/kloe.schema.json index 4de86ee..7a957b3 100644 --- a/kloe.schema.json +++ b/kloe.schema.json @@ -606,9 +606,6 @@ "type": "object", "properties": { "flow": { - "enum": [ - "hyper-device" - ], "type": "string" }, "baseUrl": { diff --git a/src/client/app.css b/src/client/app.css index c2acfde..84cd81f 100644 --- a/src/client/app.css +++ b/src/client/app.css @@ -3539,6 +3539,19 @@ body.lb-open { border-color: var(--accent); } +/* A provider this instance has no key for: available, but only to whoever + brings an account. */ +.conntag { + margin-left: 7px; + padding: 1px 6px; + border: 1px solid var(--rule-strong); + border-radius: 999px; + font-size: 10.5px; + font-weight: 500; + color: var(--ink-muted); + vertical-align: middle; +} + /* The device code, while a user is approving it elsewhere. The code itself is the point: big, monospace, and selectable, because it gets read off a screen and typed into another one. */ diff --git a/src/client/views/settings.js b/src/client/views/settings.js index 03d3aa1..e1dc87f 100644 --- a/src/client/views/settings.js +++ b/src/client/views/settings.js @@ -615,6 +615,7 @@ export function mount(root, _params, ctx) { function accountRow(p, conn) { var row = document.createElement("div"); row.className = "connrow" + (conn ? " on" : ""); + var path = "/api/credentials/" + encodeURIComponent(p.service) + "/" + encodeURIComponent(p.id); var dot = document.createElement("span"); dot.className = "conndot"; @@ -625,6 +626,13 @@ export function mount(root, _params, ctx) { var title = document.createElement("div"); title.className = "conntitle"; title.textContent = p.id; + if (p.userOnly) { + var tag = document.createElement("span"); + tag.className = "conntag"; + tag.textContent = "yours only"; + tag.title = "This instance has no key for it; connect one and it is yours to use."; + title.appendChild(tag); + } var sub = document.createElement("div"); sub.className = "connsub"; if (conn) { @@ -633,7 +641,9 @@ export function mount(root, _params, ctx) { ? "Connected" + (conn.label ? " \u00b7 " + conn.label : "") + " \u00b7 your credits" : "Your key " + (conn.label || "") + " \u00b7 your credits"; } else { - sub.textContent = "Using this instance\u2019s key."; + sub.textContent = p.userOnly + ? "Not configured here \u2014 connect an account to use it." + : "Using this instance\u2019s key."; } text.appendChild(title); text.appendChild(sub); @@ -646,9 +656,7 @@ export function mount(root, _params, ctx) { off.textContent = "Disconnect"; off.onclick = async function () { off.disabled = true; - await fetch("/api/credentials/" + encodeURIComponent(p.id), { method: "DELETE" }).catch( - function () {}, - ); + await fetch(path, { method: "DELETE" }).catch(function () {}); loadAccounts(); }; row.appendChild(off); @@ -661,7 +669,7 @@ export function mount(root, _params, ctx) { connect.className = "btn primary"; connect.textContent = "Connect"; connect.onclick = function () { - startDevice(p.id, row); + startDevice(p, row); }; row.appendChild(connect); } @@ -678,7 +686,7 @@ export function mount(root, _params, ctx) { var res = await fetch("/api/credentials", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ providerId: p.id, apiKey: value }), + body: JSON.stringify({ service: p.service, providerId: p.id, apiKey: value }), }).catch(function () { return { ok: false }; }); @@ -697,7 +705,8 @@ export function mount(root, _params, ctx) { * the code expires or the view goes away — a forgotten timer here would keep * hitting the provider from a page nobody is looking at. */ - async function startDevice(providerId, row) { + async function startDevice(p, row) { + var base = "/api/credentials/" + encodeURIComponent(p.service) + "/" + encodeURIComponent(p.id); stopPolling(); var panel = document.createElement("div"); panel.className = "conndevice"; @@ -706,11 +715,7 @@ export function mount(root, _params, ctx) { var start; try { - start = await ( - await fetch("/api/credentials/" + encodeURIComponent(providerId) + "/device", { - method: "POST", - }) - ).json(); + start = await (await fetch(base + "/device", { method: "POST" })).json(); if (start.error) throw new Error(start.error); } catch (e) { panel.textContent = "Could not start: " + e.message; @@ -734,11 +739,7 @@ export function mount(root, _params, ctx) { panel.appendChild(hint); panel.appendChild(open); - var url = - "/api/credentials/" + - encodeURIComponent(providerId) + - "/device/" + - encodeURIComponent(start.deviceCode); + var url = base + "/device/" + encodeURIComponent(start.deviceCode); var tick = async function () { if (Date.now() > start.expiresAt) { hint.textContent = "That code expired. Try again."; @@ -792,13 +793,31 @@ export function mount(root, _params, ctx) { if (currentTab() === "accounts") selectTab("models"); return; } - var byProvider = {}; + var byKey = {}; (data.connections || []).forEach(function (c) { - byProvider[c.providerId] = c; + byKey[c.service + "/" + c.providerId] = c; }); list.innerHTML = ""; - offered.forEach(function (p) { - list.appendChild(accountRow(p, byProvider[p.id])); + // Grouped by what the account is FOR: an inference endpoint and a search + // engine can share a name, and reading them in one flat list invites the + // reader to think they are the same account. + ["inference", "search"].forEach(function (service) { + var mine = offered.filter(function (p) { + return p.service === service; + }); + if (!mine.length) return; + list.appendChild(seclabel(service === "inference" ? "Models" : "Search")); + // Connected first, then what this instance already pays for, then the + // long tail nobody has touched. + mine + .sort(function (a, b) { + var ca = byKey[a.service + "/" + a.id] ? 0 : a.userOnly ? 2 : 1; + var cb = byKey[b.service + "/" + b.id] ? 0 : b.userOnly ? 2 : 1; + return ca - cb || a.id.localeCompare(b.id); + }) + .forEach(function (p) { + list.appendChild(accountRow(p, byKey[p.service + "/" + p.id])); + }); }); } diff --git a/src/connectors.ts b/src/connectors.ts new file mode 100644 index 0000000..3ec95f6 --- /dev/null +++ b/src/connectors.ts @@ -0,0 +1,121 @@ +import { getCatalog } from "./inference"; +import { flowFor } from "./oauthflows"; +import { encryptionConfigured } from "./secrets"; +import { getConfig } from "./settings"; + +/** + * Everything a user can connect an account to, in one vocabulary. + * + * kloe spends money in two places — inference and web search — and a user + * should be able to pay for either one themselves. Rather than teach the + * credential layer about each, a connector says the three things that layer + * needs: where the provider lives, whether a pasted key is accepted, and which + * OAuth flow (if any) a user can run. + * + * The service is part of a credential's identity, not a suffix on its name. + * "exa" is a search engine and could tomorrow be an inference endpoint, and two + * credentials called `exa` that mean different things is exactly the bug that + * key would hide. + */ + +export type Service = "inference" | "search"; +export const SERVICES: Service[] = ["inference", "search"]; + +export interface OAuthFlowRef { + flow: string; + baseUrl: string; +} + +export interface Connector { + service: Service; + /** Provider id within the service — "hyper", "exa". */ + id: string; + /** Whether a user may paste their own API key here. */ + byok: boolean; + /** The device flow a user can run, when the provider offers one. */ + oauth?: OAuthFlowRef; + /** Where the provider's API lives, for a per-user client. */ + endpoint?: string; + /** Adapter/type hint, for a per-user client. */ + type?: string; + /** + * True when the deployment does not enable this provider itself and a user's + * own credential is the only way to reach it. It still appears, because the + * catalog knows how to talk to it and the user is the one paying. + */ + userOnly?: boolean; +} + +/** Search engines that take an API key. DuckDuckGo is keyless and so not one. */ +const SEARCH_PROVIDERS = ["ceramic", "hackclub", "llmsolutions", "exa"]; + +function inferenceConnectors(): Connector[] { + const cfg = getConfig(); + const out: Connector[] = []; + const enabled = new Set(); + + for (const p of cfg.providers) { + enabled.add(p.id); + out.push({ + service: "inference", + id: p.id, + byok: p.byok !== false, + oauth: p.oauth && flowFor(p.oauth.flow) ? p.oauth : undefined, + endpoint: p.apiEndpoint, + type: p.type, + }); + } + + // Everything catwalk knows about, for the user who brings a key to a provider + // this deployment never enabled. The catalog already carries the endpoint, + // the adapter type and the model list, so there is nothing for an operator to + // configure — the credential is the only missing part, and it isn't theirs. + const catalog = getCatalog(); + if (catalog) { + for (const p of catalog.listProviders()) { + if (enabled.has(p.id)) continue; + out.push({ + service: "inference", + id: p.id, + byok: true, + endpoint: p.apiEndpoint, + type: p.type, + userOnly: true, + }); + } + } + return out; +} + +function searchConnectors(): Connector[] { + // Search has no per-provider config block to read a flag off, so every + // key-taking engine is offered. A user who brings an Exa key gets Exa, + // whether or not this deployment pays for one. + const configured = new Set(); + const cfg = getConfig().search; + if (cfg.provider && cfg.provider !== "default" && cfg.provider !== "none") { + configured.add(cfg.provider); + } + for (const b of cfg.backends ?? []) configured.add(b.provider); + + return SEARCH_PROVIDERS.map((id) => ({ + service: "search" as const, + id, + byok: true, + userOnly: !configured.has(id), + })); +} + +/** Every connector, or an empty list when credentials cannot be stored at all. */ +export function listConnectors(): Connector[] { + if (!encryptionConfigured()) return []; + return [...inferenceConnectors(), ...searchConnectors()]; +} + +export function findConnector(service: Service, id: string): Connector | undefined { + return listConnectors().find((c) => c.service === service && c.id === id); +} + +export function isService(value: string): value is Service { + return (SERVICES as string[]).includes(value); +} diff --git a/src/credentials.ts b/src/credentials.ts index 1145b94..05ca56b 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -1,23 +1,28 @@ -import { exchangeToken } from "./hyperauth"; +import { type Connector, findConnector, listConnectors, type Service } from "./connectors"; +import { flowFor } from "./oauthflows"; import { decryptSecret, encryptionConfigured, encryptSecret, hint } from "./secrets"; -import { getConfig } from "./settings"; import type { CredentialRow, Store, UserCredential } from "./store"; /** * Credentials that belong to a user rather than to the deployment. * - * Two ways in — a pasted API key, or an OAuth grant the user ran (hyper's - * device flow) — and one way out: `credentialFor(store, sub, providerId)` - * returns the bearer string to send, or undefined to mean "this user has - * nothing of their own, use the deployment's key". Everything above this module - * only sees that one answer, which is what keeps the run path from growing a - * second notion of who is paying. + * Two ways in — a pasted API key, or an OAuth grant the user ran — and one way + * out: `credentialFor(store, sub, service, providerId)` returns the bearer + * string to send, or undefined to mean "this user has nothing of their own, use + * the deployment's". Everything above this module only sees that one answer, + * which is what keeps the run path from growing a second notion of who is + * paying. * - * The refresh is the interesting part. Hyper's access tokens last an hour and - * every exchange rotates the refresh token, revoking the old one, so two - * processes refreshing at once would leave one of them holding a dead token and - * the user disconnected. A lease in the row makes exactly one of them do the - * work; the other waits briefly and re-reads what it wrote. + * Nothing here knows what a provider IS. It asks the connector registry where + * one lives and which flow it speaks, so adding a service (search, after + * inference) or a provider (anything catwalk lists) touches neither this file + * nor the run path. + * + * The refresh is the interesting part. Access tokens are short-lived and hyper + * rotates the refresh token on every exchange, revoking the old one, so two + * processes refreshing at once would leave one holding a dead token and the + * user disconnected. A lease in the row makes exactly one of them do the work; + * the other waits briefly and reads what it wrote. */ /** Refresh this far ahead of expiry, so a token can't die mid-request. */ @@ -33,6 +38,7 @@ const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms function decode(row: CredentialRow): UserCredential { return { sub: row.sub, + service: row.service as Service, providerId: row.provider_id, kind: row.kind === "oauth" ? "oauth" : "key", secret: decryptSecret(row.secret), @@ -43,41 +49,35 @@ function decode(row: CredentialRow): UserCredential { }; } -/** The provider entry from ops config, or undefined if it isn't enabled here. */ -function providerConfig(providerId: string) { - return getConfig().providers.find((p) => p.id === providerId); -} - -/** Whether a user may paste their own key for this provider. */ -export function byokAllowed(providerId: string): boolean { - const p = providerConfig(providerId); - return !!p && p.byok !== false && encryptionConfigured(); +/** Whether a user may paste their own key here. */ +export function byokAllowed(service: Service, providerId: string): boolean { + return findConnector(service, providerId)?.byok === true; } -/** The OAuth flow a user can run for this provider, if it offers one. */ -export function oauthFlow(providerId: string) { - const p = providerConfig(providerId); - return p?.oauth && encryptionConfigured() ? p.oauth : undefined; +/** The OAuth flow a user can run for this provider, if it offers one kloe speaks. */ +export function oauthFlow(service: Service, providerId: string): Connector["oauth"] { + const oauth = findConnector(service, providerId)?.oauth; + return oauth && flowFor(oauth.flow) ? oauth : undefined; } -/** Providers a user could connect something to, for the settings page. */ -export function connectableProviders(): Array<{ - id: string; - byok: boolean; - oauth: boolean; -}> { - if (!encryptionConfigured()) return []; - return getConfig() - .providers.map((p) => ({ id: p.id, byok: p.byok !== false, oauth: !!p.oauth })) - .filter((p) => p.byok || p.oauth); +/** Everything a user could connect an account to, for the settings page. */ +export function connectableProviders(): Connector[] { + return listConnectors().filter((c) => c.byok || c.oauth); } -export function saveApiKey(store: Store, sub: string, providerId: string, apiKey: string): void { - if (!byokAllowed(providerId)) { - throw new Error(`provider "${providerId}" does not accept a user-supplied key here`); +export function saveApiKey( + store: Store, + sub: string, + service: Service, + providerId: string, + apiKey: string, +): void { + if (!byokAllowed(service, providerId)) { + throw new Error(`${service} provider "${providerId}" does not accept a user-supplied key here`); } store.setCredentialRow({ sub, + service, provider_id: providerId, kind: "key", secret: encryptSecret(apiKey), @@ -91,12 +91,14 @@ export function saveApiKey(store: Store, sub: string, providerId: string, apiKey export function saveOAuthGrant( store: Store, sub: string, + service: Service, providerId: string, pair: { accessToken: string; refreshToken: string; expiresAt: number }, label?: string, ): void { store.setCredentialRow({ sub, + service, provider_id: providerId, kind: "oauth", secret: encryptSecret(pair.accessToken), @@ -106,16 +108,22 @@ export function saveOAuthGrant( }); } -export function disconnect(store: Store, sub: string, providerId: string): void { - store.deleteCredential(sub, providerId); +export function disconnect(store: Store, sub: string, service: Service, providerId: string): void { + store.deleteCredential(sub, service, providerId); } -/** What the settings page shows: which providers this user has connected, and how. */ -export function listConnections( - store: Store, - sub: string, -): Array<{ providerId: string; kind: "key" | "oauth"; label?: string; expiresAt?: number }> { +export interface Connection { + service: Service; + providerId: string; + kind: "key" | "oauth"; + label?: string; + expiresAt?: number; +} + +/** What the settings page shows: which accounts this user has connected, and how. */ +export function listConnections(store: Store, sub: string): Connection[] { return store.listCredentialRows(sub).map((r) => ({ + service: r.service as Service, providerId: r.provider_id, kind: r.kind === "oauth" ? "oauth" : "key", label: r.label ?? undefined, @@ -123,23 +131,33 @@ export function listConnections( })); } +/** Which providers of a service this user pays for themselves. */ +export function connectedProviders( + store: Store, + sub: string | undefined, + service: Service, +): string[] { + if (!sub) return []; + return store.listCredentialRows(sub, service).map((r) => r.provider_id); +} + /** * The bearer string to use for this user and provider, or undefined when they - * have none and the deployment's own key should be used. + * have none and the deployment's own credential should be used. * * Never throws on a broken connection: a revoked or unrefreshable credential - * resolves to undefined, so the run falls back to the shared key rather than - * failing outright. The user sees "disconnected" in settings, not an error in - * the middle of a sentence. + * resolves to undefined, so the run falls back rather than failing outright. + * The user sees "disconnected" in settings, not an error mid-sentence. */ export async function credentialFor( store: Store, sub: string | undefined, + service: Service, providerId: string, fetchImpl: typeof fetch = fetch, ): Promise { if (!sub || !encryptionConfigured()) return undefined; - const row = store.getCredentialRow(sub, providerId); + const row = store.getCredentialRow(sub, service, providerId); if (!row) return undefined; let cred: UserCredential; @@ -148,7 +166,7 @@ export async function credentialFor( } catch { // Ciphertext we can't read (the key changed, or the row is corrupt). Falling // back is right: the alternative is every run for this user failing. - console.warn(`[credentials] unreadable credential for ${providerId}; ignoring`); + console.warn(`[credentials] unreadable ${service} credential for ${providerId}; ignoring`); return undefined; } @@ -169,25 +187,27 @@ async function refresh( cred: UserCredential, fetchImpl: typeof fetch, ): Promise { - const flow = oauthFlow(cred.providerId); - if (!flow || !cred.refreshToken) return undefined; + const ref = oauthFlow(cred.service, cred.providerId); + const flow = ref && flowFor(ref.flow); + if (!ref || !flow || !cred.refreshToken) return undefined; const now = Date.now(); - if (!store.claimRefresh(cred.sub, cred.providerId, now, now + REFRESH_LEASE_MS)) { + if (!store.claimRefresh(cred.sub, cred.service, cred.providerId, now, now + REFRESH_LEASE_MS)) { return waitForRefresh(store, cred); } try { - const pair = await exchangeToken(flow.baseUrl, cred.refreshToken, fetchImpl); + const pair = await flow.exchange(ref.baseUrl, cred.refreshToken, fetchImpl); // Persist BEFORE returning: the token we just spent is already revoked, so // an unsaved result is a connection thrown away. - saveOAuthGrant(store, cred.sub, cred.providerId, pair, cred.label); + saveOAuthGrant(store, cred.sub, cred.service, cred.providerId, pair, cred.label); return pair.accessToken; } catch (e) { - // The grant is gone (revoked, expired, or hyper said no). Drop the row so - // the user is told to reconnect instead of every run retrying a dead token. + // The grant is gone (revoked, expired, or the provider said no). Drop the + // row so the user is told to reconnect instead of every run retrying a dead + // token. console.warn(`[credentials] ${cred.providerId} refresh failed:`, (e as Error).message); - store.deleteCredential(cred.sub, cred.providerId); + store.deleteCredential(cred.sub, cred.service, cred.providerId); return undefined; } } @@ -195,7 +215,7 @@ async function refresh( async function waitForRefresh(store: Store, cred: UserCredential): Promise { for (let waited = 0; waited < WAIT_MAX_MS; waited += WAIT_STEP_MS) { await sleep(WAIT_STEP_MS); - const row = store.getCredentialRow(cred.sub, cred.providerId); + const row = store.getCredentialRow(cred.sub, cred.service, cred.providerId); if (!row) return undefined; // the winner found the grant dead and dropped it if (row.updated_at > cred.updatedAt) { try { diff --git a/src/http.ts b/src/http.ts index 1e4bcbf..079718a 100644 --- a/src/http.ts +++ b/src/http.ts @@ -17,6 +17,7 @@ import { } from "./auth"; import type { BlobStore } from "./blobs"; import { ACTOR_IDLE_TTL_MS, SSE_RETRY_MS, SUBSCRIBER_HEARTBEAT_MS } from "./config"; +import { isService } from "./connectors"; import { byokAllowed, connectableProviders, @@ -28,7 +29,6 @@ import { } from "./credentials"; import { Event, type EventData, type EventName, parseEventId } from "./events"; import { getExecutor } from "./executor"; -import { exchangeToken, pollDeviceAuth, startDeviceAuth } from "./hyperauth"; import { getRegistry } from "./inference"; import { getContext, @@ -42,6 +42,7 @@ import { memoryRead, memoryWrite, } from "./lard"; +import { flowFor } from "./oauthflows"; import { CredentialBody, ModelPatchBody, @@ -301,7 +302,7 @@ function requireUsableModel( // is spending their own credits, and that account decides what it will run — // so the ref need not be one the deployment knows, and the provider's own // error is the right answer for one it doesn't. - if (sub && store.getCredentialRow(sub, ref.split("/")[0] ?? "")) return null; + if (sub && store.getCredentialRow(sub, "inference", ref.split("/")[0] ?? "")) return null; const unknown = requireKnownModel(ref); if (unknown) return unknown; const s = store.getModelSetting(ref); @@ -957,12 +958,13 @@ export function apiRoutes(deps: { store: Store; blobs: BlobStore; kick?: () => v // Settings/admin view: every available model + its curation state. /** - * A user's own provider credentials: what they've connected, a key they - * paste, and the two halves of hyper's device flow. + * A user's own accounts: what they've connected, a key they paste, and the + * two halves of a device flow. * - * Everything here is scoped to the caller's own sub — there is no path - * that reads or writes somebody else's credential, which is why none of - * these take a user parameter. + * Everything here is scoped to the caller's own sub — there is no path that + * reads or writes somebody else's credential, which is why none of these + * take a user parameter. The service is in the path because "exa" the + * search engine and "exa" the inference endpoint are not the same account. */ "/api/credentials": { GET: (req: Bun.BunRequest<"/api/credentials">) => { @@ -974,22 +976,25 @@ export function apiRoutes(deps: { store: Store; blobs: BlobStore; kick?: () => v }, POST: withBody(CredentialBody, (data, req: Bun.BunRequest<"/api/credentials">) => { const sub = getSession(req, store)?.sub ?? LOCAL_SUB; - if (!byokAllowed(data.providerId)) { + if (!isService(data.service) || !byokAllowed(data.service, data.providerId)) { return Response.json( - { error: `provider "${data.providerId}" does not take a user key here` }, + { error: `"${data.providerId}" does not take a user key here` }, { status: 422 }, ); } - saveApiKey(store, sub, data.providerId, data.apiKey); + saveApiKey(store, sub, data.service, data.providerId, data.apiKey); forgetUserModels(sub, data.providerId); return Response.json({ connections: listConnections(store, sub) }); }), }, - "/api/credentials/:providerId": { - DELETE: (req: Bun.BunRequest<"/api/credentials/:providerId">) => { + "/api/credentials/:service/:providerId": { + DELETE: (req: Bun.BunRequest<"/api/credentials/:service/:providerId">) => { const sub = getSession(req, store)?.sub ?? LOCAL_SUB; - disconnect(store, sub, req.params.providerId); + if (!isService(req.params.service)) { + return Response.json({ error: "unknown service" }, { status: 422 }); + } + disconnect(store, sub, req.params.service, req.params.providerId); forgetUserModels(sub, req.params.providerId); return Response.json({ connections: listConnections(store, sub) }); }, @@ -998,15 +1003,16 @@ export function apiRoutes(deps: { store: Store; blobs: BlobStore; kick?: () => v // Start a device authorization: returns the code the user types and where // to type it. The device_code comes back to the client because only the // browser knows when the user has finished approving. - "/api/credentials/:providerId/device": { - POST: async (req: Bun.BunRequest<"/api/credentials/:providerId/device">) => { - const flow = oauthFlow(req.params.providerId); - if (!flow) { + "/api/credentials/:service/:providerId/device": { + POST: async (req: Bun.BunRequest<"/api/credentials/:service/:providerId/device">) => { + const service = req.params.service; + const ref = isService(service) ? oauthFlow(service, req.params.providerId) : undefined; + const flow = ref && flowFor(ref.flow); + if (!ref || !flow) { return Response.json({ error: "no device flow for this provider" }, { status: 422 }); } try { - const start = await startDeviceAuth(flow.baseUrl, "kloe"); - return Response.json(start); + return Response.json(await flow.start(ref.baseUrl, "kloe")); } catch (e) { return Response.json({ error: (e as Error).message }, { status: 502 }); } @@ -1014,21 +1020,25 @@ export function apiRoutes(deps: { store: Store; blobs: BlobStore; kick?: () => v }, // Poll it. On approval the grant is exchanged immediately: the refresh - // token hyper hands over is one-time and short-lived, so it becomes a + // token a provider hands over is one-time and short-lived, so it becomes a // stored pair here rather than travelling any further. - "/api/credentials/:providerId/device/:deviceCode": { - GET: async (req: Bun.BunRequest<"/api/credentials/:providerId/device/:deviceCode">) => { + "/api/credentials/:service/:providerId/device/:deviceCode": { + GET: async ( + req: Bun.BunRequest<"/api/credentials/:service/:providerId/device/:deviceCode">, + ) => { + const service = req.params.service; const providerId = req.params.providerId; - const flow = oauthFlow(providerId); - if (!flow) { + const ref = isService(service) ? oauthFlow(service, providerId) : undefined; + const flow = ref && flowFor(ref.flow); + if (!ref || !flow || !isService(service)) { return Response.json({ error: "no device flow for this provider" }, { status: 422 }); } const sub = getSession(req, store)?.sub ?? LOCAL_SUB; try { - const poll = await pollDeviceAuth(flow.baseUrl, req.params.deviceCode); + const poll = await flow.poll(ref.baseUrl, req.params.deviceCode); if (poll.status !== "granted") return Response.json({ status: poll.status }); - const pair = await exchangeToken(flow.baseUrl, poll.grant.refreshToken); - saveOAuthGrant(store, sub, providerId, pair, poll.grant.teamName); + const pair = await flow.exchange(ref.baseUrl, poll.grant.refreshToken); + saveOAuthGrant(store, sub, service, providerId, pair, poll.grant.teamName); forgetUserModels(sub, providerId); return Response.json({ status: "connected", diff --git a/src/inference.ts b/src/inference.ts index 79ce019..85b8f08 100644 --- a/src/inference.ts +++ b/src/inference.ts @@ -2,13 +2,14 @@ import { type JSONValue, type LanguageModel, type ModelMessage, stepCountIs, str import type { RunStep } from "./actor"; import type { Role } from "./auth"; import type { BlobStore } from "./blobs"; -import { type LoadCatalogOptions, loadCatalog } from "./catalog"; +import { type Catalog, type LoadCatalogOptions, loadCatalog } from "./catalog"; import { credentialFor } from "./credentials"; import type { TokenUsage } from "./events"; import { contextToText, getContext, lardConnected, lardEnabled } from "./lard"; import { buildSystemPrompt } from "./prompt"; import { isEchoModel, ProviderRegistry } from "./providers"; import { RateLimiter } from "./ratelimit"; +import { searchProviderFor } from "./search"; import { getConfig } from "./settings"; import type { Store } from "./store"; import { type ToolContext, toolSet } from "./tools"; @@ -160,6 +161,11 @@ export function setRegistry(r: ProviderRegistry): void { limiters.clear(); } +/** The model catalog, or null before the registry is built (tests, early boot). */ +export function getCatalog(): Catalog | null { + return registry?.catalog ?? null; +} + export function getRegistry(): ProviderRegistry { if (!registry) { throw new Error("inference not initialized: call initInference() first"); @@ -332,7 +338,7 @@ export async function resolveModelFor( ): Promise { if (!who.store || !who.sub || isEchoModel(modelRef)) return resolveModel(modelRef); const providerName = modelRef.split("/")[0]!; - const key = await credentialFor(who.store, who.sub, providerName); + const key = await credentialFor(who.store, who.sub, "inference", providerName); return resolveModel(modelRef, key); } @@ -394,10 +400,14 @@ export async function* run(messages: ModelMessage[], opts: RunOptions): AsyncGen // are the conversation's own model. const leadRef = opts.store ? resolveRoleModel(opts.store, "lead") : null; const workerRef = opts.store ? resolveRoleModel(opts.store, "worker") : null; + // Whose search this run spends, on the same rule as the model: their own + // engines when they connected any. + const search = opts.store ? await searchProviderFor(opts.store, opts.owner) : null; const tools = toolSet({ store: opts.store, owner: opts.owner, role: opts.role, + search: search ?? undefined, conversationId: opts.conversationId, blobs: opts.blobs, model, // deep_research runs its subagent on the same model as the run diff --git a/src/oauthflows.ts b/src/oauthflows.ts new file mode 100644 index 0000000..1b87d63 --- /dev/null +++ b/src/oauthflows.ts @@ -0,0 +1,56 @@ +import { + type DevicePoll, + type DeviceStart, + exchangeToken, + pollDeviceAuth, + startDeviceAuth, + type TokenPair, +} from "./hyperauth"; + +/** + * The OAuth flows a user can run to connect their own account. + * + * A flow is three calls — start, poll, exchange — and a provider entry names + * one by string. Everything above this module (credentials.ts, the HTTP + * endpoints, the settings page) works in terms of the interface, so a second + * provider is a new entry in FLOWS rather than an edit to the refresh path. + * + * Device flows only, so far, and that is not an accident: kloe holds the + * credential on the user's behalf and never sees their password, and a device + * code is the shape that works when the thing being authorized is a server + * rather than the browser in front of them. + */ + +export interface DeviceFlow { + /** Ask the provider for a code the user types in, and where to type it. */ + start(baseUrl: string, deviceName: string, fetchImpl?: typeof fetch): Promise; + /** Has the user approved it yet? */ + poll(baseUrl: string, deviceCode: string, fetchImpl?: typeof fetch): Promise; + /** + * Trade a refresh token for an access token and its replacement. + * + * Implementations MUST return the new refresh token when the provider + * rotates: the caller persists the pair before using it, because a rotated + * token that isn't stored is a connection thrown away. + */ + exchange(baseUrl: string, refreshToken: string, fetchImpl?: typeof fetch): Promise; +} + +const hyperDevice: DeviceFlow = { + start: (baseUrl, deviceName, fetchImpl) => startDeviceAuth(baseUrl, deviceName, fetchImpl), + poll: (baseUrl, deviceCode, fetchImpl) => pollDeviceAuth(baseUrl, deviceCode, fetchImpl), + exchange: (baseUrl, refreshToken, fetchImpl) => exchangeToken(baseUrl, refreshToken, fetchImpl), +}; + +export const FLOWS: Record = { + "hyper-device": hyperDevice, +}; + +/** The named flow, or undefined — a config naming one kloe doesn't implement. */ +export function flowFor(name: string): DeviceFlow | undefined { + return FLOWS[name]; +} + +export function flowNames(): string[] { + return Object.keys(FLOWS); +} diff --git a/src/providers.ts b/src/providers.ts index 229d618..6f31c40 100644 --- a/src/providers.ts +++ b/src/providers.ts @@ -138,7 +138,7 @@ interface InlineProvider { export class ProviderRegistry { private readonly configs = new Map(); private readonly factories = new Map(); - private readonly catalog: Catalog; + readonly catalog: Catalog; private readonly inline = new Map(); private readonly fetchImpl?: typeof fetch; @@ -276,7 +276,11 @@ export class ProviderRegistry { const providerId = modelRef.slice(0, slash); const modelId = modelRef.slice(slash + 1); - const config = this.configs.get(providerId); + // A provider this deployment enables has ops config. One it doesn't can + // still be reached by a user who brought their own key: the catalog carries + // the endpoint and the adapter type, so the only missing piece was the + // credential, and that is exactly what the caller is holding. + const config = this.configs.get(providerId) ?? this.borrowedConfig(providerId, apiKeyOverride); if (!config) { throw new Error( `provider "${providerId}" is not enabled (enabled: ${this.listProviders().join(", ")})`, @@ -285,13 +289,33 @@ export class ProviderRegistry { const known = this.catalog.getModel(providerId, modelId) ?? this.inline.get(providerId)?.models.find((m) => m.id === modelId); - if (!known) { + // A user's own credential is also its own authority on what that account + // can run, so an unknown model id is the provider's to reject, not ours. + if (!known && !apiKeyOverride) { throw new Error(`unknown model "${modelId}" for provider "${providerId}"`); } return this.factoryFor(providerId, config, apiKeyOverride)(modelId); } + /** + * Ops config for a provider the deployment doesn't enable, synthesized from + * the catalog, for a caller carrying a user's credential. Undefined when + * there is no credential (nobody to pay) or no catalog entry (nowhere to send + * it), which is what makes this borrowing rather than a back door. + */ + private borrowedConfig(providerId: string, apiKeyOverride?: string): ProviderConfig | undefined { + if (!apiKeyOverride) return undefined; + const p = this.catalog.getProvider(providerId); + if (!p?.apiEndpoint) return undefined; + return { + id: providerId, + apiEndpoint: p.apiEndpoint, + maxConcurrency: DEFAULTS.maxConcurrency, + minIntervalMs: DEFAULTS.minIntervalMs, + }; + } + /** * Builds (and caches) the AI SDK adapter for a provider. Credentials are * resolved from the environment on each call; the cache is invalidated if the diff --git a/src/schemas.ts b/src/schemas.ts index c329a0d..0904e8e 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -81,6 +81,8 @@ export type ProjectAssignBody = v.InferOutput; * their runs spend their own credits rather than the deployment's. */ export const CredentialBody = v.object({ + /** "inference" | "search" — validated against the connector registry. */ + service: v.pipe(v.string(), v.minLength(1)), providerId: v.pipe(v.string(), v.minLength(1)), apiKey: v.pipe(v.string(), v.minLength(8), v.maxLength(500)), }); diff --git a/src/search.ts b/src/search.ts index 103d97c..64d36e4 100644 --- a/src/search.ts +++ b/src/search.ts @@ -1,5 +1,7 @@ import { parseHTML } from "linkedom"; +import { connectedProviders, credentialFor } from "./credentials"; import { type Config, getConfig } from "./settings"; +import type { Store } from "./store"; /** * Web search, behind a swappable provider interface so the `web_search` tool @@ -609,6 +611,33 @@ export function dedupeKey(raw: string): string { * single place a search backend is selected — everything else takes a * `SearchProvider`. */ +/** + * The search a particular user gets: their own engines when they have + * connected any, the deployment's otherwise. + * + * A user who brought one key gets that engine alone rather than a blend with + * the deployment's — blending would spend the operator's Exa credits on a + * request the user is paying for, which is the arrangement they opted out of. + * Several of their own keys blend with each other, as a deployment's would. + */ +export async function searchProviderFor( + store: Store, + sub: string | undefined, +): Promise { + const mine = connectedProviders(store, sub, "search"); + if (mine.length === 0) return createSearchProvider(); + + const cfg = getConfig().search; + const built: SearchProvider[] = []; + for (const id of mine) { + const key = await credentialFor(store, sub, "search", id); + const p = key ? backend(id, key, undefined, cfg.maxResults) : null; + if (p) built.push(p); + } + if (built.length === 0) return createSearchProvider(); // every one of theirs failed to resolve + return built.length === 1 ? built[0]! : new BlendSearchProvider(built, cfg.maxResults); +} + export function createSearchProvider( cfg: Config["search"] = getConfig().search, ): SearchProvider | null { diff --git a/src/settings.ts b/src/settings.ts index 8f2f852..edfdf43 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -40,7 +40,13 @@ const ProviderModelSchema = v.object({ * the /v1 inference path). */ const ProviderOAuthSchema = v.object({ - flow: v.picklist(["hyper-device"]), + /** + * A flow name from src/oauthflows.ts. A plain string rather than a closed + * list: the registry is the authority on what kloe implements, and a config + * naming one it doesn't simply offers no Connect button — better than a + * schema error at boot over a provider nobody uses. + */ + flow: v.string(), baseUrl: v.string(), }); diff --git a/src/store.ts b/src/store.ts index 7d4d2f3..7d59635 100644 --- a/src/store.ts +++ b/src/store.ts @@ -510,6 +510,7 @@ CREATE TABLE IF NOT EXISTS lard_tokens ( -- lost race does not mean a wasted request, it means the user is disconnected. CREATE TABLE IF NOT EXISTS user_credentials ( sub TEXT NOT NULL, + service TEXT NOT NULL DEFAULT 'inference', provider_id TEXT NOT NULL, kind TEXT NOT NULL, secret TEXT NOT NULL, @@ -518,13 +519,14 @@ CREATE TABLE IF NOT EXISTS user_credentials ( label TEXT, refresh_lease INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL, - PRIMARY KEY (sub, provider_id) + PRIMARY KEY (sub, service, provider_id) ); `; /** The stored row, ciphertext and all. Decrypted into a UserCredential by credentials.ts. */ export interface CredentialRow { sub: string; + service: string; provider_id: string; kind: string; secret: string; @@ -557,6 +559,7 @@ export interface LardToken { */ export interface UserCredential { sub: string; + service: "inference" | "search"; providerId: string; kind: "key" | "oauth"; secret: string; @@ -644,6 +647,27 @@ export class Store { } catch { // column already exists } + // Migration: a credential's identity gained the service it belongs to. The + // primary key changes, which SQLite only does by rebuilding — cheap here, + // and every existing row is an inference credential by construction. + try { + const cols = this.db.query("PRAGMA table_info(user_credentials)").all() as Array<{ + name: string; + }>; + if (cols.length > 0 && !cols.some((c) => c.name === "service")) { + this.db.exec("ALTER TABLE user_credentials RENAME TO user_credentials_old"); + this.db.exec(SCHEMA); + this.db.exec( + `INSERT INTO user_credentials + (sub, service, provider_id, kind, secret, refresh_token, expires_at, label, refresh_lease, updated_at) + SELECT sub, 'inference', provider_id, kind, secret, refresh_token, expires_at, label, refresh_lease, updated_at + FROM user_credentials_old`, + ); + this.db.exec("DROP TABLE user_credentials_old"); + } + } catch { + // already migrated + } // Migration: curation gained a role dimension. It began as a single // guest_visible flag, which is a column per role and does not survive a // third one; the flag's meaning carries over as the wildcard. @@ -1467,39 +1491,44 @@ export class Store { // rows, so a caller that forgets to encrypt is a compile error there rather // than a plaintext key here. - getCredentialRow(sub: string, providerId: string): CredentialRow | undefined { + getCredentialRow(sub: string, service: string, providerId: string): CredentialRow | undefined { return ( (this.db .query( - `SELECT sub, provider_id, kind, secret, refresh_token, expires_at, label, refresh_lease, updated_at - FROM user_credentials WHERE sub = ? AND provider_id = ?`, + `SELECT sub, service, provider_id, kind, secret, refresh_token, expires_at, label, refresh_lease, updated_at + FROM user_credentials WHERE sub = ? AND service = ? AND provider_id = ?`, ) - .get(sub, providerId) as CredentialRow | null) ?? undefined + .get(sub, service, providerId) as CredentialRow | null) ?? undefined ); } - listCredentialRows(sub: string): CredentialRow[] { - return this.db - .query( - `SELECT sub, provider_id, kind, secret, refresh_token, expires_at, label, refresh_lease, updated_at - FROM user_credentials WHERE sub = ? ORDER BY provider_id`, - ) - .all(sub) as CredentialRow[]; + listCredentialRows(sub: string, service?: string): CredentialRow[] { + const cols = `sub, service, provider_id, kind, secret, refresh_token, expires_at, label, refresh_lease, updated_at`; + return service + ? (this.db + .query( + `SELECT ${cols} FROM user_credentials WHERE sub = ? AND service = ? ORDER BY provider_id`, + ) + .all(sub, service) as CredentialRow[]) + : (this.db + .query(`SELECT ${cols} FROM user_credentials WHERE sub = ? ORDER BY service, provider_id`) + .all(sub) as CredentialRow[]); } setCredentialRow(row: Omit): void { this.db .query( `INSERT INTO user_credentials - (sub, provider_id, kind, secret, refresh_token, expires_at, label, refresh_lease, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?) - ON CONFLICT(sub, provider_id) DO UPDATE SET + (sub, service, provider_id, kind, secret, refresh_token, expires_at, label, refresh_lease, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?) + ON CONFLICT(sub, service, provider_id) DO UPDATE SET kind=excluded.kind, secret=excluded.secret, refresh_token=excluded.refresh_token, expires_at=excluded.expires_at, label=excluded.label, refresh_lease=0, updated_at=excluded.updated_at`, ) .run( row.sub, + row.service, row.provider_id, row.kind, row.secret, @@ -1510,10 +1539,10 @@ export class Store { ); } - deleteCredential(sub: string, providerId: string): void { + deleteCredential(sub: string, service: string, providerId: string): void { this.db - .query("DELETE FROM user_credentials WHERE sub = ? AND provider_id = ?") - .run(sub, providerId); + .query("DELETE FROM user_credentials WHERE sub = ? AND service = ? AND provider_id = ?") + .run(sub, service, providerId); } /** @@ -1521,14 +1550,20 @@ export class Store { * holds it. Returns false when someone else is mid-exchange — the caller * waits for their result rather than racing them into a revoked token. */ - claimRefresh(sub: string, providerId: string, now: number, until: number): boolean { + claimRefresh( + sub: string, + service: string, + providerId: string, + now: number, + until: number, + ): boolean { return ( this.db .query( `UPDATE user_credentials SET refresh_lease = ? - WHERE sub = ? AND provider_id = ? AND refresh_lease < ?`, + WHERE sub = ? AND service = ? AND provider_id = ? AND refresh_lease < ?`, ) - .run(until, sub, providerId, now).changes > 0 + .run(until, sub, service, providerId, now).changes > 0 ); } diff --git a/src/tools.ts b/src/tools.ts index 641ceab..230b647 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -777,8 +777,10 @@ const REGISTRY: Array<{ { name: "web_search", executor: "in-proc", - create: () => { - const p = createSearchProvider(); + create: (ctx) => { + // Resolved by the caller when a user pays for their own engines; falling + // back here keeps every non-run caller (tests, scripts) working. + const p = ctx.search ?? createSearchProvider(); return p ? webSearch(p) : null; }, }, @@ -790,7 +792,9 @@ const REGISTRY: Array<{ // without extraction reads nothing, extraction without discovery finds // nothing. Missing any of the three and the tool is simply not offered. if (!ctx.model || !getConfig().research.enabled) return null; - const search = createSearchProvider(); + // A research run is the heaviest search consumer there is, so it spends + // the same engines the plain tool does — a user's own when they have any. + const search = ctx.search ?? createSearchProvider(); const fetcher = createFetchProvider(); return search && fetcher ? deepResearch(ctx.model, search, fetcher, ctx) : null; }, @@ -965,6 +969,12 @@ export interface ToolContext { */ role?: Role; conversationId?: string; + /** + * The search provider this run should use, already resolved — a user's own + * engines when they connected any. Resolving needs a credential lookup, which + * is async, and building a tool set is not. + */ + search?: SearchProvider; /** * The run's own model, already resolved. `deep_research` runs its subagent on * it, so the research reasons as well as the conversation does. diff --git a/src/usermodels.ts b/src/usermodels.ts index 39778220252fc4424b3b6975c4c93c9de9252144..92857e674682c928124501ad2252a2eaad9f2303 100644 GIT binary patch delta 404 zcmaDaw?<)tg=1P~UW#*mUS4W)Nq&(+twLH+ey)O&o_;b+tXRpKD>Jtszo z{)z^0ZKrxqzB73G(v zXMn6pNL9#At<+IS%u4}FCnuI9=H#bC%*)QpFE0jZElO3$EK$hGEK4oc<5B>*s<;G1 wLW9dz0VVh}kUXg~S%*`B2O22(MfQ5BlcP8jxRF#$-og2W6Ru$MR<2zv02 { const base = loadConfig({ path: "does-not-exist.json", env: {} }); setConfig({ ...base, security: { credentialKey: "" } } as never); expect(() => encryptSecret("sk-x")).toThrow(/credentialKey/); - expect(byokAllowed("hyper")).toBe(false); + expect(byokAllowed("inference", "hyper")).toBe(false); }); test("the hint identifies a key without handing it back", () => { @@ -73,27 +82,35 @@ test("the hint identifies a key without handing it back", () => { test("a user with nothing of their own falls back to the deployment key", async () => { configure(); - expect(await credentialFor(memStore(), SUB, "hyper")).toBeUndefined(); - expect(await credentialFor(memStore(), undefined, "hyper")).toBeUndefined(); + expect(await credentialFor(memStore(), SUB, "inference", "hyper")).toBeUndefined(); + expect(await credentialFor(memStore(), undefined, "inference", "hyper")).toBeUndefined(); }); test("a pasted key is what gets sent", async () => { configure(); const store = memStore(); - saveApiKey(store, SUB, "hyper", "sk-mine-9999"); - expect(await credentialFor(store, SUB, "hyper")).toBe("sk-mine-9999"); + saveApiKey(store, SUB, "inference", "hyper", "sk-mine-9999"); + expect(await credentialFor(store, SUB, "inference", "hyper")).toBe("sk-mine-9999"); expect(listConnections(store, SUB)).toEqual([ - { providerId: "hyper", kind: "key", label: "••••9999", expiresAt: undefined }, + { + service: "inference", + providerId: "hyper", + kind: "key", + label: "••••9999", + expiresAt: undefined, + }, ]); - disconnect(store, SUB, "hyper"); - expect(await credentialFor(store, SUB, "hyper")).toBeUndefined(); + disconnect(store, SUB, "inference", "hyper"); + expect(await credentialFor(store, SUB, "inference", "hyper")).toBeUndefined(); }); test("a provider that doesn't take user keys says so", () => { configure(); - expect(byokAllowed("closed")).toBe(false); - expect(() => saveApiKey(memStore(), SUB, "closed", "sk-x")).toThrow(/does not accept/); + expect(byokAllowed("inference", "closed")).toBe(false); + expect(() => saveApiKey(memStore(), SUB, "inference", "closed", "sk-x")).toThrow( + /does not accept/, + ); }); test("a live access token is used as-is, without an exchange", async () => { @@ -102,6 +119,7 @@ test("a live access token is used as-is, without an exchange", async () => { saveOAuthGrant( store, SUB, + "inference", "hyper", { accessToken: "at-live", refreshToken: "rt-1", expiresAt: Date.now() + 3_600_000 }, "Team Kieran", @@ -109,13 +127,13 @@ test("a live access token is used as-is, without an exchange", async () => { const never = (async () => { throw new Error("should not have exchanged"); }) as unknown as typeof fetch; - expect(await credentialFor(store, SUB, "hyper", never)).toBe("at-live"); + expect(await credentialFor(store, SUB, "inference", "hyper", never)).toBe("at-live"); }); test("an expired token is exchanged, and the rotated pair is what gets stored", async () => { configure(); const store = memStore(); - saveOAuthGrant(store, SUB, "hyper", { + saveOAuthGrant(store, SUB, "inference", "hyper", { accessToken: "at-old", refreshToken: "rt-old", expiresAt: Date.now() - 1000, @@ -130,12 +148,12 @@ test("an expired token is exchanged, and the rotated pair is what gets stored", ); }) as unknown as typeof fetch; - expect(await credentialFor(store, SUB, "hyper", fetchImpl)).toBe("at-new"); + expect(await credentialFor(store, SUB, "inference", "hyper", fetchImpl)).toBe("at-new"); expect(sentRefresh).toBe("rt-old"); // The old refresh token is revoked upstream, so the new one MUST be what we // hold — this is the assertion that stands between a user and a dead link. - const row = store.getCredentialRow(SUB, "hyper")!; + const row = store.getCredentialRow(SUB, "inference", "hyper")!; expect(decryptSecret(row.refresh_token!)).toBe("rt-new"); expect(decryptSecret(row.secret)).toBe("at-new"); expect(row.expires_at).toBeGreaterThan(Date.now()); @@ -144,7 +162,7 @@ test("an expired token is exchanged, and the rotated pair is what gets stored", test("a refused refresh disconnects rather than failing every later run", async () => { configure(); const store = memStore(); - saveOAuthGrant(store, SUB, "hyper", { + saveOAuthGrant(store, SUB, "inference", "hyper", { accessToken: "at-old", refreshToken: "rt-revoked", expiresAt: Date.now() - 1000, @@ -152,16 +170,16 @@ test("a refused refresh disconnects rather than failing every later run", async const fetchImpl = (async () => new Response("refresh token revoked", { status: 401 })) as unknown as typeof fetch; - expect(await credentialFor(store, SUB, "hyper", fetchImpl)).toBeUndefined(); + expect(await credentialFor(store, SUB, "inference", "hyper", fetchImpl)).toBeUndefined(); // Gone, so the next run quietly uses the deployment's key and settings shows // the connection as absent. - expect(store.getCredentialRow(SUB, "hyper")).toBeUndefined(); + expect(store.getCredentialRow(SUB, "inference", "hyper")).toBeUndefined(); }); test("two runs refreshing at once exchange the token exactly once", async () => { configure(); const store = memStore(); - saveOAuthGrant(store, SUB, "hyper", { + saveOAuthGrant(store, SUB, "inference", "hyper", { accessToken: "at-old", refreshToken: "rt-old", expiresAt: Date.now() - 1000, @@ -178,8 +196,8 @@ test("two runs refreshing at once exchange the token exactly once", async () => }) as unknown as typeof fetch; const [a, b] = await Promise.all([ - credentialFor(store, SUB, "hyper", fetchImpl), - credentialFor(store, SUB, "hyper", fetchImpl), + credentialFor(store, SUB, "inference", "hyper", fetchImpl), + credentialFor(store, SUB, "inference", "hyper", fetchImpl), ]); // A second exchange would have revoked the token the first one just stored, @@ -192,8 +210,8 @@ test("two runs refreshing at once exchange the token exactly once", async () => test("one user's credential is invisible to another", async () => { configure(); const store = memStore(); - saveApiKey(store, SUB, "hyper", "sk-theirs-1111"); - expect(await credentialFor(store, "https://someone.new/", "hyper")).toBeUndefined(); + saveApiKey(store, SUB, "inference", "hyper", "sk-theirs-1111"); + expect(await credentialFor(store, "https://someone.new/", "inference", "hyper")).toBeUndefined(); }); // ---- the device flow ------------------------------------------------------ @@ -247,3 +265,67 @@ test("an incomplete exchange is an error, not a half-stored connection", async ( })) as unknown as typeof fetch; await expect(exchangeToken("https://hyper.test", "rt-1", half)).rejects.toThrow(/incomplete/); }); + +// ---- connectors ------------------------------------------------------------ +// The service is part of a credential's identity, not a suffix on its name. + +test("the same provider name under two services is two accounts", async () => { + configure(); + const store = memStore(); + saveApiKey(store, SUB, "inference", "hyper", "sk-inference-1111"); + saveApiKey(store, SUB, "search", "exa", "sk-search-2222"); + + expect(await credentialFor(store, SUB, "inference", "hyper")).toBe("sk-inference-1111"); + expect(await credentialFor(store, SUB, "search", "exa")).toBe("sk-search-2222"); + // Neither leaks into the other's service. + expect(await credentialFor(store, SUB, "search", "hyper")).toBeUndefined(); + expect(await credentialFor(store, SUB, "inference", "exa")).toBeUndefined(); + + // …and disconnecting one leaves the other alone. + disconnect(store, SUB, "search", "exa"); + expect(await credentialFor(store, SUB, "inference", "hyper")).toBe("sk-inference-1111"); +}); + +test("a search engine this deployment never configured is still connectable", () => { + configure(); + const offered = connectableProviders(); + const exa = offered.find((c) => c.service === "search" && c.id === "exa"); + expect(exa?.byok).toBe(true); + // Marked so the UI can say the instance has no key of its own for it. + expect(exa?.userOnly).toBe(true); +}); + +test("a user's own engines replace the deployment's rather than blending with them", async () => { + configure(); + const store = memStore(); + // Nothing connected: whatever the deployment offers, which with no key + // configured is the keyless fallback. + expect(await searchProviderFor(store, SUB)).toBeInstanceOf(DuckDuckGoSearchProvider); + + saveApiKey(store, SUB, "search", "exa", "sk-exa-user"); + const mine = await searchProviderFor(store, SUB); + expect(mine).toBeInstanceOf(ExaSearchProvider); + + // Two of their own blend with each other. + saveApiKey(store, SUB, "search", "ceramic", "sk-ceramic-user"); + expect(await searchProviderFor(store, SUB)).toBeInstanceOf(BlendSearchProvider); +}); + +test("an unimplemented oauth flow is not offered, however the config names it", () => { + const base = loadConfig({ path: "does-not-exist.json", env: {} }); + setConfig({ + ...base, + security: { credentialKey: "k" }, + providers: [ + { + id: "whatever", + apiEndpoint: "https://x.test/v1", + oauth: { flow: "not-a-real-flow", baseUrl: "https://x.test" }, + }, + ], + } as never); + // The connector drops it rather than offering a Connect button that can only + // fail once someone presses it. + expect(oauthFlow("inference", "whatever")).toBeUndefined(); + expect(flowNames()).toContain("hyper-device"); +});