fhirsmith 0.16.1 → 0.18.0

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 (142) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +105 -30
  3. package/config-template.json +130 -4
  4. package/extension-tracker/extension-tracker.js +111 -3
  5. package/extension-tracker/readme.md +3 -0
  6. package/ips/ai-client.js +263 -0
  7. package/ips/composer.js +805 -0
  8. package/ips/editor-schema.js +289 -0
  9. package/ips/editor.js +290 -0
  10. package/ips/fhirpath-ips.js +397 -0
  11. package/ips/ips-codeable-concepts.md +1742 -0
  12. package/ips/ips-template.html +125 -0
  13. package/ips/ips-valuesets.js +159 -0
  14. package/ips/ips.js +1736 -0
  15. package/ips/membership.js +179 -0
  16. package/ips/pipeline.js +1012 -0
  17. package/ips/presenter.js +974 -0
  18. package/ips/profile-editor.js +578 -0
  19. package/ips/profiles.js +506 -0
  20. package/ips/readme.md +460 -0
  21. package/ips/render.js +1482 -0
  22. package/ips/search-params.js +83 -0
  23. package/ips/search.js +667 -0
  24. package/ips/share.js +178 -0
  25. package/ips/store.js +650 -0
  26. package/ips/terminology.js +395 -0
  27. package/ips/tx-client.js +202 -0
  28. package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
  29. package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
  30. package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
  31. package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
  32. package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
  33. package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
  34. package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
  35. package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
  36. package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
  37. package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
  38. package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
  39. package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
  40. package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
  41. package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
  42. package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
  43. package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
  44. package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
  45. package/ips/valuesets/ValueSet-ips-medication.json +50 -0
  46. package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
  47. package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
  48. package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
  49. package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
  50. package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
  51. package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
  52. package/ips/valuesets/ValueSet-ips-route.json +46 -0
  53. package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
  54. package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
  55. package/ips/valuesets/elements.json +44 -0
  56. package/library/daily-report.js +422 -0
  57. package/library/fhir-search-values.js +138 -0
  58. package/library/html-server.js +32 -23
  59. package/library/html.js +9 -0
  60. package/library/mailer.js +77 -0
  61. package/library/narrative-check.js +222 -0
  62. package/library/npm-audit.js +25 -0
  63. package/library/package-manager.js +4 -2
  64. package/library/sqlite-session-store.js +29 -1
  65. package/package.json +8 -2
  66. package/packages/package-crawler.js +189 -82
  67. package/packages/packages.js +36 -63
  68. package/packages/utilities/dedupe-versions.js +22 -8
  69. package/publisher/publisher.js +29 -0
  70. package/registry/auth-store.js +366 -0
  71. package/registry/auth.js +872 -0
  72. package/registry/crawler.js +48 -1
  73. package/registry/documentation/api.md +162 -0
  74. package/registry/documentation/configuration.md +93 -0
  75. package/registry/documentation/documents.md +74 -0
  76. package/registry/documentation/internals.md +151 -0
  77. package/registry/documentation/readme.md +100 -0
  78. package/registry/documentation/security.md +199 -0
  79. package/registry/documents.js +309 -0
  80. package/registry/openapi.yaml +30 -0
  81. package/registry/registry-template.html +1 -1
  82. package/registry/registry.js +247 -54
  83. package/registry/uptime.js +154 -0
  84. package/release.sh +80 -0
  85. package/server.js +105 -44
  86. package/shl/shl.js +64 -9
  87. package/stats-db.js +93 -0
  88. package/stats.js +158 -1
  89. package/testing/readme.md +1 -1
  90. package/testing/search.js +2 -108
  91. package/testing/store.js +5 -0
  92. package/testing/testing.js +20 -0
  93. package/token/token-template.html +5 -4
  94. package/token/token.js +17 -10
  95. package/translations/Messages.properties +9 -0
  96. package/tx/README.md +52 -0
  97. package/tx/cm/cm-database.js +62 -0
  98. package/tx/cm/cm-package.js +20 -3
  99. package/tx/cs/cs-api.js +90 -14
  100. package/tx/cs/cs-cs.js +142 -19
  101. package/tx/cs/cs-icd11.js +366 -112
  102. package/tx/cs/cs-icf.js +418 -0
  103. package/tx/cs/cs-loinc.js +79 -39
  104. package/tx/cs/cs-omop.js +10 -3
  105. package/tx/cs/cs-snomed.js +918 -55
  106. package/tx/cs/cs-ucum.js +6 -0
  107. package/tx/cs/icd11-expressions.js +191 -78
  108. package/tx/importers/export-icd11-codesystem.js +58 -12
  109. package/tx/importers/icd11-schema.md +9 -5
  110. package/tx/library.js +107 -6
  111. package/tx/ocl/README.md +62 -6
  112. package/tx/ocl/cm-ocl.cjs +166 -44
  113. package/tx/ocl/cs-ocl.cjs +163 -13
  114. package/tx/ocl/resolve/public-access.js +143 -0
  115. package/tx/ocl/resolve/reference-resolver.js +458 -0
  116. package/tx/ocl/vs-ocl.cjs +167 -4
  117. package/tx/openapi-operations.js +4 -0
  118. package/tx/params.js +8 -1
  119. package/tx/problems.js +7 -5
  120. package/tx/provider.js +21 -5
  121. package/tx/sct/documentation.md +251 -0
  122. package/tx/sct/ecl.js +758 -46
  123. package/tx/sct/expressions.js +113 -52
  124. package/tx/syndication/syndication-feed.js +218 -0
  125. package/tx/syndication/syndication-store.js +415 -0
  126. package/tx/tx-html.js +37 -8
  127. package/tx/tx.fhir.org.yml +2 -3
  128. package/tx/tx.js +148 -5
  129. package/tx/vs/vs-database.js +26 -0
  130. package/tx/vs/vs-package.js +42 -10
  131. package/tx/vs/vs-vsac.js +25 -6
  132. package/tx/workers/expand.js +34 -7
  133. package/tx/workers/lookup.js +61 -6
  134. package/tx/workers/metadata.js +6 -1
  135. package/tx/workers/subsumes.js +1 -1
  136. package/tx/workers/translate.js +166 -15
  137. package/tx/workers/validate.js +128 -6
  138. package/utilities/make-sct-test-extension.js +264 -0
  139. package/utilities/submit-test-report.js +1 -1
  140. package/xig/xig.js +120 -12
  141. package/gr.sh +0 -3
  142. package/registry/readme.md +0 -124
