Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions workspaces/lightspeed/.changeset/thin-boats-judge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@red-hat-developer-hub/backstage-plugin-lightspeed-backend': minor
---

Add backend validation for image attachments, including a model vision capability check via /v1/validate-model-vision endpoint and capability to add attachments on /v1/query.
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* Copyright Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { ModelCapabilitiesCache } from './attachment-validation';

describe('ModelCapabilitiesCache', () => {
beforeEach(() => {
ModelCapabilitiesCache.clear();
});

it('should store and retrieve model capabilities', () => {
ModelCapabilitiesCache.set('gpt-4-vision', true);
expect(ModelCapabilitiesCache.get('gpt-4-vision')).toBe(true);
expect(ModelCapabilitiesCache.has('gpt-4-vision')).toBe(true);
});

it('should return undefined for unknown models', () => {
expect(ModelCapabilitiesCache.get('unknown-model')).toBeUndefined();
expect(ModelCapabilitiesCache.has('unknown-model')).toBe(false);
});

it('should update existing entries', () => {
ModelCapabilitiesCache.set('gpt-4', false);
expect(ModelCapabilitiesCache.get('gpt-4')).toBe(false);

ModelCapabilitiesCache.set('gpt-4', true);
expect(ModelCapabilitiesCache.get('gpt-4')).toBe(true);
});

it('should handle multiple models independently', () => {
ModelCapabilitiesCache.set('model-a', true);
ModelCapabilitiesCache.set('model-b', false);
ModelCapabilitiesCache.set('model-c', true);

expect(ModelCapabilitiesCache.get('model-a')).toBe(true);
expect(ModelCapabilitiesCache.get('model-b')).toBe(false);
expect(ModelCapabilitiesCache.get('model-c')).toBe(true);
expect(ModelCapabilitiesCache.has('model-a')).toBe(true);
expect(ModelCapabilitiesCache.has('model-b')).toBe(true);
expect(ModelCapabilitiesCache.has('model-c')).toBe(true);
expect(ModelCapabilitiesCache.has('model-d')).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Copyright Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

export const ModelCapabilitiesCache = {
cache: {} as Record<string, boolean>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] unbounded-cache

ModelCapabilitiesCache is a module-level plain object with no TTL, max size, or eviction policy. Any authenticated user can create unlimited cache entries with arbitrary model/provider strings. Stale results are served indefinitely.

Suggested fix: Use a bounded LRU cache with max ~1000 entries and TTL (~1 hour). Validate model/provider values.


get(model: string): boolean | undefined {
return this.cache[model];
},

set(model: string, supportsVision: boolean): void {
this.cache[model] = supportsVision;
},

has(model: string): boolean {
return model in this.cache;
},

clear(): void {
this.cache = {};
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -207,3 +207,14 @@ export const SKIP_USER_ID_ENDPOINTS = new Set(['/v1/models', '/v1/shields']);

// default number of message history being loaded
export const DEFAULT_HISTORY_LENGTH = 10;

/**
* Minimal 1x1 pixel JPEG image for testing model vision capabilities.
* Base64-encoded JPEG with minimal headers.
*/
export const TEST_VISION_JPEG =
'/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a' +
'HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIy' +
'MjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIA' +
'AhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEB' +
'AQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCwAA//2Q==';
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import request from 'supertest';
import { handlers, LOCAL_AI_ADDR } from '../../__fixtures__/handlers';
import { lcsHandlers, LOCAL_LCS_ADDR } from '../../__fixtures__/lcsHandlers';
import { lightspeedPlugin } from '../plugin';
import { ModelCapabilitiesCache } from './attachment-validation';
import { VectorStoresOperator } from './notebooks/VectorStoresOperator';

const mockUserId = `user: default/user1`;
Expand Down Expand Up @@ -1140,4 +1141,177 @@ describe('lightspeed router tests', () => {
expect(response.statusCode).toEqual(403);
});
});

describe('POST /v1/validate-model-vision', () => {
beforeEach(() => {
ModelCapabilitiesCache.clear();
});

it('returns true when model supports vision', async () => {
rcs.use(
http.post(`${LOCAL_LCS_ADDR}/v1/responses`, () => {
return HttpResponse.json({ output: 'hi' });
}),
);

const backendServer = await startBackendServer();
const response = await request(backendServer)
.post('/api/lightspeed/v1/validate-model-vision')
.send({ model: 'gpt-4o', provider: 'test-server' });

expect(response.statusCode).toEqual(200);
expect(response.body).toEqual({
model: 'gpt-4o',
provider: 'test-server',
supportsVision: true,
});
});

it('returns false when model lacks vision', async () => {
rcs.use(
http.post(`${LOCAL_LCS_ADDR}/v1/responses`, () => {
return HttpResponse.json(
{ error: 'model does not support images' },
{ status: 400 },
);
}),
);

const backendServer = await startBackendServer();
const response = await request(backendServer)
.post('/api/lightspeed/v1/validate-model-vision')
.send({ model: 'gpt-3.5-turbo', provider: 'test-server' });

expect(response.statusCode).toEqual(200);
expect(response.body).toEqual({
model: 'gpt-3.5-turbo',
provider: 'test-server',
supportsVision: false,
});
});

it('returns cached result on subsequent calls', async () => {
ModelCapabilitiesCache.set('test-server/gpt-4o', true);

const backendServer = await startBackendServer();
const response = await request(backendServer)
.post('/api/lightspeed/v1/validate-model-vision')
.send({ model: 'gpt-4o', provider: 'test-server' });

expect(response.statusCode).toEqual(200);
expect(response.body).toEqual({
model: 'gpt-4o',
provider: 'test-server',
supportsVision: true,
});
});
});

describe('POST /v1/query attachment validation', () => {
beforeEach(() => {
ModelCapabilitiesCache.clear();
});

it('rejects attachments when model lacks vision', async () => {
ModelCapabilitiesCache.set('test-server/gpt-3.5-turbo', false);

const backendServer = await startBackendServer();
const response = await request(backendServer)
.post('/api/lightspeed/v1/query')
.send({
model: 'gpt-3.5-turbo',
provider: 'test-server',
query: 'What is this?',
attachments: [
{
attachment_type: 'image',
content_type: 'image/jpeg',
content: 'base64data',
},
],
});

expect(response.statusCode).toEqual(400);
expect(response.body.error).toContain(
'This model does not support JPEG images',
);
});

it('accepts attachments when model supports vision', async () => {
ModelCapabilitiesCache.set('test-server/gpt-4o', true);

const backendServer = await startBackendServer();
const response = await request(backendServer)
.post('/api/lightspeed/v1/query')
.send({
model: 'gpt-4o',
provider: 'test-server',
query: 'What is this?',
attachments: [
{
attachment_type: 'image',
content_type: 'image/jpeg',
content: 'base64data',
},
],
});

expect(response.statusCode).toEqual(200);
});

it('accepts empty attachments regardless of vision support', async () => {
rcs.use(
http.get(`${LOCAL_LCS_ADDR}/v1/models`, () => {
return HttpResponse.json({
models: [
{
identifier: 'gpt-3.5-turbo',
provider_resource_id: 'gpt-3.5-turbo',
supports_vision: false,
},
],
});
}),
);

const backendServer = await startBackendServer();
const response = await request(backendServer)
.post('/api/lightspeed/v1/query')
.send({
model: 'gpt-3.5-turbo',
provider: 'test-server',
query: 'Hello',
attachments: [],
});

expect(response.statusCode).toEqual(200);
});

it('accepts no attachments field regardless of vision support', async () => {
rcs.use(
http.get(`${LOCAL_LCS_ADDR}/v1/models`, () => {
return HttpResponse.json({
models: [
{
identifier: 'gpt-3.5-turbo',
provider_resource_id: 'gpt-3.5-turbo',
supports_vision: false,
},
],
});
}),
);

const backendServer = await startBackendServer();
const response = await request(backendServer)
.post('/api/lightspeed/v1/query')
.send({
model: 'gpt-3.5-turbo',
provider: 'test-server',
query: 'Hello',
});

expect(response.statusCode).toEqual(200);
});
});
});
Loading
Loading