Skip to content
Merged
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@
All notable changes to `@stackbilt/llm-providers` are documented here.
Format follows [Keep a Changelog](https://keepachangelog.com/). Versions use [Semantic Versioning](https://semver.org/).

## [1.4.0] — 2026-04-17

### Deprecated
- **`MODELS.CLAUDE_3_HAIKU`** (`claude-3-haiku-20240307`) — Anthropic retires 2026-04-19. Migrate to `MODELS.CLAUDE_HAIKU_4_5` or `MODELS.CLAUDE_3_5_HAIKU`. Export retained; callers get a compile-time `@deprecated` warning.
- **`MODELS.GPT_4O`** (`gpt-4o`) — retired by OpenAI on 2026-04-03. Migrate to `MODELS.GPT_4O_MINI` or a current GPT-4 successor. Export retained; callers get a compile-time `@deprecated` warning.

### Removed
- `claude-3-haiku-20240307` — dropped from `AnthropicProvider.models[]` and its capabilities/pricing table. Calls to this ID will fail at Anthropic's cutoff; keeping it advertised would mislead consumers.
- `gpt-4o` — dropped from `OpenAIProvider.models[]` and its capabilities/pricing table.
- `gpt-4-turbo-preview` — dead alias dropped from `OpenAIProvider.models[]` (no corresponding capabilities entry; caught by the new drift test).

### Added
- **Model drift test** (`src/__tests__/model-drift.test.ts`) — asserts every provider's `models[]` array is symmetrically covered by its capabilities map. Prevents future retirement drift where a model is removed from one list but not the other. Runs across all 5 providers.

## [1.3.0] — 2026-04-16

### Added
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@stackbilt/llm-providers",
"version": "1.3.0",
"version": "1.4.0",
"description": "Multi-LLM failover with circuit breakers, cost tracking, and intelligent retry. Cloudflare Workers native.",
"author": "Stackbilt <admin@stackbilt.dev>",
"license": "Apache-2.0",
Expand Down
14 changes: 7 additions & 7 deletions src/__tests__/factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,22 +49,22 @@ const mockOpenAIProvider = {

const mockAnthropicProvider = {
name: 'anthropic',
models: ['claude-3-haiku-20240307', 'claude-3-sonnet-20240229'],
models: ['claude-haiku-4-5-20251001', 'claude-3-sonnet-20240229'],
supportsStreaming: true,
supportsTools: true,
supportsBatching: false,
supportsVision: true,
generateResponse: vi.fn().mockResolvedValue({
message: 'Anthropic response',
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30, cost: 0.002 },
model: 'claude-3-haiku-20240307',
model: 'claude-haiku-4-5-20251001',
provider: 'anthropic',
responseTime: 1200
} as LLMResponse),
streamResponse: vi.fn(),
getProviderBalance: vi.fn(),
validateConfig: vi.fn().mockReturnValue(true),
getModels: vi.fn().mockReturnValue(['claude-3-haiku-20240307', 'claude-3-sonnet-20240229']),
getModels: vi.fn().mockReturnValue(['claude-haiku-4-5-20251001', 'claude-3-sonnet-20240229']),
estimateCost: vi.fn().mockReturnValue(0.002),
healthCheck: vi.fn().mockResolvedValue(true),
getMetrics: vi.fn().mockReturnValue({
Expand Down Expand Up @@ -161,7 +161,7 @@ describe('LLMProviderFactory', () => {
mockAnthropicProvider.generateResponse.mockReset().mockResolvedValue({
message: 'Anthropic response',
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30, cost: 0.002 },
model: 'claude-3-haiku-20240307',
model: 'claude-haiku-4-5-20251001',
provider: 'anthropic',
responseTime: 1200
} as LLMResponse);
Expand Down Expand Up @@ -326,7 +326,7 @@ describe('LLMProviderFactory', () => {
// Test Claude model request
const claudeRequest: LLMRequest = {
...testRequest,
model: 'claude-3-haiku-20240307'
model: 'claude-haiku-4-5-20251001'
};

await factory.generateResponse(claudeRequest);
Expand Down Expand Up @@ -373,7 +373,7 @@ describe('LLMProviderFactory', () => {
fallbackRules: [{
condition: 'error',
fallbackProvider: 'anthropic',
fallbackModel: 'claude-3-haiku-20240307'
fallbackModel: 'claude-haiku-4-5-20251001'
}]
});

Expand All @@ -385,7 +385,7 @@ describe('LLMProviderFactory', () => {
});

expect(mockAnthropicProvider.generateResponse).toHaveBeenCalledWith(
expect.objectContaining({ model: 'claude-3-haiku-20240307' })
expect.objectContaining({ model: 'claude-haiku-4-5-20251001' })
);
});
});
Expand Down
8 changes: 4 additions & 4 deletions src/__tests__/from-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ const mockValidateConfig = vi.fn().mockReturnValue(true);
vi.mock('../providers/openai', () => ({
OpenAIProvider: vi.fn().mockImplementation(() => ({
name: 'openai',
models: ['gpt-4o'],
models: ['gpt-4o-mini'],
supportsStreaming: true,
supportsTools: true,
supportsBatching: true,
validateConfig: mockValidateConfig,
generateResponse: vi.fn(),
getModels: vi.fn().mockReturnValue(['gpt-4o']),
getModels: vi.fn().mockReturnValue(['gpt-4o-mini']),
estimateCost: vi.fn().mockReturnValue(0),
healthCheck: vi.fn().mockResolvedValue(true),
getMetrics: vi.fn().mockReturnValue({
Expand All @@ -33,13 +33,13 @@ vi.mock('../providers/openai', () => ({
vi.mock('../providers/anthropic', () => ({
AnthropicProvider: vi.fn().mockImplementation(() => ({
name: 'anthropic',
models: ['claude-3-haiku-20240307'],
models: ['claude-haiku-4-5-20251001'],
supportsStreaming: true,
supportsTools: true,
supportsBatching: false,
validateConfig: mockValidateConfig,
generateResponse: vi.fn(),
getModels: vi.fn().mockReturnValue(['claude-3-haiku-20240307']),
getModels: vi.fn().mockReturnValue(['claude-haiku-4-5-20251001']),
estimateCost: vi.fn().mockReturnValue(0),
healthCheck: vi.fn().mockResolvedValue(true),
getMetrics: vi.fn().mockReturnValue({
Expand Down
43 changes: 43 additions & 0 deletions src/__tests__/model-drift.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Model drift tests
*
* Guards against retired/unknown model IDs slipping through by verifying
* that every entry in a provider's `models` array has a matching entry in
* its capabilities map. A mismatch usually means a model was retired from
* one list but not the other — catch that at CI time, not at runtime.
*/

import { describe, it, expect } from 'vitest';
import { AnthropicProvider } from '../providers/anthropic';
import { OpenAIProvider } from '../providers/openai';
import { CloudflareProvider } from '../providers/cloudflare';
import { CerebrasProvider } from '../providers/cerebras';
import { GroqProvider } from '../providers/groq';

type WithCapabilities = {
models: string[];
getModelCapabilities: () => Record<string, unknown>;
};

const providers: Array<[string, WithCapabilities]> = [
['anthropic', new AnthropicProvider({ apiKey: 'test' }) as unknown as WithCapabilities],
['openai', new OpenAIProvider({ apiKey: 'test' }) as unknown as WithCapabilities],
['cloudflare', new CloudflareProvider({ ai: { run: async () => ({}) } as never, accountId: 'test' }) as unknown as WithCapabilities],
['cerebras', new CerebrasProvider({ apiKey: 'test' }) as unknown as WithCapabilities],
['groq', new GroqProvider({ apiKey: 'test' }) as unknown as WithCapabilities]
];

describe('model drift', () => {
it.each(providers)('%s: every advertised model has a capabilities entry', (_name, provider) => {
const caps = provider.getModelCapabilities();
const missing = provider.models.filter((m) => !(m in caps));
expect(missing).toEqual([]);
});

it.each(providers)('%s: every capabilities entry is advertised in models[]', (_name, provider) => {
const caps = provider.getModelCapabilities();
const advertised = new Set(provider.models);
const orphaned = Object.keys(caps).filter((m) => !advertised.has(m));
expect(orphaned).toEqual([]);
});
});
12 changes: 6 additions & 6 deletions src/__tests__/response-format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,11 @@ describe('OpenAI response_format', () => {
});

it('should pass response_format through to the API body', async () => {
mockFetch.mockResolvedValueOnce(openAIChatCompletion('gpt-4o'));
mockFetch.mockResolvedValueOnce(openAIChatCompletion('gpt-4o-mini'));

await provider.generateResponse({
messages: [{ role: 'user', content: 'Give me JSON' }],
model: 'gpt-4o',
model: 'gpt-4o-mini',
response_format: { type: 'json_object' }
});

Expand All @@ -60,23 +60,23 @@ describe('OpenAI response_format', () => {
});

it('should not include response_format when not set', async () => {
mockFetch.mockResolvedValueOnce(openAIChatCompletion('gpt-4o'));
mockFetch.mockResolvedValueOnce(openAIChatCompletion('gpt-4o-mini'));

await provider.generateResponse({
messages: [{ role: 'user', content: 'Hello' }],
model: 'gpt-4o'
model: 'gpt-4o-mini'
});

const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.response_format).toBeUndefined();
});

it('should pass response_format with type text', async () => {
mockFetch.mockResolvedValueOnce(openAIChatCompletion('gpt-4o', 'plain text'));
mockFetch.mockResolvedValueOnce(openAIChatCompletion('gpt-4o-mini', 'plain text'));

await provider.generateResponse({
messages: [{ role: 'user', content: 'Hello' }],
model: 'gpt-4o',
model: 'gpt-4o-mini',
response_format: { type: 'text' }
});

Expand Down
Loading
Loading