@mfittko/repo-wiki 0.2.1

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 (190) hide show
  1. package/.llmwiki/schema.md +107 -0
  2. package/AGENTS.md +42 -0
  3. package/CHANGELOG.md +91 -0
  4. package/LICENSE +21 -0
  5. package/README.md +254 -0
  6. package/dist/bin/repo-wiki.d.ts +2 -0
  7. package/dist/bin/repo-wiki.js +7 -0
  8. package/dist/bin/repo-wiki.js.map +1 -0
  9. package/dist/src/cli.d.ts +1 -0
  10. package/dist/src/cli.js +404 -0
  11. package/dist/src/cli.js.map +1 -0
  12. package/dist/src/compiler.d.ts +55 -0
  13. package/dist/src/compiler.js +2046 -0
  14. package/dist/src/compiler.js.map +1 -0
  15. package/dist/src/config.d.ts +63 -0
  16. package/dist/src/config.js +86 -0
  17. package/dist/src/config.js.map +1 -0
  18. package/dist/src/context-assembler.d.ts +68 -0
  19. package/dist/src/context-assembler.js +378 -0
  20. package/dist/src/context-assembler.js.map +1 -0
  21. package/dist/src/data-model-signals.d.ts +1 -0
  22. package/dist/src/data-model-signals.js +13 -0
  23. package/dist/src/data-model-signals.js.map +1 -0
  24. package/dist/src/docs-ingestor.d.ts +138 -0
  25. package/dist/src/docs-ingestor.js +844 -0
  26. package/dist/src/docs-ingestor.js.map +1 -0
  27. package/dist/src/docs-linter.d.ts +14 -0
  28. package/dist/src/docs-linter.js +164 -0
  29. package/dist/src/docs-linter.js.map +1 -0
  30. package/dist/src/docs-validation.d.ts +36 -0
  31. package/dist/src/docs-validation.js +297 -0
  32. package/dist/src/docs-validation.js.map +1 -0
  33. package/dist/src/extractors.d.ts +50 -0
  34. package/dist/src/extractors.js +2275 -0
  35. package/dist/src/extractors.js.map +1 -0
  36. package/dist/src/frontmatter.d.ts +46 -0
  37. package/dist/src/frontmatter.js +377 -0
  38. package/dist/src/frontmatter.js.map +1 -0
  39. package/dist/src/index.d.ts +26 -0
  40. package/dist/src/index.js +18 -0
  41. package/dist/src/index.js.map +1 -0
  42. package/dist/src/init.d.ts +12 -0
  43. package/dist/src/init.js +121 -0
  44. package/dist/src/init.js.map +1 -0
  45. package/dist/src/language.d.ts +2 -0
  46. package/dist/src/language.js +62 -0
  47. package/dist/src/language.js.map +1 -0
  48. package/dist/src/linter.d.ts +33 -0
  49. package/dist/src/linter.js +398 -0
  50. package/dist/src/linter.js.map +1 -0
  51. package/dist/src/llm-provider.d.ts +267 -0
  52. package/dist/src/llm-provider.js +474 -0
  53. package/dist/src/llm-provider.js.map +1 -0
  54. package/dist/src/page-ownership.d.ts +38 -0
  55. package/dist/src/page-ownership.js +96 -0
  56. package/dist/src/page-ownership.js.map +1 -0
  57. package/dist/src/planner.d.ts +55 -0
  58. package/dist/src/planner.js +422 -0
  59. package/dist/src/planner.js.map +1 -0
  60. package/dist/src/prompts.d.ts +103 -0
  61. package/dist/src/prompts.js +344 -0
  62. package/dist/src/prompts.js.map +1 -0
  63. package/dist/src/publisher.d.ts +68 -0
  64. package/dist/src/publisher.js +662 -0
  65. package/dist/src/publisher.js.map +1 -0
  66. package/dist/src/repository-analysis.d.ts +88 -0
  67. package/dist/src/repository-analysis.js +485 -0
  68. package/dist/src/repository-analysis.js.map +1 -0
  69. package/dist/src/scanner.d.ts +122 -0
  70. package/dist/src/scanner.js +309 -0
  71. package/dist/src/scanner.js.map +1 -0
  72. package/dist/src/search.d.ts +71 -0
  73. package/dist/src/search.js +410 -0
  74. package/dist/src/search.js.map +1 -0
  75. package/dist/src/secret-patterns.d.ts +3 -0
  76. package/dist/src/secret-patterns.js +14 -0
  77. package/dist/src/secret-patterns.js.map +1 -0
  78. package/dist/src/utils/args.d.ts +2 -0
  79. package/dist/src/utils/args.js +19 -0
  80. package/dist/src/utils/args.js.map +1 -0
  81. package/dist/src/utils/dotenv.d.ts +7 -0
  82. package/dist/src/utils/dotenv.js +73 -0
  83. package/dist/src/utils/dotenv.js.map +1 -0
  84. package/dist/src/utils/fs.d.ts +22 -0
  85. package/dist/src/utils/fs.js +83 -0
  86. package/dist/src/utils/fs.js.map +1 -0
  87. package/dist/src/utils/git.d.ts +13 -0
  88. package/dist/src/utils/git.js +39 -0
  89. package/dist/src/utils/git.js.map +1 -0
  90. package/dist/src/wiki-graph.d.ts +74 -0
  91. package/dist/src/wiki-graph.js +335 -0
  92. package/dist/src/wiki-graph.js.map +1 -0
  93. package/dist/src/wiki-patch.d.ts +152 -0
  94. package/dist/src/wiki-patch.js +489 -0
  95. package/dist/src/wiki-patch.js.map +1 -0
  96. package/dist/src/wiki-query.d.ts +63 -0
  97. package/dist/src/wiki-query.js +255 -0
  98. package/dist/src/wiki-query.js.map +1 -0
  99. package/dist/test/cli.test.d.ts +1 -0
  100. package/dist/test/cli.test.js +514 -0
  101. package/dist/test/cli.test.js.map +1 -0
  102. package/dist/test/compiler-eval.test.d.ts +1 -0
  103. package/dist/test/compiler-eval.test.js +234 -0
  104. package/dist/test/compiler-eval.test.js.map +1 -0
  105. package/dist/test/compiler.test.d.ts +1 -0
  106. package/dist/test/compiler.test.js +2537 -0
  107. package/dist/test/compiler.test.js.map +1 -0
  108. package/dist/test/context-assembler.test.d.ts +1 -0
  109. package/dist/test/context-assembler.test.js +379 -0
  110. package/dist/test/context-assembler.test.js.map +1 -0
  111. package/dist/test/docs-linter.test.d.ts +1 -0
  112. package/dist/test/docs-linter.test.js +900 -0
  113. package/dist/test/docs-linter.test.js.map +1 -0
  114. package/dist/test/dotenv.test.d.ts +1 -0
  115. package/dist/test/dotenv.test.js +77 -0
  116. package/dist/test/dotenv.test.js.map +1 -0
  117. package/dist/test/extractors-go.test.d.ts +1 -0
  118. package/dist/test/extractors-go.test.js +393 -0
  119. package/dist/test/extractors-go.test.js.map +1 -0
  120. package/dist/test/extractors-rust.test.d.ts +1 -0
  121. package/dist/test/extractors-rust.test.js +219 -0
  122. package/dist/test/extractors-rust.test.js.map +1 -0
  123. package/dist/test/extractors-utils.test.d.ts +1 -0
  124. package/dist/test/extractors-utils.test.js +786 -0
  125. package/dist/test/extractors-utils.test.js.map +1 -0
  126. package/dist/test/fixtures/compiler-e2e/basic-node-service/repo/infra/deploy.d.ts +1 -0
  127. package/dist/test/fixtures/compiler-e2e/basic-node-service/repo/infra/deploy.js +4 -0
  128. package/dist/test/fixtures/compiler-e2e/basic-node-service/repo/infra/deploy.js.map +1 -0
  129. package/dist/test/frontmatter.test.d.ts +1 -0
  130. package/dist/test/frontmatter.test.js +287 -0
  131. package/dist/test/frontmatter.test.js.map +1 -0
  132. package/dist/test/init-planner.test.d.ts +1 -0
  133. package/dist/test/init-planner.test.js +688 -0
  134. package/dist/test/init-planner.test.js.map +1 -0
  135. package/dist/test/linter.test.d.ts +1 -0
  136. package/dist/test/linter.test.js +426 -0
  137. package/dist/test/linter.test.js.map +1 -0
  138. package/dist/test/llm-provider.test.d.ts +1 -0
  139. package/dist/test/llm-provider.test.js +783 -0
  140. package/dist/test/llm-provider.test.js.map +1 -0
  141. package/dist/test/page-ownership.test.d.ts +1 -0
  142. package/dist/test/page-ownership.test.js +247 -0
  143. package/dist/test/page-ownership.test.js.map +1 -0
  144. package/dist/test/publisher.test.d.ts +1 -0
  145. package/dist/test/publisher.test.js +1297 -0
  146. package/dist/test/publisher.test.js.map +1 -0
  147. package/dist/test/repository-analysis.test.d.ts +1 -0
  148. package/dist/test/repository-analysis.test.js +182 -0
  149. package/dist/test/repository-analysis.test.js.map +1 -0
  150. package/dist/test/run-compiled-tests.d.ts +1 -0
  151. package/dist/test/run-compiled-tests.js +48 -0
  152. package/dist/test/run-compiled-tests.js.map +1 -0
  153. package/dist/test/scanner.test.d.ts +1 -0
  154. package/dist/test/scanner.test.js +551 -0
  155. package/dist/test/scanner.test.js.map +1 -0
  156. package/dist/test/search.test.d.ts +1 -0
  157. package/dist/test/search.test.js +92 -0
  158. package/dist/test/search.test.js.map +1 -0
  159. package/dist/test/update-changelog.test.d.ts +1 -0
  160. package/dist/test/update-changelog.test.js +125 -0
  161. package/dist/test/update-changelog.test.js.map +1 -0
  162. package/dist/test/wiki-graph.test.d.ts +1 -0
  163. package/dist/test/wiki-graph.test.js +164 -0
  164. package/dist/test/wiki-graph.test.js.map +1 -0
  165. package/dist/test/wiki-patch.test.d.ts +1 -0
  166. package/dist/test/wiki-patch.test.js +610 -0
  167. package/dist/test/wiki-patch.test.js.map +1 -0
  168. package/dist/test/wiki-query.test.d.ts +1 -0
  169. package/dist/test/wiki-query.test.js +163 -0
  170. package/dist/test/wiki-query.test.js.map +1 -0
  171. package/docs/PLAN.md +993 -0
  172. package/docs/WHY.md +61 -0
  173. package/docs/plans/agent-integration.md +85 -0
  174. package/docs/plans/ci-publishing.md +111 -0
  175. package/docs/plans/doc-validation.md +92 -0
  176. package/docs/plans/github-action.md +113 -0
  177. package/docs/plans/incremental-mode.md +98 -0
  178. package/docs/plans/karpathy-llm-wiki-alignment.md +84 -0
  179. package/docs/plans/llm-compiler.md +160 -0
  180. package/docs/plans/production-scanner.md +104 -0
  181. package/docs/plans/query-and-file-back.md +103 -0
  182. package/docs/plans/search-index.md +118 -0
  183. package/docs/plans/trust-hardening.md +74 -0
  184. package/docs/plans/wiki-graph.md +183 -0
  185. package/docs/plans/wiki-health.md +76 -0
  186. package/package.json +83 -0
  187. package/prompts/compiler.md +16 -0
  188. package/prompts/lint.md +18 -0
  189. package/prompts/page-templates.md +25 -0
  190. package/skills/repo-wiki-cli/SKILL.md +139 -0
