Skip to content

Commit 7b81c3d

Browse files
authored
fix(ai-integrations): copy model, model server annotations to resource, component annotations (#1668)
Assisted-by: claude-4-sonnet (code changes and unit tests)
1 parent ee0989f commit 7b81c3d

File tree

3 files changed

+203
-10
lines changed

3 files changed

+203
-10
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-catalog-backend-module-model-catalog': minor
3+
---
4+
5+
copy model/modelServer annotations to resource/component annotations

workspaces/ai-integrations/plugins/catalog-backend-module-model-catalog/src/clients/ModelCatalogGenerator.test.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,4 +516,168 @@ describe('Model Catalog Generator', () => {
516516
// Should not have annotations if none were provided in API
517517
expect(modelServerComponent.metadata.annotations).toBeUndefined();
518518
});
519+
520+
it('should copy modelServer annotations to component metadata when present', () => {
521+
const modelCatalog: ModelCatalog = {
522+
modelServer: {
523+
name: 'annotated-server',
524+
owner: 'example-user',
525+
description: 'Model server with annotations',
526+
lifecycle: 'production',
527+
annotations: {
528+
'custom.io/annotation1': 'server-value1',
529+
'custom.io/annotation2': 'server-value2',
530+
'backstage.io/custom-tag': 'server-custom',
531+
},
532+
},
533+
models: [
534+
{
535+
name: 'test-model',
536+
description: 'Test model',
537+
lifecycle: 'production',
538+
owner: 'example-user',
539+
},
540+
],
541+
};
542+
543+
const modelCatalogEntities = GenerateCatalogEntities(modelCatalog);
544+
545+
const modelServerComponent = modelCatalogEntities.find(
546+
entity =>
547+
entity.kind === 'Component' &&
548+
entity.metadata.name === 'annotated-server',
549+
) as ComponentEntity;
550+
551+
expect(modelServerComponent).toBeDefined();
552+
expect(modelServerComponent.metadata.annotations).toBeDefined();
553+
expect(modelServerComponent.metadata.annotations).toEqual({
554+
'custom.io/annotation1': 'server-value1',
555+
'custom.io/annotation2': 'server-value2',
556+
'backstage.io/custom-tag': 'server-custom',
557+
});
558+
});
559+
560+
it('should merge modelServer annotations with API annotations when both are present', () => {
561+
const modelCatalog: ModelCatalog = {
562+
modelServer: {
563+
name: 'fully-annotated-service',
564+
owner: 'example-user',
565+
description: 'Model service with both server and API annotations',
566+
lifecycle: 'production',
567+
annotations: {
568+
'server.io/annotation1': 'from-server',
569+
'server.io/annotation2': 'also-from-server',
570+
},
571+
API: {
572+
url: 'https://api.example.com',
573+
type: Type.Openapi,
574+
spec: 'https://example.com/openapi.json',
575+
annotations: {
576+
'api.io/annotation1': 'from-api',
577+
'api.io/annotation2': 'also-from-api',
578+
},
579+
},
580+
},
581+
models: [
582+
{
583+
name: 'test-model',
584+
description: 'Test model',
585+
lifecycle: 'production',
586+
owner: 'example-user',
587+
},
588+
],
589+
};
590+
591+
const modelCatalogEntities = GenerateCatalogEntities(modelCatalog);
592+
593+
// Find the model server component entity
594+
const modelServerComponent = modelCatalogEntities.find(
595+
entity =>
596+
entity.kind === 'Component' &&
597+
entity.metadata.name === 'fully-annotated-service',
598+
) as ComponentEntity;
599+
600+
expect(modelServerComponent).toBeDefined();
601+
expect(modelServerComponent.metadata.annotations).toBeDefined();
602+
// Should contain annotations from both API and modelServer
603+
expect(modelServerComponent.metadata.annotations).toEqual({
604+
'api.io/annotation1': 'from-api',
605+
'api.io/annotation2': 'also-from-api',
606+
'server.io/annotation1': 'from-server',
607+
'server.io/annotation2': 'also-from-server',
608+
});
609+
});
610+
611+
it('should copy model annotations to resource metadata when present', () => {
612+
const modelCatalog: ModelCatalog = {
613+
models: [
614+
{
615+
name: 'annotated-model',
616+
description: 'Model with annotations',
617+
lifecycle: 'production',
618+
owner: 'example-user',
619+
annotations: {
620+
'custom.io/model-annotation1': 'model-value1',
621+
'custom.io/model-annotation2': 'model-value2',
622+
'backstage.io/custom-model-tag': 'model-custom',
623+
},
624+
},
625+
],
626+
};
627+
628+
const modelCatalogEntities = GenerateCatalogEntities(modelCatalog);
629+
630+
// Find the model resource entity
631+
const modelResource = modelCatalogEntities.find(
632+
entity =>
633+
entity.kind === 'Resource' &&
634+
entity.metadata.name === 'annotated-model',
635+
);
636+
637+
expect(modelResource).toBeDefined();
638+
expect(modelResource!.metadata.annotations).toBeDefined();
639+
expect(modelResource!.metadata.annotations).toEqual({
640+
'custom.io/model-annotation1': 'model-value1',
641+
'custom.io/model-annotation2': 'model-value2',
642+
'backstage.io/custom-model-tag': 'model-custom',
643+
});
644+
});
645+
646+
it('should copy model annotations and preserve TechDocs special case handling', () => {
647+
const modelCatalog: ModelCatalog = {
648+
models: [
649+
{
650+
name: 'fully-annotated-model',
651+
description: 'Model with multiple annotations including TechDocs',
652+
lifecycle: 'production',
653+
owner: 'example-user',
654+
annotations: {
655+
TechDocs:
656+
'https://github.com/redhat-ai-dev/granite-3.1-8b-lab-docs/tree/main',
657+
'custom.io/model-annotation1': 'model-value1',
658+
'custom.io/model-annotation2': 'model-value2',
659+
},
660+
},
661+
],
662+
};
663+
664+
const modelCatalogEntities = GenerateCatalogEntities(modelCatalog);
665+
666+
// Find the model resource entity
667+
const modelResource = modelCatalogEntities.find(
668+
entity =>
669+
entity.kind === 'Resource' &&
670+
entity.metadata.name === 'fully-annotated-model',
671+
);
672+
673+
expect(modelResource).toBeDefined();
674+
expect(modelResource!.metadata.annotations).toBeDefined();
675+
// Should have TechDocs converted to backstage.io/techdocs-ref and other annotations copied
676+
expect(modelResource!.metadata.annotations).toEqual({
677+
'backstage.io/techdocs-ref':
678+
'url:https://github.com/redhat-ai-dev/granite-3.1-8b-lab-docs/tree/main',
679+
'custom.io/model-annotation1': 'model-value1',
680+
'custom.io/model-annotation2': 'model-value2',
681+
});
682+
});
519683
});

