1
20
21import { z } from "zod";
22
23import type { ProviderId } from "./types.js";
24
25export const PROVIDER_IDS = ["openai", "anthropic", "google", "oss", "custom"] as const;
26const providerIdSchema = z.enum(PROVIDER_IDS);
27
28
29export const AiConfigSchema = z.object({
30 enabled: z.boolean(),
31 provider: providerIdSchema,
32 chatModel: z.string().min(1),
33 embedModel: z.string().min(1),
34 endpoint: z
35 .object({
36 baseUrl: z.string().url(),
37 extraHeaders: z.record(z.string()).optional(),
38 })
39 .optional(),
40
41 secretsRef: z.string().min(1).optional(),
42
46 embedFallbackProvider: providerIdSchema.optional(),
47 fallback: z
48 .object({
49 provider: providerIdSchema,
50 chatModel: z.string().min(1),
51 })
52 .nullable()
53 .optional(),
54 budgets: z
55 .object({
56 tokensPerMonth: z.number().int().nonnegative(),
57 requestsPerMinute: z.number().int().positive(),
58 maxOutputTokens: z.number().int().positive(),
59 dailySpendUsd: z.number().nonnegative(),
60 })
61 .optional(),
62 policies: z
63 .object({
64 redactPii: z.boolean(),
65 logPrompts: z.boolean(),
66 allowUserContentInPrompts: z.boolean(),
67 })
68 .optional(),
69});
70export type AiConfig = z.infer<typeof AiConfigSchema>;
71
72
73export const OrgAiPolicySchema = z.object({
74 enabled: z.boolean().optional(),
75 provider: providerIdSchema.optional(),
76 chatModel: z.string().min(1).optional(),
77 embedModel: z.string().min(1).optional(),
78
79 budgets: AiConfigSchema.shape.budgets,
80 policies: AiConfigSchema.shape.policies,
81
82 allowOwnerOverride: z.boolean().optional(),
83});
84export type OrgAiPolicy = z.infer<typeof OrgAiPolicySchema>;
85
86
87export const EnvDefaultsSchema = z.object({
88 provider: providerIdSchema.default("openai"),
89 chatModel: z.string().default("gpt-4o"),
90 embedModel: z.string().default("text-embedding-3-small"),
91
92 baseUrl: z.string().url().optional(),
93
94 apiKeyRef: z.string().optional(),
95 embedFallbackProvider: providerIdSchema.default("openai"),
96});
97export type EnvDefaults = z.infer<typeof EnvDefaultsSchema>;
98
99export interface ResolvedAiConfig {
100 enabled: boolean;
101 provider: ProviderId;
102 chatModel: string;
103 embedModel: string;
104 embedFallbackProvider: ProviderId;
105 endpoint?: { baseUrl: string; extraHeaders?: Record<string, string> };
106 secretsRef?: string;
107 fallback: { provider: ProviderId; chatModel: string } | null;
108 budgets: NonNullable<AiConfig["budgets"]>;
109 policies: NonNullable<AiConfig["policies"]>;
110 allowOwnerOverride: boolean;
111
118 workspaceId?: string;
119}
120
121export const DEFAULT_BUDGETS: ResolvedAiConfig["budgets"] = {
122 tokensPerMonth: 0,
123 requestsPerMinute: 30,
124 maxOutputTokens: 4096,
125 dailySpendUsd: 0,
126};
127
128export const DEFAULT_POLICIES: ResolvedAiConfig["policies"] = {
129 redactPii: false,
130 logPrompts: false,
131 allowUserContentInPrompts: true,
132};
133
134
138export function resolveAiConfig(
139 workspace: Partial<AiConfig> | null | undefined,
140 org: Partial<OrgAiPolicy> | null | undefined,
141 env: Partial<EnvDefaults>,
142): ResolvedAiConfig {
143 const e = { ...EnvDefaultsSchema.parse({ provider: "openai", chatModel: "gpt-4o", embedModel: "text-embedding-3-small", embedFallbackProvider: "openai" }), ...env };
144
145 const provider = org?.provider ?? workspace?.provider ?? e.provider;
146 const chatModel = org?.chatModel ?? workspace?.chatModel ?? e.chatModel;
147 const embedModel = org?.embedModel ?? workspace?.embedModel ?? e.embedModel;
148 const embedFallbackProvider = workspace?.embedFallbackProvider ?? e.embedFallbackProvider;
149
150 const budgets = { ...DEFAULT_BUDGETS, ...workspace?.budgets, ...org?.budgets };
151 const policies = { ...DEFAULT_POLICIES, ...workspace?.policies, ...org?.policies };
152
153 const endpoint =
154 workspace?.endpoint ?? (e.baseUrl ? { baseUrl: e.baseUrl } : undefined);
155
156 const secretsRef = workspace?.secretsRef ?? e.apiKeyRef ?? undefined;
157
158 const fallback = workspace?.fallback !== undefined ? workspace.fallback : null;
159
160 return {
161 enabled: org?.enabled ?? workspace?.enabled ?? true,
162 provider,
163 chatModel,
164 embedModel,
165 embedFallbackProvider,
166 ...(endpoint
167 ? { endpoint: endpoint.extraHeaders ? { baseUrl: endpoint.baseUrl, extraHeaders: endpoint.extraHeaders } : { baseUrl: endpoint.baseUrl } }
168 : {}),
169 ...(secretsRef ? { secretsRef } : {}),
170 fallback,
171 budgets,
172 policies,
173 allowOwnerOverride: org?.allowOwnerOverride ?? true,
174 };
175}
176
177
184export function assertKeyRef(ref: string): void {
185 if (/^(sk-|xai-|AIza|sk-ant-|sk_live_|sk_test_)/.test(ref)) {
186 throw new Error("apiKeyRef must be a name reference, never a literal key");
187 }
188 if (ref.length > 128) {
189 throw new Error("apiKeyRef too long");
190 }
191}
192
193
194export function resolveKeyRef(ref: string, env: Record<string, string | undefined>): string | undefined {
195 if (ref.startsWith("env:")) {
196 return env[ref.slice(4)];
197 }
198
199 return undefined;
200}
201