Skip to content

Commit 0d27ac8

Browse files
committed
feat(ai): Ask AI suggestions streaming via HTTP
New HTTP route GET /integration/ai/stream added. This route calls Ask AI service about specified event and responds text/event-stream. Reponse carrying AiStream which represent AiStreamPart sequence: either text-delta (text-increments generated by AI assistant) or error (failure description during response generation). NOTE: Response doesn't carry reasoning, tooling and start/end parts since they're not required yet. Route checks workspace membership before calling Ask AI. For this purpose function checkUserInWorkspaceByProjectId became exported. Failed membership check leads to response with 403. Also route checks if specified event exists. Failed check leads to response with 404. Aborting request cancel Ask AI suggestion generation. For this purpose AbortController is declared as an eslint global: it is on globalThis since Node 15, but eslint's node env predates it.
1 parent b81afc5 commit 0d27ac8

15 files changed

Lines changed: 895 additions & 126 deletions

File tree

.eslintrc.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ module.exports = {
44
'node': true,
55
'jest': true
66
},
7+
globals: {
8+
/**
9+
* TODO: bump eslint since it's current env uses older "node" version which missing required global types
10+
*/
11+
'AbortController': 'readonly'
12+
},
713
rules: {
814
'@typescript-eslint/camelcase': 'warn',
915
'@typescript-eslint/no-unused-vars': 'warn',

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hawk.api",
3-
"version": "1.5.13",
3+
"version": "1.5.14",
44
"main": "index.ts",
55
"license": "BUSL-1.1",
66
"scripts": {
@@ -42,7 +42,7 @@
4242
"@graphql-tools/schema": "^8.5.1",
4343
"@graphql-tools/utils": "^8.9.0",
4444
"@hawk.so/nodejs": "^3.3.2",
45-
"@hawk.so/types": "^0.5.9",
45+
"@hawk.so/types": "^0.7.0",
4646
"@n1ru4l/json-patch-plus": "^0.2.0",
4747
"@node-saml/node-saml": "^5.0.1",
4848
"@octokit/oauth-methods": "^4.0.0",

src/directives/requireUserInWorkspace.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ async function checkUserInWorkspaceByWorkspaceId(context: ResolverContextBase, w
3737
* @param context - request context
3838
* @param projectId - project id
3939
*/
40-
async function checkUserInWorkspaceByProjectId(context: ResolverContextBase, projectId: string): Promise<void> {
40+
export async function checkUserInWorkspaceByProjectId(context: ResolverContextBase, projectId: string): Promise<void> {
4141
const userId = context.user.id;
4242

4343
if (userId) {

src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import ReleasesFactory from './models/releasesFactory';
3232
import RedisHelper from './redisHelper';
3333
import { appendSsoRoutes } from './sso';
3434
import { appendGitHubRoutes } from './integrations/github';
35+
import { appendAiAssistantRoutes } from './services/askAi';
3536

3637
/**
3738
* Option to enable playground
@@ -272,6 +273,11 @@ class HawkAPI {
272273
*/
273274
appendGitHubRoutes(this.app, sharedFactories);
274275

276+
/**
277+
* Append AI assistant route to Express app
278+
*/
279+
appendAiAssistantRoutes(this.app);
280+
275281
await this.server.start();
276282
this.app.use(graphqlUploadExpress());
277283
this.server.applyMiddleware({ app: this.app });

src/integrations/vercel-ai/index.ts

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { generateText } from 'ai';
1+
import { generateText, streamText, type TextStreamPart, type ToolSet } from 'ai';
2+
import { getErrorMessage, ProviderOptions } from '@ai-sdk/provider-utils';
3+
import type { AiStream } from '@hawk.so/types';
24

35
/**
46
* Params for a single completion call to the model
@@ -15,6 +17,44 @@ export interface CompletionParams {
1517
prompt: string;
1618
}
1719

20+
/**
21+
* Params for a streaming completion call to the model
22+
*/
23+
export interface StreamParams extends CompletionParams {
24+
/**
25+
* Aborted when the answer is no longer required, which stops the model
26+
*/
27+
signal: AbortSignal;
28+
}
29+
30+
/**
31+
* Converts Vercel SDK's stream parts.
32+
*
33+
* Everything but text and error parts is dropped.
34+
*
35+
* @param parts - stream of incoming SDK parts
36+
* @returns {AiStream} stream converted of converted parts
37+
*/
38+
async function * toAiStream<TOOLS extends ToolSet>(
39+
parts: AsyncIterable<TextStreamPart<TOOLS>>
40+
): AiStream {
41+
for await (const part of parts) {
42+
if (part.type === 'text-delta') {
43+
yield {
44+
type: 'text-delta',
45+
delta: part.text,
46+
};
47+
}
48+
49+
if (part.type === 'error') {
50+
yield {
51+
type: 'error',
52+
errorText: getErrorMessage(part.error),
53+
};
54+
}
55+
}
56+
}
57+
1858
/**
1959
* Interface for interacting with Vercel AI Gateway
2060
*
@@ -27,11 +67,24 @@ class VercelAIApi {
2767
*/
2868
private readonly modelId: string;
2969

70+
/**
71+
* Provider Gateway configurations
72+
*/
73+
private readonly providerOptions: ProviderOptions;
74+
75+
/**
76+
* Set up model id and provider fallback order
77+
*/
3078
constructor() {
3179
/**
3280
* @todo make it dynamic, get from project settings
3381
*/
3482
this.modelId = 'deepseek/deepseek-v4-flash';
83+
this.providerOptions = {
84+
gateway: {
85+
order: ['novita', 'azure', 'deepseek'],
86+
},
87+
};
3588
}
3689

3790
/**
@@ -45,15 +98,29 @@ class VercelAIApi {
4598
model: this.modelId,
4699
system,
47100
prompt,
48-
providerOptions: {
49-
gateway: {
50-
order: ['novita', 'azure', 'deepseek'],
51-
},
52-
},
101+
providerOptions: this.providerOptions,
53102
});
54103

55104
return text;
56105
}
106+
107+
/**
108+
* Send a system/prompt pair to the model and return the streamed text
109+
*
110+
* @param {StreamParams} params - system instruction, prompt and abort signal
111+
* @returns {AiStream} text generated by the model, as it arrives
112+
*/
113+
public stream({ system, prompt, signal }: StreamParams): AiStream {
114+
const { fullStream } = streamText({
115+
model: this.modelId,
116+
system,
117+
prompt,
118+
providerOptions: this.providerOptions,
119+
abortSignal: signal,
120+
});
121+
122+
return toAiStream(fullStream);
123+
}
57124
}
58125

59126
export const vercelAIApi = new VercelAIApi();

src/services/askAi/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { AskAiService, askAiService } from './service';
2+
export { appendAiAssistantRoutes } from './routes';

src/services/askAi/routes.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import '../../typeDefs/expressContext';
2+
import express from 'express';
3+
import { ObjectId } from 'mongodb';
4+
import { getEventsFactory } from '../../resolvers/helpers/eventsFactory';
5+
import { checkUserInWorkspaceByProjectId } from '../../directives/requireUserInWorkspace';
6+
import { askAiService } from './service';
7+
import type { AiStreamPart } from '@hawk.so/types';
8+
9+
/**
10+
* Verify the requesting user is a member of the project's workspace.
11+
*
12+
* @param req - Express request
13+
* @param res - Express response
14+
* @param projectId - project id from query parameters (may be `string[]` if repeated)
15+
* @returns user id and validated project id if authorized, `null` otherwise (response already sent)
16+
*/
17+
async function authorizeProjectAccess(
18+
req: express.Request,
19+
res: express.Response,
20+
projectId: unknown
21+
): Promise<{ userId: string; projectId: string } | null> {
22+
const userId = req.context?.user?.id;
23+
24+
if (!userId) {
25+
res.status(401).json({ error: 'Unauthorized. Please provide authorization token.' });
26+
27+
return null;
28+
}
29+
30+
if (!projectId || typeof projectId !== 'string') {
31+
res.status(400).json({ error: 'projectId query parameter is required' });
32+
33+
return null;
34+
}
35+
36+
if (!ObjectId.isValid(projectId)) {
37+
res.status(400).json({ error: `Invalid projectId format: ${projectId}` });
38+
39+
return null;
40+
}
41+
42+
try {
43+
await checkUserInWorkspaceByProjectId(req.context, projectId);
44+
} catch (error) {
45+
res.status(403).json({ error: error instanceof Error ? error.message : 'You have no access to this workspace' });
46+
47+
return null;
48+
}
49+
50+
return {
51+
userId,
52+
projectId,
53+
};
54+
}
55+
56+
/**
57+
* Create AI assistant router
58+
*
59+
* @returns Express router with AI assistant endpoints
60+
*/
61+
export function createAiStreamRouter(): express.Router {
62+
const router = express.Router();
63+
64+
/**
65+
* GET /integration/ai/stream?projectId=<projectId>&eventId=<eventId>&originalEventId=<originalEventId>
66+
* Stream an AI suggestion for the event
67+
*/
68+
router.get('/stream', async (req, res, next) => {
69+
const abort = new AbortController();
70+
71+
/** Abort response generation when connection is closed */
72+
res.on('close', () => abort.abort());
73+
74+
try {
75+
const { projectId, eventId, originalEventId } = req.query;
76+
77+
const authResult = await authorizeProjectAccess(req, res, projectId);
78+
79+
if (!authResult) {
80+
return;
81+
}
82+
83+
if (!eventId || typeof eventId !== 'string') {
84+
res.status(400).json({ error: 'eventId query parameter is required' });
85+
86+
return;
87+
}
88+
89+
if (!originalEventId || typeof originalEventId !== 'string') {
90+
res.status(400).json({ error: 'originalEventId query parameter is required' });
91+
92+
return;
93+
}
94+
95+
const eventsFactory = getEventsFactory(req.context, authResult.projectId);
96+
97+
let stream;
98+
99+
try {
100+
stream = await askAiService.streamSuggestion(eventsFactory, eventId, originalEventId, abort.signal);
101+
} catch (error) {
102+
if (!(error instanceof Error) || error.message !== 'Event not found') {
103+
throw error;
104+
}
105+
106+
res.status(404).json({ error: error.message });
107+
108+
return;
109+
}
110+
111+
res.writeHead(200, {
112+
'content-type': 'text/event-stream',
113+
'cache-control': 'no-cache',
114+
connection: 'keep-alive',
115+
});
116+
117+
try {
118+
for await (const part of stream) {
119+
if (abort.signal.aborted) {
120+
break;
121+
}
122+
123+
res.write(`data: ${JSON.stringify(part)}\n\n`);
124+
}
125+
} catch (error) {
126+
if (!abort.signal.aborted) {
127+
const part: AiStreamPart = {
128+
type: 'error',
129+
errorText: error instanceof Error ? error.message : 'AI suggestion failed.',
130+
};
131+
132+
res.write(`data: ${JSON.stringify(part)}\n\n`);
133+
}
134+
}
135+
136+
res.end();
137+
} catch (error) {
138+
if (abort.signal.aborted) {
139+
return;
140+
}
141+
142+
next(error);
143+
}
144+
});
145+
146+
return router;
147+
}
148+
149+
/**
150+
* Append AI assistant routes to Express app
151+
*
152+
* @param app - Express application instance
153+
*/
154+
export function appendAiAssistantRoutes(app: express.Application): void {
155+
app.use('/integration/ai', createAiStreamRouter());
156+
}

0 commit comments

Comments
 (0)