@xuda.io/ai_module 1.1.5651 → 1.1.5652

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.
Files changed (2) hide show
  1. package/index.mjs +107 -4
  2. package/package.json +1 -1
package/index.mjs CHANGED
@@ -231,6 +231,62 @@ const resolve_ai_model = function (m) {
231
231
  return _conf?.ai_models?.[code]?.model || _conf?.ai_models?.[m]?.model || m;
232
232
  };
233
233
 
234
+ // Every text model in the catalog is a gpt-5-family REASONING model, and the API
235
+ // default effort is 'medium'. Measured on dev 2026-08-11: gpt-5-nano asked to write
236
+ // a three line email burned 960-1600 hidden reasoning tokens and took 10-16s, while
237
+ // the same call at effort 'minimal' took 1.4-2.7s with 0 reasoning tokens and the
238
+ // same answer. The cheapest model was the SLOWEST thing we ran, purely because
239
+ // nobody set an effort. So every internal call now names one.
240
+ //
241
+ // 'minimal' is the right default for what submit_chat_gpt_prompt is actually used
242
+ // for (titles, categories, field assist, short classifications). A caller that
243
+ // genuinely needs the model to think passes effort: 'low' | 'medium' | 'high'.
244
+ //
245
+ // Non-reasoning ids (gpt-4o-mini and friends, still named by a few modules) reject
246
+ // the parameter outright, so only send it to a model that understands it.
247
+ const INTERNAL_AI_EFFORT = () => _conf?.internal_ai_effort || 'minimal';
248
+
249
+ const _is_reasoning_model = function (real_model) {
250
+ return /^(gpt-5|o[1-9])/.test(String(real_model || ''));
251
+ };
252
+
253
+ // Responses-API shape: { reasoning: { effort } }. Returns {} when the model or the
254
+ // caller opted out, so it can always be spread into the request.
255
+ const reasoning_opt = function (real_model, effort) {
256
+ const e = effort === undefined ? INTERNAL_AI_EFFORT() : effort;
257
+ if (!e || e === 'default' || !_is_reasoning_model(real_model)) return {};
258
+ return { reasoning: { effort: e } };
259
+ };
260
+
261
+ // Request timeout, sized off the effort tier.
262
+ //
263
+ // Measured on dev 2026-08-11: roughly 13% of calls to api.openai.com go silent for
264
+ // 19.5-20s AFTER the edge has ACKed our request, on a 401 to /v1/models as readily
265
+ // as on a real completion, and from the laptop as readily as from dev. tcpdump shows
266
+ // no retransmission in either direction, so it is upstream at OpenAI and nothing here
267
+ // fixes it. See the cf-ray IDs in the change log.
268
+ //
269
+ // What we CAN stop doing is waiting it out. The SDK had no timeout set, so its 10
270
+ // minute default applied and every stall cost the full 20s. A stalled request never
271
+ // recovers early and a fresh one answers in ~200ms, so the cure is to give up and
272
+ // re-ask. The stall lands before generation starts, so the abandoned attempt bills
273
+ // nothing and there is no reason to hedge (leave the first running) instead.
274
+ //
275
+ // Timeouts are ~6x the observed normal for the tier, so only a genuinely stuck call
276
+ // trips them. The SDK's own maxRetries (2) does the re-asking and treats a timeout as
277
+ // retryable, which puts 'minimal' at ~9s instead of ~21s for a stall.
278
+ const AI_EFFORT_TIMEOUT_MS = { minimal: 8000, low: 15000, medium: 60000, high: 120000 };
279
+ const AI_TIMEOUT_DEFAULT_MS = 60000;
280
+
281
+ const ai_timeout_ms = function (effort, real_model, tools) {
282
+ // A hosted tool (web_search and friends) runs its own loop inside the one request
283
+ // and is legitimately slow, so those never get the short tier.
284
+ if (Array.isArray(tools) && tools.length) return _conf.ai_tools_timeout_ms || 180000;
285
+ if (!_is_reasoning_model(real_model)) return _conf.ai_timeout_ms?.default || AI_TIMEOUT_DEFAULT_MS;
286
+ const e = effort === undefined ? INTERNAL_AI_EFFORT() : effort;
287
+ return _conf.ai_timeout_ms?.[e] || AI_EFFORT_TIMEOUT_MS[e] || _conf.ai_timeout_ms?.default || AI_TIMEOUT_DEFAULT_MS;
288
+ };
289
+
234
290
  // A catalog code a user may pick for Codex generation: an OpenAI text model
235
291
  // flagged codex:true in ai_models. Guards generate_site_draft's model param so a
236
292
  // non-codex pick (image/voice/unknown) falls back to the default rather than
