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
4 changes: 4 additions & 0 deletions src/core/llm/providers/AIModelProviderManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { XAIProvider, XAIProviderConfig } from './implementations/XAIProvider';
import { GeminiProvider, GeminiProviderConfig } from './implementations/GeminiProvider';
import { ClaudeCodeProvider, ClaudeCodeProviderConfig } from './implementations/ClaudeCodeProvider';
import { GeminiCLIProvider, GeminiCLIProviderConfig } from './implementations/GeminiCLIProvider';
import { LiteLLMProvider, LiteLLMProviderConfig } from './implementations/LiteLLMProvider';
import { GMIError, GMIErrorCode, createGMIErrorFromError } from '../../utils/errors.js'; // Corrected import path

/**
Expand Down Expand Up @@ -153,6 +154,9 @@ export class AIModelProviderManager {
case 'gemini-cli':
providerInstance = new GeminiCLIProvider();
break;
case 'litellm':
providerInstance = new LiteLLMProvider();
break;
default:
console.warn(`AIModelProviderManager: Unknown provider ID '${providerEntry.providerId}'. Skipping.`);
continue;
Expand Down
225 changes: 225 additions & 0 deletions src/core/llm/providers/__tests__/LiteLLMProvider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
/**
* @fileoverview Tests for the LiteLLMProvider.
*
* LiteLLMProvider delegates to OpenAIProvider with a custom baseURL,
* same pattern as GroqProvider. Tests verify initialization, delegation,
* dynamic model discovery, and error handling.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { LiteLLMProvider } from '../implementations/LiteLLMProvider.js';
import { OpenAIProvider } from '../implementations/OpenAIProvider.js';

// Mock OpenAIProvider to avoid real HTTP calls
vi.mock('../implementations/OpenAIProvider.js', () => {
const MockOpenAIProvider = vi.fn().mockImplementation(() => ({
initialize: vi.fn().mockResolvedValue(undefined),
generateCompletion: vi.fn().mockResolvedValue({
id: 'chatcmpl-test',
object: 'chat.completion',
created: 0,
modelId: 'anthropic/claude-sonnet-4-6',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'OK' },
finishReason: 'stop',
},
],
usage: { promptTokens: 13, completionTokens: 4, totalTokens: 17 },
}),
generateCompletionStream: vi.fn().mockImplementation(async function* () {
yield {
id: 'chatcmpl-test',
object: 'chat.completion.chunk',
created: 0,
modelId: 'anthropic/claude-sonnet-4-6',
choices: [
{
index: 0,
message: { role: 'assistant', content: '' },
finishReason: 'stop',
},
],
responseTextDelta: 'OK',
isFinal: true,
usage: { promptTokens: 13, completionTokens: 4, totalTokens: 17 },
};
}),
generateEmbeddings: vi.fn().mockResolvedValue({
object: 'list',
data: [{ object: 'embedding', embedding: [0.1, 0.2, 0.3], index: 0 }],
model: 'text-embedding-3-small',
usage: { prompt_tokens: 5, total_tokens: 5 },
}),
checkHealth: vi
.fn()
.mockResolvedValue({ isHealthy: true, details: 'OK' }),
shutdown: vi.fn().mockResolvedValue(undefined),
}));
return { OpenAIProvider: MockOpenAIProvider };
});

describe('LiteLLMProvider', () => {
let provider: LiteLLMProvider;

beforeEach(() => {
vi.clearAllMocks();
provider = new LiteLLMProvider();
});

describe('initialization', () => {
it('throws when API key is missing', async () => {
await expect(
provider.initialize({ apiKey: '' }),
).rejects.toThrow('API key is required');
});

it('initializes with valid config', async () => {
await provider.initialize({
apiKey: 'sk-test',
baseURL: 'http://localhost:4000/v1',
});

expect(provider.isInitialized).toBe(true);
expect(provider.providerId).toBe('litellm');
expect(provider.defaultModelId).toBe('gpt-4o-mini');
});

it('uses custom default model', async () => {
await provider.initialize({
apiKey: 'sk-test',
defaultModelId: 'anthropic/claude-sonnet-4-6',
});

expect(provider.defaultModelId).toBe('anthropic/claude-sonnet-4-6');
});

it('delegates to OpenAIProvider with proxy baseURL', async () => {
await provider.initialize({
apiKey: 'sk-test',
baseURL: 'http://proxy:4000/v1',
});

const mockInstance = (OpenAIProvider as unknown as ReturnType<typeof vi.fn>).mock.results[0].value;
expect(mockInstance.initialize).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: 'sk-test',
baseURL: 'http://proxy:4000/v1',
}),
);
});
});

describe('generateCompletion', () => {
it('delegates to OpenAIProvider', async () => {
await provider.initialize({ apiKey: 'sk-test' });

const res = await provider.generateCompletion(
'anthropic/claude-sonnet-4-6',
[{ role: 'user', content: 'Say OK' }],
{ temperature: 0 },
);

expect(res.choices[0].message.content).toBe('OK');
expect(res.choices[0].finishReason).toBe('stop');
expect(res.usage?.totalTokens).toBe(17);
});
});

describe('generateCompletionStream', () => {
it('yields streamed chunks from delegate', async () => {
await provider.initialize({ apiKey: 'sk-test' });

const chunks: any[] = [];
for await (const chunk of provider.generateCompletionStream(
'anthropic/claude-sonnet-4-6',
[{ role: 'user', content: 'Say OK' }],
{},
)) {
chunks.push(chunk);
}

expect(chunks.length).toBeGreaterThan(0);
expect(chunks[chunks.length - 1].isFinal).toBe(true);
});
});

describe('generateEmbeddings', () => {
it('delegates to OpenAIProvider', async () => {
await provider.initialize({ apiKey: 'sk-test' });

const res = await provider.generateEmbeddings(
'text-embedding-3-small',
['hello'],
);

expect(res.data[0].embedding).toEqual([0.1, 0.2, 0.3]);
});
});

describe('listAvailableModels', () => {
it('fetches models from proxy /v1/models', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: [
{ id: 'gpt-4o-mini' },
{ id: 'anthropic/claude-sonnet-4-6' },
],
}),
});
vi.stubGlobal('fetch', mockFetch);

await provider.initialize({ apiKey: 'sk-test' });
const models = await provider.listAvailableModels();

expect(models).toHaveLength(2);
expect(models[0].modelId).toBe('gpt-4o-mini');
expect(models[1].modelId).toBe('anthropic/claude-sonnet-4-6');

vi.unstubAllGlobals();
});

it('returns empty array on fetch error', async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: false });
vi.stubGlobal('fetch', mockFetch);

await provider.initialize({ apiKey: 'sk-test' });
const models = await provider.listAvailableModels();

expect(models).toEqual([]);

vi.unstubAllGlobals();
});

it('returns empty array on network failure', async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
vi.stubGlobal('fetch', mockFetch);

await provider.initialize({ apiKey: 'sk-test' });
const models = await provider.listAvailableModels();

expect(models).toEqual([]);

vi.unstubAllGlobals();
});
Comment on lines +160 to +205
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Ensure global fetch cleanup runs even when assertions fail.

In Line 180, Line 192, and Line 204, vi.unstubAllGlobals() is only reached on the happy path. If an assertion throws earlier, fetch can leak into later tests and create order-dependent failures. Move cleanup to an afterEach for this suite.

Suggested fix
-import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
@@
 describe('LiteLLMProvider', () => {
   let provider: LiteLLMProvider;
 
   beforeEach(() => {
     vi.clearAllMocks();
     provider = new LiteLLMProvider();
   });
+  
+  afterEach(() => {
+    vi.unstubAllGlobals();
+  });
@@
-      vi.unstubAllGlobals();
@@
-      vi.unstubAllGlobals();
@@
-      vi.unstubAllGlobals();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/llm/providers/__tests__/LiteLLMProvider.test.ts` around lines 160 -
205, The tests stub the global fetch inside each it block using vi.stubGlobal
but call vi.unstubAllGlobals only inside the happy path, which can leak fetch if
an assertion fails; refactor the describe('listAvailableModels') suite to move
vi.stubGlobal usage into each test as-is but add an afterEach block that calls
vi.unstubAllGlobals() (or vi.restoreAllMocks()/appropriate Vitest cleanup) to
ensure fetch is always restored; reference the test suite, the
provider.initialize(...) and provider.listAvailableModels() usages to locate
where stubbing happens and add the afterEach cleanup for all cases.

});

describe('checkHealth', () => {
it('delegates to OpenAIProvider', async () => {
await provider.initialize({ apiKey: 'sk-test' });

const health = await provider.checkHealth();
expect(health.isHealthy).toBe(true);
});
});

describe('shutdown', () => {
it('delegates and resets state', async () => {
await provider.initialize({ apiKey: 'sk-test' });
await provider.shutdown();

expect(provider.isInitialized).toBe(false);
});
});
});
Loading