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.
- package/.changeset/config.json +9 -9
- package/.claude/settings.local.json +25 -0
- package/.github/workflows/claude-code-review.yml +44 -0
- package/.github/workflows/claude.yml +85 -0
- package/CONTRIBUTING.md +186 -0
- package/NOTES/NOTES +44 -0
- package/README.md +206 -3
- package/biome.json +1 -1
- package/dist/chunk-23UPXDNL.js +3044 -0
- package/dist/chunk-2W7MO2DL.js +1366 -0
- package/dist/chunk-3NUAZGMA.js +1689 -0
- package/dist/chunk-7TOWB2XB.js +366 -0
- package/dist/chunk-7XOTOADQ.js +3065 -0
- package/dist/chunk-AH2PDM2K.js +3042 -0
- package/dist/chunk-BNXWSZ63.js +3742 -0
- package/dist/chunk-BTL5DJVU.js +3222 -0
- package/dist/chunk-HDHYG7E4.js +104 -0
- package/dist/chunk-HLR4KZBP.js +3234 -0
- package/dist/chunk-IP3FRFEB.js +1045 -0
- package/dist/chunk-KHU56VDO.js +3042 -0
- package/dist/chunk-KRYIFLQR.js +85 -89
- package/dist/chunk-LBSDNLEM.js +287 -0
- package/dist/chunk-MNTQ7HCP.js +2643 -0
- package/dist/chunk-MUJELQQ6.js +1387 -0
- package/dist/chunk-MXJGMSLV.js +2199 -0
- package/dist/chunk-N6QJGC3Z.js +2636 -0
- package/dist/chunk-OBELGBPM.js +1713 -0
- package/dist/chunk-OT7R5XTA.js +3192 -0
- package/dist/chunk-P7X4RA2T.js +106 -0
- package/dist/chunk-PIDUQNC2.js +3185 -0
- package/dist/chunk-POGCDIH4.js +3187 -0
- package/dist/chunk-PSIEOQGZ.js +3043 -0
- package/dist/chunk-PVRT3IHA.js +3238 -0
- package/dist/chunk-QNN4TT23.js +1430 -0
- package/dist/chunk-RE3R45RJ.js +3042 -0
- package/dist/chunk-S7E6TFX6.js +718 -657
- package/dist/chunk-SG6GLU4U.js +1378 -0
- package/dist/chunk-SJCDV2ST.js +274 -0
- package/dist/chunk-SYE5XLF3.js +104 -0
- package/dist/chunk-T5VLYBZD.js +103 -0
- package/dist/chunk-TOQB7VWU.js +3238 -0
- package/dist/chunk-VFNMZ4ZQ.js +3228 -0
- package/dist/chunk-VVTGZNBT.js +1533 -1423
- package/dist/chunk-W7Q4RFEV.js +104 -0
- package/dist/chunk-XTYYVRLO.js +3190 -0
- package/dist/chunk-Y6MDYVJD.js +3063 -0
- package/dist/cli/main.js +4072 -629
- package/dist/index.d.ts +420 -33
- package/dist/index.js +8 -15
- package/dist/mcp/server.js +103 -7
- package/dist/schema-BAWSG7KY.js +22 -0
- package/dist/schema-E3QUPL26.js +20 -0
- package/dist/schema-EHL7WUT6.js +20 -0
- package/docs/019-USAGE.md +44 -5
- package/docs/020-current-implementation.md +8 -8
- package/docs/021-DOGFOODING-FINDINGS.md +1 -1
- package/docs/CONFIG.md +1123 -0
- package/docs/ERRORS.md +383 -0
- package/docs/summarization.md +320 -0
- package/justfile +40 -0
- package/package.json +39 -33
- package/research/INDEX.md +315 -0
- package/research/code-review/README.md +90 -0
- package/research/code-review/cli-error-handling-review.md +979 -0
- package/research/code-review/code-review-validation-report.md +464 -0
- package/research/code-review/main-ts-review.md +1128 -0
- package/research/config-docs/SUMMARY.md +357 -0
- package/research/config-docs/TEST-RESULTS.md +776 -0
- package/research/config-docs/TODO.md +542 -0
- package/research/config-docs/analysis.md +744 -0
- package/research/config-docs/fix-validation.md +502 -0
- package/research/config-docs/help-audit.md +264 -0
- package/research/config-docs/help-system-analysis.md +890 -0
- package/research/frontmatter/COMMENTS-ARE-SKIPPED.md +149 -0
- package/research/frontmatter/LLM-CODE-NAVIGATION.md +276 -0
- package/research/issue-review.md +603 -0
- package/research/llm-summarization/agent-cli-tools-2026.md +1082 -0
- package/research/llm-summarization/alternative-providers-2026.md +1428 -0
- package/research/llm-summarization/anthropic-2026.md +367 -0
- package/research/llm-summarization/claude-cli-integration.md +1706 -0
- package/research/llm-summarization/cli-integration-patterns.md +3155 -0
- package/research/llm-summarization/openai-2026.md +473 -0
- package/research/llm-summarization/openai-compatible-providers-2026.md +1022 -0
- package/research/llm-summarization/opencode-cli-integration.md +1552 -0
- package/research/llm-summarization/prompt-engineering-2026.md +1426 -0
- package/research/llm-summarization/prototype-results.md +56 -0
- package/research/llm-summarization/provider-switching-patterns-2026.md +2153 -0
- package/research/llm-summarization/typescript-llm-libraries-2026.md +2436 -0
- package/research/mdcontext-pudding/00-EXECUTIVE-SUMMARY.md +282 -0
- package/research/mdcontext-pudding/01-index-embed.md +956 -0
- package/research/mdcontext-pudding/02-search-COMMANDS.md +142 -0
- package/research/mdcontext-pudding/02-search-SUMMARY.md +146 -0
- package/research/mdcontext-pudding/02-search.md +970 -0
- package/research/mdcontext-pudding/03-context.md +779 -0
- package/research/mdcontext-pudding/04-navigation-and-analytics.md +803 -0
- package/research/mdcontext-pudding/04-tree.md +704 -0
- package/research/mdcontext-pudding/05-config.md +1038 -0
- package/research/mdcontext-pudding/06-links-summary.txt +87 -0
- package/research/mdcontext-pudding/06-links.md +679 -0
- package/research/mdcontext-pudding/07-stats.md +693 -0
- package/research/mdcontext-pudding/BUG-FIX-PLAN.md +388 -0
- package/research/mdcontext-pudding/P0-BUG-VALIDATION.md +167 -0
- package/research/mdcontext-pudding/README.md +168 -0
- package/research/mdcontext-pudding/TESTING-SUMMARY.md +128 -0
- package/research/research-quality-review.md +834 -0
- package/research/semantic-search/embedding-text-analysis.md +156 -0
- package/research/semantic-search/multi-word-failure-reproduction.md +171 -0
- package/research/semantic-search/query-processing-analysis.md +207 -0
- package/research/semantic-search/root-cause-and-solution.md +114 -0
- package/research/semantic-search/threshold-validation-report.md +69 -0
- package/research/semantic-search/vector-search-analysis.md +63 -0
- package/research/test-path-issues.md +276 -0
- package/review/ALP-76/1-error-type-design.md +962 -0
- package/review/ALP-76/2-error-handling-patterns.md +906 -0
- package/review/ALP-76/3-error-presentation.md +624 -0
- package/review/ALP-76/4-test-coverage.md +625 -0
- package/review/ALP-76/5-migration-completeness.md +440 -0
- package/review/ALP-76/6-effect-best-practices.md +755 -0
- package/scripts/apply-branch-protection.sh +47 -0
- package/scripts/branch-protection-templates.json +79 -0
- package/scripts/prototype-summarization.ts +346 -0
- package/scripts/rebuild-hnswlib.js +32 -37
- package/scripts/setup-branch-protection.sh +64 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/active-provider.json +7 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/bm25.json +541 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/bm25.meta.json +5 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/config.json +8 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/embeddings/openai_text-embedding-3-small_512/vectors.bin +0 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/embeddings/openai_text-embedding-3-small_512/vectors.meta.bin +0 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/indexes/documents.json +60 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/indexes/links.json +13 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/indexes/sections.json +1197 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/configuration-management.md +99 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/distributed-systems.md +92 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/error-handling.md +78 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/failure-automation.md +55 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/job-context.md +69 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/process-orchestration.md +99 -0
- package/src/cli/argv-preprocessor.test.ts +2 -2
- package/src/cli/cli.test.ts +230 -33
- package/src/cli/commands/config-cmd.ts +642 -0
- package/src/cli/commands/context.ts +97 -9
- package/src/cli/commands/duplicates.ts +122 -0
- package/src/cli/commands/embeddings.ts +529 -0
- package/src/cli/commands/index-cmd.ts +210 -30
- package/src/cli/commands/index.ts +3 -0
- package/src/cli/commands/search.ts +894 -64
- package/src/cli/commands/stats.ts +3 -0
- package/src/cli/commands/tree.ts +26 -5
- package/src/cli/config-layer.ts +176 -0
- package/src/cli/error-handler.test.ts +235 -0
- package/src/cli/error-handler.ts +655 -0
- package/src/cli/flag-schemas.ts +66 -0
- package/src/cli/help.ts +209 -7
- package/src/cli/main.ts +348 -58
- package/src/cli/options.ts +10 -0
- package/src/cli/shared-error-handling.ts +199 -0
- package/src/cli/utils.ts +150 -17
- package/src/config/file-provider.test.ts +320 -0
- package/src/config/file-provider.ts +273 -0
- package/src/config/index.ts +72 -0
- package/src/config/integration.test.ts +667 -0
- package/src/config/precedence.test.ts +277 -0
- package/src/config/precedence.ts +451 -0
- package/src/config/schema.test.ts +414 -0
- package/src/config/schema.ts +603 -0
- package/src/config/service.test.ts +320 -0
- package/src/config/service.ts +243 -0
- package/src/config/testing.test.ts +264 -0
- package/src/config/testing.ts +110 -0
- package/src/core/types.ts +6 -33
- package/src/duplicates/detector.test.ts +183 -0
- package/src/duplicates/detector.ts +414 -0
- package/src/duplicates/index.ts +18 -0
- package/src/embeddings/embedding-namespace.test.ts +300 -0
- package/src/embeddings/embedding-namespace.ts +947 -0
- package/src/embeddings/heading-boost.test.ts +222 -0
- package/src/embeddings/hnsw-build-options.test.ts +198 -0
- package/src/embeddings/hyde.test.ts +272 -0
- package/src/embeddings/hyde.ts +264 -0
- package/src/embeddings/index.ts +2 -0
- package/src/embeddings/openai-provider.ts +332 -83
- package/src/embeddings/pricing.json +22 -0
- package/src/embeddings/provider-constants.ts +204 -0
- package/src/embeddings/provider-errors.test.ts +967 -0
- package/src/embeddings/provider-errors.ts +565 -0
- package/src/embeddings/provider-factory.test.ts +240 -0
- package/src/embeddings/provider-factory.ts +225 -0
- package/src/embeddings/provider-integration.test.ts +788 -0
- package/src/embeddings/query-preprocessing.test.ts +187 -0
- package/src/embeddings/semantic-search-threshold.test.ts +508 -0
- package/src/embeddings/semantic-search.ts +780 -93
- package/src/embeddings/types.ts +293 -16
- package/src/embeddings/vector-store.ts +486 -77
- package/src/embeddings/voyage-provider.ts +313 -0
- package/src/errors/errors.test.ts +845 -0
- package/src/errors/index.ts +533 -0
- package/src/index/ignore-patterns.test.ts +354 -0
- package/src/index/ignore-patterns.ts +305 -0
- package/src/index/indexer.ts +286 -48
- package/src/index/storage.ts +94 -30
- package/src/index/types.ts +40 -2
- package/src/index/watcher.ts +67 -9
- package/src/index.ts +22 -0
- package/src/integration/search-keyword.test.ts +678 -0
- package/src/mcp/server.ts +135 -6
- package/src/parser/parser.ts +18 -19
- package/src/parser/section-filter.test.ts +277 -0
- package/src/parser/section-filter.ts +125 -3
- package/src/search/__tests__/hybrid-search.test.ts +650 -0
- package/src/search/bm25-store.ts +366 -0
- package/src/search/cross-encoder.test.ts +253 -0
- package/src/search/cross-encoder.ts +406 -0
- package/src/search/fuzzy-search.test.ts +419 -0
- package/src/search/fuzzy-search.ts +273 -0
- package/src/search/hybrid-search.ts +448 -0
- package/src/search/path-matcher.test.ts +276 -0
- package/src/search/path-matcher.ts +33 -0
- package/src/search/searcher.test.ts +99 -1
- package/src/search/searcher.ts +189 -67
- package/src/search/wink-bm25.d.ts +30 -0
- package/src/summarization/cli-providers/claude.ts +202 -0
- package/src/summarization/cli-providers/detection.test.ts +273 -0
- package/src/summarization/cli-providers/detection.ts +118 -0
- package/src/summarization/cli-providers/index.ts +8 -0
- package/src/summarization/cost.test.ts +139 -0
- package/src/summarization/cost.ts +102 -0
- package/src/summarization/error-handler.test.ts +127 -0
- package/src/summarization/error-handler.ts +111 -0
- package/src/summarization/index.ts +102 -0
- package/src/summarization/pipeline.test.ts +498 -0
- package/src/summarization/pipeline.ts +231 -0
- package/src/summarization/prompts.test.ts +269 -0
- package/src/summarization/prompts.ts +133 -0
- package/src/summarization/provider-factory.test.ts +396 -0
- package/src/summarization/provider-factory.ts +178 -0
- package/src/summarization/types.ts +184 -0
- package/src/summarize/summarizer.ts +104 -35
- package/src/types/huggingface-transformers.d.ts +66 -0
- package/tests/fixtures/cli/.mdcontext/active-provider.json +7 -0
- package/tests/fixtures/cli/.mdcontext/embeddings/openai_text-embedding-3-small_512/vectors.bin +0 -0
- package/tests/fixtures/cli/.mdcontext/embeddings/openai_text-embedding-3-small_512/vectors.meta.bin +0 -0
- package/tests/fixtures/cli/.mdcontext/indexes/documents.json +4 -4
- package/tests/fixtures/cli/.mdcontext/indexes/sections.json +14 -0
- package/tests/integration/embed-index.test.ts +712 -0
- package/tests/integration/search-context.test.ts +469 -0
- package/tests/integration/search-semantic.test.ts +522 -0
- package/vitest.config.ts +1 -6
- package/AGENTS.md +0 -46
- package/tests/fixtures/cli/.mdcontext/vectors.bin +0 -0
- 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 {
|
|
5
|
+
import { Effect, Redacted } from 'effect'
|
|
6
6
|
import OpenAI from 'openai'
|
|
7
|
-
import
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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.
|
|
66
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
274
|
+
throw new ApiKeyInvalidError({
|
|
275
|
+
provider: this.providerName,
|
|
276
|
+
details: error.message,
|
|
277
|
+
})
|
|
98
278
|
}
|
|
99
|
-
|
|
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 =
|
|
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 =>
|
|
121
|
-
|
|
122
|
-
// ============================================================================
|
|
123
|
-
// Error Handler Utility
|
|
124
|
-
// ============================================================================
|
|
385
|
+
): Effect.Effect<EmbeddingProvider, ApiKeyMissingError> =>
|
|
386
|
+
OpenAIProvider.create(options)
|
|
125
387
|
|
|
126
388
|
/**
|
|
127
|
-
*
|
|
128
|
-
* Use
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
+
}
|