@@ -4763,7 +4819,7 @@ export const delete_prompt_attachment = async function (req, job_id, headers, fi
4763
4819
  };
4764
4820
 
4765
4821
  export const submit_chat_gpt_prompt = async function (req) {
4766
- const { model = _conf.default_ai_model, prompt = '', response_format, uid, metadata = {}, account_profile_info, tools = [], conversation_id, response_id } = req; //'gpt-5-mini
4822
+ const { model = _conf.default_ai_model, prompt = '', response_format, uid, metadata = {}, account_profile_info, tools = [], conversation_id, response_id, effort } = req; //'gpt-5-mini
4767
4823
 
4768
4824
  let formattedParams;
4769
4825
  if (response_format) {
@@ -4772,14 +4828,19 @@ export const submit_chat_gpt_prompt = async function (req) {
4772
4828
 
4773
4829
  try {
4774
4830
  let response;
4831
+ const real_model = resolve_ai_model(model);
4832
+ const eff = effort === undefined ? INTERNAL_AI_EFFORT() : effort;
4833
+ const timeout = ai_timeout_ms(effort, real_model, tools);
4834
+ const started = Date.now();
4775
4835
  try {
4776
4836
  let opt = {
4777
- model: resolve_ai_model(model),
4837
+ model: real_model,
4778
4838
  input: prompt,
4779
4839
  tools,
4780
4840
  text: {
4781
4841
  format: formattedParams,
4782
4842
  },
4843
+ ...reasoning_opt(real_model, effort),
4783
4844
  };
4784
4845
  if (conversation_id) {
4785
4846
  opt.conversationId = conversation_id;
@@ -4787,10 +4848,25 @@ export const submit_chat_gpt_prompt = async function (req) {
4787
4848
  if (response_id) {
4788
4849
  opt.previous_response_id = response_id;
4789
4850
  }
4790
- response = await client.responses.create(opt);
4851
+ // Second argument is per-request options, not part of the body. The SDK's own
4852
+ // maxRetries (2) treats a timeout as retryable, which is the whole point: a
4853
+ // request the OpenAI edge has gone silent on is abandoned and re-asked instead
4854
+ // of waited out.
4855
+ response = await client.responses.create(opt, { timeout });
4791
4856
  report_ai_status(model);
4857
+ // No timing existed on this path, so "the AI is slow" was never anything we
4858
+ // could point at a number. One line per call, only when it actually dragged.
4859
+ const ms = Date.now() - started;
4860
+ if (ms > (_conf.ai_slow_log_ms || 5000)) {
4861
+ console.warn(`[ai] slow prompt ${ms}ms model=${real_model} effort=${eff} timeout=${timeout}ms reasoning_tokens=${response?.usage?.output_tokens_details?.reasoning_tokens ?? '?'} func=${metadata?.func || '-'}`);
4862
+ }
4792
4863
  } catch (err) {
4793
4864
  report_ai_status(model, err);
4865
+ // A timeout here means every attempt was abandoned, so say so plainly rather
4866
+ // than handing the caller the SDK's generic connection-error wording.
4867
+ if (/timed? ?out/i.test(err?.message || '') || err?.name === 'APIConnectionTimeoutError') {
4868
+ console.warn(`[ai] prompt timed out after ${Date.now() - started}ms model=${real_model} effort=${eff} timeout=${timeout}ms func=${metadata?.func || '-'}`);
4869
+ }
4794
4870
  throw err;
4795
4871
  }
4796
4872
  account_msa.record_ai_usage(uid, response.usage.input_tokens, response.usage.output_tokens, 'submit chat', prompt, model, metadata, account_profile_info, tools);
@@ -5017,6 +5093,9 @@ export const ai_field_assist = async function (req) {
5017
5093
  uid,
5018
5094
  prompt: parts.join('\n'),
5019
5095
  model,
5096
+ // Sparkle-icon writing: the user is watching, and the per-field rules above are
5097
+ // explicit enough that hidden reasoning adds latency, not quality.
5098
+ effort: _conf.field_assist?.effort || 'low',
5020
5099
  metadata: { func: 'ai_field_assist', field, mode },
5021
5100
  account_profile_info,
5022
5101
  });
@@ -5169,6 +5248,10 @@ export const triage_error_incident = async function (req) {
5169
5248
  prompt,
5170
5249
  response_format: verdict_schema,
5171
5250
  uid: _conf.superuser_account_ids?.[0],
5251
+ // Root-causing an incident is the one thing here worth thinking about, and it
5252
+ // runs on a cron where nobody is watching a spinner, so it opts out of the
5253
+ // 'minimal' default.
5254
+ effort: _conf.error_resolver?.effort || 'medium',
5172
5255
  metadata: { func: 'triage_error_incident', signature: incident?.signature, code: incident?.code },
5173
5256
  });
5174
5257
 
@@ -5225,6 +5308,8 @@ export const diagnose_vps_snapshot = async function (req) {
5225
5308
  prompt: parts.join('\n'),
5226
5309
  response_format: verdict_schema,
5227
5310
  uid,
5311
+ // Reading a server snapshot is analysis, not a one-liner, and it runs unattended.
5312
+ effort: _conf.auto_diagnose?.effort || 'medium',
5228
5313
  metadata: { func: 'diagnose_vps_snapshot', app_name },
5229
5314
  });
5230
5315
 
@@ -5374,6 +5459,9 @@ export const generate_release_notes = async function (req) {
5374
5459
  uid,
5375
5460
  prompt: parts.join('\n'),
5376
5461
  model: _conf.release_notes?.model || _conf.default_ai_model,
5462
+ // Turning a changelog into readable notes is a writing job, so give it a little
5463
+ // more than 'minimal' without paying for full reasoning.
5464
+ effort: _conf.release_notes?.effort || 'low',
5377
5465
  metadata: { func: 'generate_release_notes', app_id: app_ref, version },
5378
5466
  account_profile_info,
5379
5467
  });
@@ -6433,6 +6521,10 @@ Rules:
6433
6521
  prompt,
6434
6522
  model: ai_model || _conf.default_ai_model,
6435
6523
  response_format: ComposedEmailSchema,
6524
+ // The "Writing your email..." spinner. It carries a long rule list, so it gets
6525
+ // 'low' rather than 'minimal' to keep instruction-following tight; that is still
6526
+ // a few seconds instead of the 10-16s the unset default was costing.
6527
+ effort: _conf.compose_email_effort || 'low',
6436
6528
  metadata: { contact_id, func: 'compose_contact_email' },
6437
6529
  account_profile_info,
6438
6530
  });
