Skip to content

Commit

Permalink
feat(lightspeed): add backend config and /conversations/:conversation…
Browse files Browse the repository at this point in the history
…_id GET&DELETE API (#2211)

* add backend config and load session API and tests

Signed-off-by: Stephanie <yangcao@redhat.com>

* generate dist-dynamic

Signed-off-by: Stephanie <yangcao@redhat.com>

* fix tsc

Signed-off-by: Stephanie <yangcao@redhat.com>

* update to conversations endpoint with path params

Signed-off-by: Stephanie <yangcao@redhat.com>

* run yarn tsc

Signed-off-by: Stephanie <yangcao@redhat.com>

* add delete API

Signed-off-by: Stephanie <yangcao@redhat.com>

* resolve review comments

Signed-off-by: Stephanie <yangcao@redhat.com>

---------

Signed-off-by: Stephanie <yangcao@redhat.com>
  • Loading branch information
yangcao77 authored Sep 21, 2024
1 parent 0f3272c commit ef9ee68
Show file tree
Hide file tree
Showing 10 changed files with 438 additions and 45 deletions.
32 changes: 11 additions & 21 deletions plugins/lightspeed-backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,32 +67,22 @@ backend.start();

### Plugin Configurations

Add the following proxy configurations into your `app-config.yaml` file:
Add the following lightspeed configurations into your `app-config.yaml` file:

```yaml
proxy:
endpoints:
'/lightspeed/api':
target: '<LLM server URL>'
headers:
content-type: 'application/json'
Authorization: 'Bearer <api-token>'
secure: true
changeOrigin: true
credentials: 'dangerously-allow-unauthenticated' # No Backstage credentials are required to access this proxy target
lightspeed:
servers:
- id: <server id>
url: <serverURL>
token: <api key> # dummy token
```
Example local development configuration:
```yaml
proxy:
endpoints:
'/lightspeed/api':
target: 'https://localhost:443/v1'
headers:
content-type: 'application/json'
Authorization: 'Bearer js92n-ssj28dbdk902' # dummy token
secure: true
changeOrigin: true
credentials: 'dangerously-allow-unauthenticated' # No Backstage credentials are required to access this proxy target
lightspeed:
servers:
- id: 'my-llm-server'
url: 'https://localhost:443/v1'
token: 'js92n-ssj28dbdk902' # dummy token
```
26 changes: 25 additions & 1 deletion plugins/lightspeed-backend/config.d.ts
Original file line number Diff line number Diff line change
@@ -1 +1,25 @@
export interface Config {}
export interface Config {
/**
* Configuration required for using lightspeed
* @visibility frontend
*/
lightspeed: {
servers: Array<{
/**
* The id of the server.
* @visibility frontend
*/
id: string;
/**
* The url of the server.
* @visibility frontend
*/
url: string;
/**
* The access token for authenticating server.
* @visibility secret
*/
token?: string;
}>;
};
}
1 change: 1 addition & 0 deletions plugins/lightspeed-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
},
"devDependencies": {
"@backstage/cli": "0.26.10",
"@backstage/test-utils": "^1.6.0",
"@janus-idp/cli": "1.12.0",
"@types/supertest": "2.0.12",
"msw": "1.0.0",
Expand Down
95 changes: 95 additions & 0 deletions plugins/lightspeed-backend/src/handlers/chatHistory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { AIMessage, HumanMessage } from '@langchain/core/messages';

import { Roles } from '../service/types';
import { deleteHistory, loadHistory, saveHistory } from './chatHistory';

const mockConversationId = 'user1+1q2w3e4r-qwer1234';

describe('Test History Functions', () => {
afterEach(async () => {
// Clear the history store before each test
await deleteHistory(mockConversationId);
});

test('saveHistory should save a human message', async () => {
const message = 'Hello, how are you?';

await saveHistory(mockConversationId, Roles.HumanRole, message);

const history = await loadHistory(mockConversationId, 10);
expect(history.length).toBe(1);
expect(history[0]).toBeInstanceOf(HumanMessage);
expect(history[0].content).toBe(message);
});

test('saveHistory should save an AI message', async () => {
const message = 'I am fine, thank you!';

await saveHistory(mockConversationId, Roles.AIRole, message);

const history = await loadHistory(mockConversationId, 10);

expect(history.length).toBe(1);
expect(history[0]).toBeInstanceOf(AIMessage);
expect(history[0].content).toBe(message);
});

test('saveHistory and loadHistory with multiple messages', async () => {
await saveHistory(mockConversationId, Roles.HumanRole, 'Hello');
await saveHistory(
mockConversationId,
Roles.AIRole,
'Hi! How can I help you today?',
);

const history = await loadHistory(mockConversationId, 10);
expect(history.length).toBe(2);
expect(history[0]).toBeInstanceOf(HumanMessage);
expect(history[0].content).toBe('Hello');
expect(history[1]).toBeInstanceOf(AIMessage);
expect(history[1].content).toBe('Hi! How can I help you today?');
});

test('saveHistory and loadHistory with exact number of messages', async () => {
await saveHistory(mockConversationId, Roles.HumanRole, 'Hello');
await saveHistory(
mockConversationId,
Roles.AIRole,
'Hi! How can I help you today?',
);

const history = await loadHistory(mockConversationId, 1);
expect(history.length).toBe(1);
expect(history[0]).toBeInstanceOf(AIMessage);
expect(history[0].content).toBe('Hi! How can I help you today?');
});

test('saveHistory should throw an error for unknown roles', async () => {
await expect(
saveHistory(mockConversationId, 'UnknownRole', 'Message'),
).rejects.toThrow('Unknown role: UnknownRole');
});

test('deleteHistory should delete specific conversation', async () => {
await saveHistory(mockConversationId, Roles.HumanRole, 'Hello');
await saveHistory('conv2', Roles.AIRole, 'Hi! How can I help you today?');

const history1 = await loadHistory(mockConversationId, 1);
expect(history1.length).toBe(1);

const history2 = await loadHistory('conv2', 1);
expect(history2.length).toBe(1);

await deleteHistory('conv2');

await expect(loadHistory('conv2', 1)).rejects.toThrow(
'unknown conversation_id: conv2',
);

expect(await loadHistory(mockConversationId, 1)).toBeDefined();
});

test('deleteHistory should not return error with unknown id', async () => {
await expect(() => deleteHistory(mockConversationId)).not.toThrow();
});
});
58 changes: 58 additions & 0 deletions plugins/lightspeed-backend/src/handlers/chatHistory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {
AIMessage,
BaseMessage,
HumanMessage,
SystemMessage,
} from '@langchain/core/messages';
import { InMemoryStore } from '@langchain/core/stores';

import { Roles } from '../service/types';

const historyStore = new InMemoryStore<BaseMessage[]>();

export async function saveHistory(
conversation_id: string,
role: string,
message: string,
): Promise<void> {
let newMessage: BaseMessage;
switch (role) {
case Roles.AIRole: {
newMessage = new AIMessage(message);
break;
}
case Roles.HumanRole: {
newMessage = new HumanMessage(message);
break;
}
case Roles.SystemRole: {
newMessage = new SystemMessage(message);
break;
}
default:
throw new Error(`Unknown role: ${role}`);
}

const sessionHistory = await historyStore.mget([conversation_id]);
let newHistory: BaseMessage[] = [];
if (sessionHistory && sessionHistory[0]) {
newHistory = sessionHistory[0];
}
newHistory.push(newMessage);
await historyStore.mset([[conversation_id, newHistory]]);
}

export async function loadHistory(
conversation_id: string,
historyLength: number,
): Promise<BaseMessage[]> {
const sessionHistory = await historyStore.mget([conversation_id]);
if (!sessionHistory[0]) {
throw new Error(`unknown conversation_id: ${conversation_id}`);
}
return sessionHistory[0]?.slice(-historyLength);
}

export async function deleteHistory(conversation_id: string): Promise<void> {
return await historyStore.mdelete([conversation_id]);
}
66 changes: 61 additions & 5 deletions plugins/lightspeed-backend/src/service/router.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { getVoidLogger } from '@backstage/backend-common';
// import { mockCredentials, mockServices } from '@backstage/backend-test-utils';
import { ConfigReader } from '@backstage/config';
import { MockConfigApi } from '@backstage/test-utils';

import { AIMessage } from '@langchain/core/messages';
import express from 'express';
import request from 'supertest';

import { saveHistory } from '../handlers/chatHistory';
import { Roles } from '../service/types';
import { createRouter } from './router';

const mockAIMessage = new AIMessage('Mockup AI Message');
Expand All @@ -27,6 +29,22 @@ jest.mock('@langchain/core/prompts', () => {
};
});

