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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hawk.api",
"version": "1.5.9",
"version": "1.5.10",
"main": "index.ts",
"license": "BUSL-1.1",
"scripts": {
Expand Down
74 changes: 43 additions & 31 deletions src/integrations/vercel-ai/index.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,57 @@
import { EventAddons, EventData } from '@hawk.so/types';
import { generateText } from 'ai';
import { eventSolvingInput } from './inputs/eventSolving';
import { ctoInstruction } from './instructions/cto';

/**
* 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;
}

/**
* Interface for interacting with Vercel AI Gateway
*/
class VercelAIApi {
/**
* 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';
}
/**
* Model ID to use for generating suggestions
*/
private readonly modelId: string;

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'],
},
this.modelId = 'deepseek/deepseek-v4-flash';
}

/**
* 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
* @todo add defence against invalid prompt injection
*/
public async complete({ system, prompt }: CompletionParams): Promise<string> {
const { text } = await generateText({
model: this.modelId,
system,
prompt,
providerOptions: {
gateway: {
order: ['novita', 'azure', 'deepseek'],
},
});
},
});

return text;
}
return text;
}
}

export const vercelAIApi = new VercelAIApi();
7 changes: 6 additions & 1 deletion src/services/ai.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { vercelAIApi } from '../integrations/vercel-ai/';
import { eventSolvingInput } from './askAi/inputs/eventSolving';
import { ctoInstruction } from './askAi/instructions/cto';
import { EventsFactoryInterface } from './types';

/**
Expand All @@ -20,7 +22,10 @@ export class AIService {
throw new Error('Event not found');
}

return vercelAIApi.generateSuggestion(event.payload);
return vercelAIApi.complete({
system: ctoInstruction,
prompt: eventSolvingInput(event.payload),
});
}
}

Expand Down
41 changes: 41 additions & 0 deletions test/integrations/vercel-ai.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import '../../src/env-test';
import { generateText } from 'ai';
import { vercelAIApi } from '../../src/integrations/vercel-ai/';

jest.mock('ai', () => ({
generateText: jest.fn(),
}));

describe('VercelAIApi', () => {
const testSystem = 'system instruction';
const testPrompt = 'user prompt';
const testModelId = 'deepseek/deepseek-v4-flash';
const testProviderOptions = {
gateway: {
order: ['novita', 'azure', 'deepseek'],
},
};

beforeEach(() => {
jest.clearAllMocks();
});

describe('complete', () => {
it('should forward the system/prompt pair to generateText and return its text', async () => {
(generateText as jest.Mock).mockResolvedValue({ text: 'model output' });

const result = await vercelAIApi.complete({
system: testSystem,
prompt: testPrompt,
});

expect(generateText).toHaveBeenCalledWith({
model: testModelId,
system: testSystem,
prompt: testPrompt,
providerOptions: testProviderOptions,
});
expect(result).toBe('model output');
});
});
});
65 changes: 65 additions & 0 deletions test/services/askAi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import '../../src/env-test';
import { EventAddons, EventData } from '@hawk.so/types';
import { AIService } from '../../src/services/ai';
import { vercelAIApi } from '../../src/integrations/vercel-ai/';
import { ctoInstruction } from '../../src/services/askAi/instructions/cto';
import { eventSolvingInput } from '../../src/services/askAi/inputs/eventSolving';

jest.mock('../../src/integrations/vercel-ai/', () => ({
vercelAIApi: {
complete: jest.fn(),
},
}));

describe('AIService', () => {
let aiService: AIService;
const testEventId = 'repetition-id';
const testOriginalEventId = 'original-event-id';
const testPayload: EventData<EventAddons> = {
title: 'TypeError: cannot read property of undefined',
};

/**
* Build a stub events factory returning the given event
*
* @param event - event repetition to resolve, or null when not found
* @returns {object} stub factory
*/
const createEventsFactory = (event: { _id: string; payload: EventData<EventAddons> } | null): { getEventRepetition: jest.Mock } => ({
getEventRepetition: jest.fn().mockResolvedValue(event),
});

const eventsFactoryWithPayload = (): ReturnType<typeof createEventsFactory> => createEventsFactory({
_id: testEventId,
payload: testPayload,
});

beforeEach(() => {
jest.clearAllMocks();
aiService = new AIService();
});

describe('generateSuggestion', () => {
it('should send the instruction and serialized event to the transport and return its text unchanged', async () => {
(vercelAIApi.complete as jest.Mock).mockResolvedValue('generated suggestion');
const eventsFactory = eventsFactoryWithPayload();

const result = await aiService.generateSuggestion(eventsFactory, testEventId, testOriginalEventId);

expect(eventsFactory.getEventRepetition).toHaveBeenCalledWith(testEventId, testOriginalEventId);
expect(vercelAIApi.complete).toHaveBeenCalledWith({
system: ctoInstruction,
prompt: eventSolvingInput(testPayload),
});
expect(result).toBe('generated suggestion');
});

it('should throw Event not found when the events factory returns nothing', async () => {
await expect(
aiService.generateSuggestion(createEventsFactory(null), testEventId, testOriginalEventId)
).rejects.toThrow('Event not found');

expect(vercelAIApi.complete).not.toHaveBeenCalled();
});
});
});
Loading