@@ -6474,6 +6566,9 @@ const chat_email = async function (req, job_id, headers) {
6474
6566
  // path, and every inbound/auto reply) leaves both empty and behaves exactly as before.
6475
6567
  const composed_subject = String(req.subject || '').trim();
6476
6568
  const composed_html = _sanitize_email_html(req.body_html);
6569
+ // UI-155: the template the user picked in the composer, for THIS message only. Empty on
6570
+ // every other path, which leaves the address's saved default in charge.
6571
+ const composed_style = String(req.template_style || '').trim();
6477
6572
  try {
6478
6573
  if (!account_profile_info.account_profile_obj?.email_account_id) {
6479
6574
  throw await email_binding_error(account_profile_info);
@@ -6566,6 +6661,9 @@ const chat_email = async function (req, job_id, headers) {
6566
6661
  uid,
6567
6662
  email_account_id: profile_doc.email_account_id,
6568
6663
  style: profile_doc.email_template?.style,
6664
+ // UI-155: a per-message choice made in the composer, which outranks the address's
6665
+ // saved default inside the renderer.
6666
+ ...(composed_style ? { style_override: composed_style } : {}),
6569
6667
  body_text: body,
6570
6668
  // A composed body is already HTML the user laid out (bold, lists, links), so the
6571
6669
  // template has to drop it in as-is. Escaping it into paragraphs the way a plain
@@ -6582,7 +6680,12 @@ const chat_email = async function (req, job_id, headers) {
6582
6680
  // With no template style selected there is nothing wrapping the body, so the composed
6583
6681
  // HTML is the whole message. Without this it would fall through to sendEmailFromAccount's
6584
6682
  // "wrap the plain text in one <p>" default and the formatting would be lost.
6585
- sent_email_result = await email_ms.sendEmailFromAccount(email_account_doc, contact_info.email, subject, body, template_html || composed_html || null, email_attachments);
6683
+ // UI-157: cc / bcc come from the composer and are normalized inside sendEmailFromAccount,
6684
+ // so anything that is not an address is dropped rather than reaching the SMTP server.
6685
+ sent_email_result = await email_ms.sendEmailFromAccount(email_account_doc, contact_info.email, subject, body, template_html || composed_html || null, email_attachments, {
6686
+ cc: req.cc,
6687
+ bcc: req.bcc,
6688
+ });
6586
6689
  if (!sent_email_result.success) {
6587
6690
  throw new Error('error sending email');
6588
6691
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xuda.io/ai_module",
3
- "version": "1.1.5651",
3
+ "version": "1.1.5652",
4
4
  "description": "Xuda AI Module",
5
5
  "main": "index.mjs",
6
6
  "type": "module",