import type { AgentModelOAuthCredential } from "@rakazo/adapter-kit"; import type { AgentRunRequest } from "@rakazo/adapter-kit"; import { OPENAI_COMPATIBLE_PROVIDER_ID } from "@rakazo/contracts"; import { resolveDeploymentModel } from "./deployment-model.js"; import { modelIdSupportsImages } from "./model-vision.js"; import { toOAuthCredential } from "./pi-credentials.js"; import { parseModelSecret, resolveModelAuth, secretValuesToRedact, serializeModelSecret, } from "./pi-oauth.js"; import type { ExecutorDeps } from "./executor-types.js"; const modelCredentialLocks = new Map>(); /** * Return a deployment API key when the winning provider matches the * configured deployment model provider. A provider named by deployment * settings or a bot override gets no key rather than another vendor's. */ export function deploymentKeyFor( deps: ExecutorDeps, provider: string, ): string | undefined { if (!deps.deploymentModelKey) return undefined; return provider === resolveDeploymentModel().provider ? deps.deploymentModelKey : undefined; } /** * Resolve model credentials for a given provider/model pair. * * When a credential row exists, fetch the secret from the database, * apply any required OAuth flow, and return the resolved API key plus * metadata (base URL, reasoning flags, image support, etc.). * * When no credential exists, fall back to the deployment key for the * winning provider. */ export async function resolveModelKey( deps: ExecutorDeps, userId: string, spaceId: string, credential: { secretId: string; provider: string; defaultModel?: string | null; supportsImages?: boolean; } | null, provider: string, modelId: string, registerSecrets?: (values: string[]) => void, ): Promise<{ apiKey?: string; baseUrl?: string; reasoning?: boolean; maxTokens?: number; contextWindow?: number; thinkingLevel?: AgentRunRequest["model"]["thinkingLevel"]; acceptsImages?: boolean; maxImagesPerPrompt?: number; oauth?: AgentModelOAuthCredential; persistOAuth?: (credential: AgentModelOAuthCredential) => Promise; redact: string[]; }> { if (credential) { return withModelCredentialLock(credential.secretId, async () => { const row = await deps.prisma.secret.findFirst({ where: { id: credential.secretId, userId, spaceId: null }, }); if (!row) return { apiKey: deploymentKeyFor(deps, provider), redact: [] }; const plaintext = deps.secretStore.load(row.ciphertext, row.id); registerSecrets?.(secretValuesToRedact(parseModelSecret(plaintext))); const persist = async (next: string) => { const stored = await deps.secretStore.put( next, { operationId: "cred", traceId: "cred-refresh", spaceId, userId, signal: new AbortController().signal, }, row.id, ); await deps.prisma.secret.update({ where: { id: row.id }, data: { ciphertext: stored.ciphertext }, }); }; const resolved = await resolveModelAuth(plaintext, credential.provider, { persist, }); const oauth = resolved.secret.kind === "oauth" ? resolved.secret.credential : undefined; const baseUrl = resolved.secret.kind === "openai_compatible" ? resolved.secret.baseUrl : undefined; const acceptsImages = credential.provider === OPENAI_COMPATIBLE_PROVIDER_ID && resolved.secret.kind === "openai_compatible" && (modelIdSupportsImages(resolved.secret.visionModelIds, modelId) || // Legacy secrets have no per-model list, so keep their existing // capability scoped to the model saved in the space preference. (resolved.secret.visionModelIds === undefined && credential.supportsImages === true && credential.defaultModel?.trim() === modelId.trim())); return { apiKey: resolved.apiKey, baseUrl, reasoning: resolved.secret.kind === "openai_compatible" ? resolved.secret.reasoning : undefined, maxTokens: resolved.secret.kind === "openai_compatible" ? resolved.secret.maxTokens : undefined, contextWindow: resolved.secret.kind === "openai_compatible" ? resolved.secret.contextWindow : undefined, thinkingLevel: resolved.secret.kind === "openai_compatible" ? resolved.secret.thinkingLevel : undefined, acceptsImages, maxImagesPerPrompt: resolved.secret.kind === "openai_compatible" ? resolved.secret.maxImagesPerPrompt : undefined, oauth, persistOAuth: oauth ? async (next) => { await withModelCredentialLock(credential.secretId, async () => { const currentRow = await deps.prisma.secret.findFirst({ where: { id: credential.secretId, userId, spaceId: null }, }); if (!currentRow) return; const current = parseModelSecret( deps.secretStore.load( currentRow.ciphertext, currentRow.id, ), ); if (current.kind === "oauth") { const stored = current.credential; if (stored.expires > next.expires) return; if ( stored.access === next.access && stored.refresh === next.refresh && stored.expires === next.expires ) { return; } } await persist( serializeModelSecret({ kind: "oauth", credential: toOAuthCredential(next), }), ); }); } : undefined, redact: [...secretValuesToRedact(resolved.secret), resolved.apiKey].filter( (value): value is string => Boolean(value), ), }; }); } return { apiKey: deploymentKeyFor(deps, provider), redact: [] }; } /** * Serialize access to credential operations for a single secret key. * Prevents concurrent reads/writes from producing torn state. */ export async function withModelCredentialLock( key: string, fn: () => Promise, ): Promise { const previous = modelCredentialLocks.get(key) ?? Promise.resolve(); let release!: () => void; const current = previous.then( () => new Promise((resolve) => { release = resolve; }), ); modelCredentialLocks.set(key, current); await previous; try { return await fn(); } finally { release(); if (modelCredentialLocks.get(key) === current) modelCredentialLocks.delete(key); } }