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
2 changes: 2 additions & 0 deletions packages/core/src/availability/fallbackIntegration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ describe('Fallback Integration', () => {
setActiveModel: vi.fn(),
getUserTier: () => undefined,
getHasAccessToPreviewModel: () => true,
getGemini31LaunchedSync: () => false,
hasGemini35FlashGAAccess: () => false,
getModelAvailabilityService: () => availabilityService,
modelConfigService: undefined as unknown as ModelConfigService,
} as unknown as Config;
Expand Down
70 changes: 70 additions & 0 deletions packages/core/src/availability/policyHelpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const createMockConfig = (overrides: Partial<Config> = {}): Config => {
},
getContentGeneratorConfig: () => ({ authType: undefined }),
getHasAccessToPreviewModel: () => true,
hasGemini35FlashGAAccess: () => false,
getMaxAttemptsPerTurn: () => 3,
getExperimentalDynamicModelConfiguration: () => false,
getReleaseChannel: () => 'preview',
Expand Down Expand Up @@ -283,6 +284,9 @@ describe('policyHelpers', () => {
describe('applyModelSelection', () => {
const mockModelConfigService = {
getResolvedConfig: vi.fn(),
// resolveModelId is called for non-chat-model calls to apply modelIdResolutions
// (preview-gated model fallback for API-key users).
resolveModelId: vi.fn((model: string) => model),
};

const mockAvailabilityService = {
Expand Down Expand Up @@ -446,6 +450,72 @@ describe('policyHelpers', () => {
expect(config.setActiveModel).toHaveBeenCalledWith('gemini-pro');
expect(result.maxAttempts).toBe(1);
});

it('applies modelIdResolutions for non-chat tool configs (web-search/web-fetch preview fallback)', () => {
// Reproduces the INVALID_MODEL bug: API-key users have hasAccessToPreview=false,
// but web-search/web-fetch alias chains resolve to gemini-3-flash-preview which
// requires preview access. resolveModelId must map it to gemini-2.5-flash.
const config = createExtendedMockConfig({
getHasAccessToPreviewModel: () => false,
getGemini31LaunchedSync: () => false,
hasGemini35FlashGAAccess: () => false,
});

// Alias chain resolves web-search → gemini-3-flash-base → gemini-3-flash-preview
mockModelConfigService.getResolvedConfig
.mockReturnValueOnce({
model: 'gemini-3-flash-preview',
generateContentConfig: { tools: [{ googleSearch: {} }] },
})
// Second call after resolveModelId returns the fallback model config
.mockReturnValueOnce({
model: 'gemini-2.5-flash',
generateContentConfig: { tools: [{ googleSearch: {} }] },
});

// resolveModelId maps gemini-3-flash-preview → gemini-2.5-flash for non-preview users
mockModelConfigService.resolveModelId.mockImplementation(
(model: string) =>
model === 'gemini-3-flash-preview' ? 'gemini-2.5-flash' : model,
);

mockAvailabilityService.selectFirstAvailable.mockReturnValue({
selectedModel: 'gemini-2.5-flash',
});

const result = applyModelSelection(config, {
model: 'web-search',
isChatModel: false,
});

expect(result.model).toBe('gemini-2.5-flash');
expect(mockModelConfigService.resolveModelId).toHaveBeenCalledWith(
'gemini-3-flash-preview',
expect.objectContaining({ hasAccessToPreview: false }),
);
expect(config.setActiveModel).not.toHaveBeenCalled();
});

it('does not apply modelIdResolutions for chat model calls (already routed)', () => {
const config = createExtendedMockConfig({
getHasAccessToPreviewModel: () => false,
});
mockModelConfigService.getResolvedConfig.mockReturnValue({
model: 'gemini-2.5-pro',
generateContentConfig: {},
});
mockAvailabilityService.selectFirstAvailable.mockReturnValue({
selectedModel: 'gemini-2.5-pro',
});

applyModelSelection(config, {
model: 'gemini-2.5-pro',
isChatModel: true,
});

// resolveModelId must NOT be called for chat model — router already handled it
expect(mockModelConfigService.resolveModelId).not.toHaveBeenCalled();
});
});

describe('applyAvailabilityTransition', () => {
Expand Down
30 changes: 27 additions & 3 deletions packages/core/src/availability/policyHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,15 +286,39 @@ export function applyModelSelection(
options: { consumeAttempt?: boolean } = {},
): { model: string; config: GenerateContentConfig; maxAttempts?: number } {
const resolved = config.modelConfigService.getResolvedConfig(modelConfigKey);
const model = resolved.model;

// Tool sub-agent configs (web-search, web-fetch, loop-detection, etc.) resolve
// their alias chain to a concrete model ID (e.g. gemini-3-flash-preview via
// gemini-3-flash-base) but never go through modelIdResolutions. This means
// API-key users without preview access hit INVALID_MODEL. For non-chat-model
// calls, apply modelIdResolutions now so preview-gated sub-agent models fall
// back to the appropriate GA model (e.g. gemini-2.5-flash).
let model = resolved.model;
let resolvedGenerateContentConfig = resolved.generateContentConfig;
if (!modelConfigKey.isChatModel) {
const resolvedModelId = config.modelConfigService.resolveModelId(model, {
hasAccessToPreview: config.getHasAccessToPreviewModel?.() ?? false,
useGemini3_1: config.getGemini31LaunchedSync?.() ?? false,
useGemini3_5Flash: config.hasGemini35FlashGAAccess?.() ?? false,
});
if (resolvedModelId !== model) {
model = resolvedModelId;
const refetchedResolved = config.modelConfigService.getResolvedConfig({
...modelConfigKey,
model,
});
resolvedGenerateContentConfig = refetchedResolved.generateContentConfig;
}
}

const selection = selectModelForAvailability(config, model);

if (!selection) {
return { model, config: resolved.generateContentConfig };
return { model, config: resolvedGenerateContentConfig };
}

const finalModel = selection.selectedModel ?? model;
let generateContentConfig = resolved.generateContentConfig;
let generateContentConfig = resolvedGenerateContentConfig;

if (finalModel !== model) {
const fallbackResolved = config.modelConfigService.getResolvedConfig({
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/core/baseLlmClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ describe('BaseLlmClient', () => {
getResolvedConfig: vi
.fn()
.mockImplementation(({ model }) => makeResolvedModelConfig(model)),
resolveModelId: vi.fn((model: string) => model),
} as unknown as ModelConfigService,
getModelAvailabilityService: vi
.fn()
Expand All @@ -128,6 +129,9 @@ describe('BaseLlmClient', () => {
getMaxAttempts: vi.fn().mockReturnValue(3),
getModel: vi.fn().mockReturnValue('test-model'),
getActiveModel: vi.fn().mockReturnValue('test-model'),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(true),
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
hasGemini35FlashGAAccess: vi.fn().mockReturnValue(false),
} as unknown as Mocked<Config>;

client = new BaseLlmClient(mockContentGenerator, mockConfig);
Expand Down