diff --git a/server/ai/credentials/autoDefaults.ts b/server/ai/credentials/autoDefaults.ts new file mode 100644 index 00000000..424fee00 --- /dev/null +++ b/server/ai/credentials/autoDefaults.ts @@ -0,0 +1,78 @@ +/** + * Best-effort default seeding after a new AI credential is created. + * + * The first credential should make every AI surface usable without forcing the + * admin to visit Defaults. Existing defaults are never overwritten. + */ + +import type { DbClient } from '../../db/client' +import { createAuditEvent } from '../../repositories/audit' +import { listDefaults, setDefaultForScope } from '../defaults/store' +import { resolveDriver } from '../drivers' +import { resolveCredentialForDriver } from './store' +import type { CredentialRecord } from './types' +import type { ToolScope } from '../runtime/types' + +const ALL_SCOPES: ToolScope[] = ['site', 'content', 'data', 'plugin'] + +export async function seedEmptyDefaults( + db: DbClient, + record: CredentialRecord, + userId: string, +): Promise { + const existing = await listDefaults(db) + const filled = new Set(existing.map((d) => d.scope)) + const emptyScopes = ALL_SCOPES.filter((scope) => !filled.has(scope)) + if (emptyScopes.length === 0) return + + let topModelId: string | null + let apiKeyForRedaction: string | null = null + try { + const resolved = await resolveCredentialForDriver(db, record) + apiKeyForRedaction = resolved.apiKey + const driver = resolveDriver(record.providerId) + const models = await driver.listModels(resolved) + const liveModels = models.filter((model) => model.catalogueSource !== 'fallback') + const top = liveModels.find((m) => m.tier === 'smartest') ?? liveModels[0] + topModelId = top?.id ?? null + } catch (err) { + console.warn( + '[ai/credentials] auto-default skipped - model lookup failed:', + safeCredentialErrorMessage(err, [apiKeyForRedaction]), + ) + return + } + if (!topModelId) { + console.warn( + `[ai/credentials] auto-default skipped - no live models resolved for ${record.providerId}/${record.id}.`, + ) + return + } + + for (const scope of emptyScopes) { + await setDefaultForScope(db, scope, record.id, topModelId, userId) + await createAuditEvent(db, { + actorUserId: userId, + action: 'ai.default.updated', + targetType: 'ai_default', + targetId: scope, + metadata: { scope, credentialId: record.id, modelId: topModelId, auto: true }, + }) + } +} + +function safeCredentialErrorMessage( + err: unknown, + secrets: readonly (string | null | undefined)[] = [], + fallback = 'Unknown error', +): string { + const message = err instanceof Error && err.message ? err.message : fallback + let redacted = message + for (const secret of secrets) { + if (!secret) continue + redacted = redacted.split(secret).join('[redacted]') + } + return redacted + .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [redacted]') + .replace(/\bsk-[A-Za-z0-9._-]{6,}\b/g, '[redacted]') +} diff --git a/server/ai/credentials/store.ts b/server/ai/credentials/store.ts index a5f13840..473bac59 100644 --- a/server/ai/credentials/store.ts +++ b/server/ai/credentials/store.ts @@ -38,6 +38,12 @@ import type { } from './types' import type { AiAuthMode, AiProviderId } from '../runtime/types' import type { AiResolvedCredential } from '../drivers/types' +import { + openAiOAuthNeedsRefresh, + parseOpenAiOAuthSecret, + refreshOpenAiOAuthSecret, + serializeOpenAiOAuthSecret, +} from '../oauth/openai' // --------------------------------------------------------------------------- // Row shape ↔ record shape @@ -138,7 +144,7 @@ export async function listCredentialsForUser( created_at, updated_at, last_used_at from ai_provider_credentials where user_id = ${userId} - and auth_mode in ('apiKey', 'baseUrl') + and auth_mode in ('apiKey', 'baseUrl', 'oauth') order by created_at desc ` return rows.map(rowToRecord) @@ -177,6 +183,7 @@ export async function readCredentialForUser( * - The auth_mode + shape are inconsistent (data corruption). */ export async function resolveCredentialForDriver( + db: DbClient, record: CredentialRecord, ): Promise { const currentFingerprint = await getMasterKeyFingerprint() @@ -197,6 +204,43 @@ export async function resolveCredentialForDriver( }) } + if (record.authMode === 'oauth') { + if (record.providerId !== 'openai') { + throw new CredentialError( + `Credential ${record.id} is marked auth_mode='oauth' for unsupported provider "${record.providerId}".`, + 500, + ) + } + if (!apiKey) { + throw new CredentialError( + `Credential ${record.id} is marked auth_mode='oauth' but has no ` + + `stored token envelope — data corruption. Reconnect the provider in /admin/ai/providers.`, + 500, + ) + } + let secret: ReturnType + try { + secret = parseOpenAiOAuthSecret(apiKey) + } catch { + throw new CredentialError( + `Credential ${record.id} has an invalid OpenAI OAuth token envelope. ` + + `Reconnect the provider in /admin/ai/providers.`, + 500, + ) + } + if (openAiOAuthNeedsRefresh(secret)) { + secret = await refreshAndPersistOpenAiOAuthSecret(db, record, secret) + } + return { + id: record.id, + providerId: record.providerId, + authMode: record.authMode, + apiKey: secret.access, + baseUrl: null, + ...(secret.accountId ? { oauth: { accountId: secret.accountId } } : {}), + } + } + if (record.authMode === 'apiKey' && !apiKey) { throw new CredentialError( `Credential ${record.id} is marked auth_mode='apiKey' but has no ` + @@ -222,6 +266,25 @@ export async function resolveCredentialForDriver( } } +async function refreshAndPersistOpenAiOAuthSecret( + db: DbClient, + record: CredentialRecord, + secret: ReturnType, +): Promise> { + const refreshed = await refreshOpenAiOAuthSecret(secret) + const encrypted = await encryptKey(serializeOpenAiOAuthSecret(refreshed)) + const fingerprint = await getMasterKeyFingerprint() + await db` + update ai_provider_credentials + set ciphertext = ${encrypted.ciphertext}, + iv = ${encrypted.iv}, + key_fingerprint = ${fingerprint}, + updated_at = current_timestamp + where id = ${record.id} and user_id = ${record.userId} + ` + return refreshed +} + // --------------------------------------------------------------------------- // Write // --------------------------------------------------------------------------- @@ -235,6 +298,7 @@ export async function resolveCredentialForDriver( * - duplicate (user_id, provider_id, display_label) — surfaced as 409 * - missing key for 'apiKey' mode — surfaced as 400 * - missing url for 'baseUrl' mode — surfaced as 400 + * - invalid provider/missing token envelope for 'oauth' mode — surfaced as 400 */ export async function createCredentialForUser( db: DbClient, @@ -291,6 +355,12 @@ async function maybeEncryptForInput( if (input.authMode === 'apiKey') { return encryptKey(input.apiKey) } + if (input.authMode === 'oauth') { + if (input.providerId !== 'openai') { + throw new CredentialError('OAuth credentials are currently supported only for OpenAI.', 400) + } + return encryptKey(serializeOpenAiOAuthSecret(input.oauth)) + } // baseUrl mode: API key is optional (bearer-protected proxies) if (input.apiKey && input.apiKey.length > 0) return encryptKey(input.apiKey) return null diff --git a/server/ai/credentials/types.ts b/server/ai/credentials/types.ts index 7d828d42..062ee7cf 100644 --- a/server/ai/credentials/types.ts +++ b/server/ai/credentials/types.ts @@ -15,9 +15,12 @@ * apiKey → ciphertext set, iv set, baseUrl null * baseUrl → baseUrl set; ciphertext + iv optional (bearer-protected * endpoints store an apiKey too) + * oauth → ciphertext set, iv set, baseUrl null; ciphertext is a + * provider-specific token JSON envelope */ import type { AiAuthMode, AiProviderId } from '../runtime/types' +import type { OpenAiOAuthSecret } from '../oauth/openai' export interface CredentialRecord { readonly id: string @@ -70,6 +73,12 @@ export type CreateCredentialInput = baseUrl: string apiKey?: string } + | { + providerId: 'openai' + authMode: 'oauth' + displayLabel: string + oauth: OpenAiOAuthSecret + } /** * Update: only mutable fields. Auth mode is intentionally immutable — to diff --git a/server/ai/drivers/openai.ts b/server/ai/drivers/openai.ts index c3071c3e..49200999 100644 --- a/server/ai/drivers/openai.ts +++ b/server/ai/drivers/openai.ts @@ -36,14 +36,18 @@ import type { } from './types' import { runToolLoop } from './http/toolLoop' import { createResponsesAdapter } from './responses-shared' +import { + OPENAI_OAUTH_CODEX_RESPONSES_ENDPOINT, + OPENAI_OAUTH_MODELS, +} from '../oauth/openai' -const SUPPORTED_AUTH_MODES: AiAuthMode[] = ['apiKey'] +const SUPPORTED_AUTH_MODES: AiAuthMode[] = ['apiKey', 'oauth'] const OPENAI_BASE_URL = 'https://api.openai.com/v1' const OPENAI_ENDPOINT = `${OPENAI_BASE_URL}/responses` const OPENAI_MODELS_ENDPOINT = `${OPENAI_BASE_URL}/models` -const openaiAdapter = createResponsesAdapter({ +const openaiApiKeyAdapter = createResponsesAdapter({ label: 'OpenAI', endpoint: OPENAI_ENDPOINT, buildHeaders(req) { @@ -56,6 +60,34 @@ const openaiAdapter = createResponsesAdapter({ const toolNames = req.tools.map((t) => t.name).sort().join(',') return `instatic:${req.toolContextBase.scope}:${stableHash(toolNames)}` }, + buildExtraBody() { + return { store: false } + }, +}) + +const openaiOAuthAdapter = createResponsesAdapter({ + label: 'OpenAI OAuth', + endpoint: OPENAI_OAUTH_CODEX_RESPONSES_ENDPOINT, + buildHeaders(req) { + const headers: Record = { + Authorization: `Bearer ${req.credentials.apiKey!}`, + 'content-type': 'application/json', + originator: 'instatic', + 'User-Agent': 'instatic/0.0.7', + 'session-id': req.toolContextBase.conversationId, + } + if (req.credentials.oauth?.accountId) { + headers['ChatGPT-Account-Id'] = req.credentials.oauth.accountId + } + return headers + }, + promptCacheKey(req) { + const toolNames = req.tools.map((t) => t.name).sort().join(',') + return `instatic:${req.toolContextBase.scope}:${stableHash(toolNames)}` + }, + buildExtraBody() { + return { store: false } + }, }) export const openaiDriver: AiProvider = { @@ -82,6 +114,12 @@ export const openaiDriver: AiProvider = { }, async *stream(req: AiStreamRequest): AsyncIterable { + if (req.credentials.authMode === 'oauth' && req.credentials.apiKey) { + for await (const event of runToolLoop(openaiOAuthAdapter, req)) { + yield event.type === 'usage' ? { ...event, costUsd: 0 } : event + } + return + } if (req.credentials.authMode !== 'apiKey' || !req.credentials.apiKey) { // Defensive: a non-apiKey credential reaching the driver implies a // mismatched DB row or a bypassed UI. Fail cleanly instead of POSTing @@ -93,7 +131,7 @@ export const openaiDriver: AiProvider = { } return } - yield* runToolLoop(openaiAdapter, req) + yield* runToolLoop(openaiApiKeyAdapter, req) }, } @@ -137,6 +175,7 @@ function stableHash(value: string): string { * and derive the label + tier from the id — heuristic, not authoritative. */ async function fetchOpenAiModels(creds: AiResolvedCredential): Promise { + if (creds.authMode === 'oauth') return OPENAI_OAUTH_MODELS if (creds.authMode !== 'apiKey' || !creds.apiKey) return [] const res = await fetch(OPENAI_MODELS_ENDPOINT, { diff --git a/server/ai/drivers/responses-shared.ts b/server/ai/drivers/responses-shared.ts index 12c31bd0..cb59ba62 100644 --- a/server/ai/drivers/responses-shared.ts +++ b/server/ai/drivers/responses-shared.ts @@ -327,6 +327,7 @@ interface ResponsesAdapterOptions { readonly endpoint: string buildHeaders(req: AiStreamRequest): Record promptCacheKey?: (req: AiStreamRequest) => string | null + buildExtraBody?: (req: AiStreamRequest) => Record } /** @@ -356,6 +357,7 @@ export function createResponsesAdapter( const promptCacheKey = opts.promptCacheKey?.(req) if (promptCacheKey) body.prompt_cache_key = promptCacheKey if (req.tools.length > 0) body.tools = buildResponsesTools(req.tools) + Object.assign(body, opts.buildExtraBody?.(req)) return body }, diff --git a/server/ai/drivers/types.ts b/server/ai/drivers/types.ts index 58af3ecc..294309f5 100644 --- a/server/ai/drivers/types.ts +++ b/server/ai/drivers/types.ts @@ -27,6 +27,8 @@ import type { * Shape varies by auth mode: * - 'apiKey' → apiKey set, baseUrl === null * - 'baseUrl' → baseUrl set, apiKey may be set (optional bearer) + * - 'oauth' → apiKey set to the current access token; oauth carries + * provider-specific metadata that is safe for request headers. * * The shape constraints mirror the `ai_creds_apikey_shape_check` DB-level * check, so by the time a CredentialRecord reaches this stage, the runtime @@ -38,6 +40,9 @@ export interface AiResolvedCredential { readonly authMode: AiAuthMode readonly apiKey: string | null readonly baseUrl: string | null + readonly oauth?: { + readonly accountId?: string + } } // --------------------------------------------------------------------------- @@ -166,7 +171,7 @@ export interface AiProvider { * of showing a separate auth-mode picker. * * anthropic → ['apiKey'] - * openai → ['apiKey'] + * openai → ['apiKey', 'oauth'] * openrouter → ['apiKey'] * ollama → ['baseUrl'] */ diff --git a/server/ai/handlers/chat.ts b/server/ai/handlers/chat.ts index aa0b57f5..86410d9d 100644 --- a/server/ai/handlers/chat.ts +++ b/server/ai/handlers/chat.ts @@ -132,7 +132,7 @@ async function handleAiChat( } let resolvedCredential try { - resolvedCredential = await resolveCredentialForDriver(credential) + resolvedCredential = await resolveCredentialForDriver(db, credential) } catch (err) { const message = err instanceof Error ? err.message : 'Credential resolution failed.' return jsonResponse({ error: message }, { status: 409 }) diff --git a/server/ai/handlers/credentials.ts b/server/ai/handlers/credentials.ts index d105541d..6be7193b 100644 --- a/server/ai/handlers/credentials.ts +++ b/server/ai/handlers/credentials.ts @@ -23,12 +23,8 @@ import { toCredentialView, updateCredentialForUser, } from '../credentials/store' +import { seedEmptyDefaults } from '../credentials/autoDefaults' import { resolveDriver } from '../drivers' -import type { CredentialRecord } from '../credentials/types' -import { listDefaults, setDefaultForScope } from '../defaults/store' -import type { ToolScope } from '../runtime/types' - -const ALL_SCOPES: ToolScope[] = ['site', 'content', 'data', 'plugin'] const ProviderId = Type.Union([ Type.Literal('anthropic'), @@ -141,65 +137,6 @@ async function handleCreate(req: Request, db: DbClient): Promise { } } -// --------------------------------------------------------------------------- -// Auto-default seeding -// --------------------------------------------------------------------------- - -/** - * After a credential is created, assign it as the default for every scope that - * has no default yet. This is a "fill the blanks" convenience — a scope that - * already points at some credential is left untouched. - * - * The default model is the credential's top live model (the `smartest`-tier - * entry, else the first). Best-effort: if the model list can't be resolved - * (offline, bad key) we simply skip seeding rather than fail the create. - */ -async function seedEmptyDefaults( - db: DbClient, - record: CredentialRecord, - userId: string, -): Promise { - const existing = await listDefaults(db) - const filled = new Set(existing.map((d) => d.scope)) - const emptyScopes = ALL_SCOPES.filter((scope) => !filled.has(scope)) - if (emptyScopes.length === 0) return - - let topModelId: string | null - let apiKeyForRedaction: string | null = null - try { - const resolved = await resolveCredentialForDriver(record) - apiKeyForRedaction = resolved.apiKey - const driver = resolveDriver(record.providerId) - const models = await driver.listModels(resolved) - const liveModels = models.filter((model) => model.catalogueSource !== 'fallback') - const top = liveModels.find((m) => m.tier === 'smartest') ?? liveModels[0] - topModelId = top?.id ?? null - } catch (err) { - console.warn( - '[ai/credentials] auto-default skipped - model lookup failed:', - safeCredentialErrorMessage(err, [apiKeyForRedaction]), - ) - return - } - if (!topModelId) { - console.warn( - `[ai/credentials] auto-default skipped - no live models resolved for ${record.providerId}/${record.id}.`, - ) - return - } - - for (const scope of emptyScopes) { - await setDefaultForScope(db, scope, record.id, topModelId, userId) - await createAuditEvent(db, { - actorUserId: userId, - action: 'ai.default.updated', - targetType: 'ai_default', - targetId: scope, - metadata: { scope, credentialId: record.id, modelId: topModelId, auto: true }, - }) - } -} - // --------------------------------------------------------------------------- // Item: PUT (update) + DELETE // --------------------------------------------------------------------------- @@ -295,7 +232,7 @@ async function dispatchTest(req: Request, db: DbClient, id: string): Promise() + +const StartBodySchema = Type.Object({ + displayLabel: Type.String({ minLength: 1 }), +}) + +const CompleteBodySchema = Type.Object({ + flowId: Type.String({ minLength: 1 }), +}) + +export function tryHandleOpenAiOAuth( + req: Request, + db: DbClient, + pathname: string, +): Promise | null { + if (pathname === '/admin/api/ai/oauth/openai/device/start') { + return handleStart(req, db) + } + if (pathname === '/admin/api/ai/oauth/openai/device/complete') { + return handleComplete(req, db) + } + return null +} + +async function handleStart(req: Request, db: DbClient): Promise { + if (req.method !== 'POST') { + return jsonResponse({ error: 'Method not allowed' }, { status: 405 }) + } + const userOrResponse = await requireCapability(req, db, 'ai.providers.manage') + if (userOrResponse instanceof Response) return userOrResponse + + const body = await readValidatedBody(req, StartBodySchema) + if (!body) return badRequest('Invalid request body.') + + try { + pruneExpiredFlows() + const device = await requestOpenAiDeviceAuthorization() + const flowId = nanoid(FLOW_ID_BYTES) + pendingFlows.set(flowId, { + userId: userOrResponse.id, + displayLabel: body.displayLabel, + deviceAuthId: device.deviceAuthId, + userCode: device.userCode, + intervalMs: device.intervalMs, + expiresAt: Date.now() + FLOW_TTL_MS, + }) + return jsonResponse({ + flowId, + userCode: device.userCode, + verificationUrl: device.verificationUrl, + intervalMs: device.intervalMs, + }) + } catch (err) { + console.error('[ai/openai-oauth] start failed:', getErrorMessage(err, 'Unknown error')) + return jsonResponse({ error: 'Failed to start OpenAI OAuth.' }, { status: 502 }) + } +} + +async function handleComplete(req: Request, db: DbClient): Promise { + if (req.method !== 'POST') { + return jsonResponse({ error: 'Method not allowed' }, { status: 405 }) + } + const userOrResponse = await requireCapability(req, db, 'ai.providers.manage') + if (userOrResponse instanceof Response) return userOrResponse + + const body = await readValidatedBody(req, CompleteBodySchema) + if (!body) return badRequest('Invalid request body.') + + pruneExpiredFlows() + const flow = pendingFlows.get(body.flowId) + if (!flow || flow.userId !== userOrResponse.id) { + return jsonResponse({ error: 'OpenAI OAuth flow not found or expired.' }, { status: 404 }) + } + + try { + const result = await pollOpenAiDeviceToken({ + deviceAuthId: flow.deviceAuthId, + userCode: flow.userCode, + }) + if (result.status === 'pending') { + return jsonResponse({ status: 'pending', retryAfterMs: flow.intervalMs }) + } + + const record = await createCredentialForUser(db, userOrResponse.id, { + providerId: 'openai', + authMode: 'oauth', + displayLabel: flow.displayLabel, + oauth: result.secret, + }) + await createAuditEvent(db, { + actorUserId: userOrResponse.id, + action: 'ai.credential.created', + targetType: 'ai_credential', + targetId: record.id, + metadata: { + providerId: record.providerId, + authMode: record.authMode, + displayLabel: record.displayLabel, + }, + }) + + try { + await seedEmptyDefaults(db, record, userOrResponse.id) + } catch (err) { + console.warn( + '[ai/openai-oauth] auto-default skipped - default seeding failed:', + getErrorMessage(err, 'Unknown error'), + ) + } + + pendingFlows.delete(body.flowId) + return jsonResponse({ + status: 'success', + credential: await toCredentialView(record), + }) + } catch (err) { + if (err instanceof CredentialError) { + return jsonResponse({ error: err.message }, { status: err.status }) + } + pendingFlows.delete(body.flowId) + console.warn('[ai/openai-oauth] complete failed:', getErrorMessage(err, 'Unknown error')) + return jsonResponse({ + status: 'failed', + error: 'OpenAI OAuth failed. Please retry the authorization flow.', + }) + } +} + +function pruneExpiredFlows(): void { + const now = Date.now() + for (const [flowId, flow] of pendingFlows) { + if (flow.expiresAt <= now) pendingFlows.delete(flowId) + } +} diff --git a/server/ai/oauth/openai.ts b/server/ai/oauth/openai.ts new file mode 100644 index 00000000..3cd727fe --- /dev/null +++ b/server/ai/oauth/openai.ts @@ -0,0 +1,279 @@ +/** + * OpenAI ChatGPT/Codex OAuth helpers. + * + * This mirrors the public flow used by opencode's OpenAI Codex auth plugin: + * device authorization via auth.openai.com, token exchange with PKCE, then + * Responses requests against ChatGPT's Codex endpoint using the OAuth access + * token instead of an API key. + */ + +import { Type, parseValue, type Static } from '@core/utils/typeboxHelpers' +import type { AiProviderModel } from '../drivers/types' + +export const OPENAI_OAUTH_ISSUER = 'https://auth.openai.com' +export const OPENAI_OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann' +export const OPENAI_OAUTH_DEVICE_URL = `${OPENAI_OAUTH_ISSUER}/codex/device` +export const OPENAI_OAUTH_CODEX_RESPONSES_ENDPOINT = + 'https://chatgpt.com/backend-api/codex/responses' + +const TOKEN_REFRESH_SAFETY_MARGIN_MS = 3_000 +const DEVICE_POLLING_SAFETY_MARGIN_MS = 3_000 +const USER_AGENT = 'instatic/0.0.7' + +export const OPENAI_OAUTH_MODELS: AiProviderModel[] = [ + { + id: 'gpt-5.5', + label: 'GPT 5.5', + tier: 'smartest', + contextWindow: 400_000, + pricing: { inputPerMTok: 0, outputPerMTok: 0 }, + catalogueSource: 'live', + capabilities: { toolCalling: true, visionInput: true, promptCache: false, streaming: true }, + }, + { + id: 'gpt-5.4', + label: 'GPT 5.4', + tier: 'smart', + pricing: { inputPerMTok: 0, outputPerMTok: 0 }, + catalogueSource: 'live', + capabilities: { toolCalling: true, visionInput: true, promptCache: false, streaming: true }, + }, + { + id: 'gpt-5.4-mini', + label: 'GPT 5.4 Mini', + tier: 'fast', + pricing: { inputPerMTok: 0, outputPerMTok: 0 }, + catalogueSource: 'live', + capabilities: { toolCalling: true, visionInput: true, promptCache: false, streaming: true }, + }, + { + id: 'gpt-5.3-codex-spark', + label: 'GPT 5.3 Codex Spark', + tier: 'fast', + pricing: { inputPerMTok: 0, outputPerMTok: 0 }, + catalogueSource: 'live', + capabilities: { toolCalling: true, visionInput: true, promptCache: false, streaming: true }, + }, +] + +const OpenAiDeviceAuthorizationSchema = Type.Object( + { + device_auth_id: Type.String(), + user_code: Type.String(), + interval: Type.Union([Type.String(), Type.Number()]), + }, + { additionalProperties: true }, +) + +const OpenAiDeviceTokenSchema = Type.Object( + { + authorization_code: Type.String(), + code_verifier: Type.String(), + }, + { additionalProperties: true }, +) + +const OpenAiTokenResponseSchema = Type.Object( + { + access_token: Type.String(), + refresh_token: Type.Optional(Type.String()), + expires_in: Type.Optional(Type.Number()), + id_token: Type.Optional(Type.String()), + }, + { additionalProperties: true }, +) + +const OpenAiOAuthSecretSchema = Type.Object({ + refresh: Type.String(), + access: Type.String(), + expires: Type.Number(), + accountId: Type.Optional(Type.String()), +}) + +type OpenAiDeviceAuthorization = Static +type OpenAiDeviceToken = Static +type OpenAiTokenResponse = Static +export type OpenAiOAuthSecret = Static + +export interface OpenAiDeviceAuthorizationResult { + deviceAuthId: string + userCode: string + intervalMs: number + verificationUrl: string +} + +export interface OpenAiDevicePending { + deviceAuthId: string + userCode: string +} + +export async function requestOpenAiDeviceAuthorization(): Promise { + const response = await fetch(`${OPENAI_OAUTH_ISSUER}/api/accounts/deviceauth/usercode`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': USER_AGENT, + }, + body: JSON.stringify({ client_id: OPENAI_OAUTH_CLIENT_ID }), + }) + if (!response.ok) { + throw new Error(`OpenAI device authorization failed: ${response.status} ${response.statusText}`) + } + + let body: OpenAiDeviceAuthorization + try { + body = parseValue(OpenAiDeviceAuthorizationSchema, await response.json()) + } catch { + throw new Error('OpenAI device authorization response was invalid.') + } + return { + deviceAuthId: body.device_auth_id, + userCode: body.user_code, + intervalMs: intervalMs(body) + DEVICE_POLLING_SAFETY_MARGIN_MS, + verificationUrl: OPENAI_OAUTH_DEVICE_URL, + } +} + +export async function pollOpenAiDeviceToken( + pending: OpenAiDevicePending, +): Promise<{ status: 'pending' } | { status: 'success'; secret: OpenAiOAuthSecret }> { + const response = await fetch(`${OPENAI_OAUTH_ISSUER}/api/accounts/deviceauth/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': USER_AGENT, + }, + body: JSON.stringify({ + device_auth_id: pending.deviceAuthId, + user_code: pending.userCode, + }), + }) + + if (response.status === 403 || response.status === 404) { + return { status: 'pending' } + } + if (!response.ok) { + throw new Error(`OpenAI device token polling failed: ${response.status} ${response.statusText}`) + } + + let deviceToken: OpenAiDeviceToken + try { + deviceToken = parseValue(OpenAiDeviceTokenSchema, await response.json()) + } catch { + throw new Error('OpenAI device token response was invalid.') + } + const tokens = await exchangeDeviceCodeForTokens(deviceToken) + return { status: 'success', secret: tokenResponseToSecret(tokens) } +} + +export function serializeOpenAiOAuthSecret(secret: OpenAiOAuthSecret): string { + return JSON.stringify(secret) +} + +export function parseOpenAiOAuthSecret(raw: string): OpenAiOAuthSecret { + return parseValue(OpenAiOAuthSecretSchema, JSON.parse(raw)) +} + +export function openAiOAuthNeedsRefresh(secret: OpenAiOAuthSecret): boolean { + return secret.expires <= Date.now() + TOKEN_REFRESH_SAFETY_MARGIN_MS +} + +export async function refreshOpenAiOAuthSecret(secret: OpenAiOAuthSecret): Promise { + const response = await fetch(`${OPENAI_OAUTH_ISSUER}/oauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: secret.refresh, + client_id: OPENAI_OAUTH_CLIENT_ID, + }).toString(), + }) + if (!response.ok) { + throw new Error(`OpenAI OAuth token refresh failed: ${response.status} ${response.statusText}`) + } + let tokens: OpenAiTokenResponse + try { + tokens = parseValue(OpenAiTokenResponseSchema, await response.json()) + } catch { + throw new Error('OpenAI OAuth refresh response was invalid.') + } + return tokenResponseToSecret(tokens, secret) +} + +function intervalMs(body: OpenAiDeviceAuthorization): number { + const seconds = typeof body.interval === 'number' ? body.interval : Number.parseInt(body.interval, 10) + return Math.max(Number.isFinite(seconds) ? seconds : 5, 1) * 1_000 +} + +async function exchangeDeviceCodeForTokens(deviceToken: OpenAiDeviceToken): Promise { + const response = await fetch(`${OPENAI_OAUTH_ISSUER}/oauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: deviceToken.authorization_code, + redirect_uri: `${OPENAI_OAUTH_ISSUER}/deviceauth/callback`, + client_id: OPENAI_OAUTH_CLIENT_ID, + code_verifier: deviceToken.code_verifier, + }).toString(), + }) + if (!response.ok) { + throw new Error(`OpenAI OAuth token exchange failed: ${response.status} ${response.statusText}`) + } + try { + return parseValue(OpenAiTokenResponseSchema, await response.json()) + } catch { + throw new Error('OpenAI OAuth token exchange response was invalid.') + } +} + +function tokenResponseToSecret( + tokens: OpenAiTokenResponse, + previous?: OpenAiOAuthSecret, +): OpenAiOAuthSecret { + const accountId = extractAccountId(tokens) ?? previous?.accountId + const refresh = tokens.refresh_token ?? previous?.refresh + if (!refresh) { + throw new Error('OpenAI OAuth response did not include a refresh token.') + } + return { + refresh, + access: tokens.access_token, + expires: Date.now() + (tokens.expires_in ?? 3600) * 1_000, + ...(accountId ? { accountId } : {}), + } +} + +interface IdTokenClaims { + chatgpt_account_id?: string + organizations?: Array<{ id?: string }> + 'https://api.openai.com/auth'?: { + chatgpt_account_id?: string + } +} + +function extractAccountId(tokens: OpenAiTokenResponse): string | undefined { + for (const token of [tokens.id_token, tokens.access_token]) { + if (!token) continue + const claims = parseJwtClaims(token) + const accountId = claims && ( + claims.chatgpt_account_id || + claims['https://api.openai.com/auth']?.chatgpt_account_id || + claims.organizations?.[0]?.id + ) + if (accountId) return accountId + } + return undefined +} + +function parseJwtClaims(token: string): IdTokenClaims | undefined { + const parts = token.split('.') + if (parts.length !== 3) return undefined + try { + const payload = parts[1]!.replace(/-/g, '+').replace(/_/g, '/') + const padded = payload.padEnd(Math.ceil(payload.length / 4) * 4, '=') + return JSON.parse(atob(padded)) as IdTokenClaims + } catch { + return undefined + } +} diff --git a/server/ai/runtime/types.ts b/server/ai/runtime/types.ts index 29f90acc..e422b8bc 100644 --- a/server/ai/runtime/types.ts +++ b/server/ai/runtime/types.ts @@ -31,8 +31,10 @@ export type AiProviderId = 'anthropic' | 'openai' | 'ollama' | 'openrouter' * - `apiKey` — encrypted user-supplied key (Anthropic, OpenAI, OpenRouter). * - `baseUrl` — OpenAI-compatible local endpoint (Ollama). Optional * bearer token may be stored alongside the URL. + * - `oauth` — encrypted provider token set. Currently OpenAI + * ChatGPT/Codex OAuth only. */ -export type AiAuthMode = 'apiKey' | 'baseUrl' +export type AiAuthMode = 'apiKey' | 'baseUrl' | 'oauth' // One AI surface in the admin. Each scope has its own toolset + system prompt. export type ToolScope = 'site' | 'content' | 'data' | 'plugin' @@ -206,4 +208,3 @@ export interface AiBrowserBridge { // Aggregated usage — drivers report token counts so the handler can persist // per-message + per-conversation totals and compute cost from pricing.ts. // --------------------------------------------------------------------------- - diff --git a/server/db/migrations-pg.ts b/server/db/migrations-pg.ts index 37dfaebd..d18f1f73 100644 --- a/server/db/migrations-pg.ts +++ b/server/db/migrations-pg.ts @@ -1039,4 +1039,29 @@ export const pgMigrations: Migration[] = [ where token_hash is not null; `, }, + { + id: '019_ai_openai_oauth_credentials', + sql: ` + -- ─── OpenAI ChatGPT/Codex OAuth credentials ───────────────────────── + -- + -- OAuth stores an encrypted token envelope in the same ciphertext/iv + -- columns as API keys. It is intentionally restricted to OpenAI because + -- the runtime adapter maps these credentials to ChatGPT's Codex + -- Responses endpoint, not the public platform API-key endpoint. + alter table ai_provider_credentials + drop constraint if exists ai_creds_authmode_check; + alter table ai_provider_credentials + drop constraint if exists ai_creds_apikey_shape_check; + + alter table ai_provider_credentials + add constraint ai_creds_authmode_check + check (auth_mode in ('apiKey', 'baseUrl', 'oauth')), + add constraint ai_creds_apikey_shape_check + check ( + (auth_mode = 'apiKey' and ciphertext is not null and iv is not null and base_url is null) or + (auth_mode = 'baseUrl' and base_url is not null) or + (auth_mode = 'oauth' and provider_id = 'openai' and ciphertext is not null and iv is not null and base_url is null) + ); + `, + }, ] diff --git a/server/db/migrations-sqlite.ts b/server/db/migrations-sqlite.ts index e5ec8fb6..c3d01865 100644 --- a/server/db/migrations-sqlite.ts +++ b/server/db/migrations-sqlite.ts @@ -1103,4 +1103,56 @@ export const sqliteMigrations: Migration[] = [ where token_hash is not null; `, }, + { + id: '019_ai_openai_oauth_credentials', + sql: ` + -- ─── OpenAI ChatGPT/Codex OAuth credentials — SQLite mirror of PG 019 ─ + -- + -- SQLite cannot ALTER CHECK constraints, so rebuild the credentials + -- table with the widened auth_mode + shape checks. Existing rows are + -- copied unchanged; OAuth rows are restricted to OpenAI. + + pragma defer_foreign_keys = on; + + create table ai_provider_credentials__migr019 ( + id text primary key, + user_id text not null references users(id) on delete cascade, + provider_id text not null, + auth_mode text not null, + display_label text not null, + ciphertext blob, + iv blob, + base_url text, + key_fingerprint text, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + last_used_at text, + constraint ai_creds_authmode_check + check (auth_mode in ('apiKey', 'baseUrl', 'oauth')), + constraint ai_creds_apikey_shape_check + check ( + (auth_mode = 'apiKey' and ciphertext is not null and iv is not null and base_url is null) or + (auth_mode = 'baseUrl' and base_url is not null) or + (auth_mode = 'oauth' and provider_id = 'openai' and ciphertext is not null and iv is not null and base_url is null) + ) + ); + + insert into ai_provider_credentials__migr019 ( + id, user_id, provider_id, auth_mode, display_label, + ciphertext, iv, base_url, key_fingerprint, + created_at, updated_at, last_used_at + ) + select + id, user_id, provider_id, auth_mode, display_label, + ciphertext, iv, base_url, key_fingerprint, + created_at, updated_at, last_used_at + from ai_provider_credentials; + + drop table ai_provider_credentials; + alter table ai_provider_credentials__migr019 rename to ai_provider_credentials; + + create unique index if not exists ai_creds_user_label_idx + on ai_provider_credentials (user_id, provider_id, display_label); + `, + }, ] diff --git a/src/__tests__/ai/credentialsHandler.test.ts b/src/__tests__/ai/credentialsHandler.test.ts index 31220c5c..a727cf0d 100644 --- a/src/__tests__/ai/credentialsHandler.test.ts +++ b/src/__tests__/ai/credentialsHandler.test.ts @@ -215,4 +215,130 @@ describe('AI credential handler', () => { expect(body.error).not.toContain(apiKey) expect(body.error).toContain('[redacted]') }) + + it('creates an OpenAI OAuth credential through the device flow without leaking tokens', async () => { + const cookie = await harness.setupOwner() + const accessToken = fakeJwt({ chatgpt_account_id: 'acct_test' }) + const refreshToken = 'refresh-secret-test-token' + + globalThis.fetch = async (input, init) => { + const url = typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url + + if (url === 'https://auth.openai.com/api/accounts/deviceauth/usercode') { + expect(init?.method).toBe('POST') + return new Response(JSON.stringify({ + device_auth_id: 'device-auth-1', + user_code: 'ABCD-EFGH', + interval: '1', + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + + if (url === 'https://auth.openai.com/api/accounts/deviceauth/token') { + expect(init?.method).toBe('POST') + expect(String(init?.body)).toContain('device-auth-1') + return new Response(JSON.stringify({ + authorization_code: 'authorization-code-1', + code_verifier: 'verifier-1', + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + + if (url === 'https://auth.openai.com/oauth/token') { + expect(init?.method).toBe('POST') + expect(String(init?.body)).toContain('authorization-code-1') + return new Response(JSON.stringify({ + access_token: accessToken, + refresh_token: refreshToken, + expires_in: 3600, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + + return originalFetch(input, init) + } + + const startRes = await harness.ai('/admin/api/ai/oauth/openai/device/start', { + method: 'POST', + cookie, + json: { displayLabel: 'ChatGPT OAuth' }, + }) + expect(startRes.status).toBe(200) + const startBody = await readJson<{ + flowId: string + userCode: string + verificationUrl: string + intervalMs: number + }>(startRes) + expect(startBody.userCode).toBe('ABCD-EFGH') + expect(startBody.verificationUrl).toBe('https://auth.openai.com/codex/device') + expect(JSON.stringify(startBody)).not.toContain(accessToken) + expect(JSON.stringify(startBody)).not.toContain(refreshToken) + + const completeRes = await harness.ai('/admin/api/ai/oauth/openai/device/complete', { + method: 'POST', + cookie, + json: { flowId: startBody.flowId }, + }) + expect(completeRes.status).toBe(200) + const completeBody = await readJson<{ + status: 'success' + credential: { id: string; providerId: string; authMode: string; displayLabel: string } + }>(completeRes) + expect(completeBody.status).toBe('success') + expect(completeBody.credential).toMatchObject({ + providerId: 'openai', + authMode: 'oauth', + displayLabel: 'ChatGPT OAuth', + }) + expect(JSON.stringify(completeBody)).not.toContain(accessToken) + expect(JSON.stringify(completeBody)).not.toContain(refreshToken) + + const { rows } = await harness.db<{ + provider_id: string + auth_mode: string + ciphertext: Uint8Array | null + iv: Uint8Array | null + base_url: string | null + }>` + select provider_id, auth_mode, ciphertext, iv, base_url + from ai_provider_credentials + where id = ${completeBody.credential.id} + ` + expect(rows[0]).toMatchObject({ + provider_id: 'openai', + auth_mode: 'oauth', + base_url: null, + }) + expect(rows[0]?.ciphertext).toBeInstanceOf(Uint8Array) + expect(rows[0]?.iv).toBeInstanceOf(Uint8Array) + + const defaults = await harness.db<{ count: number; model_id: string }>` + select count(*) as count, min(model_id) as model_id + from ai_defaults + ` + expect(defaults.rows[0]?.count).toBe(4) + expect(defaults.rows[0]?.model_id).toBe('gpt-5.5') + }) }) + +function fakeJwt(payload: Record): string { + return `${base64UrlJson({ alg: 'none', typ: 'JWT' })}.${base64UrlJson(payload)}.signature` +} + +function base64UrlJson(value: unknown): string { + return btoa(JSON.stringify(value)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') +} diff --git a/src/__tests__/ai/responsesMapping.test.ts b/src/__tests__/ai/responsesMapping.test.ts index c12e1cb7..2cf86559 100644 --- a/src/__tests__/ai/responsesMapping.test.ts +++ b/src/__tests__/ai/responsesMapping.test.ts @@ -238,6 +238,58 @@ describe('runToolLoop via openaiDriver (Responses)', () => { expect(usage!.promptTokens).toBe(45) expect(usage!.completionTokens).toBe(15) }) + + test('sends store=false for OpenAI OAuth Codex requests', async () => { + const requestBodies: Array> = [] + globalThis.fetch = (async (url: string, init: RequestInit) => { + expect(url).toBe('https://chatgpt.com/backend-api/codex/responses') + expect((init.headers as Record).Authorization).toBe('Bearer oauth-access-test') + expect((init.headers as Record)['ChatGPT-Account-Id']).toBe('acct_test') + requestBodies.push(JSON.parse(init.body as string)) + return sseResponse( + responsesSse( + { type: 'response.output_text.delta', delta: 'oauth reply' }, + { type: 'response.completed', response: { usage: { input_tokens: 4, output_tokens: 2 } } }, + ), + ) + }) as typeof fetch + + const bridge: AiBrowserBridge = { async callBrowser(): Promise { return { ok: true } } } + const req: AiStreamRequest = { + systemPrompt: ['You are a test.'], + messages: [{ role: 'user', content: [{ kind: 'text', text: 'go' }] }], + tools: [], + modelId: 'gpt-5.5', + modelCapabilities: { toolCalling: true, visionInput: true, promptCache: false, streaming: true }, + credentials: { + id: 'cr', + providerId: 'openai', + authMode: 'oauth', + apiKey: 'oauth-access-test', + baseUrl: null, + oauth: { accountId: 'acct_test' }, + }, + signal: new AbortController().signal, + bridge, + toolContextBase: { + db: {} as never, + userId: 'u1', + capabilities: [], + scope: 'site', + conversationId: 'c1', + snapshot: {}, + }, + } + + const events: AiStreamEvent[] = [] + for await (const ev of openaiDriver.stream(req)) events.push(ev) + + expect(requestBodies).toHaveLength(1) + expect(requestBodies[0]!.store).toBe(false) + expect(requestBodies[0]!.model).toBe('gpt-5.5') + const usage = events.find((e) => e.type === 'usage') as { costUsd?: number } | undefined + expect(usage?.costUsd).toBe(0) + }) }) describe('openrouterDriver', () => { diff --git a/src/admin/ai/api.ts b/src/admin/ai/api.ts index 39294437..981b8f5a 100644 --- a/src/admin/ai/api.ts +++ b/src/admin/ai/api.ts @@ -39,6 +39,7 @@ const ProviderId = Type.Union([ const AuthMode = Type.Union([ Type.Literal('apiKey'), Type.Literal('baseUrl'), + Type.Literal('oauth'), ]) const ToolScope = Type.Union([ @@ -69,6 +70,31 @@ const CredentialItemResponseSchema = Type.Object({ credential: CredentialViewSchema, }) +const OpenAiOAuthStartResponseSchema = Type.Object({ + flowId: Type.String(), + userCode: Type.String(), + verificationUrl: Type.String(), + intervalMs: Type.Number(), +}) + +const OpenAiOAuthCompleteResponseSchema = Type.Union([ + Type.Object({ + status: Type.Literal('pending'), + retryAfterMs: Type.Optional(Type.Number()), + }), + Type.Object({ + status: Type.Literal('success'), + credential: CredentialViewSchema, + }), + Type.Object({ + status: Type.Literal('failed'), + error: Type.String(), + }), +]) + +export type OpenAiOAuthStart = Static +export type OpenAiOAuthComplete = Static + const TestResponseSchema = Type.Object({ ok: Type.Boolean(), modelCount: Type.Optional(Type.Number()), @@ -199,6 +225,22 @@ export async function createCredential(body: CreateCredentialBody): Promise { + return apiRequest('/admin/api/ai/oauth/openai/device/start', { + method: 'POST', + body: { displayLabel }, + schema: OpenAiOAuthStartResponseSchema, + }) +} + +export async function completeOpenAiOAuthDevice(flowId: string): Promise { + return apiRequest('/admin/api/ai/oauth/openai/device/complete', { + method: 'POST', + body: { flowId }, + schema: OpenAiOAuthCompleteResponseSchema, + }) +} + export async function deleteCredential(id: string): Promise { await apiRequest(`/admin/api/ai/credentials/${encodeURIComponent(id)}`, { method: 'DELETE' }) } diff --git a/src/admin/pages/ai/AiPage.module.css b/src/admin/pages/ai/AiPage.module.css index f67a0a61..3bdd3886 100644 --- a/src/admin/pages/ai/AiPage.module.css +++ b/src/admin/pages/ai/AiPage.module.css @@ -218,6 +218,28 @@ line-height: 1.3; } +.oauthPanel { + grid-column: 1 / -1; + display: grid; + gap: var(--space-m); + padding: var(--space-l); + background: var(--bg-surface-2); + border-radius: var(--radius); +} + +.oauthCode { + width: fit-content; + margin-top: var(--space-3xs); + padding: var(--space-xs) var(--space-m); + color: var(--text-bright); + background: var(--bg-surface); + border-radius: var(--radius-sm); + font-family: var(--font-mono); + font-size: var(--text-2xl); + line-height: 1.1; + letter-spacing: 0; +} + /* ── Audit tab ───────────────────────────────────────────────────────── */ .auditHeaderActions { diff --git a/src/admin/pages/ai/tabs/ProvidersTab.tsx b/src/admin/pages/ai/tabs/ProvidersTab.tsx index fa15b3eb..7bc41cdc 100644 --- a/src/admin/pages/ai/tabs/ProvidersTab.tsx +++ b/src/admin/pages/ai/tabs/ProvidersTab.tsx @@ -5,7 +5,7 @@ * is the wire-safe `CredentialView` (no plaintext, no ciphertext). */ -import { useId, useState } from 'react' +import { useEffect, useId, useState, type FormEvent } from 'react' import { useAsyncResource } from '@admin/lib/useAsyncResource' import { Button } from '@ui/components/Button' import { Dialog } from '@ui/components/Dialog' @@ -14,13 +14,17 @@ import { Select } from '@ui/components/Select' import { PlusIcon } from 'pixel-art-icons/icons/plus' import { TrashSolidIcon } from 'pixel-art-icons/icons/trash-solid' import { CheckIcon } from 'pixel-art-icons/icons/check' +import { ExternalLinkSolidIcon } from 'pixel-art-icons/icons/external-link-solid' import { type CredentialView, type CreateCredentialBody, + type OpenAiOAuthStart, type TestResult, + completeOpenAiOAuthDevice, createCredential, deleteCredential, listCredentials, + startOpenAiOAuthDevice, testCredential, } from '../../../ai/api' import { ApiError } from '@core/http' @@ -28,20 +32,28 @@ import styles from '../AiPage.module.css' import { getErrorMessage } from '@core/utils/errorMessage' type ProviderId = 'anthropic' | 'openai' | 'ollama' | 'openrouter' -type AuthMode = 'apiKey' | 'baseUrl' - -// Each provider has exactly one credential shape; the UI derives it instead -// of asking the user to choose an auth mode that cannot vary. -const PROVIDERS: Array<{ id: ProviderId; label: string; authMode: AuthMode }> = [ - { id: 'anthropic', label: 'Anthropic (Claude)', authMode: 'apiKey' }, - { id: 'openai', label: 'OpenAI', authMode: 'apiKey' }, - { id: 'openrouter', label: 'OpenRouter', authMode: 'apiKey' }, - { id: 'ollama', label: 'Ollama (local)', authMode: 'baseUrl' }, +type AuthMode = 'apiKey' | 'baseUrl' | 'oauth' +type ProviderOptionId = 'anthropic' | 'openai-api-key' | 'openai-oauth' | 'openrouter' | 'ollama' + +// Provider options include auth shape so OpenAI can expose both API-key and +// ChatGPT/Codex OAuth without adding a second form control. +const PROVIDERS: Array<{ + id: ProviderOptionId + providerId: ProviderId + label: string + authMode: AuthMode +}> = [ + { id: 'anthropic', providerId: 'anthropic', label: 'Anthropic (Claude)', authMode: 'apiKey' }, + { id: 'openai-api-key', providerId: 'openai', label: 'OpenAI API key', authMode: 'apiKey' }, + { id: 'openai-oauth', providerId: 'openai', label: 'OpenAI ChatGPT OAuth', authMode: 'oauth' }, + { id: 'openrouter', providerId: 'openrouter', label: 'OpenRouter', authMode: 'apiKey' }, + { id: 'ollama', providerId: 'ollama', label: 'Ollama (local)', authMode: 'baseUrl' }, ] const AUTH_MODE_LABEL: Record = { apiKey: 'API key', baseUrl: 'Endpoint URL', + oauth: 'OAuth', } const PROVIDER_LABEL: Record = { @@ -230,7 +242,7 @@ export function ProvidersTab() { // --------------------------------------------------------------------------- async function submitCredential( - effectiveAuthMode: AuthMode, + effectiveAuthMode: Exclude, providerId: ProviderId, displayLabel: string, apiKey: string, @@ -275,21 +287,88 @@ function AddCredentialDialog({ const baseUrlInputId = useId() const formId = useId() - const [providerId, setProviderId] = useState('anthropic') + const [providerOptionId, setProviderOptionId] = useState('anthropic') const [displayLabel, setDisplayLabel] = useState('') const [apiKey, setApiKey] = useState('') const [baseUrl, setBaseUrl] = useState('http://localhost:11434') const [busy, setBusy] = useState(false) const [error, setError] = useState(null) + const [oauthFlow, setOauthFlow] = useState(null) + const [oauthFailed, setOauthFailed] = useState(false) - const providerSpec = PROVIDERS.find((p) => p.id === providerId)! + const providerSpec = PROVIDERS.find((p) => p.id === providerOptionId)! + const providerId = providerSpec.providerId const effectiveAuthMode = providerSpec.authMode - async function handleSubmit(e: React.FormEvent) { + useEffect(() => { + if (!oauthFlow || oauthFailed) return + const flow = oauthFlow + let cancelled = false + let timer: number | undefined + + async function poll() { + try { + const result = await completeOpenAiOAuthDevice(flow.flowId) + if (cancelled) return + if (result.status === 'pending') { + timer = window.setTimeout(poll, result.retryAfterMs ?? flow.intervalMs) + return + } + if (result.status === 'success') { + onCreated() + return + } + setOauthFailed(true) + setError(result.error) + } catch (err) { + if (cancelled) return + setOauthFailed(true) + setError(getErrorMessage(err, 'OpenAI OAuth failed.')) + } + } + + timer = window.setTimeout(poll, flow.intervalMs) + return () => { + cancelled = true + if (timer !== undefined) window.clearTimeout(timer) + } + }, [oauthFlow, oauthFailed, onCreated]) + + async function handleSubmit(e: FormEvent) { e.preventDefault() + if (effectiveAuthMode === 'oauth') { + await startOAuthFlow() + return + } await submitCredential(effectiveAuthMode, providerId, displayLabel, apiKey, baseUrl, onCreated, setError, setBusy) } + async function startOAuthFlow() { + setError(null) + setOauthFailed(false) + setBusy(true) + try { + const flow = await startOpenAiOAuthDevice(displayLabel) + setOauthFlow(flow) + window.open(flow.verificationUrl, '_blank', 'noopener,noreferrer') + } catch (err) { + if (err instanceof ApiError) { + setError(err.message) + } else { + setError(getErrorMessage(err, 'Failed to start OpenAI OAuth.')) + } + } finally { + setBusy(false) + } + } + + function handleProviderChange(nextId: ProviderOptionId) { + setProviderOptionId(nextId) + setError(null) + setOauthFlow(null) + setOauthFailed(false) + } + return ( Cancel - + {oauthFlow ? ( + + ) : ( + + )} } > @@ -313,8 +407,9 @@ function AddCredentialDialog({ )} - {effectiveAuthMode === 'baseUrl' && ( + {!oauthFlow && effectiveAuthMode === 'baseUrl' && ( <>