import { AI_PROVIDERS, AI_CONNECTION_CAPABILITIES, isAiConnectionCompatible, type AiConnectionBinding, type AiProvider } from "@paperclipai/shared"; type AuthenticationFailure = { errorCode?: string | null; resultJson?: unknown; }; function record(value: unknown): Record | null { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : null; } /** A personal model credential gap is repairable without changing other secrets. */ function missingPersonalAiCredentialProvider(run: AuthenticationFailure): AiProvider | undefined { if (run.errorCode !== "configuration_incomplete") return undefined; const gap = record(record(run.resultJson)?.configurationIncomplete); if (gap?.reason !== "secret_binding_missing" || !Array.isArray(gap.missingBindings) || !gap.missingBindings.length) return undefined; if (typeof gap.agentId !== "string" || !gap.agentId) return undefined; const missingBindings = gap.missingBindings; return AI_PROVIDERS.find(provider => { const keys = new Set(Object.values(AI_CONNECTION_CAPABILITIES[provider].methods).map(method => method.envKey)); return missingBindings.every((value: unknown) => { const binding = record(value); return binding?.bindingType === "user_secret_ref" // Account repair replaces this agent's model credential. It cannot // satisfy project, environment, routine, or another agent's bindings. && binding.consumerType === "agent" && binding.consumerId === gap.agentId && ["user_secret_missing", "secret_inactive"].includes(String(binding.errorCode)) && typeof binding.envKey === "string" && keys.has(binding.envKey) && binding.configPath === `env.${binding.envKey}`; }); }); } export function isAiConnectionConfigurationFailure(run: AuthenticationFailure): boolean { return run.errorCode === "configuration_incomplete" && ( record(record(run.resultJson)?.configurationIncomplete)?.reason === "ai_connection_unavailable" || Boolean(missingPersonalAiCredentialProvider(run)) ); } export function isAiAuthenticationRepairable(run: AuthenticationFailure): boolean { return isAiAuthenticationFailure(run.errorCode) || isAiConnectionConfigurationFailure(run); } /** Provider authentication signals only. Tool authorization and quotas need different repairs. */ export function isAiAuthenticationFailure(code: string | null | undefined): boolean { return Boolean(code && ( /^(acpx|claude|codex|grok|opencode|gemini|kimi|pi|cursor|copilot)_auth_required$/i.test(code) || ["adapter_auth_missing", "authentication_required", "auth_required", "refresh_token_reused", "refresh_token_expired", "refresh_token_invalidated"].includes(code) )); } /** Only a persisted blocked classification confirms that an inline repair owns recovery. */ export function isAiAuthenticationBlocked(run: (AuthenticationFailure & { livenessState?: string | null }) | null | undefined): boolean { return run?.livenessState === "blocked" && isAiAuthenticationRepairable(run); } export function aiBindingForAuthRecovery( adapterType: string, config: Record, failure?: AuthenticationFailure, ): AiConnectionBinding | undefined { if (failure?.errorCode === "configuration_incomplete" && !isAiConnectionConfigurationFailure(failure)) return undefined; const missingProvider = failure ? missingPersonalAiCredentialProvider(failure) : undefined; for (const provider of AI_PROVIDERS) { if (missingProvider && missingProvider !== provider) continue; for (const method of ["subscription", "api_key"] as const) { if (!AI_CONNECTION_CAPABILITIES[provider].methods[method]) continue; // Recovery presents a concrete sign-in method. A responsible-user binding // normally follows any compatible default, so check exact metadata first. if (isAiConnectionCompatible({ provider, method }, adapterType, config.model, config.provider, config.acpxAgent)) { return { provider, method, mode: "responsible_user" }; } } } return undefined; }