import { Secret } from "../configuration/secrets.ts"; import { MODEL_CATALOG, MODEL_PROVIDERS, isModelProvider, isCredentialProvider, modelEfforts, type CredentialProvider, type ModelEffort, type ModelConfiguration, type ModelProvider, } from "../shared/model-providers.ts"; export { MODEL_CATALOG, type ModelConfiguration, } from "../shared/model-providers.ts"; export interface ModelSettings { configuration: ModelConfiguration; credentials: Record; providers: Record; providerSetupUrl: string | null; } export interface ConversationModelSettings extends ModelSettings { override: ModelConfiguration | null; } export const DEFAULT_MODEL: ModelConfiguration = { provider: "workers-ai", model: MODEL_CATALOG["workers-ai"][0], }; export function parseModelConfiguration(value: unknown): ModelConfiguration { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Select a supported provider and model"); const record = value as Record; if ( Object.keys(record).some( (key) => !["provider", "model", "effort"].includes(key), ) || !Object.hasOwn(record, "provider") || !Object.hasOwn(record, "model") || !isModelProvider(record.provider) || typeof record.model !== "string" || !(MODEL_CATALOG[record.provider] as readonly string[]).includes( record.model, ) ) throw new Error("Select a supported provider and model"); if ( Object.hasOwn(record, "effort") && !modelEfforts(record as ModelConfiguration).includes( record.effort as ModelEffort, ) ) throw new Error("Select a supported effort for this model"); return { provider: record.provider, model: record.model, ...(record.effort === undefined ? {} : { effort: record.effort }), } as ModelConfiguration; } export function parseProviderKey( provider: unknown, value: unknown, ): Secret | null { if (!isCredentialProvider(provider)) throw new Error("Unsupported credential provider"); if (value === null) return null; if ( typeof value !== "string" || value.length < 20 || value.length > 512 || !/^[\x21-\x7e]+$/.test(value) ) throw new Error( `${MODEL_PROVIDERS[provider].name} API key must be 20–512 printable characters without spaces`, ); return new Secret(value); } // Credentials are encrypted in customer-owned parent storage. HKDF separates // this use from cookie signing; the installation ID also authenticates ciphertext. async function encryptionKey(secret: Secret, installationId: string) { const material = await crypto.subtle.importKey( "raw", new TextEncoder().encode(secret.reveal()), "HKDF", false, ["deriveKey"], ); return crypto.subtle.deriveKey( { name: "HKDF", hash: "SHA-256", salt: new TextEncoder().encode(installationId), info: new TextEncoder().encode("flarebot/provider-credentials/v1"), }, material, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"], ); } export async function encryptProviderKey( value: Secret, secret: Secret, installationId: string, ): Promise { const iv = crypto.getRandomValues(new Uint8Array(12)); const ciphertext = await crypto.subtle.encrypt( { name: "AES-GCM", iv, additionalData: new TextEncoder().encode(installationId), }, await encryptionKey(secret, installationId), new TextEncoder().encode(value.reveal()), ); return JSON.stringify({ version: 1, iv: Array.from(iv), ciphertext: Array.from(new Uint8Array(ciphertext)), }); } export async function decryptProviderKey( value: string, secret: Secret, installationId: string, ): Promise { try { const envelope = JSON.parse(value); if (envelope.version !== 1) throw new Error(); const plaintext = await crypto.subtle.decrypt( { name: "AES-GCM", iv: new Uint8Array(envelope.iv), additionalData: new TextEncoder().encode(installationId), }, await encryptionKey(secret, installationId), new Uint8Array(envelope.ciphertext), ); return new Secret(new TextDecoder().decode(plaintext)); } catch { throw new Error( "Stored provider API key cannot be read; replace it in settings", ); } }