@odla-ai/cli 0.27.4 → 0.27.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,14 +7,14 @@
7
7
 
8
8
  Project-neutral provisioning CLI for odla apps. It creates and validates an
9
9
  `odla.config.mjs`, then uses that config to register an app, enable services,
10
- push odla-db schema/rules, configure per-app BYOK AI, configure Clerk auth, record
10
+ push odla-db schema/rules, configure hosted app AI (or explicit BYOK), configure Clerk auth, record
11
11
  deployment links, compose declared npm capability integrations, connect Google Calendar booking, provision o11y ingest credentials, and transfer local or
12
12
  deployed Worker secrets without printing them. It also gives platform admins a
13
13
  scoped System AI control surface and gives app owners a provider-key-free
14
14
  hosted security command.
15
15
 
16
16
  Provisioning does not know about any specific app. App identity, environments,
17
- services, schema, rules, integrations, auth, AI provider, and links all come
17
+ services, schema, rules, integrations, auth, AI mode/model, and links all come
18
18
  from config. Operator commands can also receive explicit platform/app/env
19
19
  context so remote agents are not forced to manufacture a project checkout.
20
20
 
@@ -485,8 +485,8 @@ shown-once credential.
485
485
  5. Collision-checks and merges declared integration schema/rule fragments,
486
486
  pushes the composed database contract, and creates integration seeds only
487
487
  when their natural-key rows are absent.
488
- 6. Configures platform AI and stores provider keys in the tenant vault when the
489
- configured key env var is set.
488
+ 6. Configures hosted app AI by default. An explicit BYOK config stores its
489
+ provider key in the tenant vault when the configured key env var is set.
490
490
  7. For calendar, reads owner-visible connection status and, when needed, asks
491
491
  the platform for a state-bound Google authorization URL and opens only the
492
492
  exact Google OAuth endpoint (or a same-platform interstitial). The human completes consent there;
@@ -730,7 +730,8 @@ change events with actor attribution; credential values never enter that trail.
730
730
  It never returns prompts, repository source, model output, reports, or provider
731
731
  credentials. Credential writes accept only
732
732
  `--from-env <NAME>` or `--stdin` and never place the value in argv, output, or
733
- the cache. System AI and each app's BYOK AI are separate vault/funding paths.
733
+ the cache. System AI and hosted app AI have separate routes and accounting;
734
+ explicit BYOK has its own app-tenant vault path.
734
735
  Discovery and validation provider/model settings are authoritative server
735
736
  policy: a security command has no field that can override either route. Admin
736
737
  bounds can only narrow the hard ceilings of 64 calls per role/run and, for each
@@ -829,10 +830,9 @@ export default {
829
830
  defaultRules: "deny",
830
831
  publicRead: [], // namespaces where a `view: "true"` rule is intentional
831
832
  },
