diff --git a/src/client/app.css b/src/client/app.css index 98a845d..ecde686 100644 --- a/src/client/app.css +++ b/src/client/app.css @@ -3633,6 +3633,37 @@ body.lb-open { background: var(--bg-sunk); } +/* Pasting a credential file: wide enough to see it went in, with the line that + says where to get it directly underneath. */ +.connpastewrap { + flex-basis: 100%; + width: 100%; + margin-top: 10px; +} +.connpaste { + width: 100%; + padding: 8px 10px; + border: 1px solid var(--rule-strong); + border-radius: var(--radius-md); + background: var(--bg-sunk); + color: var(--ink); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 12px; + resize: vertical; +} +.connpaste:focus { + outline: none; + border-color: var(--accent); +} +.connhelp { + margin-top: 5px; + font-size: 12px; + color: var(--ink-muted); +} +.connhelp.bad { + color: var(--danger, #d9534f); +} + /* 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/connections.js b/src/client/views/connections.js index 81ad3e3..6624026 100644 --- a/src/client/views/connections.js +++ b/src/client/views/connections.js @@ -106,7 +106,8 @@ function providerRow(p, conn) { var path = "/api/credentials/" + encodeURIComponent(p.service) + "/" + encodeURIComponent(p.id); var row = { key: p.service + "/" + p.id, - name: p.id, + name: p.label || p.id, + logoKey: p.id, service: p.service, connected: !!conn, canOAuth: !!p.oauth, @@ -142,7 +143,31 @@ function providerRow(p, conn) { }, }); } - if (p.byok) { + if (p.paste) { + // A whole credential file, not a key: a textarea, and a line saying where + // to get it, since nobody guesses "run codex login" from a placeholder. + row.paste = { + placeholder: p.paste.label, + help: p.paste.help, + onSubmit: async function (value, ctx) { + var res = await fetch("/api/credentials", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ service: p.service, providerId: p.id, apiKey: value }), + }).catch(function () { + return { ok: false }; + }); + if (res.ok) { + ctx.reload(); + return null; + } + var body = await res.json().catch(function () { + return {}; + }); + return body.error || "That was refused."; + }, + }; + } else if (p.byok) { row.input = { placeholder: "Paste an API key", onSubmit: async function (value, ctx) { @@ -284,7 +309,7 @@ function renderRow(row, ctx) { text.className = "conntext"; var title = document.createElement("div"); title.className = "conntitle"; - var mark = row.service ? logoFor(row.service, row.name) : ""; + var mark = row.service ? logoFor(row.service, row.logoKey || row.name) : ""; if (mark) { // The logo IS the name when there is one; the text would only repeat it. var logo = document.createElement("span"); @@ -331,6 +356,34 @@ function renderRow(row, ctx) { }, }; + if (row.paste) { + var box = document.createElement("textarea"); + box.className = "connpaste"; + box.rows = 2; + box.placeholder = row.paste.placeholder; + box.spellcheck = false; + var help = document.createElement("div"); + help.className = "connhelp"; + help.textContent = row.paste.help; + var wrap = document.createElement("div"); + wrap.className = "connpastewrap"; + wrap.appendChild(box); + wrap.appendChild(help); + box.addEventListener("change", async function () { + var value = box.value.trim(); + if (!value) return; + box.disabled = true; + var err = await row.paste.onSubmit(value, rowCtx); + box.value = ""; + box.disabled = false; + if (err) { + help.textContent = err; + help.classList.add("bad"); + } + }); + el.appendChild(wrap); + } + if (row.input) { var input = document.createElement("input"); input.type = "password"; diff --git a/src/connectors.ts b/src/connectors.ts index 7c041d8..a769249 100644 --- a/src/connectors.ts +++ b/src/connectors.ts @@ -31,10 +31,19 @@ export interface Connector { service: Service; /** Provider id within the service — "hyper", "exa". */ id: string; + /** What to call it, when the id is not what people say. */ + label?: 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; + /** + * Connect by pasting a credential a local tool already holds, for a provider + * whose sign-in a server cannot run on the user's behalf. + */ + paste?: { flow: string; label: string; help: string }; + /** Models nothing enumerates, carried by the provider's own entry. */ + models?: Array<{ id: string; name: string }>; /** Where the provider's API lives, for a per-user client. */ endpoint?: string; /** Adapter/type hint, for a per-user client. */ @@ -93,11 +102,18 @@ function inferenceConnectors(): Connector[] { for (const p of WELL_KNOWN) { if (enabled.has(p.id)) continue; enabled.add(p.id); + const paste = p.paste && flowFor(p.paste.flow) ? p.paste : undefined; out.push({ service: p.service, id: p.id, - byok: true, - oauth: flowFor(p.oauth.flow) ? p.oauth : undefined, + label: p.label, + // A provider you connect by pasting a whole credential file has no + // separate "here is my API key" path; offering both would be two boxes + // for one job. + byok: !paste, + oauth: p.oauth && flowFor(p.oauth.flow) ? p.oauth : undefined, + paste, + models: p.models, endpoint: p.apiEndpoint, type: p.type, userOnly: true, diff --git a/src/credentials.ts b/src/credentials.ts index 05ca56b..936c966 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -45,10 +45,21 @@ function decode(row: CredentialRow): UserCredential { refreshToken: row.refresh_token ? decryptSecret(row.refresh_token) : undefined, expiresAt: row.expires_at ?? undefined, label: row.label ?? undefined, + meta: parseMeta(row.meta), updatedAt: row.updated_at, }; } +function parseMeta(raw: string | null): Record | undefined { + if (!raw) return undefined; + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? (parsed as Record) : undefined; + } catch { + return undefined; + } +} + /** Whether a user may paste their own key here. */ export function byokAllowed(service: Service, providerId: string): boolean { return findConnector(service, providerId)?.byok === true; @@ -62,7 +73,7 @@ export function oauthFlow(service: Service, providerId: string): Connector["oaut /** Everything a user could connect an account to, for the settings page. */ export function connectableProviders(): Connector[] { - return listConnectors().filter((c) => c.byok || c.oauth); + return listConnectors().filter((c) => c.byok || c.oauth || c.paste); } export function saveApiKey( @@ -84,9 +95,30 @@ export function saveApiKey( refresh_token: null, expires_at: null, label: hint(apiKey), + meta: null, }); } +/** + * Store a credential a user pasted from a tool on their own machine. + * + * The flow parses it, so what counts as valid — and what to say when it isn't — + * belongs to whoever knows that provider, not here. + */ +export function saveTokenBundle( + store: Store, + sub: string, + service: Service, + providerId: string, + pasted: string, +): void { + const connector = findConnector(service, providerId); + const flow = connector?.paste && flowFor(connector.paste.flow); + if (!flow?.parse) throw new Error(`"${providerId}" is not connected by pasting a credential`); + const parsed = flow.parse(pasted); + saveOAuthGrant(store, sub, service, providerId, parsed.pair, parsed.label, parsed.meta); +} + /** Store a fresh OAuth grant. Called with the pair an exchange just produced. */ export function saveOAuthGrant( store: Store, @@ -95,6 +127,7 @@ export function saveOAuthGrant( providerId: string, pair: { accessToken: string; refreshToken: string; expiresAt: number }, label?: string, + meta?: Record, ): void { store.setCredentialRow({ sub, @@ -105,6 +138,9 @@ export function saveOAuthGrant( refresh_token: encryptSecret(pair.refreshToken), expires_at: pair.expiresAt, label: label ?? null, + // Not encrypted: an account id is an identifier the provider puts in a + // header, not a thing that grants anything on its own. + meta: meta ? JSON.stringify(meta) : null, }); } @@ -141,9 +177,15 @@ export function connectedProviders( return store.listCredentialRows(sub, service).map((r) => r.provider_id); } +/** What a request needs to act as this user: the secret, and anything beside it. */ +export interface Credential { + secret: string; + meta?: Record; +} + /** - * The bearer string to use for this user and provider, or undefined when they - * have none and the deployment's own credential should be used. + * The credential to use for this user and provider, or undefined when they + * have none and the deployment's own should be used. * * Never throws on a broken connection: a revoked or unrefreshable credential * resolves to undefined, so the run falls back rather than failing outright. @@ -155,7 +197,7 @@ export async function credentialFor( service: Service, providerId: string, fetchImpl: typeof fetch = fetch, -): Promise { +): Promise { if (!sub || !encryptionConfigured()) return undefined; const row = store.getCredentialRow(sub, service, providerId); if (!row) return undefined; @@ -170,9 +212,12 @@ export async function credentialFor( return undefined; } - if (cred.kind === "key") return cred.secret; - if (cred.expiresAt && cred.expiresAt - REFRESH_SKEW_MS > Date.now()) return cred.secret; - return refresh(store, cred, fetchImpl); + if (cred.kind === "key") return { secret: cred.secret }; + if (cred.expiresAt && cred.expiresAt - REFRESH_SKEW_MS > Date.now()) { + return { secret: cred.secret, meta: cred.meta }; + } + const refreshed = await refresh(store, cred, fetchImpl); + return refreshed ? { secret: refreshed, meta: cred.meta } : undefined; } /** @@ -187,9 +232,11 @@ async function refresh( cred: UserCredential, fetchImpl: typeof fetch, ): Promise { - const ref = oauthFlow(cred.service, cred.providerId); - const flow = ref && flowFor(ref.flow); - if (!ref || !flow || !cred.refreshToken) return undefined; + const connector = findConnector(cred.service, cred.providerId); + const named = connector?.oauth?.flow ?? connector?.paste?.flow; + const flow = named ? flowFor(named) : undefined; + const baseUrl = connector?.oauth?.baseUrl ?? ""; + if (!flow || !cred.refreshToken) return undefined; const now = Date.now(); if (!store.claimRefresh(cred.sub, cred.service, cred.providerId, now, now + REFRESH_LEASE_MS)) { @@ -197,10 +244,10 @@ async function refresh( } try { - 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.service, cred.providerId, pair, cred.label); + const pair = await flow.exchange(baseUrl, cred.refreshToken, fetchImpl); + // Persist BEFORE returning: a rotated token we didn't store is a connection + // thrown away. + saveOAuthGrant(store, cred.sub, cred.service, cred.providerId, pair, cred.label, cred.meta); return pair.accessToken; } catch (e) { // The grant is gone (revoked, expired, or the provider said no). Drop the diff --git a/src/http.ts b/src/http.ts index 202774f..81efe4b 100644 --- a/src/http.ts +++ b/src/http.ts @@ -18,7 +18,7 @@ import { } from "./auth"; import type { BlobStore } from "./blobs"; import { ACTOR_IDLE_TTL_MS, SSE_RETRY_MS, SUBSCRIBER_HEARTBEAT_MS } from "./config"; -import { credentialsReady, isService } from "./connectors"; +import { credentialsReady, findConnector, isService } from "./connectors"; import { byokAllowed, connectableProviders, @@ -27,6 +27,7 @@ import { oauthFlow, saveApiKey, saveOAuthGrant, + saveTokenBundle, } from "./credentials"; import { Event, type EventData, type EventName, parseEventId } from "./events"; import { getExecutor } from "./executor"; @@ -314,10 +315,11 @@ function requireUsableModel( } /** - * Whose settings a request is touching. With auth off there is one user and - * `LOCAL_SUB` is them; the two must agree, or a preference gets written under - * one name and read under another (which is exactly what happened to the - * per-user model list). + * Whose request this is. With auth off there is one user and `LOCAL_SUB` is + * them; every path must agree, or a credential gets stored under one name and + * looked up under another — which is how a connected account turned into + * "unknown model" the first time, and how a hidden model came back the time + * before that. */ function whoami(req: Request, store: Store): string { return getSession(req, store)?.sub ?? LOCAL_SUB; @@ -965,13 +967,29 @@ export function apiRoutes(deps: { store: Store; blobs: BlobStore; kick?: () => v { status: 503 }, ); } - if (!isService(data.service) || !byokAllowed(data.service, data.providerId)) { + if (!isService(data.service)) { + return Response.json({ error: "unknown service" }, { status: 422 }); + } + const service = data.service; + const connector = findConnector(service, data.providerId); + if (!connector || !(connector.byok || connector.paste)) { return Response.json( - { error: `"${data.providerId}" does not take a user key here` }, + { error: `"${data.providerId}" does not take a user credential here` }, { status: 422 }, ); } - saveApiKey(store, sub, data.service, data.providerId, data.apiKey); + try { + // A provider connected by pasting a whole credential file parses it + // through its own flow; one connected by key just stores the key. + if (connector.paste) { + saveTokenBundle(store, sub, service, data.providerId, data.apiKey); + } else { + saveApiKey(store, sub, service, data.providerId, data.apiKey); + } + } catch (e) { + // The flow's message is written for whoever pasted it. + return Response.json({ error: (e as Error).message }, { status: 422 }); + } forgetUserModels(sub, data.providerId); return Response.json({ connections: listConnections(store, sub) }); }), @@ -997,7 +1015,7 @@ export function apiRoutes(deps: { store: Store; blobs: BlobStore; kick?: () => v const service = req.params.service; const ref = isService(service) ? oauthFlow(service, req.params.providerId) : undefined; const flow = ref && flowFor(ref.flow); - if (!ref || !flow) { + if (!ref || !flow?.start) { return Response.json({ error: "no device flow for this provider" }, { status: 422 }); } try { @@ -1019,7 +1037,7 @@ export function apiRoutes(deps: { store: Store; blobs: BlobStore; kick?: () => v const providerId = req.params.providerId; const ref = isService(service) ? oauthFlow(service, providerId) : undefined; const flow = ref && flowFor(ref.flow); - if (!ref || !flow || !isService(service)) { + if (!ref || !flow?.poll || !isService(service)) { return Response.json({ error: "no device flow for this provider" }, { status: 422 }); } const sub = getSession(req, store)?.sub ?? LOCAL_SUB; @@ -1143,7 +1161,7 @@ export function apiRoutes(deps: { store: Store; blobs: BlobStore; kick?: () => v data, store, requestRole(req, store), - getSession(req, store)?.sub, + whoami(req, store), ); kick(); return res; @@ -1166,7 +1184,7 @@ export function apiRoutes(deps: { store: Store; blobs: BlobStore; kick?: () => v data, store, requestRole(req, store), - getSession(req, store)?.sub, + whoami(req, store), ); kick(); return res; diff --git a/src/inference.ts b/src/inference.ts index 4ee2880..21c7a7b 100644 --- a/src/inference.ts +++ b/src/inference.ts @@ -3,7 +3,7 @@ import type { RunStep } from "./actor"; import type { Role } from "./auth"; import type { BlobStore } from "./blobs"; import { type Catalog, type LoadCatalogOptions, loadCatalog } from "./catalog"; -import { credentialFor } from "./credentials"; +import { type Credential, credentialFor } from "./credentials"; import type { TokenUsage } from "./events"; import { contextToText, getContext, LOCAL_SUB, lardConnected, lardEnabled } from "./lard"; import { buildSystemPrompt } from "./prompt"; @@ -328,8 +328,8 @@ export function modelSupportsImages(modelRef: string): boolean { } /** Resolves a model ref to a rate-limited LanguageModel. */ -export function resolveModel(modelRef: string, apiKey?: string): LanguageModel { - const model = getRegistry().resolveModel(modelRef, apiKey); +export function resolveModel(modelRef: string, credential?: Credential): LanguageModel { + const model = getRegistry().resolveModel(modelRef, credential); const providerName = modelRef.split("/")[0]!; const limiter = limiterFor(providerName); return limiter ? limiter.wrap(model) : model; @@ -350,8 +350,8 @@ 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, "inference", providerName); - return resolveModel(modelRef, key); + const credential = await credentialFor(who.store, who.sub, "inference", providerName); + return resolveModel(modelRef, credential); } /** Project-scoped context to fold into a run: the pinned lard project (whose diff --git a/src/oauthflows.ts b/src/oauthflows.ts index 1b87d63..93ad621 100644 --- a/src/oauthflows.ts +++ b/src/oauthflows.ts @@ -21,11 +21,27 @@ import { * rather than the browser in front of them. */ +/** What a pasted credential file yields: a grant, plus whatever else it carries. */ +export interface ParsedGrant { + pair: TokenPair; + /** Provider-specific bits a request needs later, e.g. an account id. */ + meta?: Record; + label?: string; +} + 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; + /** + * Ask the provider for a code the user types in, and where to type it. + * Absent for a provider kloe cannot sign into on the user's behalf. + */ + start?(baseUrl: string, deviceName: string, fetchImpl?: typeof fetch): Promise; /** Has the user approved it yet? */ - poll(baseUrl: string, deviceCode: string, fetchImpl?: typeof fetch): Promise; + poll?(baseUrl: string, deviceCode: string, fetchImpl?: typeof fetch): Promise; + /** + * Read a credential file the user pasted, for a flow that has no `start`. + * Throws with something a person can act on when it isn't what it should be. + */ + parse?(pasted: string): ParsedGrant; /** * Trade a refresh token for an access token and its replacement. * @@ -42,8 +58,95 @@ const hyperDevice: DeviceFlow = { exchange: (baseUrl, refreshToken, fetchImpl) => exchangeToken(baseUrl, refreshToken, fetchImpl), }; +/** + * OpenAI's Codex, as the CLI holds it: an access token, a refresh token and the + * ChatGPT account id, kept in ~/.codex/auth.json. kloe can refresh it (that + * endpoint takes only the client id, which is public) but cannot obtain the + * first one, because the client's registered redirect is localhost:1455. + */ +const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; +const TIMEOUT_MS = 15_000; +const CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token"; + +/** Seconds until a JWT's `exp`, or a conservative hour when it won't parse. */ +function jwtExpiry(token: string): number { + try { + const part = token.split(".")[1] ?? ""; + const claims = JSON.parse(Buffer.from(part, "base64url").toString("utf8")) as { exp?: number }; + return claims.exp ? claims.exp * 1000 : Date.now() + 3_600_000; + } catch { + return Date.now() + 3_600_000; + } +} + +const codex: DeviceFlow = { + parse(pasted) { + let file: { + auth_mode?: string; + OPENAI_API_KEY?: string | null; + tokens?: { access_token?: string; refresh_token?: string; account_id?: string }; + }; + try { + file = JSON.parse(pasted); + } catch { + throw new Error("that is not JSON — paste the whole contents of ~/.codex/auth.json"); + } + const tokens = file.tokens; + if (!tokens?.access_token || !tokens.refresh_token) { + throw new Error( + file.OPENAI_API_KEY + ? "that file holds an API key rather than a ChatGPT sign-in; paste the key into the OpenAI row instead" + : "no tokens in that file — run `codex login` first", + ); + } + return { + pair: { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt: jwtExpiry(tokens.access_token), + }, + // The account id is a header on every request, not a secret; without it + // the endpoint answers 401 however good the token is. + meta: tokens.account_id ? { accountId: tokens.account_id } : undefined, + label: file.auth_mode === "chatgpt" ? "ChatGPT account" : file.auth_mode, + }; + }, + + async exchange(_baseUrl, refreshToken, fetchImpl = fetch) { + const res = await fetchImpl(CODEX_TOKEN_URL, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + client_id: CODEX_CLIENT_ID, + grant_type: "refresh_token", + refresh_token: refreshToken, + scope: "openid profile email", + }), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + if (!res.ok) throw new Error(`codex: refresh → ${res.status} ${await res.text()}`); + const data = (await res.json()) as { + access_token?: string; + refresh_token?: string; + id_token?: string; + expires_in?: number; + }; + if (!data.access_token) throw new Error("codex: refresh returned no access token"); + return { + accessToken: data.access_token, + // OpenAI may or may not rotate it; keeping the old one when it doesn't is + // the difference between a connection that lasts and one that expires. + refreshToken: data.refresh_token ?? refreshToken, + expiresAt: data.expires_in + ? Date.now() + data.expires_in * 1000 + : jwtExpiry(data.access_token), + }; + }, +}; + export const FLOWS: Record = { "hyper-device": hyperDevice, + codex, }; /** The named flow, or undefined — a config naming one kloe doesn't implement. */ diff --git a/src/providers.ts b/src/providers.ts index b2aed71..d68cec0 100644 --- a/src/providers.ts +++ b/src/providers.ts @@ -1,9 +1,11 @@ +import { randomUUID } from "node:crypto"; import { createAnthropic } from "@ai-sdk/anthropic"; import { createOpenAI } from "@ai-sdk/openai"; import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; -import type { LanguageModel } from "ai"; +import { type LanguageModel, wrapLanguageModel } from "ai"; import type { Catalog, CatalogModel, ProviderType } from "./catalog"; import { parseModel } from "./catalog"; +import type { Credential } from "./credentials"; import { discoverModels, enrichModels } from "./discover"; import { resolveRef } from "./settings"; import { wellKnownProvider } from "./wellknown"; @@ -265,7 +267,8 @@ export class ProviderRegistry { * required (no bare/default form): a ref must name both a provider and a * model, both must be enabled/known, or this throws. */ - resolveModel(modelRef: string, apiKeyOverride?: string): LanguageModel { + resolveModel(modelRef: string, credential?: Credential): LanguageModel { + const apiKeyOverride = credential?.secret; if (isEchoModel(modelRef)) { return createEchoModel(); } @@ -296,7 +299,7 @@ export class ProviderRegistry { throw new Error(`unknown model "${modelId}" for provider "${providerId}"`); } - return this.factoryFor(providerId, config, apiKeyOverride)(modelId); + return this.factoryFor(providerId, config, credential)(modelId); } /** @@ -331,8 +334,9 @@ export class ProviderRegistry { private factoryFor( providerId: string, config: ProviderConfig, - apiKeyOverride?: string, + credential?: Credential, ): ModelFactory { + const apiKeyOverride = credential?.secret; const catProvider = this.catalog.getProvider(providerId); const inline = this.inline.get(providerId); // Keyless is allowed when NO key is declared: it resolves to undefined and @@ -351,12 +355,17 @@ export class ProviderRegistry { // so it always wins here via config.apiEndpoint. const baseURL = resolveRef(config.apiEndpoint) ?? resolveRef(catProvider?.apiEndpoint); - const type = catProvider?.type ?? inline?.type ?? "openai-compat"; + // The well-known entry has a say too: a borrowed provider is in neither the + // catalog nor the ops file, and defaulting it to openai-compat sends a + // Responses endpoint a chat-completions body — which it answers 403, from a + // stack that names the wrong adapter. + const type = + catProvider?.type ?? inline?.type ?? wellKnownProvider(providerId)?.type ?? "openai-compat"; // A user's own credential builds a throwaway adapter rather than a cache // entry. Keeping one per credential would hold a closure for every user and // every hourly token rotation, to save an object construction — and the // entry most likely to be reused is the one most likely to be stale. - if (apiKeyOverride) return buildFactory(providerId, type, apiKey, baseURL); + if (apiKeyOverride) return buildFactory(providerId, type, apiKey, baseURL, credential?.meta); const cached = this.factories.get(providerId); if (cached && cached.apiKey === apiKey && cached.baseURL === baseURL) { @@ -374,6 +383,7 @@ function buildFactory( type: ProviderType, apiKey: string | undefined, baseURL: string | undefined, + meta?: Record, ): ModelFactory { // Omit the credential entirely when there isn't one, so keyless (local) // endpoints work and keyed ones are unchanged. @@ -387,6 +397,43 @@ function buildFactory( const p = createOpenAI({ ...key, ...(baseURL ? { baseURL } : {}) }); return (modelId) => p(modelId); } + // OpenAI's Responses API, as the Codex CLI speaks it. A ChatGPT account is + // identified by a header rather than by the token, and the endpoint wants + // to be told which client it is talking to; without either it answers 401 + // to a perfectly good token. + case "openai-responses": { + const p = createOpenAI({ + ...key, + ...(baseURL ? { baseURL } : {}), + headers: { + ...(meta?.accountId ? { "chatgpt-account-id": meta.accountId } : {}), + "OpenAI-Beta": "responses=experimental", + originator: "codex_cli_rs", + session_id: randomUUID(), + }, + }); + // `store: false` is not a preference an operator might hold, it is what + // this endpoint requires: without it every request is a 400 saying so. + // It belongs with the adapter that knows the endpoint rather than in + // config somebody could forget. + // + // Keyed "openai" because the SDK namespaces provider options by adapter + // family, not by the id kloe knows the provider as — naming the adapter + // "codex" changes what `model.provider` reads and nothing else. + return (modelId) => + wrapLanguageModel({ + model: p.responses(modelId), + middleware: { + transformParams: async ({ params }) => ({ + ...params, + providerOptions: { + ...params.providerOptions, + openai: { store: false, ...params.providerOptions?.openai }, + }, + }), + }, + }); + } // openai-compat, openrouter, and any other type fall back to the // OpenAI-compatible adapter, which requires an explicit baseURL. default: { diff --git a/src/schemas.ts b/src/schemas.ts index b2684e6..cb59e5d 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -84,7 +84,13 @@ 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)), + /** + * The credential itself: an API key, or the whole credential file for a + * provider connected by pasting one. The ceiling is generous because a + * `~/.codex/auth.json` is a few kilobytes of JWTs, and mean enough that the + * endpoint is not a place to post a novel. + */ + apiKey: v.pipe(v.string(), v.minLength(8), v.maxLength(20_000)), }); export type CredentialBody = v.InferOutput; diff --git a/src/search.ts b/src/search.ts index 5625365..0d4732e 100644 --- a/src/search.ts +++ b/src/search.ts @@ -632,7 +632,7 @@ export async function searchProviderFor( 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; + const p = key ? backend(id, key.secret, undefined, cfg.maxResults) : null; if (p) built.push(p); } if (built.length === 1) return built[0]!; diff --git a/src/store.ts b/src/store.ts index 3033dcc..1707bb8 100644 --- a/src/store.ts +++ b/src/store.ts @@ -495,6 +495,7 @@ CREATE TABLE IF NOT EXISTS user_credentials ( refresh_token TEXT, expires_at INTEGER, label TEXT, + meta TEXT, refresh_lease INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL, PRIMARY KEY (sub, service, provider_id) @@ -511,6 +512,8 @@ export interface CredentialRow { refresh_token: string | null; expires_at: number | null; label: string | null; + /** JSON: non-secret bits a request needs, e.g. a ChatGPT account id. */ + meta: string | null; refresh_lease: number; updated_at: number; } @@ -539,6 +542,8 @@ export interface UserCredential { sub: string; service: "inference" | "search"; providerId: string; + /** Non-secret provider bits stored beside the token. */ + meta?: Record; kind: "key" | "oauth"; secret: string; refreshToken?: string; @@ -622,6 +627,13 @@ export class Store { } catch { // column already exists } + // Migration: room for the non-secret bits a provider needs alongside the + // token (a ChatGPT account id, so far). + try { + this.db.exec("ALTER TABLE user_credentials ADD COLUMN meta TEXT"); + } 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. @@ -634,8 +646,8 @@ export class Store { 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 + (sub, service, provider_id, kind, secret, refresh_token, expires_at, label, meta, refresh_lease, updated_at) + SELECT sub, 'inference', provider_id, kind, secret, refresh_token, expires_at, label, NULL, refresh_lease, updated_at FROM user_credentials_old`, ); this.db.exec("DROP TABLE user_credentials_old"); @@ -1528,7 +1540,7 @@ export class Store { return ( (this.db .query( - `SELECT sub, service, provider_id, kind, secret, refresh_token, expires_at, label, refresh_lease, updated_at + `SELECT sub, service, provider_id, kind, secret, refresh_token, expires_at, label, meta, refresh_lease, updated_at FROM user_credentials WHERE sub = ? AND service = ? AND provider_id = ?`, ) .get(sub, service, providerId) as CredentialRow | null) ?? undefined @@ -1536,7 +1548,7 @@ export class Store { } listCredentialRows(sub: string, service?: string): CredentialRow[] { - const cols = `sub, service, provider_id, kind, secret, refresh_token, expires_at, label, refresh_lease, updated_at`; + const cols = `sub, service, provider_id, kind, secret, refresh_token, expires_at, label, meta, refresh_lease, updated_at`; return service ? (this.db .query( @@ -1552,11 +1564,11 @@ export class Store { this.db .query( `INSERT INTO user_credentials - (sub, service, provider_id, kind, secret, refresh_token, expires_at, label, refresh_lease, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?) + (sub, service, provider_id, kind, secret, refresh_token, expires_at, label, meta, 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, + expires_at=excluded.expires_at, label=excluded.label, meta=excluded.meta, refresh_lease=0, updated_at=excluded.updated_at`, ) .run( @@ -1568,6 +1580,7 @@ export class Store { row.refresh_token, row.expires_at, row.label, + row.meta, Date.now(), ); } diff --git a/src/usermodels.ts b/src/usermodels.ts index 7f758e70d5feb9cc205458147c75fa2c410f504c..f2fa3ac7c100e44904fe656f51a81537d4230bc7 100644 GIT binary patch delta 331 zcmdm@wp(+yVuj3-Vuk$jJcXRh;u3|t z)YOz>g}i))#NzDCymTvtl8jV^)V!2}{LH)(h1|qSg}nR{E(HaBeTCG#(%jUd#FA8n z#JtLqjLf`r9fica6oqn-;UH~^$;tVp5MA{OMWuNPnIMx(5DH-CLd+}AFU?7TxFRvH zvOKX;k4r%zGfhDwIX^EiHMu0e$X+iuKP5G%*j_IuH7~s+LsOxeOF=6boA^ TAmsImQ; } export const WELL_KNOWN: WellKnownProvider[] = [ @@ -32,6 +49,33 @@ export const WELL_KNOWN: WellKnownProvider[] = [ apiEndpoint: "https://hyper.charm.land/v1", oauth: { flow: "hyper-device", baseUrl: "https://hyper.charm.land" }, }, + { + // A ChatGPT subscription, through the endpoint the Codex CLI uses. The + // sign-in is OpenAI's, and its client redirects only to localhost:1455 — + // so kloe cannot run it, and instead takes what `codex login` already + // stored. It refreshes from there on its own. + service: "inference", + id: "codex", + label: "ChatGPT (Codex)", + type: "openai-responses", + apiEndpoint: "https://chatgpt.com/backend-api/codex", + paste: { + flow: "codex", + label: "Paste ~/.codex/auth.json", + help: "Run `codex login` on your own machine, then paste the contents of ~/.codex/auth.json.", + }, + // This endpoint enumerates nothing, and what a ChatGPT plan may run is not + // what an API key may run: asking for a model outside the list is a 400 + // saying so. + models: [ + { id: "gpt-5.6-sol", name: "GPT-5.6 Sol" }, + { id: "gpt-5.6-terra", name: "GPT-5.6 Terra" }, + { id: "gpt-5.6-luna", name: "GPT-5.6 Luna" }, + { id: "gpt-5.5", name: "GPT-5.5" }, + { id: "gpt-5.4", name: "GPT-5.4" }, + { id: "gpt-5.4-mini", name: "GPT-5.4 Mini" }, + ], + }, ]; export function wellKnownProvider(id: string): WellKnownProvider | undefined { diff --git a/tests/credentials.test.ts b/tests/credentials.test.ts index 7bf0fa1..477fa09 100644 --- a/tests/credentials.test.ts +++ b/tests/credentials.test.ts @@ -9,6 +9,7 @@ import { oauthFlow, saveApiKey, saveOAuthGrant, + saveTokenBundle, } from "../src/credentials"; import { exchangeToken, pollDeviceAuth, startDeviceAuth } from "../src/hyperauth"; import { flowNames } from "../src/oauthflows"; @@ -96,7 +97,9 @@ test("a pasted key is what gets sent", async () => { configure(); const store = memStore(); saveApiKey(store, SUB, "inference", "hyper", "sk-mine-9999"); - expect(await credentialFor(store, SUB, "inference", "hyper")).toBe("sk-mine-9999"); + expect(await credentialFor(store, SUB, "inference", "hyper")).toMatchObject({ + secret: "sk-mine-9999", + }); expect(listConnections(store, SUB)).toEqual([ { service: "inference", @@ -133,7 +136,9 @@ 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, "inference", "hyper", never)).toBe("at-live"); + expect(await credentialFor(store, SUB, "inference", "hyper", never)).toMatchObject({ + secret: "at-live", + }); }); test("an expired token is exchanged, and the rotated pair is what gets stored", async () => { @@ -154,7 +159,9 @@ test("an expired token is exchanged, and the rotated pair is what gets stored", ); }) as unknown as typeof fetch; - expect(await credentialFor(store, SUB, "inference", "hyper", fetchImpl)).toBe("at-new"); + expect(await credentialFor(store, SUB, "inference", "hyper", fetchImpl)).toMatchObject({ + secret: "at-new", + }); expect(sentRefresh).toBe("rt-old"); // The old refresh token is revoked upstream, so the new one MUST be what we @@ -209,8 +216,8 @@ test("two runs refreshing at once exchange the token exactly once", async () => // A second exchange would have revoked the token the first one just stored, // leaving the user connected to nothing. expect(exchanges).toBe(1); - expect(a).toBe("at-new"); - expect(b).toBe("at-new"); + expect(a).toMatchObject({ secret: "at-new" }); + expect(b).toMatchObject({ secret: "at-new" }); }); test("one user's credential is invisible to another", async () => { @@ -286,15 +293,21 @@ test("the same provider name under two services is two accounts", async () => { 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"); + expect(await credentialFor(store, SUB, "inference", "hyper")).toMatchObject({ + secret: "sk-inference-1111", + }); + expect(await credentialFor(store, SUB, "search", "exa")).toMatchObject({ + secret: "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"); + expect(await credentialFor(store, SUB, "inference", "hyper")).toMatchObject({ + secret: "sk-inference-1111", + }); }); test("a search engine this deployment never configured is still connectable", () => { @@ -443,3 +456,88 @@ test("an engine you connected yourself is yours to use, whatever the role allows // Their key, their money: the role bounds the instance's engines, not theirs. expect(await searchProviderFor(store, SUB, "guest")).toBeInstanceOf(ExaSearchProvider); }); + +// ---- codex: a credential a local tool already holds ------------------------ + +test("a codex auth file is read into a grant, with the account id beside it", () => { + configure(); + const store = memStore(); + // Shape of ~/.codex/auth.json after `codex login`, tokens stubbed. + const authFile = JSON.stringify({ + auth_mode: "chatgpt", + OPENAI_API_KEY: null, + tokens: { id_token: "x", access_token: "at-1", refresh_token: "rt-1", account_id: "acc-1" }, + }); + saveTokenBundle(store, SUB, "inference", "codex", authFile); + + const row = store.getCredentialRow(SUB, "inference", "codex")!; + expect(decryptSecret(row.secret)).toBe("at-1"); + expect(decryptSecret(row.refresh_token!)).toBe("rt-1"); + // The account id rides along unencrypted: it is a header, not a secret, and + // the endpoint answers 401 without it however good the token is. + expect(JSON.parse(row.meta!)).toEqual({ accountId: "acc-1" }); + expect(row.label).toBe("ChatGPT account"); +}); + +test("a codex file that isn't one says what to do about it", () => { + configure(); + const store = memStore(); + expect(() => saveTokenBundle(store, SUB, "inference", "codex", "not json")).toThrow(/not JSON/); + expect(() => + saveTokenBundle(store, SUB, "inference", "codex", JSON.stringify({ tokens: {} })), + ).toThrow(/codex login/); + // An API-key file is a real thing to have, and belongs in a different row. + expect(() => + saveTokenBundle( + store, + SUB, + "inference", + "codex", + JSON.stringify({ OPENAI_API_KEY: "sk-1", tokens: {} }), + ), + ).toThrow(/API key/); +}); + +test("codex is offered everywhere, and refreshes itself", async () => { + configure(); + const codex = connectableProviders().find((c) => c.id === "codex"); + // No deployment configures it: the account is the user's and so is the bill. + expect(codex?.paste?.flow).toBe("codex"); + expect(codex?.byok).toBe(false); // one way in, not two boxes for one job + expect(codex?.models?.length).toBeGreaterThan(0); + + const store = memStore(); + saveTokenBundle( + store, + SUB, + "inference", + "codex", + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { access_token: "at-old", refresh_token: "rt-old", account_id: "acc-1" }, + }), + ); + // The pasted access token was minted long ago; kloe refreshes without being + // able to sign in, because refreshing needs only the public client id. + store.setCredentialRow({ + ...store.getCredentialRow(SUB, "inference", "codex")!, + expires_at: Date.now() - 1000, + }); + + let sent: Record = {}; + const fetchImpl = (async (_url: string, init: RequestInit) => { + sent = JSON.parse(init.body as string); + return new Response(JSON.stringify({ access_token: "at-new", expires_in: 3600 }), { + status: 200, + }); + }) as unknown as typeof fetch; + + const got = await credentialFor(store, SUB, "inference", "codex", fetchImpl); + expect(got).toMatchObject({ secret: "at-new", meta: { accountId: "acc-1" } }); + expect(sent.grant_type).toBe("refresh_token"); + // OpenAI need not rotate it; keeping the old one is what makes the + // connection last rather than expire on the next refresh. + expect(decryptSecret(store.getCredentialRow(SUB, "inference", "codex")!.refresh_token!)).toBe( + "rt-old", + ); +});