diff --git a/.env.example b/.env.example index c20c6c1e4..81f55dde1 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,13 @@ SIDECAR_CREDENTIAL_ENCRYPTION_KEY=2222222222222222222222222222222222222222222222 # RERANK_MAX_DOC_CHARS= # RERANK_TIMEOUT_MS= +# Adapters a sidecar loads for providers the stock runtime does not serve. +# A JSON list of {provider, specifier, export}; the specifier is imported at +# boot, so it is operator config only, never tenant or deploy data. These two +# entries are what "Continue with Codex" and "Continue with xAI" need in +# order to run inference once the sign-in has stored its credential. +SIDECAR_ADAPTER_MANIFEST=[{"provider":"codex","specifier":"@corbits/codex-provider","export":"createCodexResponsesAdapter"},{"provider":"xai","specifier":"@corbits/xai-provider","export":"createXaiResponsesAdapter"}] + # Sidecars run as child processes of the hub. Both optional. # PROCESS_PROVISIONER_SIDECAR_ENTRY= # PROCESS_PROVISIONER_RUNTIME= diff --git a/apps/hub/package.json b/apps/hub/package.json index f07f21626..01b04fe36 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -20,6 +20,7 @@ "@corbits/error-sink": "workspace:*", "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#65590a85fa143251b3ac20ba2eca92fc35e70e51", "@corbits/memory": "github:corbitsdev/corbits-memory#e74da20f148a302dff5400915fe504ee2395e913", + "@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core#e97d563b823506d72f83dead478dd9fa9dccfd4c", "@corbits/url-path": "workspace:*", "@corbits/webhooks": "github:corbitsdev/webhooks#3f4f83147fd826d3aec5bcac7ef4b0ae7f75c00d", "@corbits/workflows": "workspace:*", diff --git a/apps/hub/src/provisioners/process.ts b/apps/hub/src/provisioners/process.ts index 310322838..07113025c 100644 --- a/apps/hub/src/provisioners/process.ts +++ b/apps/hub/src/provisioners/process.ts @@ -315,6 +315,9 @@ function sidecarEnvFor( } const home = process.env["HOME"]; const tmpdir = process.env["TMPDIR"]; + // Operator config, not tenant data: without it a sidecar has no adapter + // for a provider the stock runtime does not serve (Codex, xAI). + const adapterManifest = process.env["SIDECAR_ADAPTER_MANIFEST"]; return { SIDECAR_DATA_DIR: sidecarDataDir, HUB_WS_URL: args.hubWebSocketUrl, @@ -324,6 +327,7 @@ function sidecarEnvFor( PATH: path, ...(home === undefined ? {} : { HOME: home }), ...(tmpdir === undefined ? {} : { TMPDIR: tmpdir }), + ...(adapterManifest === undefined ? {} : { SIDECAR_ADAPTER_MANIFEST: adapterManifest }), }; } diff --git a/apps/hub/src/server.ts b/apps/hub/src/server.ts index 0e5a45382..35ef40602 100644 --- a/apps/hub/src/server.ts +++ b/apps/hub/src/server.ts @@ -57,6 +57,9 @@ import { mountMailbox, } from "@corbits/mailbox"; import { createMemory, loadMemoryConfig } from "@corbits/memory"; +import { mountOAuthLogin } from "@corbits/oauth-core/hub"; +import { CODEX_PROVIDER, codexOAuthConfig, exchangeCodexCode } from "@corbits/codex-provider"; +import { XAI_PROVIDER, xaiOAuthConfig, exchangeXaiCode } from "@corbits/xai-provider"; import { createCronTicker, createRunTriggerCronDeliver, mountCron } from "@corbits/cron"; import { createHubMailboxAuthorizeSender, @@ -632,6 +635,41 @@ export async function createHubServer({ app.route("/", memoryApp); } + { + // "Continue with Codex"/"Continue with xAI": the whole loopback PKCE + // flow runs here, so the verifier and the callback listener never + // leave this process and the browser only learns a credential id. + const oauthLoginApi = new Hono(); + const requireGrant = createRequireGrant({ + grantStore, + conditionRegistry: grantConditionRegistry, + }); + mountOAuthLogin(oauthLoginApi, { + db, + cipher: credentialCipher, + requireGrant: requireGrant("credential:*", "create"), + providers: { + [CODEX_PROVIDER]: { + oauthConfig: codexOAuthConfig, + exchange: (code, verifier, now) => exchangeCodexCode(code, verifier, now), + // The Codex backend rejects inference without this header value. + metadata: (tokens) => + "accountId" in tokens && typeof tokens.accountId === "string" + ? { accountId: tokens.accountId } + : {}, + }, + [XAI_PROVIDER]: { + oauthConfig: xaiOAuthConfig, + exchange: (code, verifier, now) => exchangeXaiCode(code, verifier, now), + }, + }, + onError: (error, { provider }) => { + reportError(error, { operation: "hub.oauth-login", extra: { provider } }); + }, + }); + app.route(TENANT_PREFIX, oauthLoginApi); + } + await installWebhooks({ app, db, diff --git a/apps/web/src/onboarding/provider-connect-step.tsx b/apps/web/src/onboarding/provider-connect-step.tsx index 26eeb6ee7..fc10b66d9 100644 --- a/apps/web/src/onboarding/provider-connect-step.tsx +++ b/apps/web/src/onboarding/provider-connect-step.tsx @@ -2,10 +2,18 @@ // derives the single offering it mints; a tenant that already resolves an // offering skips this step (see `resolveExistingOffering`). import { Button, Input, RadioGroup, RadioOption, Select } from "@corbits/react-ui"; -import { getResolvedCatalog, shadowOffering } from "@/settings/inference"; +import { + cancelProviderLogin, + credentialNameFor, + ensureProviderRow, + getResolvedCatalog, + readProviderLogin, + shadowOffering, + startProviderLogin, +} from "@/settings/inference"; import { reportError } from "@corbits/error-sink"; -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; import type { FormEvent } from "react"; import type { ModelProviderPlugin } from "@intx/types"; @@ -13,6 +21,9 @@ import type { ModelProviderPlugin } from "@intx/types"; import { fetchOllamaTags } from "./ollama-tags"; export type ProviderOption = { + /** Stable option id: two OAuth providers share one plugin, so the plugin + * cannot identify a row on its own. */ + readonly id: string; readonly plugin: ModelProviderPlugin; readonly label: string; readonly description: string; @@ -22,12 +33,16 @@ export type ProviderOption = { readonly keyHint: string; /** A server on this machine: base URL and model are typed in, no key needed. */ readonly local: boolean; + /** Set when this provider signs in instead of taking a key: the name the + * hub registered its OAuth login under. */ + readonly oauthProvider?: string; }; // Each hosted option names one canonical model so connecting mints exactly // one offering and the flow never asks "which model". export const PROVIDER_OPTIONS: readonly ProviderOption[] = [ { + id: "anthropic", plugin: "anthropic", label: "Anthropic", description: "Claude models, direct from Anthropic.", @@ -38,6 +53,7 @@ export const PROVIDER_OPTIONS: readonly ProviderOption[] = [ local: false, }, { + id: "openai", plugin: "openai", label: "OpenAI", description: "GPT models, direct from OpenAI.", @@ -48,6 +64,7 @@ export const PROVIDER_OPTIONS: readonly ProviderOption[] = [ local: false, }, { + id: "google-genai", plugin: "google-genai", label: "Google", description: "Gemini models, direct from Google.", @@ -58,6 +75,34 @@ export const PROVIDER_OPTIONS: readonly ProviderOption[] = [ local: false, }, { + id: "codex", + plugin: "openai-responses", + label: "Codex", + description: "GPT models on your ChatGPT subscription. Sign in, no key.", + canonicalName: "gpt-5.5", + modelDisplayName: "GPT-5.5 (Codex)", + // The ChatGPT backend the subscription token authenticates against — + // not platform.openai.com, which only takes API keys. + baseURL: "https://chatgpt.com/backend-api", + keyHint: "", + local: false, + oauthProvider: "codex", + }, + { + id: "xai", + plugin: "openai-responses", + label: "xAI", + description: "Grok models on your xAI account. Sign in, no key.", + canonicalName: "grok-4.6", + modelDisplayName: "Grok 4.6 (xAI)", + // The grok-cli chat proxy; api.x.ai rejects these tokens outright. + baseURL: "https://cli-chat-proxy.grok.com/v1", + keyHint: "", + local: false, + oauthProvider: "xai", + }, + { + id: "ollama", plugin: "openai-compatible", label: "Ollama (local)", description: "A model served by Ollama on this machine. No key needed.", @@ -107,6 +152,15 @@ export async function resolveExistingOffering(tenantId: string): Promise void; readonly onError: (message: string) => void; }) { - const [selected, setSelected] = useState( - PROVIDER_OPTIONS[0]?.plugin ?? "anthropic", - ); + const [selected, setSelected] = useState(PROVIDER_OPTIONS[0]?.id ?? "anthropic"); const [apiKey, setApiKey] = useState(""); const [baseURL, setBaseURL] = useState(""); const [modelName, setModelName] = useState(""); const [submitting, setSubmitting] = useState(false); + const [loginId, setLoginId] = useState(null); const option = - PROVIDER_OPTIONS.find((candidate) => candidate.plugin === selected) ?? PROVIDER_OPTIONS[0]; + PROVIDER_OPTIONS.find((candidate) => candidate.id === selected) ?? PROVIDER_OPTIONS[0]; const isLocal = option?.local === true; + const oauthProvider = option?.oauthProvider; // Fetched straight from the browser to the user-supplied base URL — this // never touches the hub. Validates the base URL is actually an Ollama @@ -142,22 +196,106 @@ export function ProviderConnectStep({ const tags = tagsQuery.data ?? []; const modelKnown = !isLocal || tags.includes(modelName); - const ready = isLocal - ? baseURL.trim().length > 0 && modelName.trim().length > 0 && modelKnown - : apiKey.trim().length > 0; + const ready = + oauthProvider !== undefined || + (isLocal + ? baseURL.trim().length > 0 && modelName.trim().length > 0 && modelKnown + : apiKey.trim().length > 0); + + function fail(cause: unknown, operation: string) { + const refId = reportError(cause, { operation, tenantId }); + onError(`${cause instanceof Error ? cause.message : String(cause)} (ref ${refId})`); + } - function selectOption(plugin: ModelProviderPlugin) { - setSelected(plugin); - const next = PROVIDER_OPTIONS.find((candidate) => candidate.plugin === plugin); + function selectOption(id: string) { + const next = PROVIDER_OPTIONS.find((candidate) => candidate.id === id); + if (loginId !== null) { + // Abandoning a login must free the fixed loopback port it holds. + void cancelProviderLogin(tenantId, loginId).catch((cause: unknown) => { + reportError(cause, { operation: "onboarding.cancel-provider-login", tenantId }); + }); + setLoginId(null); + } + setSelected(id); setBaseURL(next?.local === true ? next.baseURL : ""); setModelName(""); } + // Starting a login is the hub's job end to end: it runs the loopback PKCE + // flow and stores the tokens, and hands back only a URL to open. + const startLogin = useMutation({ + mutationFn: async (target: { option: ProviderOption; provider: string }) => { + const providerId = await ensureProviderRow(tenantId, { + providerName: target.option.label, + plugin: target.option.plugin, + baseURL: target.option.baseURL, + }); + return startProviderLogin(tenantId, { + provider: target.provider, + providerId, + credentialName: credentialNameFor(target.option.label), + }); + }, + onSuccess: (started) => { + setLoginId(started.loginId); + window.open(started.authorizeUrl, "_blank", "noopener,noreferrer"); + }, + onError: (cause: unknown) => { + fail(cause, "onboarding.start-provider-login"); + }, + }); + + // Polls the login the hub is hosting and, the moment it lands, mints the + // offering over the credential the hub stored — the same catalog chain + // the API-key path walks, differing only in where the secret came from. + const login = useQuery({ + queryKey: ["onboarding", "oauth-login", tenantId, loginId], + enabled: loginId !== null && option !== undefined, + queryFn: async () => { + if (loginId === null || option === undefined) throw new Error("no login in flight"); + const state = await readProviderLogin(tenantId, loginId); + if (state.status !== "completed") return state; + const created = await shadowOffering(tenantId, { + canonicalName: option.canonicalName, + modelDisplayName: option.modelDisplayName, + providerName: option.label, + plugin: option.plugin, + baseURL: option.baseURL, + credential: { credentialId: state.credentialId }, + priority: 0, + }); + return { + status: "connected" as const, + offering: offeringFromOption(option, option.canonicalName, created.id), + }; + }, + refetchInterval: (query) => (query.state.data?.status === "pending" ? 2000 : false), + }); + + const loginState = login.data; + const loginError = login.error; + + // Handing the finished offering to the parent is the only thing left, and + // it is a callback, not a fetch — every hub call above is a query. + useEffect(() => { + if (loginState?.status === "connected") onConnected(loginState.offering); + }, [loginState, onConnected]); + + useEffect(() => { + if (loginError !== null) fail(loginError, "onboarding.provider-login"); + // `fail` closes over props that are stable for this step's lifetime. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loginError]); + async function handleSubmit(event: FormEvent) { event.preventDefault(); if (option === undefined || !ready || submitting) { return; } + if (oauthProvider !== undefined) { + startLogin.mutate({ option, provider: oauthProvider }); + return; + } setSubmitting(true); try { const canonicalName = isLocal ? modelName.trim() : option.canonicalName; @@ -167,43 +305,51 @@ export function ProviderConnectStep({ providerName: option.label, plugin: option.plugin, baseURL: isLocal ? baseURL.trim() : option.baseURL, - apiKey: isLocal ? LOCAL_PLACEHOLDER_KEY : apiKey.trim(), + credential: { apiKey: isLocal ? LOCAL_PLACEHOLDER_KEY : apiKey.trim() }, priority: 0, }); - onConnected({ - sourceOfferingIds: [created.id], - defaultSourceOfferingId: created.id, - declaredSources: [{ provider: option.plugin, model: canonicalName }], - }); + onConnected(offeringFromOption(option, canonicalName, created.id)); } catch (cause) { - const refId = reportError(cause, { - operation: "onboarding.connect-provider", - tenantId, - }); - onError(`${cause instanceof Error ? cause.message : String(cause)} (ref ${refId})`); + fail(cause, "onboarding.connect-provider"); } finally { setSubmitting(false); } } + const waiting = startLogin.isPending || loginState?.status === "pending"; + const submitLabel = + oauthProvider !== undefined + ? waiting + ? "Waiting for sign-in…" + : `Continue with ${option?.label ?? ""}` + : submitting + ? "Connecting…" + : "Connect"; + return (
void handleSubmit(event)}> selectOption(value as ModelProviderPlugin)} + onValueChange={selectOption} > {PROVIDER_OPTIONS.map((candidate) => ( ))} - {isLocal ? ( + {oauthProvider !== undefined ? ( +

+ {waiting + ? "Finish signing in on the tab that opened, then come back here." + : "A new tab opens to sign in. Your workbench stores the result; no key to paste."} +

+ ) : isLocal ? ( <> )} -
); diff --git a/apps/web/src/settings/inference/api.test.ts b/apps/web/src/settings/inference/api.test.ts index 51234a628..21a1a3467 100644 --- a/apps/web/src/settings/inference/api.test.ts +++ b/apps/web/src/settings/inference/api.test.ts @@ -35,7 +35,7 @@ const INPUT = { providerName: "anthropic", plugin: "anthropic" as const, baseURL: "https://api.anthropic.com", - apiKey: "sk-test", + credential: { apiKey: "sk-test" }, priority: 2, }; diff --git a/apps/web/src/settings/inference/api.ts b/apps/web/src/settings/inference/api.ts index 35d2beeff..bc83be218 100644 --- a/apps/web/src/settings/inference/api.ts +++ b/apps/web/src/settings/inference/api.ts @@ -236,13 +236,20 @@ export function updateOwnOffering( ); } -export type ShadowOfferingInput = { - readonly canonicalName: string; - readonly modelDisplayName: string | null; +/** The provider identity a credential and a catalog entry hang off. */ +export type ProviderIdentity = { readonly providerName: string; readonly plugin: typeof ModelProviderPlugin.infer; readonly baseURL: string; - readonly apiKey: string; +}; + +export type ShadowOfferingInput = ProviderIdentity & { + readonly canonicalName: string; + readonly modelDisplayName: string | null; + /** The key to store, or the id of a credential the hub already stored — + * an OAuth login mints its own row hub-side, so the browser never sees + * a token to pass here. */ + readonly credential: { readonly apiKey: string } | { readonly credentialId: string }; /** The exact priority of the offering being shadowed — this row takes * over its slot in resolution (`listVisibleOfferings`'s leaf-wins-by-name * cascade), so it must sort exactly where that offering did, never @@ -348,8 +355,10 @@ async function ensureCredential( input: ShadowOfferingInput, fetchImpl: FetchImpl, ): Promise { - const providerRow = await ensureCredentialProvider(tenantId, input, fetchImpl); - const credentialName = `${input.providerName}-workbench`; + if ("credentialId" in input.credential) return input.credential.credentialId; + const apiKey = input.credential.apiKey; + const providerRow = await ensureProviderRow(tenantId, input, fetchImpl); + const credentialName = credentialNameFor(input.providerName); const created = await fetchImpl(`/api/tenants/${tenantId}/credentials`, { method: "POST", headers: { "content-type": "application/json" }, @@ -358,7 +367,7 @@ async function ensureCredential( providerId: providerRow, name: credentialName, type: "api_key", - secret: input.apiKey, + secret: apiKey, }), ), }); @@ -393,10 +402,17 @@ async function ensureCredential( return existing.id; } -async function ensureCredentialProvider( +/** The one credential name a workbench files a provider's key or token under. */ +export function credentialNameFor(providerName: string): string { + return `${providerName}-workbench`; +} + +/** Mints (or resolves) this workbench's own `provider` row, the row a + * credential must reference before it can be stored. */ +export async function ensureProviderRow( tenantId: string, - input: ShadowOfferingInput, - fetchImpl: FetchImpl, + input: ProviderIdentity, + fetchImpl: FetchImpl = fetch, ): Promise { const created = await fetchImpl(`/api/tenants/${tenantId}/providers`, { method: "POST", diff --git a/apps/web/src/settings/inference/index.ts b/apps/web/src/settings/inference/index.ts index 106ade59a..52d5be861 100644 --- a/apps/web/src/settings/inference/index.ts +++ b/apps/web/src/settings/inference/index.ts @@ -7,7 +7,15 @@ export { shadowOffering, updateOwnOffering, } from "./api"; -export type { ModelInfo, ModelOfferingInfo, ShadowOfferingInput } from "./api"; +export { credentialNameFor, ensureProviderRow } from "./api"; +export type { ModelInfo, ModelOfferingInfo, ProviderIdentity, ShadowOfferingInput } from "./api"; +export { + cancelProviderLogin, + readProviderLogin, + startProviderLogin, + type LoginState, + type StartedLogin, +} from "./oauth-login"; export { buildEffectiveInferenceRows, chatCapableModels, diff --git a/apps/web/src/settings/inference/oauth-login.ts b/apps/web/src/settings/inference/oauth-login.ts new file mode 100644 index 000000000..9d173b288 --- /dev/null +++ b/apps/web/src/settings/inference/oauth-login.ts @@ -0,0 +1,80 @@ +// The browser's half of a hub-hosted OAuth login (`@corbits/oauth-core/hub`). +// The hub runs the loopback PKCE flow and stores the tokens itself, so +// nothing here ever holds a token — only a login id and, at the end, the id +// of the credential the hub wrote. + +import { type } from "arktype"; + +import { InferenceSettingsApiError, type FetchImpl } from "./api"; + +const StartedLogin = type({ loginId: "string", authorizeUrl: "string" }); +export type StartedLogin = typeof StartedLogin.infer; + +const LoginState = type({ status: "'pending'" }) + .or({ status: "'completed'", credentialId: "string" }) + .or({ status: "'failed'", message: "string" }) + .or({ status: "'cancelled'" }); +export type LoginState = typeof LoginState.infer; + +async function parsed( + response: Response, + schema: (data: unknown) => T | type.errors, + verb: string, +): Promise { + if (!response.ok) { + throw new InferenceSettingsApiError( + `The server answered ${String(response.status)} while ${verb}.`, + response.status, + ); + } + const body: unknown = await response.json().catch(() => undefined); + const result = schema(body); + if (result instanceof type.errors) { + throw new InferenceSettingsApiError( + `Unexpected response shape while ${verb}: ${result.summary}`, + ); + } + return result; +} + +/** Starts a login for `provider`, filing the credential it will mint under + * `providerId`/`credentialName`. Returns the URL to send the person to. */ +export async function startProviderLogin( + tenantId: string, + input: { + readonly provider: string; + readonly providerId: string; + readonly credentialName: string; + }, + fetchImpl: FetchImpl = fetch, +): Promise { + return parsed( + await fetchImpl(`/api/tenants/${tenantId}/oauth-logins`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }), + StartedLogin, + "starting the sign-in", + ); +} + +export async function readProviderLogin( + tenantId: string, + loginId: string, + fetchImpl: FetchImpl = fetch, +): Promise { + return parsed( + await fetchImpl(`/api/tenants/${tenantId}/oauth-logins/${loginId}`), + LoginState, + "checking the sign-in", + ); +} + +export async function cancelProviderLogin( + tenantId: string, + loginId: string, + fetchImpl: FetchImpl = fetch, +): Promise { + await fetchImpl(`/api/tenants/${tenantId}/oauth-logins/${loginId}`, { method: "DELETE" }); +} diff --git a/bun.lock b/bun.lock index 2a4120036..036a9cea6 100644 --- a/bun.lock +++ b/bun.lock @@ -61,6 +61,7 @@ "@corbits/error-sink": "workspace:*", "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#65590a85fa143251b3ac20ba2eca92fc35e70e51", "@corbits/memory": "github:corbitsdev/corbits-memory#e74da20f148a302dff5400915fe504ee2395e913", + "@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core#e97d563b823506d72f83dead478dd9fa9dccfd4c", "@corbits/url-path": "workspace:*", "@corbits/webhooks": "github:corbitsdev/webhooks#3f4f83147fd826d3aec5bcac7ef4b0ae7f75c00d", "@corbits/workflows": "workspace:*", @@ -635,7 +636,7 @@ "@corbits/myra": ["@corbits/myra@workspace:agents/myra"], - "@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#1b7f9fb", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-1b7f9fb", "sha512-PscyiDAl46umTM44NQPN1q5+PsGUV53Rxr4Wz/Ofz+k9LOOz6FBKamRFtWR8YWdKJt9I8sPOLOyEcMNNN2i+YQ=="], + "@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#e97d563", { "dependencies": { "@intx/db": "^0.3.0", "@intx/hub-api": "^0.3.0", "@intx/hub-common": "^0.3.0", "@intx/types": "^0.3.0", "arktype": "2.2.3", "drizzle-orm": "^0.45.2", "hono": "^4.11.9" } }, "corbitsdev-corbits-oauth-core-e97d563", "sha512-YbW3CXt/W+fw+0RHBm8hWXg9m0Gckt/iTDJJ/c60SYhOVOvPVaQ+HscRgq+UxMThtyer9xIBkkL8B8YgdEBfgQ=="], "@corbits/ollama-adapter": ["@corbits/ollama-adapter@github:corbitsdev/corbits-ollama-adapter#0387eb1", { "dependencies": { "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-ollama-adapter-0387eb1", "sha512-O1dl5lSwWzNGJibQLlpYDFKJZWpstgHX519bl8oe1ALL/KHszT/tBDlpsD9jN3hmQD3jV2L1jNwvOwLOBLtpyw=="], @@ -1837,8 +1838,12 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@corbits/codex-provider/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#1b7f9fb", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-1b7f9fb", "sha512-PscyiDAl46umTM44NQPN1q5+PsGUV53Rxr4Wz/Ofz+k9LOOz6FBKamRFtWR8YWdKJt9I8sPOLOyEcMNNN2i+YQ=="], + "@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], + "@corbits/xai-provider/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#1b7f9fb", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-1b7f9fb", "sha512-PscyiDAl46umTM44NQPN1q5+PsGUV53Rxr4Wz/Ofz+k9LOOz6FBKamRFtWR8YWdKJt9I8sPOLOyEcMNNN2i+YQ=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@npmcli/agent/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], @@ -1957,13 +1962,13 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], - "@workbench/hub/@corbits/codex-provider/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#1b7f9fb", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-1b7f9fb", "sha512-PscyiDAl46umTM44NQPN1q5+PsGUV53Rxr4Wz/Ofz+k9LOOz6FBKamRFtWR8YWdKJt9I8sPOLOyEcMNNN2i+YQ=="], + "@workbench/hub/@corbits/codex-provider/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#e97d563", { "dependencies": { "@intx/db": "^0.3.0", "@intx/hub-api": "^0.3.0", "@intx/hub-common": "^0.3.0", "@intx/types": "^0.3.0", "arktype": "2.2.3", "drizzle-orm": "^0.45.2", "hono": "^4.11.9" } }, "corbitsdev-corbits-oauth-core-e97d563", "sha512-YbW3CXt/W+fw+0RHBm8hWXg9m0Gckt/iTDJJ/c60SYhOVOvPVaQ+HscRgq+UxMThtyer9xIBkkL8B8YgdEBfgQ=="], "@workbench/hub/@corbits/codex-provider/@corbits/openai-responses": ["@corbits/openai-responses@github:corbitsdev/corbits-openai-responses#7efe000", { "dependencies": { "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-openai-responses-7efe000", "sha512-pH6C8T1WDJjqM+YbnQpofd7HgP5krEj48JCCQK16KI98a6kaz+gffpgdOdQpU3aNVZnFlyADXod1Z4tqUG/3ww=="], "@workbench/hub/@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], - "@workbench/hub/@corbits/xai-provider/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#1b7f9fb", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-1b7f9fb", "sha512-PscyiDAl46umTM44NQPN1q5+PsGUV53Rxr4Wz/Ofz+k9LOOz6FBKamRFtWR8YWdKJt9I8sPOLOyEcMNNN2i+YQ=="], + "@workbench/hub/@corbits/xai-provider/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#e97d563", { "dependencies": { "@intx/db": "^0.3.0", "@intx/hub-api": "^0.3.0", "@intx/hub-common": "^0.3.0", "@intx/types": "^0.3.0", "arktype": "2.2.3", "drizzle-orm": "^0.45.2", "hono": "^4.11.9" } }, "corbitsdev-corbits-oauth-core-e97d563", "sha512-YbW3CXt/W+fw+0RHBm8hWXg9m0Gckt/iTDJJ/c60SYhOVOvPVaQ+HscRgq+UxMThtyer9xIBkkL8B8YgdEBfgQ=="], "@workbench/hub/@corbits/xai-provider/@corbits/openai-responses": ["@corbits/openai-responses@github:corbitsdev/corbits-openai-responses#7efe000", { "dependencies": { "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-openai-responses-7efe000", "sha512-pH6C8T1WDJjqM+YbnQpofd7HgP5krEj48JCCQK16KI98a6kaz+gffpgdOdQpU3aNVZnFlyADXod1Z4tqUG/3ww=="], diff --git a/docs/local-dev.md b/docs/local-dev.md index 6665e9964..64155bc70 100644 --- a/docs/local-dev.md +++ b/docs/local-dev.md @@ -49,3 +49,21 @@ both are set a reranker outage degrades search quietly instead of breaking it. See `.env.example` for every memory-plane variable. + +## Signing in to Codex and xAI + +Both are subscription providers with no API key: onboarding offers +"Continue with Codex" and "Continue with xAI", and the hub runs the whole +loopback OAuth flow in its own process — the browser only ever sees the +authorize URL and, at the end, the id of the credential the hub stored. +Each provider pins its own loopback port (1455 for Codex, 1456 for xAI), +so those ports must be free on the machine running the hub, and the +browser must be on that same machine. + +Serving inference with one of those credentials also needs the matching +adapter in the sidecar, which is what `SIDECAR_ADAPTER_MANIFEST` in +`.env.example` is for; the hub forwards it to every sidecar it spawns. +Leave it unset to run without those two providers. + +Nothing refreshes an expired OAuth credential today. When one lapses, sign +in again from Settings to replace it.