diff --git a/apps/docs/content/docs/workflows/blocks/agent.mdx b/apps/docs/content/docs/workflows/blocks/agent.mdx index b09e868ea93..c7a98cd9863 100644 --- a/apps/docs/content/docs/workflows/blocks/agent.mdx +++ b/apps/docs/content/docs/workflows/blocks/agent.mdx @@ -168,7 +168,7 @@ Live tool-call chips stream for **OpenAI, Anthropic, Azure Anthropic, Google, Ve | Anthropic | Summaries only — These generations omit full thinking; Sim requests summarized thinking on streaming runs. | `claude-fable-5-1`, `claude-fable-5`, `claude-sonnet-5-5`, `claude-sonnet-5`, `claude-opus-5-5`, `claude-opus-5`, `claude-opus-4-8`, `claude-opus-4-7`, `claude-opus-4-6`, `claude-sonnet-4-6`, `claude-opus-4-5`, `claude-sonnet-4-5`, `claude-haiku-5-5`, `claude-haiku-4-5` | | Azure OpenAI | Summaries only — Requires OpenAI organization verification; falls back to no summaries. | `azure/gpt-6.1-sol`, `azure/gpt-6-sol`, `azure/gpt-6-luna`, `azure/gpt-6-astra`, `azure/gpt-5.6-sol`, `azure/gpt-5.6-terra`, `azure/gpt-5.6-luna`, `azure/gpt-5.5`, `azure/gpt-5.4-pro`, `azure/gpt-5.4`, `azure/gpt-5.4-mini`, `azure/gpt-5.4-nano`, `azure/gpt-5.2`, `azure/gpt-5.1`, `azure/gpt-5.1-codex`, `azure/gpt-5`, `azure/gpt-5-mini`, `azure/gpt-5-nano`, `azure/o3`, `azure/o4-mini` | | Azure Anthropic | Summaries only — These generations omit full thinking; Sim requests summarized thinking on streaming runs. | `azure-anthropic/claude-fable-5-1`, `azure-anthropic/claude-opus-5-5`, `azure-anthropic/claude-sonnet-5-5`, `azure-anthropic/claude-haiku-5-5`, `azure-anthropic/claude-opus-5`, `azure-anthropic/claude-opus-4-8`, `azure-anthropic/claude-opus-4-7`, `azure-anthropic/claude-opus-4-6`, `azure-anthropic/claude-opus-4-5`, `azure-anthropic/claude-sonnet-5`, `azure-anthropic/claude-sonnet-4-6`, `azure-anthropic/claude-sonnet-4-5`, `azure-anthropic/claude-opus-4-1`, `azure-anthropic/claude-haiku-4-5` | -| Google | Summaries only | `gemini-3.8-flash`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash-lite`, `gemini-3.5-flash`, `gemini-3.1-pro-preview`, `gemini-3.1-flash-lite`, `gemini-3-flash-preview`, `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite` | +| Google | Summaries only | `gemini-4-argon`, `gemini-3.8-flash`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash-lite`, `gemini-3.5-flash`, `gemini-3.1-pro-preview`, `gemini-3.1-flash-lite`, `gemini-3-flash-preview`, `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite` | | Vertex AI | Summaries only | `vertex/gemini-3.8-flash`, `vertex/gemini-3.7-flash`, `vertex/gemini-3.6-flash`, `vertex/gemini-3.5-flash-lite`, `vertex/gemini-3.5-flash`, `vertex/gemini-3.1-pro-preview`, `vertex/gemini-3.1-flash-lite`, `vertex/gemini-3-flash-preview`, `vertex/gemini-2.5-pro`, `vertex/gemini-2.5-flash`, `vertex/gemini-2.5-flash-lite` | | DeepSeek | Full thinking deltas | `deepseek-v4-pro`, `deepseek-flash`, `deepseek-v4-flash`, `deepseek-reasoner` | | xAI | Full thinking deltas | `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309` | diff --git a/apps/sim/lib/knowledge/documents/image-extraction-gemini.ts b/apps/sim/lib/knowledge/documents/image-extraction-gemini.ts index 135bfbec093..0252b7f7e5e 100644 --- a/apps/sim/lib/knowledge/documents/image-extraction-gemini.ts +++ b/apps/sim/lib/knowledge/documents/image-extraction-gemini.ts @@ -9,7 +9,7 @@ import { resolveProviderKey } from '@/lib/embeddings/keys' import { PermanentDocumentProcessingError } from '@/lib/knowledge/documents/document-processing-error' import { readBoundedHttpErrorPayload } from '@/lib/knowledge/documents/utils' import { getProviderModels } from '@/providers/models' -import { isGemini3Model } from '@/providers/utils' +import { isGemini3OrLaterModel } from '@/providers/utils' const logger = createLogger('GeminiImageExtraction') @@ -22,7 +22,7 @@ export const DEFAULT_IMAGE_EXTRACTION_MODEL = 'gemini-3.8-flash' /** * Transcription needs little reasoning, and Gemini bills thinking as output that - * also spends the output budget. Every Gemini 3 model accepts `LOW`. + * also spends the output budget. Gemini 3 and later models accept `LOW`. */ const THINKING_LEVEL = 'LOW' @@ -72,19 +72,19 @@ export class GeminiImageExtractionError extends Error { /** * The model from `KB_IMAGE_EXTRACTION_MODEL`, or the default when it is unset or - * not a Gemini 3 model in the catalog: the request uses Gemini 3's JSON-schema - * output and thinking level, and only catalog models carry the pricing that + * not a Gemini 3 or later model in the catalog: the request uses modern Gemini + * JSON-schema output and thinking level, and only catalog models carry the pricing that * bills each image. Never throws: a bad value must not stop indexing. */ export function resolveImageExtractionModel(): string { const configured = env.KB_IMAGE_EXTRACTION_MODEL?.trim() if (!configured) return DEFAULT_IMAGE_EXTRACTION_MODEL const catalogModel = getProviderModels('google').find( - (id) => id.toLowerCase() === configured.toLowerCase() && isGemini3Model(id) + (id) => id.toLowerCase() === configured.toLowerCase() && isGemini3OrLaterModel(id) ) if (catalogModel) return catalogModel logger.warn( - `KB_IMAGE_EXTRACTION_MODEL="${configured}" is not a supported Gemini 3 model, falling back to ${DEFAULT_IMAGE_EXTRACTION_MODEL}` + `KB_IMAGE_EXTRACTION_MODEL="${configured}" is not a supported Gemini 3 or later model, falling back to ${DEFAULT_IMAGE_EXTRACTION_MODEL}` ) return DEFAULT_IMAGE_EXTRACTION_MODEL } diff --git a/apps/sim/providers/gemini/config.ts b/apps/sim/providers/gemini/config.ts new file mode 100644 index 00000000000..32576759f9f --- /dev/null +++ b/apps/sim/providers/gemini/config.ts @@ -0,0 +1,29 @@ +import { FunctionCallingConfigMode, type GenerateContentConfig, type Schema } from '@google/genai' +import { cleanSchemaForGemini } from '@/providers/google/utils' +import type { ProviderRequest } from '@/providers/types' +import { isGemini3OrLaterModel } from '@/providers/utils' + +/** Applies the response schema only on turns that can accept it alongside their tool configuration. */ +export function applyGeminiResponseFormat( + config: GenerateContentConfig, + responseFormat: ProviderRequest['responseFormat'], + model: string +): void { + if (!responseFormat) return + + if (config.toolConfig?.functionCallingConfig?.mode === FunctionCallingConfigMode.ANY) { + config.responseMimeType = undefined + config.responseSchema = undefined + config.responseJsonSchema = undefined + return + } + + if (config.tools?.length && !isGemini3OrLaterModel(model)) return + + config.responseMimeType = 'application/json' + if (isGemini3OrLaterModel(model)) { + config.responseJsonSchema = responseFormat.schema + } else { + config.responseSchema = cleanSchemaForGemini(responseFormat.schema) as Schema + } +} diff --git a/apps/sim/providers/gemini/core.test.ts b/apps/sim/providers/gemini/core.test.ts index 9e0ce6a8d77..bb69686778f 100644 --- a/apps/sim/providers/gemini/core.test.ts +++ b/apps/sim/providers/gemini/core.test.ts @@ -118,6 +118,7 @@ describe('Vertex Gemini request compatibility', () => { }) it.each([ + 'gemini-4-argon', 'vertex/gemini-3.8-flash', 'vertex/gemini-3.7-flash', 'vertex/gemini-3.6-flash', diff --git a/apps/sim/providers/gemini/core.ts b/apps/sim/providers/gemini/core.ts index 17970aa8aaa..94c10af2102 100644 --- a/apps/sim/providers/gemini/core.ts +++ b/apps/sim/providers/gemini/core.ts @@ -7,7 +7,6 @@ import { type GoogleGenAI, type Interactions, type Part, - type Schema, type ThinkingConfig, type ToolConfig, } from '@google/genai' @@ -21,11 +20,11 @@ import { captureProviderConversationStep, recordProviderConversationToolError, } from '@/providers/conversation-history' +import { applyGeminiResponseFormat } from '@/providers/gemini/config' import { createGeminiStreamingToolLoopStream } from '@/providers/gemini/streaming-tool-loop' import { priceGeminiTokens, splitGeminiTokens, splitGeminiUsage } from '@/providers/gemini/usage' import { checkForForcedToolUsage, - cleanSchemaForGemini, convertToGeminiFormat, convertUsageMetadata, createReadableStreamFromGeminiStream, @@ -53,7 +52,7 @@ import type { } from '@/providers/types' import { isDeepResearchModel, - isGemini3Model, + isGemini3OrLaterModel, prepareToolExecution, prepareToolsWithUsageControl, sumToolCosts, @@ -362,19 +361,14 @@ function buildNextConfig( if (allForcedToolsUsed && request.responseFormat) { nextConfig.tools = undefined nextConfig.toolConfig = undefined - if (isGemini3Model(model)) { - logger.info('Gemini 3: Stripping tools after forced tool execution, schema already set') - } else { - nextConfig.responseMimeType = 'application/json' - nextConfig.responseSchema = cleanSchemaForGemini(request.responseFormat.schema) as Schema - logger.info('Using structured output for final response after tool execution') - } + logger.info('Using structured output for final response after tool execution') } else if (state.currentToolConfig) { nextConfig.toolConfig = state.currentToolConfig } else { nextConfig.toolConfig = { functionCallingConfig: { mode: FunctionCallingConfigMode.AUTO } } } + applyGeminiResponseFormat(nextConfig, request.responseFormat, model) return nextConfig } @@ -1006,27 +1000,12 @@ export async function executeGeminiRequest( geminiConfig.systemInstruction = systemInstruction } - // Handle response format - if (request.responseFormat && !tools?.length) { - geminiConfig.responseMimeType = 'application/json' - geminiConfig.responseSchema = cleanSchemaForGemini(request.responseFormat.schema) as Schema - logger.info('Using Gemini native structured output format') - } else if (request.responseFormat && tools?.length && isGemini3Model(model)) { - geminiConfig.responseMimeType = 'application/json' - geminiConfig.responseJsonSchema = request.responseFormat.schema - logger.info('Using Gemini 3 structured output with tools (responseJsonSchema)') - } else if (request.responseFormat && tools?.length) { - logger.warn( - 'Gemini 2 does not support responseFormat with tools. Structured output will be applied after tool execution.' - ) - } - - // Gemini 3.x takes thinkingLevel directly; Gemini 2.5-series rejects it and needs thinkingBudget. + // Gemini 3 and later take thinkingLevel directly; Gemini 2.5-series rejects it and needs thinkingBudget. // includeThoughts is required for thought parts to appear; it is requested // only on agent-events runs so legacy runs keep the pre-agent-events payload. if (request.thinkingLevel && request.thinkingLevel !== 'none') { const thinkingConfig: ThinkingConfig = { includeThoughts: request.agentEvents === true } - if (isGemini3Model(model)) { + if (isGemini3OrLaterModel(model)) { thinkingConfig.thinkingLevel = mapToThinkingLevel(request.thinkingLevel) } else { thinkingConfig.thinkingBudget = mapToThinkingBudget(model, request.thinkingLevel) @@ -1034,7 +1013,7 @@ export async function executeGeminiRequest( geminiConfig.thinkingConfig = thinkingConfig } else if ( request.thinkingLevel === 'none' && - !isGemini3Model(model) && + !isGemini3OrLaterModel(model) && supportsDisablingGemini25Thinking(model) ) { // Omitting thinkingConfig falls back to the API's dynamic default (ON for gemini-2.5-flash), @@ -1087,17 +1066,19 @@ export async function executeGeminiRequest( } } + applyGeminiResponseFormat(geminiConfig, request.responseFormat, model) + const initialCallTime = Date.now() /** * Gemini 2 cannot combine responseSchema with tools, so structured output * is applied on a final schema-configured request after tools settle — the * silent path does this; the live loop would break as soon as a turn has - * no calls and skip the schema. Gemini 3 carries responseJsonSchema - * alongside tools, so its live loop keeps structured output. + * no calls and skip the schema. Gemini 3 and later carry responseJsonSchema + * alongside automatic tools, or apply it after forced calls settle. */ const hasActiveTools = Boolean(geminiConfig.tools?.length) const responseFormatNeedsFinalPass = - Boolean(request.responseFormat) && hasActiveTools && !isGemini3Model(model) + Boolean(request.responseFormat) && hasActiveTools && !isGemini3OrLaterModel(model) const liveToolLoopSupported = !responseFormatNeedsFinalPass const shouldStream = request.stream && !hasActiveTools @@ -1262,10 +1243,7 @@ export async function executeGeminiRequest( tools: undefined, toolConfig: undefined, } - if (request.responseFormat && !isGemini3Model(model)) { - finalConfig.responseMimeType = 'application/json' - finalConfig.responseSchema = cleanSchemaForGemini(request.responseFormat.schema) as Schema - } + applyGeminiResponseFormat(finalConfig, request.responseFormat, model) const finalStartTime = Date.now() const finalResponsePayload = await prepareConversationGeneration(request, 'gemini', { diff --git a/apps/sim/providers/gemini/streaming-tool-loop.ts b/apps/sim/providers/gemini/streaming-tool-loop.ts index c57bce99fde..223d0b93a8b 100644 --- a/apps/sim/providers/gemini/streaming-tool-loop.ts +++ b/apps/sim/providers/gemini/streaming-tool-loop.ts @@ -20,7 +20,6 @@ import { type GenerateContentResponse, type GoogleGenAI, type Part, - type Schema, type ToolConfig, } from '@google/genai' import type { Logger } from '@sim/logger' @@ -32,9 +31,9 @@ import { captureProviderConversationStep, recordProviderConversationToolError, } from '@/providers/conversation-history' +import { applyGeminiResponseFormat } from '@/providers/gemini/config' import { checkForForcedToolUsage, - cleanSchemaForGemini, convertUsageMetadata, ensureStructResponse, geminiRetryDelayMs, @@ -51,7 +50,7 @@ import { import { ensureToolCallId } from '@/providers/tool-call-id' import { enrichLastModelSegment } from '@/providers/trace-enrichment' import type { ModelPricing, ProviderRequest, TimeSegment } from '@/providers/types' -import { isGemini3Model, prepareToolExecution, sumToolCosts } from '@/providers/utils' +import { prepareToolExecution, sumToolCosts } from '@/providers/utils' import type { GeminiUsage } from './types' import { priceGeminiTokens, splitGeminiUsage } from './usage' @@ -96,19 +95,14 @@ function buildNextConfig( if (allForcedToolsUsed && request.responseFormat) { nextConfig.tools = undefined nextConfig.toolConfig = undefined - if (isGemini3Model(model)) { - logger.info('Gemini 3: Stripping tools after forced tool execution, schema already set') - } else { - nextConfig.responseMimeType = 'application/json' - nextConfig.responseSchema = cleanSchemaForGemini(request.responseFormat.schema) as Schema - logger.info('Using structured output for final response after tool execution') - } + logger.info('Using structured output for final response after tool execution') } else if (currentToolConfig) { nextConfig.toolConfig = currentToolConfig } else { nextConfig.toolConfig = { functionCallingConfig: { mode: FunctionCallingConfigMode.AUTO } } } + applyGeminiResponseFormat(nextConfig, request.responseFormat, model) return nextConfig } @@ -310,6 +304,8 @@ export function createGeminiStreamingToolLoopStream( model ) + applyGeminiResponseFormat(turnConfig, request.responseFormat, model) + const modelStart = Date.now() const turnPayload = await prepareConversationGeneration(request, 'gemini', { model, diff --git a/apps/sim/providers/google/index.live.test.ts b/apps/sim/providers/google/index.live.test.ts new file mode 100644 index 00000000000..3c3f17b8245 --- /dev/null +++ b/apps/sim/providers/google/index.live.test.ts @@ -0,0 +1,189 @@ +import { writeFile } from 'node:fs/promises' +import { getErrorMessage } from '@sim/utils/errors' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import type { StreamingExecution } from '@/executor/types' +import { executeProviderRequest } from '@/providers' +import type { ProviderRequest, ProviderResponse } from '@/providers/types' + +vi.unmock('@/tools/registry') +vi.unmock('@/tools/metadata') +vi.unmock('@/tools/metadata-outputs') + +const model = process.env.GEMINI_LIVE_MODEL || 'gemini-4-argon' +const apiKey = process.env.GEMINI_API_KEY +const reportPath = process.env.GEMINI_REPORT_PATH +const results: Array<{ + name: string + status: 'passed' | 'failed' + durationMs: number + error?: string +}> = [] + +async function check(name: string, run: () => Promise) { + const start = Date.now() + try { + await run() + results.push({ name, status: 'passed', durationMs: Date.now() - start }) + } catch (error) { + results.push({ + name, + status: 'failed', + durationMs: Date.now() - start, + error: getErrorMessage(error), + }) + throw error + } +} + +async function execute(request: Partial = {}) { + expect(vi.isMockFunction(globalThis.fetch)).toBe(false) + return executeProviderRequest('google', { + model, + apiKey, + maxTokens: 65536, + thinkingLevel: 'low', + abortSignal: AbortSignal.timeout(120_000), + messages: [{ role: 'user', content: 'Reply with only the result of 6 times 7.' }], + ...request, + }) +} + +beforeAll(() => { + if (!apiKey) throw new Error('Set GEMINI_API_KEY to a Gemini API key with model access') + if (!reportPath) throw new Error('Set GEMINI_REPORT_PATH to a writable JSON report path') +}) + +afterAll(async () => { + if (reportPath) { + await writeFile( + reportPath, + JSON.stringify({ model, recordedAt: new Date().toISOString(), results }, null, 2) + ) + } +}) + +describe('Google Gemini live acceptance', () => { + it.each(['low', 'medium', 'high'])( + 'answers with %s thinking', + async (thinkingLevel) => { + await check(`thinking:${thinkingLevel}`, async () => { + const result = (await execute({ thinkingLevel })) as ProviderResponse + expect(result.content.trim()).toBe('42') + expect(result.tokens?.total).toBeGreaterThan(0) + expect(result.cost?.total).toBeGreaterThan(0) + }) + }, + 120_000 + ) + + it('returns a native JSON-schema answer', async () => { + await check('structured-output', async () => { + const result = (await execute({ + responseFormat: { + name: 'answer', + schema: { + type: 'object', + properties: { answer: { type: 'integer' } }, + required: ['answer'], + additionalProperties: false, + }, + }, + })) as ProviderResponse + expect(JSON.parse(result.content)).toEqual({ answer: 42 }) + }) + }, 120_000) + + it('reads an inline image through the attachment pipeline', async () => { + await check('vision', async () => { + const base64 = + 'iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAb0lEQVR4nO3PAQkAAAyEwO9feoshgnABdLep8QUNyPEFDcjxBQ3I8QUNyPEFDcjxBQ3I8QUNyPEFDcjxBQ3I8QUNyPEFDcjxBQ3I8QUNyPEFDcjxBQ3I8QUNyPEFDcjxBQ3I8QUNyPEFDcjxBQ3I8QUNyPEFDcjxBQ3IPanc8OLDQitxAAAAAElFTkSuQmCC' + const result = (await execute({ + messages: [ + { + role: 'user', + content: 'What is the dominant color in this image? Reply with one color word.', + files: [ + { + id: 'inline-fixture', + name: 'fixture.png', + url: '', + key: '', + type: 'image/png', + size: Buffer.from(base64, 'base64').length, + base64, + }, + ], + }, + ], + })) as ProviderResponse + expect(result.content.toLowerCase()).toContain('red') + expect(result.tokens?.total).toBeGreaterThan(0) + }) + }, 120_000) + + it('streams answer events and settles usage', async () => { + await check('stream', async () => { + const result = (await execute({ stream: true, agentEvents: true })) as StreamingExecution + const reader = result.stream.getReader() + while (!(await reader.read()).done) {} + const execution = await result.execution + expect(execution.output.content?.trim()).toBe('42') + expect(execution.output.tokens?.total).toBeGreaterThan(0) + expect(execution.output.cost?.total).toBeGreaterThan(0) + }) + }, 120_000) + + it.each([false, true])( + 'returns structured output after a real HTTP tool (stream=%s)', + async (stream) => { + await check(`structured-tool-loop:stream=${stream}`, async () => { + const result = await execute({ + stream, + agentEvents: stream, + messages: [ + { + role: 'user', + content: + 'Fetch https://example.com with the tool, then return its HTTP status as status.', + }, + ], + tools: [ + { + id: 'http_request', + description: 'Fetch the example page', + usageControl: 'force', + params: { url: 'https://example.com', method: 'GET' }, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + responseFormat: { + name: 'status', + schema: { + type: 'object', + properties: { status: { type: 'integer' } }, + required: ['status'], + additionalProperties: false, + }, + }, + }) + if (stream) { + const streaming = result as StreamingExecution + const reader = streaming.stream.getReader() + while (!(await reader.read()).done) {} + const execution = await streaming.execution + expect(JSON.parse(execution.output.content ?? '')).toEqual({ status: 200 }) + expect( + execution.output.toolCalls?.list.find((call) => call.name === 'http_request')?.result + ).toMatchObject({ status: 200 }) + } else { + const response = result as ProviderResponse + expect(JSON.parse(response.content)).toEqual({ status: 200 }) + expect( + response.toolCalls?.find((call) => call.name === 'http_request')?.result + ).toMatchObject({ status: 200 }) + } + }) + }, + 120_000 + ) +}) diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index e384a3848e8..ac769c5421c 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -2496,6 +2496,25 @@ export const PROVIDER_DEFINITIONS: Record = { icon: GeminiIcon, color: '#4285F4', models: [ + { + id: 'gemini-4-argon', + pricing: { + input: 2, + cachedInput: 0.1, + output: 10, + updatedAt: '2026-10-13', + }, + capabilities: { + thinking: { + levels: ['low', 'medium', 'high'], + default: 'medium', + }, + maxOutputTokens: 65536, + }, + contextWindow: 1048576, + releaseDate: '2026-09-30', + recommended: true, + }, { id: 'gemini-3.8-flash', pricing: { diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index 304e4f05661..14e238e93a1 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -35,7 +35,7 @@ import { getBaseModelProviders, getProvider, getProviderFromModel, - isGemini3Model, + isGemini3OrLaterModel, prepareToolExecution, prepareToolsWithUsageControl, shouldBillModelUsage, @@ -1540,7 +1540,7 @@ describe('findProviderFromModel', () => { }) }) -describe('isGemini3Model', () => { +describe('isGemini3OrLaterModel', () => { it.each([ 'gemini-3.8-flash', 'VERTEX/gemini-3.8-flash', @@ -1548,15 +1548,15 @@ describe('isGemini3Model', () => { 'vertex/publishers/google/models/gemini-3.8-flash', 'vertex/projects/test-project/locations/global/publishers/google/models/gemini-3.8-flash', ])('recognizes the Gemini family in %s', (model) => { - expect(isGemini3Model(model)).toBe(true) + expect(isGemini3OrLaterModel(model)).toBe(true) }) it.each([ 'vertex/gemini-2.5-pro', 'vertex/custom-gemini-3-deployment', 'vertex/publishers/another-provider/models/gemini-3.8-flash', - ])('does not infer Gemini 3 behavior from %s', (model) => { - expect(isGemini3Model(model)).toBe(false) + ])('does not infer modern Gemini behavior from %s', (model) => { + expect(isGemini3OrLaterModel(model)).toBe(false) }) }) diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 1cc8a03d458..cc02cf7bbfd 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -1491,7 +1491,8 @@ export function isDeepResearchModel(model: string): boolean { return MODELS_WITH_DEEP_RESEARCH.includes(model.toLowerCase()) } -export function isGemini3Model(model: string): boolean { +/** Accepts bare Gemini IDs, google/ or vertex/ prefixes, and Google publisher resource names. */ +export function isGemini3OrLaterModel(model: string): boolean { const normalized = model .toLowerCase() .replace(/^vertex\//, '') @@ -1499,7 +1500,7 @@ export function isGemini3Model(model: string): boolean { /^(?:google\/|(?:projects\/[^/]+\/locations\/[^/]+\/)?publishers\/google\/models\/)/, '' ) - return normalized.startsWith('gemini-3') + return Number(normalized.match(/^gemini-(\d+)(?:[.-]|$)/)?.[1]) >= 3 } /** diff --git a/apps/sim/providers/vertex/index.ts b/apps/sim/providers/vertex/index.ts index 65e49151d81..ea5edba6328 100644 --- a/apps/sim/providers/vertex/index.ts +++ b/apps/sim/providers/vertex/index.ts @@ -10,7 +10,7 @@ import type { StreamingExecution } from '@/executor/types' import { executeGeminiRequest } from '@/providers/gemini/core' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import type { ProviderConfig, ProviderRequest, ProviderResponse } from '@/providers/types' -import { isGemini3Model } from '@/providers/utils' +import { isGemini3OrLaterModel } from '@/providers/utils' const logger = createLogger('VertexProvider') @@ -37,11 +37,11 @@ export const vertexProvider: ProviderConfig = { ): Promise => { const model = request.model.replace(/^vertex\//i, '') const vertexProject = request.vertexProject || env.VERTEX_PROJECT - /** Gemini 3 models use global or multi-region endpoints instead of legacy regions. */ + /** Gemini 3 and later use global or multi-region endpoints instead of legacy regions. */ const vertexLocation = ( request.vertexLocation || env.VERTEX_LOCATION || - (isGemini3Model(model) ? 'global' : 'us-central1') + (isGemini3OrLaterModel(model) ? 'global' : 'us-central1') ).toLowerCase() if (!vertexProject) { diff --git a/packages/testing/src/mocks/providers-utils.mock.ts b/packages/testing/src/mocks/providers-utils.mock.ts index d0309338198..573fd6a9714 100644 --- a/packages/testing/src/mocks/providers-utils.mock.ts +++ b/packages/testing/src/mocks/providers-utils.mock.ts @@ -126,7 +126,7 @@ export const providersUtilsMockFns = { mockSupportsThinking: vi.fn((_model: string): boolean => false), mockSupportsPromptCaching: vi.fn((_model: string): boolean => false), mockIsDeepResearchModel: vi.fn((_model: string): boolean => false), - mockIsGemini3Model: vi.fn((_model: string): boolean => false), + mockIsGemini3OrLaterModel: vi.fn((_model: string): boolean => false), mockPrepareToolExecution: vi.fn( ( _tool: unknown, @@ -202,7 +202,7 @@ export const providersUtilsMock = { supportsThinking: providersUtilsMockFns.mockSupportsThinking, supportsPromptCaching: providersUtilsMockFns.mockSupportsPromptCaching, isDeepResearchModel: providersUtilsMockFns.mockIsDeepResearchModel, - isGemini3Model: providersUtilsMockFns.mockIsGemini3Model, + isGemini3OrLaterModel: providersUtilsMockFns.mockIsGemini3OrLaterModel, prepareToolExecution: providersUtilsMockFns.mockPrepareToolExecution, checkForForcedToolUsageOpenAI: providersUtilsMockFns.mockCheckForForcedToolUsageOpenAI, }