From 7d1c927ab14a921fb0f74963863abc8d99ddbeaf Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 10 Jul 2026 09:08:00 -0700 Subject: [PATCH 01/22] fix(models): correct model catalog data and Gemini thinking-config wire format (#5559) * fix(models): correct model catalog data and Gemini thinking-config wire format - OpenAI: remove fabricated 'max' reasoning-effort value from gpt-5.6 family - Anthropic: fix claude-sonnet-4-6 maxOutputTokens (64k -> 128k); remove 3 fully retired models (claude-opus-4-0, claude-sonnet-4-0, claude-3-haiku-20240307); fix budget_tokens/max_tokens clamp that could send budget_tokens >= max_tokens for claude-opus-4-1 at its default thinking level - Google/Vertex: un-deprecate gemini-3-flash-preview (no shutdown date announced); add thinking capability to gemini-2.5-pro/flash/flash-lite (google + vertex) - Fix gemini/core.ts to send thinkingBudget (not thinkingLevel) for Gemini 2.5-series models, which reject thinkingLevel entirely - only Gemini 3.x supports it - Bedrock: mark claude-opus-4-1 deprecated per AWS's own Bedrock lifecycle schedule (Legacy since Jul 8 2026, separate from Anthropic's direct-API date) * fix(models): restore retired Claude entries as deprecated instead of removing Greptile caught a real backward-compat regression: fully removing claude-opus-4-0/claude-sonnet-4-0/claude-3-haiku-20240307 dropped them from getHostedModels()/shouldBillModelUsage(), so saved workflows still referencing them would fail on a missing-API-key error instead of Anthropic's actual "model retired" error. deprecated:true isn't consumed by the model picker (only copilot's serializer reads it), so restoring them this way costs nothing on hiding from new selection while preserving hosted-key resolution for existing references. * fix(models): restore Sol-exclusive 'max' reasoning value; fix Gemini 2.5 disable-thinking gap Found during a final per-model audit round with independent 2-3 source verification on every changed model: - gpt-5.6-sol: restore 'max' reasoning-effort value. Multiple independent sources (OpenAI's own model-guidance docs page, launch announcement, and press coverage) confirm 'max' is a real, newly-launched value exclusive to Sol - not fabricated as originally assessed. Terra and Luna correctly do NOT get 'max' (confirmed Sol-exclusive), so they're unchanged. - gemini-2.5-flash / gemini-2.5-flash-lite (google + vertex): selecting 'none' for thinking level was sending no thinkingConfig at all, which falls back to the API's dynamic default (thinking stays ON for flash) - not actually disabling it, even though both models explicitly support thinkingBudget:0. Now sends an explicit budget of 0 for these two models specifically (gemini-2.5-pro is correctly excluded - it cannot disable thinking at all, floor is 128 not 0). * chore(models): tighten inline comments in anthropic/gemini core Trim verbose multi-line comments to single concise lines and remove duplication with the TSDoc already on the ANTHROPIC_MIN_BUDGET_TOKENS/ ANTHROPIC_THINKING_OUTPUT_HEADROOM constants. No behavior change - verified against the full test suite (811 files, 11141 tests, all passing). --- apps/sim/providers/anthropic/core.ts | 26 ++++++++--- apps/sim/providers/gemini/core.ts | 20 +++++++-- apps/sim/providers/google/utils.test.ts | 60 ++++++++++++++++++++++++- apps/sim/providers/google/utils.ts | 41 +++++++++++++++++ apps/sim/providers/models.ts | 32 +++++++++++-- apps/sim/providers/utils.test.ts | 50 ++++++++++----------- 6 files changed, 189 insertions(+), 40 deletions(-) diff --git a/apps/sim/providers/anthropic/core.ts b/apps/sim/providers/anthropic/core.ts index e9bdab06049..27d84febb1d 100644 --- a/apps/sim/providers/anthropic/core.ts +++ b/apps/sim/providers/anthropic/core.ts @@ -82,6 +82,12 @@ const THINKING_BUDGET_TOKENS: Record = { high: 32768, } +/** Anthropic's documented floor for `budget_tokens` (Messages API reference: "Must be >=1024 and less than max_tokens"). */ +const ANTHROPIC_MIN_BUDGET_TOKENS = 1024 + +/** Headroom reserved for text output above the thinking budget when computing max_tokens. */ +const ANTHROPIC_THINKING_OUTPUT_HEADROOM = 4096 + /** * Checks if a model supports adaptive thinking (thinking.type: "adaptive"). * Fable 5 supports ONLY adaptive thinking (always on; type: "disabled" is rejected). @@ -338,16 +344,26 @@ export async function executeAnthropicProviderRequest( payload.output_config = thinkingConfig.outputConfig } - // Per Anthropic docs: budget_tokens must be less than max_tokens. - // Ensure max_tokens leaves room for both thinking and text output. + // Keep budget_tokens < max_tokens (see constants above) by shrinking the budget + // itself when the model's output cap is too tight — clamping max_tokens alone + // can leave budget_tokens >= max_tokens. if ( thinkingConfig.thinking.type === 'enabled' && 'budget_tokens' in thinkingConfig.thinking ) { - const budgetTokens = thinkingConfig.thinking.budget_tokens - const minMaxTokens = budgetTokens + 4096 + const modelMax = getMaxOutputTokensForModel(request.model) + let budgetTokens = thinkingConfig.thinking.budget_tokens + + if (budgetTokens + ANTHROPIC_THINKING_OUTPUT_HEADROOM > modelMax) { + budgetTokens = Math.max( + ANTHROPIC_MIN_BUDGET_TOKENS, + modelMax - ANTHROPIC_THINKING_OUTPUT_HEADROOM + ) + thinkingConfig.thinking.budget_tokens = budgetTokens + } + + const minMaxTokens = budgetTokens + ANTHROPIC_THINKING_OUTPUT_HEADROOM if (payload.max_tokens < minMaxTokens) { - const modelMax = getMaxOutputTokensForModel(request.model) payload.max_tokens = Math.min(minMaxTokens, modelMax) logger.info( `Adjusted max_tokens to ${payload.max_tokens} to satisfy budget_tokens (${budgetTokens}) constraint` diff --git a/apps/sim/providers/gemini/core.ts b/apps/sim/providers/gemini/core.ts index be1161ef09f..1419df73782 100644 --- a/apps/sim/providers/gemini/core.ts +++ b/apps/sim/providers/gemini/core.ts @@ -24,7 +24,9 @@ import { ensureStructResponse, extractAllFunctionCallParts, extractTextContent, + mapToThinkingBudget, mapToThinkingLevel, + supportsDisablingGemini25Thinking, } from '@/providers/google/utils' import { enrichLastModelSegment } from '@/providers/trace-enrichment' import type { @@ -952,13 +954,23 @@ export async function executeGeminiRequest( ) } - // Configure thinking only when the user explicitly selects a thinking level + // Gemini 3.x takes thinkingLevel directly; Gemini 2.5-series rejects it and needs thinkingBudget. if (request.thinkingLevel && request.thinkingLevel !== 'none') { - const thinkingConfig: ThinkingConfig = { - includeThoughts: false, - thinkingLevel: mapToThinkingLevel(request.thinkingLevel), + const thinkingConfig: ThinkingConfig = { includeThoughts: false } + if (isGemini3Model(model)) { + thinkingConfig.thinkingLevel = mapToThinkingLevel(request.thinkingLevel) + } else { + thinkingConfig.thinkingBudget = mapToThinkingBudget(model, request.thinkingLevel) } geminiConfig.thinkingConfig = thinkingConfig + } else if ( + request.thinkingLevel === 'none' && + !isGemini3Model(model) && + supportsDisablingGemini25Thinking(model) + ) { + // Omitting thinkingConfig falls back to the API's dynamic default (ON for gemini-2.5-flash), + // so disabling requires an explicit budget of 0. + geminiConfig.thinkingConfig = { includeThoughts: false, thinkingBudget: 0 } } // Prepare tools diff --git a/apps/sim/providers/google/utils.test.ts b/apps/sim/providers/google/utils.test.ts index 7489216049a..7cd7fff8277 100644 --- a/apps/sim/providers/google/utils.test.ts +++ b/apps/sim/providers/google/utils.test.ts @@ -2,9 +2,67 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { convertToGeminiFormat, ensureStructResponse } from '@/providers/google/utils' +import { + convertToGeminiFormat, + ensureStructResponse, + mapToThinkingBudget, + supportsDisablingGemini25Thinking, +} from '@/providers/google/utils' import type { ProviderRequest } from '@/providers/types' +describe('mapToThinkingBudget', () => { + it('maps named levels to a within-range budget for gemini-2.5-pro (128-32768, cannot disable)', () => { + expect(mapToThinkingBudget('gemini-2.5-pro', 'low')).toBeGreaterThanOrEqual(128) + expect(mapToThinkingBudget('gemini-2.5-pro', 'high')).toBeLessThanOrEqual(32768) + }) + + it('maps named levels to a within-range budget for gemini-2.5-flash (0-24576)', () => { + expect(mapToThinkingBudget('gemini-2.5-flash', 'low')).toBeLessThanOrEqual(24576) + expect(mapToThinkingBudget('gemini-2.5-flash', 'high')).toBeLessThanOrEqual(24576) + }) + + it('maps named levels to a within-range budget for gemini-2.5-flash-lite (512-24576)', () => { + expect(mapToThinkingBudget('gemini-2.5-flash-lite', 'low')).toBeGreaterThanOrEqual(512) + expect(mapToThinkingBudget('gemini-2.5-flash-lite', 'high')).toBeLessThanOrEqual(24576) + }) + + it('strips the vertex/ prefix before looking up the model', () => { + expect(mapToThinkingBudget('vertex/gemini-2.5-flash', 'medium')).toBe( + mapToThinkingBudget('gemini-2.5-flash', 'medium') + ) + }) + + it('falls back to dynamic budget (-1) for models with no explicit mapping', () => { + expect(mapToThinkingBudget('gemini-2.0-flash', 'medium')).toBe(-1) + }) + + it('falls back to the high budget for an unrecognized level on a mapped model', () => { + expect(mapToThinkingBudget('gemini-2.5-flash', 'unknown-level')).toBe( + mapToThinkingBudget('gemini-2.5-flash', 'high') + ) + }) +}) + +describe('supportsDisablingGemini25Thinking', () => { + it('returns true for gemini-2.5-flash and gemini-2.5-flash-lite', () => { + expect(supportsDisablingGemini25Thinking('gemini-2.5-flash')).toBe(true) + expect(supportsDisablingGemini25Thinking('gemini-2.5-flash-lite')).toBe(true) + }) + + it('returns false for gemini-2.5-pro, which cannot disable thinking', () => { + expect(supportsDisablingGemini25Thinking('gemini-2.5-pro')).toBe(false) + }) + + it('strips the vertex/ prefix before checking', () => { + expect(supportsDisablingGemini25Thinking('vertex/gemini-2.5-flash')).toBe(true) + expect(supportsDisablingGemini25Thinking('vertex/gemini-2.5-pro')).toBe(false) + }) + + it('returns false for models with no explicit mapping', () => { + expect(supportsDisablingGemini25Thinking('gemini-3.5-flash')).toBe(false) + }) +}) + describe('ensureStructResponse', () => { describe('should return objects unchanged', () => { it('should return plain object unchanged', () => { diff --git a/apps/sim/providers/google/utils.ts b/apps/sim/providers/google/utils.ts index bd98db115c1..05aa74328f7 100644 --- a/apps/sim/providers/google/utils.ts +++ b/apps/sim/providers/google/utils.ts @@ -350,6 +350,47 @@ export function mapToThinkingLevel(level: string): ThinkingLevel { } } +/** + * Per-model thinkingBudget ranges for Gemini 2.5-series models. Unlike Gemini 3.x, these + * models reject `thinkingLevel` entirely (Gemini API docs: "Gemini 2.5 series models don't + * support thinkingLevel; use thinkingBudget instead") and require a numeric token budget + * within each model's own documented range. + */ +const GEMINI_25_THINKING_BUDGETS: Record> = { + 'gemini-2.5-pro': { low: 2048, medium: 8192, high: 32768 }, // valid range 128-32768, cannot disable + 'gemini-2.5-flash': { low: 2048, medium: 8192, high: 24576 }, // valid range 0-24576 + 'gemini-2.5-flash-lite': { low: 1024, medium: 8192, high: 24576 }, // valid range 512-24576 +} + +/** + * Maps a named thinking level to a `thinkingBudget` token count for Gemini 2.5-series models. + * Falls back to -1 (dynamic/automatic budget) for any model not in the explicit table above, + * rather than guessing a number that could fall outside an unmapped model's valid range. + */ +export function mapToThinkingBudget(model: string, level: string): number { + const normalized = model.toLowerCase().replace(/^vertex\//, '') + const budgets = GEMINI_25_THINKING_BUDGETS[normalized] + if (!budgets) return -1 + return budgets[level.toLowerCase()] ?? budgets.high +} + +/** + * Gemini 2.5-series models that accept `thinkingBudget: 0` to explicitly disable thinking. + * gemini-2.5-pro cannot disable thinking at all (its documented budget floor is 128, not 0), + * so it's deliberately excluded here. + */ +const GEMINI_25_MODELS_SUPPORTING_DISABLE = new Set(['gemini-2.5-flash', 'gemini-2.5-flash-lite']) + +/** + * Whether this Gemini 2.5-series model supports explicitly disabling thinking via budget=0. + * Omitting thinkingConfig entirely (the 'none' no-op path) falls back to the API's own + * dynamic default, which is ON for gemini-2.5-flash — not the same as actually disabling it. + */ +export function supportsDisablingGemini25Thinking(model: string): boolean { + const normalized = model.toLowerCase().replace(/^vertex\//, '') + return GEMINI_25_MODELS_SUPPORTING_DISABLE.has(normalized) +} + /** * Result of checking forced tool usage */ diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index 830492aeb2f..e1de7772e08 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -315,7 +315,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { reasoningEffort: { - values: ['none', 'low', 'medium', 'high', 'xhigh', 'max'], + values: ['none', 'low', 'medium', 'high', 'xhigh'], }, verbosity: { values: ['low', 'medium', 'high'], @@ -335,7 +335,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { reasoningEffort: { - values: ['none', 'low', 'medium', 'high', 'xhigh', 'max'], + values: ['none', 'low', 'medium', 'high', 'xhigh'], }, verbosity: { values: ['low', 'medium', 'high'], @@ -841,7 +841,7 @@ export const PROVIDER_DEFINITIONS: Record = { capabilities: { temperature: { min: 0, max: 1 }, nativeStructuredOutputs: true, - maxOutputTokens: 64000, + maxOutputTokens: 128000, thinking: { levels: ['low', 'medium', 'high', 'max'], default: 'high', @@ -1509,7 +1509,6 @@ export const PROVIDER_DEFINITIONS: Record = { }, contextWindow: 1048576, releaseDate: '2025-12-17', - deprecated: true, }, { id: 'gemini-2.5-pro', @@ -1521,6 +1520,10 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { temperature: { min: 0, max: 2 }, + thinking: { + levels: ['low', 'medium', 'high'], + default: 'high', + }, maxOutputTokens: 65536, }, contextWindow: 1048576, @@ -1536,6 +1539,10 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { temperature: { min: 0, max: 2 }, + thinking: { + levels: ['low', 'medium', 'high'], + default: 'medium', + }, maxOutputTokens: 65536, }, contextWindow: 1048576, @@ -1551,6 +1558,10 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { temperature: { min: 0, max: 2 }, + thinking: { + levels: ['low', 'medium', 'high'], + default: 'low', + }, maxOutputTokens: 65536, }, contextWindow: 1048576, @@ -1725,6 +1736,10 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { temperature: { min: 0, max: 2 }, + thinking: { + levels: ['low', 'medium', 'high'], + default: 'high', + }, maxOutputTokens: 65535, }, contextWindow: 1048576, @@ -1740,6 +1755,10 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { temperature: { min: 0, max: 2 }, + thinking: { + levels: ['low', 'medium', 'high'], + default: 'medium', + }, maxOutputTokens: 65535, }, contextWindow: 1048576, @@ -1755,6 +1774,10 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { temperature: { min: 0, max: 2 }, + thinking: { + levels: ['low', 'medium', 'high'], + default: 'low', + }, maxOutputTokens: 65535, }, contextWindow: 1048576, @@ -2876,6 +2899,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, contextWindow: 200000, releaseDate: '2025-08-05', + deprecated: true, }, { id: 'bedrock/amazon.nova-2-pro-v1:0', diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index 0182491436e..1e9c877a976 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -196,8 +196,8 @@ describe('Model Capabilities', () => { 'gpt-5-chat-latest', 'azure/gpt-5-chat', 'gemini-2.5-flash', - 'claude-sonnet-4-0', - 'claude-opus-4-0', + 'claude-sonnet-4-5', + 'claude-opus-4-1', 'grok-3-latest', 'grok-3-fast-latest', 'deepseek-v3', @@ -243,7 +243,7 @@ describe('Model Capabilities', () => { it.concurrent('should be case insensitive', () => { expect(supportsTemperature('GPT-4O')).toBe(true) - expect(supportsTemperature('claude-sonnet-4-0')).toBe(true) + expect(supportsTemperature('claude-sonnet-4-5')).toBe(true) }) it.concurrent( @@ -277,7 +277,7 @@ describe('Model Capabilities', () => { }) it.concurrent('should return 1 for models with temperature range 0-1', () => { - const modelsRange01 = ['claude-sonnet-4-0', 'claude-opus-4-0'] + const modelsRange01 = ['claude-sonnet-4-5', 'claude-opus-4-1'] for (const model of modelsRange01) { expect(getMaxTemperature(model)).toBe(1) @@ -316,7 +316,7 @@ describe('Model Capabilities', () => { it.concurrent('should be case insensitive', () => { expect(getMaxTemperature('GPT-4O')).toBe(2) - expect(getMaxTemperature('CLAUDE-SONNET-4-0')).toBe(1) + expect(getMaxTemperature('CLAUDE-SONNET-4-5')).toBe(1) }) it.concurrent( @@ -413,7 +413,7 @@ describe('Model Capabilities', () => { expect(supportsThinking('claude-opus-4-6')).toBe(true) expect(supportsThinking('claude-opus-4-5')).toBe(true) expect(supportsThinking('claude-sonnet-4-5')).toBe(true) - expect(supportsThinking('claude-sonnet-4-0')).toBe(true) + expect(supportsThinking('claude-sonnet-4-5')).toBe(true) expect(supportsThinking('claude-haiku-4-5')).toBe(true) expect(supportsThinking('gemini-3-flash-preview')).toBe(true) }) @@ -438,11 +438,11 @@ describe('Model Capabilities', () => { expect(MODELS_TEMP_RANGE_0_2).toContain('gemini-2.5-flash') expect(MODELS_TEMP_RANGE_0_2).toContain('deepseek-v3') expect(MODELS_TEMP_RANGE_0_2).toContain('grok-3-latest') - expect(MODELS_TEMP_RANGE_0_2).not.toContain('claude-sonnet-4-0') + expect(MODELS_TEMP_RANGE_0_2).not.toContain('claude-sonnet-4-5') }) it.concurrent('should have correct models in MODELS_TEMP_RANGE_0_1', () => { - expect(MODELS_TEMP_RANGE_0_1).toContain('claude-sonnet-4-0') + expect(MODELS_TEMP_RANGE_0_1).toContain('claude-sonnet-4-5') expect(MODELS_TEMP_RANGE_0_1).not.toContain('grok-3-latest') expect(MODELS_TEMP_RANGE_0_1).not.toContain('gpt-4o') }) @@ -464,7 +464,7 @@ describe('Model Capabilities', () => { MODELS_TEMP_RANGE_0_1.length ) expect(MODELS_WITH_TEMPERATURE_SUPPORT).toContain('gpt-4o') - expect(MODELS_WITH_TEMPERATURE_SUPPORT).toContain('claude-sonnet-4-0') + expect(MODELS_WITH_TEMPERATURE_SUPPORT).toContain('claude-sonnet-4-5') } ) @@ -496,7 +496,7 @@ describe('Model Capabilities', () => { expect(MODELS_WITH_REASONING_EFFORT).not.toContain('azure/gpt-5-chat') expect(MODELS_WITH_REASONING_EFFORT).not.toContain('gpt-4o') - expect(MODELS_WITH_REASONING_EFFORT).not.toContain('claude-sonnet-4-0') + expect(MODELS_WITH_REASONING_EFFORT).not.toContain('claude-sonnet-4-5') }) it.concurrent('should have correct models in MODELS_WITH_VERBOSITY', () => { @@ -525,16 +525,14 @@ describe('Model Capabilities', () => { expect(MODELS_WITH_VERBOSITY).not.toContain('o4-mini') expect(MODELS_WITH_VERBOSITY).not.toContain('gpt-4o') - expect(MODELS_WITH_VERBOSITY).not.toContain('claude-sonnet-4-0') + expect(MODELS_WITH_VERBOSITY).not.toContain('claude-sonnet-4-5') }) it.concurrent('should have correct models in MODELS_WITH_THINKING', () => { expect(MODELS_WITH_THINKING).toContain('claude-opus-4-6') expect(MODELS_WITH_THINKING).toContain('claude-opus-4-5') expect(MODELS_WITH_THINKING).toContain('claude-opus-4-1') - expect(MODELS_WITH_THINKING).toContain('claude-opus-4-0') expect(MODELS_WITH_THINKING).toContain('claude-sonnet-4-5') - expect(MODELS_WITH_THINKING).toContain('claude-sonnet-4-0') expect(MODELS_WITH_THINKING).toContain('gemini-3-flash-preview') @@ -659,7 +657,7 @@ describe('Model Capabilities', () => { }) it.concurrent('should return correct levels for other Claude models (budget_tokens)', () => { - for (const model of ['claude-opus-4-5', 'claude-sonnet-4-5', 'claude-sonnet-4-0']) { + for (const model of ['claude-opus-4-5', 'claude-sonnet-4-5', 'claude-haiku-4-5']) { const levels = getThinkingLevelsForModel(model) expect(levels).toBeDefined() expect(levels).toContain('low') @@ -713,7 +711,7 @@ describe('Max Output Tokens', () => { }) it.concurrent('should return updated max for Claude Sonnet 4.6', () => { - expect(getMaxOutputTokensForModel('claude-sonnet-4-6')).toBe(64000) + expect(getMaxOutputTokensForModel('claude-sonnet-4-6')).toBe(128000) }) it.concurrent('should return published max for Gemini 2.5 Pro', () => { @@ -873,8 +871,8 @@ describe('getHostedModels', () => { expect(hostedModels).toContain('gpt-4o') expect(hostedModels).toContain('o1') - expect(hostedModels).toContain('claude-sonnet-4-0') - expect(hostedModels).toContain('claude-opus-4-0') + expect(hostedModels).toContain('claude-sonnet-4-5') + expect(hostedModels).toContain('claude-opus-4-1') expect(hostedModels).toContain('gemini-2.5-pro') expect(hostedModels).toContain('gemini-2.5-flash') @@ -899,8 +897,8 @@ describe('shouldBillModelUsage', () => { expect(shouldBillModelUsage('gpt-4o')).toBe(true) expect(shouldBillModelUsage('o1')).toBe(true) - expect(shouldBillModelUsage('claude-sonnet-4-0')).toBe(true) - expect(shouldBillModelUsage('claude-opus-4-0')).toBe(true) + expect(shouldBillModelUsage('claude-sonnet-4-5')).toBe(true) + expect(shouldBillModelUsage('claude-opus-4-1')).toBe(true) expect(shouldBillModelUsage('gemini-2.5-pro')).toBe(true) expect(shouldBillModelUsage('gemini-2.5-flash')).toBe(true) @@ -921,7 +919,7 @@ describe('shouldBillModelUsage', () => { it.concurrent('should be case insensitive', () => { expect(shouldBillModelUsage('GPT-4O')).toBe(true) - expect(shouldBillModelUsage('Claude-Sonnet-4-0')).toBe(true) + expect(shouldBillModelUsage('Claude-Sonnet-4-5')).toBe(true) expect(shouldBillModelUsage('GEMINI-2.5-PRO')).toBe(true) }) @@ -936,7 +934,7 @@ describe('Provider Management', () => { describe('getProviderFromModel', () => { it.concurrent('should return correct provider for known models', () => { expect(getProviderFromModel('gpt-4o')).toBe('openai') - expect(getProviderFromModel('claude-sonnet-4-0')).toBe('anthropic') + expect(getProviderFromModel('claude-sonnet-4-5')).toBe('anthropic') expect(getProviderFromModel('gemini-2.5-pro')).toBe('google') expect(getProviderFromModel('azure/gpt-4o')).toBe('azure-openai') }) @@ -985,7 +983,7 @@ describe('Provider Management', () => { expect(config).toBeDefined() expect(config?.id).toBe('openai') - const anthropicConfig = getProviderConfigFromModel('claude-sonnet-4-0') + const anthropicConfig = getProviderConfigFromModel('claude-sonnet-4-5') expect(anthropicConfig).toBeDefined() expect(anthropicConfig?.id).toBe('anthropic') }) @@ -998,7 +996,7 @@ describe('Provider Management', () => { expect(allModels.length).toBeGreaterThan(0) expect(allModels).toContain('gpt-4o') - expect(allModels).toContain('claude-sonnet-4-0') + expect(allModels).toContain('claude-sonnet-4-5') expect(allModels).toContain('gemini-2.5-pro') }) }) @@ -1022,8 +1020,8 @@ describe('Provider Management', () => { expect(openaiModels).toContain('o1') const anthropicModels = getProviderModels('anthropic') - expect(anthropicModels).toContain('claude-sonnet-4-0') - expect(anthropicModels).toContain('claude-opus-4-0') + expect(anthropicModels).toContain('claude-sonnet-4-5') + expect(anthropicModels).toContain('claude-opus-4-1') }) it.concurrent('should return empty array for unknown providers', () => { @@ -1037,7 +1035,7 @@ describe('Provider Management', () => { const allProviders = getAllModelProviders() expect(typeof allProviders).toBe('object') expect(allProviders['gpt-4o']).toBe('openai') - expect(allProviders['claude-sonnet-4-0']).toBe('anthropic') + expect(allProviders['claude-sonnet-4-5']).toBe('anthropic') const baseProviders = getBaseModelProviders() expect(typeof baseProviders).toBe('object') From 2986362b385f6ef05e8956095bb65bce0edb9fbb Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 10 Jul 2026 09:15:42 -0700 Subject: [PATCH 02/22] feat(providers): add NVIDIA NIM and Z.ai providers (#5560) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * v0.6.29: login improvements, posthog telemetry (#4026) * feat(posthog): Add tracking on mothership abort (#4023) Co-authored-by: Theodore Li * fix(login): fix captcha headers for manual login (#4025) * fix(signup): fix turnstile key loading * fix(login): fix captcha header passing * Catch user already exists, remove login form captcha * feat(providers): add NVIDIA NIM and Z.ai providers - NVIDIA NIM (BYOK): Nemotron model family (70B/Ultra-253B/Super-49B v1.5, Nemotron-3 Nano/Super/Ultra) via integrate.api.nvidia.com's OpenAI-compatible API - Z.ai (hosted): GLM model family (5.2 down to 4-32B) via api.z.ai's OpenAI-compatible API, bare glm-* model ids with no provider prefix, Sim-provided key rotation (ZAI_API_KEY_1/2/3) matching openai/anthropic/google - Z.ai tool_choice is forced to 'auto' since the API only documents auto support; forced/none tool_choice from prepareToolsWithUsageControl is ignored with a warning log instead of being sent to the API * fix(providers): scope zai model routing to the exact catalog Drop the /^glm/ fallback pattern - it would overmatch any unrelated self-hosted "glm-*" model (e.g. a custom vLLM/LiteLLM deployment) and misroute it to Z.ai's hosted, Sim-billed key. Routing now relies solely on the exact model-id match against zai's static catalog. * fix(providers): wire thinking/reasoning_effort into Z.ai requests request.thinkingLevel and request.reasoningEffort were computed but never mapped onto the Z.ai payload, so the thinking toggle and GLM-5.2's reasoning_effort control silently no-op'd and always ran on Z.ai's server-side default instead of the user's selection. * fix(providers): route Z.ai through the workspace BYOK resolver too getApiKeyWithBYOK (the resolver actually used by workspace provider runs, per providers/index.ts) only rotated server keys for openai/anthropic/google/mistral, so GLM calls without a user apiKey failed even with ZAI_API_KEY_1/2/3 configured — getApiKey in providers/utils.ts had zai wired but that's not the codepath workspace runs go through. Add zai to the hosted-check condition, and register it as a BYOKProviderId so a workspace can also bring its own Z.ai key. --------- Co-authored-by: Theodore Li Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com> Co-authored-by: Vikhyath Mondreti Co-authored-by: Theodore Li --- apps/sim/components/icons.tsx | 32 ++ apps/sim/lib/api-key/byok.ts | 3 +- apps/sim/lib/api/contracts/byok-keys.ts | 1 + apps/sim/lib/core/config/api-keys.ts | 7 +- apps/sim/lib/core/config/env.ts | 3 + apps/sim/lib/tokenization/constants.ts | 10 + apps/sim/providers/attachments.ts | 10 + apps/sim/providers/models.test.ts | 84 +++ apps/sim/providers/models.ts | 343 +++++++++++++ apps/sim/providers/nvidia/index.ts | 632 +++++++++++++++++++++++ apps/sim/providers/nvidia/utils.ts | 14 + apps/sim/providers/registry.ts | 4 + apps/sim/providers/types.ts | 2 + apps/sim/providers/utils.ts | 5 +- apps/sim/providers/zai/index.ts | 649 ++++++++++++++++++++++++ apps/sim/providers/zai/utils.ts | 14 + apps/sim/tools/types.ts | 1 + 17 files changed, 1811 insertions(+), 3 deletions(-) create mode 100644 apps/sim/providers/nvidia/index.ts create mode 100644 apps/sim/providers/nvidia/utils.ts create mode 100644 apps/sim/providers/zai/index.ts create mode 100644 apps/sim/providers/zai/utils.ts diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index fe869a5b77e..b8f8a650e65 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -3687,6 +3687,38 @@ export const SakanaIcon = (props: SVGProps) => ( ) +export const NvidiaIcon = (props: SVGProps) => ( + + NVIDIA + + +) + +export const ZaiIcon = (props: SVGProps) => ( + + Z.ai + + + + + +) + export function MetaIcon(props: SVGProps) { const id = useId() const gradient1Id = `meta_gradient_1_${id}` diff --git a/apps/sim/lib/api-key/byok.ts b/apps/sim/lib/api-key/byok.ts index 73df4a3a1b0..4a4f26b3271 100644 --- a/apps/sim/lib/api-key/byok.ts +++ b/apps/sim/lib/api-key/byok.ts @@ -204,13 +204,14 @@ export async function getApiKeyWithBYOK( const isClaudeModel = provider === 'anthropic' const isGeminiModel = provider === 'google' const isMistralModel = provider === 'mistral' + const isZaiModel = provider === 'zai' const byokProviderId = isGeminiModel ? 'google' : (provider as BYOKProviderId) if ( isHosted && workspaceId && - (isOpenAIModel || isClaudeModel || isGeminiModel || isMistralModel) + (isOpenAIModel || isClaudeModel || isGeminiModel || isMistralModel || isZaiModel) ) { const hostedModels = getHostedModels() const isModelHosted = hostedModels.some((m) => m.toLowerCase() === model.toLowerCase()) diff --git a/apps/sim/lib/api/contracts/byok-keys.ts b/apps/sim/lib/api/contracts/byok-keys.ts index 0c4f10f7085..d9b8a561099 100644 --- a/apps/sim/lib/api/contracts/byok-keys.ts +++ b/apps/sim/lib/api/contracts/byok-keys.ts @@ -6,6 +6,7 @@ export const byokProviderIdSchema = z.enum([ 'anthropic', 'google', 'mistral', + 'zai', 'fireworks', 'together', 'baseten', diff --git a/apps/sim/lib/core/config/api-keys.ts b/apps/sim/lib/core/config/api-keys.ts index ad093a4d987..1a1f04218cd 100644 --- a/apps/sim/lib/core/config/api-keys.ts +++ b/apps/sim/lib/core/config/api-keys.ts @@ -11,7 +11,8 @@ export function getRotatingApiKey(provider: string): string { provider !== 'openai' && provider !== 'anthropic' && provider !== 'gemini' && - provider !== 'cohere' + provider !== 'cohere' && + provider !== 'zai' ) { throw new Error(`No rotation implemented for provider: ${provider}`) } @@ -34,6 +35,10 @@ export function getRotatingApiKey(provider: string): string { if (env.COHERE_API_KEY_1) keys.push(env.COHERE_API_KEY_1) if (env.COHERE_API_KEY_2) keys.push(env.COHERE_API_KEY_2) if (env.COHERE_API_KEY_3) keys.push(env.COHERE_API_KEY_3) + } else if (provider === 'zai') { + if (env.ZAI_API_KEY_1) keys.push(env.ZAI_API_KEY_1) + if (env.ZAI_API_KEY_2) keys.push(env.ZAI_API_KEY_2) + if (env.ZAI_API_KEY_3) keys.push(env.ZAI_API_KEY_3) } if (keys.length === 0) { diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 990ab7679d3..6cf92e243a3 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -144,6 +144,9 @@ export const env = createEnv({ GEMINI_API_KEY_1: z.string().min(1).optional(), // Primary Gemini API key GEMINI_API_KEY_2: z.string().min(1).optional(), // Additional Gemini API key for load balancing GEMINI_API_KEY_3: z.string().min(1).optional(), // Additional Gemini API key for load balancing + ZAI_API_KEY_1: z.string().min(1).optional(), // Primary Z.ai API key for load balancing + ZAI_API_KEY_2: z.string().min(1).optional(), // Additional Z.ai API key for load balancing + ZAI_API_KEY_3: z.string().min(1).optional(), // Additional Z.ai API key for load balancing OLLAMA_URL: z.string().url().optional(), // Ollama local LLM server URL VLLM_BASE_URL: z.string().url().optional(), // vLLM self-hosted base URL (OpenAI-compatible) VLLM_API_KEY: z.string().optional(), // Optional bearer token for vLLM diff --git a/apps/sim/lib/tokenization/constants.ts b/apps/sim/lib/tokenization/constants.ts index 34d166ba2ed..7012e0dfe23 100644 --- a/apps/sim/lib/tokenization/constants.ts +++ b/apps/sim/lib/tokenization/constants.ts @@ -61,11 +61,21 @@ export const TOKENIZATION_CONFIG = { confidence: 'medium', supportedMethods: ['heuristic', 'fallback'], }, + nvidia: { + avgCharsPerToken: 4, + confidence: 'medium', + supportedMethods: ['heuristic', 'fallback'], + }, meta: { avgCharsPerToken: 4, confidence: 'medium', supportedMethods: ['heuristic', 'fallback'], }, + zai: { + avgCharsPerToken: 4, + confidence: 'medium', + supportedMethods: ['heuristic', 'fallback'], + }, ollama: { avgCharsPerToken: 4, confidence: 'low', diff --git a/apps/sim/providers/attachments.ts b/apps/sim/providers/attachments.ts index d6b37b2dce1..c902d959382 100644 --- a/apps/sim/providers/attachments.ts +++ b/apps/sim/providers/attachments.ts @@ -36,7 +36,9 @@ export type AttachmentProvider = | 'deepseek' | 'cerebras' | 'sakana' + | 'nvidia' | 'meta' + | 'zai' export interface PreparedProviderAttachment { file: UserFile @@ -124,7 +126,9 @@ const UNSUPPORTED_FILE_PROVIDERS = new Set([ 'deepseek', 'cerebras', 'sakana', + 'nvidia', 'meta', + 'zai', ]) const PROVIDER_SUPPORTED_LABELS: Record = { @@ -145,7 +149,9 @@ const PROVIDER_SUPPORTED_LABELS: Record = { deepseek: 'no file attachments in the current API adapter', cerebras: 'no file attachments in the current API adapter', sakana: 'no file attachments in the current API adapter', + nvidia: 'no file attachments in the current API adapter', meta: 'no file attachments in the current API adapter', + zai: 'no file attachments in the current API adapter', } export function getAttachmentProvider(providerId: ProviderId | string): AttachmentProvider | null { @@ -166,7 +172,9 @@ export function getAttachmentProvider(providerId: ProviderId | string): Attachme if (providerId === 'deepseek') return 'deepseek' if (providerId === 'cerebras') return 'cerebras' if (providerId === 'sakana') return 'sakana' + if (providerId === 'nvidia') return 'nvidia' if (providerId === 'meta') return 'meta' + if (providerId === 'zai') return 'zai' return null } @@ -315,7 +323,9 @@ function isMimeTypeSupportedByProvider( case 'deepseek': case 'cerebras': case 'sakana': + case 'nvidia': case 'meta': + case 'zai': return false default: { const _exhaustive: never = provider diff --git a/apps/sim/providers/models.test.ts b/apps/sim/providers/models.test.ts index b3b16b54bc7..0c01133649c 100644 --- a/apps/sim/providers/models.test.ts +++ b/apps/sim/providers/models.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest' import { getBaseModelProviders, + getHostedModels, orderModelIdsByReleaseDate, PROVIDER_DEFINITIONS, } from '@/providers/models' @@ -134,3 +135,86 @@ describe('sakana provider definition', () => { expect(baseModels['fugu-ultra']).toBe('sakana') }) }) + +describe('nvidia provider definition', () => { + const nvidia = PROVIDER_DEFINITIONS.nvidia + + const expectedModels = [ + { id: 'nvidia/llama-3.1-nemotron-70b-instruct', contextWindow: 128000 }, + { id: 'nvidia/llama-3.1-nemotron-ultra-253b-v1', contextWindow: 131072 }, + { id: 'nvidia/llama-3.3-nemotron-super-49b-v1.5', contextWindow: 131072 }, + { id: 'nvidia/nemotron-3-nano-30b-a3b', contextWindow: 262144 }, + { id: 'nvidia/nemotron-3-super-120b-a12b', contextWindow: 1048576 }, + { id: 'nvidia/nemotron-3-ultra-550b-a55b', contextWindow: 1048576 }, + ] + + it('is registered with the current-gen Super model as the default', () => { + expect(nvidia).toBeDefined() + expect(nvidia.id).toBe('nvidia') + expect(nvidia.defaultModel).toBe('nvidia/nemotron-3-super-120b-a12b') + expect(nvidia.modelPatterns).toEqual([/^nvidia\//]) + }) + + it('exposes all six Nemotron models with the documented context windows', () => { + expect(nvidia.models.map((m) => m.id)).toEqual(expectedModels.map((m) => m.id)) + for (const expected of expectedModels) { + const model = nvidia.models.find((m) => m.id === expected.id) + expect(model?.contextWindow).toBe(expected.contextWindow) + } + }) + + it('routes every nvidia model ID to the nvidia provider', () => { + const baseModels = getBaseModelProviders() + for (const expected of expectedModels) { + expect(baseModels[expected.id]).toBe('nvidia') + } + }) +}) + +describe('zai provider definition', () => { + const zai = PROVIDER_DEFINITIONS.zai + + const expectedModels = [ + { id: 'glm-5.2', contextWindow: 1000000 }, + { id: 'glm-5.1', contextWindow: 200000 }, + { id: 'glm-5', contextWindow: 200000 }, + { id: 'glm-5-turbo', contextWindow: 200000 }, + { id: 'glm-4.7', contextWindow: 200000 }, + { id: 'glm-4.7-flashx', contextWindow: 200000 }, + { id: 'glm-4.6', contextWindow: 200000 }, + { id: 'glm-4.5', contextWindow: 128000 }, + { id: 'glm-4.5-air', contextWindow: 128000 }, + { id: 'glm-4.5-x', contextWindow: 128000 }, + { id: 'glm-4.5-airx', contextWindow: 128000 }, + { id: 'glm-4-32b-0414-128k', contextWindow: 128000 }, + ] + + it('is registered with a bare glm-4.6 as the default model', () => { + expect(zai).toBeDefined() + expect(zai.id).toBe('zai') + expect(zai.defaultModel).toBe('glm-4.6') + expect(zai.defaultModel.startsWith('zai/')).toBe(false) + // No fallback pattern — an unscoped `/^glm/` would overmatch unrelated self-hosted + // "glm-*" models and misroute them to Z.ai's hosted billing. + expect(zai.modelPatterns).toEqual([]) + }) + + it('exposes every GLM model with the documented context window', () => { + expect(zai.models.map((m) => m.id)).toEqual(expectedModels.map((m) => m.id)) + for (const expected of expectedModels) { + const model = zai.models.find((m) => m.id === expected.id) + expect(model?.contextWindow).toBe(expected.contextWindow) + } + }) + + it('routes every bare glm-* model ID to the zai provider', () => { + const baseModels = getBaseModelProviders() + for (const expected of expectedModels) { + expect(baseModels[expected.id]).toBe('zai') + } + }) + + it('is included in getHostedModels since Sim provides the Z.ai key server-side', () => { + expect(getHostedModels()).toContain('glm-4.6') + }) +}) diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index e1de7772e08..35dc90369a4 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -21,6 +21,7 @@ import { LitellmIcon, MetaIcon, MistralIcon, + NvidiaIcon, OllamaIcon, OpenAIIcon, OpenRouterIcon, @@ -29,6 +30,7 @@ import { VertexIcon, VllmIcon, xAIIcon, + ZaiIcon, } from '@/components/icons' import type { ModelPricing, ProviderId } from '@/providers/types' @@ -2376,6 +2378,115 @@ export const PROVIDER_DEFINITIONS: Record = { }, ], }, + nvidia: { + id: 'nvidia', + name: 'NVIDIA NIM', + description: "NVIDIA's Nemotron models via NIM's OpenAI-compatible API", + defaultModel: 'nvidia/nemotron-3-super-120b-a12b', + modelPatterns: [/^nvidia\//], + icon: NvidiaIcon, + color: '#77B900', + isReseller: true, + contextInformationAvailable: true, + capabilities: { + temperature: { min: 0, max: 2 }, + toolUsageControl: true, + }, + models: [ + { + id: 'nvidia/llama-3.1-nemotron-70b-instruct', + pricing: { + input: 1.2, + output: 1.2, + updatedAt: '2026-07-09', + }, + capabilities: { + temperature: { min: 0, max: 2 }, + toolUsageControl: true, + maxOutputTokens: 4096, + }, + contextWindow: 128000, + releaseDate: '2024-10-15', + }, + { + id: 'nvidia/llama-3.1-nemotron-ultra-253b-v1', + pricing: { + input: 0.6, + output: 1.8, + updatedAt: '2026-07-09', + }, + capabilities: { + temperature: { min: 0, max: 2 }, + toolUsageControl: true, + maxOutputTokens: 8192, + }, + contextWindow: 131072, + releaseDate: '2025-04-07', + }, + { + id: 'nvidia/llama-3.3-nemotron-super-49b-v1.5', + pricing: { + input: 0.4, + output: 0.4, + updatedAt: '2026-07-09', + }, + capabilities: { + temperature: { min: 0, max: 2 }, + toolUsageControl: true, + maxOutputTokens: 8192, + }, + contextWindow: 131072, + releaseDate: '2025-07-25', + }, + { + id: 'nvidia/nemotron-3-nano-30b-a3b', + pricing: { + input: 0.05, + output: 0.2, + updatedAt: '2026-07-09', + }, + capabilities: { + temperature: { min: 0, max: 2 }, + toolUsageControl: true, + maxOutputTokens: 8192, + }, + contextWindow: 262144, + releaseDate: '2025-12-15', + speedOptimized: true, + }, + { + id: 'nvidia/nemotron-3-super-120b-a12b', + pricing: { + input: 0.08, + output: 0.45, + updatedAt: '2026-07-09', + }, + capabilities: { + temperature: { min: 0, max: 2 }, + toolUsageControl: true, + maxOutputTokens: 8192, + }, + contextWindow: 1048576, + releaseDate: '2026-03-11', + recommended: true, + }, + { + id: 'nvidia/nemotron-3-ultra-550b-a55b', + pricing: { + input: 0.5, + output: 2.2, + updatedAt: '2026-07-09', + }, + capabilities: { + temperature: { min: 0, max: 2 }, + toolUsageControl: true, + maxOutputTokens: 8192, + }, + contextWindow: 1048576, + releaseDate: '2026-06-04', + }, + ], + }, meta: { id: 'meta', name: 'Meta', @@ -2408,6 +2519,237 @@ export const PROVIDER_DEFINITIONS: Record = { }, ], }, + zai: { + id: 'zai', + name: 'Z.ai', + description: "Z.ai's GLM models via an OpenAI-compatible API", + defaultModel: 'glm-4.6', + // No fallback pattern: `/^glm/` would overmatch any unrelated self-hosted "glm-*" model + // (e.g. a custom vLLM/LiteLLM deployment) and misroute it to Z.ai's hosted billing. + // Routing for zai's own models relies solely on the exact catalog match in `models`. + modelPatterns: [], + icon: ZaiIcon, + color: '#2D2D2D', + contextInformationAvailable: true, + capabilities: { + temperature: { min: 0, max: 1 }, + toolUsageControl: true, + }, + models: [ + { + id: 'glm-5.2', + pricing: { + input: 1.4, + output: 4.4, + updatedAt: '2026-07-10', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + toolUsageControl: true, + maxOutputTokens: 131072, + reasoningEffort: { + values: ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'], + }, + }, + contextWindow: 1000000, + releaseDate: '2026-06-13', + recommended: true, + }, + { + id: 'glm-5.1', + pricing: { + input: 1.4, + output: 4.4, + updatedAt: '2026-07-10', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + toolUsageControl: true, + maxOutputTokens: 131072, + thinking: { + levels: ['disabled', 'enabled'], + default: 'enabled', + }, + }, + contextWindow: 200000, + releaseDate: '2026-04-07', + }, + { + id: 'glm-5', + pricing: { + input: 1.0, + output: 3.2, + updatedAt: '2026-07-10', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + toolUsageControl: true, + maxOutputTokens: 131072, + thinking: { + levels: ['disabled', 'enabled'], + default: 'enabled', + }, + }, + contextWindow: 200000, + releaseDate: '2026-02-11', + }, + { + id: 'glm-5-turbo', + pricing: { + input: 1.2, + output: 4.0, + updatedAt: '2026-07-10', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + toolUsageControl: true, + maxOutputTokens: 131072, + thinking: { + levels: ['disabled', 'enabled'], + default: 'enabled', + }, + }, + contextWindow: 200000, + releaseDate: '2026-03-15', + }, + { + id: 'glm-4.7', + pricing: { + input: 0.6, + output: 2.2, + updatedAt: '2026-07-10', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + toolUsageControl: true, + maxOutputTokens: 131072, + thinking: { + levels: ['disabled', 'enabled'], + default: 'enabled', + }, + }, + contextWindow: 200000, + releaseDate: '2025-12-22', + }, + { + id: 'glm-4.7-flashx', + pricing: { + input: 0.07, + output: 0.4, + updatedAt: '2026-07-10', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + toolUsageControl: true, + maxOutputTokens: 131072, + }, + contextWindow: 200000, + releaseDate: '2025-12-22', + }, + { + id: 'glm-4.6', + pricing: { + input: 0.6, + output: 2.2, + updatedAt: '2026-07-10', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + toolUsageControl: true, + maxOutputTokens: 131072, + thinking: { + levels: ['disabled', 'enabled'], + default: 'enabled', + }, + }, + contextWindow: 200000, + releaseDate: '2025-09-30', + }, + { + id: 'glm-4.5', + pricing: { + input: 0.6, + output: 2.2, + updatedAt: '2026-07-10', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + toolUsageControl: true, + maxOutputTokens: 98304, + thinking: { + levels: ['disabled', 'enabled'], + default: 'enabled', + }, + }, + contextWindow: 128000, + releaseDate: '2025-07-28', + }, + { + id: 'glm-4.5-air', + pricing: { + input: 0.2, + output: 1.1, + updatedAt: '2026-07-10', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + toolUsageControl: true, + maxOutputTokens: 98304, + thinking: { + levels: ['disabled', 'enabled'], + default: 'enabled', + }, + }, + contextWindow: 128000, + releaseDate: '2025-07-28', + }, + { + id: 'glm-4.5-x', + pricing: { + input: 2.2, + output: 8.9, + updatedAt: '2026-07-10', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + toolUsageControl: true, + maxOutputTokens: 98304, + }, + contextWindow: 128000, + releaseDate: '2025-07-28', + }, + { + id: 'glm-4.5-airx', + pricing: { + input: 1.1, + output: 4.5, + updatedAt: '2026-07-10', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + toolUsageControl: true, + maxOutputTokens: 98304, + }, + contextWindow: 128000, + releaseDate: '2025-07-28', + }, + { + id: 'glm-4-32b-0414-128k', + pricing: { + input: 0.1, + output: 0.1, + updatedAt: '2026-07-10', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + toolUsageControl: true, + maxOutputTokens: 16384, + }, + contextWindow: 128000, + releaseDate: '2025-04-14', + }, + ], + }, mistral: { id: 'mistral', name: 'Mistral AI', @@ -3552,6 +3894,7 @@ export function getHostedModels(): string[] { ...getProviderModels('openai'), ...getProviderModels('anthropic'), ...getProviderModels('google'), + ...getProviderModels('zai'), ] } diff --git a/apps/sim/providers/nvidia/index.ts b/apps/sim/providers/nvidia/index.ts new file mode 100644 index 00000000000..0b147d29b10 --- /dev/null +++ b/apps/sim/providers/nvidia/index.ts @@ -0,0 +1,632 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import OpenAI from 'openai' +import type { StreamingExecution } from '@/executor/types' +import { MAX_TOOL_ITERATIONS } from '@/providers' +import { formatMessagesForProvider } from '@/providers/attachments' +import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createReadableStreamFromNvidiaStream } from '@/providers/nvidia/utils' +import { createStreamingExecution } from '@/providers/streaming-execution' +import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' +import type { + ProviderConfig, + ProviderRequest, + ProviderResponse, + TimeSegment, +} from '@/providers/types' +import { ProviderError } from '@/providers/types' +import { + calculateCost, + prepareToolExecution, + prepareToolsWithUsageControl, + sumToolCosts, + trackForcedToolUsage, +} from '@/providers/utils' +import { executeTool } from '@/tools' + +const logger = createLogger('NvidiaProvider') + +const NVIDIA_BASE_URL = 'https://integrate.api.nvidia.com/v1' + +export const nvidiaProvider: ProviderConfig = { + id: 'nvidia', + name: 'NVIDIA NIM', + description: "NVIDIA's Nemotron models via NIM's OpenAI-compatible API", + version: '1.0.0', + models: getProviderModels('nvidia'), + defaultModel: getProviderDefaultModel('nvidia'), + + executeRequest: async ( + request: ProviderRequest + ): Promise => { + if (!request.apiKey) { + throw new Error('API key is required for NVIDIA NIM') + } + + const providerStartTime = Date.now() + const providerStartTimeISO = new Date(providerStartTime).toISOString() + + try { + const nvidia = new OpenAI({ + apiKey: request.apiKey, + baseURL: NVIDIA_BASE_URL, + }) + + const allMessages = [] + + if (request.systemPrompt) { + allMessages.push({ + role: 'system', + content: request.systemPrompt, + }) + } + + if (request.context) { + allMessages.push({ + role: 'user', + content: request.context, + }) + } + + if (request.messages) { + allMessages.push(...request.messages) + } + const formattedMessages = formatMessagesForProvider(allMessages, 'nvidia') + + const tools = request.tools?.length + ? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool)) + : undefined + + const payload: any = { + model: request.model, + messages: formattedMessages, + } + + if (request.temperature !== undefined) payload.temperature = request.temperature + if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens + + const responseFormatPayload = request.responseFormat + ? { + type: 'json_schema' as const, + json_schema: { + name: request.responseFormat.name || 'response_schema', + schema: request.responseFormat.schema || request.responseFormat, + strict: request.responseFormat.strict !== false, + }, + } + : undefined + + let preparedTools: ReturnType | null = null + let hasActiveTools = false + + if (tools?.length) { + preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'openai') + const { tools: filteredTools, toolChoice } = preparedTools + + if (filteredTools?.length && toolChoice) { + payload.tools = filteredTools + payload.tool_choice = toolChoice + hasActiveTools = true + + logger.info('NVIDIA NIM request configuration:', { + toolCount: filteredTools.length, + toolChoice: + typeof toolChoice === 'string' + ? toolChoice + : toolChoice.type === 'function' + ? `force:${toolChoice.function.name}` + : 'unknown', + model: request.model, + }) + } + } + + // Structured output and tool calling cannot be sent together — OpenAI-compatible + // backends reject a request that carries both `response_format` and active + // `tools`/`tool_choice`. Defer the schema until after the tool loop completes. + const deferResponseFormat = !!responseFormatPayload && hasActiveTools + if (responseFormatPayload && !deferResponseFormat) { + payload.response_format = responseFormatPayload + } + + if (request.stream && (!tools || tools.length === 0 || !hasActiveTools)) { + logger.info('Using streaming response for NVIDIA NIM request (no tools)') + + const streamResponse = await nvidia.chat.completions.create( + { + ...payload, + stream: true, + stream_options: { include_usage: true }, + }, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: request.model }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + isStreaming: true, + createStream: ({ output }) => + createReadableStreamFromNvidiaStream(streamResponse as any, (content, usage) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } + + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } + }), + }) + + return streamingResult + } + + const initialCallTime = Date.now() + const originalToolChoice = payload.tool_choice + const forcedTools = preparedTools?.forcedTools || [] + let usedForcedTools: string[] = [] + + let currentResponse = await nvidia.chat.completions.create( + payload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const firstResponseTime = Date.now() - initialCallTime + + let content = currentResponse.choices[0]?.message?.content || '' + + const tokens = { + input: currentResponse.usage?.prompt_tokens || 0, + output: currentResponse.usage?.completion_tokens || 0, + total: currentResponse.usage?.total_tokens || 0, + } + const toolCalls = [] + const toolResults: Record[] = [] + const currentMessages = [...formattedMessages] + let iterationCount = 0 + let hasUsedForcedTool = false + let modelTime = firstResponseTime + let toolsTime = 0 + + const timeSegments: TimeSegment[] = [ + { + type: 'model', + name: request.model, + startTime: initialCallTime, + endTime: initialCallTime + firstResponseTime, + duration: firstResponseTime, + }, + ] + + if ( + typeof originalToolChoice === 'object' && + currentResponse.choices[0]?.message?.tool_calls + ) { + const toolCallsResponse = currentResponse.choices[0].message.tool_calls + const result = trackForcedToolUsage( + toolCallsResponse, + originalToolChoice, + logger, + 'openai', + forcedTools, + usedForcedTools + ) + hasUsedForcedTool = result.hasUsedForcedTool + usedForcedTools = result.usedForcedTools + } + + try { + while (iterationCount < MAX_TOOL_ITERATIONS) { + if (currentResponse.choices[0]?.message?.content) { + content = currentResponse.choices[0].message.content + } + + const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + toolCallsInResponse, + { model: request.model, provider: 'nvidia' } + ) + + if (!toolCallsInResponse || toolCallsInResponse.length === 0) { + break + } + + const toolsStartTime = Date.now() + + const toolExecutionPromises = toolCallsInResponse.map(async (toolCall) => { + const toolCallStartTime = Date.now() + const toolName = toolCall.function.name + + try { + const toolArgs = JSON.parse(toolCall.function.arguments) + const tool = request.tools?.find((t) => t.id === toolName) + + // Every tool_call in the assistant message must be answered by a matching + // `tool` message, or the next request violates the OpenAI message contract. + // Emit an error result for an unknown tool rather than dropping it. + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } + + const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) + const result = await executeTool(toolName, executionParams, { + signal: request.abortSignal, + }) + const toolCallEndTime = Date.now() + + return { + toolCall, + toolName, + toolParams, + result, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } catch (error) { + const toolCallEndTime = Date.now() + logger.error('Error processing tool call:', { error, toolName }) + + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: getErrorMessage(error, 'Tool execution failed'), + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } + }) + + const executionResults = await Promise.allSettled(toolExecutionPromises) + + currentMessages.push({ + role: 'assistant', + content: null, + tool_calls: toolCallsInResponse.map((tc) => ({ + id: tc.id, + type: 'function', + function: { + name: tc.function.name, + arguments: tc.function.arguments, + }, + })), + }) + + for (const settledResult of executionResults) { + if (settledResult.status === 'rejected' || !settledResult.value) continue + + const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = + settledResult.value + + timeSegments.push({ + type: 'tool', + name: toolName, + startTime: startTime, + endTime: endTime, + duration: duration, + toolCallId: toolCall.id, + }) + + let resultContent: any + if (result.success && result.output) { + toolResults.push(result.output) + resultContent = result.output + } else { + resultContent = { + error: true, + message: result.error || 'Tool execution failed', + tool: toolName, + } + } + + toolCalls.push({ + name: toolName, + arguments: toolParams, + startTime: new Date(startTime).toISOString(), + endTime: new Date(endTime).toISOString(), + duration: duration, + result: resultContent, + success: result.success, + }) + + currentMessages.push({ + role: 'tool', + tool_call_id: toolCall.id, + content: JSON.stringify(resultContent), + }) + } + + const thisToolsTime = Date.now() - toolsStartTime + toolsTime += thisToolsTime + + const nextPayload = { + ...payload, + messages: currentMessages, + } + + if ( + typeof originalToolChoice === 'object' && + hasUsedForcedTool && + forcedTools.length > 0 + ) { + const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) + + if (remainingTools.length > 0) { + nextPayload.tool_choice = { + type: 'function', + function: { name: remainingTools[0] }, + } + logger.info(`Forcing next tool: ${remainingTools[0]}`) + } else { + nextPayload.tool_choice = 'auto' + logger.info('All forced tools have been used, switching to auto tool_choice') + } + } + + const nextModelStartTime = Date.now() + currentResponse = await nvidia.chat.completions.create( + nextPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + + if ( + typeof nextPayload.tool_choice === 'object' && + currentResponse.choices[0]?.message?.tool_calls + ) { + const toolCallsResponse = currentResponse.choices[0].message.tool_calls + const result = trackForcedToolUsage( + toolCallsResponse, + nextPayload.tool_choice, + logger, + 'openai', + forcedTools, + usedForcedTools + ) + hasUsedForcedTool = result.hasUsedForcedTool + usedForcedTools = result.usedForcedTools + } + + const nextModelEndTime = Date.now() + const thisModelTime = nextModelEndTime - nextModelStartTime + + timeSegments.push({ + type: 'model', + name: request.model, + startTime: nextModelStartTime, + endTime: nextModelEndTime, + duration: thisModelTime, + }) + + modelTime += thisModelTime + + if (currentResponse.choices[0]?.message?.content) { + content = currentResponse.choices[0].message.content + } + + if (currentResponse.usage) { + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 + tokens.total += currentResponse.usage.total_tokens || 0 + } + + iterationCount++ + } + + if (iterationCount === MAX_TOOL_ITERATIONS) { + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'nvidia' } + ) + } + } catch (error) { + logger.error('Error in NVIDIA NIM request:', { error }) + throw error + } + + if (request.stream) { + logger.info('Using streaming for final NVIDIA NIM response after tool processing') + + // The tool loop is complete: this final pass only produces the textual answer. + // Force `tool_choice: 'none'` so the model cannot emit fresh tool calls that the + // text-only stream adapter would silently drop. + const streamingPayload: any = { + ...payload, + messages: currentMessages, + tool_choice: 'none', + stream: true, + stream_options: { include_usage: true }, + } + if (deferResponseFormat && responseFormatPayload) { + streamingPayload.response_format = responseFormatPayload + streamingPayload.parallel_tool_calls = false + } + + const streamResponse = await nvidia.chat.completions.create( + streamingPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: iterationCount + 1, + timeSegments, + }, + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: undefined as number | undefined, + total: accumulatedCost.total, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + isStreaming: true, + createStream: ({ output }) => + createReadableStreamFromNvidiaStream(streamResponse as any, (content, usage) => { + output.content = content + output.tokens = { + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, + total: tokens.total + usage.total_tokens, + } + + const streamCost = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + const tc = sumToolCosts(toolResults) + output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } + }), + }) + + return streamingResult + } + + // Tools were active, so `response_format` was withheld from the loop. Make one final + // tool-free call to obtain the structured response now that the tool work is done. + if (deferResponseFormat && responseFormatPayload) { + logger.info('Applying deferred JSON schema response format after tool processing') + + const finalFormatStartTime = Date.now() + const finalPayload: any = { + ...payload, + messages: currentMessages, + response_format: responseFormatPayload, + tool_choice: 'none', + parallel_tool_calls: false, + } + + currentResponse = await nvidia.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + + const finalFormatEndTime = Date.now() + timeSegments.push({ + type: 'model', + name: request.model, + startTime: finalFormatStartTime, + endTime: finalFormatEndTime, + duration: finalFormatEndTime - finalFormatStartTime, + }) + modelTime += finalFormatEndTime - finalFormatStartTime + + const formattedContent = currentResponse.choices[0]?.message?.content + if (formattedContent) { + content = formattedContent + } + + if (currentResponse.usage) { + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 + tokens.total += currentResponse.usage.total_tokens || 0 + } + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'nvidia' } + ) + } + + const providerEndTime = Date.now() + const providerEndTimeISO = new Date(providerEndTime).toISOString() + const totalDuration = providerEndTime - providerStartTime + + return { + content, + model: request.model, + tokens, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + toolResults: toolResults.length > 0 ? toolResults : undefined, + timing: { + startTime: providerStartTimeISO, + endTime: providerEndTimeISO, + duration: totalDuration, + modelTime: modelTime, + toolsTime: toolsTime, + firstResponseTime: firstResponseTime, + iterations: iterationCount + 1, + timeSegments: timeSegments, + }, + } + } catch (error) { + const providerEndTime = Date.now() + const providerEndTimeISO = new Date(providerEndTime).toISOString() + const totalDuration = providerEndTime - providerStartTime + + logger.error('Error in NVIDIA NIM request:', { + error, + duration: totalDuration, + }) + + throw new ProviderError(toError(error).message, { + startTime: providerStartTimeISO, + endTime: providerEndTimeISO, + duration: totalDuration, + }) + } + }, +} diff --git a/apps/sim/providers/nvidia/utils.ts b/apps/sim/providers/nvidia/utils.ts new file mode 100644 index 00000000000..ef9c37b3a50 --- /dev/null +++ b/apps/sim/providers/nvidia/utils.ts @@ -0,0 +1,14 @@ +import type { ChatCompletionChunk } from 'openai/resources/chat/completions' +import type { CompletionUsage } from 'openai/resources/completions' +import { createOpenAICompatibleStream } from '@/providers/utils' + +/** + * Creates a ReadableStream from an NVIDIA NIM streaming response. + * Uses the shared OpenAI-compatible streaming utility. + */ +export function createReadableStreamFromNvidiaStream( + nvidiaStream: AsyncIterable, + onComplete?: (content: string, usage: CompletionUsage) => void +): ReadableStream { + return createOpenAICompatibleStream(nvidiaStream, 'NVIDIA', onComplete) +} diff --git a/apps/sim/providers/registry.ts b/apps/sim/providers/registry.ts index a221966914c..9e98d02d134 100644 --- a/apps/sim/providers/registry.ts +++ b/apps/sim/providers/registry.ts @@ -13,6 +13,7 @@ import { groqProvider } from '@/providers/groq' import { litellmProvider } from '@/providers/litellm' import { metaProvider } from '@/providers/meta' import { mistralProvider } from '@/providers/mistral' +import { nvidiaProvider } from '@/providers/nvidia' import { ollamaProvider } from '@/providers/ollama' import { ollamaCloudProvider } from '@/providers/ollama-cloud' import { openaiProvider } from '@/providers/openai' @@ -23,6 +24,7 @@ import type { ProviderConfig, ProviderId } from '@/providers/types' import { vertexProvider } from '@/providers/vertex' import { vllmProvider } from '@/providers/vllm' import { xAIProvider } from '@/providers/xai' +import { zaiProvider } from '@/providers/zai' const logger = createLogger('ProviderRegistry') @@ -37,7 +39,9 @@ const providerRegistry: Record = { cerebras: cerebrasProvider, groq: groqProvider, sakana: sakanaProvider, + nvidia: nvidiaProvider, meta: metaProvider, + zai: zaiProvider, vllm: vllmProvider, litellm: litellmProvider, mistral: mistralProvider, diff --git a/apps/sim/providers/types.ts b/apps/sim/providers/types.ts index 2cb08a6f6c6..01752a9c1b8 100644 --- a/apps/sim/providers/types.ts +++ b/apps/sim/providers/types.ts @@ -12,7 +12,9 @@ export type ProviderId = | 'cerebras' | 'groq' | 'sakana' + | 'nvidia' | 'meta' + | 'zai' | 'mistral' | 'ollama' | 'ollama-cloud' diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index b9848bc5d73..3b8bdd10c6a 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -155,7 +155,9 @@ export const providers: Record = { cerebras: buildProviderMetadata('cerebras'), groq: buildProviderMetadata('groq'), sakana: buildProviderMetadata('sakana'), + nvidia: buildProviderMetadata('nvidia'), meta: buildProviderMetadata('meta'), + zai: buildProviderMetadata('zai'), mistral: buildProviderMetadata('mistral'), bedrock: buildProviderMetadata('bedrock'), openrouter: buildProviderMetadata('openrouter'), @@ -900,8 +902,9 @@ export function getApiKey(provider: string, model: string, userProvidedKey?: str const isOpenAIModel = provider === 'openai' const isClaudeModel = provider === 'anthropic' const isGeminiModel = provider === 'google' + const isZaiModel = provider === 'zai' - if (isHosted && (isOpenAIModel || isClaudeModel || isGeminiModel)) { + if (isHosted && (isOpenAIModel || isClaudeModel || isGeminiModel || isZaiModel)) { const hostedModels = getHostedModels() const isModelHosted = hostedModels.some((m) => m.toLowerCase() === model.toLowerCase()) diff --git a/apps/sim/providers/zai/index.ts b/apps/sim/providers/zai/index.ts new file mode 100644 index 00000000000..d254546d6eb --- /dev/null +++ b/apps/sim/providers/zai/index.ts @@ -0,0 +1,649 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import OpenAI from 'openai' +import type { StreamingExecution } from '@/executor/types' +import { MAX_TOOL_ITERATIONS } from '@/providers' +import { formatMessagesForProvider } from '@/providers/attachments' +import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createStreamingExecution } from '@/providers/streaming-execution' +import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' +import type { + ProviderConfig, + ProviderRequest, + ProviderResponse, + TimeSegment, +} from '@/providers/types' +import { ProviderError } from '@/providers/types' +import { + calculateCost, + prepareToolExecution, + prepareToolsWithUsageControl, + sumToolCosts, + trackForcedToolUsage, +} from '@/providers/utils' +import { createReadableStreamFromZaiStream } from '@/providers/zai/utils' +import { executeTool } from '@/tools' + +const logger = createLogger('ZaiProvider') + +const ZAI_BASE_URL = 'https://api.z.ai/api/paas/v4' + +export const zaiProvider: ProviderConfig = { + id: 'zai', + name: 'Z.ai', + description: "Z.ai's GLM models via an OpenAI-compatible API", + version: '1.0.0', + models: getProviderModels('zai'), + defaultModel: getProviderDefaultModel('zai'), + + executeRequest: async ( + request: ProviderRequest + ): Promise => { + if (!request.apiKey) { + throw new Error('API key is required for Z.ai') + } + + const providerStartTime = Date.now() + const providerStartTimeISO = new Date(providerStartTime).toISOString() + + try { + const zai = new OpenAI({ + apiKey: request.apiKey, + baseURL: ZAI_BASE_URL, + }) + + const allMessages = [] + + if (request.systemPrompt) { + allMessages.push({ + role: 'system', + content: request.systemPrompt, + }) + } + + if (request.context) { + allMessages.push({ + role: 'user', + content: request.context, + }) + } + + if (request.messages) { + allMessages.push(...request.messages) + } + const formattedMessages = formatMessagesForProvider(allMessages, 'zai') + + const tools = request.tools?.length + ? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool)) + : undefined + + const payload: any = { + model: request.model, + messages: formattedMessages, + } + + if (request.temperature !== undefined) payload.temperature = request.temperature + if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens + + // GLM's `thinking` toggle (models where `capabilities.thinking.levels` is + // ['disabled', 'enabled']) maps directly to Z.ai's `thinking: { type }` request param. + if (request.thinkingLevel === 'enabled' || request.thinkingLevel === 'disabled') { + payload.thinking = { type: request.thinkingLevel } + } + + // GLM-5.2's `reasoningEffort` capability maps directly to Z.ai's `reasoning_effort` + // request param — the only model in this catalog that documents it. + if (request.reasoningEffort !== undefined && request.reasoningEffort !== 'auto') { + payload.reasoning_effort = request.reasoningEffort + } + + // Z.ai's chat-completions API supports `text` and `json_object` response formats but + // does not have confirmed `json_schema` support, unlike the shared OpenAI-compatible + // template — request a plain JSON-object hint instead of a strict schema. + const responseFormatPayload = request.responseFormat + ? ({ type: 'json_object' as const } as const) + : undefined + + let preparedTools: ReturnType | null = null + let hasActiveTools = false + + if (tools?.length) { + preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'openai') + const { tools: filteredTools, toolChoice } = preparedTools + + if (filteredTools?.length && toolChoice) { + payload.tools = filteredTools + // Z.ai's chat-completions API documents `tool_choice` support for `"auto"` only — + // forcing a specific function or disabling tool use via the parameter is rejected. + // Ignore any forced/none choice `prepareToolsWithUsageControl` may have produced. + payload.tool_choice = 'auto' + hasActiveTools = true + + if (preparedTools.forcedTools.length > 0) { + logger.warn( + "Z.ai does not support forcing a specific tool via tool_choice (API only accepts 'auto') — ignoring force setting and falling back to auto", + { forcedTools: preparedTools.forcedTools, model: request.model } + ) + } + + logger.info('Z.ai request configuration:', { + toolCount: filteredTools.length, + toolChoice: 'auto', + model: request.model, + }) + } + } + + // Structured output and tool calling cannot be sent together — OpenAI-compatible + // backends reject a request that carries both `response_format` and active + // `tools`/`tool_choice`. Defer the schema until after the tool loop completes. + const deferResponseFormat = !!responseFormatPayload && hasActiveTools + if (responseFormatPayload && !deferResponseFormat) { + payload.response_format = responseFormatPayload + } + + if (request.stream && (!tools || tools.length === 0 || !hasActiveTools)) { + logger.info('Using streaming response for Z.ai request (no tools)') + + const streamResponse = await zai.chat.completions.create( + { + ...payload, + stream: true, + stream_options: { include_usage: true }, + }, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: request.model }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + isStreaming: true, + createStream: ({ output }) => + createReadableStreamFromZaiStream(streamResponse as any, (content, usage) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } + + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } + }), + }) + + return streamingResult + } + + const initialCallTime = Date.now() + const originalToolChoice = payload.tool_choice + const forcedTools = preparedTools?.forcedTools || [] + let usedForcedTools: string[] = [] + + let currentResponse = await zai.chat.completions.create( + payload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const firstResponseTime = Date.now() - initialCallTime + + let content = currentResponse.choices[0]?.message?.content || '' + + const tokens = { + input: currentResponse.usage?.prompt_tokens || 0, + output: currentResponse.usage?.completion_tokens || 0, + total: currentResponse.usage?.total_tokens || 0, + } + const toolCalls = [] + const toolResults: Record[] = [] + const currentMessages = [...formattedMessages] + let iterationCount = 0 + let hasUsedForcedTool = false + let modelTime = firstResponseTime + let toolsTime = 0 + + const timeSegments: TimeSegment[] = [ + { + type: 'model', + name: request.model, + startTime: initialCallTime, + endTime: initialCallTime + firstResponseTime, + duration: firstResponseTime, + }, + ] + + if ( + typeof originalToolChoice === 'object' && + currentResponse.choices[0]?.message?.tool_calls + ) { + const toolCallsResponse = currentResponse.choices[0].message.tool_calls + const result = trackForcedToolUsage( + toolCallsResponse, + originalToolChoice, + logger, + 'openai', + forcedTools, + usedForcedTools + ) + hasUsedForcedTool = result.hasUsedForcedTool + usedForcedTools = result.usedForcedTools + } + + try { + while (iterationCount < MAX_TOOL_ITERATIONS) { + if (currentResponse.choices[0]?.message?.content) { + content = currentResponse.choices[0].message.content + } + + const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + toolCallsInResponse, + { model: request.model, provider: 'zai' } + ) + + if (!toolCallsInResponse || toolCallsInResponse.length === 0) { + break + } + + const toolsStartTime = Date.now() + + const toolExecutionPromises = toolCallsInResponse.map(async (toolCall) => { + const toolCallStartTime = Date.now() + const toolName = toolCall.function.name + + try { + const toolArgs = JSON.parse(toolCall.function.arguments) + const tool = request.tools?.find((t) => t.id === toolName) + + // Every tool_call in the assistant message must be answered by a matching + // `tool` message, or the next request violates the OpenAI message contract. + // Emit an error result for an unknown tool rather than dropping it. + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } + + const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) + const result = await executeTool(toolName, executionParams, { + signal: request.abortSignal, + }) + const toolCallEndTime = Date.now() + + return { + toolCall, + toolName, + toolParams, + result, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } catch (error) { + const toolCallEndTime = Date.now() + logger.error('Error processing tool call:', { error, toolName }) + + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: getErrorMessage(error, 'Tool execution failed'), + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } + }) + + const executionResults = await Promise.allSettled(toolExecutionPromises) + + currentMessages.push({ + role: 'assistant', + content: null, + tool_calls: toolCallsInResponse.map((tc) => ({ + id: tc.id, + type: 'function', + function: { + name: tc.function.name, + arguments: tc.function.arguments, + }, + })), + }) + + for (const settledResult of executionResults) { + if (settledResult.status === 'rejected' || !settledResult.value) continue + + const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = + settledResult.value + + timeSegments.push({ + type: 'tool', + name: toolName, + startTime: startTime, + endTime: endTime, + duration: duration, + toolCallId: toolCall.id, + }) + + let resultContent: any + if (result.success && result.output) { + toolResults.push(result.output) + resultContent = result.output + } else { + resultContent = { + error: true, + message: result.error || 'Tool execution failed', + tool: toolName, + } + } + + toolCalls.push({ + name: toolName, + arguments: toolParams, + startTime: new Date(startTime).toISOString(), + endTime: new Date(endTime).toISOString(), + duration: duration, + result: resultContent, + success: result.success, + }) + + currentMessages.push({ + role: 'tool', + tool_call_id: toolCall.id, + content: JSON.stringify(resultContent), + }) + } + + const thisToolsTime = Date.now() - toolsStartTime + toolsTime += thisToolsTime + + const nextPayload = { + ...payload, + messages: currentMessages, + } + + if ( + typeof originalToolChoice === 'object' && + hasUsedForcedTool && + forcedTools.length > 0 + ) { + const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) + + if (remainingTools.length > 0) { + nextPayload.tool_choice = { + type: 'function', + function: { name: remainingTools[0] }, + } + logger.info(`Forcing next tool: ${remainingTools[0]}`) + } else { + nextPayload.tool_choice = 'auto' + logger.info('All forced tools have been used, switching to auto tool_choice') + } + } + + const nextModelStartTime = Date.now() + currentResponse = await zai.chat.completions.create( + nextPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + + if ( + typeof nextPayload.tool_choice === 'object' && + currentResponse.choices[0]?.message?.tool_calls + ) { + const toolCallsResponse = currentResponse.choices[0].message.tool_calls + const result = trackForcedToolUsage( + toolCallsResponse, + nextPayload.tool_choice, + logger, + 'openai', + forcedTools, + usedForcedTools + ) + hasUsedForcedTool = result.hasUsedForcedTool + usedForcedTools = result.usedForcedTools + } + + const nextModelEndTime = Date.now() + const thisModelTime = nextModelEndTime - nextModelStartTime + + timeSegments.push({ + type: 'model', + name: request.model, + startTime: nextModelStartTime, + endTime: nextModelEndTime, + duration: thisModelTime, + }) + + modelTime += thisModelTime + + if (currentResponse.choices[0]?.message?.content) { + content = currentResponse.choices[0].message.content + } + + if (currentResponse.usage) { + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 + tokens.total += currentResponse.usage.total_tokens || 0 + } + + iterationCount++ + } + + if (iterationCount === MAX_TOOL_ITERATIONS) { + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'zai' } + ) + } + } catch (error) { + logger.error('Error in Z.ai request:', { error }) + throw error + } + + if (request.stream) { + logger.info('Using streaming for final Z.ai response after tool processing') + + // The tool loop is complete: this final pass only produces the textual answer. + // Z.ai only documents `tool_choice: 'auto'` support, so drop `tools`/`tool_choice` + // entirely rather than sending an unsupported `'none'` — with no tools declared, + // the model has nothing to call and the text-only stream adapter stays safe. + const streamingPayload: any = { + ...payload, + messages: currentMessages, + stream: true, + stream_options: { include_usage: true }, + } + streamingPayload.tools = undefined + streamingPayload.tool_choice = undefined + if (deferResponseFormat && responseFormatPayload) { + streamingPayload.response_format = responseFormatPayload + } + + const streamResponse = await zai.chat.completions.create( + streamingPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: iterationCount + 1, + timeSegments, + }, + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: undefined as number | undefined, + total: accumulatedCost.total, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + isStreaming: true, + createStream: ({ output }) => + createReadableStreamFromZaiStream(streamResponse as any, (content, usage) => { + output.content = content + output.tokens = { + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, + total: tokens.total + usage.total_tokens, + } + + const streamCost = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + const tc = sumToolCosts(toolResults) + output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } + }), + }) + + return streamingResult + } + + // Tools were active, so `response_format` was withheld from the loop. Make one final + // tool-free call to obtain the structured response now that the tool work is done. + if (deferResponseFormat && responseFormatPayload) { + logger.info('Applying deferred response_format after tool processing') + + const finalFormatStartTime = Date.now() + // Z.ai only documents `tool_choice: 'auto'` support, so drop `tools`/`tool_choice` + // rather than sending an unsupported `'none'` — with no tools declared, the model + // has nothing left to call and simply returns the formatted answer. + const finalPayload: any = { + ...payload, + messages: currentMessages, + response_format: responseFormatPayload, + } + finalPayload.tools = undefined + finalPayload.tool_choice = undefined + + currentResponse = await zai.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + + const finalFormatEndTime = Date.now() + timeSegments.push({ + type: 'model', + name: request.model, + startTime: finalFormatStartTime, + endTime: finalFormatEndTime, + duration: finalFormatEndTime - finalFormatStartTime, + }) + modelTime += finalFormatEndTime - finalFormatStartTime + + const formattedContent = currentResponse.choices[0]?.message?.content + if (formattedContent) { + content = formattedContent + } + + if (currentResponse.usage) { + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 + tokens.total += currentResponse.usage.total_tokens || 0 + } + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'zai' } + ) + } + + const providerEndTime = Date.now() + const providerEndTimeISO = new Date(providerEndTime).toISOString() + const totalDuration = providerEndTime - providerStartTime + + return { + content, + model: request.model, + tokens, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + toolResults: toolResults.length > 0 ? toolResults : undefined, + timing: { + startTime: providerStartTimeISO, + endTime: providerEndTimeISO, + duration: totalDuration, + modelTime: modelTime, + toolsTime: toolsTime, + firstResponseTime: firstResponseTime, + iterations: iterationCount + 1, + timeSegments: timeSegments, + }, + } + } catch (error) { + const providerEndTime = Date.now() + const providerEndTimeISO = new Date(providerEndTime).toISOString() + const totalDuration = providerEndTime - providerStartTime + + logger.error('Error in Z.ai request:', { + error, + duration: totalDuration, + }) + + throw new ProviderError(toError(error).message, { + startTime: providerStartTimeISO, + endTime: providerEndTimeISO, + duration: totalDuration, + }) + } + }, +} diff --git a/apps/sim/providers/zai/utils.ts b/apps/sim/providers/zai/utils.ts new file mode 100644 index 00000000000..4b86c6b7619 --- /dev/null +++ b/apps/sim/providers/zai/utils.ts @@ -0,0 +1,14 @@ +import type { ChatCompletionChunk } from 'openai/resources/chat/completions' +import type { CompletionUsage } from 'openai/resources/completions' +import { createOpenAICompatibleStream } from '@/providers/utils' + +/** + * Creates a ReadableStream from a Z.ai streaming response. + * Uses the shared OpenAI-compatible streaming utility. + */ +export function createReadableStreamFromZaiStream( + zaiStream: AsyncIterable, + onComplete?: (content: string, usage: CompletionUsage) => void +): ReadableStream { + return createOpenAICompatibleStream(zaiStream, 'Z.ai', onComplete) +} diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index dcce33cbe20..910e825f251 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -7,6 +7,7 @@ export type BYOKProviderId = | 'anthropic' | 'google' | 'mistral' + | 'zai' | 'fireworks' | 'together' | 'baseten' From 8bf942407ca823692e34803dbb472e796e39e641 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 10 Jul 2026 09:58:34 -0700 Subject: [PATCH 03/22] feat(models): add latest Groq and Cerebras models (#5561) * feat(models): add latest Groq and Cerebras models, flag near-term retirements - Groq: add qwen/qwen3.6-27b (Preview, $0.60/$3.00, 131k ctx) - live on console.groq.com/docs/models, not previously in the catalog - Groq: mark meta-llama/llama-4-scout-17b-16e-instruct and qwen/qwen3-32b deprecated - both have an announced shutdown date of July 17, 2026 per Groq's own deprecations page - Cerebras: add gemma-4-31b (Preview, $0.99/$1.49, 131k ctx/40k max output) - live on inference-docs.cerebras.ai, not previously in the catalog Every field independently verified via 2+ live sources (provider docs + pricing pages) before adding; no code changes needed since both providers use generic OpenAI-compatible completions with no per-model special-casing. * fix(models): add verified releaseDate for groq/qwen/qwen3.6-27b Greptile correctly caught that omitting releaseDate causes this model to sort last within the Groq section in the model picker (orderModelIdsByReleaseDate treats a missing date as Number.NEGATIVE_INFINITY) - misleading since it's actually the newest model in the lineup. Verified 2026-04-21 (Qwen's own upstream release date, matching this repo's existing convention of using the model creator's release date rather than a reseller-specific date) via llm-stats.com, cross-checked against two other independent sources. --- apps/sim/providers/models.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index 35dc90369a4..86912dda877 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -2211,6 +2211,20 @@ export const PROVIDER_DEFINITIONS: Record = { contextWindow: 131072, releaseDate: '2025-12-22', }, + { + id: 'cerebras/gemma-4-31b', + pricing: { + input: 0.99, + output: 1.49, + updatedAt: '2026-07-10', + }, + capabilities: { + temperature: { min: 0, max: 2 }, + maxOutputTokens: 40000, + }, + contextWindow: 131072, + releaseDate: '2026-04-02', + }, ], }, groq: { @@ -2282,6 +2296,20 @@ export const PROVIDER_DEFINITIONS: Record = { }, contextWindow: 131072, releaseDate: '2025-04-29', + deprecated: true, + }, + { + id: 'groq/qwen/qwen3.6-27b', + pricing: { + input: 0.6, + output: 3.0, + updatedAt: '2026-07-10', + }, + capabilities: { + maxOutputTokens: 32768, + }, + contextWindow: 131072, + releaseDate: '2026-04-21', }, { id: 'groq/llama-3.1-8b-instant', @@ -2322,6 +2350,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, contextWindow: 131072, releaseDate: '2025-04-05', + deprecated: true, }, { id: 'groq/moonshotai/kimi-k2-instruct-0905', From 08320d5ab0d9e6d1e65da54ce8ebd18f9831c73a Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 10 Jul 2026 10:55:34 -0700 Subject: [PATCH 04/22] fix(sidebar): fix rename input losing its selection on open (#5563) * fix(sidebar): fix rename input losing its selection on open Radix's FocusScope defers close-time focus teardown to a setTimeout(0), which can occasionally run after the rename input's own focus()/select() and clobber the selection. Focus the input from onCloseAutoFocus instead, which runs inside that same deferred teardown and always wins the race. Affects workflow, folder, and workspace rename (all route through the shared sidebar ContextMenu component). * fix(sidebar): only refocus the rename input when Rename triggered the close onCloseAutoFocus fires on every menu close, not just after selecting Rename. Gate the refocus behind a ref set only when the Rename item was selected, so closing the menu for an unrelated action (Delete, Duplicate, ...) while an earlier rename is still live doesn't steal focus back into it and delay its blur-save. --- .../components/context-menu/context-menu.tsx | 33 ++++++++++++++++++- .../components/folder-item/folder-item.tsx | 1 + .../workflow-item/workflow-item.tsx | 1 + .../workspace-header/workspace-header.tsx | 3 ++ 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx index ac6cd52bac0..13e4dc6ab16 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx @@ -1,5 +1,6 @@ 'use client' +import { useRef } from 'react' import { DropdownMenu, DropdownMenuContent, @@ -34,6 +35,18 @@ interface ContextMenuProps { onMarkAsUnread?: () => void onTogglePin?: () => void onRename?: () => void + /** + * Ref to the rename input rendered by the "Rename" action, if any. Radix's + * FocusScope defers its close-time focus teardown to a `setTimeout(0)`, which + * can run after the rename input's own mount-time `focus()`/`select()` and + * clobber the selection (the "rename deselects the text" bug). Focusing from + * `onCloseAutoFocus` runs synchronously inside that same deferred teardown, so + * it always wins the race regardless of scheduler timing. Only applied when + * this specific close was caused by selecting "Rename" (see + * `justSelectedRenameRef`) — an unrelated action closing the menu while an + * earlier rename is still live must not steal focus back into it. + */ + renameInputRef?: React.RefObject onCreate?: () => void onCreateFolder?: () => void onDuplicate?: () => void @@ -84,6 +97,7 @@ export function ContextMenu({ onMarkAsUnread, onTogglePin, onRename, + renameInputRef, onCreate, onCreateFolder, onDuplicate, @@ -132,6 +146,13 @@ export function ContextMenu({ (showUploadLogo && onUploadLogo) const hasCopySection = (showDuplicate && onDuplicate) || (showExport && onExport) + /** + * Only the "Rename" item should trigger the `onCloseAutoFocus` refocus below — + * an unrelated action (Delete, Duplicate, ...) closing this menu while a rename + * from an earlier interaction is still live must not steal focus back into it. + */ + const justSelectedRenameRef = useRef(false) + return ( !open && onClose()} modal={false}> @@ -152,7 +173,16 @@ export function ContextMenu({ side='bottom' sideOffset={4} className='max-h-[var(--radix-dropdown-menu-content-available-height,400px)]' - onCloseAutoFocus={(e) => e.preventDefault()} + onCloseAutoFocus={(e) => { + e.preventDefault() + const shouldFocusRenameInput = justSelectedRenameRef.current + justSelectedRenameRef.current = false + const input = shouldFocusRenameInput ? renameInputRef?.current : null + if (input) { + input.focus() + input.select() + } + }} > {showOpenInNewTab && onOpenInNewTab && ( { + justSelectedRenameRef.current = true onRename() onClose() }} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx index 377e562eadf..cc29b050118 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx @@ -575,6 +575,7 @@ export const FolderItem = memo(function FolderItem({ workspaceId, folder }: Fold menuRef={menuRef} onClose={closeMenu} onRename={handleStartEdit} + renameInputRef={inputRef} onCreate={handleCreateWorkflowInFolder} onCreateFolder={handleCreateFolderInFolder} onDuplicate={handleDuplicate} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx index d0c4b104b4e..fcc7d8a28b8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx @@ -487,6 +487,7 @@ export const WorkflowItem = memo(function WorkflowItem({ onClose={closeMenu} onOpenInNewTab={handleOpenInNewTab} onRename={handleStartEdit} + renameInputRef={inputRef} onDuplicate={handleDuplicate} onExport={handleExport} onDelete={handleOpenDeleteModal} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx index 711d96af8db..dcdfefbacb4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx @@ -113,6 +113,7 @@ function WorkspaceHeaderImpl({ const isContextMenuOpeningRef = useRef(false) const contextMenuClosedRef = useRef(true) const hasInputFocusedRef = useRef(false) + const renameInputRef = useRef(null) const [isMounted, setIsMounted] = useState(false) useEffect(() => { @@ -408,6 +409,7 @@ function WorkspaceHeaderImpl({ )} { + renameInputRef.current = el if (el && !hasInputFocusedRef.current) { hasInputFocusedRef.current = true el.focus() @@ -643,6 +645,7 @@ function WorkspaceHeaderImpl({ menuRef={contextMenuRef} onClose={closeContextMenu} onRename={handleRenameAction} + renameInputRef={renameInputRef} onDelete={handleDeleteAction} onLeave={handleLeaveAction} onUploadLogo={handleUploadLogoAction} From 4952ddb73fa022f1707c55683e861379d6a97ee4 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 10 Jul 2026 12:24:57 -0700 Subject: [PATCH 05/22] feat(landing): add HubSpot tracking script for hosted marketing site (#5565) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(landing): add HubSpot tracking script for hosted marketing site - Loads the HubSpot loader in the landing route group only, gated by isHosted - Not loaded for self-hosted/OSS deployments - Adds the loader's companion scripts (analytics, form-tracking, banner) and their beacon hosts to CSP, verified against the actual scripts' network calls * fix(landing): scope HubSpot CSP hosts to the landing route group only Greptile flagged that the HubSpot script/connect hosts were added to the shared CSP arrays used by every route, including /workspace, /login, and /signup — even though the HubSpot loader only ever renders inside the (landing) route group. - Move the HubSpot hosts out of STATIC_SCRIPT_SRC/STATIC_CONNECT_SRC - Add generateLandingRuntimeCSP(), which extends the shared runtime policy with the HubSpot hosts, mirroring the existing getChatEmbedCSPPolicy() pattern for route-scoped CSP variants - Wire it into proxy.ts's catch-all branch, which is what actually serves the marketing/landing site; /workspace, /login, /signup keep the unmodified shared policy * fix(landing): track HubSpot pageviews on client-side landing navigations Cursor Bugbot flagged that the HubSpot loader only auto-fires a pageview on the initial load. Since LandingLayout persists across client-side navigations between landing routes, subsequent Link navigations never told HubSpot about the route change, undercounting pageviews. Add HubspotPageViewTracker, a small client component using the standard Next.js App Router pattern (usePathname/useSearchParams in a Suspense boundary) to push a manual pageview through HubSpot's _hsq queue on every navigation after the first. * simplify(landing): drop unnecessary Suspense from HubSpot pageview tracker usePathname() alone doesn't require a Suspense boundary to preserve static rendering — only useSearchParams() does. The tracker only needs the path, not the query string, so drop useSearchParams and the Suspense wrapper entirely. Simpler, and no risk to the landing site's static rendering/LCP. * fix(landing): exclude non-landing routes from the landing CSP fallback Greptile's second pass caught that proxy.ts's catch-all branch (which serves generateLandingRuntimeCSP()) also handles several non-landing pages that fall through the earlier explicit branches: /verify, /sso, /reset-password (auth sub-pages), /resume/[workflowId] (interfaces), /f/[token] (file shares), /playground, and the authenticated/callbackUrl invite fallthrough. None of these render the HubSpot loader, so they shouldn't get its CSP allowance either. Add an explicit non-landing path prefix list and only fall back to generateRuntimeCSP() (the tight policy) for those, keeping generateLandingRuntimeCSP() for everything else in the catch-all. * fix(landing): add /unsubscribe to non-landing paths, fix tracker remount bug - Greptile correctly flagged /unsubscribe as another top-level page outside (landing) that reaches the CSP fallback branch; add it to the exclusion list - Cursor caught that the per-mount useRef in HubspotPageViewTracker resets whenever LandingLayout remounts (e.g. leaving the landing site and coming back), but next/script dedupes the loader by id and won't re-fire the auto-tracked pageview on remount — so that return visit was silently dropped. Move the flag to module scope so it reflects the actual once-per-browser-session lifetime of the loader script, not per-mount * fix(landing): track query-only landing navigations in HubSpot pageviews Cursor caught that the tracker only depended on usePathname(), so client-side navigations that change only the query string (blog/library pagination, careers filters) never fired a pageview at all, and setPath dropped the search string even when the path did change. Add useSearchParams() back (the officially documented Next.js pattern for tracking all route changes) and depend on the full path+query string. Wrap the tracker in a local Suspense boundary, as required to keep the route statically rendered — the fallback is null and the component renders nothing, so this has no LCP/visual cost. * test(proxy): add regression coverage for the non-landing path classifier Exports isNonLandingPath and covers the exact prefix-boundary cases (e.g. /f vs /ffoo, /resume vs /resumes) that the CSP routing fix depends on, so this logic is verified by CI rather than my own ad-hoc checks. * fix(landing): exclude /landing-preview from the landing CSP fallback Greptile caught that /landing-preview calls notFound() in production (see app/landing-preview/page.tsx) and its subroutes (marks-lab, readme-tour-capture) don't render the (landing) layout either, so none of them ever load the HubSpot tracker — but the CSP fallback was still classifying the whole prefix as landing. * chore(landing): remove the /landing-preview test scaffold It was a temporary route for visual iteration (404s in production) — not needed anymore, and Greptile had just flagged it as another route that falsely inherited the landing CSP allowance. Removing it outright is simpler than maintaining an exclusion for it. Also removes SandboxWorkspacePermissionsProvider, which existed solely to support the deleted readme-tour-capture page and has no other callers. * refactor(landing): fix invalid const assertion, trim comments - Fixed HUBSPOT_SCRIPT_SRC/HUBSPOT_CONNECT_SRC: 'as const' cannot wrap a ternary expression directly (TS1355) — caught by a full project typecheck I ran specifically to verify this PR, not by lint/tests alone. Rewrote using the same conditional-spread-inside-array-literal pattern already used by every other array in this file (e.g. STATIC_FRAME_SRC) - Trimmed comments across all four touched files down to only the non-obvious 'why' (module-scope tracking flag, HubSpot CSP scoping rationale), matching this codebase's terse comment style elsewhere * revert(landing): drop landing-scoped CSP, put HubSpot in the shared policy Cursor caught a fundamental problem with the landing-scoped CSP: the Content-Security-Policy header is fixed to the document's initial HTTP response and is NOT re-applied on Next.js client-side (soft) navigation. Both the landing navbar's ChipLink to /login and AuthShell's Link back to / are soft navigations (confirmed directly in the source, not assumed). That means: - /login -> / (soft nav): the browser keeps /login's CSP, which never allowed HubSpot hosts, so the loader gets silently blocked on landing. - / -> /login (soft nav): the browser keeps the landing CSP, which is MORE permissive than /login's, undoing the tightening entirely. A per-route CSP is fundamentally incompatible with this app's client-side-routed navigation. Greptile's original 'CSP too broad on /workspace' concern was valid in isolation, but the fix built across the last several rounds doesn't actually work — it's neither reliably tighter nor reliably functional, and each round's patch (exclusion list entries, /landing-preview handling) was really just papering over that core issue. Revert to a single shared CSP for the whole app, matching exactly how GTM/GA/ahrefs are already handled in this same file: HubSpot hosts land in STATIC_SCRIPT_SRC/STATIC_CONNECT_SRC under the existing isHosted gate. /workspace's CSP header technically allows origins it never requests (same accepted tradeoff as GTM/GA), but the tracking script only ever renders in the (landing) layout — matching the CSP scope Greptile originally objected to. This is now provably correct because it can't desync: proxy.ts, csp.ts, and proxy.test.ts are byte-for-byte identical to origin/staging except for the HubSpot host list itself. * fix(csp): drop overbroad *.hubspot.com connect-src wildcard Greptile correctly flagged that *.hubspot.com is far broader than anything the tracker actually needs — it covers HubSpot's entire product surface (app, api, marketing), not just the tracking endpoints. Re-checked my own network trace from earlier: the pageview beacon itself is an image pixel (new Image() to track.hubspot.com/__pto.gif), which is governed by img-src (already wide open to any https: origin), not connect-src. The *.hubspot.com entry was an unverified guess for the forms-API/banner fetch calls I couldn't pin down through minification — removing it since I can't confirm what it was actually protecting, keeping only the verified *.hscollectedforms.net entry. --- .../(landing)/hubspot-page-view-tracker.tsx | 38 + apps/sim/app/(landing)/layout.tsx | 21 +- .../landing-preview/marks-lab/marks-lab.tsx | 966 ------------------ .../app/landing-preview/marks-lab/page.tsx | 10 - apps/sim/app/landing-preview/page.tsx | 22 - .../[workspaceId]/page.tsx | 670 ------------ .../workspace-permissions-provider.tsx | 33 - apps/sim/lib/core/security/csp.ts | 10 + 8 files changed, 68 insertions(+), 1702 deletions(-) create mode 100644 apps/sim/app/(landing)/hubspot-page-view-tracker.tsx delete mode 100644 apps/sim/app/landing-preview/marks-lab/marks-lab.tsx delete mode 100644 apps/sim/app/landing-preview/marks-lab/page.tsx delete mode 100644 apps/sim/app/landing-preview/page.tsx delete mode 100644 apps/sim/app/landing-preview/readme-tour-capture/[workspaceId]/page.tsx diff --git a/apps/sim/app/(landing)/hubspot-page-view-tracker.tsx b/apps/sim/app/(landing)/hubspot-page-view-tracker.tsx new file mode 100644 index 00000000000..2fbb35cf526 --- /dev/null +++ b/apps/sim/app/(landing)/hubspot-page-view-tracker.tsx @@ -0,0 +1,38 @@ +'use client' + +import { useEffect } from 'react' +import { usePathname, useSearchParams } from 'next/navigation' + +declare global { + interface Window { + _hsq?: unknown[][] + } +} + +// next/script dedupes by id and never reloads on remount, so this must be +// module-scope (not a ref) to survive LandingLayout unmounting/remounting. +let hasTrackedInitialPageView = false + +/** + * The HubSpot loader only auto-tracks the first page load; LandingLayout + * persists across client-side navigations, so HubSpot never sees the rest. + * Pushes a manual pageview through `_hsq` on every navigation after the first. + */ +export function HubspotPageViewTracker() { + const pathname = usePathname() + const searchParams = useSearchParams() + const query = searchParams.toString() + + useEffect(() => { + if (!hasTrackedInitialPageView) { + hasTrackedInitialPageView = true + return + } + + window._hsq = window._hsq || [] + window._hsq.push(['setPath', query ? `${pathname}?${query}` : pathname]) + window._hsq.push(['trackPageView']) + }, [pathname, query]) + + return null +} diff --git a/apps/sim/app/(landing)/layout.tsx b/apps/sim/app/(landing)/layout.tsx index 7e320fad1b8..740deccce9f 100644 --- a/apps/sim/app/(landing)/layout.tsx +++ b/apps/sim/app/(landing)/layout.tsx @@ -1,7 +1,13 @@ import type { ReactNode } from 'react' +import { Suspense } from 'react' import type { Metadata } from 'next' +import Script from 'next/script' +import { isHosted } from '@/lib/core/config/env-flags' import { SITE_URL } from '@/lib/core/utils/urls' import { LandingShell } from '@/app/(landing)/components' +import { HubspotPageViewTracker } from '@/app/(landing)/hubspot-page-view-tracker' + +const HUBSPOT_SCRIPT_SRC = 'https://js-na2.hs-scripts.com/246720681.js' as const /** * Route-group layout for the entire landing family - the home page, platform and @@ -24,5 +30,18 @@ export const metadata: Metadata = { } export default function LandingLayout({ children }: { children: ReactNode }) { - return {children} + return ( + + {children} + {/* HubSpot tracking — hosted only */} + {isHosted && ( + <> +