@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.
Files changed (29) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/README.md +5 -0
  3. package/dist/modules/ai_assistant/data/repositories/AiChatConversationRepository.js.map +2 -2
  4. package/dist/modules/ai_assistant/data/repositories/AiPendingActionRepository.js.map +1 -1
  5. package/dist/modules/ai_assistant/encryption.js +31 -0
  6. package/dist/modules/ai_assistant/encryption.js.map +7 -0
  7. package/dist/modules/ai_assistant/lib/ai-api-operation-runner.js +20 -9
  8. package/dist/modules/ai_assistant/lib/ai-api-operation-runner.js.map +2 -2
  9. package/dist/modules/ai_assistant/lib/generated-registry-loader.js +30 -2
  10. package/dist/modules/ai_assistant/lib/generated-registry-loader.js.map +2 -2
  11. package/dist/modules/ai_assistant/lib/llm-adapters/openai.js +2 -1
  12. package/dist/modules/ai_assistant/lib/llm-adapters/openai.js.map +2 -2
  13. package/dist/modules/ai_assistant/lib/openai-compatible-presets.js +1 -0
  14. package/dist/modules/ai_assistant/lib/openai-compatible-presets.js.map +2 -2
  15. package/dist/modules/ai_assistant/widgets/notifications/ConversationSharedRenderer.js +2 -2
  16. package/dist/modules/ai_assistant/widgets/notifications/ConversationSharedRenderer.js.map +2 -2
  17. package/package.json +7 -8
  18. package/src/modules/ai_assistant/__tests__/encryption-map.test.ts +175 -0
  19. package/src/modules/ai_assistant/data/repositories/AiChatConversationRepository.ts +5 -4
  20. package/src/modules/ai_assistant/data/repositories/AiPendingActionRepository.ts +3 -3
  21. package/src/modules/ai_assistant/encryption.ts +29 -0
  22. package/src/modules/ai_assistant/lib/__tests__/ai-api-operation-runner.test.ts +154 -0
  23. package/src/modules/ai_assistant/lib/__tests__/generated-registry-loader.test.ts +107 -0
  24. package/src/modules/ai_assistant/lib/__tests__/llm-adapters-openai.test.ts +73 -0
  25. package/src/modules/ai_assistant/lib/ai-api-operation-runner.ts +22 -11
  26. package/src/modules/ai_assistant/lib/generated-registry-loader.ts +48 -4
  27. package/src/modules/ai_assistant/lib/llm-adapters/openai.ts +20 -2
  28. package/src/modules/ai_assistant/lib/openai-compatible-presets.ts +1 -0
  29. 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, path)
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
- alias: { '@': appRoot },
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 against `appRoot`.
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
- toResolvedLiteral(path.join(appRoot, relativePath))
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 openai(options.modelId)
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')}