Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ module.exports = {
'node': true,
'jest': true
},
globals: {
/**
* TODO: bump ESLint because its current Node environment is missing required globals
*/
Comment thread
Copilot marked this conversation as resolved.
'AbortController': 'readonly'
},
rules: {
'@typescript-eslint/camelcase': 'warn',
'@typescript-eslint/no-unused-vars': 'warn',
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hawk.api",
"version": "1.5.13",
"version": "1.5.14",
"main": "index.ts",
"license": "BUSL-1.1",
"scripts": {
Expand Down Expand Up @@ -37,12 +37,12 @@
"xml2js": "^0.6.2"
},
"dependencies": {
"@ai-sdk/openai": "^2.0.64",
"@ai-sdk/provider-utils": "^3.0.36",
"@graphql-tools/merge": "^8.3.1",
"@graphql-tools/schema": "^8.5.1",
"@graphql-tools/utils": "^8.9.0",
"@hawk.so/nodejs": "^3.3.2",
"@hawk.so/types": "^0.5.9",
"@hawk.so/types": "^0.7.0",
"@n1ru4l/json-patch-plus": "^0.2.0",
"@node-saml/node-saml": "^5.0.1",
"@octokit/oauth-methods": "^4.0.0",
Expand Down
2 changes: 1 addition & 1 deletion src/directives/requireUserInWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ async function checkUserInWorkspaceByWorkspaceId(context: ResolverContextBase, w
* @param context - request context
* @param projectId - project id
*/
async function checkUserInWorkspaceByProjectId(context: ResolverContextBase, projectId: string): Promise<void> {
export async function checkUserInWorkspaceByProjectId(context: ResolverContextBase, projectId: string): Promise<void> {
const userId = context.user.id;

if (userId) {
Expand Down
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import ReleasesFactory from './models/releasesFactory';
import RedisHelper from './redisHelper';
import { appendSsoRoutes } from './sso';
import { appendGitHubRoutes } from './integrations/github';
import { appendAiAssistantRoutes } from './services/askAi';

/**
* Option to enable playground
Expand Down Expand Up @@ -272,6 +273,11 @@ class HawkAPI {
*/
appendGitHubRoutes(this.app, sharedFactories);

/**
* Append AI assistant route to Express app
*/
appendAiAssistantRoutes(this.app);

await this.server.start();
this.app.use(graphqlUploadExpress());
this.server.applyMiddleware({ app: this.app });
Expand Down
81 changes: 75 additions & 6 deletions src/integrations/vercel-ai/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { generateText } from 'ai';
import { generateText, streamText, type TextStreamPart, type ToolSet } from 'ai';
import { getErrorMessage, ProviderOptions } from '@ai-sdk/provider-utils';
Comment thread
Reversean marked this conversation as resolved.
import type { AiStream } from '@hawk.so/types';
import { SUGGESTION_FALLBACK_MESSAGE } from '../../services/askAi/service';

/**
* Params for a single completion call to the model
Expand All @@ -15,6 +18,45 @@ export interface CompletionParams {
prompt: string;
}

/**
* Params for a streaming completion call to the model
*/
export interface StreamParams extends CompletionParams {
/**
* Aborted when the answer is no longer required, which stops the model
*/
signal: AbortSignal;
}

/**
* Converts Vercel SDK's stream parts.
*
* Everything but text and error parts is dropped.
*
* @param parts - stream of incoming SDK parts
* @returns {AiStream} stream of converted parts
*/
async function * toAiStream<TOOLS extends ToolSet>(
parts: AsyncIterable<TextStreamPart<TOOLS>>
): AiStream {
for await (const part of parts) {
if (part.type === 'text-delta') {
yield {
type: 'text-delta',
delta: part.text,
};
}

if (part.type === 'error') {
console.error('AI response generation failed:', getErrorMessage(part.error));
yield {
type: 'error',
errorText: SUGGESTION_FALLBACK_MESSAGE,
};
}
}
}

/**
* Interface for interacting with Vercel AI Gateway
*
Expand All @@ -27,11 +69,24 @@ class VercelAIApi {
*/
private readonly modelId: string;

/**
* Provider Gateway configurations
*/
private readonly providerOptions: ProviderOptions;

/**
* Set up model id and provider fallback order
*/
constructor() {
/**
* @todo make it dynamic, get from project settings
*/
this.modelId = 'deepseek/deepseek-v4-flash';
this.providerOptions = {
gateway: {
order: ['novita', 'azure', 'deepseek'],
},
};
}

/**
Expand All @@ -45,15 +100,29 @@ class VercelAIApi {
model: this.modelId,
system,
prompt,
providerOptions: {
gateway: {
order: ['novita', 'azure', 'deepseek'],
},
},
providerOptions: this.providerOptions,
});

return text;
}

/**
* Send a system/prompt pair to the model and return the streamed text
*
* @param {StreamParams} params - system instruction, prompt and abort signal
* @returns {AiStream} text generated by the model, as it arrives
*/
public stream({ system, prompt, signal }: StreamParams): AiStream {
const { fullStream } = streamText({
model: this.modelId,
system,
prompt,
providerOptions: this.providerOptions,
abortSignal: signal,
});

return toAiStream(fullStream);
}
}

export const vercelAIApi = new VercelAIApi();
1 change: 1 addition & 0 deletions src/services/askAi/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { AskAiService, askAiService } from './service';
export { appendAiAssistantRoutes } from './routes';
165 changes: 165 additions & 0 deletions src/services/askAi/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import '../../typeDefs/expressContext';
import express from 'express';
import { ObjectId } from 'mongodb';
import { getEventsFactory } from '../../resolvers/helpers/eventsFactory';
import { checkUserInWorkspaceByProjectId } from '../../directives/requireUserInWorkspace';
import { askAiService, SUGGESTION_FALLBACK_MESSAGE } from './service';
import { ForbiddenError } from 'apollo-server-express';
import type { AiStreamPart } from '@hawk.so/types';

/**
* Verify the requesting user is a member of the project's workspace.
*
* @param req - Express request
* @param res - Express response
* @param projectId - project id from query parameters (may be `string[]` if repeated)
* @returns user id and validated project id if authorized, `null` otherwise (response already sent)
*/
async function authorizeProjectAccess(
req: express.Request,
res: express.Response,
Comment thread
Reversean marked this conversation as resolved.
projectId: unknown
): Promise<{ userId: string; projectId: string } | null> {
const userId = req.context?.user?.id;

if (!userId) {
res.status(401).json({ error: 'Unauthorized. Please provide authorization token.' });

return null;
}

if (!projectId || typeof projectId !== 'string') {
res.status(400).json({ error: 'projectId query parameter is required' });

return null;
}

if (!ObjectId.isValid(projectId)) {
res.status(400).json({ error: `Invalid projectId format: ${projectId}` });

return null;
}

try {
await checkUserInWorkspaceByProjectId(req.context, projectId);
} catch (error) {
if (!(error instanceof ForbiddenError)) {
throw error;
}

res.status(403).json({ error: error.message });

return null;
}

return {
userId,
projectId,
};
}

/**
* Create AI assistant router
*
* @returns Express router with AI assistant endpoints
*/
export function createAiStreamRouter(): express.Router {
const router = express.Router();

/**
* GET /integration/ai/stream?projectId=<projectId>&eventId=<eventId>&originalEventId=<originalEventId>
* Stream an AI suggestion for the event
*/
router.get('/stream', async (req, res, next) => {
const abort = new AbortController();

/** Abort response generation when connection is closed */
res.on('close', () => abort.abort());

try {
const { projectId, eventId, originalEventId } = req.query;

const authResult = await authorizeProjectAccess(req, res, projectId);

if (!authResult) {
return;
}

if (!eventId || typeof eventId !== 'string') {
res.status(400).json({ error: 'eventId query parameter is required' });

return;
}

if (!originalEventId || typeof originalEventId !== 'string') {
res.status(400).json({ error: 'originalEventId query parameter is required' });

return;
}

const eventsFactory = getEventsFactory(req.context, authResult.projectId);

let stream;

try {
stream = await askAiService.streamSuggestion(eventsFactory, eventId, originalEventId, abort.signal);
} catch (error) {
if (!(error instanceof Error) || error.message !== 'Event not found') {
throw error;
}

res.status(404).json({ error: error.message });

return;
}

res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'keep-alive',
});

try {
for await (const part of stream) {
if (abort.signal.aborted) {
break;
}

res.write(`data: ${JSON.stringify(part)}\n\n`);
}
} catch (error) {
if (!abort.signal.aborted) {
console.error(
'AI response generation failed:',
error instanceof Error ? error.message : String(error)
);
const part: AiStreamPart = {
type: 'error',
errorText: SUGGESTION_FALLBACK_MESSAGE,
};

res.write(`data: ${JSON.stringify(part)}\n\n`);
}
}

res.end();
} catch (error) {
if (abort.signal.aborted) {
return;
}

next(error);
}
});

return router;
}

/**
* Append AI assistant routes to Express app
*
* @param app - Express application instance
*/
export function appendAiAssistantRoutes(app: express.Application): void {
app.use('/integration/ai', createAiStreamRouter());
}
5 changes: 0 additions & 5 deletions src/services/askAi/security/nonceEcho.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
/**
* Message returned to the user instead of a rejected suggestion
*/
export const SUGGESTION_FALLBACK_MESSAGE = 'Could not generate an answer.';

/**
* True if the output reproduces the per-request nonce, which only the markers
* wrapping the untrusted data contain.
Expand Down
Loading
Loading