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
99 changes: 99 additions & 0 deletions packages/backend/src/ai/ai.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,23 @@ describe('AIService', () => {
expect(aiService.redis.removeInflight).toHaveBeenCalledWith('U1', 'T1');
expect(aiService.redis.decrementDailyRequests).toHaveBeenCalledWith('U1', 'T1');
});

it('alerts #muzzlefeedback when OpenAI returns a 429 error', async () => {
const createSpy = aiService.openAi.responses.create as Mock;
createSpy.mockRejectedValue(
Object.assign(new Error('Rate limit exceeded'), {
status: 429,
error: { message: 'Please slow down.' },
}),
);

await expect(aiService.generateText('U1', 'T1', 'C1', 'hello')).rejects.toThrow('Rate limit exceeded');

expect(aiService.webService.sendMessage).toHaveBeenCalledWith(
'#muzzlefeedback',
'OpenAI 429 during generateText: Please slow down.',
);
});
});

describe('generateImage', () => {
Expand Down Expand Up @@ -517,6 +534,88 @@ describe('AIService', () => {
});
});

describe('alertOnOpenAiRateLimit', () => {
it('alerts #muzzlefeedback for 429 errors with the OpenAI message', async () => {
await aiService.alertOnOpenAiRateLimit(
Object.assign(new Error('Rate limit exceeded'), {
status: 429,
error: { message: 'Please slow down.' },
}),
'generateText',
);

expect(aiService.webService.sendMessage).toHaveBeenCalledWith(
'#muzzlefeedback',
'OpenAI 429 during generateText: Please slow down.',
);
});

it('does not alert for non-429 errors', async () => {
await aiService.alertOnOpenAiRateLimit(new Error('API error'), 'generateText');

expect(aiService.webService.sendMessage).not.toHaveBeenCalled();
});

it('falls back to the top-level error message when the nested OpenAI message is blank', async () => {
await aiService.alertOnOpenAiRateLimit(
{
status: 429,
error: { message: ' ' },
message: ' Rate limit exceeded ',
},
'generateText',
);

expect(aiService.webService.sendMessage).toHaveBeenCalledWith(
'#muzzlefeedback',
'OpenAI 429 during generateText: Rate limit exceeded',
);
});

it('uses a default alert message when OpenAI omits all error text', async () => {
await aiService.alertOnOpenAiRateLimit(
{
status: 429,
error: { message: ' ' },
message: ' ',
},
'generateText',
);

expect(aiService.webService.sendMessage).toHaveBeenCalledWith(
'#muzzlefeedback',
'OpenAI 429 during generateText: OpenAI returned a 429 response without an error message.',
);
});

it('logs and suppresses Slack delivery failures', async () => {
(aiService.webService.sendMessage as Mock).mockRejectedValueOnce(new Error('slack failed'));

await expect(
aiService.alertOnOpenAiRateLimit(
Object.assign(new Error('Rate limit exceeded'), {
status: 429,
error: { message: 'Please slow down.' },
}),
'generateText',
),
).resolves.toBeUndefined();

expect(aiService.aiServiceLogger.error).toHaveBeenCalledWith(
'Failed to send OpenAI 429 alert to Slack',
expect.objectContaining({
context: {
operation: 'generateText',
openAiErrorMessage: 'Please slow down.',
},
error: expect.objectContaining({
message: 'slack failed',
}),
}),
);
});
});

