@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.
- package/README.md +466 -0
- package/package.json +48 -0
- package/src/ai/client.ts +1059 -0
- package/src/ai/errors.ts +114 -0
- package/src/ai/index.ts +27 -0
- package/src/ai/model-policy.ts +27 -0
- package/src/ai/pricing.ts +158 -0
- package/src/ai/providers/deepseek.ts +61 -0
- package/src/ai/providers/gemini.ts +919 -0
- package/src/ai/providers/openai-compatible.ts +731 -0
- package/src/ai/providers/sse.ts +163 -0
- package/src/ai/registry.ts +65 -0
- package/src/ai/schema.ts +382 -0
- package/src/ai/types.ts +282 -0
- package/src/auth/browser-exchange.ts +316 -0
- package/src/auth/errors.ts +57 -0
- package/src/auth/index.ts +29 -0
- package/src/auth/login-policy.ts +95 -0
- package/src/auth/oauth-state.ts +332 -0
- package/src/auth/passkey.ts +760 -0
- package/src/auth/redaction.ts +142 -0
- package/src/auth/session.ts +106 -0
- package/src/auth/types.ts +72 -0
- package/src/changelog/catalogue.ts +140 -0
- package/src/changelog/index.ts +5 -0
- package/src/changelog/locale.ts +122 -0
- package/src/changelog/service.ts +148 -0
- package/src/changelog/types.ts +122 -0
- package/src/db/bypass-tenant.ts +15 -0
- package/src/db/plugins/tenant-filter.ts +518 -0
- package/src/db/tenant-context.ts +51 -0
- package/src/guide/index.ts +5 -0
- package/src/guide/markdown.ts +108 -0
- package/src/guide/service.ts +345 -0
- package/src/guide/source.ts +116 -0
- package/src/guide/types.ts +177 -0
- package/src/index.ts +1 -0
- package/src/tenant.ts +14 -0
package/src/ai/errors.ts
ADDED
|
@@ -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
|
+
}
|
package/src/ai/index.ts
ADDED
|
@@ -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
|
+
}
|