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 .changeset/soft-tips-agree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"manifest": patch
---

Validate routing selections against discovered models and avoid clearing native-only tier models when an unsupported subscription record shares a provider with an active API key.
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,30 @@ describe('ModelDiscoveryService', () => {
expect(providerRepo.save).toHaveBeenCalledWith(provider);
});

it('should not synthesize api-key models from pricing data when native discovery returns empty', async () => {
fetcher.fetch.mockResolvedValue([]);
mockPricingSync.getAll.mockReturnValue(
new Map([
[
'openai/gpt-4o',
{
input: 0.0000025,
output: 0.00001,
contextWindow: 128000,
displayName: 'GPT-4o',
},
],
]),
);

const provider = makeProvider();
const result = await service.discoverModels(provider);

expect(result).toEqual([]);
expect(provider.cached_models).toEqual([]);
expect(providerRepo.save).toHaveBeenCalledWith(provider);
});

it('should return [] when decrypt fails', async () => {
mockDecrypt.mockImplementation(() => {
throw new Error('bad key');
Expand Down Expand Up @@ -303,10 +327,9 @@ describe('ModelDiscoveryService', () => {
expect(mockModelRegistry.registerModels).not.toHaveBeenCalled();
});

it('should pass confirmed models to buildFallbackModels when native fetch fails', async () => {
it('should ignore pricing fallback when native fetch returns empty', async () => {
fetcher.fetch.mockResolvedValue([]);
const confirmed = new Set(['gpt-4o']);
mockModelRegistry.getConfirmedModels.mockReturnValue(confirmed);
mockModelRegistry.getConfirmedModels.mockReturnValue(new Set(['gpt-4o']));

// Set up OpenRouter cache with matching models
const orMap = new Map([
Expand All @@ -317,10 +340,8 @@ describe('ModelDiscoveryService', () => {

const result = await service.discoverModels(makeProvider());

expect(mockModelRegistry.getConfirmedModels).toHaveBeenCalledWith('openai');
// Only confirmed model should be in fallback
expect(result).toHaveLength(1);
expect(result[0].id).toBe('gpt-4o');
expect(mockModelRegistry.getConfirmedModels).not.toHaveBeenCalled();
expect(result).toEqual([]);
});

it('should not call registry when modelRegistry is null', async () => {
Expand Down Expand Up @@ -746,7 +767,7 @@ describe('ModelDiscoveryService', () => {
expect(result[0].inputPricePerToken).toBe(0.00003);
});

it('should build fallback models from OpenRouter cache when native API returns empty', async () => {
it('should ignore OpenRouter pricing cache when native api-key discovery returns empty', async () => {
fetcher.fetch.mockResolvedValue([]);

const orMap = new Map([
Expand Down Expand Up @@ -782,12 +803,7 @@ describe('ModelDiscoveryService', () => {

const result = await service.discoverModels(makeProvider({ provider: 'anthropic' }));

expect(result).toHaveLength(2);
expect(result[0].id).toBe('claude-opus-4-6');
expect(result[0].displayName).toBe('Claude Opus 4.6');
expect(result[0].inputPricePerToken).toBe(0.000015);
expect(result[0].provider).toBe('anthropic');
expect(result[1].id).toBe('claude-sonnet-4-6');
expect(result).toEqual([]);
});

it('should unwrap OAuth blob for OpenAI subscription before fetching', async () => {
Expand Down Expand Up @@ -930,9 +946,12 @@ describe('ModelDiscoveryService', () => {
}),
);

// Should fall back to buildFallbackModels (not subscription fallback, since token exists)
// With a subscription provider, known subscription models are still supplemented
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
// even when the live token-based fetch returns no rows.
expect(fetcher.fetch).toHaveBeenCalled();
expect(result.length).toBeGreaterThanOrEqual(0);
expect(result.map((model) => model.id)).toEqual(
expect.arrayContaining(['gpt-5.4', 'gpt-5.2-codex']),
);
});

it('should stamp authType as api_key for regular providers', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { parseOAuthTokenBlob } from '../openai-oauth.types';
import {
findOpenRouterPrefix,
lookupWithVariants,
buildFallbackModels,
buildSubscriptionFallbackModels,
supplementWithKnownModels,
} from './model-fallback';
Expand Down Expand Up @@ -84,24 +83,13 @@ export class ModelDiscoveryService {
endpointOverride,
);

// Register confirmed model IDs from native API for future fallback filtering
// Preserve confirmed model IDs from native discovery for later validation.
if (raw.length > 0 && this.modelRegistry) {
this.modelRegistry.registerModels(
provider.provider,
raw.map((m) => m.id),
);
}

// If native API returned no models, fall back to OpenRouter filtered by confirmed models
if (raw.length === 0) {
const confirmed = this.modelRegistry?.getConfirmedModels(provider.provider) ?? null;
raw = buildFallbackModels(this.pricingSync, provider.provider, confirmed);
if (raw.length > 0) {
this.logger.log(
`Native API returned 0 models for ${provider.provider} — using ${raw.length} models from pricing data`,
);
}
}
}

// For subscription providers, supplement with knownModels so users can
Expand Down
Loading
Loading