describe('participate', () => {
it('should be defined', () => {
expect(aiService.participate).toBeDefined();
Expand Down
58 changes: 57 additions & 1 deletion packages/backend/src/ai/ai.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,32 @@ const isResponseOutputMessage = (block: ResponseOutputItem): block is ResponseOu
const isResponseOutputText = (block: ResponseOutputText | ResponseOutputRefusal): block is ResponseOutputText =>
block.type === 'output_text';

const getOpenAiStatusCode = (error: unknown): number | undefined => {
if (!isRecord(error)) {
return undefined;
}

const status = Reflect.get(error, 'status');
return typeof status === 'number' ? status : undefined;
};

const getOpenAiErrorMessage = (error: unknown): string | undefined => {
if (!isRecord(error)) {
return error instanceof Error ? error.message : undefined;
}

const apiError = Reflect.get(error, 'error');
if (isRecord(apiError)) {
const apiErrorMessage = Reflect.get(apiError, 'message');
if (typeof apiErrorMessage === 'string' && apiErrorMessage.trim()) {
return apiErrorMessage.trim();
}
}

const message = Reflect.get(error, 'message');
return typeof message === 'string' && message.trim() ? message.trim() : undefined;
};

const extractAndParseOpenAiResponse = (response: OpenAI.Responses.Response): string | undefined => {
const textBlock = response.output.find(isResponseOutputMessage);
const outputText = textBlock?.content.find(isResponseOutputText)?.text;
Expand Down Expand Up @@ -133,6 +159,28 @@ export class AIService {
return this.slackPersistenceService.setCustomPrompt(userId, teamId, prompt);
}

/**
* Sends a Slack alert to #muzzlefeedback when OpenAI returns a 429 rate-limit response.
* Expects OpenAI-style errors with a numeric `status` field and an optional nested `error.message`.
* Returns without side effects for non-429 errors.
*/
public async alertOnOpenAiRateLimit(error: unknown, operation: string): Promise<void> {
if (getOpenAiStatusCode(error) !== 429) {
return;
}

const errorMessage = getOpenAiErrorMessage(error) ?? 'OpenAI returned a 429 response without an error message.';

try {
await this.webService.sendMessage('#muzzlefeedback', `OpenAI 429 during ${operation}: ${errorMessage}`);
} catch (slackError) {
logError(this.aiServiceLogger, 'Failed to send OpenAI 429 alert to Slack', slackError, {
operation,
openAiErrorMessage: errorMessage,
});
}
}

public clearCustomPrompt(userId: string, teamId: string): Promise<boolean> {
return this.slackPersistenceService.clearCustomPrompt(userId, teamId);
}
Expand Down Expand Up @@ -163,6 +211,7 @@ export class AIService {
}
})
.catch(async (e) => {
await this.alertOnOpenAiRateLimit(e, 'generateText');
logError(this.aiServiceLogger, 'Failed to generate AI text response', e, {
userId,
teamId,
Expand Down Expand Up @@ -205,7 +254,11 @@ export class AIService {
input: REDPLOY_MOONBEAM_TEXT_PROMPT,
user: 'Moonbeam',
})
.then((x) => extractAndParseOpenAiResponse(x));
.then((x) => extractAndParseOpenAiResponse(x))
.catch(async (error) => {
await this.alertOnOpenAiRateLimit(error, 'redeployMoonbeam');
throw error;
});

const aiImage = this.gemini.models
.generateContent({
Expand Down Expand Up @@ -373,6 +426,7 @@ export class AIService {
return extractAndParseOpenAiResponse(x);
})
.catch(async (e) => {
await this.alertOnOpenAiRateLimit(e, 'generateCorpoSpeak');
logError(this.aiServiceLogger, 'Failed to generate corpo-speak response', e, {
prompt: text,
});
Expand Down Expand Up @@ -467,6 +521,7 @@ export class AIService {
});
})
.catch(async (e) => {
await this.alertOnOpenAiRateLimit(e, 'promptWithHistory');
logError(this.aiServiceLogger, 'Failed to process prompt with history', e, {
userId: request.user_id,
teamId: request.team_id,
Expand Down Expand Up @@ -556,6 +611,7 @@ export class AIService {
}
})
.catch(async (e) => {
await this.alertOnOpenAiRateLimit(e, 'participate');
logError(this.aiServiceLogger, 'Failed to generate AI participation response', e, {
teamId,
channelId,
Expand Down
81 changes: 41 additions & 40 deletions packages/backend/src/ai/memory/memory.job.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,29 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MemoryJob } from './memory.job';

type ExtractMemoriesInvoker = {
extractMemories: (
teamId: string,
channelId: string,
conversationHistory: string,
participantSlackIds: string[],
) => Promise<void>;
};

const invokeExtractMemories = (
job: MemoryJob,
teamId: string,
channelId: string,
conversationHistory: string,
participantSlackIds: string[],
): Promise<void> =>
(job as unknown as ExtractMemoriesInvoker).extractMemories(
teamId,
channelId,
conversationHistory,
participantSlackIds,
);

describe('MemoryJob', () => {
let job: MemoryJob;
let memoryPersistenceService: {
Expand All @@ -21,6 +44,7 @@ describe('MemoryJob', () => {
warn: ReturnType<typeof vi.fn>;
};
let aiService: {
alertOnOpenAiRateLimit: ReturnType<typeof vi.fn>;
openAi: {
responses: {
create: ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -48,6 +72,7 @@ describe('MemoryJob', () => {
warn: vi.fn(),
};
aiService = {
alertOnOpenAiRateLimit: vi.fn().mockResolvedValue(undefined),
openAi: {
responses: {
create: vi.fn(),
Expand All @@ -65,16 +90,7 @@ describe('MemoryJob', () => {
it('returns early when extraction lock exists', async () => {
redis.getValue.mockResolvedValue('1');

await (
job as never as {
extractMemories: (
teamId: string,
channelId: string,
conversationHistory: string,
participantSlackIds: string[],
) => Promise<void>;
}
).extractMemories('T1', 'C1', 'history', ['U1']);
await invokeExtractMemories(job, 'T1', 'C1', 'history', ['U1']);

expect(jobLogger.info).toHaveBeenCalled();
});
Expand All @@ -84,16 +100,7 @@ describe('MemoryJob', () => {
output: [{ type: 'message', content: [{ type: 'output_text', text: 'NONE' }] }],
});

await (
job as never as {
extractMemories: (
teamId: string,
channelId: string,
conversationHistory: string,
participantSlackIds: string[],
) => Promise<void>;
}
).extractMemories('T1', 'C1', 'history', ['U1']);
await invokeExtractMemories(job, 'T1', 'C1', 'history', ['U1']);

expect(memoryPersistenceService.saveMemories).not.toHaveBeenCalled();
});
Expand All @@ -117,16 +124,7 @@ describe('MemoryJob', () => {
],
});

await (
job as never as {
extractMemories: (
teamId: string,
channelId: string,
conversationHistory: string,
participantSlackIds: string[],
) => Promise<void>;
}
).extractMemories('T1', 'C1', 'history', ['U123ABC']);
await invokeExtractMemories(job, 'T1', 'C1', 'history', ['U123ABC']);

expect(memoryPersistenceService.saveMemories).toHaveBeenCalled();
expect(memoryPersistenceService.reinforceMemory).toHaveBeenCalledWith(10);
Expand All @@ -152,17 +150,20 @@ describe('MemoryJob', () => {
],
});

await (
job as never as {
extractMemories: (
teamId: string,
channelId: string,
conversationHistory: string,
participantSlackIds: string[],
) => Promise<void>;
}
).extractMemories('T1', 'C1', 'history', ['U123ABC']);
await invokeExtractMemories(job, 'T1', 'C1', 'history', ['U123ABC']);

expect(jobLogger.warn).toHaveBeenCalled();
});

it('alerts on OpenAI 429 errors during extraction', async () => {
const rateLimitError = Object.assign(new Error('Rate limit exceeded'), {
status: 429,
error: { message: 'Too many requests.' },
});
aiService.openAi.responses.create.mockRejectedValue(rateLimitError);

await invokeExtractMemories(job, 'T1', 'C1', 'history', ['U1']);

expect(aiService.alertOnOpenAiRateLimit).toHaveBeenCalledWith(rateLimitError, 'memory extraction');
});
});
4 changes: 4 additions & 0 deletions packages/backend/src/ai/memory/memory.job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ export class MemoryJob {
instructions: prompt,
input: conversationHistory,
})
.catch(async (error) => {
await this.aiService.alertOnOpenAiRateLimit(error, 'memory extraction');
throw error;
})
.then((response) => extractAndParseOpenAiResponse(response));

if (!result) {
Expand Down
Loading