@exvio/os-backend-core 0.4.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.
@@ -0,0 +1,114 @@
1
+ import type { AIOperation, AIProviderName } from './types.ts'
2
+
3
+ export type AIErrorCode =
4
+ | 'not_configured'
5
+ | 'missing_tenant'
6
+ | 'invalid_config'
7
+ | 'unknown_provider'
8
+ | 'unsupported_operation'
9
+ | 'invalid_request'
10
+ | 'authentication'
11
+ | 'permission_denied'
12
+ | 'rate_limited'
13
+ | 'timeout'
14
+ | 'aborted'
15
+ | 'network'
16
+ | 'endpoint_not_allowed'
17
+ | 'content_filtered'
18
+ | 'invalid_response'
19
+ | 'provider_error'
20
+
21
+ export interface AIErrorOptions {
22
+ readonly code: AIErrorCode
23
+ readonly message: string
24
+ readonly provider?: AIProviderName
25
+ readonly model?: string
26
+ readonly operation?: AIOperation
27
+ readonly status?: number
28
+ readonly retryable?: boolean
29
+ /** Accepted for call-site compatibility but deliberately never retained. */
30
+ readonly cause?: unknown
31
+ }
32
+
33
+ /** A stable, secret-free error envelope shared by every provider. */
34
+ export class AIError extends Error {
35
+ readonly code: AIErrorCode
36
+ readonly provider?: AIProviderName
37
+ readonly model?: string
38
+ readonly operation?: AIOperation
39
+ readonly status?: number
40
+ readonly retryable: boolean
41
+
42
+ constructor(options: AIErrorOptions) {
43
+ // Provider/network errors can contain Authorization headers, prompts, or
44
+ // response bodies. The public error envelope never keeps the raw cause.
45
+ super(options.message)
46
+ this.name = 'AIError'
47
+ this.code = options.code
48
+ this.provider = options.provider
49
+ this.model = options.model
50
+ this.operation = options.operation
51
+ this.status = options.status
52
+ this.retryable = options.retryable ?? false
53
+ }
54
+ }
55
+
56
+ export interface AIErrorContext {
57
+ readonly provider?: AIProviderName
58
+ readonly model?: string
59
+ readonly operation?: AIOperation
60
+ }
61
+
62
+ /** Map an HTTP status without retaining response bodies, headers, or credentials. */
63
+ export function aiErrorFromHttpStatus(status: number, context: AIErrorContext = {}): AIError {
64
+ if (status === 401) {
65
+ return new AIError({ ...context, status, code: 'authentication', message: 'AI provider authentication failed' })
66
+ }
67
+ if (status === 403) {
68
+ return new AIError({ ...context, status, code: 'permission_denied', message: 'AI provider denied the request' })
69
+ }
70
+ if (status === 408 || status === 504) {
71
+ return new AIError({ ...context, status, code: 'timeout', message: 'AI provider request timed out', retryable: true })
72
+ }
73
+ if (status === 429) {
74
+ return new AIError({ ...context, status, code: 'rate_limited', message: 'AI provider rate limit exceeded', retryable: true })
75
+ }
76
+ if (status === 500 || status === 502 || status === 503 || status === 504) {
77
+ return new AIError({ ...context, status, code: 'provider_error', message: 'AI provider is temporarily unavailable', retryable: true })
78
+ }
79
+ return new AIError({ ...context, status, code: 'provider_error', message: 'AI provider rejected the request' })
80
+ }
81
+
82
+ export function unsupportedAIProviderOperation(
83
+ provider: AIProviderName,
84
+ operation: AIOperation,
85
+ model?: string,
86
+ ): AIError {
87
+ return new AIError({
88
+ code: 'unsupported_operation',
89
+ message: `AI provider "${provider}" does not support ${operation}`,
90
+ provider,
91
+ model,
92
+ operation,
93
+ })
94
+ }
95
+
96
+ export function toAIError(error: unknown, context: AIErrorContext = {}): AIError {
97
+ if (error instanceof AIError) return error
98
+ if (error instanceof DOMException && error.name === 'AbortError') {
99
+ return new AIError({ ...context, code: 'aborted', message: 'AI request was aborted', cause: error })
100
+ }
101
+ if (error instanceof Error && error.name === 'AbortError') {
102
+ return new AIError({ ...context, code: 'aborted', message: 'AI request was aborted', cause: error })
103
+ }
104
+ return new AIError({
105
+ ...context,
106
+ code: 'provider_error',
107
+ message: 'AI provider request failed',
108
+ cause: error,
109
+ })
110
+ }
111
+
112
+ export function isRetryableAIError(error: unknown): error is AIError {
113
+ return error instanceof AIError && error.retryable
114
+ }
@@ -0,0 +1,27 @@
1
+ export { createAIClient } from './client.ts'
2
+ export {
3
+ createAIProviderRegistry,
4
+ withAIProvider,
5
+ type AIProviderRegistry,
6
+ } from './registry.ts'
7
+ export {
8
+ createAIObjectPrompt,
9
+ parseAIConfig,
10
+ parseAIJSON,
11
+ parseAIObject,
12
+ validateAIObject,
13
+ } from './schema.ts'
14
+ export {
15
+ AIError,
16
+ aiErrorFromHttpStatus,
17
+ isRetryableAIError,
18
+ toAIError,
19
+ unsupportedAIProviderOperation,
20
+ } from './errors.ts'
21
+ export type * from './errors.ts'
22
+ export type * from './types.ts'
23
+ export * from './pricing.ts'
24
+ export {
25
+ assertActiveAIModelId,
26
+ replacementForRetiredAIModelId,
27
+ } from './model-policy.ts'
@@ -0,0 +1,27 @@
1
+ import { AIError } from './errors.ts'
2
+
3
+ const RETIRED_MODEL_REPLACEMENTS: Readonly<Record<string, string>> = Object.freeze({
4
+ 'deepseek-chat': 'deepseek-v4-flash',
5
+ 'deepseek-reasoner': 'deepseek-v4-flash',
6
+ 'gemini-3.1-flash-lite-preview': 'gemini-3.1-flash-lite',
7
+ })
8
+
9
+ /**
10
+ * Return the canonical replacement used by forward data migrations.
11
+ * Runtime requests must never be rewritten silently; call
12
+ * `assertActiveAIModelId` at configuration boundaries instead.
13
+ */
14
+ export function replacementForRetiredAIModelId(model: string): string | undefined {
15
+ return RETIRED_MODEL_REPLACEMENTS[model.trim()]
16
+ }
17
+
18
+ /** Reject model identifiers that built-in providers no longer serve. */
19
+ export function assertActiveAIModelId(model: string): void {
20
+ const normalized = model.trim()
21
+ if (replacementForRetiredAIModelId(normalized) === undefined) return
22
+ throw new AIError({
23
+ code: 'invalid_config',
24
+ message: 'AI model identifier is retired',
25
+ model: normalized,
26
+ })
27
+ }
@@ -0,0 +1,158 @@
1
+ import type { AIModel, AIUsage } from './types.ts'
2
+
3
+ export interface AIPriceRate {
4
+ /** USD per one million uncached input tokens. */
5
+ readonly inputPerMillionUsd: number
6
+ /** USD per one million output tokens. */
7
+ readonly outputPerMillionUsd: number
8
+ /** Falls back to inputPerMillionUsd when the provider has no cache rate. */
9
+ readonly cachedInputPerMillionUsd?: number
10
+ /** Falls back to inputPerMillionUsd when the provider reports cache misses. */
11
+ readonly cacheMissInputPerMillionUsd?: number
12
+ }
13
+
14
+ export interface AIPriceEntry {
15
+ readonly provider: string
16
+ readonly model: string
17
+ readonly version: string
18
+ readonly effectiveFrom: string
19
+ readonly effectiveTo?: string
20
+ readonly rate: AIPriceRate
21
+ }
22
+
23
+ export interface AIPriceCatalog {
24
+ resolve(model: AIModel, at?: Date): AIPriceEntry | null
25
+ }
26
+
27
+ export interface AICostResult {
28
+ readonly currency: 'USD'
29
+ readonly amount: string
30
+ readonly catalogVersion: string
31
+ readonly effectiveFrom: string
32
+ }
33
+
34
+ /**
35
+ * Builds an immutable, date-aware price catalogue. Rates deliberately come
36
+ * from the consumer/operations layer: provider prices change independently of
37
+ * this package and must be versioned instead of silently rewritten in code.
38
+ */
39
+ export function createPriceCatalog(entries: readonly AIPriceEntry[]): AIPriceCatalog {
40
+ const snapshot = entries.map((entry) => {
41
+ assertRate(entry)
42
+ const from = parseDate(entry.effectiveFrom, 'effectiveFrom')
43
+ const to = entry.effectiveTo === undefined
44
+ ? Number.POSITIVE_INFINITY
45
+ : parseDate(entry.effectiveTo, 'effectiveTo')
46
+ if (to <= from) throw new TypeError('AI price effectiveTo must be after effectiveFrom')
47
+ return { entry: Object.freeze({ ...entry, rate: Object.freeze({ ...entry.rate }) }), from, to }
48
+ })
49
+
50
+ return Object.freeze({
51
+ resolve(model: AIModel, at = new Date()): AIPriceEntry | null {
52
+ const instant = at.getTime()
53
+ if (!Number.isFinite(instant)) throw new TypeError('AI price lookup date is invalid')
54
+ const matches = snapshot
55
+ .filter(({ entry, from, to }) => (
56
+ entry.provider === model.provider && entry.model === model.model && instant >= from && instant < to
57
+ ))
58
+ .sort((a, b) => b.from - a.from)
59
+ return matches[0]?.entry ?? null
60
+ },
61
+ })
62
+ }
63
+
64
+ /** Unknown models return null, never a misleading zero-cost value. */
65
+ export function calculateCostUsd(
66
+ model: AIModel,
67
+ usage: AIUsage | null,
68
+ catalog: AIPriceCatalog,
69
+ at = new Date(),
70
+ ): AICostResult | null {
71
+ if (!usage) return null
72
+ assertUsage(usage)
73
+ const price = catalog.resolve(model, at)
74
+ if (!price) return null
75
+
76
+ const input = tokenCount(usage.inputTokens)
77
+ const output = tokenCount(usage.outputTokens)
78
+ const cached = Math.min(input, tokenCount(usage.cachedInputTokens))
79
+ const reportedMiss = usage.cacheMissInputTokens === undefined
80
+ ? input - cached
81
+ : Math.min(input - cached, tokenCount(usage.cacheMissInputTokens))
82
+ const unclassified = Math.max(0, input - cached - reportedMiss)
83
+
84
+ const rate = price.rate
85
+ const inputCost = (
86
+ cached * (rate.cachedInputPerMillionUsd ?? rate.inputPerMillionUsd)
87
+ + reportedMiss * (rate.cacheMissInputPerMillionUsd ?? rate.inputPerMillionUsd)
88
+ + unclassified * rate.inputPerMillionUsd
89
+ ) / 1_000_000
90
+ const outputCost = (output * rate.outputPerMillionUsd) / 1_000_000
91
+
92
+ return {
93
+ currency: 'USD',
94
+ amount: (inputCost + outputCost).toFixed(9),
95
+ catalogVersion: price.version,
96
+ effectiveFrom: price.effectiveFrom,
97
+ }
98
+ }
99
+
100
+ function tokenCount(value: number | undefined): number {
101
+ return Number.isSafeInteger(value) && (value ?? -1) >= 0 ? value! : 0
102
+ }
103
+
104
+ function assertRate(entry: AIPriceEntry): void {
105
+ if (!entry.provider || !entry.model || !entry.version) {
106
+ throw new TypeError('AI price entry requires provider, model, and version')
107
+ }
108
+ if (!isPlainRecord(entry.rate)) throw new TypeError('AI price rate must be an object')
109
+ assertRateValue(entry.rate.inputPerMillionUsd)
110
+ assertRateValue(entry.rate.outputPerMillionUsd)
111
+ if (entry.rate.cachedInputPerMillionUsd !== undefined) assertRateValue(entry.rate.cachedInputPerMillionUsd)
112
+ if (entry.rate.cacheMissInputPerMillionUsd !== undefined) assertRateValue(entry.rate.cacheMissInputPerMillionUsd)
113
+ }
114
+
115
+ function assertRateValue(value: unknown): asserts value is number {
116
+ if (!Number.isFinite(value) || (value as number) < 0) {
117
+ throw new TypeError('AI price rates must be finite and non-negative')
118
+ }
119
+ }
120
+
121
+ function assertUsage(usage: AIUsage): void {
122
+ for (const value of [usage.inputTokens, usage.outputTokens, usage.totalTokens]) {
123
+ if (!Number.isSafeInteger(value) || value < 0) {
124
+ throw new TypeError('AI usage token counts must be non-negative safe integers')
125
+ }
126
+ }
127
+ for (const value of [
128
+ usage.cachedInputTokens,
129
+ usage.cacheMissInputTokens,
130
+ usage.reasoningTokens,
131
+ ]) {
132
+ if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) {
133
+ throw new TypeError('AI usage token counts must be non-negative safe integers')
134
+ }
135
+ }
136
+ const cached = usage.cachedInputTokens ?? 0
137
+ const missed = usage.cacheMissInputTokens ?? 0
138
+ if (
139
+ usage.totalTokens < usage.inputTokens + usage.outputTokens
140
+ || cached > usage.inputTokens
141
+ || missed > usage.inputTokens
142
+ || cached + missed > usage.inputTokens
143
+ ) {
144
+ throw new TypeError('AI cache token counts cannot exceed input tokens')
145
+ }
146
+ }
147
+
148
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
149
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false
150
+ const prototype = Object.getPrototypeOf(value)
151
+ return prototype === Object.prototype || prototype === null
152
+ }
153
+
154
+ function parseDate(value: string, field: string): number {
155
+ const parsed = Date.parse(value)
156
+ if (!Number.isFinite(parsed)) throw new TypeError(`AI price ${field} must be an ISO date`)
157
+ return parsed
158
+ }
@@ -0,0 +1,61 @@
1
+ import { AIError } from '../errors.ts'
2
+ import type { AIProviderFactory, AIProviderFactoryContext } from '../types.ts'
3
+ import { OpenAICompatibleProvider } from './openai-compatible.ts'
4
+
5
+ /** Official DeepSeek chat-completions endpoint. */
6
+ export const DEEPSEEK_DEFAULT_ENDPOINT = 'https://api.deepseek.com/chat/completions'
7
+
8
+ /**
9
+ * Current non-legacy default for consumer configuration and migrations.
10
+ *
11
+ * Runtime requests still carry an explicit provider/model pair; the provider
12
+ * never infers a model from a prefix or silently replaces a requested model.
13
+ */
14
+ export const DEEPSEEK_DEFAULT_MODEL = 'deepseek-v4-flash'
15
+
16
+ function buildDeepSeekProvider(context: AIProviderFactoryContext): OpenAICompatibleProvider {
17
+ const apiKey = context.config.apiKey?.trim()
18
+ if (!apiKey) {
19
+ throw new AIError({
20
+ code: 'not_configured',
21
+ message: 'DeepSeek API key is not configured',
22
+ provider: context.name,
23
+ })
24
+ }
25
+
26
+ return new OpenAICompatibleProvider({
27
+ name: context.name,
28
+ apiKey,
29
+ endpoint: context.config.endpoint ?? context.config.baseUrl ?? DEEPSEEK_DEFAULT_ENDPOINT,
30
+ fetch: context.fetch,
31
+ // DeepSeek's public chat-completions endpoint does not accept image/file
32
+ // content. Keep it fail-closed and route multimodal lanes elsewhere.
33
+ capabilities: { content: false, object: false, embedding: false },
34
+ // V4 generation models otherwise default to reasoning mode, which can
35
+ // consume the output budget before returning visible content. Callers may
36
+ // explicitly opt back in with disableThinking:false.
37
+ disableThinkingByDefault: isDeepSeekV4,
38
+ })
39
+ }
40
+
41
+ Object.defineProperty(buildDeepSeekProvider, 'defaultEndpoint', {
42
+ value: DEEPSEEK_DEFAULT_ENDPOINT,
43
+ enumerable: true,
44
+ writable: false,
45
+ configurable: false,
46
+ })
47
+ export const deepseekProviderFactory: AIProviderFactory = Object.freeze(buildDeepSeekProvider)
48
+
49
+ /** Convenient constructor for a registry entry. */
50
+ export function createDeepSeekProvider(): AIProviderFactory {
51
+ return deepseekProviderFactory
52
+ }
53
+
54
+ /** Named consistently with other built-in provider factory constructors. */
55
+ export function createDeepSeekProviderFactory(): AIProviderFactory {
56
+ return deepseekProviderFactory
57
+ }
58
+
59
+ function isDeepSeekV4(model: string): boolean {
60
+ return /^deepseek-v[4-9](?:[-_.]|$)/i.test(model)
61
+ }