Skip to content
Open
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
*/
'AbortController': 'readonly'
},
rules: {
'@typescript-eslint/camelcase': 'warn',
'@typescript-eslint/no-unused-vars': 'warn',
Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hawk.api",
"version": "1.5.10",
"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 All @@ -57,7 +57,7 @@
"@types/lodash.mergewith": "^4.6.9",
"@types/mime-types": "^2.1.0",
"@types/morgan": "^1.9.10",
"@types/node": "^16.11.46",
"@types/node": "^24.13.3",
"@types/safe-regex": "^1.1.6",
"@types/uuid": "^8.3.4",
"ai": "^5.0.89",
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
149 changes: 116 additions & 33 deletions src/integrations/vercel-ai/index.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,128 @@
import { EventAddons, EventData } from '@hawk.so/types';
import { generateText } from 'ai';
import { eventSolvingInput } from './inputs/eventSolving';
import { ctoInstruction } from './instructions/cto';
import { generateText, streamText, type TextStreamPart, type ToolSet } from 'ai';
import { getErrorMessage, ProviderOptions } from '@ai-sdk/provider-utils';
import type { AiStream } from '@hawk.so/types';
import { SUGGESTION_FALLBACK_MESSAGE } from '../../services/askAi/service';

/**
* Params for a single completion call to the model
*/
export interface CompletionParams {
/**
* System instruction that steers the model's behavior
*/
system: string;

/**
* User-facing prompt describing what the model should complete
*/
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
*
* No tools are passed to the model, so a hijacked prompt can only produce text.
* Adding them requires reworking the security layer first.
*/
class VercelAIApi {
/**
* Model ID to use for generating suggestions
*/
private readonly modelId: string;
/**
* Model ID to use for generating suggestions
*/
private readonly modelId: string;

constructor() {
/**
* @todo make it dynamic, get from project settings
*/
this.modelId = 'deepseek/deepseek-v4-flash';
}
/**
* Provider Gateway configurations
*/
private readonly providerOptions: ProviderOptions;

/**
* Set up model id and provider fallback order
*/
constructor() {
/**
* Generate AI suggestion for the event
*
* @param {EventData<EventAddons>} payload - event data to make suggestion
* @returns {Promise<string>} AI suggestion for the event
* @todo add defence against invalid prompt injection
* @todo make it dynamic, get from project settings
*/
public async generateSuggestion(payload: EventData<EventAddons>) {
const { text } = await generateText({
model: this.modelId,
system: ctoInstruction,
prompt: eventSolvingInput(payload),
providerOptions: {
gateway: {
order: ['novita', 'azure', 'deepseek'],
},
},
});

return text;
}
this.modelId = 'deepseek/deepseek-v4-flash';
this.providerOptions = {
gateway: {
order: ['novita', 'azure', 'deepseek'],
},
};
}

/**
* Send a system/prompt pair to the model and return the generated text
*
* @param {CompletionParams} params - system instruction and prompt to complete
* @returns {Promise<string>} text generated by the model
*/
public async complete({ system, prompt }: CompletionParams): Promise<string> {
const { text } = await generateText({
model: this.modelId,
system,
prompt,
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();
5 changes: 0 additions & 5 deletions src/integrations/vercel-ai/inputs/eventSolving.ts

This file was deleted.

4 changes: 2 additions & 2 deletions src/resolvers/event.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ const {
parseBulkEventIds,
enqueueAssigneeNotification,
} = require('./helpers/bulkEventUtils');
const { aiService } = require('../services/ai');
const { askAiService } = require('../services/askAi');
const { UserInputError } = require('apollo-server-express');
const { ObjectId } = require('mongodb');

Expand Down Expand Up @@ -106,7 +106,7 @@ module.exports = {
async aiSuggestion({ projectId, _id: eventId, originalEventId }, _args, context) {
const factory = getEventsFactory(context, projectId);

return aiService.generateSuggestion(factory, eventId, originalEventId);
return askAiService.generateSuggestion(factory, eventId, originalEventId);
},

/**
Expand Down
27 changes: 0 additions & 27 deletions src/services/ai.ts

This file was deleted.

2 changes: 2 additions & 0 deletions src/services/askAi/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { AskAiService, askAiService } from './service';
export { appendAiAssistantRoutes } from './routes';
16 changes: 16 additions & 0 deletions src/services/askAi/inputs/eventSolving.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { EventData, EventAddons } from '@hawk.so/types';

/**
* Serialize event data for the model prompt.
*
* @warning returns unwrapped attacker-controlled data (headers, user-agent,
* query params, stack trace). Sending it to a model bypasses the injection
* defense. Go through {@link buildEventPrompt}, which wraps it in the
* nonce-carrying markers spotlighting and {@link echoesNonce} rely on.
*
* @param payload - event data to make suggestion for
* @returns serialized, unwrapped event data
*/
export const eventSolvingInput = (payload: EventData<EventAddons>) => `
Payload: ${JSON.stringify(payload)}
`;
Loading
Loading