Skip to content

Commit 4bf51cc

Browse files
fullsend-ai-coder[bot]claudegabemontero
authored
feat(#3306): add boost-backend-module-llamastack provider module (#3572)
* feat(#3306): add boost-backend-module-llamastack provider module Create the Llama Stack provider module as an independent createBackendModule with ResponsesApiProvider, OpenAI Agent SDK orchestration, and all provider caches backed by Backstage cacheService. Implements: - Task 2.1: Package scaffold with pluginId 'boost', moduleId 'llamastack' - Task 2.2: ResponsesApiProvider and ResponsesApiProviderFactory - Task 3.2: Model list cache (60s TTL via cacheService) - Task 3.5: MCP auth token cache (dynamic TTL via cacheService) - Task 3.8: Identity-keyed ClientManager cache (1h TTL) - Task 3b.2: Llama Stack-specific types defined in module only - Platform-ops 1.5: Provider session maps (24h TTL via cacheService) - Platform-ops 1.6: ClientManager identity-keyed cacheService All caches serialize to JSON strings for CacheService JsonValue compatibility. No raw Map<> caches — all use Backstage coreServices.cache per Decision 3. 30 tests, all passing. Zero lint warnings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address review findings — streaming done-event, method naming, constant placement, null guard - Fix streaming contract violation: return immediately after yielding done event instead of continuing to process remaining SSE events in the buffer - Rename remove() to delete() in SessionMap and ClientManager for consistency with sibling classes (DocumentSyncService.deleteHash, cache.invalidate) - Move TTL constants and KEY_PREFIX from module-level const to static readonly class members matching the DocumentSyncService pattern - Add null-coalescing guard (result.output ?? []) in extractTextFromResponse to handle undefined output from the Responses API Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: flush decoder buffer after SSE stream ends After the ReadableStream reader returns done, remaining buffer content was silently discarded. Flush the TextDecoder and process any remaining buffered SSE data before emitting the final done event. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add JSON.parse safety and sanitize error messages Wrap JSON.parse in cache getters (ClientManager, SessionMap, ModelListCache) with try/catch — on corrupted data, log warning, delete the key, and return undefined. Strip upstream API error body from thrown errors and yielded error events to prevent leaking internal infrastructure details to clients. Full error text is still logged server-side. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: wrap chatStream fetch in try/catch for network error handling Wrap the fetch call in chatStream() with try/catch so network failures (DNS, connection refused) yield a { type: 'error' } event instead of propagating an unhandled exception. Full error logged server-side. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: destructure mcpAuthTokenCache and log skipped non-text inputs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: guard against empty input after non-text item filtering Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: fullsend-code <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: gabemontero <gmontero@redhat.com>
1 parent 6c9cfa5 commit 4bf51cc

19 files changed

Lines changed: 2132 additions & 0 deletions
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
{
2+
"name": "@red-hat-developer-hub/backstage-plugin-boost-backend-module-llamastack",
3+
"version": "0.1.0",
4+
"license": "Apache-2.0",
5+
"description": "Llama Stack provider module for the boost AI platform",
6+
"main": "src/index.ts",
7+
"types": "src/index.ts",
8+
"publishConfig": {
9+
"access": "public"
10+
},
11+
"backstage": {
12+
"role": "backend-plugin-module",
13+
"pluginId": "boost",
14+
"pluginPackage": "@red-hat-developer-hub/backstage-plugin-boost-backend",
15+
"pluginPackages": [
16+
"@red-hat-developer-hub/backstage-plugin-boost-backend",
17+
"@red-hat-developer-hub/backstage-plugin-boost-backend-module-llamastack",
18+
"@red-hat-developer-hub/backstage-plugin-boost-common",
19+
"@red-hat-developer-hub/backstage-plugin-boost-node"
20+
]
21+
},
22+
"exports": {
23+
".": "./src/index.ts",
24+
"./package.json": "./package.json"
25+
},
26+
"typesVersions": {
27+
"*": {
28+
"package.json": [
29+
"package.json"
30+
]
31+
}
32+
},
33+
"dependencies": {
34+
"@backstage/backend-plugin-api": "^1.9.1",
35+
"@red-hat-developer-hub/backstage-plugin-boost-common": "workspace:^",
36+
"@red-hat-developer-hub/backstage-plugin-boost-node": "workspace:^"
37+
},
38+
"devDependencies": {
39+
"@backstage/cli": "^0.34.5"
40+
},
41+
"sideEffects": false,
42+
"scripts": {
43+
"build": "backstage-cli package build",
44+
"clean": "backstage-cli package clean",
45+
"lint": "backstage-cli package lint",
46+
"lint:check": "backstage-cli package lint",
47+
"lint:fix": "backstage-cli package lint --fix",
48+
"test": "backstage-cli package test --passWithNoTests --coverage",
49+
"tsc": "tsc",
50+
"start": "backstage-cli package start",
51+
"prepack": "backstage-cli package prepack",
52+
"postpack": "backstage-cli package postpack"
53+
},
54+
"files": [
55+
"dist"
56+
],
57+
"repository": {
58+
"type": "git",
59+
"url": "git+https://github.com/redhat-developer/rhdh-plugins.git",
60+
"directory": "workspaces/boost/plugins/boost-backend-module-llamastack"
61+
},
62+
"homepage": "https://red.ht/rhdh",
63+
"bugs": "https://github.com/redhat-developer/rhdh-plugins/issues"
64+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
## API Report File for "@red-hat-developer-hub/backstage-plugin-boost-backend-module-llamastack"
2+
3+
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
4+
5+
```ts
6+
import { BackendFeature } from '@backstage/backend-plugin-api';
7+
8+
// @public
9+
const boostModuleLlamastack: BackendFeature;
10+
export default boostModuleLlamastack;
11+
```
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/**
18+
* Llama Stack provider module for the boost AI platform.
19+
*
20+
* @packageDocumentation
21+
*/
22+
23+
export { boostModuleLlamastack as default } from './module';
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import {
18+
coreServices,
19+
createBackendModule,
20+
} from '@backstage/backend-plugin-api';
21+
import { boostProviderExtensionPoint } from '@red-hat-developer-hub/backstage-plugin-boost-node';
22+
import { ResponsesApiProviderFactory } from './provider/ResponsesApiProviderFactory';
23+
24+
/**
25+
* Llama Stack provider module for the boost plugin.
26+
*
27+
* Registers a ResponsesApiProvider via the boost extension point,
28+
* enabling Llama Stack as an AI backend. All caches use Backstage
29+
* cacheService (Decision 3).
30+
*
31+
* Install alongside the core boost plugin:
32+
*
33+
* ```ts
34+
* // packages/backend/src/index.ts
35+
* backend.add(import('@red-hat-developer-hub/backstage-plugin-boost-backend'));
36+
* backend.add(import('@red-hat-developer-hub/backstage-plugin-boost-backend-module-llamastack'));
37+
* ```
38+
*
39+
* @public
40+
*/
41+
export const boostModuleLlamastack = createBackendModule({
42+
pluginId: 'boost',
43+
moduleId: 'llamastack',
44+
register(reg) {
45+
reg.registerInit({
46+
deps: {
47+
providers: boostProviderExtensionPoint,
48+
config: coreServices.rootConfig,
49+
cache: coreServices.cache,
50+
logger: coreServices.logger,
51+
},
52+
async init({ providers, config, cache, logger }) {
53+
logger.info('Initializing Llama Stack provider module');
54+
55+
const factory = new ResponsesApiProviderFactory({
56+
config,
57+
cache,
58+
logger,
59+
});
60+
61+
const {
62+
provider,
63+
modelListCache,
64+
mcpAuthTokenCache,
65+
clientManager,
66+
sessionMap,
67+
} = factory.create();
68+
69+
// Register the provider with the boost plugin
70+
providers.registerProvider(provider);
71+
72+
logger.info(
73+
`Llama Stack provider registered (id: ${provider.descriptor.id})`,
74+
);
75+
logger.info(
76+
`Caches initialized: modelList (60s TTL), mcpAuthToken (dynamic TTL), ` +
77+
`clientManager (1h TTL), sessionMap (24h TTL)`,
78+
);
79+
80+
// Log cache readiness (these are available for future route handlers)
81+
logger.debug(
82+
`ModelListCache: ready, ClientManager: ready, SessionMap: ready`,
83+
);
84+
85+
// Suppress unused-variable warnings — these caches are initialized
86+
// and available for provider-internal operations. Future iterations
87+
// will wire them into route handlers and orchestration flows.
88+
void modelListCache;
89+
void mcpAuthTokenCache;
90+
void clientManager;
91+
void sessionMap;
92+
},
93+
});
94+
},
95+
});
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import type {
18+
CacheService,
19+
LoggerService,
20+
} from '@backstage/backend-plugin-api';
21+
import { ClientManager } from './ClientManager';
22+
import type { ClientState } from '../types';
23+
24+
function createMockLogger(): LoggerService {
25+
return {
26+
info: jest.fn(),
27+
warn: jest.fn(),
28+
error: jest.fn(),
29+
debug: jest.fn(),
30+
child: jest.fn().mockReturnThis(),
31+
};
32+
}
33+
34+
function createMockCache(): CacheService {
35+
const store = new Map<string, unknown>();
36+
const cache: CacheService = {
37+
get: jest.fn(async (key: string) => store.get(key)) as CacheService['get'],
38+
set: jest.fn(async (key: string, value: unknown) => {
39+
store.set(key, value);
40+
}),
41+
delete: jest.fn(async (key: string) => {
42+
store.delete(key);
43+
}),
44+
withOptions: jest.fn().mockReturnThis(),
45+
};
46+
return cache;
47+
}
48+
49+
describe('ClientManager', () => {
50+
let cache: CacheService;
51+
let clientManager: ClientManager;
52+
53+
beforeEach(() => {
54+
cache = createMockCache();
55+
clientManager = new ClientManager({
56+
cache,
57+
logger: createMockLogger(),
58+
});
59+
});
60+
61+
it('stores and retrieves client state', async () => {
62+
const state: ClientState = {
63+
userRef: 'user:default/john',
64+
lastActivity: '2025-01-01T00:00:00.000Z',
65+
sessionCount: 1,
66+
};
67+
68+
await clientManager.set('user:default/john', state);
69+
const result = await clientManager.get('user:default/john');
70+
expect(result).toEqual(state);
71+
});
72+
73+
it('returns undefined for unknown user', async () => {
74+
const result = await clientManager.get('user:default/unknown');
75+
expect(result).toBeUndefined();
76+
});
77+
78+
it('stores state with 1-hour TTL', async () => {
79+
const state: ClientState = {
80+
userRef: 'user:default/john',
81+
lastActivity: '2025-01-01T00:00:00.000Z',
82+
sessionCount: 1,
83+
};
84+
85+
await clientManager.set('user:default/john', state);
86+
expect(cache.set).toHaveBeenCalledWith(
87+
'llamastack:client:user:default/john',
88+
JSON.stringify(state),
89+
{ ttl: 3600000 },
90+
);
91+
});
92+
93+
it('records activity and increments session count', async () => {
94+
const first = await clientManager.recordActivity('user:default/jane');
95+
expect(first.sessionCount).toBe(1);
96+
expect(first.userRef).toBe('user:default/jane');
97+
98+
const second = await clientManager.recordActivity('user:default/jane');
99+
expect(second.sessionCount).toBe(2);
100+
});
101+
102+
it('deletes client state', async () => {
103+
await clientManager.recordActivity('user:default/john');
104+
await clientManager.delete('user:default/john');
105+
const result = await clientManager.get('user:default/john');
106+
expect(result).toBeUndefined();
107+
});
108+
});

0 commit comments

Comments
 (0)