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
2 changes: 1 addition & 1 deletion server/dist/db/index.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 45 additions & 0 deletions server/dist/db/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion server/dist/db/index.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion server/dist/services/model-scout.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 32 additions & 1 deletion server/dist/services/model-scout.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion server/dist/services/model-scout.js.map

Large diffs are not rendered by default.

77 changes: 77 additions & 0 deletions server/src/__tests__/db/realtime-restore-v23.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { initDb, getDb } from '../../db/index.js';
import { selectSweepCandidateIds } from '../../services/model-scout.js';

/**
* The scout probes Google with generateContent, but realtime models answer only
* over bidiGenerateContent — so Google returns a 404 meaning "wrong endpoint",
* which the retirement logic read as "removed". Two working Gemini realtime
* models were disabled in production as a result.
*/
describe('V23 realtime model restore', () => {
beforeAll(() => {
process.env.ENCRYPTION_KEY = '0'.repeat(64);
initDb(':memory:');
});

it('leaves realtime-only models enabled', () => {
const db = getDb();
const rows = db.prepare(`
SELECT m.model_id, m.enabled FROM models m
WHERE EXISTS (
SELECT 1 FROM model_capabilities c
WHERE c.model_db_id = m.id AND c.enabled = 1 AND c.capability = 'realtime_audio'
)
AND NOT EXISTS (
SELECT 1 FROM model_capabilities c2
WHERE c2.model_db_id = m.id AND c2.enabled = 1 AND c2.capability IN ('chat', 'vision')
)
`).all() as { model_id: string; enabled: number }[];

expect(rows.length, 'catalog should have realtime-only models').toBeGreaterThan(0);
for (const row of rows) {
expect(row.enabled, `${row.model_id} must not be left retired`).toBe(1);
}
});

it('excludes realtime-only models from the chat probe', () => {
const db = getDb();
const candidates = new Set(selectSweepCandidateIds(db));

const realtimeOnly = db.prepare(`
SELECT m.id, m.model_id FROM models m
WHERE EXISTS (
SELECT 1 FROM model_capabilities c
WHERE c.model_db_id = m.id AND c.enabled = 1 AND c.capability = 'realtime_audio'
)
AND NOT EXISTS (
SELECT 1 FROM model_capabilities c2
WHERE c2.model_db_id = m.id AND c2.enabled = 1 AND c2.capability IN ('chat', 'vision')
)
`).all() as { id: number; model_id: string }[];

for (const row of realtimeOnly) {
expect(
candidates.has(row.id),
`${row.model_id} cannot answer a generateContent probe and must not be swept`,
).toBe(false);
}
});

it('still sweeps ordinary chat models', () => {
const db = getDb();
const candidates = new Set(selectSweepCandidateIds(db));

const chatModels = db.prepare(`
SELECT m.id, m.model_id FROM models m
JOIN model_capabilities c ON c.model_db_id = m.id
WHERE m.enabled = 1 AND m.is_free = 1 AND c.capability = 'chat' AND c.enabled = 1
LIMIT 5
`).all() as { id: number; model_id: string }[];

expect(chatModels.length).toBeGreaterThan(0);
for (const row of chatModels) {
expect(candidates.has(row.id), `${row.model_id} should still be swept`).toBe(true);
}
});
});
29 changes: 29 additions & 0 deletions server/src/__tests__/services/scout-auto-retire.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,32 @@ describe('recordGoneStreak', () => {
expect(recordGoneStreak(db, modelId, true)).toBeNull();
});
});

/**
* Regression: the scout retired two working Gemini realtime models.
*
* It probes Google with generateContent, but realtime models only answer over
* bidiGenerateContent, so Google returns a 404 that means "wrong endpoint",
* not "model removed". isGoneMessage matched the 404 and three cycles later
* both rows were disabled — while they were working perfectly in the app.
*/
describe('wrong-method 404s are not deprecation', () => {
const wrongMethod = [
'Google API error 404: models/gemini-3.1-flash-live-preview is not found for API version v1beta, or is not supported for generateContent. Call ModelService.ListModels to see the list of available models and their supported methods.',
'Google API error 404: models/gemini-2.5-flash-native-audio-preview-12-2025 is not found for API version v1beta, or is not supported for generateContent.',
'API error 404: this model is not supported by the completions endpoint',
];

for (const msg of wrongMethod) {
it(`keeps the model when the endpoint is wrong: ${msg.slice(30, 78)}…`, () => {
expect(isGoneMessage(msg)).toBe(false);
});
}

it('still retires a genuine Google removal', () => {
// No "supported methods" clause — the model really is gone.
expect(isGoneMessage(
'Google API error 404: This model models/gemini-2.5-pro is no longer available to new users',
)).toBe(true);
});
});
47 changes: 47 additions & 0 deletions server/src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export async function initDb(dbPath?: string): Promise<Database.Database> {
seedModelCapabilities(db);
// Must follow seedModelCapabilities — that is where the image rows are seeded.
retireDeadCatalogRowsV22(db);
restoreRealtimeModelsV23(db);
flagPaidGoogleModels(db);
purgeLegacyBazaarlinkDiscoveries(db);
ensureUnifiedKey(db);
Expand Down Expand Up @@ -1803,6 +1804,52 @@ function migrateModelsV21(db: Database.Database) {
});
apply();}

/**
* V23 (August 2026): undo the realtime models the scout retired by mistake.
*
* The scout probes Google with generateContent. Realtime/live models are only
* reachable over bidiGenerateContent, so Google answers:
*
* "models/gemini-3.1-flash-live-preview is not found for API version v1beta,
* or is not supported for generateContent. Call ModelService.ListModels to
* see the list of available models and their supported methods."
*
* isGoneMessage() read the 404 as removal and the streak logic disabled two
* models that were working perfectly. Both the message guard and the candidate
* query are fixed; this restores the rows the bug switched off.
*
* Scoped to rows whose ONLY capabilities are realtime/audio, so it re-enables
* exactly the class the probe cannot legitimately judge and never resurrects a
* genuinely retired chat model. Runs after seedModelCapabilities because it
* reads model_capabilities. Idempotent.
*/
function restoreRealtimeModelsV23(db: Database.Database) {
db.prepare(`
UPDATE models SET enabled = 1
WHERE enabled = 0
AND EXISTS (
SELECT 1 FROM model_capabilities c
WHERE c.model_db_id = models.id AND c.enabled = 1
AND c.capability IN ('realtime_audio', 'audio')
)
AND NOT EXISTS (
SELECT 1 FROM model_capabilities c2
WHERE c2.model_db_id = models.id AND c2.enabled = 1
AND c2.capability IN ('chat', 'vision')
)
`).run();

// Clear the streak so a stale counter cannot retire them again on the next
// probe before the fixed candidate query excludes them.
db.prepare(`
UPDATE model_availability SET gone_streak = 0, status = 'unknown'
WHERE model_db_id IN (
SELECT c.model_db_id FROM model_capabilities c
WHERE c.enabled = 1 AND c.capability IN ('realtime_audio', 'audio')
)
`).run();
}

/**
* Adds model_availability.gone_streak for scout-driven auto-retirement.
* Separate from the migrateModelsV* series because it is a schema change on an
Expand Down
Loading
Loading