@@ -0,0 +1,783 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { MockLLMProvider, OpenAICompatibleProvider, LLMProviderError, createProvider, createProviderFromResolvedConfig, resolveProviderConfig, buildRequest, } from '../src/llm-provider.js';
4
+ import { buildPrompt, buildFoundationPrompt, buildModulePrompt, buildCrossCuttingPrompt, } from '../src/prompts.js';
5
+ // ── Helpers ────────────────────────────────────────────────────────────────
6
+ function makeContext(overrides = {}) {
7
+ return {
8
+ pageName: 'Module-Auth',
9
+ pageTitle: 'Auth',
10
+ repoRemote: 'https://github.com/owner/repo',
11
+ repoCommit: 'abc123',
12
+ sourceCards: [
13
+ {
14
+ path: 'src/auth/index.ts',
15
+ category: 'source',
16
+ language: 'TypeScript',
17
+ symbols: ['login', 'logout', 'refreshToken'],
18
+ imports: ['./tokens.js', './session.js'],
19
+ reasons: ['auth'],
20
+ },
21
+ ],
22
+ docCards: [
23
+ { path: 'docs/auth.md', status: 'validated', claims: ['JWT-based session'] },
24
+ ],
25
+ ...overrides,
26
+ };
27
+ }
28
+ function makeRequest(overrides = {}) {
29
+ return {
30
+ archetype: 'module',
31
+ pageName: 'Module-Auth',
32
+ pageTitle: 'Auth',
33
+ systemPrompt: 'System instructions.',
34
+ userPrompt: 'User instructions.',
35
+ ...overrides,
36
+ };
37
+ }
38
+ // ── MockLLMProvider ────────────────────────────────────────────────────────
39
+ test('MockLLMProvider has name "mock"', () => {
40
+ const provider = new MockLLMProvider();
41
+ assert.equal(provider.name, 'mock');
42
+ });
43
+ test('MockLLMProvider returns a response without network calls', async () => {
44
+ const provider = new MockLLMProvider();
45
+ const response = await provider.complete(makeRequest());
46
+ assert.equal(response.provider, 'mock');
47
+ assert.ok(typeof response.content === 'string' && response.content.length > 0, 'content must be non-empty');
48
+ });
49
+ test('MockLLMProvider response includes frontmatter', async () => {
50
+ const provider = new MockLLMProvider();
51
+ const response = await provider.complete(makeRequest({ pageName: 'Module-Auth', pageTitle: 'Auth' }));
52
+ assert.match(response.content, /^---/);
53
+ assert.match(response.content, /kind:/);
54
+ assert.doesNotMatch(response.content, /page_type:/);
55
+ assert.match(response.content, /page_name:/);
56
+ assert.match(response.content, /compiled_at:/);
57
+ assert.match(response.content, /source_commit:/);
58
+ });
59
+ test('MockLLMProvider response includes page title as h1', async () => {
60
+ const provider = new MockLLMProvider();
61
+ const response = await provider.complete(makeRequest({ pageTitle: 'My Module' }));
62
+ assert.match(response.content, /^# My Module/m);
63
+ });
64
+ test('MockLLMProvider includes HUMAN_NOTES block for module archetype', async () => {
65
+ const provider = new MockLLMProvider();
66
+ const response = await provider.complete(makeRequest({ archetype: 'module' }));
67
+ assert.match(response.content, /HUMAN_NOTES_START/);
68
+ assert.match(response.content, /HUMAN_NOTES_END/);
69
+ });
70
+ test('MockLLMProvider omits HUMAN_NOTES block for foundation archetype', async () => {
71
+ const provider = new MockLLMProvider();
72
+ const response = await provider.complete(makeRequest({ archetype: 'foundation' }));
73
+ assert.doesNotMatch(response.content, /HUMAN_NOTES_START/);
74
+ });
75
+ test('MockLLMProvider omits HUMAN_NOTES block for cross-cutting archetype', async () => {
76
+ const provider = new MockLLMProvider();
77
+ const response = await provider.complete(makeRequest({ archetype: 'cross-cutting' }));
78
+ assert.doesNotMatch(response.content, /HUMAN_NOTES_START/);
79
+ });
80
+ test('MockLLMProvider is deterministic: same input produces same output', async () => {
81
+ const provider = new MockLLMProvider();
82
+ const req = makeRequest({ archetype: 'foundation', pageName: 'Architecture', pageTitle: 'Architecture' });
83
+ const r1 = await provider.complete(req);
84
+ const r2 = await provider.complete(req);
85
+ assert.equal(r1.content, r2.content);
86
+ });
87
+ // ── LLMProviderError ───────────────────────────────────────────────────────
88
+ test('LLMProviderError has correct name, message, provider, and code', () => {
89
+ const err = new LLMProviderError('bad key', 'openai', 'MISSING_API_KEY');
90
+ assert.equal(err.name, 'LLMProviderError');
91
+ assert.equal(err.message, 'bad key');
92
+ assert.equal(err.provider, 'openai');
93
+ assert.equal(err.code, 'MISSING_API_KEY');
94
+ assert.equal(err.retryable, false);
95
+ assert.ok(err instanceof Error);
96
+ });
97
+ test('LLMProviderError retryable defaults to false', () => {
98
+ const err = new LLMProviderError('timeout', 'mock');
99
+ assert.equal(err.retryable, false);
100
+ });
101
+ test('LLMProviderError retryable can be set to true', () => {
102
+ const err = new LLMProviderError('timeout', 'openai', 'RATE_LIMIT', true);
103
+ assert.equal(err.retryable, true);
104
+ });
105
+ // ── createProvider ─────────────────────────────────────────────────────────
106
+ test('createProvider with no config returns MockLLMProvider', () => {
107
+ const provider = createProvider();
108
+ assert.equal(provider.name, 'mock');
109
+ assert.ok(provider instanceof MockLLMProvider);
110
+ });
111
+ test('createProvider with provider="mock" returns MockLLMProvider', () => {
112
+ const provider = createProvider({ provider: 'mock' });
113
+ assert.equal(provider.name, 'mock');
114
+ assert.ok(provider instanceof MockLLMProvider);
115
+ });
116
+ test('createProvider with openai alias and no apiKey throws MISSING_API_KEY', () => {
117
+ const config = { provider: 'openai' };
118
+ assert.throws(() => createProvider(config), (err) => {
119
+ assert.ok(err instanceof LLMProviderError);
120
+ assert.equal(err.code, 'MISSING_API_KEY');
121
+ assert.equal(err.provider, 'openai');
122
+ return true;
123
+ });
124
+ });
125
+ test('createProvider with openai-compatible and apiKey returns hosted provider', () => {
126
+ const provider = createProvider({ provider: 'openai-compatible', apiKey: 'key-123' });
127
+ assert.equal(provider.name, 'openai-compatible');
128
+ assert.ok(provider instanceof OpenAICompatibleProvider);
129
+ });
130
+ test('createProvider accepts openai alias for OpenAI-compatible provider', () => {
131
+ const provider = createProvider({ provider: 'openai', apiKey: 'key-123' });
132
+ assert.equal(provider.name, 'openai-compatible');
133
+ });
134
+ test('createProvider with unknown provider and apiKey throws UNKNOWN_PROVIDER', () => {
135
+ const config = { provider: 'unknown-llm', apiKey: 'key-123' };
136
+ assert.throws(() => createProvider(config), (err) => {
137
+ assert.ok(err instanceof LLMProviderError);
138
+ assert.equal(err.code, 'UNKNOWN_PROVIDER');
139
+ assert.equal(err.provider, 'unknown-llm');
140
+ return true;
141
+ });
142
+ });
143
+ test('resolveProviderConfig applies env overrides and resolves api key', () => {
144
+ const resolved = resolveProviderConfig({
145
+ provider: 'mock',
146
+ base_url: 'https://config.example/v1',
147
+ model: 'config-model',
148
+ api_key_env: 'CONFIG_KEY',
149
+ system_prompt: 'config prompt',
150
+ temperature: 0.2,
151
+ max_output_tokens: 1000,
152
+ timeout_ms: 10000,
153
+ reasoning_effort: 'medium',
154
+ retries: 1,
155
+ validation_retries: 3,
156
+ }, {
157
+ LLMWIKI_LLM_PROVIDER: 'openai-compatible',
158
+ LLMWIKI_COMPILER_MODE: 'llm',
159
+ LLMWIKI_LLM_BASE_URL: 'https://env.example/v1',
160
+ LLMWIKI_LLM_MODEL: 'env-model',
161
+ LLMWIKI_LLM_API_KEY: 'secret-key',
162
+ LLMWIKI_LLM_SYSTEM_PROMPT: 'env prompt',
163
+ LLMWIKI_LLM_TEMPERATURE: '0.3',
164
+ LLMWIKI_LLM_REASONING_EFFORT: 'low',
165
+ LLMWIKI_LLM_MAX_OUTPUT_TOKENS: '2000',
166
+ LLMWIKI_LLM_RETRIES: '4',
167
+ LLMWIKI_LLM_VALIDATION_RETRIES: '2',
168
+ });
169
+ assert.equal(resolved.provider, 'openai-compatible');
170
+ assert.equal(resolved.baseUrl, 'https://env.example/v1');
171
+ assert.equal(resolved.model, 'env-model');
172
+ assert.equal(resolved.apiKey, 'secret-key');
173
+ assert.equal(resolved.apiKeyEnv, 'LLMWIKI_LLM_API_KEY');
174
+ assert.equal(resolved.systemPrompt, 'env prompt');
175
+ assert.equal(resolved.temperature, 0.3);
176
+ assert.equal(resolved.maxOutputTokens, 2000);
177
+ assert.equal(resolved.timeoutMs, 10000);
178
+ assert.equal(resolved.reasoningEffort, 'low');
179
+ assert.equal(resolved.retries, 4);
180
+ assert.equal(resolved.validationRetries, 2);
181
+ });
182
+ test('resolveProviderConfig honors explicit hosted provider config before deterministic mode default', () => {
183
+ const resolved = resolveProviderConfig({ provider: 'openai-compatible', apiKey: 'secret-key' }, { LLMWIKI_COMPILER_MODE: 'deterministic' });
184
+ assert.equal(resolved.provider, 'openai-compatible');
185
+ });
186
+ test('resolveProviderConfig honors explicit mock provider config before llm mode default', () => {
187
+ const resolved = resolveProviderConfig({ provider: 'mock' }, { LLMWIKI_COMPILER_MODE: 'llm' });
188
+ assert.equal(resolved.provider, 'mock');
189
+ });
190
+ test('resolveProviderConfig uses OpenAI-compatible provider for llm mode without explicit provider', () => {
191
+ const resolved = resolveProviderConfig({}, { LLMWIKI_COMPILER_MODE: 'llm' });
192
+ assert.equal(resolved.provider, 'openai-compatible');
193
+ });
194
+ test('createProvider requires API key for llm mode without explicit provider', () => {
195
+ assert.throws(() => createProvider({ mode: 'llm' }), (err) => {
196
+ assert.ok(err instanceof LLMProviderError);
197
+ assert.equal(err.code, 'MISSING_API_KEY');
198
+ assert.equal(err.provider, 'openai-compatible');
199
+ return true;
200
+ });
201
+ });
202
+ test('resolveProviderConfig honors nested explicit mock provider before llm mode default', () => {
203
+ const resolved = resolveProviderConfig({
204
+ mode: 'llm',
205
+ llm: { provider: 'mock' },
206
+ });
207
+ assert.equal(resolved.provider, 'mock');
208
+ });
209
+ test('createProvider honors nested explicit mock provider in llm mode without API key', () => {
210
+ const provider = createProvider({ mode: 'llm', llm: { provider: 'mock' } });
211
+ assert.equal(provider.name, 'mock');
212
+ });
213
+ test('resolveProviderConfig honors compiler config mode only as a default after nested llm provider', () => {
214
+ const resolved = resolveProviderConfig({
215
+ mode: 'deterministic',
216
+ llm: { provider: 'openai-compatible', apiKey: 'secret-key' },
217
+ });
218
+ assert.equal(resolved.provider, 'openai-compatible');
219
+ });
220
+ test('resolveProviderConfig treats blank environment variables as unset', () => {
221
+ const resolved = resolveProviderConfig({ provider: 'mock', model: 'config-model', apiKey: 'config-key' }, {
222
+ LLMWIKI_COMPILER_MODE: ' ',
223
+ LLMWIKI_LLM_PROVIDER: '',
224
+ LLMWIKI_LLM_MODEL: ' ',
225
+ LLMWIKI_LLM_API_KEY: '',
226
+ LLMWIKI_LLM_TEMPERATURE: '',
227
+ });
228
+ assert.equal(resolved.provider, 'mock');
229
+ assert.equal(resolved.model, 'config-model');
230
+ assert.equal(resolved.apiKey, 'config-key');
231
+ assert.equal(resolved.temperature, 0.1);
232
+ });
233
+ test('resolveProviderConfig rejects invalid numeric environment config', () => {
234
+ assert.throws(() => resolveProviderConfig({}, { LLMWIKI_LLM_TIMEOUT_MS: 'not-a-number' }), (err) => {
235
+ assert.ok(err instanceof LLMProviderError);
236
+ assert.equal(err.code, 'INVALID_CONFIG');
237
+ return true;
238
+ });
239
+ });
240
+ test('resolveProviderConfig rejects invalid reasoning effort config with field context', () => {
241
+ assert.throws(() => resolveProviderConfig({}, { LLMWIKI_LLM_REASONING_EFFORT: 'turbo' }), (err) => {
242
+ assert.ok(err instanceof LLMProviderError);
243
+ assert.equal(err.code, 'INVALID_CONFIG');
244
+ assert.equal(err.provider, 'config');
245
+ assert.match(err.message, /reasoningEffort/);
246
+ assert.match(err.message, /"turbo"/);
247
+ return true;
248
+ });
249
+ });
250
+ test('resolveProviderConfig rejects invalid numeric JSON config', () => {
251
+ assert.throws(() => resolveProviderConfig({ timeoutMs: 'soon' }, {}), (err) => {
252
+ assert.ok(err instanceof LLMProviderError);
253
+ assert.equal(err.code, 'INVALID_CONFIG');
254
+ return true;
255
+ });
256
+ });
257
+ test('resolveProviderConfig rejects negative integer config', () => {
258
+ for (const config of [
259
+ { maxOutputTokens: -1 },
260
+ { timeoutMs: -1 },
261
+ { retries: -1 },
262
+ { validationRetries: -1 },
263
+ { validation_retries: -1 },
264
+ ]) {
265
+ assert.throws(() => resolveProviderConfig(config, {}), (err) => {
266
+ assert.ok(err instanceof LLMProviderError);
267
+ assert.equal(err.code, 'INVALID_CONFIG');
268
+ assert.equal(err.provider, 'config');
269
+ return true;
270
+ });
271
+ }
272
+ });
273
+ test('OpenAICompatibleProvider posts chat-completions request', async (t) => {
274
+ let captured = {};
275
+ t.mock.method(globalThis, 'fetch', (async (url, init) => {
276
+ captured = {
277
+ url,
278
+ body: JSON.parse(String(init.body)),
279
+ authorization: init.headers.authorization,
280
+ };
281
+ return new Response(JSON.stringify({
282
+ choices: [{ message: { content: '# Generated' } }],
283
+ usage: { prompt_tokens: 10, completion_tokens: 2 },
284
+ }), { status: 200, headers: { 'content-type': 'application/json' } });
285
+ }));
286
+ const provider = createProvider({ provider: 'openai-compatible', apiKey: 'key-123', model: 'test-model', baseUrl: 'https://llm.example/v1' });
287
+ const response = await provider.complete(makeRequest({ maxTokens: 123, temperature: 0.4 }));
288
+ assert.equal(captured.url, 'https://llm.example/v1/chat/completions');
289
+ assert.equal(captured.authorization, 'Bearer key-123');
290
+ assert.equal(captured.body.model, 'test-model');
291
+ assert.equal(captured.body.max_tokens, 123);
292
+ assert.equal(captured.body.max_completion_tokens, undefined);
293
+ assert.equal(captured.body.temperature, 0.4);
294
+ assert.equal(response.content, '# Generated');
295
+ assert.equal(response.promptTokens, 10);
296
+ assert.equal(response.completionTokens, 2);
297
+ });
298
+ test('OpenAICompatibleProvider uses reasoning-model chat compatibility params for GPT-5 models', async (t) => {
299
+ let captured = {};
300
+ t.mock.method(globalThis, 'fetch', (async (_url, init) => {
301
+ captured = {
302
+ body: JSON.parse(String(init.body)),
303
+ };
304
+ return new Response(JSON.stringify({
305
+ choices: [{ message: { content: '# Generated' } }],
306
+ usage: { prompt_tokens: 10, completion_tokens: 2 },
307
+ }), { status: 200, headers: { 'content-type': 'application/json' } });
308
+ }));
309
+ const provider = createProvider({ provider: 'openai-compatible', apiKey: 'key-123', model: 'gpt-5.5', baseUrl: 'https://llm.example/v1' });
310
+ await provider.complete(makeRequest({ maxTokens: 123, temperature: 0, reasoningEffort: 'low' }));
311
+ assert.equal(captured.body.max_tokens, undefined);
312
+ assert.equal(captured.body.max_completion_tokens, 123);
313
+ assert.equal(captured.body.temperature, undefined);
314
+ assert.equal(captured.body.reasoning_effort, 'low');
315
+ });
316
+ async function assertProviderRejectsCode(promise, code, retryable) {
317
+ await assert.rejects(promise, (err) => {
318
+ assert.ok(err instanceof LLMProviderError);
319
+ assert.equal(err.code, code);
320
+ if (retryable !== undefined)
321
+ assert.equal(err.retryable, retryable);
322
+ return true;
323
+ });
324
+ }
325
+ test('OpenAICompatibleProvider rejects missing choices', async (t) => {
326
+ t.mock.method(globalThis, 'fetch', (async () => new Response(JSON.stringify({}), { status: 200 })));
327
+ const provider = createProvider({ provider: 'openai-compatible', apiKey: 'key-123' });
328
+ await assertProviderRejectsCode(provider.complete(makeRequest()), 'MISSING_CHOICES');
329
+ });
330
+ test('OpenAICompatibleProvider rejects empty choices', async (t) => {
331
+ t.mock.method(globalThis, 'fetch', (async () => new Response(JSON.stringify({ choices: [] }), { status: 200 })));
332
+ const provider = createProvider({ provider: 'openai-compatible', apiKey: 'key-123' });
333
+ await assertProviderRejectsCode(provider.complete(makeRequest()), 'MISSING_CHOICES');
334
+ });
335
+ test('OpenAICompatibleProvider rejects empty message content', async (t) => {
336
+ t.mock.method(globalThis, 'fetch', (async () => new Response(JSON.stringify({ choices: [{ message: { content: ' ' } }] }), { status: 200 })));
337
+ const provider = createProvider({ provider: 'openai-compatible', apiKey: 'key-123' });
338
+ await assertProviderRejectsCode(provider.complete(makeRequest()), 'EMPTY_RESPONSE');
339
+ });
340
+ test('OpenAICompatibleProvider rejects invalid JSON response', async (t) => {
341
+ t.mock.method(globalThis, 'fetch', (async () => new Response('not-json', { status: 200 })));
342
+ const provider = createProvider({ provider: 'openai-compatible', apiKey: 'key-123' });
343
+ await assertProviderRejectsCode(provider.complete(makeRequest()), 'INVALID_JSON', false);
344
+ });
345
+ test('OpenAICompatibleProvider retries retryable HTTP failures', async (t) => {
346
+ let calls = 0;
347
+ t.mock.method(globalThis, 'fetch', (async () => {
348
+ calls += 1;
349
+ if (calls === 1) {
350
+ return new Response(JSON.stringify({ error: 'rate limit' }), { status: 429 });
351
+ }
352
+ return new Response(JSON.stringify({ choices: [{ message: { content: '# After retry' } }] }), { status: 200 });
353
+ }));
354
+ const provider = createProvider({ provider: 'openai-compatible', apiKey: 'key-123', retries: 1 });
355
+ const response = await provider.complete(makeRequest());
356
+ assert.equal(calls, 2);
357
+ assert.equal(response.content, '# After retry');
358
+ });
359
+ test('OpenAICompatibleProvider includes sanitized provider error details for HTTP failures', async (t) => {
360
+ t.mock.method(globalThis, 'fetch', (async () => new Response(JSON.stringify({
361
+ error: {
362
+ message: "Unsupported value: 'temperature' does not support 0 with this model. Only the default (1) value is supported.",
363
+ type: 'invalid_request_error',
364
+ code: 'unsupported_value',
365
+ param: 'temperature',
366
+ },
367
+ }), { status: 400, headers: { 'content-type': 'application/json' } })));
368
+ const provider = createProvider({ provider: 'openai-compatible', apiKey: 'key-123', model: 'test-model' });
369
+ await assert.rejects(provider.complete(makeRequest({ temperature: 0 })), (err) => {
370
+ assert.ok(err instanceof LLMProviderError);
371
+ assert.equal(err.code, 'HTTP_400');
372
+ assert.match(err.message, /Unsupported value: 'temperature'/);
373
+ assert.match(err.message, /type=invalid_request_error/);
374
+ assert.match(err.message, /code=unsupported_value/);
375
+ assert.match(err.message, /param=temperature/);
376
+ return true;
377
+ });
378
+ });
379
+ test('OpenAICompatibleProvider surfaces timeout as retryable', async (t) => {
380
+ t.mock.method(globalThis, 'fetch', ((_, init) => new Promise((_resolve, reject) => {
381
+ init.signal.addEventListener('abort', () => {
382
+ const error = new Error('aborted');
383
+ error.name = 'AbortError';
384
+ reject(error);
385
+ });
386
+ })));
387
+ const provider = createProvider({ provider: 'openai-compatible', apiKey: 'key-123', timeoutMs: 1, retries: 0 });
388
+ await assertProviderRejectsCode(provider.complete(makeRequest()), 'TIMEOUT', true);
389
+ });
390
+ // ── buildRequest ───────────────────────────────────────────────────────────
391
+ test('buildRequest sets archetype, pageName, pageTitle on the request', () => {
392
+ const ctx = makeContext();
393
+ const req = buildRequest('module', ctx);
394
+ assert.equal(req.archetype, 'module');
395
+ assert.equal(req.pageName, 'Module-Auth');
396
+ assert.equal(req.pageTitle, 'Auth');
397
+ });
398
+ test('buildRequest populates systemPrompt and userPrompt', () => {
399
+ const ctx = makeContext();
400
+ const req = buildRequest('module', ctx);
401
+ assert.ok(req.systemPrompt.length > 0);
402
+ assert.ok(req.userPrompt.length > 0);
403
+ });
404
+ test('buildRequest passes maxTokens through', () => {
405
+ const ctx = makeContext();
406
+ const req = buildRequest('foundation', ctx, 4096);
407
+ assert.equal(req.maxTokens, 4096);
408
+ });
409
+ test('buildRequest accepts request options without positional maxTokens', () => {
410
+ const ctx = makeContext();
411
+ const req = buildRequest('foundation', ctx, {
412
+ systemPrompt: 'Custom system prompt.',
413
+ temperature: 0.2,
414
+ reasoningEffort: 'low',
415
+ maxOutputTokens: 2048,
416
+ });
417
+ assert.equal(req.systemPrompt, 'Custom system prompt.');
418
+ assert.equal(req.temperature, 0.2);
419
+ assert.equal(req.reasoningEffort, 'low');
420
+ assert.equal(req.maxTokens, 2048);
421
+ });
422
+ test('buildRequest treats null maxTokens argument as no positional budget', () => {
423
+ const ctx = makeContext();
424
+ const req = buildRequest('foundation', ctx, null);
425
+ assert.equal(req.maxTokens, undefined);
426
+ assert.ok(req.systemPrompt.length > 0);
427
+ });
428
+ test('buildRequest user prompt includes source card paths', () => {
429
+ const ctx = makeContext();
430
+ const req = buildRequest('module', ctx);
431
+ assert.match(req.userPrompt, /src\/auth\/index\.ts/);
432
+ });
433
+ // ── Prompt templates ───────────────────────────────────────────────────────
434
+ test('buildPrompt foundation returns system + user prompts', () => {
435
+ const ctx = makeContext({ pageName: 'Architecture', pageTitle: 'Architecture' });
436
+ const prompt = buildPrompt('foundation', ctx);
437
+ assert.ok(prompt.system.length > 0);
438
+ assert.ok(prompt.user.length > 0);
439
+ assert.match(prompt.system, /authoritative/);
440
+ assert.match(prompt.user, /Architecture/);
441
+ });
442
+ test('buildPrompt module includes module name and source files', () => {
443
+ const ctx = makeContext({
444
+ moduleInfo: {
445
+ name: 'Auth',
446
+ slug: 'Module-Auth',
447
+ files: ['src/auth/index.ts', 'src/auth/tokens.ts'],
448
+ categories: { source: 2 },
449
+ languages: { TypeScript: 2 },
450
+ important_reasons: ['auth'],
451
+ },
452
+ });
453
+ const prompt = buildPrompt('module', ctx);
454
+ assert.match(prompt.user, /Auth/);
455
+ assert.match(prompt.user, /src\/auth\/index\.ts/);
456
+ assert.match(prompt.user, /HUMAN_NOTES/);
457
+ });
458
+ test('buildPrompt module includes strict hosted LLM output instructions', () => {
459
+ const ctx = makeContext({
460
+ moduleInfo: {
461
+ name: 'Auth',
462
+ slug: 'Module-Auth',
463
+ files: ['src/auth/index.ts', 'src/auth/tokens.ts'],
464
+ categories: { source: 2 },
465
+ languages: { TypeScript: 2 },
466
+ important_reasons: ['auth'],
467
+ },
468
+ });
469
+ const prompt = buildPrompt('module', ctx);
470
+ assert.match(prompt.system, /Output only the complete markdown page/);
471
+ assert.match(prompt.system, /first line of the response must be exactly `---`/);
472
+ assert.match(prompt.system, /Do not include preamble, explanation, commentary, a markdown fence, or any code block wrapper/);
473
+ assert.match(prompt.system, /source_repo, source_commit, compiled_at, kind, page_state, source_paths/);
474
+ assert.match(prompt.system, /confidence metadata and claim status/);
475
+ assert.match(prompt.user, /Output only the raw markdown page/);
476
+ assert.match(prompt.user, /first line must be exactly `---`/);
477
+ assert.match(prompt.user, /kind: "module"/);
478
+ assert.match(prompt.user, /source_paths must be a non-empty array drawn only from the Source files in this module and Source cards listed above/);
479
+ assert.match(prompt.user, /End with this exact human notes block/);
480
+ });
481
+ test('buildPrompt cross-cutting references the page title', () => {
482
+ const ctx = makeContext({ pageName: 'Dependency-Map', pageTitle: 'Dependency Map' });
483
+ const prompt = buildPrompt('cross-cutting', ctx);
484
+ assert.match(prompt.user, /Dependency Map/);
485
+ });
486
+ test('buildPrompt includes existing content when provided', () => {
487
+ const ctx = makeContext({ existingContent: '# Old content\n\nSome previous text.' });
488
+ const prompt = buildPrompt('module', ctx);
489
+ assert.match(prompt.user, /Old content/);
490
+ });
491
+ test('buildPrompt wraps existing content with a fence that survives Markdown fences', () => {
492
+ const ctx = makeContext({ existingContent: '```ts\nconst value = true;\n```\n~~~~\nnotes\n~~~~' });
493
+ const prompt = buildPrompt('module', ctx);
494
+ assert.match(prompt.user, /~~~~~/);
495
+ assert.doesNotMatch(prompt.user, /Existing wiki content to update:\n```/);
496
+ });
497
+ test('buildPrompt throws clearly for invalid runtime archetypes', () => {
498
+ const ctx = makeContext();
499
+ assert.throws(() => buildPrompt('invalid', ctx), /Unsupported page archetype: invalid/);
500
+ });
501
+ test('buildPrompt indicates bootstrap mode when no existing content', () => {
502
+ const ctx = makeContext({ existingContent: undefined });
503
+ const prompt = buildPrompt('foundation', ctx);
504
+ assert.match(prompt.user, /bootstrap mode/i);
505
+ });
506
+ test('buildPrompt system prompt includes authority and output contract', () => {
507
+ const ctx = makeContext();
508
+ const prompt = buildPrompt('module', ctx);
509
+ assert.match(prompt.system, /authoritative/);
510
+ assert.match(prompt.system, /HUMAN_NOTES_START/);
511
+ assert.match(prompt.system, /frontmatter/);
512
+ });
513
+ test('buildFoundationPrompt includes repoRemote and repoCommit', () => {
514
+ const ctx = makeContext({ repoRemote: 'https://github.com/test/repo', repoCommit: 'deadbeef' });
515
+ const prompt = buildFoundationPrompt(ctx);
516
+ assert.match(prompt.user, /https:\/\/github\.com\/test\/repo/);
517
+ assert.match(prompt.user, /deadbeef/);
518
+ });
519
+ test('buildModulePrompt shows file count when moduleInfo is set', () => {
520
+ const ctx = makeContext({
521
+ moduleInfo: {
522
+ name: 'Payments',
523
+ slug: 'Module-Payments',
524
+ files: ['src/pay/charge.ts', 'src/pay/refund.ts'],
525
+ categories: { source: 2 },
526
+ languages: { TypeScript: 2 },
527
+ important_reasons: ['billing-or-payment'],
528
+ },
529
+ });
530
+ const prompt = buildModulePrompt(ctx);
531
+ assert.match(prompt.user, /Files: 2/);
532
+ });
533
+ test('buildCrossCuttingPrompt includes source card count', () => {
534
+ const ctx = makeContext({ pageName: 'Security-and-Secrets', pageTitle: 'Security and Secrets' });
535
+ const prompt = buildCrossCuttingPrompt(ctx);
536
+ assert.match(prompt.user, /Source cards \(1 files\)/);
537
+ });
538
+ test('buildPrompt formats structured source card surfaces without object stringification', () => {
539
+ const ctx = makeContext({
540
+ sourceCards: [
541
+ {
542
+ path: 'src/api/users.ts',
543
+ category: 'source',
544
+ language: 'TypeScript',
545
+ symbols: ['listUsers'],
546
+ routes: [{ kind: 'http-route', framework: 'express', methods: ['GET'], path: '/users', handler: 'listUsers' }],
547
+ models: [{ name: 'User', kind: 'model', framework: 'prisma' }],
548
+ migrations: [{ kind: 'migration-file', id: '001', name: 'create-users' }],
549
+ },
550
+ ],
551
+ });
552
+ const prompt = buildPrompt('module', ctx);
553
+ assert.doesNotMatch(prompt.user, /\[object Object\]/);
554
+ assert.match(prompt.user, /routes: GET \/users \(express, http-route, handler=listUsers\)/);
555
+ assert.match(prompt.user, /models: User \(model, prisma\)/);
556
+ assert.match(prompt.user, /migrations: 001 create-users \(migration-file\)/);
557
+ });
558
+ test('buildPrompt doc cards are included in user prompt', () => {
559
+ const ctx = makeContext({
560
+ docCards: [{ path: 'docs/security.md', status: 'stale', claims: ['Uses bcrypt'] }],
561
+ });
562
+ const prompt = buildPrompt('cross-cutting', ctx);
563
+ assert.match(prompt.user, /docs\/security\.md/);
564
+ assert.match(prompt.user, /stale/);
565
+ });
566
+ // ── End-to-end pipeline: buildRequest + MockLLMProvider ───────────────────
567
+ test('pipeline: buildRequest + MockLLMProvider produces valid content', async () => {
568
+ const provider = new MockLLMProvider();
569
+ const ctx = makeContext();
570
+ const req = buildRequest('module', ctx);
571
+ const resp = await provider.complete(req);
572
+ assert.equal(resp.provider, 'mock');
573
+ assert.match(resp.content, /^---/);
574
+ assert.match(resp.content, /# Auth/m);
575
+ assert.match(resp.content, /HUMAN_NOTES_START/);
576
+ });
577
+ // ── Architecture prompt ───────────────────────────────────────────────────
578
+ import { buildArchitecturePrompt } from '../src/prompts.js';
579
+ import { resolveArchitectureOverrides } from '../src/llm-provider.js';
580
+ test('buildArchitecturePrompt returns system + user prompts', () => {
581
+ const ctx = makeContext({ pageName: 'Architecture', pageTitle: 'Architecture' });
582
+ const prompt = buildArchitecturePrompt(ctx);
583
+ assert.ok(prompt.system.length > 0);
584
+ assert.ok(prompt.user.length > 0);
585
+ });
586
+ test('buildArchitecturePrompt system prompt includes architecture Mermaid diagram rules', () => {
587
+ const ctx = makeContext({ pageName: 'Architecture', pageTitle: 'Architecture' });
588
+ const prompt = buildArchitecturePrompt(ctx);
589
+ assert.match(prompt.system, /Mermaid/);
590
+ assert.match(prompt.system, /flowchart/);
591
+ assert.match(prompt.system, /Do not invent unsupported relationships/);
592
+ });
593
+ test('buildArchitecturePrompt user prompt includes required section headings', () => {
594
+ const ctx = makeContext({ pageName: 'Architecture', pageTitle: 'Architecture' });
595
+ const prompt = buildArchitecturePrompt(ctx);
596
+ assert.match(prompt.user, /Executive Architecture Summary/);
597
+ assert.match(prompt.user, /System and Repository Context/);
598
+ assert.match(prompt.user, /Major Modules and Responsibilities/);
599
+ assert.match(prompt.user, /Runtime, Data, and Control-Flow Relationships/);
600
+ assert.match(prompt.user, /Build, Test, Deployment, and Operational Surfaces/);
601
+ assert.match(prompt.user, /Cross-Cutting Concerns/);
602
+ assert.match(prompt.user, /Caveats and Open Questions/);
603
+ });
604
+ test('buildArchitecturePrompt user prompt requires architecture kind in frontmatter', () => {
605
+ const ctx = makeContext({ pageName: 'Architecture', pageTitle: 'Architecture' });
606
+ const prompt = buildArchitecturePrompt(ctx);
607
+ assert.match(prompt.user, /kind: "architecture"/);
608
+ });
609
+ test('buildArchitecturePrompt user prompt requires HUMAN_NOTES block', () => {
610
+ const ctx = makeContext({ pageName: 'Architecture', pageTitle: 'Architecture' });
611
+ const prompt = buildArchitecturePrompt(ctx);
612
+ assert.match(prompt.user, /HUMAN_NOTES_START/);
613
+ assert.match(prompt.user, /HUMAN_NOTES_END/);
614
+ });
615
+ test('buildArchitecturePrompt user prompt includes diagram guardrails', () => {
616
+ const ctx = makeContext({ pageName: 'Architecture', pageTitle: 'Architecture' });
617
+ const prompt = buildArchitecturePrompt(ctx);
618
+ assert.match(prompt.user, /caveats/i);
619
+ assert.match(prompt.user, /evidence/i);
620
+ });
621
+ test('buildPrompt routes architecture archetype to architecture prompt', () => {
622
+ const ctx = makeContext({ pageName: 'Architecture', pageTitle: 'Architecture' });
623
+ const archPrompt = buildPrompt('architecture', ctx);
624
+ const archDirectPrompt = buildArchitecturePrompt(ctx);
625
+ assert.equal(archPrompt.system, archDirectPrompt.system);
626
+ assert.equal(archPrompt.user, archDirectPrompt.user);
627
+ });
628
+ test('MockLLMProvider includes HUMAN_NOTES block for architecture archetype', async () => {
629
+ const provider = new MockLLMProvider();
630
+ const response = await provider.complete(makeRequest({ archetype: 'architecture' }));
631
+ assert.match(response.content, /kind: "architecture"/);
632
+ assert.match(response.content, /HUMAN_NOTES_START/);
633
+ assert.match(response.content, /HUMAN_NOTES_END/);
634
+ });
635
+ // ── resolveArchitectureOverrides ──────────────────────────────────────────
636
+ test('resolveArchitectureOverrides returns undefined when no overrides are set', () => {
637
+ const overrides = resolveArchitectureOverrides({}, {});
638
+ assert.equal(overrides.model, undefined);
639
+ assert.equal(overrides.maxOutputTokens, undefined);
640
+ });
641
+ test('resolveArchitectureOverrides reads model from config page_budgets', () => {
642
+ const overrides = resolveArchitectureOverrides({
643
+ page_budgets: { architecture: { model: 'gpt-4.1' } }
644
+ }, {});
645
+ assert.equal(overrides.model, 'gpt-4.1');
646
+ });
647
+ test('resolveArchitectureOverrides reads max_output_tokens from config page_budgets', () => {
648
+ const overrides = resolveArchitectureOverrides({
649
+ page_budgets: { architecture: { max_output_tokens: 12000 } }
650
+ }, {});
651
+ assert.equal(overrides.maxOutputTokens, 12000);
652
+ });
653
+ test('resolveArchitectureOverrides reads timeout and reasoning effort from config page_budgets', () => {
654
+ const overrides = resolveArchitectureOverrides({
655
+ page_budgets: { architecture: { timeout_ms: 180000, reasoning_effort: 'low' } }
656
+ }, {});
657
+ assert.equal(overrides.timeoutMs, 180000);
658
+ assert.equal(overrides.reasoningEffort, 'low');
659
+ });
660
+ test('resolveArchitectureOverrides rejects invalid max output tokens from config page_budgets', () => {
661
+ for (const maxOutputTokens of [-1, null]) {
662
+ assert.throws(() => resolveArchitectureOverrides({
663
+ page_budgets: { architecture: { max_output_tokens: maxOutputTokens } }
664
+ }, {}), (err) => {
665
+ assert.ok(err instanceof LLMProviderError);
666
+ assert.equal(err.code, 'INVALID_CONFIG');
667
+ return true;
668
+ });
669
+ }
670
+ });
671
+ test('resolveArchitectureOverrides reads model from LLMWIKI_LLM_ARCHITECTURE_MODEL env var', () => {
672
+ const overrides = resolveArchitectureOverrides({}, { LLMWIKI_LLM_ARCHITECTURE_MODEL: 'gpt-4.1' });
673
+ assert.equal(overrides.model, 'gpt-4.1');
674
+ });
675
+ test('resolveArchitectureOverrides reads max tokens from LLMWIKI_LLM_ARCHITECTURE_MAX_OUTPUT_TOKENS env var', () => {
676
+ const overrides = resolveArchitectureOverrides({}, { LLMWIKI_LLM_ARCHITECTURE_MAX_OUTPUT_TOKENS: '12000' });
677
+ assert.equal(overrides.maxOutputTokens, 12000);
678
+ });
679
+ test('resolveArchitectureOverrides reads timeout and reasoning effort from architecture env vars', () => {
680
+ const overrides = resolveArchitectureOverrides({}, {
681
+ LLMWIKI_LLM_ARCHITECTURE_TIMEOUT_MS: '180000',
682
+ LLMWIKI_LLM_ARCHITECTURE_REASONING_EFFORT: 'low'
683
+ });
684
+ assert.equal(overrides.timeoutMs, 180000);
685
+ assert.equal(overrides.reasoningEffort, 'low');
686
+ });
687
+ test('resolveArchitectureOverrides env var overrides config page_budgets', () => {
688
+ const overrides = resolveArchitectureOverrides({ page_budgets: { architecture: { model: 'gpt-4.1-mini', max_output_tokens: 4000 } } }, { LLMWIKI_LLM_ARCHITECTURE_MODEL: 'gpt-4.1', LLMWIKI_LLM_ARCHITECTURE_MAX_OUTPUT_TOKENS: '12000' });
689
+ assert.equal(overrides.model, 'gpt-4.1');
690
+ assert.equal(overrides.maxOutputTokens, 12000);
691
+ });
692
+ test('resolveArchitectureOverrides lets global env model override config page_budgets', () => {
693
+ const overrides = resolveArchitectureOverrides({ page_budgets: { architecture: { model: 'gpt-4.1' } } }, { LLMWIKI_LLM_MODEL: 'gpt-4.1-mini' });
694
+ assert.equal(overrides.model, undefined);
695
+ });
696
+ test('resolveArchitectureOverrides lets global env max output tokens override config page_budgets', () => {
697
+ const overrides = resolveArchitectureOverrides({ page_budgets: { architecture: { max_output_tokens: 12000 } } }, { LLMWIKI_LLM_MAX_OUTPUT_TOKENS: '8000' });
698
+ assert.equal(overrides.maxOutputTokens, undefined);
699
+ });
700
+ test('resolveArchitectureOverrides architecture env var overrides global env var', () => {
701
+ const overrides = resolveArchitectureOverrides({ page_budgets: { architecture: { model: 'configured-model', max_output_tokens: 4000, timeout_ms: 60000, reasoning_effort: 'medium' } } }, {
702
+ LLMWIKI_LLM_MODEL: 'global-model',
703
+ LLMWIKI_LLM_ARCHITECTURE_MODEL: 'architecture-model',
704
+ LLMWIKI_LLM_MAX_OUTPUT_TOKENS: '8000',
705
+ LLMWIKI_LLM_ARCHITECTURE_MAX_OUTPUT_TOKENS: '12000',
706
+ LLMWIKI_LLM_TIMEOUT_MS: '90000',
707
+ LLMWIKI_LLM_ARCHITECTURE_TIMEOUT_MS: '180000',
708
+ LLMWIKI_LLM_REASONING_EFFORT: 'medium',
709
+ LLMWIKI_LLM_ARCHITECTURE_REASONING_EFFORT: 'low'
710
+ });
711
+ assert.equal(overrides.model, 'architecture-model');
712
+ assert.equal(overrides.maxOutputTokens, 12000);
713
+ assert.equal(overrides.timeoutMs, 180000);
714
+ assert.equal(overrides.reasoningEffort, 'low');
715
+ });
716
+ test('createProviderFromResolvedConfig does not re-read global model env over resolved architecture model', () => {
717
+ const previousModel = process.env.LLMWIKI_LLM_MODEL;
718
+ process.env.LLMWIKI_LLM_MODEL = 'global-model';
719
+ try {
720
+ const provider = createProviderFromResolvedConfig({
721
+ provider: 'openai-compatible',
722
+ apiKey: 'test-key',
723
+ apiKeyEnv: 'LLMWIKI_LLM_API_KEY',
724
+ model: 'architecture-model',
725
+ baseUrl: 'https://example.test/v1',
726
+ systemPrompt: 'system',
727
+ temperature: 0.1,
728
+ maxOutputTokens: 12000,
729
+ timeoutMs: 1000,
730
+ reasoningEffort: undefined,
731
+ retries: 0,
732
+ validationRetries: 0,
733
+ });
734
+ assert.ok(provider instanceof OpenAICompatibleProvider);
735
+ assert.equal(provider.model, 'architecture-model');
736
+ }
737
+ finally {
738
+ if (previousModel === undefined) {
739
+ delete process.env.LLMWIKI_LLM_MODEL;
740
+ }
741
+ else {
742
+ process.env.LLMWIKI_LLM_MODEL = previousModel;
743
+ }
744
+ }
745
+ });
746
+ test('resolveArchitectureOverrides reads page_budgets from nested llm config', () => {
747
+ const overrides = resolveArchitectureOverrides({
748
+ mode: 'llm',
749
+ llm: { page_budgets: { architecture: { model: 'gpt-4.1', max_output_tokens: 12000, timeout_ms: 180000, reasoning_effort: 'low' } } }
750
+ }, {});
751
+ assert.equal(overrides.model, 'gpt-4.1');
752
+ assert.equal(overrides.maxOutputTokens, 12000);
753
+ assert.equal(overrides.timeoutMs, 180000);
754
+ assert.equal(overrides.reasoningEffort, 'low');
755
+ });
756
+ test('resolveArchitectureOverrides treats blank env var as unset', () => {
757
+ const overrides = resolveArchitectureOverrides({ page_budgets: { architecture: { model: 'gpt-4.1' } } }, { LLMWIKI_LLM_ARCHITECTURE_MODEL: ' ' });
758
+ // Blank env var is treated as unset, so config value is used.
759
+ assert.equal(overrides.model, 'gpt-4.1');
760
+ });
761
+ test('resolveArchitectureOverrides rejects invalid max output tokens env var', () => {
762
+ assert.throws(() => resolveArchitectureOverrides({}, { LLMWIKI_LLM_ARCHITECTURE_MAX_OUTPUT_TOKENS: 'not-a-number' }), (err) => {
763
+ assert.ok(err instanceof LLMProviderError);
764
+ assert.equal(err.code, 'INVALID_CONFIG');
765
+ return true;
766
+ });
767
+ });
768
+ test('resolveArchitectureOverrides rejects invalid timeout and reasoning effort env vars with field context', () => {
769
+ assert.throws(() => resolveArchitectureOverrides({}, { LLMWIKI_LLM_ARCHITECTURE_TIMEOUT_MS: 'not-a-number' }), (err) => {
770
+ assert.ok(err instanceof LLMProviderError);
771
+ assert.equal(err.code, 'INVALID_CONFIG');
772
+ assert.match(err.message, /architecture timeoutMs/);
773
+ return true;
774
+ });
775
+ assert.throws(() => resolveArchitectureOverrides({}, { LLMWIKI_LLM_ARCHITECTURE_REASONING_EFFORT: 'turbo' }), (err) => {
776
+ assert.ok(err instanceof LLMProviderError);
777
+ assert.equal(err.code, 'INVALID_CONFIG');
778
+ assert.match(err.message, /architecture reasoningEffort/);
779
+ assert.match(err.message, /"turbo"/);
780
+ return true;
781
+ });
782
+ });
783
+ //# sourceMappingURL=llm-provider.test.js.map