const mockConversationId = 'user1+1q2w3e4r-qwer1234';
const mockServerURL = 'http://localhost:7007/api/proxy/lightspeed/api';
const mockModel = 'test-model';

const mockConfiguration = new MockConfigApi({
lightspeed: {
servers: [
{
id: 'test-server',
url: mockServerURL,
token: 'dummy-token',
},
],
},
});

(global.fetch as jest.Mock) = jest.fn();

describe('createRouter', () => {
Expand All @@ -35,7 +53,7 @@ describe('createRouter', () => {
beforeAll(async () => {
const router = await createRouter({
logger: getVoidLogger(),
config: new ConfigReader({}),
config: mockConfiguration,

// TODO: for user authentication
// httpAuth: mockServices.httpAuth({
Expand All @@ -62,9 +80,47 @@ describe('createRouter', () => {
});
});

const mockConversationId = 'user1+1q2w3e4r-qwer1234';
const mockServerURL = 'http://localhost:7007/api/proxy/lightspeed/api';
const mockModel = 'test-model';
describe('GET and DELETE /conversations/:conversation_id', () => {
it('load history', async () => {
const humanMessage = 'Hello';
const aiMessage = 'Hi! How can I help you today?';
await saveHistory(mockConversationId, Roles.HumanRole, humanMessage);
await saveHistory(mockConversationId, Roles.AIRole, aiMessage);

const response = await request(app).get(
`/conversations/${mockConversationId}`,
);
expect(response.statusCode).toEqual(200);
// Parse response body
const responseData = response.body;

// Check that responseData is an array
expect(Array.isArray(responseData)).toBe(true);
expect(responseData.length).toBe(2);

expect(responseData[0].id).toContain('HumanMessage');
expect(responseData[0].kwargs?.content).toBe(humanMessage);

expect(responseData[1].id).toContain('AIMessage');
expect(responseData[1].kwargs?.content).toBe(aiMessage);
});

it('delete history', async () => {
// delete request
const deleteResponse = await request(app).delete(
`/conversations/${mockConversationId}`,
);
expect(deleteResponse.statusCode).toEqual(200);
});

it('load history with deleted conversation_id', async () => {
const response = await request(app).get(
`/conversations/${mockConversationId}`,
);
expect(response.statusCode).toEqual(500);
expect(response.body.error).toContain('unknown conversation_id');
});
});

describe('POST /v1/query', () => {
it('chat completions', async () => {
Expand Down
Loading

0 comments on commit ef9ee68

Please sign in to comment.