Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions server/ai/credentials/autoDefaults.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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]')
}
72 changes: 71 additions & 1 deletion server/ai/credentials/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<AiResolvedCredential> {
const currentFingerprint = await getMasterKeyFingerprint()
Expand All @@ -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<typeof parseOpenAiOAuthSecret>
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 ` +
Expand All @@ -222,6 +266,25 @@ export async function resolveCredentialForDriver(
}
}

async function refreshAndPersistOpenAiOAuthSecret(
db: DbClient,
record: CredentialRecord,
secret: ReturnType<typeof parseOpenAiOAuthSecret>,
): Promise<ReturnType<typeof parseOpenAiOAuthSecret>> {
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
// ---------------------------------------------------------------------------
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions server/ai/credentials/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
45 changes: 42 additions & 3 deletions server/ai/drivers/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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<string, string> = {
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 = {
Expand All @@ -82,6 +114,12 @@ export const openaiDriver: AiProvider = {
},

async *stream(req: AiStreamRequest): AsyncIterable<AiStreamEvent> {
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
Expand All @@ -93,7 +131,7 @@ export const openaiDriver: AiProvider = {
}
return
}
yield* runToolLoop(openaiAdapter, req)
yield* runToolLoop(openaiApiKeyAdapter, req)
},
}

Expand Down Expand Up @@ -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<AiProviderModel[]> {
if (creds.authMode === 'oauth') return OPENAI_OAUTH_MODELS
if (creds.authMode !== 'apiKey' || !creds.apiKey) return []

const res = await fetch(OPENAI_MODELS_ENDPOINT, {
Expand Down
2 changes: 2 additions & 0 deletions server/ai/drivers/responses-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ interface ResponsesAdapterOptions {
readonly endpoint: string
buildHeaders(req: AiStreamRequest): Record<string, string>
promptCacheKey?: (req: AiStreamRequest) => string | null
buildExtraBody?: (req: AiStreamRequest) => Record<string, unknown>
}

/**
Expand Down Expand Up @@ -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
},

Expand Down
7 changes: 6 additions & 1 deletion server/ai/drivers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -38,6 +40,9 @@ export interface AiResolvedCredential {
readonly authMode: AiAuthMode
readonly apiKey: string | null
readonly baseUrl: string | null
readonly oauth?: {
readonly accountId?: string
}
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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']
*/
Expand Down
2 changes: 1 addition & 1 deletion server/ai/handlers/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
Loading