Skip to content
Merged
4 changes: 4 additions & 0 deletions packages/common/src/dto/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { CoreV1EventList, V1PodList } from '@kubernetes/client-node';
import * as webSocket from './webSocket';
import { ReadStream } from 'fs';

import { Architecture } from '../cluster-config';

export { webSocket };

export type GitOauthProvider =
Expand Down Expand Up @@ -120,6 +122,8 @@ export interface AiToolDefinition {
envVarName?: string;
/** One-time setup command run in the editor container at postStart */
setupCommand?: string;
/** Architectures this tool supports. Omit to support all architectures. */
arch?: Architecture[];
}

export interface AiProviderDefinition {
Expand Down
9 changes: 8 additions & 1 deletion packages/common/src/dto/cluster-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@
* Red Hat, Inc. - initial API and implementation
*/

export type Architecture = 'x86_64' | 'arm64' | 's390x' | 'ppc64le';
// Linux kernel names (uname -m) and OCI/Docker aliases are both accepted.
export type Architecture =
| 'x86_64'
| 'amd64'
| 'arm64'
| 'aarch64'
| 's390x'
| 'ppc64le';

export interface ClusterConfig {
dashboardWarning?: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ describe('AI Registry API Service', () => {
it('should return parsed registry from ConfigMap data', async () => {
const registryData = {
providers: [{ id: 'provider1', name: 'Provider 1' }],
tools: [{ id: 'tool1', name: 'Tool 1' }],
tools: [{ id: 'tool1', name: 'Tool 1', providerId: 'provider1' }],
defaultAiProviders: ['provider1'],
};

Expand Down Expand Up @@ -133,7 +133,7 @@ describe('AI Registry API Service', () => {
it('should skip ConfigMaps with undefined data', async () => {
const registryData = {
providers: [{ id: 'provider2', name: 'Provider 2' }],
tools: [{ id: 'tool2', name: 'Tool 2' }],
tools: [{ id: 'tool2', name: 'Tool 2', providerId: 'provider2' }],
defaultAiProviders: ['provider2'],
};

Expand Down Expand Up @@ -207,5 +207,220 @@ describe('AI Registry API Service', () => {

expect(result).toEqual(EMPTY_REGISTRY);
});

it('should filter out tools without a string providerId', async () => {
const registryData = {
providers: [],
tools: [{ foo: 42 }, { providerId: 123 }, { providerId: 'valid/provider', name: 'Valid' }],
defaultAiProviders: [],
};

mockCoreV1Api.listNamespacedConfigMap.mockResolvedValueOnce({
items: [
{
metadata: { name: 'ai-tool-registry' },
data: { 'registry.json': JSON.stringify(registryData) },
} as V1ConfigMap,
],
} as V1ConfigMapList);

const result = await service.get();

expect(result.tools).toHaveLength(1);
expect(result.tools[0]).toMatchObject({ providerId: 'valid/provider' });
});
});

describe('arch filtering', () => {
const toolNoArch = {
providerId: 'google/gemini',
tag: 'latest',
name: 'Gemini CLI',
url: 'https://example.com',
binary: 'gemini',
pattern: 'bundle',
injectorImage: 'quay.io/test/gemini:latest',
};
const toolX86Only = {
providerId: 'test/x86-only',
tag: 'latest',
name: 'X86 Only Tool',
url: 'https://example.com',
binary: 'x86tool',
pattern: 'init',
injectorImage: 'quay.io/test/x86tool:latest',
arch: ['x86_64'],
};
const toolX86AndArm = {
providerId: 'test/multi-arch',
tag: 'latest',
name: 'Multi Arch Tool',
url: 'https://example.com',
binary: 'multitool',
pattern: 'init',
injectorImage: 'quay.io/test/multitool:latest',
arch: ['x86_64', 'arm64'],
};

const registryData = {
providers: [{ id: 'provider1', name: 'Provider 1', publisher: 'Test' }],
tools: [toolNoArch, toolX86Only, toolX86AndArm],
defaultAiProviders: ['provider1'],
};

beforeEach(() => {
mockCoreV1Api.listNamespacedConfigMap.mockResolvedValue({
items: [
{
metadata: { name: 'ai-tool-registry' },
data: {
'registry.json': JSON.stringify(registryData),
},
} as V1ConfigMap,
],
} as V1ConfigMapList);
});

it('should return all tools when currentArch is not provided', async () => {
const result = await service.get();

expect(result.tools).toHaveLength(3);
expect(result.tools).toEqual(registryData.tools);
});

it('should filter tools by architecture when currentArch is provided', async () => {
const result = await service.get('arm64');

expect(result.tools).toHaveLength(2);
expect(result.tools).toContainEqual(toolNoArch);
expect(result.tools).toContainEqual(toolX86AndArm);
expect(result.tools).not.toContainEqual(toolX86Only);
});

it('should include tools with no arch field on any architecture', async () => {
const result = await service.get('s390x');

expect(result.tools).toHaveLength(1);
expect(result.tools).toContainEqual(toolNoArch);
});

it('should return tools matching x86_64 architecture', async () => {
const result = await service.get('x86_64');

expect(result.tools).toHaveLength(3);
expect(result.tools).toContainEqual(toolNoArch);
expect(result.tools).toContainEqual(toolX86Only);
expect(result.tools).toContainEqual(toolX86AndArm);
});

it('should skip null elements in tools and return remaining valid tools', async () => {
mockCoreV1Api.listNamespacedConfigMap.mockResolvedValue({
items: [
{
metadata: { name: 'ai-tool-registry' },
data: {
'registry.json': JSON.stringify({
providers: registryData.providers,
tools: [null, toolNoArch, null, toolX86Only],
defaultAiProviders: registryData.defaultAiProviders,
}),
},
} as V1ConfigMap,
],
} as V1ConfigMapList);

const result = await service.get('x86_64');

expect(result.tools).toHaveLength(2);
expect(result.tools).toContainEqual(toolNoArch);
expect(result.tools).toContainEqual(toolX86Only);
});

it('should match tools using OCI alias "amd64" when cluster reports x86_64', async () => {
mockCoreV1Api.listNamespacedConfigMap.mockResolvedValue({
items: [
{
metadata: { name: 'ai-tool-registry' },
data: {
'registry.json': JSON.stringify({
providers: registryData.providers,
tools: [{ ...toolX86Only, arch: ['amd64', 'arm64'] }],
defaultAiProviders: registryData.defaultAiProviders,
}),
},
} as V1ConfigMap,
],
} as V1ConfigMapList);

const result = await service.get('x86_64');

expect(result.tools).toHaveLength(1);
});

it('should match tools using OCI alias "aarch64" when cluster reports arm64', async () => {
mockCoreV1Api.listNamespacedConfigMap.mockResolvedValue({
items: [
{
metadata: { name: 'ai-tool-registry' },
data: {
'registry.json': JSON.stringify({
providers: registryData.providers,
tools: [{ ...toolX86Only, arch: ['aarch64'] }],
defaultAiProviders: registryData.defaultAiProviders,
}),
},
} as V1ConfigMap,
],
} as V1ConfigMapList);

const result = await service.get('arm64');

expect(result.tools).toHaveLength(1);
});

it('should match x86_64 tools when currentArch is passed as "amd64"', async () => {
const result = await service.get('amd64');

expect(result.tools).toHaveLength(3);
expect(result.tools).toContainEqual(toolNoArch);
expect(result.tools).toContainEqual(toolX86Only);
expect(result.tools).toContainEqual(toolX86AndArm);
});

it('should match arm64 tools when currentArch is passed as "aarch64"', async () => {
const result = await service.get('aarch64');

expect(result.tools).toHaveLength(2);
expect(result.tools).toContainEqual(toolNoArch);
expect(result.tools).toContainEqual(toolX86AndArm);
expect(result.tools).not.toContainEqual(toolX86Only);
});

it('should treat a non-array arch value as no restriction when filtering', async () => {
const toolMalformedArch = {
...toolX86Only,
providerId: 'test/malformed',
arch: 'x86_64' as unknown as string[],
};
mockCoreV1Api.listNamespacedConfigMap.mockResolvedValue({
items: [
{
metadata: { name: 'ai-tool-registry' },
data: {
'registry.json': JSON.stringify({
providers: registryData.providers,
tools: [toolMalformedArch],
defaultAiProviders: registryData.defaultAiProviders,
}),
},
} as V1ConfigMap,
],
} as V1ConfigMapList);

const result = await service.get('arm64');

expect(result.tools).toHaveLength(1);
expect(result.tools[0]).toMatchObject({ providerId: 'test/malformed' });
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

// Generated by AI Assistant

import { api } from '@eclipse-che/common';
import { api, Architecture } from '@eclipse-che/common';
import * as k8s from '@kubernetes/client-node';
import { V1ConfigMapList } from '@kubernetes/client-node';

Expand Down Expand Up @@ -46,7 +46,7 @@ export class AiRegistryApiService implements IAiRegistryApi {
};
}

async get(): Promise<api.IAiRegistry> {
async get(currentArch?: Architecture): Promise<api.IAiRegistry> {
if (!this.env.NAMESPACE) {
logger.warn('Mandatory environment variables are not defined: $CHECLUSTER_CR_NAMESPACE');
return EMPTY_REGISTRY;
Expand Down Expand Up @@ -75,9 +75,31 @@ export class AiRegistryApiService implements IAiRegistryApi {
continue;
}
const registry = parsed as Record<string, unknown>;
const rawTools = Array.isArray(registry.tools) ? (registry.tools as unknown[]) : [];
const allTools = rawTools.filter(
(t): t is api.AiToolDefinition =>
typeof t === 'object' &&
t !== null &&
typeof (t as Record<string, unknown>).providerId === 'string',
);
// Normalize OCI/Docker aliases to Linux kernel names before comparing so
// both 'amd64'/'x86_64' and 'aarch64'/'arm64' match each other.
const toCanonical = (a: Architecture): Architecture => {
if (a === 'amd64') return 'x86_64';
if (a === 'aarch64') return 'arm64';
return a;
};
const canonicalArch = currentArch ? toCanonical(currentArch) : undefined;
const filteredTools = canonicalArch
? allTools.filter(
tool =>
!Array.isArray(tool.arch) ||
tool.arch.map(a => toCanonical(a as Architecture)).includes(canonicalArch),
)
: allTools;
return {
providers: Array.isArray(registry.providers) ? registry.providers : [],
tools: Array.isArray(registry.tools) ? registry.tools : [],
tools: filteredTools,
defaultAiProviders: Array.isArray(registry.defaultAiProviders)
? registry.defaultAiProviders
: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -592,8 +592,11 @@ export interface IAiRegistryApi {
/**
* Reads the AI tool registry from a ConfigMap in the cluster.
* Returns providers, tools, and default provider selections.
* When currentArch is provided, tools whose arch list does not include
* the current architecture are filtered out. Tools with no arch field
* are always returned.
*/
get(): Promise<api.IAiRegistry>;
get(currentArch?: Architecture): Promise<api.IAiRegistry>;
}

export interface IAiProviderKeyApi {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ import { FastifyInstance } from 'fastify';

import { baseApiPath } from '@/constants/config';
import { stubAiRegistry } from '@/routes/api/helpers/__mocks__/getDevWorkspaceClient';
import { getDevWorkspaceClient } from '@/routes/api/helpers/getDevWorkspaceClient';
import { setup, teardown } from '@/utils/appBuilder';
import { logger } from '@/utils/logger';

jest.mock('../helpers/getDevWorkspaceClient.ts');
jest.mock('../helpers/getServiceAccountToken.ts');
Expand All @@ -39,4 +41,20 @@ describe('AI Registry Route', () => {
expect(res.statusCode).toEqual(200);
expect(res.json()).toEqual(stubAiRegistry);
});

test('logs a warning and returns all tools when arch detection fails', async () => {
jest.mocked(getDevWorkspaceClient).mockReturnValueOnce({
aiRegistryApi: { get: jest.fn().mockResolvedValue(stubAiRegistry) },
serverConfigApi: {
getCurrentArchitecture: jest.fn().mockRejectedValue(new Error('uname failed')),
},
} as unknown as ReturnType<typeof getDevWorkspaceClient>);
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(jest.fn() as typeof logger.warn);

const res = await app.inject().get(`${baseApiPath}/ai-registry`);

expect(res.statusCode).toEqual(200);
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
});
12 changes: 10 additions & 2 deletions packages/dashboard-backend/src/routes/api/aiRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { baseApiPath } from '@/constants/config';
import { getDevWorkspaceClient } from '@/routes/api/helpers/getDevWorkspaceClient';
import { getServiceAccountToken } from '@/routes/api/helpers/getServiceAccountToken';
import { getSchema } from '@/services/helpers';
import { logger } from '@/utils/logger';

const tags = ['AI Registry'];

Expand All @@ -35,8 +36,15 @@ export function registerAiRegistryRoute(isLocalRun: boolean, instance: FastifyIn
return EMPTY_REGISTRY;
}
const token = getServiceAccountToken();
const { aiRegistryApi } = getDevWorkspaceClient(token);
return aiRegistryApi.get();
const { aiRegistryApi, serverConfigApi } = getDevWorkspaceClient(token);
const currentArch = await serverConfigApi.getCurrentArchitecture().catch(error => {
logger.warn(
error,
'Failed to detect current architecture; serving all tools without arch filtering',
);
return undefined;
});
return aiRegistryApi.get(currentArch);
});
});
}
Loading
Loading