@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
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A deliberately small SSE decoder for OpenAI-compatible streaming APIs.
|
|
3
|
+
*
|
|
4
|
+
* It implements the pieces of the event-stream format those APIs rely on:
|
|
5
|
+
* CRLF/LF/CR line endings, comments, multi-line `data` fields and a final
|
|
6
|
+
* unterminated event. The decoder never includes provider data in thrown
|
|
7
|
+
* errors because a response can contain user input or other sensitive data.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface SseJsonOptions {
|
|
11
|
+
signal?: AbortSignal
|
|
12
|
+
/** Refuse an individual event larger than this many decoded characters. */
|
|
13
|
+
maxEventSize?: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type SseJsonEvent<T> =
|
|
17
|
+
| { type: 'data'; data: T }
|
|
18
|
+
| { type: 'done' }
|
|
19
|
+
|
|
20
|
+
export class SseDecodeError extends Error {
|
|
21
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
22
|
+
super(message, options)
|
|
23
|
+
this.name = 'SseDecodeError'
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Decode JSON `data:` events from a fetch response body.
|
|
29
|
+
*
|
|
30
|
+
* The iterator yields a `done` event only for an explicit `[DONE]` marker.
|
|
31
|
+
* Consumers should still treat normal EOF as completion because a number of
|
|
32
|
+
* OpenAI-compatible servers omit that marker.
|
|
33
|
+
*/
|
|
34
|
+
export async function* parseSseJson<T>(
|
|
35
|
+
body: ReadableStream<Uint8Array>,
|
|
36
|
+
options: SseJsonOptions = {},
|
|
37
|
+
): AsyncGenerator<SseJsonEvent<T>, void, undefined> {
|
|
38
|
+
const { signal, maxEventSize = 1_048_576 } = options
|
|
39
|
+
if (!Number.isSafeInteger(maxEventSize) || maxEventSize <= 0) {
|
|
40
|
+
throw new TypeError('maxEventSize must be a positive safe integer')
|
|
41
|
+
}
|
|
42
|
+
throwIfAborted(signal)
|
|
43
|
+
|
|
44
|
+
const reader = body.getReader()
|
|
45
|
+
const decoder = new TextDecoder()
|
|
46
|
+
let buffer = ''
|
|
47
|
+
let sawExplicitDone = false
|
|
48
|
+
let finishedReading = false
|
|
49
|
+
|
|
50
|
+
const onAbort = () => {
|
|
51
|
+
// Cancelling unblocks a pending read. Ignore the returned promise here;
|
|
52
|
+
// the generator's finally block performs the awaited cleanup.
|
|
53
|
+
void reader.cancel(abortReason(signal))
|
|
54
|
+
}
|
|
55
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
while (!sawExplicitDone) {
|
|
59
|
+
throwIfAborted(signal)
|
|
60
|
+
const result = await reader.read()
|
|
61
|
+
throwIfAborted(signal)
|
|
62
|
+
|
|
63
|
+
if (result.done) {
|
|
64
|
+
finishedReading = true
|
|
65
|
+
buffer += decoder.decode()
|
|
66
|
+
break
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
buffer += decoder.decode(result.value, { stream: true })
|
|
70
|
+
if (buffer.length > maxEventSize && findFrameBoundary(buffer) === undefined) {
|
|
71
|
+
throw new SseDecodeError('SSE event exceeded the configured size limit')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
while (true) {
|
|
75
|
+
const boundary = findFrameBoundary(buffer)
|
|
76
|
+
if (!boundary) break
|
|
77
|
+
|
|
78
|
+
const frame = buffer.slice(0, boundary.index)
|
|
79
|
+
buffer = buffer.slice(boundary.index + boundary.length)
|
|
80
|
+
const event = decodeFrame<T>(frame, maxEventSize)
|
|
81
|
+
if (!event) continue
|
|
82
|
+
|
|
83
|
+
yield event
|
|
84
|
+
if (event.type === 'done') {
|
|
85
|
+
sawExplicitDone = true
|
|
86
|
+
break
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A valid server may close after its last `data:` line without sending a
|
|
92
|
+
// blank separator or [DONE]. Decode that final frame before returning.
|
|
93
|
+
if (!sawExplicitDone && finishedReading && buffer.length > 0) {
|
|
94
|
+
const event = decodeFrame<T>(buffer, maxEventSize)
|
|
95
|
+
if (event) yield event
|
|
96
|
+
}
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (signal?.aborted) throw abortReason(signal)
|
|
99
|
+
throw error
|
|
100
|
+
} finally {
|
|
101
|
+
signal?.removeEventListener('abort', onAbort)
|
|
102
|
+
// If the consumer stopped early, cancel the transport so its socket can
|
|
103
|
+
// be returned to the pool. Calling cancel after EOF is harmless in the
|
|
104
|
+
// web-stream implementations used by Bun and Node.
|
|
105
|
+
if (!finishedReading) {
|
|
106
|
+
try {
|
|
107
|
+
await reader.cancel()
|
|
108
|
+
} catch {
|
|
109
|
+
// Preserve the original iteration result/error.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
reader.releaseLock()
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function decodeFrame<T>(frame: string, maxEventSize: number): SseJsonEvent<T> | undefined {
|
|
117
|
+
if (frame.length > maxEventSize) {
|
|
118
|
+
throw new SseDecodeError('SSE event exceeded the configured size limit')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// A boundary can consist of mixed newline styles; splitting this way also
|
|
122
|
+
// accepts the final unterminated line at EOF.
|
|
123
|
+
const lines = frame.split(/\r\n|\n|\r/)
|
|
124
|
+
const dataLines: string[] = []
|
|
125
|
+
|
|
126
|
+
for (const line of lines) {
|
|
127
|
+
if (line.length === 0 || line.startsWith(':')) continue
|
|
128
|
+
|
|
129
|
+
const colon = line.indexOf(':')
|
|
130
|
+
const field = colon === -1 ? line : line.slice(0, colon)
|
|
131
|
+
if (field !== 'data') continue
|
|
132
|
+
|
|
133
|
+
let value = colon === -1 ? '' : line.slice(colon + 1)
|
|
134
|
+
if (value.startsWith(' ')) value = value.slice(1)
|
|
135
|
+
dataLines.push(value)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (dataLines.length === 0) return undefined
|
|
139
|
+
const payload = dataLines.join('\n')
|
|
140
|
+
if (payload.trim() === '[DONE]') return { type: 'done' }
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
return { type: 'data', data: JSON.parse(payload) as T }
|
|
144
|
+
} catch (cause) {
|
|
145
|
+
throw new SseDecodeError('Provider returned malformed JSON in an SSE event', { cause })
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function findFrameBoundary(input: string): { index: number; length: number } | undefined {
|
|
150
|
+
// A blank line is two line endings. Each ending may independently be CRLF,
|
|
151
|
+
// LF, or CR, so this expression also handles mixed proxy-normalised input.
|
|
152
|
+
const match = /(?:\r\n|\n|\r)(?:\r\n|\n|\r)/.exec(input)
|
|
153
|
+
return match?.index === undefined ? undefined : { index: match.index, length: match[0].length }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
157
|
+
if (signal?.aborted) throw abortReason(signal)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function abortReason(signal: AbortSignal | undefined): unknown {
|
|
161
|
+
if (signal?.reason !== undefined) return signal.reason
|
|
162
|
+
return new DOMException('The operation was aborted', 'AbortError')
|
|
163
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { AIError } from './errors.ts'
|
|
2
|
+
import type { AIProviderFactory, AIProviderName } from './types.ts'
|
|
3
|
+
|
|
4
|
+
export type AIProviderRegistry = ReadonlyMap<AIProviderName, AIProviderFactory>
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create a request-independent registry snapshot. There is deliberately no
|
|
8
|
+
* package-global registry: each application owns exactly which providers are
|
|
9
|
+
* available, and tests cannot leak registrations into one another.
|
|
10
|
+
*/
|
|
11
|
+
export function createAIProviderRegistry(
|
|
12
|
+
entries: Iterable<readonly [AIProviderName, AIProviderFactory]> = [],
|
|
13
|
+
): AIProviderRegistry {
|
|
14
|
+
const registry = new Map<AIProviderName, AIProviderFactory>()
|
|
15
|
+
for (const [name, factory] of entries) {
|
|
16
|
+
assertProviderRegistration(name, factory)
|
|
17
|
+
if (registry.has(name)) {
|
|
18
|
+
throw new AIError({
|
|
19
|
+
code: 'invalid_config',
|
|
20
|
+
message: 'AI provider registry contains a duplicate name',
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
registry.set(name, factory)
|
|
24
|
+
}
|
|
25
|
+
return registry
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Return a new snapshot with one provider added; the input map is untouched. */
|
|
29
|
+
export function withAIProvider(
|
|
30
|
+
registry: AIProviderRegistry,
|
|
31
|
+
name: AIProviderName,
|
|
32
|
+
factory: AIProviderFactory,
|
|
33
|
+
): AIProviderRegistry {
|
|
34
|
+
assertProviderRegistration(name, factory)
|
|
35
|
+
if (registry.has(name)) {
|
|
36
|
+
throw new AIError({
|
|
37
|
+
code: 'invalid_config',
|
|
38
|
+
message: 'AI provider is already registered',
|
|
39
|
+
provider: name,
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
const next = new Map(registry)
|
|
43
|
+
next.set(name, factory)
|
|
44
|
+
return next
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function assertProviderRegistration(name: string, factory: AIProviderFactory): void {
|
|
48
|
+
if (!isNonEmptyString(name) || name.length > 128 || /\s/.test(name)) {
|
|
49
|
+
throw new AIError({
|
|
50
|
+
code: 'invalid_config',
|
|
51
|
+
message: 'AI provider registry name is invalid',
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
if (typeof factory !== 'function') {
|
|
55
|
+
throw new AIError({
|
|
56
|
+
code: 'invalid_config',
|
|
57
|
+
message: 'AI provider registry factory is invalid',
|
|
58
|
+
provider: name,
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
64
|
+
return typeof value === 'string' && value.trim().length > 0
|
|
65
|
+
}
|
package/src/ai/schema.ts
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { AIError } from './errors.ts'
|
|
2
|
+
import { assertActiveAIModelId } from './model-policy.ts'
|
|
3
|
+
import type {
|
|
4
|
+
AIConfig,
|
|
5
|
+
AIModelRef,
|
|
6
|
+
AIObjectSchema,
|
|
7
|
+
AIProviderConfig,
|
|
8
|
+
} from './types.ts'
|
|
9
|
+
|
|
10
|
+
/** Validate and copy application-owned config without retaining unknown fields. */
|
|
11
|
+
export function parseAIConfig(value: unknown): AIConfig {
|
|
12
|
+
if (!isRecord(value)) invalidConfig()
|
|
13
|
+
|
|
14
|
+
const rawProviders = value.providers
|
|
15
|
+
const rawLanes = value.lanes
|
|
16
|
+
if (!isRecord(rawProviders) || !isRecord(rawLanes)) invalidConfig()
|
|
17
|
+
|
|
18
|
+
const providers: Record<string, AIProviderConfig> = Object.create(null) as Record<string, AIProviderConfig>
|
|
19
|
+
for (const [name, rawProvider] of Object.entries(rawProviders)) {
|
|
20
|
+
if (!validName(name) || !isRecord(rawProvider)) invalidConfig()
|
|
21
|
+
|
|
22
|
+
const apiKey = optionalString(rawProvider.apiKey ?? rawProvider.api_key)
|
|
23
|
+
const endpoint = optionalString(rawProvider.endpoint)
|
|
24
|
+
const baseUrl = optionalString(rawProvider.baseUrl ?? rawProvider.base_url)
|
|
25
|
+
const settings = rawProvider.settings
|
|
26
|
+
if (settings !== undefined && !isRecord(settings)) invalidConfig()
|
|
27
|
+
|
|
28
|
+
providers[name] = Object.freeze({
|
|
29
|
+
...(apiKey === undefined ? {} : { apiKey }),
|
|
30
|
+
...(endpoint === undefined ? {} : { endpoint }),
|
|
31
|
+
...(baseUrl === undefined ? {} : { baseUrl }),
|
|
32
|
+
...(settings === undefined ? {} : { settings: Object.freeze({ ...settings }) }),
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const lanes: Record<string, AIModelRef> = Object.create(null) as Record<string, AIModelRef>
|
|
37
|
+
for (const [name, rawLane] of Object.entries(rawLanes)) {
|
|
38
|
+
if (!validName(name) || !isRecord(rawLane)) invalidConfig()
|
|
39
|
+
const provider = requiredString(rawLane.provider)
|
|
40
|
+
const model = requiredString(rawLane.model ?? rawLane.default_model)
|
|
41
|
+
assertActiveAIModelId(model)
|
|
42
|
+
lanes[name] = Object.freeze({ provider, model })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return Object.freeze({
|
|
46
|
+
providers: Object.freeze(providers),
|
|
47
|
+
lanes: Object.freeze(lanes),
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Parse an exact JSON value, allowing only a single surrounding markdown fence. */
|
|
52
|
+
export function parseAIJSON(text: string): unknown {
|
|
53
|
+
if (typeof text !== 'string') invalidResponse()
|
|
54
|
+
const trimmed = text.trim()
|
|
55
|
+
if (!trimmed) invalidResponse()
|
|
56
|
+
|
|
57
|
+
const fenced = /^```(?:json)?\s*\r?\n?([\s\S]*?)\r?\n?```$/i.exec(trimmed)
|
|
58
|
+
const source = (fenced?.[1] ?? trimmed).trim()
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(source) as unknown
|
|
61
|
+
} catch {
|
|
62
|
+
invalidResponse()
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function validateAIObject<T>(value: unknown, schema: AIObjectSchema<T>): T {
|
|
67
|
+
assertAIObjectSchema(schema)
|
|
68
|
+
try {
|
|
69
|
+
return schema.validate(value)
|
|
70
|
+
} catch {
|
|
71
|
+
invalidResponse()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function parseAIObject<T>(text: string, schema: AIObjectSchema<T>): T {
|
|
76
|
+
return validateAIObject(parseAIJSON(text), schema)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createAIObjectPrompt<T>(prompt: string, schema: AIObjectSchema<T>): string {
|
|
80
|
+
if (typeof prompt !== 'string' || prompt.length === 0) {
|
|
81
|
+
throw new AIError({
|
|
82
|
+
code: 'invalid_request',
|
|
83
|
+
message: 'AI object prompt must not be empty',
|
|
84
|
+
operation: 'object',
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
assertAIObjectSchema(schema)
|
|
88
|
+
return `${prompt}\n\nReturn only JSON matching this schema:\n${JSON.stringify(schema.jsonSchema)}`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Validate before any billable provider request is started. */
|
|
92
|
+
export function assertAIObjectSchema<T>(schema: AIObjectSchema<T>): void {
|
|
93
|
+
if (
|
|
94
|
+
!schema
|
|
95
|
+
|| typeof schema.validate !== 'function'
|
|
96
|
+
|| !isRecord(schema.jsonSchema)
|
|
97
|
+
|| !isAIJSONValue(schema.jsonSchema)
|
|
98
|
+
|| !isSupportedAIJSONSchema(schema.jsonSchema)
|
|
99
|
+
) {
|
|
100
|
+
throw new AIError({
|
|
101
|
+
code: 'invalid_request',
|
|
102
|
+
message: 'AI object schema is invalid',
|
|
103
|
+
operation: 'object',
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** JSON-Schema subset validator used for untrusted provider tool arguments. */
|
|
109
|
+
export function matchesAIJSONSchema(
|
|
110
|
+
value: unknown,
|
|
111
|
+
schema: unknown,
|
|
112
|
+
root: unknown = schema,
|
|
113
|
+
depth = 0,
|
|
114
|
+
): boolean {
|
|
115
|
+
if (depth > 64) return false
|
|
116
|
+
if (typeof schema === 'boolean') return schema
|
|
117
|
+
if (!isRecord(schema)) return false
|
|
118
|
+
|
|
119
|
+
if (typeof schema.$ref === 'string') {
|
|
120
|
+
const resolved = resolveLocalRef(root, schema.$ref)
|
|
121
|
+
return resolved !== undefined && matchesAIJSONSchema(value, resolved, root, depth + 1)
|
|
122
|
+
}
|
|
123
|
+
if (schema.nullable === true && value === null) return true
|
|
124
|
+
if (Array.isArray(schema.allOf) && !schema.allOf.every(item => matchesAIJSONSchema(value, item, root, depth + 1))) return false
|
|
125
|
+
if (Array.isArray(schema.anyOf) && !schema.anyOf.some(item => matchesAIJSONSchema(value, item, root, depth + 1))) return false
|
|
126
|
+
if (Array.isArray(schema.oneOf)) {
|
|
127
|
+
if (schema.oneOf.filter(item => matchesAIJSONSchema(value, item, root, depth + 1)).length !== 1) return false
|
|
128
|
+
}
|
|
129
|
+
if (schema.not !== undefined && matchesAIJSONSchema(value, schema.not, root, depth + 1)) return false
|
|
130
|
+
if (schema.const !== undefined && !jsonEquals(value, schema.const)) return false
|
|
131
|
+
if (Array.isArray(schema.enum) && !schema.enum.some(item => jsonEquals(value, item))) return false
|
|
132
|
+
|
|
133
|
+
const types = typeof schema.type === 'string'
|
|
134
|
+
? [schema.type]
|
|
135
|
+
: Array.isArray(schema.type) && schema.type.every(item => typeof item === 'string')
|
|
136
|
+
? schema.type as string[]
|
|
137
|
+
: []
|
|
138
|
+
if (types.length > 0 && !types.some(type => matchesJSONType(value, type))) return false
|
|
139
|
+
|
|
140
|
+
if (isRecord(value)) {
|
|
141
|
+
const properties = isRecord(schema.properties) ? schema.properties : {}
|
|
142
|
+
if (Array.isArray(schema.required)) {
|
|
143
|
+
for (const key of schema.required) {
|
|
144
|
+
if (typeof key !== 'string' || !Object.hasOwn(value, key)) return false
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const [key, child] of Object.entries(value)) {
|
|
148
|
+
if (Object.hasOwn(properties, key)) {
|
|
149
|
+
if (!matchesAIJSONSchema(child, properties[key], root, depth + 1)) return false
|
|
150
|
+
} else if (schema.additionalProperties === false) {
|
|
151
|
+
return false
|
|
152
|
+
} else if (isRecord(schema.additionalProperties) || typeof schema.additionalProperties === 'boolean') {
|
|
153
|
+
if (!matchesAIJSONSchema(child, schema.additionalProperties, root, depth + 1)) return false
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (Array.isArray(value)) {
|
|
159
|
+
if (typeof schema.minItems === 'number' && value.length < schema.minItems) return false
|
|
160
|
+
if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) return false
|
|
161
|
+
if (schema.items !== undefined && !value.every(item => matchesAIJSONSchema(item, schema.items, root, depth + 1))) return false
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (typeof value === 'string') {
|
|
165
|
+
if (typeof schema.minLength === 'number' && value.length < schema.minLength) return false
|
|
166
|
+
if (typeof schema.maxLength === 'number' && value.length > schema.maxLength) return false
|
|
167
|
+
if (typeof schema.pattern === 'string') {
|
|
168
|
+
try {
|
|
169
|
+
if (!new RegExp(schema.pattern, 'u').test(value)) return false
|
|
170
|
+
} catch {
|
|
171
|
+
return false
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (typeof value === 'number') {
|
|
177
|
+
if (typeof schema.minimum === 'number' && value < schema.minimum) return false
|
|
178
|
+
if (typeof schema.maximum === 'number' && value > schema.maximum) return false
|
|
179
|
+
if (typeof schema.exclusiveMinimum === 'number' && value <= schema.exclusiveMinimum) return false
|
|
180
|
+
if (typeof schema.exclusiveMaximum === 'number' && value >= schema.exclusiveMaximum) return false
|
|
181
|
+
if (typeof schema.multipleOf === 'number' && (
|
|
182
|
+
schema.multipleOf <= 0
|
|
183
|
+
|| Math.abs(value / schema.multipleOf - Math.round(value / schema.multipleOf)) > Number.EPSILON * 16
|
|
184
|
+
)) return false
|
|
185
|
+
}
|
|
186
|
+
return true
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function isAIJSONValue(value: unknown, seen = new WeakSet<object>()): boolean {
|
|
190
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return true
|
|
191
|
+
if (typeof value === 'number') return Number.isFinite(value)
|
|
192
|
+
if (typeof value !== 'object') return false
|
|
193
|
+
if (seen.has(value)) return false
|
|
194
|
+
seen.add(value)
|
|
195
|
+
const valid = Array.isArray(value)
|
|
196
|
+
? value.every(item => isAIJSONValue(item, seen))
|
|
197
|
+
: isRecord(value) && Object.values(value).every(item => isAIJSONValue(item, seen))
|
|
198
|
+
seen.delete(value)
|
|
199
|
+
return valid
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const SUPPORTED_SCHEMA_KEYS = new Set([
|
|
203
|
+
'$ref', '$defs', 'definitions',
|
|
204
|
+
'title', 'description', 'default', 'examples',
|
|
205
|
+
'type', 'nullable', 'enum', 'const',
|
|
206
|
+
'allOf', 'anyOf', 'oneOf', 'not',
|
|
207
|
+
'properties', 'required', 'additionalProperties',
|
|
208
|
+
'items', 'minItems', 'maxItems',
|
|
209
|
+
'minLength', 'maxLength', 'pattern',
|
|
210
|
+
'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf',
|
|
211
|
+
])
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Core intentionally supports a strict JSON-Schema subset. Unknown validation
|
|
215
|
+
* keywords fail before a provider call rather than being silently ignored.
|
|
216
|
+
*/
|
|
217
|
+
export function isSupportedAIJSONSchema(schema: unknown): boolean {
|
|
218
|
+
return isSupportedSchema(schema, schema, 0)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function isSupportedSchema(schema: unknown, root: unknown, depth: number): boolean {
|
|
222
|
+
if (depth > 64) return false
|
|
223
|
+
if (typeof schema === 'boolean') return true
|
|
224
|
+
if (!isRecord(schema)) return false
|
|
225
|
+
if (Object.keys(schema).some(key => !SUPPORTED_SCHEMA_KEYS.has(key))) return false
|
|
226
|
+
|
|
227
|
+
if (schema.$ref !== undefined) {
|
|
228
|
+
if (
|
|
229
|
+
typeof schema.$ref !== 'string'
|
|
230
|
+
|| (schema.$ref !== '#' && !schema.$ref.startsWith('#/'))
|
|
231
|
+
|| Object.keys(schema).some(key => !['$ref', 'title', 'description'].includes(key))
|
|
232
|
+
) return false
|
|
233
|
+
const resolved = resolveLocalRef(root, schema.$ref)
|
|
234
|
+
return resolved !== undefined && isSupportedSchema(resolved, root, depth + 1)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
for (const definitionsKey of ['$defs', 'definitions'] as const) {
|
|
238
|
+
const definitions = schema[definitionsKey]
|
|
239
|
+
if (definitions !== undefined) {
|
|
240
|
+
if (!isRecord(definitions)) return false
|
|
241
|
+
if (Object.values(definitions).some(child => !isSupportedSchema(child, root, depth + 1))) return false
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const allowedTypes = new Set(['null', 'object', 'array', 'string', 'number', 'integer', 'boolean'])
|
|
246
|
+
if (schema.type !== undefined) {
|
|
247
|
+
const types = typeof schema.type === 'string' ? [schema.type] : schema.type
|
|
248
|
+
if (!Array.isArray(types) || types.length === 0 || types.some(type => typeof type !== 'string' || !allowedTypes.has(type))) {
|
|
249
|
+
return false
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (schema.nullable !== undefined && typeof schema.nullable !== 'boolean') return false
|
|
253
|
+
|
|
254
|
+
const properties = schema.properties
|
|
255
|
+
if (properties !== undefined) {
|
|
256
|
+
if (!isRecord(properties)) return false
|
|
257
|
+
if (Object.values(properties).some(child => !isSupportedSchema(child, root, depth + 1))) return false
|
|
258
|
+
}
|
|
259
|
+
if (schema.required !== undefined && (
|
|
260
|
+
!Array.isArray(schema.required)
|
|
261
|
+
|| schema.required.some(key => typeof key !== 'string')
|
|
262
|
+
|| new Set(schema.required).size !== schema.required.length
|
|
263
|
+
)) return false
|
|
264
|
+
if (
|
|
265
|
+
schema.additionalProperties !== undefined
|
|
266
|
+
&& typeof schema.additionalProperties !== 'boolean'
|
|
267
|
+
&& !isSupportedSchema(schema.additionalProperties, root, depth + 1)
|
|
268
|
+
) return false
|
|
269
|
+
if (schema.items !== undefined && !isSupportedSchema(schema.items, root, depth + 1)) return false
|
|
270
|
+
|
|
271
|
+
for (const combinator of ['allOf', 'anyOf', 'oneOf'] as const) {
|
|
272
|
+
const entries = schema[combinator]
|
|
273
|
+
if (entries !== undefined && (
|
|
274
|
+
!Array.isArray(entries)
|
|
275
|
+
|| entries.length === 0
|
|
276
|
+
|| entries.some(child => !isSupportedSchema(child, root, depth + 1))
|
|
277
|
+
)) return false
|
|
278
|
+
}
|
|
279
|
+
if (schema.not !== undefined && !isSupportedSchema(schema.not, root, depth + 1)) return false
|
|
280
|
+
|
|
281
|
+
if (schema.enum !== undefined && (
|
|
282
|
+
!Array.isArray(schema.enum) || schema.enum.length === 0 || !isAIJSONValue(schema.enum)
|
|
283
|
+
)) return false
|
|
284
|
+
if (schema.const !== undefined && !isAIJSONValue(schema.const)) return false
|
|
285
|
+
for (const annotation of ['default', 'examples'] as const) {
|
|
286
|
+
if (schema[annotation] !== undefined && !isAIJSONValue(schema[annotation])) return false
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
for (const integerKeyword of ['minItems', 'maxItems', 'minLength', 'maxLength'] as const) {
|
|
290
|
+
const value = schema[integerKeyword]
|
|
291
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || (value as number) < 0)) return false
|
|
292
|
+
}
|
|
293
|
+
for (const numberKeyword of [
|
|
294
|
+
'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf',
|
|
295
|
+
] as const) {
|
|
296
|
+
const value = schema[numberKeyword]
|
|
297
|
+
if (value !== undefined && (!Number.isFinite(value) || (numberKeyword === 'multipleOf' && (value as number) <= 0))) {
|
|
298
|
+
return false
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (schema.pattern !== undefined) {
|
|
302
|
+
if (typeof schema.pattern !== 'string') return false
|
|
303
|
+
try { new RegExp(schema.pattern, 'u') } catch { return false }
|
|
304
|
+
}
|
|
305
|
+
return true
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function matchesJSONType(value: unknown, type: string): boolean {
|
|
309
|
+
switch (type) {
|
|
310
|
+
case 'null': return value === null
|
|
311
|
+
case 'object': return isRecord(value)
|
|
312
|
+
case 'array': return Array.isArray(value)
|
|
313
|
+
case 'string': return typeof value === 'string'
|
|
314
|
+
case 'number': return typeof value === 'number' && Number.isFinite(value)
|
|
315
|
+
case 'integer': return typeof value === 'number' && Number.isSafeInteger(value)
|
|
316
|
+
case 'boolean': return typeof value === 'boolean'
|
|
317
|
+
default: return false
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function resolveLocalRef(root: unknown, ref: string): unknown {
|
|
322
|
+
if (ref === '#') return root
|
|
323
|
+
if (!ref.startsWith('#/')) return undefined
|
|
324
|
+
let current: unknown = root
|
|
325
|
+
for (const segment of ref.slice(2).split('/')) {
|
|
326
|
+
if (!isRecord(current)) return undefined
|
|
327
|
+
const key = segment.replaceAll('~1', '/').replaceAll('~0', '~')
|
|
328
|
+
if (!Object.hasOwn(current, key)) return undefined
|
|
329
|
+
current = current[key]
|
|
330
|
+
}
|
|
331
|
+
return current
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function jsonEquals(left: unknown, right: unknown): boolean {
|
|
335
|
+
if (Object.is(left, right)) return true
|
|
336
|
+
if (Array.isArray(left) && Array.isArray(right)) {
|
|
337
|
+
return left.length === right.length && left.every((value, index) => jsonEquals(value, right[index]))
|
|
338
|
+
}
|
|
339
|
+
if (isRecord(left) && isRecord(right)) {
|
|
340
|
+
const leftKeys = Object.keys(left)
|
|
341
|
+
const rightKeys = Object.keys(right)
|
|
342
|
+
return leftKeys.length === rightKeys.length
|
|
343
|
+
&& leftKeys.every(key => Object.hasOwn(right, key) && jsonEquals(left[key], right[key]))
|
|
344
|
+
}
|
|
345
|
+
return false
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function optionalString(value: unknown): string | undefined {
|
|
349
|
+
if (value === undefined || value === null) return undefined
|
|
350
|
+
if (typeof value === 'string' && value.trim().length === 0) return undefined
|
|
351
|
+
return requiredString(value)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function requiredString(value: unknown): string {
|
|
355
|
+
if (typeof value !== 'string' || value.trim().length === 0) invalidConfig()
|
|
356
|
+
return value.trim()
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function validName(value: string): boolean {
|
|
360
|
+
return value.length > 0 && value.length <= 128 && !/\s/.test(value)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
364
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
|
365
|
+
const prototype = Object.getPrototypeOf(value)
|
|
366
|
+
return prototype === Object.prototype || prototype === null
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function invalidConfig(): never {
|
|
370
|
+
throw new AIError({
|
|
371
|
+
code: 'invalid_config',
|
|
372
|
+
message: 'AI configuration is invalid',
|
|
373
|
+
})
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function invalidResponse(): never {
|
|
377
|
+
throw new AIError({
|
|
378
|
+
code: 'invalid_response',
|
|
379
|
+
message: 'AI provider returned an invalid structured response',
|
|
380
|
+
operation: 'object',
|
|
381
|
+
})
|
|
382
|
+
}
|