mdcontext 0.1.0 → 0.2.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 (251) hide show
  1. package/.changeset/config.json +9 -9
  2. package/.claude/settings.local.json +25 -0
  3. package/.github/workflows/claude-code-review.yml +44 -0
  4. package/.github/workflows/claude.yml +85 -0
  5. package/CONTRIBUTING.md +186 -0
  6. package/NOTES/NOTES +44 -0
  7. package/README.md +206 -3
  8. package/biome.json +1 -1
  9. package/dist/chunk-23UPXDNL.js +3044 -0
  10. package/dist/chunk-2W7MO2DL.js +1366 -0
  11. package/dist/chunk-3NUAZGMA.js +1689 -0
  12. package/dist/chunk-7TOWB2XB.js +366 -0
  13. package/dist/chunk-7XOTOADQ.js +3065 -0
  14. package/dist/chunk-AH2PDM2K.js +3042 -0
  15. package/dist/chunk-BNXWSZ63.js +3742 -0
  16. package/dist/chunk-BTL5DJVU.js +3222 -0
  17. package/dist/chunk-HDHYG7E4.js +104 -0
  18. package/dist/chunk-HLR4KZBP.js +3234 -0
  19. package/dist/chunk-IP3FRFEB.js +1045 -0
  20. package/dist/chunk-KHU56VDO.js +3042 -0
  21. package/dist/chunk-KRYIFLQR.js +85 -89
  22. package/dist/chunk-LBSDNLEM.js +287 -0
  23. package/dist/chunk-MNTQ7HCP.js +2643 -0
  24. package/dist/chunk-MUJELQQ6.js +1387 -0
  25. package/dist/chunk-MXJGMSLV.js +2199 -0
  26. package/dist/chunk-N6QJGC3Z.js +2636 -0
  27. package/dist/chunk-OBELGBPM.js +1713 -0
  28. package/dist/chunk-OT7R5XTA.js +3192 -0
  29. package/dist/chunk-P7X4RA2T.js +106 -0
  30. package/dist/chunk-PIDUQNC2.js +3185 -0
  31. package/dist/chunk-POGCDIH4.js +3187 -0
  32. package/dist/chunk-PSIEOQGZ.js +3043 -0
  33. package/dist/chunk-PVRT3IHA.js +3238 -0
  34. package/dist/chunk-QNN4TT23.js +1430 -0
  35. package/dist/chunk-RE3R45RJ.js +3042 -0
  36. package/dist/chunk-S7E6TFX6.js +718 -657
  37. package/dist/chunk-SG6GLU4U.js +1378 -0
  38. package/dist/chunk-SJCDV2ST.js +274 -0
  39. package/dist/chunk-SYE5XLF3.js +104 -0
  40. package/dist/chunk-T5VLYBZD.js +103 -0
  41. package/dist/chunk-TOQB7VWU.js +3238 -0
  42. package/dist/chunk-VFNMZ4ZQ.js +3228 -0
  43. package/dist/chunk-VVTGZNBT.js +1533 -1423
  44. package/dist/chunk-W7Q4RFEV.js +104 -0
  45. package/dist/chunk-XTYYVRLO.js +3190 -0
  46. package/dist/chunk-Y6MDYVJD.js +3063 -0
  47. package/dist/cli/main.js +4072 -629
  48. package/dist/index.d.ts +420 -33
  49. package/dist/index.js +8 -15
  50. package/dist/mcp/server.js +103 -7
  51. package/dist/schema-BAWSG7KY.js +22 -0
  52. package/dist/schema-E3QUPL26.js +20 -0
  53. package/dist/schema-EHL7WUT6.js +20 -0
  54. package/docs/019-USAGE.md +44 -5
  55. package/docs/020-current-implementation.md +8 -8
  56. package/docs/021-DOGFOODING-FINDINGS.md +1 -1
  57. package/docs/CONFIG.md +1123 -0
  58. package/docs/ERRORS.md +383 -0
  59. package/docs/summarization.md +320 -0
  60. package/justfile +40 -0
  61. package/package.json +39 -33
  62. package/research/INDEX.md +315 -0
  63. package/research/code-review/README.md +90 -0
  64. package/research/code-review/cli-error-handling-review.md +979 -0
  65. package/research/code-review/code-review-validation-report.md +464 -0
  66. package/research/code-review/main-ts-review.md +1128 -0
  67. package/research/config-docs/SUMMARY.md +357 -0
  68. package/research/config-docs/TEST-RESULTS.md +776 -0
  69. package/research/config-docs/TODO.md +542 -0
  70. package/research/config-docs/analysis.md +744 -0
  71. package/research/config-docs/fix-validation.md +502 -0
  72. package/research/config-docs/help-audit.md +264 -0
  73. package/research/config-docs/help-system-analysis.md +890 -0
  74. package/research/frontmatter/COMMENTS-ARE-SKIPPED.md +149 -0
  75. package/research/frontmatter/LLM-CODE-NAVIGATION.md +276 -0
  76. package/research/issue-review.md +603 -0
  77. package/research/llm-summarization/agent-cli-tools-2026.md +1082 -0
  78. package/research/llm-summarization/alternative-providers-2026.md +1428 -0
  79. package/research/llm-summarization/anthropic-2026.md +367 -0
  80. package/research/llm-summarization/claude-cli-integration.md +1706 -0
  81. package/research/llm-summarization/cli-integration-patterns.md +3155 -0
  82. package/research/llm-summarization/openai-2026.md +473 -0
  83. package/research/llm-summarization/openai-compatible-providers-2026.md +1022 -0
  84. package/research/llm-summarization/opencode-cli-integration.md +1552 -0
  85. package/research/llm-summarization/prompt-engineering-2026.md +1426 -0
  86. package/research/llm-summarization/prototype-results.md +56 -0
  87. package/research/llm-summarization/provider-switching-patterns-2026.md +2153 -0
  88. package/research/llm-summarization/typescript-llm-libraries-2026.md +2436 -0
  89. package/research/mdcontext-pudding/00-EXECUTIVE-SUMMARY.md +282 -0
  90. package/research/mdcontext-pudding/01-index-embed.md +956 -0
  91. package/research/mdcontext-pudding/02-search-COMMANDS.md +142 -0
  92. package/research/mdcontext-pudding/02-search-SUMMARY.md +146 -0
  93. package/research/mdcontext-pudding/02-search.md +970 -0
  94. package/research/mdcontext-pudding/03-context.md +779 -0
  95. package/research/mdcontext-pudding/04-navigation-and-analytics.md +803 -0
  96. package/research/mdcontext-pudding/04-tree.md +704 -0
  97. package/research/mdcontext-pudding/05-config.md +1038 -0
  98. package/research/mdcontext-pudding/06-links-summary.txt +87 -0
  99. package/research/mdcontext-pudding/06-links.md +679 -0
  100. package/research/mdcontext-pudding/07-stats.md +693 -0
  101. package/research/mdcontext-pudding/BUG-FIX-PLAN.md +388 -0
  102. package/research/mdcontext-pudding/P0-BUG-VALIDATION.md +167 -0
  103. package/research/mdcontext-pudding/README.md +168 -0
  104. package/research/mdcontext-pudding/TESTING-SUMMARY.md +128 -0
  105. package/research/research-quality-review.md +834 -0
  106. package/research/semantic-search/embedding-text-analysis.md +156 -0
  107. package/research/semantic-search/multi-word-failure-reproduction.md +171 -0
  108. package/research/semantic-search/query-processing-analysis.md +207 -0
  109. package/research/semantic-search/root-cause-and-solution.md +114 -0
  110. package/research/semantic-search/threshold-validation-report.md +69 -0
  111. package/research/semantic-search/vector-search-analysis.md +63 -0
  112. package/research/test-path-issues.md +276 -0
  113. package/review/ALP-76/1-error-type-design.md +962 -0
  114. package/review/ALP-76/2-error-handling-patterns.md +906 -0
  115. package/review/ALP-76/3-error-presentation.md +624 -0
  116. package/review/ALP-76/4-test-coverage.md +625 -0
  117. package/review/ALP-76/5-migration-completeness.md +440 -0
  118. package/review/ALP-76/6-effect-best-practices.md +755 -0
  119. package/scripts/apply-branch-protection.sh +47 -0
  120. package/scripts/branch-protection-templates.json +79 -0
  121. package/scripts/prototype-summarization.ts +346 -0
  122. package/scripts/rebuild-hnswlib.js +32 -37
  123. package/scripts/setup-branch-protection.sh +64 -0
  124. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/active-provider.json +7 -0
  125. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/bm25.json +541 -0
  126. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/bm25.meta.json +5 -0
  127. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/config.json +8 -0
  128. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/embeddings/openai_text-embedding-3-small_512/vectors.bin +0 -0
  129. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/embeddings/openai_text-embedding-3-small_512/vectors.meta.bin +0 -0
  130. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/indexes/documents.json +60 -0
  131. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/indexes/links.json +13 -0
  132. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/indexes/sections.json +1197 -0
  133. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/configuration-management.md +99 -0
  134. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/distributed-systems.md +92 -0
  135. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/error-handling.md +78 -0
  136. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/failure-automation.md +55 -0
  137. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/job-context.md +69 -0
  138. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/process-orchestration.md +99 -0
  139. package/src/cli/argv-preprocessor.test.ts +2 -2
  140. package/src/cli/cli.test.ts +230 -33
  141. package/src/cli/commands/config-cmd.ts +642 -0
  142. package/src/cli/commands/context.ts +97 -9
  143. package/src/cli/commands/duplicates.ts +122 -0
  144. package/src/cli/commands/embeddings.ts +529 -0
  145. package/src/cli/commands/index-cmd.ts +210 -30
  146. package/src/cli/commands/index.ts +3 -0
  147. package/src/cli/commands/search.ts +894 -64
  148. package/src/cli/commands/stats.ts +3 -0
  149. package/src/cli/commands/tree.ts +26 -5
  150. package/src/cli/config-layer.ts +176 -0
  151. package/src/cli/error-handler.test.ts +235 -0
  152. package/src/cli/error-handler.ts +655 -0
  153. package/src/cli/flag-schemas.ts +66 -0
  154. package/src/cli/help.ts +209 -7
  155. package/src/cli/main.ts +348 -58
  156. package/src/cli/options.ts +10 -0
  157. package/src/cli/shared-error-handling.ts +199 -0
  158. package/src/cli/utils.ts +150 -17
  159. package/src/config/file-provider.test.ts +320 -0
  160. package/src/config/file-provider.ts +273 -0
  161. package/src/config/index.ts +72 -0
  162. package/src/config/integration.test.ts +667 -0
  163. package/src/config/precedence.test.ts +277 -0
  164. package/src/config/precedence.ts +451 -0
  165. package/src/config/schema.test.ts +414 -0
  166. package/src/config/schema.ts +603 -0
  167. package/src/config/service.test.ts +320 -0
  168. package/src/config/service.ts +243 -0
  169. package/src/config/testing.test.ts +264 -0
  170. package/src/config/testing.ts +110 -0
  171. package/src/core/types.ts +6 -33
  172. package/src/duplicates/detector.test.ts +183 -0
  173. package/src/duplicates/detector.ts +414 -0
  174. package/src/duplicates/index.ts +18 -0
  175. package/src/embeddings/embedding-namespace.test.ts +300 -0
  176. package/src/embeddings/embedding-namespace.ts +947 -0
  177. package/src/embeddings/heading-boost.test.ts +222 -0
  178. package/src/embeddings/hnsw-build-options.test.ts +198 -0
  179. package/src/embeddings/hyde.test.ts +272 -0
  180. package/src/embeddings/hyde.ts +264 -0
  181. package/src/embeddings/index.ts +2 -0
  182. package/src/embeddings/openai-provider.ts +332 -83
  183. package/src/embeddings/pricing.json +22 -0
  184. package/src/embeddings/provider-constants.ts +204 -0
  185. package/src/embeddings/provider-errors.test.ts +967 -0
  186. package/src/embeddings/provider-errors.ts +565 -0
  187. package/src/embeddings/provider-factory.test.ts +240 -0
  188. package/src/embeddings/provider-factory.ts +225 -0
  189. package/src/embeddings/provider-integration.test.ts +788 -0
  190. package/src/embeddings/query-preprocessing.test.ts +187 -0
  191. package/src/embeddings/semantic-search-threshold.test.ts +508 -0
  192. package/src/embeddings/semantic-search.ts +780 -93
  193. package/src/embeddings/types.ts +293 -16
  194. package/src/embeddings/vector-store.ts +486 -77
  195. package/src/embeddings/voyage-provider.ts +313 -0
  196. package/src/errors/errors.test.ts +845 -0
  197. package/src/errors/index.ts +533 -0
  198. package/src/index/ignore-patterns.test.ts +354 -0
  199. package/src/index/ignore-patterns.ts +305 -0
  200. package/src/index/indexer.ts +286 -48
  201. package/src/index/storage.ts +94 -30
  202. package/src/index/types.ts +40 -2
  203. package/src/index/watcher.ts +67 -9
  204. package/src/index.ts +22 -0
  205. package/src/integration/search-keyword.test.ts +678 -0
  206. package/src/mcp/server.ts +135 -6
  207. package/src/parser/parser.ts +18 -19
  208. package/src/parser/section-filter.test.ts +277 -0
  209. package/src/parser/section-filter.ts +125 -3
  210. package/src/search/__tests__/hybrid-search.test.ts +650 -0
  211. package/src/search/bm25-store.ts +366 -0
  212. package/src/search/cross-encoder.test.ts +253 -0
  213. package/src/search/cross-encoder.ts +406 -0
  214. package/src/search/fuzzy-search.test.ts +419 -0
  215. package/src/search/fuzzy-search.ts +273 -0
  216. package/src/search/hybrid-search.ts +448 -0
  217. package/src/search/path-matcher.test.ts +276 -0
  218. package/src/search/path-matcher.ts +33 -0
  219. package/src/search/searcher.test.ts +99 -1
  220. package/src/search/searcher.ts +189 -67
  221. package/src/search/wink-bm25.d.ts +30 -0
  222. package/src/summarization/cli-providers/claude.ts +202 -0
  223. package/src/summarization/cli-providers/detection.test.ts +273 -0
  224. package/src/summarization/cli-providers/detection.ts +118 -0
  225. package/src/summarization/cli-providers/index.ts +8 -0
  226. package/src/summarization/cost.test.ts +139 -0
  227. package/src/summarization/cost.ts +102 -0
  228. package/src/summarization/error-handler.test.ts +127 -0
  229. package/src/summarization/error-handler.ts +111 -0
  230. package/src/summarization/index.ts +102 -0
  231. package/src/summarization/pipeline.test.ts +498 -0
  232. package/src/summarization/pipeline.ts +231 -0
  233. package/src/summarization/prompts.test.ts +269 -0
  234. package/src/summarization/prompts.ts +133 -0
  235. package/src/summarization/provider-factory.test.ts +396 -0
  236. package/src/summarization/provider-factory.ts +178 -0
  237. package/src/summarization/types.ts +184 -0
  238. package/src/summarize/summarizer.ts +104 -35
  239. package/src/types/huggingface-transformers.d.ts +66 -0
  240. package/tests/fixtures/cli/.mdcontext/active-provider.json +7 -0
  241. package/tests/fixtures/cli/.mdcontext/embeddings/openai_text-embedding-3-small_512/vectors.bin +0 -0
  242. package/tests/fixtures/cli/.mdcontext/embeddings/openai_text-embedding-3-small_512/vectors.meta.bin +0 -0
  243. package/tests/fixtures/cli/.mdcontext/indexes/documents.json +4 -4
  244. package/tests/fixtures/cli/.mdcontext/indexes/sections.json +14 -0
  245. package/tests/integration/embed-index.test.ts +712 -0
  246. package/tests/integration/search-context.test.ts +469 -0
  247. package/tests/integration/search-semantic.test.ts +522 -0
  248. package/vitest.config.ts +1 -6
  249. package/AGENTS.md +0 -46
  250. package/tests/fixtures/cli/.mdcontext/vectors.bin +0 -0
  251. package/tests/fixtures/cli/.mdcontext/vectors.meta.json +0 -1264