workspaces/ai-integrations/plugins/catalog-backend-module-model-catalog/src/clients/ModelCatalogGenerator.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -131,17 +131,28 @@ export function GenerateModelResourceEntities(
131131

132132
// Handle any annotations present on the model resource
133133
if (model.annotations !== undefined) {
134-
// Add the techdocs annotation to the resource if present
135-
let techdocsUrl: string = model.annotations.TechDocs;
136-
techdocsUrl = techdocsUrl.trim();
137-
if (model.annotations.TechDocs !== '') {
138-
if (modelResourceEntity.metadata.annotations === undefined) {
139-
modelResourceEntity.metadata.annotations = {};
140-
}
141-
modelResourceEntity.metadata.annotations[
142-
'backstage.io/techdocs-ref'
143-
] = `url:${techdocsUrl}`;
134+
// Initialize annotations object if needed
135+
if (modelResourceEntity.metadata.annotations === undefined) {
136+
modelResourceEntity.metadata.annotations = {};
144137
}
138+
139+
// Copy all annotations from model to resource entity
140+
Object.keys(model.annotations).forEach(key => {
141+
// Special handling for TechDocs annotation
142+
if (key === 'TechDocs') {
143+
let techdocsUrl: string = model.annotations![key];
144+
techdocsUrl = techdocsUrl.trim();
145+
if (techdocsUrl !== '') {
146+
modelResourceEntity.metadata.annotations![
147+
'backstage.io/techdocs-ref'
148+
] = `url:${techdocsUrl}`;
149+
}
150+
} else {
151+
// Copy all other annotations as-is
152+
modelResourceEntity.metadata.annotations![key] =
153+
model.annotations![key];
154+
}
155+
});
145156
}
146157
modelResourceEntities.push(modelResourceEntity);
147158
});
@@ -213,6 +224,19 @@ export function GenerateModelServerComponentEntity(
213224
url: `${modelServer.homepageURL}`,
214225
});
215226
}
227+
228+
// Copy annotations from modelServer to component metadata if they exist
229+
if (modelServer.annotations !== undefined) {
230+
if (modelServerComponent.metadata.annotations === undefined) {
231+
modelServerComponent.metadata.annotations = {};
232+
}
233+
// Copy all key-value pairs from modelServer annotations to component annotations
234+
Object.keys(modelServer.annotations).forEach(key => {
235+
modelServerComponent.metadata.annotations![key] =
236+
modelServer.annotations![key];
237+
});
238+
}
239+
216240
return modelServerComponent;
217241
}
218242

0 commit comments

Comments
 (0)