@@ -4,9 +4,100 @@ const Database = require('better-sqlite3');
4
4
  const htmlServer = require('../library/html-server');
5
5
  const escape = require('escape-html');
6
6
  const packageJson = require('../package.json');
7
+ const { tokenMatches, tokenConfigured } = require('../library/request-token');
7
8
 
8
9
  const TEMPLATE_NAME = 'ext-tracker';
9
10
 
11
+ // What the IG Publisher submits (ExtensionTracker.generate() in the publisher): anything
12
+ // that doesn't look like that is refused, which keeps what anyone can put in the database
13
+ // (and so in these pages) to the shapes the pages expect
14
+ const CODE = /^[A-Za-z0-9._-]{1,100}$/;
15
+ const VERSION = /^[A-Za-z0-9._+-]{1,50}$/;
16
+ const FHIR_VERSION = /^\d+\.\d+\.\d+(-[A-Za-z0-9.-]+)?$/;
17
+ const URL_LIKE = /^[A-Za-z][A-Za-z0-9+.-]*:[^\s"'<>\\]{1,1000}$/;
18
+ const RESOURCE_TYPE = /^[A-Za-z][A-Za-z0-9_]{0,99}$/;
19
+ // an element path, e.g. Patient.name or ImplementationGuide.definition.page (a logical
20
+ // model's type can be a URL)
21
+ const PATH = /^[A-Za-z][\w.:/#%[\]-]{0,511}$/;
22
+ const TYPE_CODE = /^[^\s"'<>\\]{1,256}$/;
23
+ const MAX_TITLE = 1024;
24
+ const MAX_ITEMS = 20000;
25
+
26
+ /**
27
+ * Checks that a submission looks like what the IG Publisher sends.
28
+ *
29
+ * @returns {string[]} what is wrong (empty if nothing)
30
+ */
31
+ function validateSubmission(data) {
32
+ const errors = [];
33
+ const check = (ok, message) => {
34
+ if (!ok && errors.length < 10) {
35
+ errors.push(message);
36
+ }
37
+ };
38
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
39
+ const str = (v, re) => typeof v === 'string' && re.test(v);
40
+ const optionalTitle = (v) => v === undefined || v === null || (typeof v === 'string' && v.length <= MAX_TITLE);
41
+ const onlyKeys = (obj, keys, where) => {
42
+ for (const k of Object.keys(obj)) {
43
+ check(keys.includes(k), `${where}: unexpected property ${JSON.stringify(k.substring(0, 50))}`);
44
+ }
45
+ };
46
+
47
+ if (!isObject(data)) {
48
+ return ['the submission must be a JSON object'];
49
+ }
50
+ onlyKeys(data, ['package', 'version', 'fhirVersion', 'jurisdiction', 'extensions', 'profiles', 'usage'], 'submission');
51
+ check(str(data.package, CODE), 'package must be a package id');
52
+ check(str(data.version, VERSION), 'version must be a version');
53
+ check(str(data.fhirVersion, FHIR_VERSION), 'fhirVersion must be a FHIR version (e.g. 4.0.1)');
54
+ check(data.jurisdiction === undefined || data.jurisdiction === null || str(data.jurisdiction, CODE), 'jurisdiction must be a code');
55
+
56
+ let items = 0;
57
+ if (data.extensions !== undefined) {
58
+ check(Array.isArray(data.extensions), 'extensions must be an array');
59
+ for (const [i, ext] of (Array.isArray(data.extensions) ? data.extensions : []).entries()) {
60
+ items++;
61
+ if (!isObject(ext)) {
62
+ check(false, `extensions[${i}] must be an object`);
63
+ continue;
64
+ }
65
+ onlyKeys(ext, ['url', 'title', 'types'], `extensions[${i}]`);
66
+ check(str(ext.url, URL_LIKE), `extensions[${i}].url must be a URL`);
67
+ check(optionalTitle(ext.title), `extensions[${i}].title must be text`);
68
+ check(ext.types === undefined || (Array.isArray(ext.types) && ext.types.every(t => str(t, TYPE_CODE))), `extensions[${i}].types must be an array of type codes`);
69
+ items += Array.isArray(ext.types) ? ext.types.length : 0;
70
+ }
71
+ }
72
+ if (data.profiles !== undefined) {
73
+ check(isObject(data.profiles), 'profiles must be an object');
74
+ for (const [type, list] of Object.entries(isObject(data.profiles) ? data.profiles : {})) {
75
+ check(RESOURCE_TYPE.test(type), `profiles: ${JSON.stringify(type.substring(0, 50))} is not a resource type`);
76
+ check(Array.isArray(list), `profiles.${type} must be an array`);
77
+ for (const [i, prof] of (Array.isArray(list) ? list : []).entries()) {
78
+ items++;
79
+ if (!isObject(prof)) {
80
+ check(false, `profiles.${type}[${i}] must be an object`);
81
+ continue;
82
+ }
83
+ onlyKeys(prof, ['url', 'title'], `profiles.${type}[${i}]`);
84
+ check(str(prof.url, URL_LIKE), `profiles.${type}[${i}].url must be a URL`);
85
+ check(optionalTitle(prof.title), `profiles.${type}[${i}].title must be text`);
86
+ }
87
+ }
88
+ }
89
+ if (data.usage !== undefined) {
90
+ check(isObject(data.usage), 'usage must be an object');
91
+ for (const [url, locations] of Object.entries(isObject(data.usage) ? data.usage : {})) {
92
+ check(URL_LIKE.test(url), `usage: ${JSON.stringify(url.substring(0, 50))} is not a URL`);
93
+ check(Array.isArray(locations) && locations.every(l => str(l, PATH)), `usage[${JSON.stringify(url.substring(0, 50))}] must be an array of element paths`);
94
+ items += Array.isArray(locations) ? locations.length : 0;
95
+ }
96
+ }
97
+ check(items <= MAX_ITEMS, `the submission has more than ${MAX_ITEMS} items`);
98
+ return errors;
99
+ }
100
+
10
101
  class ExtensionTrackerModule {
11
102
  constructor(stats) {
12
103
  this.stats = stats;
@@ -15,6 +106,7 @@ class ExtensionTrackerModule {
15
106
  }
16
107
 
17
108
  initialize(config, app) {
109
+ this.config = config;
18
110
  const dbPath = config.database || path.join(config.folder || '.', 'extension-tracker.db');
19
111
  this.db = new Database(dbPath);
20
112
  this.db.pragma('journal_mode = WAL');
@@ -130,10 +222,12 @@ class ExtensionTrackerModule {
130
222
  * @param {string} cookiePrefix - cookie name prefix for persistence
131
223
  */
132
224
  buildColumnFilters(tableId, columns, cookiePrefix) {
225
+ // goes inside a <script>, and the options are submitted data: with no '<' in it, no
226
+ // value can close the script element
133
227
  const colsJson = JSON.stringify(columns.map(c => ({
134
228
  type: c.type || 'text',
135
229
  options: c.options || []
136
- })));
230
+ }))).replace(/</g, '\\u003c');
137
231
 
138
232
  return `
139
233
  <script>
@@ -246,8 +340,21 @@ class ExtensionTrackerModule {
246
340
  const startTime = Date.now();
247
341
  const data = req.body;
248
342
 
249
- if (!data.package || !data.version || !data.fhirVersion) {
250
- return res.status(400).json({ error: 'Missing required fields: package, version, fhirVersion' });
343
+ // The IG Publisher sends a token. It is in the publisher's source, so it is no secret,
344
+ // but it keeps out anything that isn't trying to look like the publisher
345
+ if (tokenConfigured(this.config.token)) {
346
+ const auth = req.get('Authorization') || '';
347
+ const supplied = auth.replace(/^Bearer\s+/i, '');
348
+ if (!tokenMatches(this.config.token, supplied)) {
349
+ return res.status(401).json({ error: 'A valid token is required in the Authorization header' });
350
+ }
351
+ }
352
+
353
+ const errors = validateSubmission(data);
354
+ if (errors.length > 0) {
355
+ // logged, so that a real publisher submission refused by a rule that is too strict is seen
356
+ console.log(`Extension tracker: refused a submission for ${String(data?.package).substring(0, 100)} from ${req.ip}: ${errors.slice(0, 3).join('; ')}`);
357
+ return res.status(400).json({ error: 'Not a valid submission', issues: errors });
251
358
  }
252
359
 
253
360
  try {
@@ -695,3 +802,4 @@ class ExtensionTrackerModule {
695
802
  }
696
803
 
697
804
  module.exports = ExtensionTrackerModule;
805
+ module.exports.validateSubmission = validateSubmission;
@@ -14,11 +14,14 @@ Tracks extensions, profiles, and extension usage across FHIR Implementation Guid
14
14
 
15
15
  - **database**: path to the SQLite database file (auto-created if missing)
16
16
  - **url** (optional): URL base to mount at (defaults to `/ext-tracker`)
17
+ - **token** (optional): the token a submission must carry, as `Authorization: Bearer <token>`. The IG Publisher sends `9D1C23C0-3915-4542-882E-2BCC55645DF8`. It is in the publisher's source, so it is not a secret, but it keeps out submissions that aren't from the publisher. Without it, submissions need no token
17
18
 
18
19
  ## POST — Submit IG Data
19
20
 
20
21
  POST JSON to the module root. Required fields: `package`, `version`, `fhirVersion`. Optional: `jurisdiction`, `extensions`, `profiles`, `usage`.
21
22
 
23
+ A submission must look like what the IG Publisher sends, or it is refused (400): no other properties; `package`, `version`, `fhirVersion` and `jurisdiction` are codes; extension, profile and usage keys are URLs; `profiles` is keyed by resource type; usage locations are element paths; titles are text of at most 1024 characters; and no more than 20000 items in all.
24
+
22
25
  ```
23
26
  POST /ext-tracker
24
27
  Content-Type: application/json
@@ -0,0 +1,263 @@
1
+ //
2
+ // Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
3
+ //
4
+ // Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
5
+ //
6
+
7
+ /**
8
+ * A client for the AI services the assessment step can use: two API shapes, which between
9
+ * them cover most of what there is.
10
+ *
11
+ * openai the chat-completions API: OpenAI, OpenRouter, LiteLLM, Groq, Ollama, Azure,
12
+ * Gemini's compatibility endpoint... POST {baseUrl}/chat/completions
13
+ * anthropic the Messages API: POST {baseUrl}/v1/messages
14
+ *
15
+ * An account is { provider, baseUrl, model, apiKey }. The client sends one system prompt
16
+ * and one user message and returns the text that comes back, with the token counts the
17
+ * service reports. Responses are bounded, and nothing about the account is in an error's
18
+ * message beyond the provider and model.
19
+ *
20
+ * Also here: the encryption of API keys at rest (AES-256-GCM, a key derived from a server
21
+ * secret), so a copy of the database is not a copy of the accounts.
22
+ *
23
+ * @module ips/ai-client
24
+ */
25
+
26
+ const crypto = require('crypto');
27
+
28
+ const PROVIDERS = {
29
+ openai: { name: 'OpenAI-compatible (chat completions)', defaultBaseUrl: 'https://api.openai.com/v1' },
30
+ anthropic: { name: 'Anthropic (Messages API)', defaultBaseUrl: 'https://api.anthropic.com' }
31
+ };
32
+ const DEFAULT_TIMEOUT_MS = 120000;
33
+ // of the answer: a thorough assessment of a big document runs to several thousand tokens
34
+ const DEFAULT_MAX_TOKENS = 16384;
35
+ const MAX_RESPONSE_BYTES = 1024 * 1024;
36
+ const ANTHROPIC_VERSION = '2023-06-01';
37
+
38
+ function isObject(v) {
39
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
40
+ }
41
+
42
+ /** Check an account's shape; throws with a message fit for a settings page. */
43
+ function checkAccount(account) {
44
+ if (!isObject(account)) {
45
+ throw new Error('No AI account');
46
+ }
47
+ if (!PROVIDERS[account.provider]) {
48
+ throw new Error(`Unknown AI provider '${account.provider}' (openai or anthropic)`);
49
+ }
50
+ if (typeof account.model !== 'string' || !account.model.trim()) {
51
+ throw new Error('The AI account has no model');
52
+ }
53
+ if (typeof account.apiKey !== 'string' || !account.apiKey) {
54
+ throw new Error('The AI account has no API key');
55
+ }
56
+ const base = typeof account.baseUrl === 'string' && account.baseUrl.trim() ? account.baseUrl.trim() : PROVIDERS[account.provider].defaultBaseUrl;
57
+ let u;
58
+ try {
59
+ u = new URL(base);
60
+ } catch {
61
+ throw new Error('The AI account\'s base URL is not a URL');
62
+ }
63
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') {
64
+ throw new Error('The AI account\'s base URL must be http(s)');
65
+ }
66
+ return { ...account, baseUrl: base.replace(/\/+$/, '') };
67
+ }
68
+
69
+ /** Read a response body, up to a limit. */
70
+ async function readBounded(res, limit) {
71
+ const reader = res.body?.getReader ? res.body.getReader() : null;
72
+ if (!reader) {
73
+ const t = await res.text();
74
+ return t.length > limit ? t.substring(0, limit) : t;
75
+ }
76
+ const chunks = [];
77
+ let size = 0;
78
+ for (;;) {
79
+ const { done, value } = await reader.read();
80
+ if (done) {
81
+ break;
82
+ }
83
+ size += value.length;
84
+ if (size > limit) {
85
+ await reader.cancel();
86
+ throw new Error('The AI service\'s response was too large');
87
+ }
88
+ chunks.push(value);
89
+ }
90
+ return Buffer.concat(chunks).toString('utf8');
91
+ }
92
+
93
+ /**
94
+ * Ask the service.
95
+ *
96
+ * @param {{provider: string, baseUrl?: string, model: string, apiKey: string}} account
97
+ * @param {{system: string, user: string, maxTokens?: number, timeoutMs?: number, fetch?: Function}} request
98
+ * @returns {Promise<{text: string, usage: {input: number|null, output: number|null, cost: number|null}, model: string, provider: string, truncated: boolean, maxTokens: number}>}
99
+ * usage.cost: in USD, when the service says (OpenRouter does); truncated: the answer was
100
+ * cut off at maxTokens
101
+ */
102
+ async function complete(account, request) {
103
+ const a = checkAccount(account);
104
+ const doFetch = request.fetch || fetch;
105
+ const maxTokens = Number.isInteger(request.maxTokens) && request.maxTokens > 0 ? request.maxTokens : DEFAULT_MAX_TOKENS;
106
+ const timeoutMs = Number.isInteger(request.timeoutMs) && request.timeoutMs > 0 ? request.timeoutMs : DEFAULT_TIMEOUT_MS;
107
+ let url;
108
+ let headers;
109
+ let body;
110
+ if (a.provider === 'anthropic') {
111
+ url = `${a.baseUrl}/v1/messages`;
112
+ headers = { 'content-type': 'application/json', 'x-api-key': a.apiKey, 'anthropic-version': ANTHROPIC_VERSION };
113
+ body = { model: a.model, max_tokens: maxTokens, system: request.system, messages: [{ role: 'user', content: request.user }] };
114
+ } else {
115
+ url = `${a.baseUrl}/chat/completions`;
116
+ headers = { 'content-type': 'application/json', authorization: `Bearer ${a.apiKey}` };
117
+ body = { model: a.model, max_tokens: maxTokens, temperature: 0, messages: [{ role: 'system', content: request.system }, { role: 'user', content: request.user }] };
118
+ }
119
+ const controller = new AbortController();
120
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
121
+ let res;
122
+ let text;
123
+ try {
124
+ res = await doFetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal: controller.signal, redirect: 'manual' });
125
+ text = await readBounded(res, MAX_RESPONSE_BYTES);
126
+ } catch (e) {
127
+ throw new Error(e.name === 'AbortError' ? `The AI service (${a.provider}, ${a.model}) did not answer within ${timeoutMs} ms` : `The AI service (${a.provider}, ${a.model}) could not be reached: ${e.message}`);
128
+ } finally {
129
+ clearTimeout(timer);
130
+ }
131
+ let json = null;
132
+ try {
133
+ json = JSON.parse(text);
134
+ } catch {
135
+ // not JSON: reported below
136
+ }
137
+ if (res.status < 200 || res.status >= 300) {
138
+ const detail = json?.error?.message || json?.message || (typeof json?.error === 'string' ? json.error : '') || '';
139
+ throw new Error(`The AI service (${a.provider}, ${a.model}) answered HTTP ${res.status}${detail ? `: ${String(detail).substring(0, 300)}` : ''}`);
140
+ }
141
+ if (!isObject(json)) {
142
+ throw new Error(`The AI service (${a.provider}, ${a.model}) did not answer with JSON`);
143
+ }
144
+ let out;
145
+ let usage;
146
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
147
+ // whether the answer was cut off at the token limit
148
+ let truncated;
149
+ if (a.provider === 'anthropic') {
150
+ out = (Array.isArray(json.content) ? json.content : []).filter(c => c?.type === 'text' && typeof c.text === 'string').map(c => c.text).join('\n');
151
+ usage = { input: num(json.usage?.input_tokens), output: num(json.usage?.output_tokens), cost: null };
152
+ truncated = json.stop_reason === 'max_tokens';
153
+ } else {
154
+ const choice = Array.isArray(json.choices) ? json.choices[0] : null;
155
+ const msg = choice?.message;
156
+ out = typeof msg?.content === 'string' ? msg.content : (Array.isArray(msg?.content) ? msg.content.filter(c => typeof c?.text === 'string').map(c => c.text).join('\n') : '');
157
+ // OpenRouter says what the call cost (in USD) in usage.cost; others don't
158
+ usage = { input: num(json.usage?.prompt_tokens), output: num(json.usage?.completion_tokens), cost: num(json.usage?.cost) };
159
+ truncated = choice?.finish_reason === 'length';
160
+ }
161
+ if (typeof out !== 'string' || !out.trim()) {
162
+ throw new Error(`The AI service (${a.provider}, ${a.model}) answered with no text`);
163
+ }
164
+ return { text: out, usage, model: typeof json.model === 'string' ? json.model : a.model, provider: a.provider, truncated: truncated === true, maxTokens };
165
+ }
166
+
167
+ /**
168
+ * The JSON object in a model's answer: the whole answer, or the first {...} in it (models
169
+ * wrap JSON in prose or code fences however they are asked not to), or null.
170
+ */
171
+ function jsonIn(text) {
172
+ if (typeof text !== 'string') {
173
+ return null;
174
+ }
175
+ const tries = [text.trim()];
176
+ const fence = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
177
+ if (fence) {
178
+ tries.push(fence[1].trim());
179
+ }
180
+ const first = text.indexOf('{');
181
+ const last = text.lastIndexOf('}');
182
+ if (first >= 0 && last > first) {
183
+ tries.push(text.substring(first, last + 1));
184
+ }
185
+ for (const t of tries) {
186
+ try {
187
+ const v = JSON.parse(t);
188
+ if (isObject(v)) {
189
+ return v;
190
+ }
191
+ } catch {
192
+ // the next
193
+ }
194
+ }
195
+ return null;
196
+ }
197
+
198
+ // list prices, USD per million tokens, for the models a server is likely to use without
199
+ // configuring prices (Anthropic's, as published October 2026). They go stale: the
200
+ // configuration's prices (modules.ips.ai.prices) override them, by model name or prefix
201
+ const DEFAULT_PRICES = {
202
+ 'claude-fable-5-1': { input: 10, output: 50 },
203
+ 'claude-opus-5-5': { input: 4, output: 20 },
204
+ 'claude-sonnet-5-5': { input: 2, output: 10 },
205
+ 'claude-haiku-5-5': { input: 0.1, output: 0.5 }
206
+ };
207
+
208
+ /**
209
+ * What a call cost, in USD: what the service said, else from a price list - model name (or
210
+ * a prefix of it, the longest wins) -> { input, output } in USD per million tokens - else null.
211
+ */
212
+ function costOf(usage, model, prices) {
213
+ if (typeof usage?.cost === 'number') {
214
+ return usage.cost;
215
+ }
216
+ if (!isObject(prices) || typeof model !== 'string' || typeof usage?.input !== 'number' || typeof usage?.output !== 'number') {
217
+ return null;
218
+ }
219
+ let best = null;
220
+ for (const [name, p] of Object.entries(prices)) {
221
+ if ((model === name || model.startsWith(name)) && isObject(p) && (!best || name.length > best.name.length)) {
222
+ best = { name, p };
223
+ }
224
+ }
225
+ if (!best || typeof best.p.input !== 'number' || typeof best.p.output !== 'number') {
226
+ return null;
227
+ }
228
+ return usage.input / 1e6 * best.p.input + usage.output / 1e6 * best.p.output;
229
+ }
230
+
231
+ // ---- keys at rest ----
232
+
233
+ function keyFrom(secret) {
234
+ return crypto.createHash('sha256').update(`ips-ai-key:${secret}`).digest();
235
+ }
236
+
237
+ /** Encrypt an API key for the database: v1:iv:tag:ciphertext, base64. */
238
+ function encryptKey(apiKey, secret) {
239
+ const iv = crypto.randomBytes(12);
240
+ const cipher = crypto.createCipheriv('aes-256-gcm', keyFrom(secret), iv);
241
+ const enc = Buffer.concat([cipher.update(apiKey, 'utf8'), cipher.final()]);
242
+ return `v1:${iv.toString('base64')}:${cipher.getAuthTag().toString('base64')}:${enc.toString('base64')}`;
243
+ }
244
+
245
+ /** @returns {string|null} the key, or null when it cannot be read (another secret, or damaged) */
246
+ function decryptKey(stored, secret) {
247
+ if (typeof stored !== 'string') {
248
+ return null;
249
+ }
250
+ const parts = stored.split(':');
251
+ if (parts.length !== 4 || parts[0] !== 'v1') {
252
+ return null;
253
+ }
254
+ try {
255
+ const decipher = crypto.createDecipheriv('aes-256-gcm', keyFrom(secret), Buffer.from(parts[1], 'base64'));
256
+ decipher.setAuthTag(Buffer.from(parts[2], 'base64'));
257
+ return Buffer.concat([decipher.update(Buffer.from(parts[3], 'base64')), decipher.final()]).toString('utf8');
258
+ } catch {
259
+ return null;
260
+ }
261
+ }
262
+
263
+ module.exports = { complete, checkAccount, jsonIn, costOf, encryptKey, decryptKey, DEFAULT_PRICES, PROVIDERS, MAX_RESPONSE_BYTES, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_TOKENS };