@@ -2,105 +2,294 @@
2
2
  * OpenAI embedding provider
3
3
  */
4
4
 
5
- import { Console, Effect } from 'effect'
5
+ import { Effect, Redacted } from 'effect'
6
6
  import OpenAI from 'openai'
7
- import type { EmbeddingProvider, EmbeddingResult } from './types.js'
7
+ import {
8
+ ApiKeyInvalidError,
9
+ ApiKeyMissingError,
10
+ EmbeddingError,
11
+ } from '../errors/index.js'
12
+ import pricingData from './pricing.json' with { type: 'json' }
13
+ import {
14
+ getRecommendedDimensions,
15
+ inferProviderFromUrl,
16
+ supportsMatryoshka,
17
+ validateModelDimensions,
18
+ } from './provider-constants.js'
19
+ import type {
20
+ EmbeddingProvider,
21
+ EmbeddingResult,
22
+ EmbedOptions,
23
+ } from './types.js'
8
24
 
9
25
  // ============================================================================
10
26
  // Cost Constants
11
27
  // ============================================================================
12
28
 
13
- // Prices per 1M tokens (as of 2024)
14
- const PRICING: Record<string, number> = {
15
- 'text-embedding-3-small': 0.02,
16
- 'text-embedding-3-large': 0.13,
17
- 'text-embedding-ada-002': 0.1,
29
+ /**
30
+ * OpenAI embedding model pricing data.
31
+ *
32
+ * Prices per 1M tokens. Loaded from pricing.json for easy updates.
33
+ *
34
+ * Maintenance: Update src/embeddings/pricing.json quarterly from
35
+ * https://platform.openai.com/docs/pricing
36
+ */
37
+ export const PRICING_DATA = {
38
+ /** Last update date in YYYY-MM format */
39
+ lastUpdated: pricingData.lastUpdated,
40
+ /** Source URL for verification */
41
+ source: pricingData.sources.openai,
42
+ /** Prices per 1M tokens by model */
43
+ prices: pricingData.openai as Record<string, number>,
18
44
  }
19
45
 
20
- // ============================================================================
21
- // Error Classes
22
- // ============================================================================
46
+ /**
47
+ * Check if pricing data is stale (>90 days old).
48
+ *
49
+ * @returns Warning message if stale, null otherwise
50
+ */
51
+ export const checkPricingFreshness = (): string | null => {
52
+ const [year, month] = PRICING_DATA.lastUpdated.split('-').map(Number)
53
+ if (!year || !month) return null
23
54
 
24
- export class MissingApiKeyError extends Error {
25
- constructor() {
26
- super('OPENAI_API_KEY not set')
27
- this.name = 'MissingApiKeyError'
28
- }
29
- }
55
+ const lastUpdated = new Date(year, month - 1, 1) // Month is 0-indexed
56
+ const now = new Date()
57
+ const daysSince = Math.floor(
58
+ (now.getTime() - lastUpdated.getTime()) / (1000 * 60 * 60 * 24),
59
+ )
30
60
 
31
- export class InvalidApiKeyError extends Error {
32
- constructor(message?: string) {
33
- super(message ?? 'Invalid OPENAI_API_KEY')
34
- this.name = 'InvalidApiKeyError'
61
+ if (daysSince > 90) {
62
+ return `Pricing data is ${daysSince} days old. May not reflect current rates.`
35
63
  }
64
+ return null
36
65
  }
37
66
 
67
+ /**
68
+ * Get the pricing date for display.
69
+ *
70
+ * @returns Formatted string like "2024-09"
71
+ */
72
+ export const getPricingDate = (): string => PRICING_DATA.lastUpdated
73
+
38
74
  // ============================================================================
39
75
  // OpenAI Provider
40
76
  // ============================================================================
41
77
 
42
78
  export interface OpenAIProviderOptions {
43
- readonly apiKey?: string | undefined
79
+ /**
80
+ * API key for the provider. Can be a plain string or a Redacted<string>.
81
+ * If not provided, falls back to environment variables:
82
+ * - OPENROUTER_API_KEY (if using OpenRouter)
83
+ * - OPENAI_API_KEY (default)
84
+ */
85
+ readonly apiKey?: string | Redacted.Redacted<string> | undefined
44
86
  readonly model?: string | undefined
45
87
  readonly batchSize?: number | undefined
88
+ readonly baseURL?: string | undefined
89
+ /**
90
+ * Number of embedding dimensions. If not specified, uses recommended
91
+ * dimensions for the model (512 for Matryoshka models, native for others).
92
+ */
93
+ readonly dimensions?: number | undefined
94
+ /**
95
+ * Provider name for error context (e.g., 'ollama', 'lm-studio')
96
+ * Defaults to 'openai' if baseURL is not set
97
+ */
98
+ readonly providerName?: string | undefined
99
+ /**
100
+ * Request timeout in milliseconds.
101
+ * Default: 30000 (30 seconds)
102
+ */
103
+ readonly timeout?: number | undefined
46
104
  }
47
105
 
48
106
  export class OpenAIProvider implements EmbeddingProvider {
49
107
  readonly name: string
50
108
  readonly dimensions: number
109
+ /** Provider name for error context */
110
+ readonly providerName: string
111
+ /** Model name */
112
+ readonly model: string
113
+ /** Base URL for API requests */
114
+ readonly baseURL: string | undefined
51
115
 
52
116
  private readonly client: OpenAI
53
- private readonly model: string
54
117
  private readonly batchSize: number
55
118
 
56
- constructor(options: OpenAIProviderOptions = {}) {
57
- const apiKey = options.apiKey ?? process.env.OPENAI_API_KEY
58
- if (!apiKey) {
59
- throw new MissingApiKeyError()
60
- }
61
-
62
- this.client = new OpenAI({ apiKey })
119
+ private constructor(
120
+ apiKey: Redacted.Redacted<string>,
121
+ options: OpenAIProviderOptions = {},
122
+ ) {
123
+ this.baseURL = options.baseURL
124
+ this.client = new OpenAI({
125
+ apiKey: Redacted.value(apiKey),
126
+ baseURL: options.baseURL,
127
+ timeout: options.timeout ?? 30000,
128
+ maxRetries: 2,
129
+ })
63
130
  this.model = options.model ?? 'text-embedding-3-small'
64
131
  this.batchSize = options.batchSize ?? 100
65
- this.name = `openai:${this.model}`
66
- this.dimensions = 512
132
+ this.providerName =
133
+ options.providerName ?? this.inferProviderName(options.baseURL)
134
+ this.name = `${this.providerName}:${this.model}`
135
+
136
+ const recommendedDims = getRecommendedDimensions(this.model)
137
+ this.dimensions = options.dimensions ?? recommendedDims ?? 512
138
+ }
139
+
140
+ /**
141
+ * Infer the provider name from the baseURL.
142
+ * Delegates to centralized inferProviderFromUrl for single source of truth.
143
+ */
144
+ private inferProviderName(baseURL: string | undefined): string {
145
+ return inferProviderFromUrl(baseURL)
146
+ }
147
+
148
+ /**
149
+ * Create an OpenAI provider instance.
150
+ * Returns an Effect that fails with ApiKeyMissingError if no API key is available.
151
+ *
152
+ * API keys are handled securely using Effect's Redacted type to prevent
153
+ * accidental logging of sensitive values.
154
+ */
155
+ static create(
156
+ options: OpenAIProviderOptions = {},
157
+ ): Effect.Effect<OpenAIProvider, ApiKeyMissingError> {
158
+ // For OpenRouter provider, check OPENROUTER_API_KEY first, then fall back to OPENAI_API_KEY
159
+ const isOpenRouter =
160
+ options.baseURL?.includes('openrouter') ||
161
+ options.providerName === 'openrouter'
162
+
163
+ // Normalize API key to Redacted<string>
164
+ // If apiKey is already Redacted, use it; if string, wrap it; if undefined, check env vars
165
+ const resolveApiKey = ():
166
+ | Redacted.Redacted<string>
167
+ | string
168
+ | undefined => {
169
+ if (options.apiKey !== undefined) {
170
+ return options.apiKey
171
+ }
172
+ return (
173
+ (isOpenRouter ? process.env.OPENROUTER_API_KEY : undefined) ??
174
+ process.env.OPENAI_API_KEY
175
+ )
176
+ }
177
+
178
+ const rawApiKey = resolveApiKey()
179
+ if (!rawApiKey) {
180
+ return Effect.fail(
181
+ new ApiKeyMissingError({
182
+ provider: isOpenRouter ? 'OpenRouter' : 'OpenAI',
183
+ envVar: isOpenRouter ? 'OPENROUTER_API_KEY' : 'OPENAI_API_KEY',
184
+ }),
185
+ )
186
+ }
187
+
188
+ // Wrap in Redacted if it's a plain string
189
+ const redactedApiKey = Redacted.isRedacted(rawApiKey)
190
+ ? rawApiKey
191
+ : Redacted.make(rawApiKey)
192
+
193
+ // Check key format for warnings (need to access value temporarily)
194
+ const apiKeyValue = Redacted.value(redactedApiKey)
195
+ const shouldWarnOpenRouter =
196
+ isOpenRouter &&
197
+ apiKeyValue.startsWith('sk-') &&
198
+ !apiKeyValue.startsWith('sk-or-')
199
+
200
+ // Validate dimensions if explicitly set
201
+ const model = options.model ?? 'text-embedding-3-small'
202
+ const dimensionValidation = options.dimensions
203
+ ? validateModelDimensions(model, options.dimensions)
204
+ : { isValid: true }
205
+
206
+ return Effect.succeed(new OpenAIProvider(redactedApiKey, options)).pipe(
207
+ shouldWarnOpenRouter
208
+ ? Effect.tap(() =>
209
+ Effect.logWarning(
210
+ '⚠️ Using OpenAI key format with OpenRouter. Consider setting OPENROUTER_API_KEY with a key starting with "sk-or-"',
211
+ ),
212
+ )
213
+ : (self) => self,
214
+ // Warn about invalid dimension configuration
215
+ dimensionValidation.warning
216
+ ? Effect.tap(() =>
217
+ Effect.logWarning(`⚠️ ${dimensionValidation.warning}`),
218
+ )
219
+ : (self) => self,
220
+ )
67
221
  }
68
222
 
69
- async embed(texts: string[]): Promise<EmbeddingResult> {
223
+ async embed(
224
+ texts: string[],
225
+ options?: EmbedOptions,
226
+ ): Promise<EmbeddingResult> {
70
227
  if (texts.length === 0) {
71
228
  return { embeddings: [], tokensUsed: 0, cost: 0 }
72
229
  }
73
230
 
74
231
  const allEmbeddings: number[][] = []
75
232
  let totalTokens = 0
233
+ const totalBatches = Math.ceil(texts.length / this.batchSize)
76
234
 
77
235
  try {
78
236
  // Process in batches
79
237
  for (let i = 0; i < texts.length; i += this.batchSize) {
80
238
  const batch = texts.slice(i, i + this.batchSize)
239
+ const batchIndex = Math.floor(i / this.batchSize)
81
240
 
82
- const response = await this.client.embeddings.create({
241
+ // Only pass dimensions parameter for models that support it (Matryoshka)
242
+ // Non-Matryoshka models will use their native dimensions automatically
243
+ const embedParams: OpenAI.Embeddings.EmbeddingCreateParams = {
83
244
  model: this.model,
84
245
  input: batch,
85
- dimensions: 512, // Ensure consistent dimensions
86
- })
246
+ }
247
+
248
+ // Only add dimensions parameter for Matryoshka-compatible models
249
+ if (supportsMatryoshka(this.model)) {
250
+ embedParams.dimensions = this.dimensions
251
+ }
252
+
253
+ const response = await this.client.embeddings.create(embedParams)
87
254
 
88
255
  for (const item of response.data) {
89
256
  allEmbeddings.push(item.embedding)
90
257
  }
91
258
 
92
259
  totalTokens += response.usage?.total_tokens ?? 0
260
+
261
+ // Report batch progress
262
+ if (options?.onBatchProgress) {
263
+ options.onBatchProgress({
264
+ batchIndex: batchIndex + 1,
265
+ totalBatches,
266
+ processedTexts: Math.min(i + this.batchSize, texts.length),
267
+ totalTexts: texts.length,
268
+ })
269
+ }
93
270
  }
94
271
  } catch (error) {
95
272
  // Check for authentication errors (401 Unauthorized, invalid API key)
96
273
  if (error instanceof OpenAI.AuthenticationError) {
97
- throw new InvalidApiKeyError(error.message)
274
+ throw new ApiKeyInvalidError({
275
+ provider: this.providerName,
276
+ details: error.message,
277
+ })
98
278
  }
99
- throw error
279
+ // Wrap error with provider context for better error messages
280
+ throw new EmbeddingError({
281
+ reason: this.classifyError(error),
282
+ message: error instanceof Error ? error.message : String(error),
283
+ provider: this.providerName,
284
+ cause: error,
285
+ })
100
286
  }
101
287
 
102
- // Calculate cost
103
- const pricePerMillion = PRICING[this.model] ?? 0.02
288
+ // Calculate cost (only for paid providers)
289
+ const pricePerMillion =
290
+ this.providerName === 'openai' || this.providerName === 'openrouter'
291
+ ? (PRICING_DATA.prices[this.model] ?? 0.02)
292
+ : 0 // Local providers are free
104
293
  const cost = (totalTokens / 1_000_000) * pricePerMillion
105
294
 
106
295
  return {
@@ -109,57 +298,117 @@ export class OpenAIProvider implements EmbeddingProvider {
109
298
  cost,
110
299
  }
111
300
  }
301
+
302
+ /**
303
+ * Classify an error into a known category for better error handling.
304
+ * Uses OpenAI SDK error types where available, falls back to string matching
305
+ * for non-OpenAI providers (Ollama, LM Studio, OpenRouter).
306
+ */
307
+ private classifyError(
308
+ error: unknown,
309
+ ): 'RateLimit' | 'QuotaExceeded' | 'Network' | 'ModelError' | 'Unknown' {
310
+ // Use OpenAI SDK error types when available
311
+ if (error instanceof OpenAI.RateLimitError) {
312
+ return 'RateLimit'
313
+ }
314
+ if (error instanceof OpenAI.BadRequestError) {
315
+ const msg = error.message.toLowerCase()
316
+ if (msg.includes('model')) return 'ModelError'
317
+ }
318
+ if (error instanceof OpenAI.APIConnectionError) {
319
+ return 'Network'
320
+ }
321
+
322
+ // Fallback to string matching for non-SDK errors (local providers, etc.)
323
+ if (!(error instanceof Error)) return 'Unknown'
324
+ const msg = error.message.toLowerCase()
325
+
326
+ // Rate limiting
327
+ if (
328
+ msg.includes('429') ||
329
+ msg.includes('rate limit') ||
330
+ msg.includes('too many requests')
331
+ ) {
332
+ return 'RateLimit'
333
+ }
334
+
335
+ // Quota/billing issues
336
+ if (
337
+ msg.includes('quota') ||
338
+ msg.includes('insufficient') ||
339
+ msg.includes('billing')
340
+ ) {
341
+ return 'QuotaExceeded'
342
+ }
343
+
344
+ // Network issues
345
+ if (
346
+ msg.includes('econnrefused') ||
347
+ msg.includes('timeout') ||
348
+ msg.includes('network') ||
349
+ msg.includes('enotfound') ||
350
+ msg.includes('connection')
351
+ ) {
352
+ return 'Network'
353
+ }
354
+
355
+ // Model issues
356
+ if (
357
+ msg.includes('model') &&
358
+ (msg.includes('not found') ||
359
+ msg.includes('not exist') ||
360
+ msg.includes('invalid'))
361
+ ) {
362
+ return 'ModelError'
363
+ }
364
+
365
+ return 'Unknown'
366
+ }
112
367
  }
113
368
 
114
369
  // ============================================================================
115
- // Factory
370
+ // Factory Functions
116
371
  // ============================================================================
117
372
 
373
+ /**
374
+ * Create an OpenAI embedding provider.
375
+ * Returns an Effect that fails with ApiKeyMissingError if no API key is available.
376
+ *
377
+ * Usage:
378
+ * ```typescript
379
+ * const provider = yield* createOpenAIProvider()
380
+ * const result = yield* Effect.tryPromise(() => provider.embed(texts))
381
+ * ```
382
+ */
118
383
  export const createOpenAIProvider = (
119
384
  options?: OpenAIProviderOptions,
120
- ): EmbeddingProvider => new OpenAIProvider(options)
121
-
122
- // ============================================================================
123
- // Error Handler Utility
124
- // ============================================================================
385
+ ): Effect.Effect<EmbeddingProvider, ApiKeyMissingError> =>
386
+ OpenAIProvider.create(options)
125
387
 
126
388
  /**
127
- * Catches OpenAI API key errors and displays helpful messages.
128
- * Use with Effect.pipe after operations that may throw MissingApiKeyError or InvalidApiKeyError.
389
+ * Wrap an embedding operation to catch InvalidApiKeyError thrown during embed().
390
+ * Use this when calling provider.embed() to convert thrown errors to Effect failures.
391
+ *
392
+ * Usage:
393
+ * ```typescript
394
+ * const result = yield* wrapEmbedding(provider.embed(texts))
395
+ * ```
129
396
  */
130
- export const handleApiKeyError = <A, E>(
131
- effect: Effect.Effect<A, E | MissingApiKeyError | InvalidApiKeyError>,
132
- ): Effect.Effect<A, E | Error> =>
133
- effect.pipe(
134
- Effect.catchIf(
135
- (e): e is MissingApiKeyError => e instanceof MissingApiKeyError,
136
- () =>
137
- Effect.gen(function* () {
138
- yield* Console.error('')
139
- yield* Console.error('Error: OPENAI_API_KEY not set')
140
- yield* Console.error('')
141
- yield* Console.error(
142
- 'To use semantic search, set your OpenAI API key:',
143
- )
144
- yield* Console.error(' export OPENAI_API_KEY=sk-...')
145
- yield* Console.error('')
146
- yield* Console.error('Or add to .env file in project root.')
147
- return yield* Effect.fail(new Error('Missing API key'))
148
- }),
149
- ),
150
- Effect.catchIf(
151
- (e): e is InvalidApiKeyError => e instanceof InvalidApiKeyError,
152
- (e) =>
153
- Effect.gen(function* () {
154
- yield* Console.error('')
155
- yield* Console.error('Error: Invalid OPENAI_API_KEY')
156
- yield* Console.error('')
157
- yield* Console.error('The provided API key was rejected by OpenAI.')
158
- yield* Console.error('Please check your API key is correct:')
159
- yield* Console.error(' export OPENAI_API_KEY=sk-...')
160
- yield* Console.error('')
161
- yield* Console.error(`Details: ${e.message}`)
162
- return yield* Effect.fail(new Error('Invalid API key'))
163
- }),
164
- ),
165
- )
397
+ export const wrapEmbedding = (
398
+ embedPromise: Promise<EmbeddingResult>,
399
+ providerName = 'openai',
400
+ ): Effect.Effect<EmbeddingResult, ApiKeyInvalidError | EmbeddingError> =>
401
+ Effect.tryPromise({
402
+ try: () => embedPromise,
403
+ catch: (e) => {
404
+ if (e instanceof ApiKeyInvalidError) {
405
+ return e
406
+ }
407
+ return new EmbeddingError({
408
+ reason: 'Unknown',
409
+ message: e instanceof Error ? e.message : String(e),
410
+ provider: providerName,
411
+ cause: e,
412
+ })
413
+ },
414
+ })
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "description": "Embedding model pricing data. Update quarterly from provider pricing pages.",
4
+ "lastUpdated": "2026-01",
5
+ "sources": {
6
+ "openai": "https://platform.openai.com/docs/pricing",
7
+ "voyage": "https://docs.voyageai.com/docs/pricing"
8
+ },
9
+ "openai": {
10
+ "text-embedding-3-small": 0.02,
11
+ "text-embedding-3-large": 0.13,
12
+ "text-embedding-ada-002": 0.1
13
+ },
14
+ "voyage": {
15
+ "voyage-3.5-lite": 0.02,
16
+ "voyage-3": 0.06,
17
+ "voyage-code-3": 0.18,
18
+ "voyage-2": 0.1,
19
+ "voyage-large-2": 0.12,
20
+ "voyage-code-2": 0.12
21
+ }
22
+ }