/** * @factory/ai — config resolution (ai-architecture §2.3, TASK-0121). * * Resolution order (gateway, per call): * * 1. workspace.ai config → use it * 2. org policy.ai → use it (org overrides workspace — enterprise control) * 3. platform default → env AI_DEFAULT_* (self-hosted default: a local * Ollama/vLLM endpoint, so a fresh install works offline) * * **Per-field** fallback (not all-or-nothing): each field independently falls * workspace → org → env → built-in default, so a workspace that only sets * `chatModel` keeps the platform's embed model and budgets. * * Secrets: `secretsRef` / `apiKeyRef` reference a **name** of a provider key * (resolved at call time from the app's encrypted `provider_secrets` store, or * an `env:NAME` reference resolved from the process environment). A literal * key value is never stored in config JSON. */ import { z } from "zod"; import type { ProviderId } from "./types.js"; export const PROVIDER_IDS = ["openai", "anthropic", "google", "oss", "custom"] as const; const providerIdSchema = z.enum(PROVIDER_IDS); /** Workspace-level AI config (`Workspace.settings.ai`, Zod-validated). */ export const AiConfigSchema = z.object({ enabled: z.boolean(), provider: providerIdSchema, chatModel: z.string().min(1), embedModel: z.string().min(1), endpoint: z .object({ baseUrl: z.string().url(), extraHeaders: z.record(z.string()).optional(), }) .optional(), /** Name of the provider key in `provider_secrets` (never a literal). */ secretsRef: z.string().min(1).optional(), /** * The embed fallback provider — used when the primary has no embedding API * (Anthropic has none). Default `openai`. */ embedFallbackProvider: providerIdSchema.optional(), fallback: z .object({ provider: providerIdSchema, chatModel: z.string().min(1), }) .nullable() .optional(), budgets: z .object({ tokensPerMonth: z.number().int().nonnegative(), requestsPerMinute: z.number().int().positive(), maxOutputTokens: z.number().int().positive(), dailySpendUsd: z.number().nonnegative(), }) .optional(), policies: z .object({ redactPii: z.boolean(), logPrompts: z.boolean(), allowUserContentInPrompts: z.boolean(), }) .optional(), }); export type AiConfig = z.infer; /** Org-level policy (enterprise control; overrides workspace per field). */ export const OrgAiPolicySchema = z.object({ enabled: z.boolean().optional(), provider: providerIdSchema.optional(), chatModel: z.string().min(1).optional(), embedModel: z.string().min(1).optional(), /** 0 = platform default. */ budgets: AiConfigSchema.shape.budgets, policies: AiConfigSchema.shape.policies, /** When false, the owner cannot override an exhausted budget for their own calls. */ allowOwnerOverride: z.boolean().optional(), }); export type OrgAiPolicy = z.infer; /** Platform defaults from the environment (fresh-install offline default is OSS). */ export const EnvDefaultsSchema = z.object({ provider: providerIdSchema.default("openai"), chatModel: z.string().default("gpt-4o"), embedModel: z.string().default("text-embedding-3-small"), /** OSS/custom base URL from the environment. */ baseUrl: z.string().url().optional(), /** `env:NAME` reference to the platform API key. */ apiKeyRef: z.string().optional(), embedFallbackProvider: providerIdSchema.default("openai"), }); export type EnvDefaults = z.infer; export interface ResolvedAiConfig { enabled: boolean; provider: ProviderId; chatModel: string; embedModel: string; embedFallbackProvider: ProviderId; endpoint?: { baseUrl: string; extraHeaders?: Record }; secretsRef?: string; fallback: { provider: ProviderId; chatModel: string } | null; budgets: NonNullable; policies: NonNullable; allowOwnerOverride: boolean; /** * The workspace this config was resolved FOR (set by the app layer's * `resolveConfig` from its `workspaceId` argument). The registry reads it * to scope per-workspace test seams (the mock-outage arming) — without it a * single process-global arming would be consumed by whichever parallel * request (e.g. Playwright's desktop + mobile projects) lands first. */ workspaceId?: string; } export const DEFAULT_BUDGETS: ResolvedAiConfig["budgets"] = { tokensPerMonth: 0, requestsPerMinute: 30, maxOutputTokens: 4096, dailySpendUsd: 0, }; export const DEFAULT_POLICIES: ResolvedAiConfig["policies"] = { redactPii: false, logPrompts: false, allowUserContentInPrompts: true, }; /** * Per-field merge: `workspace` → `org` → `env` → defaults. Fields that are * `undefined` at a level fall through to the next level. */ export function resolveAiConfig( workspace: Partial | null | undefined, org: Partial | null | undefined, env: Partial, ): ResolvedAiConfig { const e = { ...EnvDefaultsSchema.parse({ provider: "openai", chatModel: "gpt-4o", embedModel: "text-embedding-3-small", embedFallbackProvider: "openai" }), ...env }; const provider = org?.provider ?? workspace?.provider ?? e.provider; const chatModel = org?.chatModel ?? workspace?.chatModel ?? e.chatModel; const embedModel = org?.embedModel ?? workspace?.embedModel ?? e.embedModel; const embedFallbackProvider = workspace?.embedFallbackProvider ?? e.embedFallbackProvider; const budgets = { ...DEFAULT_BUDGETS, ...workspace?.budgets, ...org?.budgets }; const policies = { ...DEFAULT_POLICIES, ...workspace?.policies, ...org?.policies }; const endpoint = workspace?.endpoint ?? (e.baseUrl ? { baseUrl: e.baseUrl } : undefined); const secretsRef = workspace?.secretsRef ?? e.apiKeyRef ?? undefined; const fallback = workspace?.fallback !== undefined ? workspace.fallback : null; return { enabled: org?.enabled ?? workspace?.enabled ?? true, provider, chatModel, embedModel, embedFallbackProvider, ...(endpoint ? { endpoint: endpoint.extraHeaders ? { baseUrl: endpoint.baseUrl, extraHeaders: endpoint.extraHeaders } : { baseUrl: endpoint.baseUrl } } : {}), ...(secretsRef ? { secretsRef } : {}), fallback, budgets, policies, allowOwnerOverride: org?.allowOwnerOverride ?? true, }; } /** * An API-key reference is a **name**, never a literal. Valid forms: * - `env:NAME` — resolved from the process environment * - `NAME` — resolved from the encrypted `provider_secrets` store * A value that looks like a raw key (starts with `sk-`, `xai-`, `AIza`, …) is * rejected at save time. */ export function assertKeyRef(ref: string): void { if (/^(sk-|xai-|AIza|sk-ant-|sk_live_|sk_test_)/.test(ref)) { throw new Error("apiKeyRef must be a name reference, never a literal key"); } if (ref.length > 128) { throw new Error("apiKeyRef too long"); } } /** Resolve an `env:NAME` reference against the environment. */ export function resolveKeyRef(ref: string, env: Record): string | undefined { if (ref.startsWith("env:")) { return env[ref.slice(4)]; } // non-env refs are resolved by the app's secrets store (injected elsewhere) return undefined; }