@ouraihub/aether-worker 0.0.0-stage → 0.1.1
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/LICENSE +789 -0
- package/migrations/0001.sql +22 -0
- package/package.json +38 -3
- package/src/app.ts +363 -0
- package/src/cloudflare.ts +49 -0
- package/src/context.ts +55 -0
- package/src/conversations/action.ts +257 -0
- package/src/conversations/index.ts +480 -0
- package/src/conversations/model.ts +427 -0
- package/src/conversations/routes.ts +101 -0
- package/src/coordinator-helpers.ts +131 -0
- package/src/coordinator.ts +488 -0
- package/src/credentials.ts +171 -0
- package/src/files.ts +117 -0
- package/src/host.ts +25 -0
- package/src/identity.ts +227 -0
- package/src/images/index.ts +73 -0
- package/src/index.ts +30 -0
- package/src/logger.ts +98 -0
- package/src/mcp/auth.ts +398 -0
- package/src/mcp/client.ts +491 -0
- package/src/mcp/common.ts +124 -0
- package/src/mcp/index.ts +292 -0
- package/src/mcp/oauth.ts +343 -0
- package/src/mcp/types.ts +81 -0
- package/src/outbound.ts +320 -0
- package/src/providers/catalog.ts +63 -0
- package/src/providers/http.ts +88 -0
- package/src/providers/index.ts +445 -0
- package/src/providers/model-options.ts +28 -0
- package/src/runtime-input.ts +44 -0
- package/src/runtime-tools.ts +298 -0
- package/src/runtime.ts +476 -0
- package/src/search/index.ts +497 -0
- package/src/skills/index.ts +1 -0
- package/src/skills/persistence.ts +153 -0
- package/src/skills/service.ts +250 -0
- package/src/storage.ts +293 -0
- package/src/testing/fakes.ts +97 -0
- package/README.md +0 -3
package/src/mcp/types.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { OutboundHttp } from '@ouraihub/aether-core'
|
|
2
|
+
import type { Actor } from '../context.js'
|
|
3
|
+
import type { CredentialPort } from '../credentials.js'
|
|
4
|
+
import type { DocumentStore } from '../storage.js'
|
|
5
|
+
|
|
6
|
+
export interface McpScope {
|
|
7
|
+
readonly id: string
|
|
8
|
+
readonly label: string
|
|
9
|
+
readonly destructive: boolean
|
|
10
|
+
}
|
|
11
|
+
export interface ExposedMcpTool {
|
|
12
|
+
readonly name: string
|
|
13
|
+
readonly description: string
|
|
14
|
+
readonly inputSchema: Readonly<Record<string, unknown>>
|
|
15
|
+
readonly scope: string
|
|
16
|
+
readonly readOnly: boolean
|
|
17
|
+
readonly destructive: boolean
|
|
18
|
+
readonly idempotent: boolean
|
|
19
|
+
readonly maxRequestBytes?: number
|
|
20
|
+
readonly validate: (args: Readonly<Record<string, unknown>>) => boolean
|
|
21
|
+
readonly handler: (
|
|
22
|
+
args: Readonly<Record<string, unknown>>,
|
|
23
|
+
context: McpCallContext,
|
|
24
|
+
) => Promise<unknown>
|
|
25
|
+
}
|
|
26
|
+
export interface McpCallContext {
|
|
27
|
+
readonly actor: Actor
|
|
28
|
+
readonly tokenId: string
|
|
29
|
+
readonly scopes: readonly string[]
|
|
30
|
+
readonly signal: AbortSignal
|
|
31
|
+
}
|
|
32
|
+
export interface McpServiceOptions {
|
|
33
|
+
readonly applicationId: string
|
|
34
|
+
readonly origin: string
|
|
35
|
+
readonly store: DocumentStore
|
|
36
|
+
readonly credentials: CredentialPort
|
|
37
|
+
readonly outbound: OutboundHttp
|
|
38
|
+
readonly scopes: readonly McpScope[]
|
|
39
|
+
readonly tools: readonly ExposedMcpTool[]
|
|
40
|
+
readonly resolveIdentity: (request: Request) => Promise<Actor | null>
|
|
41
|
+
/** Must check the actor may grant every requested scope, not just that login exists. */
|
|
42
|
+
readonly authorizeConsent: (
|
|
43
|
+
actor: Actor,
|
|
44
|
+
scopes: readonly string[],
|
|
45
|
+
request: Request,
|
|
46
|
+
) => Promise<boolean>
|
|
47
|
+
/** Must check resource/business access immediately before external tool dispatch. */
|
|
48
|
+
readonly authorizeTool: (
|
|
49
|
+
actor: Actor,
|
|
50
|
+
tool: ExposedMcpTool,
|
|
51
|
+
args: Readonly<Record<string, unknown>>,
|
|
52
|
+
signal: AbortSignal,
|
|
53
|
+
) => Promise<boolean>
|
|
54
|
+
readonly mcpOrigins?: readonly string[]
|
|
55
|
+
readonly now?: () => number
|
|
56
|
+
}
|
|
57
|
+
export interface McpSettings {
|
|
58
|
+
readonly enabled: boolean
|
|
59
|
+
readonly enabledScopes: readonly string[]
|
|
60
|
+
}
|
|
61
|
+
export interface GrantRecord {
|
|
62
|
+
readonly id: string
|
|
63
|
+
readonly applicationId: string
|
|
64
|
+
readonly tenantId: string | null
|
|
65
|
+
readonly subjectId: string
|
|
66
|
+
readonly clientId: string
|
|
67
|
+
readonly redirectUri: string
|
|
68
|
+
readonly scopes: readonly string[]
|
|
69
|
+
readonly createdAt: number
|
|
70
|
+
readonly expiresAt: number | null
|
|
71
|
+
readonly revokedAt: number | null
|
|
72
|
+
}
|
|
73
|
+
export interface ApiKeyRecord {
|
|
74
|
+
readonly id: string
|
|
75
|
+
readonly name: string
|
|
76
|
+
readonly scopes: readonly string[]
|
|
77
|
+
readonly prefix: string
|
|
78
|
+
readonly createdAt: number
|
|
79
|
+
readonly expiresAt: number | null
|
|
80
|
+
readonly revokedAt: number | null
|
|
81
|
+
}
|
package/src/outbound.ts
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { badRequest, KitFailure } from '@ouraihub/aether-core'
|
|
2
|
+
import type { OutboundHttp } from '@ouraihub/aether-core/ports'
|
|
3
|
+
import type { FetchBinding } from './cloudflare.ts'
|
|
4
|
+
|
|
5
|
+
export interface OutboundLimits {
|
|
6
|
+
readonly firstByteMs: number
|
|
7
|
+
readonly idleMs: number
|
|
8
|
+
readonly totalMs: number
|
|
9
|
+
readonly maxResponseBytes: number
|
|
10
|
+
}
|
|
11
|
+
/** This binding MUST enforce public-only DNS at connect time, including all resolved
|
|
12
|
+
* IPv4/IPv6 addresses and rebinding. A DNS preflight followed by ordinary fetch is
|
|
13
|
+
* not this contract. It must not follow redirects or forward through an open proxy.
|
|
14
|
+
* Deploy a trusted egress service binding; absent that binding remote egress fails.
|
|
15
|
+
*/
|
|
16
|
+
export interface TrustedOutboundGateway {
|
|
17
|
+
readonly security: 'public-addresses-at-connect-time'
|
|
18
|
+
readonly binding: FetchBinding
|
|
19
|
+
}
|
|
20
|
+
/** Separately deployed, target-bound internal gateway. It must enforce exact
|
|
21
|
+
* configured origin/port at connect time, including DNS, with no redirect/proxy. */
|
|
22
|
+
export interface TrustedPrivateOutboundGateway {
|
|
23
|
+
readonly security: 'explicit-private-targets-at-connect-time'
|
|
24
|
+
readonly binding: FetchBinding
|
|
25
|
+
readonly allowedOrigins: readonly string[]
|
|
26
|
+
}
|
|
27
|
+
export interface WorkerOutboundOptions {
|
|
28
|
+
readonly gateway?: TrustedOutboundGateway
|
|
29
|
+
readonly privateGateway?: TrustedPrivateOutboundGateway
|
|
30
|
+
readonly allowedOrigins: readonly string[]
|
|
31
|
+
readonly limits: OutboundLimits
|
|
32
|
+
/** Local integration tests only, literal loopback hosts; never hostname DNS. */
|
|
33
|
+
readonly allowLoopbackForTests?: boolean
|
|
34
|
+
readonly localFetch?: (url: string, init: RequestInit) => Promise<Response>
|
|
35
|
+
}
|
|
36
|
+
function isLoopback(hostname: string): boolean {
|
|
37
|
+
return hostname === '[::1]' || /^127(?:\.(?:\d{1,3})){3}$/u.test(hostname)
|
|
38
|
+
}
|
|
39
|
+
function privateLiteral(host: string): boolean {
|
|
40
|
+
if (host.startsWith('[')) {
|
|
41
|
+
const ip = host.slice(1, -1).toLowerCase()
|
|
42
|
+
// Public IPv6 unicast only; mapped/compatible IPv4, special and local ranges fail closed.
|
|
43
|
+
return (
|
|
44
|
+
!/^[23][0-9a-f]{0,3}:/u.test(ip) ||
|
|
45
|
+
ip.startsWith('2001:db8:') ||
|
|
46
|
+
ip.startsWith('2001:0:') ||
|
|
47
|
+
ip.startsWith('2002:')
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
if (!/^\d+(?:\.\d+){3}$/u.test(host))
|
|
51
|
+
return (
|
|
52
|
+
host === 'localhost' ||
|
|
53
|
+
host.endsWith('.localhost') ||
|
|
54
|
+
host.endsWith('.local') ||
|
|
55
|
+
!host.includes('.')
|
|
56
|
+
)
|
|
57
|
+
const parts = host.split('.').map(Number)
|
|
58
|
+
const a = parts[0],
|
|
59
|
+
b = parts[1],
|
|
60
|
+
c = parts[2]
|
|
61
|
+
return (
|
|
62
|
+
a === undefined ||
|
|
63
|
+
b === undefined ||
|
|
64
|
+
c === undefined ||
|
|
65
|
+
a === 0 ||
|
|
66
|
+
a === 10 ||
|
|
67
|
+
a === 127 ||
|
|
68
|
+
a >= 224 ||
|
|
69
|
+
(a === 100 && b >= 64 && b <= 127) ||
|
|
70
|
+
(a === 169 && b === 254) ||
|
|
71
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
72
|
+
(a === 192 && (b === 168 || b === 0 || b === 2)) ||
|
|
73
|
+
(a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
|
|
74
|
+
(a === 203 && b === 0 && c === 113)
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
export function createWorkerOutbound(options: WorkerOutboundOptions): OutboundHttp {
|
|
78
|
+
const limits = Object.freeze({ ...options.limits })
|
|
79
|
+
for (const value of Object.values(limits))
|
|
80
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw badRequest('outboundLimits', 'invalid')
|
|
81
|
+
const origins = new Set(
|
|
82
|
+
options.allowedOrigins.map((origin) => {
|
|
83
|
+
let url: URL
|
|
84
|
+
try {
|
|
85
|
+
url = new URL(origin)
|
|
86
|
+
} catch {
|
|
87
|
+
throw badRequest('allowedOrigins', 'invalid')
|
|
88
|
+
}
|
|
89
|
+
if (url.origin !== origin || url.username || url.password)
|
|
90
|
+
throw badRequest('allowedOrigins', 'invalid')
|
|
91
|
+
return origin
|
|
92
|
+
}),
|
|
93
|
+
)
|
|
94
|
+
if (origins.size === 0)
|
|
95
|
+
throw new KitFailure('needs_config', 'Outbound origins must be explicitly configured')
|
|
96
|
+
const privateOrigins = new Set(options.privateGateway?.allowedOrigins ?? [])
|
|
97
|
+
for (const origin of privateOrigins) {
|
|
98
|
+
let target: URL
|
|
99
|
+
try {
|
|
100
|
+
target = new URL(origin)
|
|
101
|
+
} catch {
|
|
102
|
+
throw badRequest('privateOrigins', 'invalid')
|
|
103
|
+
}
|
|
104
|
+
if (
|
|
105
|
+
target.origin !== origin ||
|
|
106
|
+
!origins.has(origin) ||
|
|
107
|
+
(target.protocol !== 'http:' && target.protocol !== 'https:')
|
|
108
|
+
)
|
|
109
|
+
throw badRequest('privateOrigins', 'invalid')
|
|
110
|
+
}
|
|
111
|
+
return Object.freeze({
|
|
112
|
+
async fetch(url: string, init: RequestInit): Promise<Response> {
|
|
113
|
+
let target: URL
|
|
114
|
+
try {
|
|
115
|
+
target = new URL(url)
|
|
116
|
+
} catch {
|
|
117
|
+
throw badRequest('url', 'invalid')
|
|
118
|
+
}
|
|
119
|
+
const local = options.allowLoopbackForTests === true && isLoopback(target.hostname)
|
|
120
|
+
const privateTarget = privateOrigins.has(target.origin)
|
|
121
|
+
if (
|
|
122
|
+
target.username ||
|
|
123
|
+
target.password ||
|
|
124
|
+
target.hash ||
|
|
125
|
+
(!local && !privateTarget && target.protocol !== 'https:') ||
|
|
126
|
+
((local || privateTarget) && target.protocol !== 'https:' && target.protocol !== 'http:') ||
|
|
127
|
+
(!local && !privateTarget && privateLiteral(target.hostname)) ||
|
|
128
|
+
!origins.has(target.origin)
|
|
129
|
+
)
|
|
130
|
+
throw badRequest('url', 'outbound_policy')
|
|
131
|
+
const gateway = privateTarget ? options.privateGateway : options.gateway
|
|
132
|
+
if (
|
|
133
|
+
!local &&
|
|
134
|
+
(!gateway ||
|
|
135
|
+
gateway.security !==
|
|
136
|
+
(privateTarget
|
|
137
|
+
? 'explicit-private-targets-at-connect-time'
|
|
138
|
+
: 'public-addresses-at-connect-time'))
|
|
139
|
+
)
|
|
140
|
+
throw new KitFailure('needs_config', 'Trusted connect-time outbound gateway is required')
|
|
141
|
+
const abort = new AbortController()
|
|
142
|
+
let failure: KitFailure | null = null
|
|
143
|
+
const firstTimer = setTimeout(
|
|
144
|
+
() => fail(new KitFailure('timeout', 'Outbound first-byte deadline exceeded')),
|
|
145
|
+
limits.firstByteMs,
|
|
146
|
+
)
|
|
147
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined
|
|
148
|
+
const totalTimer = setTimeout(
|
|
149
|
+
() => fail(new KitFailure('timeout', 'Outbound total deadline exceeded')),
|
|
150
|
+
limits.totalMs,
|
|
151
|
+
)
|
|
152
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
|
|
153
|
+
let bodyController: ReadableStreamDefaultController<Uint8Array> | undefined
|
|
154
|
+
let finished = false
|
|
155
|
+
function clean(): void {
|
|
156
|
+
clearTimeout(firstTimer)
|
|
157
|
+
clearTimeout(idleTimer ?? 0)
|
|
158
|
+
clearTimeout(totalTimer)
|
|
159
|
+
init.signal?.removeEventListener('abort', callerAbort)
|
|
160
|
+
abort.signal.removeEventListener('abort', onAbort)
|
|
161
|
+
}
|
|
162
|
+
function fail(error: KitFailure): void {
|
|
163
|
+
if (!abort.signal.aborted) {
|
|
164
|
+
failure = error
|
|
165
|
+
abort.abort()
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function callerAbort(): void {
|
|
169
|
+
fail(new KitFailure('cancelled', 'Outbound request cancelled'))
|
|
170
|
+
}
|
|
171
|
+
function onAbort(): void {
|
|
172
|
+
if (finished) return
|
|
173
|
+
finished = true
|
|
174
|
+
bodyController?.error(failure ?? new KitFailure('cancelled', 'Outbound request cancelled'))
|
|
175
|
+
if (reader) void reader.cancel().catch(() => {})
|
|
176
|
+
clean()
|
|
177
|
+
}
|
|
178
|
+
init.signal?.addEventListener('abort', callerAbort, { once: true })
|
|
179
|
+
abort.signal.addEventListener('abort', onAbort, { once: true })
|
|
180
|
+
if (init.signal?.aborted) callerAbort()
|
|
181
|
+
if (abort.signal.aborted)
|
|
182
|
+
throw failure ?? new KitFailure('cancelled', 'Outbound request cancelled')
|
|
183
|
+
try {
|
|
184
|
+
const headers = new Headers(init.headers)
|
|
185
|
+
headers.set('User-Agent', 'Aether-Worker/1')
|
|
186
|
+
const request: RequestInit & { credentials: 'omit' } = {
|
|
187
|
+
...init,
|
|
188
|
+
headers,
|
|
189
|
+
redirect: 'manual',
|
|
190
|
+
signal: abort.signal,
|
|
191
|
+
credentials: 'omit',
|
|
192
|
+
}
|
|
193
|
+
// Remote fetch is always the trusted binding. Global fetch is only the literal-loopback opt-in.
|
|
194
|
+
const pending = local
|
|
195
|
+
? (options.localFetch ?? fetch)(target.href, request)
|
|
196
|
+
: gateway?.binding.fetch(target.href, request)
|
|
197
|
+
if (!pending)
|
|
198
|
+
throw new KitFailure('needs_config', 'Trusted connect-time outbound gateway is required')
|
|
199
|
+
void pending.then(
|
|
200
|
+
(response) => {
|
|
201
|
+
if (abort.signal.aborted) void response.body?.cancel().catch(() => {})
|
|
202
|
+
},
|
|
203
|
+
() => {},
|
|
204
|
+
)
|
|
205
|
+
const aborted = Promise.withResolvers<never>()
|
|
206
|
+
const rejectAbort = () =>
|
|
207
|
+
aborted.reject(failure ?? new KitFailure('cancelled', 'Outbound request cancelled'))
|
|
208
|
+
abort.signal.addEventListener('abort', rejectAbort, { once: true })
|
|
209
|
+
let response: Response
|
|
210
|
+
try {
|
|
211
|
+
response = await Promise.race([pending, aborted.promise])
|
|
212
|
+
} finally {
|
|
213
|
+
abort.signal.removeEventListener('abort', rejectAbort)
|
|
214
|
+
}
|
|
215
|
+
if (abort.signal.aborted) {
|
|
216
|
+
void response.body?.cancel().catch(() => {})
|
|
217
|
+
throw failure
|
|
218
|
+
}
|
|
219
|
+
if (response.redirected || (response.url && response.url !== target.href)) {
|
|
220
|
+
void response.body?.cancel().catch(() => {})
|
|
221
|
+
throw badRequest('url', 'redirect_forbidden')
|
|
222
|
+
}
|
|
223
|
+
const length = response.headers.get('content-length')
|
|
224
|
+
if (
|
|
225
|
+
length !== null &&
|
|
226
|
+
(!/^\d+$/u.test(length) || Number(length) > limits.maxResponseBytes)
|
|
227
|
+
) {
|
|
228
|
+
void response.body?.cancel().catch(() => {})
|
|
229
|
+
throw badRequest('response', 'too_large', limits.maxResponseBytes)
|
|
230
|
+
}
|
|
231
|
+
if (!response.body) {
|
|
232
|
+
finished = true
|
|
233
|
+
clean()
|
|
234
|
+
return response
|
|
235
|
+
}
|
|
236
|
+
reader = response.body.getReader()
|
|
237
|
+
let received = 0
|
|
238
|
+
const body = new ReadableStream<Uint8Array>({
|
|
239
|
+
start(controller) {
|
|
240
|
+
bodyController = controller
|
|
241
|
+
},
|
|
242
|
+
async pull(controller) {
|
|
243
|
+
if (!reader || finished) return
|
|
244
|
+
idleTimer = setTimeout(
|
|
245
|
+
() => fail(new KitFailure('timeout', 'Outbound idle deadline exceeded')),
|
|
246
|
+
limits.idleMs,
|
|
247
|
+
)
|
|
248
|
+
try {
|
|
249
|
+
const result = await reader.read()
|
|
250
|
+
clearTimeout(idleTimer)
|
|
251
|
+
if (finished) return
|
|
252
|
+
if (result.done) {
|
|
253
|
+
finished = true
|
|
254
|
+
clean()
|
|
255
|
+
controller.close()
|
|
256
|
+
reader.releaseLock()
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
clearTimeout(firstTimer)
|
|
260
|
+
received += result.value.byteLength
|
|
261
|
+
if (received > limits.maxResponseBytes) {
|
|
262
|
+
fail(badRequest('response', 'too_large', limits.maxResponseBytes))
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
controller.enqueue(result.value)
|
|
266
|
+
} catch {
|
|
267
|
+
fail(new KitFailure('internal', 'Outbound response failed'))
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
async cancel() {
|
|
271
|
+
finished = true
|
|
272
|
+
clean()
|
|
273
|
+
abort.abort()
|
|
274
|
+
await reader?.cancel().catch(() => {})
|
|
275
|
+
},
|
|
276
|
+
})
|
|
277
|
+
return new Response(body, {
|
|
278
|
+
status: response.status,
|
|
279
|
+
statusText: response.statusText,
|
|
280
|
+
headers: response.headers,
|
|
281
|
+
})
|
|
282
|
+
} catch (error) {
|
|
283
|
+
finished = true
|
|
284
|
+
clean()
|
|
285
|
+
abort.abort()
|
|
286
|
+
if (error instanceof KitFailure) throw error
|
|
287
|
+
throw failure ?? new KitFailure('internal', 'Outbound request failed')
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export interface TrustedTargetChecker {
|
|
294
|
+
readonly security: 'all-dns-addresses-preflight'
|
|
295
|
+
/** Reject if any A/AAAA result is private/special/unknown, or lookup fails.
|
|
296
|
+
* The content proxy must independently enforce its own connect-time address policy. */
|
|
297
|
+
checkTarget(url: URL, signal: AbortSignal): Promise<void>
|
|
298
|
+
}
|
|
299
|
+
export function createWorkerTargetCheck(options: {
|
|
300
|
+
readonly checker: TrustedTargetChecker
|
|
301
|
+
readonly allowLoopbackForTests?: boolean
|
|
302
|
+
}): (url: URL, signal: AbortSignal) => Promise<void> {
|
|
303
|
+
if (!options.checker || options.checker.security !== 'all-dns-addresses-preflight')
|
|
304
|
+
throw new KitFailure('needs_config', 'Trusted DNS target checker is required')
|
|
305
|
+
return async (url: URL, signal: AbortSignal): Promise<void> => {
|
|
306
|
+
if (signal.aborted) throw new KitFailure('cancelled', 'Target check cancelled')
|
|
307
|
+
const local = options.allowLoopbackForTests === true && isLoopback(url.hostname)
|
|
308
|
+
if (
|
|
309
|
+
url.username ||
|
|
310
|
+
url.password ||
|
|
311
|
+
url.hash ||
|
|
312
|
+
(!local && url.protocol !== 'https:') ||
|
|
313
|
+
(local && url.protocol !== 'http:' && url.protocol !== 'https:') ||
|
|
314
|
+
(!local && privateLiteral(url.hostname))
|
|
315
|
+
)
|
|
316
|
+
throw badRequest('url', 'outbound_policy')
|
|
317
|
+
if (!local) await options.checker.checkTarget(new URL(url.href), signal)
|
|
318
|
+
if (signal.aborted) throw new KitFailure('cancelled', 'Target check cancelled')
|
|
319
|
+
}
|
|
320
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { isContract, type Provider } from '@ouraihub/aether-contracts'
|
|
2
|
+
import { KitFailure } from '@ouraihub/aether-core/error'
|
|
3
|
+
import { inferReasoning } from '@ouraihub/aether-core/providers/infer'
|
|
4
|
+
import { isSealedCredential, type SealedCredential } from '../credentials.js'
|
|
5
|
+
|
|
6
|
+
export interface Secret {
|
|
7
|
+
endpointId: string
|
|
8
|
+
keyId: string
|
|
9
|
+
sealed: SealedCredential
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Catalog {
|
|
13
|
+
providers: Provider[]
|
|
14
|
+
secrets: Secret[]
|
|
15
|
+
inferred: string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
19
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseCatalog(value: unknown): Catalog {
|
|
23
|
+
if (
|
|
24
|
+
!record(value) ||
|
|
25
|
+
!Array.isArray(value.providers) ||
|
|
26
|
+
!Array.isArray(value.secrets) ||
|
|
27
|
+
!Array.isArray(value.inferred)
|
|
28
|
+
)
|
|
29
|
+
throw new KitFailure('incompatible_db', 'Invalid provider catalog')
|
|
30
|
+
const providers: Provider[] = []
|
|
31
|
+
const secrets: Secret[] = []
|
|
32
|
+
const inferred: string[] = []
|
|
33
|
+
for (const item of value.providers) {
|
|
34
|
+
if (!isContract('Provider', item))
|
|
35
|
+
throw new KitFailure('incompatible_db', 'Invalid provider document')
|
|
36
|
+
providers.push(item)
|
|
37
|
+
}
|
|
38
|
+
for (const item of value.secrets) {
|
|
39
|
+
if (
|
|
40
|
+
!record(item) ||
|
|
41
|
+
typeof item.endpointId !== 'string' ||
|
|
42
|
+
typeof item.keyId !== 'string' ||
|
|
43
|
+
!isSealedCredential(item.sealed)
|
|
44
|
+
)
|
|
45
|
+
throw new KitFailure('incompatible_db', 'Invalid provider credential')
|
|
46
|
+
secrets.push({ endpointId: item.endpointId, keyId: item.keyId, sealed: item.sealed })
|
|
47
|
+
}
|
|
48
|
+
for (const item of value.inferred) {
|
|
49
|
+
if (typeof item !== 'string')
|
|
50
|
+
throw new KitFailure('incompatible_db', 'Invalid reasoning declaration')
|
|
51
|
+
inferred.push(item)
|
|
52
|
+
}
|
|
53
|
+
const automatic = new Set(inferred)
|
|
54
|
+
for (const provider of providers)
|
|
55
|
+
for (const endpoint of provider.endpoints)
|
|
56
|
+
for (const model of endpoint.models)
|
|
57
|
+
if (automatic.has(JSON.stringify([endpoint.id, model.id])))
|
|
58
|
+
model.reasoning =
|
|
59
|
+
model.modelType === 'chat'
|
|
60
|
+
? inferReasoning(model.id, endpoint.protocol, endpoint.baseUrl, model.maxOutput)
|
|
61
|
+
: null
|
|
62
|
+
return { providers, secrets, inferred }
|
|
63
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isContract,
|
|
3
|
+
type ModelFilter,
|
|
4
|
+
type ProviderSave,
|
|
5
|
+
type EndpointKeySave,
|
|
6
|
+
type ProbeTarget,
|
|
7
|
+
} from '@ouraihub/aether-contracts'
|
|
8
|
+
import { KitFailure, badRequest } from '@ouraihub/aether-core/error'
|
|
9
|
+
import type { WorkerContext } from '../context.js'
|
|
10
|
+
import type { ProviderService } from './index.js'
|
|
11
|
+
|
|
12
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
13
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function handleProviderRequest(
|
|
17
|
+
service: ProviderService,
|
|
18
|
+
request: Request,
|
|
19
|
+
context: WorkerContext,
|
|
20
|
+
authorizeReveal: (request: Request, context: WorkerContext) => Promise<void>,
|
|
21
|
+
): Promise<Response | null> {
|
|
22
|
+
const url = new URL(request.url)
|
|
23
|
+
const path = url.pathname
|
|
24
|
+
const root = '/api/v1/providers'
|
|
25
|
+
if (path !== root && !path.startsWith(`${root}/`)) return null
|
|
26
|
+
const actor = context.actor
|
|
27
|
+
const json = (value: unknown): Response => Response.json(value)
|
|
28
|
+
if (path === root && request.method === 'GET') return json(await service.list(actor))
|
|
29
|
+
if (path === root && request.method === 'POST') {
|
|
30
|
+
const value: unknown = await request.json()
|
|
31
|
+
if (!isContract('ProviderSave', value)) throw badRequest('provider', 'invalid')
|
|
32
|
+
return json(await service.save(actor, value as ProviderSave))
|
|
33
|
+
}
|
|
34
|
+
if (path === `${root}/model-options` && request.method === 'GET') {
|
|
35
|
+
const value: unknown = {
|
|
36
|
+
...(url.searchParams.has('modelType')
|
|
37
|
+
? { modelType: url.searchParams.get('modelType') }
|
|
38
|
+
: {}),
|
|
39
|
+
...(url.searchParams.has('supportsTools')
|
|
40
|
+
? { supportsTools: url.searchParams.get('supportsTools') === 'true' }
|
|
41
|
+
: {}),
|
|
42
|
+
}
|
|
43
|
+
if (
|
|
44
|
+
url.searchParams.has('supportsTools') &&
|
|
45
|
+
!['true', 'false'].includes(url.searchParams.get('supportsTools') ?? '')
|
|
46
|
+
)
|
|
47
|
+
throw badRequest('supportsTools', 'invalid')
|
|
48
|
+
if (!isContract('ModelFilter', value)) throw badRequest('filter', 'invalid')
|
|
49
|
+
return json(await service.modelOptions(actor, value as ModelFilter))
|
|
50
|
+
}
|
|
51
|
+
if (path === `${root}/probe` && request.method === 'POST') {
|
|
52
|
+
const value: unknown = await request.json()
|
|
53
|
+
if (!isContract('ProbeTarget', value)) throw badRequest('target', 'invalid')
|
|
54
|
+
return json(await service.probe(actor, value as ProbeTarget, context.signal))
|
|
55
|
+
}
|
|
56
|
+
if (path === `${root}/keys` && request.method === 'POST') {
|
|
57
|
+
const value: unknown = await request.json()
|
|
58
|
+
if (!isContract('EndpointKeySave', value)) throw badRequest('key', 'invalid')
|
|
59
|
+
return json(await service.saveKey(actor, value as EndpointKeySave))
|
|
60
|
+
}
|
|
61
|
+
const reveal = path.match(/^\/api\/v1\/providers\/keys\/([^/]+)\/reveal$/)
|
|
62
|
+
if (reveal && request.method === 'POST') {
|
|
63
|
+
await authorizeReveal(request, context)
|
|
64
|
+
return json(await service.revealKey(actor, decodeURIComponent(reveal[1] ?? '')))
|
|
65
|
+
}
|
|
66
|
+
const key = path.match(/^\/api\/v1\/providers\/([^/]+)\/keys\/([^/]+)$/)
|
|
67
|
+
if (key && request.method === 'DELETE')
|
|
68
|
+
return json(
|
|
69
|
+
await service.removeKey(
|
|
70
|
+
actor,
|
|
71
|
+
decodeURIComponent(key[1] ?? ''),
|
|
72
|
+
decodeURIComponent(key[2] ?? ''),
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
const enabled = path.match(/^\/api\/v1\/providers\/([^/]+)\/enabled$/)
|
|
76
|
+
if (enabled && request.method === 'POST') {
|
|
77
|
+
const value: unknown = await request.json()
|
|
78
|
+
if (!record(value) || Object.keys(value).length !== 1 || typeof value.enabled !== 'boolean')
|
|
79
|
+
throw badRequest('enabled', 'invalid')
|
|
80
|
+
return json(
|
|
81
|
+
await service.setEnabled(actor, decodeURIComponent(enabled[1] ?? ''), value.enabled),
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
const provider = path.match(/^\/api\/v1\/providers\/([^/]+)$/)
|
|
85
|
+
if (provider && request.method === 'DELETE')
|
|
86
|
+
return json(await service.remove(actor, decodeURIComponent(provider[1] ?? '')))
|
|
87
|
+
throw new KitFailure('not_found', 'Unknown route', { entity: 'route' })
|
|
88
|
+
}
|