Skip to content

Commit 298e149

Browse files
ananzhashwin-pcopensearch-changeset-bot[bot]
authored
[chat][AI] add proxy route for ml commons (#10875)
* refactor(chat): implement server-side proxy for AG-UI communication Signed-off-by: Ashwin P Chandran <[email protected]> * Changeset file for PR #10826 created/updated Signed-off-by: Ashwin P Chandran <[email protected]> * fixes tests Signed-off-by: Ashwin P Chandran <[email protected]> * [chat][AI] add proxy route for ml commons Signed-off-by: Anan Zhuang <[email protected]> * fix PR comment Signed-off-by: Anan Zhuang <[email protected]> --------- Signed-off-by: Ashwin P Chandran <[email protected]> Signed-off-by: Anan Zhuang <[email protected]> Co-authored-by: Ashwin P Chandran <[email protected]> Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com>
1 parent 4c9d417 commit 298e149

File tree

14 files changed

+950
-42
lines changed

14 files changed

+950
-42
lines changed

changelogs/fragments/10826.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
chore:
2+
- Proxies the AG-UI calls via the node server ([#10826](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/10826))

config/opensearch_dashboards.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,7 @@
410410
# chat:
411411
# enabled: true
412412
# agUiUrl: "http://your-ai-agent-url:port"
413+
# mlCommonsAgentId: "olly-chat-agent-id"
413414

414415
# @experimental Set the value to true to enable context provider
415416
# contextProvider:

src/plugins/chat/public/plugin.test.ts

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,11 @@ describe('ChatPlugin', () => {
7878
});
7979

8080
describe('start', () => {
81-
it('should initialize chat service with configured AG-UI URL', () => {
81+
it('should initialize chat service when enabled', () => {
8282
plugin.start(mockCoreStart, mockDeps);
8383

84-
expect(ChatService).toHaveBeenCalledWith('http://test-ag-ui:3000');
84+
// ChatService is called without arguments (uses proxy endpoint)
85+
expect(ChatService).toHaveBeenCalledWith();
8586
});
8687

8788
it('should register chat button in header nav controls', () => {
@@ -100,14 +101,17 @@ describe('ChatPlugin', () => {
100101
expect(startContract.chatService).toBeInstanceOf(ChatService);
101102
});
102103

103-
it('should handle missing AG-UI URL configuration', () => {
104+
it('should initialize chat service even without agUiUrl config', () => {
105+
// agUiUrl is server-side config only; client doesn't need it
104106
mockInitializerContext.config.get = jest.fn().mockReturnValue({ enabled: true });
107+
const testPlugin = new ChatPlugin(mockInitializerContext);
105108

106-
const startContract = plugin.start(mockCoreStart, mockDeps);
109+
const startContract = testPlugin.start(mockCoreStart, mockDeps);
107110

108-
expect(ChatService).not.toHaveBeenCalled();
109-
expect(startContract.chatService).toBeUndefined();
110-
expect(mockCoreStart.chrome.navControls.registerRight).not.toHaveBeenCalled();
111+
// ChatService should still be created (uses proxy endpoint)
112+
expect(ChatService).toHaveBeenCalledWith();
113+
expect(startContract.chatService).toBeInstanceOf(ChatService);
114+
expect(mockCoreStart.chrome.navControls.registerRight).toHaveBeenCalled();
111115
});
112116

113117
it('should not initialize when plugin is disabled', () => {
@@ -272,19 +276,22 @@ describe('ChatPlugin', () => {
272276
{ enabled: true, agUiUrl: 'http://localhost:3000' },
273277
{ enabled: true, agUiUrl: 'https://remote-server:8080' },
274278
{ enabled: false, agUiUrl: 'http://localhost:3000' },
275-
{ enabled: true }, // Missing agUiUrl
279+
{ enabled: true }, // Missing agUiUrl (still works with proxy)
276280
{}, // Missing both enabled and agUiUrl
277281
];
278282

279-
configs.forEach((config) => {
283+
configs.forEach((config, index) => {
284+
jest.clearAllMocks();
280285
mockInitializerContext.config.get = jest.fn().mockReturnValue(config);
281286
const testPlugin = new ChatPlugin(mockInitializerContext);
282287

283288
expect(() => testPlugin.start(mockCoreStart, mockDeps)).not.toThrow();
284289

285-
// Only first two configs should initialize the service
286-
if (config.enabled && config.agUiUrl) {
287-
expect(ChatService).toHaveBeenCalledWith(config.agUiUrl);
290+
// ChatService is initialized whenever enabled is true (regardless of agUiUrl)
291+
if (config.enabled) {
292+
expect(ChatService).toHaveBeenCalledWith();
293+
} else {
294+
expect(ChatService).not.toHaveBeenCalled();
288295
}
289296
});
290297
});

src/plugins/chat/public/plugin.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,17 +34,17 @@ export class ChatPlugin implements Plugin<ChatPluginSetup, ChatPluginStart> {
3434
// Get configuration
3535
const config = this.initializerContext.config.get<{ enabled: boolean; agUiUrl?: string }>();
3636

37-
// Check if chat plugin is enabled and has required agUiUrl
38-
if (!config.enabled || !config.agUiUrl) {
37+
// Check if chat plugin is enabled
38+
if (!config.enabled) {
3939
return {
4040
chatService: undefined,
4141
};
4242
}
4343

4444
const chatHeaderButtonRef = React.createRef<ChatHeaderButtonInstance>();
4545

46-
// Initialize chat service with configured AG-UI URL
47-
this.chatService = new ChatService(config.agUiUrl);
46+
// Initialize chat service (it will use the server proxy)
47+
this.chatService = new ChatService();
4848

4949
// Store reference to chat service for use in subscription
5050
const chatService = this.chatService;

src/plugins/chat/public/services/ag_ui_agent.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ describe('AgUiAgent', () => {
7373
headers: {
7474
'Content-Type': 'application/json',
7575
Accept: 'text/event-stream',
76+
'osd-xsrf': 'true',
7677
},
7778
body: JSON.stringify(mockInput),
7879
signal: expect.any(AbortSignal),

src/plugins/chat/public/services/ag_ui_agent.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,13 @@ export enum EventType {
2020
}
2121

2222
export class AgUiAgent {
23-
private serverUrl: string;
23+
private proxyUrl: string;
2424
private abortController?: AbortController;
2525
private sseBuffer: string = '';
2626
private activeConnection: boolean = false;
2727

28-
constructor(serverUrl: string = 'http://localhost:3000') {
29-
this.serverUrl = serverUrl;
28+
constructor(proxyUrl: string = '/api/chat/proxy') {
29+
this.proxyUrl = proxyUrl;
3030
}
3131

3232
public runAgent(input: RunAgentInput): Observable<BaseEvent> {
@@ -46,12 +46,13 @@ export class AgUiAgent {
4646
// Set active connection flag
4747
this.activeConnection = true;
4848

49-
// Make request to AG-UI server
50-
fetch(this.serverUrl, {
49+
// Make request to OpenSearch Dashboards proxy endpoint
50+
fetch(this.proxyUrl, {
5151
method: 'POST',
5252
headers: {
5353
'Content-Type': 'application/json',
5454
Accept: 'text/event-stream',
55+
'osd-xsrf': 'true', // Required for OpenSearch Dashboards API calls
5556
},
5657
body: JSON.stringify(input),
5758
signal: this.abortController.signal,
@@ -110,7 +111,7 @@ export class AgUiAgent {
110111
}
111112

112113
// eslint-disable-next-line no-console
113-
console.error('AG-UI request failed:', error.message);
114+
console.error('Chat proxy request failed:', error.message);
114115

115116
observer.next({
116117
type: EventType.RUN_ERROR,

src/plugins/chat/public/services/chat_service.test.ts

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,25 +30,14 @@ describe('ChatService', () => {
3030
// Mock AgUiAgent constructor
3131
(AgUiAgent as jest.MockedClass<typeof AgUiAgent>).mockImplementation(() => mockAgent);
3232

33-
chatService = new ChatService('http://test-server');
33+
chatService = new ChatService();
3434
});
3535

3636
afterEach(() => {
3737
jest.restoreAllMocks();
3838
});
3939

4040
describe('constructor', () => {
41-
it('should create instance with default server URL', () => {
42-
const service = new ChatService();
43-
expect(AgUiAgent).toHaveBeenCalledWith(undefined);
44-
});
45-
46-
it('should create instance with custom server URL', () => {
47-
const customUrl = 'http://custom-server:8080';
48-
const service = new ChatService(customUrl);
49-
expect(AgUiAgent).toHaveBeenCalledWith(customUrl);
50-
});
51-
5241
it('should initialize with empty available tools', () => {
5342
expect(chatService.availableTools).toEqual([]);
5443
});

src/plugins/chat/public/services/chat_service.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ export class ChatService {
3939
// ChatWindow ref for delegating sendMessage calls to proper timeline management
4040
private chatWindowRef: React.RefObject<ChatWindowInstance> | null = null;
4141

42-
constructor(serverUrl?: string) {
43-
this.agent = new AgUiAgent(serverUrl);
42+
constructor() {
43+
// No need to pass URL anymore - agent will use the proxy endpoint
44+
this.agent = new AgUiAgent();
4445
this.threadId = this.generateThreadId();
4546
}
4647

src/plugins/chat/server/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { schema, TypeOf } from '@osd/config-schema';
88
export const configSchema = schema.object({
99
enabled: schema.boolean({ defaultValue: false }),
1010
agUiUrl: schema.maybe(schema.string()),
11+
mlCommonsAgentId: schema.maybe(schema.string()),
1112
});
1213

1314
export type ChatConfigType = TypeOf<typeof configSchema>;

src/plugins/chat/server/plugin.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,40 +3,58 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6+
import { Observable } from 'rxjs';
7+
import { first } from 'rxjs/operators';
68
import {
79
PluginInitializerContext,
810
CoreSetup,
911
CoreStart,
1012
Plugin,
1113
Logger,
14+
OpenSearchDashboardsRequest,
1215
} from '../../../core/server';
1316

1417
import { ChatPluginSetup, ChatPluginStart } from './types';
1518
import { defineRoutes } from './routes';
19+
import { ChatConfigType } from './config';
1620

1721
/**
1822
* @experimental
1923
* Chat plugin for AI-powered interactions. This plugin is experimental and will change in future releases.
2024
*/
2125
export class ChatPlugin implements Plugin<ChatPluginSetup, ChatPluginStart> {
2226
private readonly logger: Logger;
27+
private readonly config$: Observable<ChatConfigType>;
28+
private capabilitiesResolver?: (request: OpenSearchDashboardsRequest) => Promise<Capabilities>;
2329

2430
constructor(initializerContext: PluginInitializerContext) {
2531
this.logger = initializerContext.logger.get();
32+
this.config$ = initializerContext.config.create<ChatConfigType>();
2633
}
2734

28-
public setup(core: CoreSetup) {
35+
public async setup(core: CoreSetup) {
2936
this.logger.debug('chat: Setup');
37+
const config = await this.config$.pipe(first()).toPromise();
3038
const router = core.http.createRouter();
39+
const getCapabilitiesResolver = () => this.capabilitiesResolver;
3140

32-
// Register server side APIs
33-
defineRoutes(router);
41+
defineRoutes(
42+
router,
43+
this.logger,
44+
config.agUiUrl,
45+
getCapabilitiesResolver,
46+
config.mlCommonsAgentId
47+
);
3448

3549
return {};
3650
}
3751

3852
public start(core: CoreStart) {
3953
this.logger.debug('chat: Started');
54+
55+
this.capabilitiesResolver = (request: OpenSearchDashboardsRequest) =>
56+
core.capabilities.resolveCapabilities(request);
57+
4058
return {};
4159
}
4260

0 commit comments

Comments
 (0)