@open-mercato/ai-assistant 0.8.1-develop.7296.1.2111d779db → 0.9.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/.turbo/turbo-build.log +1 -1
- package/README.md +5 -0
- package/dist/modules/ai_assistant/data/repositories/AiChatConversationRepository.js.map +2 -2
- package/dist/modules/ai_assistant/data/repositories/AiPendingActionRepository.js.map +1 -1
- package/dist/modules/ai_assistant/encryption.js +31 -0
- package/dist/modules/ai_assistant/encryption.js.map +7 -0
- package/dist/modules/ai_assistant/lib/ai-api-operation-runner.js +20 -9
- package/dist/modules/ai_assistant/lib/ai-api-operation-runner.js.map +2 -2
- package/dist/modules/ai_assistant/lib/generated-registry-loader.js +30 -2
- package/dist/modules/ai_assistant/lib/generated-registry-loader.js.map +2 -2
- package/dist/modules/ai_assistant/lib/llm-adapters/openai.js +2 -1
- package/dist/modules/ai_assistant/lib/llm-adapters/openai.js.map +2 -2
- package/dist/modules/ai_assistant/lib/openai-compatible-presets.js +1 -0
- package/dist/modules/ai_assistant/lib/openai-compatible-presets.js.map +2 -2
- package/dist/modules/ai_assistant/widgets/notifications/ConversationSharedRenderer.js +2 -2
- package/dist/modules/ai_assistant/widgets/notifications/ConversationSharedRenderer.js.map +2 -2
- package/package.json +7 -8
- package/src/modules/ai_assistant/__tests__/encryption-map.test.ts +175 -0
- package/src/modules/ai_assistant/data/repositories/AiChatConversationRepository.ts +5 -4
- package/src/modules/ai_assistant/data/repositories/AiPendingActionRepository.ts +3 -3
- package/src/modules/ai_assistant/encryption.ts +29 -0
- package/src/modules/ai_assistant/lib/__tests__/ai-api-operation-runner.test.ts +154 -0
- package/src/modules/ai_assistant/lib/__tests__/generated-registry-loader.test.ts +107 -0
- package/src/modules/ai_assistant/lib/__tests__/llm-adapters-openai.test.ts +73 -0
- package/src/modules/ai_assistant/lib/ai-api-operation-runner.ts +22 -11
- package/src/modules/ai_assistant/lib/generated-registry-loader.ts +48 -4
- package/src/modules/ai_assistant/lib/llm-adapters/openai.ts +20 -2
- package/src/modules/ai_assistant/lib/openai-compatible-presets.ts +1 -0
- package/src/modules/ai_assistant/widgets/notifications/ConversationSharedRenderer.tsx +2 -2
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import { createAiApiOperationRunner, normalizePath, type AiToolExecutionContext } from '../ai-api-operation-runner'
|
|
3
3
|
import type { ApiRouteManifestEntry } from '@open-mercato/shared/modules/registry'
|
|
4
|
+
import { registerApiInterceptors } from '@open-mercato/shared/lib/crud/interceptor-registry'
|
|
5
|
+
import {
|
|
6
|
+
runApiInterceptorsAfter,
|
|
7
|
+
runApiInterceptorsBefore,
|
|
8
|
+
} from '@open-mercato/shared/lib/crud/interceptor-runner'
|
|
9
|
+
import { resolveApiInterceptorRoutePath } from '@open-mercato/shared/lib/modules/api-route-identity'
|
|
4
10
|
import {
|
|
5
11
|
TRUSTED_AUTH_CONTEXT_SYMBOL,
|
|
6
12
|
resolveAuthFromRequestDetailed,
|
|
@@ -52,6 +58,7 @@ describe('createAiApiOperationRunner', () => {
|
|
|
52
58
|
let fetchSpy: jest.SpyInstance | null = null
|
|
53
59
|
|
|
54
60
|
beforeEach(() => {
|
|
61
|
+
registerApiInterceptors([])
|
|
55
62
|
if (typeof globalThis.fetch === 'function') {
|
|
56
63
|
fetchSpy = jest.spyOn(globalThis, 'fetch').mockImplementation(() => {
|
|
57
64
|
throw new Error('fetch must not be called by the in-process runner')
|
|
@@ -66,6 +73,7 @@ describe('createAiApiOperationRunner', () => {
|
|
|
66
73
|
})
|
|
67
74
|
|
|
68
75
|
afterEach(() => {
|
|
76
|
+
registerApiInterceptors([])
|
|
69
77
|
fetchSpy?.mockRestore?.()
|
|
70
78
|
fetchSpy = null
|
|
71
79
|
})
|
|
@@ -350,6 +358,152 @@ describe('createAiApiOperationRunner', () => {
|
|
|
350
358
|
expect(captured!.params.itemId).toBe('abc-123')
|
|
351
359
|
})
|
|
352
360
|
|
|
361
|
+
it.each([
|
|
362
|
+
['mixed-case', '/CUSTOMERS/PEOPLE'],
|
|
363
|
+
['percent-encoded', '/customers/%70eople'],
|
|
364
|
+
])('binds the authored manifest identity for a %s synthetic request', async (_label, path) => {
|
|
365
|
+
registerApiInterceptors([{
|
|
366
|
+
moduleId: 'test',
|
|
367
|
+
interceptors: [
|
|
368
|
+
{
|
|
369
|
+
id: 'test.ai-exact-policy',
|
|
370
|
+
targetRoute: 'customers/people',
|
|
371
|
+
methods: ['GET', 'POST'],
|
|
372
|
+
priority: 10,
|
|
373
|
+
async before(request) {
|
|
374
|
+
if (request.method === 'POST') {
|
|
375
|
+
return { ok: false, statusCode: 451, message: 'Denied by canonical policy' }
|
|
376
|
+
}
|
|
377
|
+
return { ok: true }
|
|
378
|
+
},
|
|
379
|
+
async after() {
|
|
380
|
+
return { merge: { exactPolicyApplied: true } }
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
id: 'test.ai-prefix-policy',
|
|
385
|
+
targetRoute: 'customers/*',
|
|
386
|
+
methods: ['GET', 'POST'],
|
|
387
|
+
priority: 20,
|
|
388
|
+
async before() {
|
|
389
|
+
return { ok: true }
|
|
390
|
+
},
|
|
391
|
+
async after() {
|
|
392
|
+
return { merge: { prefixPolicyApplied: true } }
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
],
|
|
396
|
+
}])
|
|
397
|
+
const handler = jest.fn(async (request: Request) => {
|
|
398
|
+
const routePath = resolveApiInterceptorRoutePath(request)
|
|
399
|
+
if (routePath === null) return new Response('{}', { status: 400 })
|
|
400
|
+
const method = request.method === 'POST' ? 'POST' : 'GET'
|
|
401
|
+
const requestPayload = {
|
|
402
|
+
method,
|
|
403
|
+
url: request.url,
|
|
404
|
+
body: {},
|
|
405
|
+
query: {},
|
|
406
|
+
headers: {},
|
|
407
|
+
}
|
|
408
|
+
const context = {
|
|
409
|
+
userId: 'user-1',
|
|
410
|
+
tenantId: 'tenant-1',
|
|
411
|
+
organizationId: 'org-1',
|
|
412
|
+
em: {} as never,
|
|
413
|
+
container: {} as never,
|
|
414
|
+
userFeatures: ['*'],
|
|
415
|
+
}
|
|
416
|
+
const before = await runApiInterceptorsBefore({
|
|
417
|
+
routePath,
|
|
418
|
+
method,
|
|
419
|
+
request: requestPayload,
|
|
420
|
+
context,
|
|
421
|
+
})
|
|
422
|
+
if (!before.ok) {
|
|
423
|
+
return new Response(JSON.stringify(before.body), {
|
|
424
|
+
status: before.statusCode,
|
|
425
|
+
headers: { 'content-type': 'application/json' },
|
|
426
|
+
})
|
|
427
|
+
}
|
|
428
|
+
const after = await runApiInterceptorsAfter({
|
|
429
|
+
routePath,
|
|
430
|
+
method,
|
|
431
|
+
request: before.request,
|
|
432
|
+
response: { statusCode: 200, body: { routePath }, headers: {} },
|
|
433
|
+
context,
|
|
434
|
+
metadataByInterceptor: before.metadataByInterceptor,
|
|
435
|
+
})
|
|
436
|
+
return new Response(JSON.stringify(after.body), {
|
|
437
|
+
status: after.statusCode,
|
|
438
|
+
headers: { 'content-type': 'application/json' },
|
|
439
|
+
})
|
|
440
|
+
})
|
|
441
|
+
const apiRoutes: ApiRouteManifestEntry[] = [
|
|
442
|
+
makeManifestEntry({
|
|
443
|
+
path: '/customers/people',
|
|
444
|
+
methods: ['GET', 'POST'],
|
|
445
|
+
load: async () => ({
|
|
446
|
+
POST: handler,
|
|
447
|
+
GET: handler,
|
|
448
|
+
openApi: { tag: 'Customers', methods: { GET: {}, POST: {} } },
|
|
449
|
+
metadata: {
|
|
450
|
+
GET: { requireAuth: true, requireFeatures: ['customers.people.manage'] },
|
|
451
|
+
POST: { requireAuth: true, requireFeatures: ['customers.people.manage'] },
|
|
452
|
+
},
|
|
453
|
+
}),
|
|
454
|
+
}),
|
|
455
|
+
]
|
|
456
|
+
const tool = makeTool({
|
|
457
|
+
name: 'customers.create_person',
|
|
458
|
+
requiredFeatures: ['customers.people.manage'],
|
|
459
|
+
isMutation: true,
|
|
460
|
+
})
|
|
461
|
+
const runner = createAiApiOperationRunner(makeCtx(tool), { apiRoutes })
|
|
462
|
+
|
|
463
|
+
const result = await runner.run({ method: 'POST', path, body: { name: 'Taylor' } })
|
|
464
|
+
const allowed = await runner.run({ method: 'GET', path })
|
|
465
|
+
|
|
466
|
+
expect(result).toMatchObject({
|
|
467
|
+
success: false,
|
|
468
|
+
statusCode: 451,
|
|
469
|
+
error: 'Denied by canonical policy',
|
|
470
|
+
})
|
|
471
|
+
expect(allowed).toEqual({
|
|
472
|
+
success: true,
|
|
473
|
+
statusCode: 200,
|
|
474
|
+
data: {
|
|
475
|
+
routePath: 'customers/people',
|
|
476
|
+
exactPolicyApplied: true,
|
|
477
|
+
prefixPolicyApplied: true,
|
|
478
|
+
},
|
|
479
|
+
})
|
|
480
|
+
expect(handler).toHaveBeenCalledTimes(2)
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
it('fails malformed synthetic request encoding before dispatch', async () => {
|
|
484
|
+
const handler = jest.fn(async () => new Response('{}', {
|
|
485
|
+
status: 200,
|
|
486
|
+
headers: { 'content-type': 'application/json' },
|
|
487
|
+
}))
|
|
488
|
+
const apiRoutes: ApiRouteManifestEntry[] = [
|
|
489
|
+
makeManifestEntry({
|
|
490
|
+
path: '/customers/[id]',
|
|
491
|
+
methods: ['GET'],
|
|
492
|
+
load: async () => ({
|
|
493
|
+
GET: handler,
|
|
494
|
+
openApi: { tag: 'Customers', methods: { GET: {} } },
|
|
495
|
+
}),
|
|
496
|
+
}),
|
|
497
|
+
]
|
|
498
|
+
const runner = createAiApiOperationRunner(makeCtx(makeTool({ name: 'customers.get' })), { apiRoutes })
|
|
499
|
+
|
|
500
|
+
const result = await runner.run({ method: 'GET', path: '/customers/%ZZ' })
|
|
501
|
+
|
|
502
|
+
expect(result.success).toBe(false)
|
|
503
|
+
expect(result.statusCode).toBe(400)
|
|
504
|
+
expect(handler).not.toHaveBeenCalled()
|
|
505
|
+
})
|
|
506
|
+
|
|
353
507
|
it('falls back to default export for legacy route entries', async () => {
|
|
354
508
|
const legacyHandler = jest.fn(async () => new Response(
|
|
355
509
|
JSON.stringify({ ok: true }),
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
findGeneratedFile,
|
|
11
11
|
compileAndImportGenerated,
|
|
12
12
|
ensureApiRouteManifestsRegistered,
|
|
13
|
+
resolveAppAliasPath,
|
|
14
|
+
createAppAliasPlugin,
|
|
13
15
|
} from '../generated-registry-loader'
|
|
14
16
|
|
|
15
17
|
function makeTempDir(): string {
|
|
@@ -95,6 +97,19 @@ describe('rewriteGeneratedAliasImports', () => {
|
|
|
95
97
|
expect(out).toBe(`import x from ${safeJsLiteral(expectedUrl)}`)
|
|
96
98
|
})
|
|
97
99
|
|
|
100
|
+
// Regression for #6575: apps whose tsconfig maps `@/*` to `./src/*` must
|
|
101
|
+
// resolve `@/...` against src/ first, not the app root.
|
|
102
|
+
it('resolves a `@/...` import to the app src/ directory when the target lives there', () => {
|
|
103
|
+
const appRoot = makeTempDir()
|
|
104
|
+
fs.mkdirSync(path.join(appRoot, 'src', 'lib'), { recursive: true })
|
|
105
|
+
fs.writeFileSync(path.join(appRoot, 'src', 'lib', 'helpers.ts'), 'export const a = 1\n')
|
|
106
|
+
|
|
107
|
+
const out = rewriteGeneratedAliasImports(`import { a } from '@/lib/helpers'`, appRoot)
|
|
108
|
+
|
|
109
|
+
const expectedUrl = pathToFileURL(path.join(appRoot, 'src', 'lib', 'helpers.ts')).href
|
|
110
|
+
expect(out).toBe(`import { a } from ${safeJsLiteral(expectedUrl)}`)
|
|
111
|
+
})
|
|
112
|
+
|
|
98
113
|
it('leaves non-alias imports untouched', () => {
|
|
99
114
|
const source = [
|
|
100
115
|
`import { z } from 'zod'`,
|
|
@@ -169,6 +184,98 @@ describe('rewriteGeneratedAliasImports', () => {
|
|
|
169
184
|
})
|
|
170
185
|
})
|
|
171
186
|
|
|
187
|
+
describe('resolveAppAliasPath', () => {
|
|
188
|
+
it('prefers src/ over the app root for ordinary alias paths', () => {
|
|
189
|
+
const appRoot = makeTempDir()
|
|
190
|
+
fs.mkdirSync(path.join(appRoot, 'src', 'lib'), { recursive: true })
|
|
191
|
+
fs.mkdirSync(path.join(appRoot, 'lib'), { recursive: true })
|
|
192
|
+
fs.writeFileSync(path.join(appRoot, 'src', 'lib', 'x.ts'), '')
|
|
193
|
+
fs.writeFileSync(path.join(appRoot, 'lib', 'x.ts'), '')
|
|
194
|
+
|
|
195
|
+
expect(resolveAppAliasPath(appRoot, 'lib/x')).toBe(path.join(appRoot, 'src', 'lib', 'x.ts'))
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('falls back to the app root for apps that keep their sources there', () => {
|
|
199
|
+
const appRoot = makeTempDir()
|
|
200
|
+
fs.mkdirSync(path.join(appRoot, 'modules', 'demo'), { recursive: true })
|
|
201
|
+
fs.writeFileSync(path.join(appRoot, 'modules', 'demo', 'index.ts'), '')
|
|
202
|
+
|
|
203
|
+
expect(resolveAppAliasPath(appRoot, 'modules/demo')).toBe(
|
|
204
|
+
path.join(appRoot, 'modules', 'demo', 'index.ts'),
|
|
205
|
+
)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('maps `.mercato/*` to the app root only', () => {
|
|
209
|
+
const appRoot = makeTempDir()
|
|
210
|
+
fs.mkdirSync(path.join(appRoot, 'src', '.mercato', 'generated'), { recursive: true })
|
|
211
|
+
fs.writeFileSync(path.join(appRoot, 'src', '.mercato', 'generated', 'entities.ts'), '')
|
|
212
|
+
|
|
213
|
+
expect(resolveAppAliasPath(appRoot, '.mercato/generated/entities')).toBeNull()
|
|
214
|
+
|
|
215
|
+
fs.mkdirSync(path.join(appRoot, '.mercato', 'generated'), { recursive: true })
|
|
216
|
+
fs.writeFileSync(path.join(appRoot, '.mercato', 'generated', 'entities.ts'), '')
|
|
217
|
+
expect(resolveAppAliasPath(appRoot, '.mercato/generated/entities')).toBe(
|
|
218
|
+
path.join(appRoot, '.mercato', 'generated', 'entities.ts'),
|
|
219
|
+
)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('returns null when nothing matches', () => {
|
|
223
|
+
expect(resolveAppAliasPath(makeTempDir(), 'lib/missing')).toBeNull()
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
describe('createAppAliasPlugin', () => {
|
|
228
|
+
// Regression for #6575: `bundleLocalModules` bundled @app module sources with
|
|
229
|
+
// `alias: { '@': appRoot }`, so every `@/lib/...` / `@/modules/...` import in
|
|
230
|
+
// an app with `@/*` -> `./src/*` failed with esbuild "Could not resolve".
|
|
231
|
+
it('bundles an @app module whose sources import through `@/` into src/', async () => {
|
|
232
|
+
const appRoot = makeTempAppRoot()
|
|
233
|
+
const generatedDir = path.join(appRoot, '.mercato', 'generated')
|
|
234
|
+
const moduleDir = path.join(appRoot, 'src', 'modules', 'example')
|
|
235
|
+
fs.mkdirSync(generatedDir, { recursive: true })
|
|
236
|
+
fs.mkdirSync(moduleDir, { recursive: true })
|
|
237
|
+
fs.mkdirSync(path.join(appRoot, 'src', 'lib'), { recursive: true })
|
|
238
|
+
fs.writeFileSync(path.join(appRoot, 'src', 'lib', 'toolName.ts'), 'export const TOOL_NAME = "example.from_src_lib"\n')
|
|
239
|
+
fs.writeFileSync(
|
|
240
|
+
path.join(moduleDir, 'ai-tools.ts'),
|
|
241
|
+
[`import { TOOL_NAME } from '@/lib/toolName'`, 'export const aiTools = [{ name: TOOL_NAME }]'].join('\n'),
|
|
242
|
+
)
|
|
243
|
+
const generatedPath = path.join(generatedDir, 'ai-tools.generated.ts')
|
|
244
|
+
fs.writeFileSync(
|
|
245
|
+
generatedPath,
|
|
246
|
+
[
|
|
247
|
+
'import * as ExampleTools from "../../src/modules/example/ai-tools"',
|
|
248
|
+
'export const aiToolConfigEntries = ExampleTools.aiTools',
|
|
249
|
+
].join('\n'),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
const esbuild = await import('esbuild')
|
|
253
|
+
const result = await esbuild.build({
|
|
254
|
+
entryPoints: [generatedPath],
|
|
255
|
+
bundle: true,
|
|
256
|
+
packages: 'external',
|
|
257
|
+
format: 'esm',
|
|
258
|
+
platform: 'node',
|
|
259
|
+
write: false,
|
|
260
|
+
logLevel: 'silent',
|
|
261
|
+
plugins: [createAppAliasPlugin(appRoot)],
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
expect(result.errors).toEqual([])
|
|
265
|
+
expect(result.outputFiles[0].text).toContain('example.from_src_lib')
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('wires the alias plugin into the bundling compile path', () => {
|
|
269
|
+
const loaderSource = fs.readFileSync(
|
|
270
|
+
path.join(__dirname, '..', 'generated-registry-loader.ts'),
|
|
271
|
+
'utf8',
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
expect(loaderSource).toContain('plugins: [createAppAliasPlugin(appRoot)]')
|
|
275
|
+
expect(loaderSource).not.toMatch(/alias:\s*\{\s*'@'/)
|
|
276
|
+
})
|
|
277
|
+
})
|
|
278
|
+
|
|
172
279
|
describe('collectAppLocalSpecifiers', () => {
|
|
173
280
|
it('collects static and dynamic @app specifiers once each, and nothing else', () => {
|
|
174
281
|
const source = [
|
|
@@ -367,3 +367,76 @@ describe('OpenAIAdapter — safety identifier + moderation capability', () => {
|
|
|
367
367
|
expect(provider.supportsInputModeration).toBe(false)
|
|
368
368
|
})
|
|
369
369
|
})
|
|
370
|
+
|
|
371
|
+
describe('OpenAIAdapter wire protocol (apiMode)', () => {
|
|
372
|
+
type ModelWithProvider = { provider: string }
|
|
373
|
+
|
|
374
|
+
function modelProvider(preset: OpenAICompatiblePreset): string {
|
|
375
|
+
const model = createOpenAICompatibleProvider(preset).createModel({
|
|
376
|
+
apiKey: 'test-key',
|
|
377
|
+
modelId: preset.defaultModel || 'local-model',
|
|
378
|
+
}) as ModelWithProvider
|
|
379
|
+
return model.provider
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
afterEach(() => {
|
|
383
|
+
jest.restoreAllMocks()
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('builds a Chat Completions model when the preset omits apiMode', () => {
|
|
387
|
+
expect(modelProvider(DEEPINFRA_PRESET)).toBe('openai.chat')
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
it('builds a Responses API model when the preset opts into apiMode "responses"', () => {
|
|
391
|
+
expect(modelProvider({ ...DEEPINFRA_PRESET, apiMode: 'responses' })).toBe('openai.responses')
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('keeps the native OpenAI preset on the Responses API and every compatible preset on Chat Completions', () => {
|
|
395
|
+
for (const preset of OPENAI_COMPATIBLE_PRESETS) {
|
|
396
|
+
const expected = preset.id === 'openai' ? 'openai.responses' : 'openai.chat'
|
|
397
|
+
expect({ id: preset.id, provider: modelProvider(preset) }).toEqual({
|
|
398
|
+
id: preset.id,
|
|
399
|
+
provider: expected,
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('posts DeepInfra turns to /chat/completions instead of /responses', async () => {
|
|
405
|
+
const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
406
|
+
new Response(
|
|
407
|
+
JSON.stringify({
|
|
408
|
+
id: 'chatcmpl-1',
|
|
409
|
+
object: 'chat.completion',
|
|
410
|
+
created: 0,
|
|
411
|
+
model: 'zai-org/GLM-5.1',
|
|
412
|
+
choices: [
|
|
413
|
+
{
|
|
414
|
+
index: 0,
|
|
415
|
+
message: { role: 'assistant', content: 'ok' },
|
|
416
|
+
finish_reason: 'stop',
|
|
417
|
+
},
|
|
418
|
+
],
|
|
419
|
+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
|
420
|
+
}),
|
|
421
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
422
|
+
),
|
|
423
|
+
)
|
|
424
|
+
const model = createOpenAICompatibleProvider(DEEPINFRA_PRESET).createModel({
|
|
425
|
+
apiKey: 'test-key',
|
|
426
|
+
modelId: 'zai-org/GLM-5.1',
|
|
427
|
+
}) as {
|
|
428
|
+
doGenerate: (options: {
|
|
429
|
+
prompt: Array<{ role: 'user'; content: Array<{ type: 'text'; text: string }> }>
|
|
430
|
+
}) => Promise<unknown>
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
await model.doGenerate({
|
|
434
|
+
prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
|
438
|
+
expect(String(fetchSpy.mock.calls[0][0])).toBe(
|
|
439
|
+
'https://api.deepinfra.com/v1/openai/chat/completions',
|
|
440
|
+
)
|
|
441
|
+
})
|
|
442
|
+
})
|
|
@@ -100,6 +100,14 @@ function buildUrl(path: string, query?: AiApiOperationRequest['query']): URL {
|
|
|
100
100
|
return url
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
function decodePathForManifestMatch(path: string): string | null {
|
|
104
|
+
try {
|
|
105
|
+
return path.split('/').map((segment) => decodeURIComponent(segment)).join('/')
|
|
106
|
+
} catch {
|
|
107
|
+
return null
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
103
111
|
function buildAuthEnvelope(ctx: AiToolExecutionContext): TrustedAuthContextEnvelope {
|
|
104
112
|
const userId = ctx.userId
|
|
105
113
|
if (!userId) {
|
|
@@ -231,6 +239,19 @@ export function createAiApiOperationRunner(
|
|
|
231
239
|
return failure(400, message) as AiApiOperationResponse<T>
|
|
232
240
|
}
|
|
233
241
|
const path = normalizePath(request.path)
|
|
242
|
+
const manifestPath = decodePathForManifestMatch(path)
|
|
243
|
+
if (manifestPath === null) {
|
|
244
|
+
return failure(400, `Invalid path encoding for ${method} ${path}`) as AiApiOperationResponse<T>
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const url = buildUrl(path, request.query)
|
|
248
|
+
const headers = new Headers()
|
|
249
|
+
const requestInit: RequestInit = { method, headers }
|
|
250
|
+
if (request.body !== undefined && method !== 'GET') {
|
|
251
|
+
headers.set('content-type', 'application/json')
|
|
252
|
+
requestInit.body = JSON.stringify(request.body)
|
|
253
|
+
}
|
|
254
|
+
const syntheticRequest = new Request(url, requestInit)
|
|
234
255
|
|
|
235
256
|
let routes: ApiRouteManifestEntry[]
|
|
236
257
|
try {
|
|
@@ -240,7 +261,7 @@ export function createAiApiOperationRunner(
|
|
|
240
261
|
return failure(500, `Operation runner manifest unavailable: ${message}`) as AiApiOperationResponse<T>
|
|
241
262
|
}
|
|
242
263
|
|
|
243
|
-
const match = findApiRouteManifestMatch(routes, method,
|
|
264
|
+
const match = findApiRouteManifestMatch(routes, method, manifestPath, syntheticRequest)
|
|
244
265
|
if (!match) {
|
|
245
266
|
return failure(
|
|
246
267
|
404,
|
|
@@ -291,15 +312,6 @@ export function createAiApiOperationRunner(
|
|
|
291
312
|
) as AiApiOperationResponse<T>
|
|
292
313
|
}
|
|
293
314
|
|
|
294
|
-
const url = buildUrl(path, request.query)
|
|
295
|
-
const headers = new Headers()
|
|
296
|
-
const requestInit: RequestInit = { method, headers }
|
|
297
|
-
if (request.body !== undefined && method !== 'GET') {
|
|
298
|
-
headers.set('content-type', 'application/json')
|
|
299
|
-
requestInit.body = JSON.stringify(request.body)
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
const syntheticRequest = new Request(url, requestInit)
|
|
303
315
|
attachTrustedAuthContext(syntheticRequest, buildAuthEnvelope(ctx))
|
|
304
316
|
|
|
305
317
|
let response: Response
|
|
@@ -339,4 +351,3 @@ async function defaultLoadApiRoutes(): Promise<ApiRouteManifestEntry[]> {
|
|
|
339
351
|
}
|
|
340
352
|
return registered
|
|
341
353
|
}
|
|
342
|
-
|
|
@@ -140,7 +140,7 @@ export async function compileAndImportGenerated(
|
|
|
140
140
|
sourcemap: false,
|
|
141
141
|
write: false,
|
|
142
142
|
logLevel: 'silent',
|
|
143
|
-
|
|
143
|
+
plugins: [createAppAliasPlugin(appRoot)],
|
|
144
144
|
})
|
|
145
145
|
fs.writeFileSync(jsPath, result.outputFiles[0].text)
|
|
146
146
|
} else {
|
|
@@ -171,6 +171,46 @@ export async function compileAndImportGenerated(
|
|
|
171
171
|
)) as Record<string, unknown>
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
const APP_ALIAS_SUFFIXES = ['.ts', '.tsx', '/index.ts', '/index.tsx']
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Resolve an `@/<rest>` specifier the way the app's tsconfig maps it:
|
|
178
|
+
* `@/.mercato/*` to the app root, every other `@/*` to `src/` first with the
|
|
179
|
+
* app root as a fallback (apps that keep their sources at the root). Mirrors
|
|
180
|
+
* the alias resolver in `createCliBundlePlugins`
|
|
181
|
+
* (`@open-mercato/shared/lib/bootstrap/dynamicLoader`), which this module can
|
|
182
|
+
* only load lazily. Returns `null` when no file matches. Exported for unit testing.
|
|
183
|
+
*/
|
|
184
|
+
export function resolveAppAliasPath(appRoot: string, rest: string): string | null {
|
|
185
|
+
const bases = rest.startsWith('.mercato/')
|
|
186
|
+
? [path.join(appRoot, rest)]
|
|
187
|
+
: [path.join(appRoot, 'src', rest), path.join(appRoot, rest)]
|
|
188
|
+
for (const base of bases) {
|
|
189
|
+
if (fs.existsSync(base) && fs.statSync(base).isFile()) return base
|
|
190
|
+
for (const suffix of APP_ALIAS_SUFFIXES) {
|
|
191
|
+
if (fs.existsSync(base + suffix)) return base + suffix
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return null
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* esbuild plugin resolving `@/` through `resolveAppAliasPath`. An unmatched
|
|
199
|
+
* specifier falls back to the literal app-root mapping so esbuild reports the
|
|
200
|
+
* missing file against the path the app author wrote. Exported for unit testing.
|
|
201
|
+
*/
|
|
202
|
+
export function createAppAliasPlugin(appRoot: string): import('esbuild').Plugin {
|
|
203
|
+
return {
|
|
204
|
+
name: 'app-alias',
|
|
205
|
+
setup(build) {
|
|
206
|
+
build.onResolve({ filter: /^@\// }, (args) => {
|
|
207
|
+
const rest = args.path.slice('@/'.length)
|
|
208
|
+
return { path: resolveAppAliasPath(appRoot, rest) ?? path.join(appRoot, rest) }
|
|
209
|
+
})
|
|
210
|
+
},
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
174
214
|
function isJestRuntime(): boolean {
|
|
175
215
|
return typeof process.env.JEST_WORKER_ID === 'string'
|
|
176
216
|
}
|
|
@@ -286,7 +326,8 @@ function toSafeJsStringLiteral(value: string): string {
|
|
|
286
326
|
* 1. `@/...` path-alias imports (both `from "@/x"` and dynamic `import("@/x")`).
|
|
287
327
|
* The `@/` alias is a Next.js bundler convention; outside the bundler Node
|
|
288
328
|
* treats `@/...` as a bare package specifier and throws
|
|
289
|
-
* `ERR_MODULE_NOT_FOUND`. Resolved
|
|
329
|
+
* `ERR_MODULE_NOT_FOUND`. Resolved like the app tsconfig maps the alias
|
|
330
|
+
* (see `resolveAppAliasPath`), falling back to `appRoot`.
|
|
290
331
|
* 2. `../../src/...` relative imports the generator emits for `@app` local
|
|
291
332
|
* modules (e.g. `from "../../src/modules/<id>/ai-tools"`). esbuild's
|
|
292
333
|
* transform (transpile-only) leaves these untouched, so the compiled
|
|
@@ -332,8 +373,11 @@ function rewriteGeneratedAliasImportsForRuntime(
|
|
|
332
373
|
: target
|
|
333
374
|
return toRuntimeLiteral(candidate)
|
|
334
375
|
}
|
|
335
|
-
const resolveAlias = (relativePath: string): string =>
|
|
336
|
-
|
|
376
|
+
const resolveAlias = (relativePath: string): string => {
|
|
377
|
+
const resolved = resolveAppAliasPath(appRoot, relativePath)
|
|
378
|
+
if (resolved !== null) return toRuntimeLiteral(resolved)
|
|
379
|
+
return toResolvedLiteral(path.join(appRoot, relativePath))
|
|
380
|
+
}
|
|
337
381
|
const resolveRelative = (specifier: string): string => {
|
|
338
382
|
const artifact = appLocalArtifacts.get(specifier)
|
|
339
383
|
if (artifact !== undefined) return toRuntimeLiteral(artifact)
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
*
|
|
11
11
|
* The factory {@link createOpenAICompatibleProvider} takes a preset and
|
|
12
12
|
* returns a fully-configured `LlmProvider` that internally calls
|
|
13
|
-
* `createOpenAI({ apiKey, baseURL })` from `@ai-sdk/openai
|
|
13
|
+
* `createOpenAI({ apiKey, baseURL })` from `@ai-sdk/openai` and builds a
|
|
14
|
+
* Chat Completions model, or a Responses API model for presets that set
|
|
15
|
+
* `apiMode: 'responses'` (only the native `openai` preset).
|
|
14
16
|
*
|
|
15
17
|
* @see packages/shared/src/lib/ai/llm-provider.ts
|
|
16
18
|
* @see ./openai-compatible-presets.ts
|
|
@@ -87,8 +89,20 @@ export interface OpenAICompatiblePreset {
|
|
|
87
89
|
* deepinfra, groq, …).
|
|
88
90
|
*/
|
|
89
91
|
usesVendorPrefixedModelIds?: boolean
|
|
92
|
+
/**
|
|
93
|
+
* Wire protocol used for model calls. `'chat'` (the default) targets
|
|
94
|
+
* `POST {baseURL}/chat/completions`, which every OpenAI-compatible backend
|
|
95
|
+
* (DeepInfra, Groq, Together, Fireworks, LiteLLM, Ollama, …) implements.
|
|
96
|
+
* `'responses'` targets OpenAI's Responses API (`POST {baseURL}/responses`),
|
|
97
|
+
* which only the native `openai` preset opts into — compatible backends do
|
|
98
|
+
* not implement it and answer `404 Not Found`.
|
|
99
|
+
*/
|
|
100
|
+
apiMode?: OpenAICompatibleApiMode
|
|
90
101
|
}
|
|
91
102
|
|
|
103
|
+
/** Wire protocol an {@link OpenAICompatiblePreset} speaks. */
|
|
104
|
+
export type OpenAICompatibleApiMode = 'chat' | 'responses'
|
|
105
|
+
|
|
92
106
|
function readFirstNonEmpty(
|
|
93
107
|
env: EnvLookup,
|
|
94
108
|
keys: readonly string[],
|
|
@@ -120,6 +134,8 @@ export function createOpenAICompatibleProvider(
|
|
|
120
134
|
)
|
|
121
135
|
}
|
|
122
136
|
|
|
137
|
+
const apiMode: OpenAICompatibleApiMode = preset.apiMode ?? 'chat'
|
|
138
|
+
|
|
123
139
|
function resolveApiKey(env?: EnvLookup): string | null {
|
|
124
140
|
return readFirstNonEmpty(env ?? process.env, preset.envKeys)
|
|
125
141
|
}
|
|
@@ -166,7 +182,9 @@ export function createOpenAICompatibleProvider(
|
|
|
166
182
|
apiKey: options.apiKey,
|
|
167
183
|
...(baseURL ? { baseURL } : {}),
|
|
168
184
|
})
|
|
169
|
-
return
|
|
185
|
+
return apiMode === 'responses'
|
|
186
|
+
? openai.responses(options.modelId)
|
|
187
|
+
: openai.chat(options.modelId)
|
|
170
188
|
},
|
|
171
189
|
}
|
|
172
190
|
|
|
@@ -26,6 +26,7 @@ const OPENAI_PRESET: OpenAICompatiblePreset = {
|
|
|
26
26
|
baseURL: undefined,
|
|
27
27
|
baseURLEnvKeys: ['OPENAI_BASE_URL'],
|
|
28
28
|
envKeys: ['OPENAI_API_KEY', 'OPENCODE_OPENAI_API_KEY'],
|
|
29
|
+
apiMode: 'responses',
|
|
29
30
|
supportsInputModeration: true,
|
|
30
31
|
supportsEndUserSafetyIdentifier: true,
|
|
31
32
|
defaultModel: 'gpt-5-mini',
|
|
@@ -26,7 +26,7 @@ export function ConversationSharedRenderer({
|
|
|
26
26
|
const handleView = async () => {
|
|
27
27
|
setExecuting(true)
|
|
28
28
|
try {
|
|
29
|
-
if (viewAction) {
|
|
29
|
+
if (viewAction && notification.status !== 'actioned') {
|
|
30
30
|
await onAction(viewAction.id)
|
|
31
31
|
}
|
|
32
32
|
if (notification.linkHref) router.push(notification.linkHref)
|
|
@@ -82,7 +82,7 @@ export function ConversationSharedRenderer({
|
|
|
82
82
|
e.stopPropagation()
|
|
83
83
|
handleView()
|
|
84
84
|
}}
|
|
85
|
-
disabled={executing || (!viewAction && !notification.linkHref)}
|
|
85
|
+
disabled={executing || (!viewAction && !notification.linkHref) || (notification.status === 'actioned' && !notification.linkHref)}
|
|
86
86
|
>
|
|
87
87
|
<ExternalLink className="size-3.5" aria-hidden="true" />
|
|
88
88
|
{t('ai_assistant.notifications.conversation_shared.view_button', 'View Conversation')}
|