832
- ai: {
833
- provider: process.env.ODLA_AI_PROVIDER ?? "anthropic",
834
- keyEnv: "ANTHROPIC_API_KEY",
835
- },
833
+ ai: { mode: "hosted" },
834
+ // BYOK compatibility instead:
835
+ // ai: { mode: "byok", provider: "anthropic", keyEnv: "ANTHROPIC_API_KEY" },
836
836
  calendar: {
837
837
  google: {
838
838
  availabilityCalendars: { dev: ["primary"] },
package/dist/bin.cjs CHANGED
@@ -1001,6 +1001,32 @@ var import_node_path4 = require("path");
1001
1001
  var import_node_url = require("url");
1002
1002
  var import_apps = require("@odla-ai/apps");
1003
1003
 
1004
+ // src/ai-config-validation.ts
1005
+ function validateAiConfig(cfg, path) {
1006
+ if (cfg.ai === void 0) return;
1007
+ if (!isRecord4(cfg.ai)) throw new Error(`${path}: ai must be an object`);
1008
+ assertOnly(cfg.ai, ["mode", "provider", "model", "keyEnv", "secretName"], `${path}: ai`);
1009
+ if (cfg.ai.mode !== void 0 && cfg.ai.mode !== "hosted" && cfg.ai.mode !== "byok") {
1010
+ throw new Error(`${path}: ai.mode must be hosted or byok`);
1011
+ }
1012
+ if (cfg.ai.mode === "byok" && !safeText(cfg.ai.provider, 100)) {
1013
+ throw new Error(`${path}: ai.provider is required when ai.mode is byok`);
1014
+ }
1015
+ if (cfg.ai.mode === "hosted" && (cfg.ai.provider || cfg.ai.keyEnv || cfg.ai.secretName)) {
1016
+ throw new Error(`${path}: hosted ai cannot configure a provider, keyEnv, or secretName`);
1017
+ }
1018
+ }
1019
+ function assertOnly(value2, allowed, label) {
1020
+ const extra = Object.keys(value2).find((key) => !allowed.includes(key));
1021
+ if (extra) throw new Error(`${label}.${extra} is not supported`);
1022
+ }
1023
+ function isRecord4(value2) {
1024
+ return value2 !== null && typeof value2 === "object" && !Array.isArray(value2);
1025
+ }
1026
+ function safeText(value2, max) {
1027
+ return typeof value2 === "string" && value2.trim().length > 0 && value2.length <= max && !/[\u0000-\u001f\u007f]/.test(value2);
1028
+ }
1029
+
1004
1030
  // src/integration-validation.ts
1005
1031
  function validateIntegrations(cfg, path, defaultServices) {
1006
1032
  if (cfg.integrations === void 0) return;
@@ -1008,16 +1034,16 @@ function validateIntegrations(cfg, path, defaultServices) {
1008
1034
  const ids = /* @__PURE__ */ new Set();
1009
1035
  for (const [index, integration] of cfg.integrations.entries()) {
1010
1036
  const at = `${path}: integrations[${index}]`;
1011
- if (!isRecord4(integration)) throw new Error(`${at} must be an object`);
1037
+ if (!isRecord5(integration)) throw new Error(`${at} must be an object`);
1012
1038
  if (!validId(integration.id)) throw new Error(`${at}.id must be lowercase letters, numbers, and hyphens`);
1013
1039
  if (ids.has(integration.id)) throw new Error(`${path}: duplicate integration id "${integration.id}"`);
1014
1040
  ids.add(integration.id);
1015
- if (!safeText(integration.title, 200)) throw new Error(`${at}.title is required`);
1016
- if (!safeText(integration.npm, 200)) throw new Error(`${at}.npm is required`);
1017
- if (integration.schema !== void 0 && (!isRecord4(integration.schema) || !isRecord4(integration.schema.entities))) {
1041
+ if (!safeText2(integration.title, 200)) throw new Error(`${at}.title is required`);
1042
+ if (!safeText2(integration.npm, 200)) throw new Error(`${at}.npm is required`);
1043
+ if (integration.schema !== void 0 && (!isRecord5(integration.schema) || !isRecord5(integration.schema.entities))) {
1018
1044
  throw new Error(`${at}.schema must contain an entities object`);
1019
1045
  }
1020
- if (integration.rules !== void 0 && !isRecord4(integration.rules)) throw new Error(`${at}.rules must be an object`);
1046
+ if (integration.rules !== void 0 && !isRecord5(integration.rules)) throw new Error(`${at}.rules must be an object`);
1021
1047
  validateSeeds(integration, at);
1022
1048
  validateProbes(integration, at);
1023
1049
  }
@@ -1031,13 +1057,13 @@ function validateSeeds(integration, at) {
1031
1057
  const ids = /* @__PURE__ */ new Set();
1032
1058
  for (const [index, seed] of integration.seeds.entries()) {
1033
1059
  const sat = `${at}.seeds[${index}]`;
1034
- if (!isRecord4(seed) || !safeText(seed.id, 200) || !safeText(seed.ns, 200)) throw new Error(`${sat} requires id and ns`);
1060
+ if (!isRecord5(seed) || !safeText2(seed.id, 200) || !safeText2(seed.ns, 200)) throw new Error(`${sat} requires id and ns`);
1035
1061
  if (ids.has(seed.id)) throw new Error(`${at} has duplicate seed id "${seed.id}"`);
1036
1062
  ids.add(seed.id);
1037
- if (!isRecord4(seed.key) || !safeText(seed.key.attr, 200) || !safeText(seed.key.value, 2048)) {
1063
+ if (!isRecord5(seed.key) || !safeText2(seed.key.attr, 200) || !safeText2(seed.key.value, 2048)) {
1038
1064
  throw new Error(`${sat}.key requires string attr and value`);
1039
1065
  }
1040
- if (!isRecord4(seed.attrs)) throw new Error(`${sat}.attrs must be an object`);
1066
+ if (!isRecord5(seed.attrs)) throw new Error(`${sat}.attrs must be an object`);
1041
1067
  if (Object.hasOwn(seed.attrs, seed.key.attr) && seed.attrs[seed.key.attr] !== seed.key.value) {
1042
1068
  throw new Error(`${sat}.attrs.${seed.key.attr} conflicts with its natural key`);
1043
1069
  }
@@ -1048,16 +1074,16 @@ function validateProbes(integration, at) {
1048
1074
  if (!Array.isArray(integration.probes)) throw new Error(`${at}.probes must be an array`);
1049
1075
  for (const [index, probe] of integration.probes.entries()) {
1050
1076
  const pat = `${at}.probes[${index}]`;
1051
- if (!isRecord4(probe) || !safeProbePath(probe.path)) throw new Error(`${pat}.path must be an absolute path without query or fragment`);
1077
+ if (!isRecord5(probe) || !safeProbePath(probe.path)) throw new Error(`${pat}.path must be an absolute path without query or fragment`);
1052
1078
  if (!Number.isInteger(probe.expectedStatus) || probe.expectedStatus < 100 || probe.expectedStatus > 599) {
1053
1079
  throw new Error(`${pat}.expectedStatus must be an HTTP status`);
1054
1080
  }
1055
1081
  }
1056
1082
  }
1057
- function isRecord4(value2) {
1083
+ function isRecord5(value2) {
1058
1084
  return value2 !== null && typeof value2 === "object" && !Array.isArray(value2);
1059
1085
  }
1060
- function safeText(value2, max) {
1086
+ function safeText2(value2, max) {
1061
1087
  return typeof value2 === "string" && value2.trim().length > 0 && value2.length <= max && !/[\u0000-\u001f\u007f]/.test(value2);
1062
1088
  }
1063
1089
  function safeProbePath(value2) {
@@ -1187,6 +1213,7 @@ function validateRawConfig(raw, path) {
1187
1213
  if (cfg.services !== void 0 && (!Array.isArray(cfg.services) || cfg.services.some((service) => typeof service !== "string" || !service.trim()))) {
1188
1214
  throw new Error(`${path}: services must be an array of non-empty names`);
1189
1215
  }
1216
+ validateAiConfig(cfg, path);
1190
1217
  validateIntegrations(cfg, path, DEFAULT_SERVICES);
1191
1218
  }
1192
1219
  function validateCalendarConfig(cfg, envs, services, path) {
@@ -1195,11 +1222,11 @@ function validateCalendarConfig(cfg, envs, services, path) {
1195
1222
  if (enabled) throw new Error(`${path}: calendar.google is required when services includes "calendar"`);
1196
1223
  return;
1197
1224
  }
1198
- if (!isRecord5(cfg.calendar)) throw new Error(`${path}: calendar must be an object`);
1199
- assertOnly(cfg.calendar, ["google"], `${path}: calendar`);
1200
- if (!isRecord5(cfg.calendar.google)) throw new Error(`${path}: calendar.google must be an object`);
1225
+ if (!isRecord6(cfg.calendar)) throw new Error(`${path}: calendar must be an object`);
1226
+ assertOnly2(cfg.calendar, ["google"], `${path}: calendar`);
1227
+ if (!isRecord6(cfg.calendar.google)) throw new Error(`${path}: calendar.google must be an object`);
1201
1228
  const google = cfg.calendar.google;
1202
- assertOnly(
1229
+ assertOnly2(
1203
1230
  google,
1204
1231
  ["availabilityCalendars", "calendars", "bookingCalendar", "bookingPageUrl"],
1205
1232
  `${path}: calendar.google`
@@ -1209,7 +1236,7 @@ function validateCalendarConfig(cfg, envs, services, path) {
1209
1236
  throw new Error(`${path}: calendar.google requires exactly one of availabilityCalendars or calendars (legacy)`);
1210
1237
  }
1211
1238
  const availability = google[availabilityKey];
1212
- if (!isRecord5(availability)) throw new Error(`${path}: calendar.google.${availabilityKey} must map env names to calendar ids`);
1239
+ if (!isRecord6(availability)) throw new Error(`${path}: calendar.google.${availabilityKey} must map env names to calendar ids`);
1213
1240
  const unknownEnv = Object.keys(availability).find((env) => !envs.includes(env));
1214
1241
  if (unknownEnv) throw new Error(`${path}: calendar.google.${availabilityKey}.${unknownEnv} is not in config envs`);
1215
1242
  for (const env of envs) {
@@ -1220,22 +1247,22 @@ function validateCalendarConfig(cfg, envs, services, path) {
1220
1247
  if (ids.length > 10) {
1221
1248
  throw new Error(`${path}: calendar.google.${availabilityKey}.${env} must contain at most 10 calendar ids`);
1222
1249
  }
1223
- if (ids.some((id) => !safeText2(id, 1024))) {
1250
+ if (ids.some((id) => !safeText3(id, 1024))) {
1224
1251
  throw new Error(`${path}: calendar.google.${availabilityKey}.${env} contains an invalid calendar id`);
1225
1252
  }
1226
1253
  }
1227
1254
  if (google.bookingCalendar !== void 0) {
1228
- if (!isRecord5(google.bookingCalendar)) throw new Error(`${path}: calendar.google.bookingCalendar must map env names to one calendar id`);
1255
+ if (!isRecord6(google.bookingCalendar)) throw new Error(`${path}: calendar.google.bookingCalendar must map env names to one calendar id`);
1229
1256
  const unknownBookingEnv = Object.keys(google.bookingCalendar).find((env) => !envs.includes(env));
1230
1257
  if (unknownBookingEnv) throw new Error(`${path}: calendar.google.bookingCalendar.${unknownBookingEnv} is not in config envs`);
1231
1258
  for (const [env, value2] of Object.entries(google.bookingCalendar)) {
1232
- if (!safeText2(value2, 1024)) {
1259
+ if (!safeText3(value2, 1024)) {
1233
1260
  throw new Error(`${path}: calendar.google.bookingCalendar.${env} must be a calendar id`);
1234
1261
  }
1235
1262
  }
1236
1263
  }
1237
1264
  if (google.bookingPageUrl !== void 0) {
1238
- if (!isRecord5(google.bookingPageUrl)) throw new Error(`${path}: calendar.google.bookingPageUrl must map env names to HTTPS URLs or null`);
1265
+ if (!isRecord6(google.bookingPageUrl)) throw new Error(`${path}: calendar.google.bookingPageUrl must map env names to HTTPS URLs or null`);
1239
1266
  const unknownBookingEnv = Object.keys(google.bookingPageUrl).find((env) => !envs.includes(env));
1240
1267
  if (unknownBookingEnv) throw new Error(`${path}: calendar.google.bookingPageUrl.${unknownBookingEnv} is not in config envs`);
1241
1268
  for (const [env, value2] of Object.entries(google.bookingPageUrl)) {
@@ -1258,14 +1285,14 @@ function validateServices(services, path) {
1258
1285
  }
1259
1286
  }
1260
1287
  }
1261
- function assertOnly(value2, allowed, label) {
1288
+ function assertOnly2(value2, allowed, label) {
1262
1289
  const extra = Object.keys(value2).find((key) => !allowed.includes(key));
1263
1290
  if (extra) throw new Error(`${label}.${extra} is not supported`);
1264
1291
  }
1265
- function isRecord5(value2) {
1292
+ function isRecord6(value2) {
1266
1293
  return value2 !== null && typeof value2 === "object" && !Array.isArray(value2);
1267
1294
  }
1268
- function safeText2(value2, max) {
1295
+ function safeText3(value2, max) {
1269
1296
  return typeof value2 === "string" && value2.trim().length > 0 && value2.length <= max && !/[\u0000-\u001f\u007f]/.test(value2);
1270
1297
  }
1271
1298
  function safeHttpsUrl(value2) {
@@ -3062,7 +3089,7 @@ async function assertTenantAdminAccess(doFetch, cfg, env, token) {
3062
3089
  `${env}: you are not an owner of "${cfg.app.id}" (tenant ${tenantId}) \u2014 nothing was minted or written; ask an existing owner to run "odla-ai app owners add <your-email>", then re-run provision`
3063
3090
  );
3064
3091
  }
3065
- throw new Error(`${env}: tenant access preflight (${tenantId}) failed: ${res.status} ${await safeText3(res)}`);
3092
+ throw new Error(`${env}: tenant access preflight (${tenantId}) failed: ${res.status} ${await safeText4(res)}`);
3066
3093
  }
3067
3094
  async function postJson(doFetch, url, bearer, body) {
3068
3095
  const res = await doFetch(url, {
@@ -3070,7 +3097,7 @@ async function postJson(doFetch, url, bearer, body) {
3070
3097
  headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
3071
3098
  body: JSON.stringify(body)
3072
3099
  });
3073
- if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await safeText3(res)}`);
3100
+ if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await safeText4(res)}`);
3074
3101
  }
3075
3102
  function normalizeClerkConfig(value2) {
3076
3103
  if (!value2) return null;
@@ -3083,7 +3110,7 @@ function normalizeClerkConfig(value2) {
3083
3110
  const publishableKey = envValue(cfg.publishableKey);
3084
3111
  return publishableKey ? { publishableKey, ...cfg.audience ? { audience: cfg.audience } : {}, ...cfg.mode ? { mode: cfg.mode } : {} } : null;
3085
3112
  }
3086
- async function safeText3(res) {
3113
+ async function safeText4(res) {
3087
3114
  try {
3088
3115
  return redactSecrets((await res.text()).slice(0, 500));
3089
3116
  } catch {
@@ -4065,18 +4092,18 @@ function assertSeedContracts(integrations, schema) {
4065
4092
  }
4066
4093
  }
4067
4094
  function isUniqueAttr(schema, ns, attr) {
4068
- if (!isRecord6(schema) || !isRecord6(schema.entities)) return false;
4095
+ if (!isRecord7(schema) || !isRecord7(schema.entities)) return false;
4069
4096
  const entity = schema.entities[ns];
4070
- if (!isRecord6(entity) || !isRecord6(entity.attrs)) return false;
4097
+ if (!isRecord7(entity) || !isRecord7(entity.attrs)) return false;
4071
4098
  const definition = entity.attrs[attr];
4072
- return isRecord6(definition) && definition.unique === true;
4099
+ return isRecord7(definition) && definition.unique === true;
4073
4100
  }
4074
4101
  function normalizeSchema(value2) {
4075
4102
  if (value2 === void 0 || value2 === null) return { entities: {}, links: {} };
4076
- if (!isRecord6(value2) || !isRecord6(value2.entities)) {
4103
+ if (!isRecord7(value2) || !isRecord7(value2.entities)) {
4077
4104
  throw new Error("db schema must be a serialized schema object with an entities map");
4078
4105
  }
4079
- if (value2.links !== void 0 && !isRecord6(value2.links)) throw new Error("db schema links must be an object");
4106
+ if (value2.links !== void 0 && !isRecord7(value2.links)) throw new Error("db schema links must be an object");
4080
4107
  return {
4081
4108
  entities: { ...value2.entities },
4082
4109
  links: { ...value2.links ?? {} }
@@ -4090,7 +4117,7 @@ function mergeMap(target, fragment, label) {
4090
4117
  target[name] = value2;
4091
4118
  }
4092
4119
  }
4093
- function isRecord6(value2) {
4120
+ function isRecord7(value2) {
4094
4121
  return value2 !== null && typeof value2 === "object" && !Array.isArray(value2);
4095
4122
  }
4096
4123
 
@@ -4109,7 +4136,7 @@ async function doctor(options) {
4109
4136
  out.log(`integrations: ${plan.integrations.length ? plan.integrations.join(", ") : "none"}`);
4110
4137
  out.log(`schema: ${schema ? `${entities.length} entities` : "none"}`);
4111
4138
  out.log(`rules: ${rules ? `${Object.keys(rules).length} namespaces` : "none"}`);
4112
- out.log(`ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
4139
+ out.log(`ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ? `byok/${cfg.ai.provider}` : "hosted" : "not enabled"}`);
4113
4140
  if (cfg.services.includes("calendar")) {
4114
4141
  const calendar = cfg.envs.map((env) => {
4115
4142
  const resolved = calendarServiceConfig(cfg, env);
@@ -4130,7 +4157,9 @@ async function doctor(options) {
4130
4157
  }
4131
4158
  }
4132
4159
  warnings.push(...integrationWarnings(database.integrations, schema, rules));
4133
- if (cfg.services.includes("ai") && !cfg.ai?.provider) warnings.push("ai service is enabled but ai.provider is not set");
4160
+ if (cfg.services.includes("ai") && cfg.ai?.mode === "byok" && !cfg.ai.provider) {
4161
+ warnings.push("ai.mode is byok but ai.provider is not set");
4162
+ }
4134
4163
  if (cfg.auth?.clerk) {
4135
4164
  for (const [env, value2] of Object.entries(cfg.auth.clerk)) {
4136
4165
  if (typeof value2 === "string" && value2.startsWith("$") && !process.env[value2.slice(1)]) {
@@ -4210,7 +4239,7 @@ function initProject(options) {
4210
4239
  if (!services.includes(dependency)) throw new Error(`--services ${service} requires ${dependency}`);
4211
4240
  }
4212
4241
  }
4213
- const aiProvider = options.aiProvider ?? "anthropic";
4242
+ const aiProvider = options.aiProvider;
4214
4243
  (0, import_node_fs13.mkdirSync)((0, import_node_path12.dirname)(configPath), { recursive: true });
4215
4244
  (0, import_node_fs13.mkdirSync)((0, import_node_path12.resolve)(rootDir, "src/odla"), { recursive: true });
4216
4245
  (0, import_node_fs13.mkdirSync)((0, import_node_path12.resolve)(rootDir, ".odla"), { recursive: true });
@@ -4237,6 +4266,15 @@ function configTemplate(input) {
4237
4266
  },
4238
4267
  },
4239
4268
  ` : "";
4269
+ const ai = input.aiProvider ? ` ai: {
4270
+ mode: "byok",
4271
+ provider: process.env.ODLA_AI_PROVIDER ?? "${input.aiProvider}",
4272
+ // Optional: set this env var while running provision to store the provider
4273
+ // key in the app vault for each tenant.
4274
+ keyEnv: "${defaultKeyEnv(input.aiProvider)}",
4275
+ },` : ` // Hosted AI uses admin-approved models and central cost tracking.
4276
+ // Pass --ai-provider during init only when this app must use BYOK.
4277
+ ai: { mode: "hosted" },`;
4240
4278
  return `export default {
4241
4279
  platformUrl: process.env.ODLA_PLATFORM_URL ?? "https://odla.ai",
4242
4280
  dbEndpoint: process.env.ODLA_ENDPOINT ?? process.env.ODLA_DB_ENDPOINT ?? "https://db.odla.ai",
@@ -4255,12 +4293,7 @@ function configTemplate(input) {
4255
4293
  // When rules is omitted, the CLI generates deny-all rules from schema.
4256
4294
  defaultRules: "deny",
4257
4295
  },
4258
- ai: {
4259
- provider: process.env.ODLA_AI_PROVIDER ?? "${input.aiProvider}",
4260
- // Optional: set this env var while running provision to store the provider
4261
- // key in the platform vault for each tenant.
4262
- keyEnv: "${defaultKeyEnv(input.aiProvider)}",
4263
- },
4296
+ ${ai}
4264
4297
  ${calendar}
4265
4298
  auth: {
4266
4299
  clerk: {
@@ -4756,12 +4789,16 @@ async function smoke(options) {
4756
4789
  out.log(` tenant: ${entry.tenantId}`);
4757
4790
  const publicConfig = await getJson(doFetch, publicConfigUrl(cfg.platformUrl, cfg.app.id, env), void 0);
4758
4791
  out.log(` public-config: ok`);
4759
- if (cfg.ai?.provider) {
4792
+ if (cfg.services.includes("ai") && cfg.ai?.provider) {
4760
4793
  const provider = publicConfig.ai?.provider ?? null;
4761
4794
  if (provider !== cfg.ai.provider) {
4762
4795
  throw new Error(`ai provider mismatch: expected "${cfg.ai.provider}", public-config has "${provider ?? "none"}"`);
4763
4796
  }
4764
- out.log(` ai: ${provider}`);
4797
+ out.log(` ai: byok/${provider}`);
4798
+ } else if (cfg.services.includes("ai")) {
4799
+ const mode = publicConfig.ai?.mode;
4800
+ if (mode !== "hosted") throw new Error(`ai mode mismatch: expected "hosted", public-config has "${String(mode ?? "none")}"`);
4801
+ out.log(" ai: hosted");
4765
4802
  }
4766
4803
  if (hasO11y) out.log(` o11y: credentials present`);
4767
4804
  if (cfg.services.includes("calendar")) {
@@ -4839,7 +4876,7 @@ async function getJson(doFetch, url, bearer) {
4839
4876
  const res = await doFetch(url, {
4840
4877
  headers: bearer ? { authorization: `Bearer ${bearer}` } : void 0
4841
4878
  });
4842
- if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText4(res)}`);
4879
+ if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText5(res)}`);
4843
4880
  return res.json();
4844
4881
  }
4845
4882
  async function postJson2(doFetch, url, bearer, body) {
@@ -4848,7 +4885,7 @@ async function postJson2(doFetch, url, bearer, body) {
4848
4885
  headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
4849
4886
  body: JSON.stringify(body)
4850
4887
  });
4851
- if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText4(res)}`);
4888
+ if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText5(res)}`);
4852
4889
  return res.json();
4853
4890
  }
4854
4891
  function publicConfigUrl(platformUrl, appId, env) {
@@ -4856,7 +4893,7 @@ function publicConfigUrl(platformUrl, appId, env) {
4856
4893
  url.searchParams.set("env", env);
4857
4894
  return url.toString();
4858
4895
  }
4859
- async function safeText4(res) {
4896
+ async function safeText5(res) {
4860
4897
  try {
4861
4898
  return redactSecrets((await res.text()).slice(0, 500));
4862
4899
  } catch {
@@ -8686,7 +8723,7 @@ Start here:
8686
8723
 
8687
8724
  Usage:
8688
8725
  odla-ai setup [--dir <project>] [--agent <name>] [--global] [--force]
8689
- odla-ai init --app-id <id> --name <name> [--services db,ai,o11y,calendar] [--env dev --env prod]
8726
+ odla-ai init --app-id <id> --name <name> [--services db,ai,o11y,calendar] [--env dev --env prod] [--ai-provider <byok-provider>]
8690
8727
  odla-ai doctor [--config odla.config.mjs]
8691
8728
  odla-ai config <diff|plan> [--config odla.config.mjs] [--email <odla-account>] [--json]
8692
8729
  odla-ai config apply --plan <plan.json> [--idempotency-key <key>] [--email <odla-account>] [--json]
@@ -10679,7 +10716,7 @@ async function provisionIntegrationSeeds(doFetch, endpoint, tenantId, dbKey, int
10679
10716
  const payload = await postJson3(doFetch, `${base}/query`, dbKey, {
10680
10717
  query: { [seed.ns]: { $: { where: { [seed.key.attr]: seed.key.value }, limit: 1 } } }
10681
10718
  });
10682
- const rows = isRecord7(payload) && isRecord7(payload.result) ? payload.result[seed.ns] : void 0;
10719
+ const rows = isRecord8(payload) && isRecord8(payload.result) ? payload.result[seed.ns] : void 0;
10683
10720
  if (!Array.isArray(rows)) {
10684
10721
  throw new Error(`${env}: integration ${integration.id} seed ${seed.id} query returned an invalid response`);
10685
10722
  }
@@ -10711,7 +10748,7 @@ async function postJson3(doFetch, url, bearer, body) {
10711
10748
  if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await responseText(res)}`);
10712
10749
  return res.json().catch(() => ({}));
10713
10750
  }
10714
- function isRecord7(value2) {
10751
+ function isRecord8(value2) {
10715
10752
  return value2 !== null && typeof value2 === "object" && !Array.isArray(value2);
10716
10753
  }
10717
10754
  async function responseText(res) {
@@ -10781,14 +10818,14 @@ async function mintDbKey(opts, tenantId) {
10781
10818
  appId: tenantId
10782
10819
  })
10783
10820
  });
10784
- if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await safeText5(created)}`);
10821
+ if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await safeText6(created)}`);
10785
10822
  res = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenantId)}/keys`, {
10786
10823
  method: "POST",
10787
10824
  headers,
10788
10825
  body: "{}"
10789
10826
  });
10790
10827
  }
10791
- if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await safeText5(res)}`);
10828
+ if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await safeText6(res)}`);
10792
10829
  const body = await res.json();
10793
10830
  if (!body.key) throw new Error(`db key mint (${tenantId}) returned no key`);
10794
10831
  return body.key;
@@ -10804,12 +10841,12 @@ async function issueO11yToken(opts) {
10804
10841
  `o11y token already exists for env "${opts.env}", but its shown-once value is not in the local credentials file; run "odla-ai provision --rotate-o11y-token --push-secrets" to replace it explicitly`
10805
10842
  );
10806
10843
  }
10807
- if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await safeText5(res)}`);
10844
+ if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await safeText6(res)}`);
10808
10845
  const body = await res.json();
10809
10846
  if (!body.token) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) returned no token`);
10810
10847
  return body.token;
10811
10848
  }
10812
- async function safeText5(res) {
10849
+ async function safeText6(res) {
10813
10850
  try {
10814
10851
  return redactSecrets((await res.text()).slice(0, 500));
10815
10852
  } catch {
@@ -10852,7 +10889,7 @@ async function provision(options) {
10852
10889
  const namespaces = Object.keys(integration.schema?.entities ?? {}).length;
10853
10890
  out.log(` integration.${integration.id}: ${namespaces} namespaces, ${integration.seeds?.length ?? 0} seeds, ${integration.probes?.length ?? 0} smoke probes`);
10854
10891
  }
10855
- out.log(` ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
10892
+ out.log(` ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "hosted" : "not enabled"}`);
10856
10893
  if (cfg.services.includes("calendar")) {
10857
10894
  for (const env of cfg.envs) {
10858
10895
  const calendar = calendarServiceConfig(cfg, env);
@@ -10903,11 +10940,11 @@ async function provision(options) {
10903
10940
  for (const service of serviceOrder) {
10904
10941
  if (service === "ai") {
10905
10942
  if (cfg.ai?.provider) {
10906
- await apps.setAi(cfg.app.id, env, { provider: cfg.ai.provider, ...cfg.ai.model ? { model: cfg.ai.model } : {} });
10907
- out.log(`${env}: ai configured (${cfg.ai.provider}${cfg.ai.model ? `/${cfg.ai.model}` : ""})`);
10943
+ await apps.setAi(cfg.app.id, env, { mode: "byok", provider: cfg.ai.provider, ...cfg.ai.model ? { model: cfg.ai.model } : {} });
10944
+ out.log(`${env}: ai configured (byok ${cfg.ai.provider}${cfg.ai.model ? `/${cfg.ai.model}` : ""})`);
10908
10945
  } else {
10909
- await apps.setService(cfg.app.id, "ai", true, { env });
10910
- out.log(`${env}: ai enabled`);
10946
+ await apps.setAi(cfg.app.id, env, { mode: "hosted", ...cfg.ai?.model ? { model: cfg.ai.model } : {} });
10947
+ out.log(`${env}: ai configured (hosted${cfg.ai?.model ? `/${cfg.ai.model}` : ""})`);
10911
10948
  }
10912
10949
  } else {
10913
10950
  const config = service === "calendar" ? calendarServiceConfig(cfg, env) : void 0;