diff --git a/src/client/views/connections.js b/src/client/views/connections.js index 573f9a5..f0789e3 100644 --- a/src/client/views/connections.js +++ b/src/client/views/connections.js @@ -93,12 +93,18 @@ var providerAccounts = { }) .forEach(function (p) { var conn = byKey[p.service + "/" + p.id]; - var row = providerRow(p, conn); - (conn || !p.userOnly ? mine : rest).push(row); + // Up top: what you have connected, what this instance runs, and + // anything you can sign into. A one-click connection is not something + // to make people go looking for. + var prominent = conn || !p.userOnly || p.oauth; + (prominent ? mine : rest).push(providerRow(p, conn)); }); - // Connected first within the group; the rest keep their alphabet. + // Connected first, then anything you can sign into, then the rest. mine.sort(function (a, b) { - return (b.connected ? 1 : 0) - (a.connected ? 1 : 0); + var rank = function (r) { + return r.connected ? 0 : r.canOAuth ? 1 : 2; + }; + return rank(a) - rank(b); }); if (mine.length) out.push({ title: g.title, rows: mine }); if (rest.length) out.push({ title: g.more, rows: rest, collapsed: true }); @@ -114,13 +120,16 @@ function providerRow(p, conn) { name: p.id, service: p.service, connected: !!conn, + canOAuth: !!p.oauth, tag: !conn && p.userOnly ? "bring your own" : "", status: conn ? conn.kind === "oauth" ? "Connected" + (conn.label ? " as " + conn.label : "") + ". Billed to you." : "Your key " + (conn.label || "") + ". Billed to you." : p.userOnly - ? "Not set up here. Connect an account to use it." + ? p.oauth + ? "Sign in and it’s yours to use, on your credits." + : "Not set up here. Connect an account to use it." : "Using this instance’s key.", actions: [], }; diff --git a/src/connectors.ts b/src/connectors.ts index 46fba1c..7c041d8 100644 --- a/src/connectors.ts +++ b/src/connectors.ts @@ -2,6 +2,7 @@ import { getCatalog } from "./inference"; import { flowFor } from "./oauthflows"; import { encryptionConfigured } from "./secrets"; import { getConfig, resolveRef } from "./settings"; +import { WELL_KNOWN } from "./wellknown"; /** * Everything a user can connect an account to, in one vocabulary. @@ -86,6 +87,23 @@ function inferenceConnectors(): Connector[] { }); } + // Services with a device flow, offered whether or not this instance runs + // them: the flow carries its own address and the credits are the user's, so + // there is nothing here for an operator to have configured. + for (const p of WELL_KNOWN) { + if (enabled.has(p.id)) continue; + enabled.add(p.id); + out.push({ + service: p.service, + id: p.id, + byok: true, + oauth: flowFor(p.oauth.flow) ? p.oauth : undefined, + endpoint: p.apiEndpoint, + type: p.type, + userOnly: true, + }); + } + // 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 diff --git a/src/http.ts b/src/http.ts index 1200a4c..10ee8fe 100644 --- a/src/http.ts +++ b/src/http.ts @@ -981,6 +981,14 @@ 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 (!credentialsReady()) { + // Storing it would throw on the encryption; say what is missing + // rather than returning a stack trace's worth of nothing. + return Response.json( + { error: "this instance has no security.credentialKey, so it cannot store a key" }, + { status: 503 }, + ); + } if (!isService(data.service) || !byokAllowed(data.service, data.providerId)) { return Response.json( { error: `"${data.providerId}" does not take a user key here` }, diff --git a/src/providers.ts b/src/providers.ts index 6f31c40..b2aed71 100644 --- a/src/providers.ts +++ b/src/providers.ts @@ -6,6 +6,7 @@ import type { Catalog, CatalogModel, ProviderType } from "./catalog"; import { parseModel } from "./catalog"; import { discoverModels, enrichModels } from "./discover"; import { resolveRef } from "./settings"; +import { wellKnownProvider } from "./wellknown"; /** * Ops config for one *enabled* provider — the deployment-specific layer. An @@ -306,11 +307,16 @@ export class ProviderRegistry { */ private borrowedConfig(providerId: string, apiKeyOverride?: string): ProviderConfig | undefined { if (!apiKeyOverride) return undefined; - const p = this.catalog.getProvider(providerId); - if (!p?.apiEndpoint) return undefined; + // The catalog first, then the services kloe knows how to connect to on its + // own — a user's hyper account has to resolve on an instance that never + // configured hyper, which is the whole point of offering it there. + const apiEndpoint = + this.catalog.getProvider(providerId)?.apiEndpoint ?? + wellKnownProvider(providerId)?.apiEndpoint; + if (!apiEndpoint) return undefined; return { id: providerId, - apiEndpoint: p.apiEndpoint, + apiEndpoint, maxConcurrency: DEFAULTS.maxConcurrency, minIntervalMs: DEFAULTS.minIntervalMs, }; diff --git a/src/wellknown.ts b/src/wellknown.ts new file mode 100644 index 0000000..6708882 --- /dev/null +++ b/src/wellknown.ts @@ -0,0 +1,39 @@ +/** + * Services kloe knows how to connect a USER to, whether or not this deployment + * runs them itself. + * + * The point is that an OAuth connection costs an operator nothing. Where a + * pasted key needs somewhere to send it, a device flow already carries its own + * address, and the credits it spends are the user's — so an instance that never + * configured hyper can still let someone bring their hyper account. Requiring + * config for that would be asking an operator to opt in on behalf of somebody + * else's money. + * + * A leaf module on purpose: the provider registry and the connector list both + * read it, and neither should have to import the other to learn where hyper is. + */ + +export interface WellKnownProvider { + service: "inference"; + id: string; + /** Adapter/enricher hint, same vocabulary as ops config. */ + type: string; + /** The inference API base. */ + apiEndpoint: string; + /** The device flow, and the app root its endpoints live at. */ + oauth: { flow: string; baseUrl: string }; +} + +export const WELL_KNOWN: WellKnownProvider[] = [ + { + service: "inference", + id: "hyper", + type: "hyper", + apiEndpoint: "https://hyper.charm.land/v1", + oauth: { flow: "hyper-device", baseUrl: "https://hyper.charm.land" }, + }, +]; + +export function wellKnownProvider(id: string): WellKnownProvider | undefined { + return WELL_KNOWN.find((p) => p.id === id); +} diff --git a/tests/credentials.test.ts b/tests/credentials.test.ts index ab74691..163b24b 100644 --- a/tests/credentials.test.ts +++ b/tests/credentials.test.ts @@ -71,7 +71,11 @@ test("without a configured key, nothing is stored in the clear", () => { const base = loadConfig({ path: "does-not-exist.json", env: {} }); setConfig({ ...base, security: { credentialKey: "" } } as never); expect(() => encryptSecret("sk-x")).toThrow(/credentialKey/); - expect(byokAllowed("inference", "hyper")).toBe(false); + // The provider is still OFFERED — hiding it reads as "nothing to connect" — + // and readiness is the separate thing that says why it won't work yet. + expect(byokAllowed("inference", "hyper")).toBe(true); + expect(credentialsReady()).toBe(false); + expect(() => saveApiKey(memStore(), SUB, "inference", "hyper", "sk-x")).toThrow(/credentialKey/); }); test("the hint identifies a key without handing it back", () => { @@ -361,3 +365,20 @@ test("with no key to encrypt with, providers are still listed and marked not rea // …and saving still refuses, so the promise the list makes is kept elsewhere. expect(() => saveApiKey(memStore(), SUB, "inference", "hyper", "sk-x")).toThrow(/credentialKey/); }); + +test("a service with a device flow is offered even where the instance runs none", () => { + const base = loadConfig({ path: "does-not-exist.json", env: {} }); + // A deployment with one unrelated provider and no hyper anywhere. + setConfig({ + ...base, + security: { credentialKey: "k" }, + providers: [{ id: "llmsolutions", apiEndpoint: "https://llmsolutions.top/v1" }], + } as never); + + const hyper = connectableProviders().find((c) => c.id === "hyper"); + // Signing in costs the operator nothing and spends the user's own credits, + // so it needs no opt-in from them. + expect(hyper?.oauth).toEqual({ flow: "hyper-device", baseUrl: "https://hyper.charm.land" }); + expect(hyper?.userOnly).toBe(true); + expect(oauthFlow("inference", "hyper")).toBeTruthy(); +});