From 183175bec08d55f5c37f0574e39bb1e6d3e1838a Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 14 Sep 2026 19:18:46 -0700 Subject: [PATCH 01/11] feat(ui): add shared media lightbox with zoom controls (#7840) * feat(ui): add shared media lightbox with zoom controls * fix(ui): share file viewer trackpad zoom with lightbox --- apps/docs/components/ui/action-media.tsx | 82 +++---- apps/docs/components/ui/image.tsx | 42 ++-- apps/docs/components/ui/lightbox.tsx | 104 --------- apps/docs/components/ui/video.tsx | 45 ++-- .../content-image/content-image.tsx | 49 ++--- apps/sim/app/(landing)/components/index.ts | 1 - .../(landing)/components/lightbox/index.ts | 1 - .../components/lightbox/lightbox.tsx | 65 ------ .../components/file-viewer/docx-preview.tsx | 3 +- .../components/file-viewer/pdf-viewer.tsx | 2 +- .../file-viewer/pptx-sandbox-host.tsx | 2 +- .../use-horizontal-wheel-scroll.ts | 2 +- .../file-viewer/zoomable-preview.tsx | 3 +- .../chat-message-attachments.tsx | 14 +- .../attached-files-list.tsx | 132 ++++++------ packages/emcn/src/components/index.ts | 1 + .../src/components/lightbox/lightbox.test.tsx | 142 +++++++++++++ .../emcn/src/components/lightbox/lightbox.tsx | 201 ++++++++++++++++++ packages/emcn/src/components/modal/modal.tsx | 4 + packages/emcn/src/index.ts | 1 + .../emcn/src/lib}/preview-wheel-zoom.test.ts | 3 +- .../emcn/src/lib}/preview-wheel-zoom.ts | 0 22 files changed, 514 insertions(+), 385 deletions(-) delete mode 100644 apps/docs/components/ui/lightbox.tsx delete mode 100644 apps/sim/app/(landing)/components/lightbox/index.ts delete mode 100644 apps/sim/app/(landing)/components/lightbox/lightbox.tsx create mode 100644 packages/emcn/src/components/lightbox/lightbox.test.tsx create mode 100644 packages/emcn/src/components/lightbox/lightbox.tsx rename {apps/sim/app/workspace/[workspaceId]/files/components/file-viewer => packages/emcn/src/lib}/preview-wheel-zoom.test.ts (97%) rename {apps/sim/app/workspace/[workspaceId]/files/components/file-viewer => packages/emcn/src/lib}/preview-wheel-zoom.ts (100%) diff --git a/apps/docs/components/ui/action-media.tsx b/apps/docs/components/ui/action-media.tsx index 32ce557eae6..79d47f5477a 100644 --- a/apps/docs/components/ui/action-media.tsx +++ b/apps/docs/components/ui/action-media.tsx @@ -1,8 +1,8 @@ 'use client' import { useRef, useState } from 'react' +import { Lightbox } from '@sim/emcn' import { cn, getAssetUrl } from '@/lib/utils' -import { Lightbox } from './lightbox' interface ActionImageProps { src: string @@ -17,10 +17,6 @@ interface ActionVideoProps { } export function ActionImage({ src, alt, enableLightbox = true }: ActionImageProps) { - const [isLightboxOpen, setIsLightboxOpen] = useState(false) - - const openLightbox = () => setIsLightboxOpen(true) - const image = ( ) + if (!enableLightbox) return image + return ( - <> - {enableLightbox ? ( - - ) : ( - image - )} - {enableLightbox && ( - setIsLightboxOpen(false)} - src={src} - alt={alt} - type='image' - /> - )} - + + + ) } export function ActionVideo({ src, alt, enableLightbox = true }: ActionVideoProps) { const videoRef = useRef(null) - const startTimeRef = useRef(0) - const [isLightboxOpen, setIsLightboxOpen] = useState(false) + const [startTime, setStartTime] = useState(0) const resolvedSrc = getAssetUrl(src) const openLightbox = () => { - startTimeRef.current = videoRef.current?.currentTime ?? 0 - setIsLightboxOpen(true) + setStartTime(videoRef.current?.currentTime ?? 0) } const video = ( @@ -85,30 +67,18 @@ export function ActionVideo({ src, alt, enableLightbox = true }: ActionVideoProp /> ) + if (!enableLightbox) return video + return ( - <> - {enableLightbox ? ( - - ) : ( - video - )} - {enableLightbox && ( - setIsLightboxOpen(false)} - src={src} - alt={alt} - type='video' - startTime={startTimeRef.current} - /> - )} - + + + ) } diff --git a/apps/docs/components/ui/image.tsx b/apps/docs/components/ui/image.tsx index ce966c50fef..93bd3a30ac1 100644 --- a/apps/docs/components/ui/image.tsx +++ b/apps/docs/components/ui/image.tsx @@ -1,8 +1,7 @@ 'use client' -import { useState } from 'react' +import { Lightbox } from '@sim/emcn' import NextImage, { type ImageProps as NextImageProps } from 'next/image' -import { Lightbox } from '@/components/ui/lightbox' import { cn } from '@/lib/utils' interface ImageProps extends Omit { @@ -17,9 +16,7 @@ export function Image({ src, ...props }: ImageProps) { - const [isLightboxOpen, setIsLightboxOpen] = useState(false) - - const openLightbox = () => setIsLightboxOpen(true) + const lightboxSrc = typeof src === 'string' ? src : 'default' in src ? src.default.src : src.src const image = ( ) - return ( - <> - {enableLightbox ? ( - - ) : ( - image - )} + if (!enableLightbox) return image - {enableLightbox && ( - setIsLightboxOpen(false)} - src={typeof src === 'string' ? src : String(src)} - alt={alt} - type='image' - /> - )} - + return ( + + + ) } diff --git a/apps/docs/components/ui/lightbox.tsx b/apps/docs/components/ui/lightbox.tsx deleted file mode 100644 index d227f389d5b..00000000000 --- a/apps/docs/components/ui/lightbox.tsx +++ /dev/null @@ -1,104 +0,0 @@ -'use client' - -import { useEffect, useEffectEvent, useLayoutEffect, useRef } from 'react' -import { getAssetUrl } from '@/lib/utils' - -interface LightboxProps { - isOpen: boolean - onClose: () => void - src: string - alt: string - type: 'image' | 'video' - startTime?: number -} - -export function Lightbox({ isOpen, onClose, src, alt, type, startTime }: LightboxProps) { - const overlayRef = useRef(null) - const mediaButtonRef = useRef(null) - const videoRef = useRef(null) - const previouslyFocusedRef = useRef(null) - const closeLightbox = useEffectEvent(onClose) - - useEffect(() => { - if (!isOpen) return - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - closeLightbox() - return - } - if (event.key === 'Tab') { - event.preventDefault() - mediaButtonRef.current?.focus() - } - } - - const handleClickOutside = (event: MouseEvent) => { - if (overlayRef.current && event.target === overlayRef.current) { - closeLightbox() - } - } - - const previousOverflow = document.body.style.overflow - - previouslyFocusedRef.current = document.activeElement as HTMLElement | null - document.addEventListener('keydown', handleKeyDown) - document.addEventListener('click', handleClickOutside) - document.body.style.overflow = 'hidden' - mediaButtonRef.current?.focus() - - return () => { - document.removeEventListener('keydown', handleKeyDown) - document.removeEventListener('click', handleClickOutside) - document.body.style.overflow = previousOverflow - previouslyFocusedRef.current?.focus() - } - }, [isOpen]) - - useLayoutEffect(() => { - if (isOpen && type === 'video' && videoRef.current && startTime != null && startTime > 0) { - videoRef.current.currentTime = startTime - } - }, [isOpen, startTime, type]) - - if (!isOpen) return null - - return ( -
-
- -
-
- ) -} diff --git a/apps/docs/components/ui/video.tsx b/apps/docs/components/ui/video.tsx index f3b1f4f9e0b..10ba67ab168 100644 --- a/apps/docs/components/ui/video.tsx +++ b/apps/docs/components/ui/video.tsx @@ -1,8 +1,8 @@ 'use client' import { useEffect, useRef, useState } from 'react' +import { Lightbox } from '@sim/emcn' import { cn, getAssetUrl } from '@/lib/utils' -import { Lightbox } from './lightbox' interface VideoProps { src: string @@ -28,8 +28,7 @@ export function Video({ height, }: VideoProps) { const videoRef = useRef(null) - const startTimeRef = useRef(0) - const [isLightboxOpen, setIsLightboxOpen] = useState(false) + const [startTime, setStartTime] = useState(0) const [isInView, setIsInView] = useState(false) useEffect(() => { @@ -55,8 +54,7 @@ export function Video({ }, []) const openLightbox = () => { - startTimeRef.current = videoRef.current?.currentTime ?? 0 - setIsLightboxOpen(true) + setStartTime(videoRef.current?.currentTime ?? 0) } const video = ( @@ -78,31 +76,18 @@ export function Video({ /> ) - return ( - <> - {enableLightbox ? ( - - ) : ( - video - )} + if (!enableLightbox) return video - {enableLightbox && ( - setIsLightboxOpen(false)} - src={src} - alt={`Video: ${src}`} - type='video' - startTime={startTimeRef.current} - /> - )} - + return ( + + + ) } diff --git a/apps/sim/app/(landing)/components/content-image/content-image.tsx b/apps/sim/app/(landing)/components/content-image/content-image.tsx index 001430c7a4a..c14d508f9c8 100644 --- a/apps/sim/app/(landing)/components/content-image/content-image.tsx +++ b/apps/sim/app/(landing)/components/content-image/content-image.tsx @@ -1,9 +1,7 @@ 'use client' -import { useState } from 'react' -import { cn } from '@sim/emcn' +import { cn, Lightbox } from '@sim/emcn' import NextImage from 'next/image' -import { Lightbox } from '@/app/(landing)/components/lightbox' interface ContentImageProps { src: string @@ -24,30 +22,27 @@ export function ContentImage({ height = 450, className, }: ContentImageProps) { - const [isLightboxOpen, setIsLightboxOpen] = useState(false) - return ( - <> - setIsLightboxOpen(true)} - /> - setIsLightboxOpen(false)} - src={src} - alt={alt} - /> - + + + ) } diff --git a/apps/sim/app/(landing)/components/index.ts b/apps/sim/app/(landing)/components/index.ts index 657a1db4c07..d0ee91b5b1d 100644 --- a/apps/sim/app/(landing)/components/index.ts +++ b/apps/sim/app/(landing)/components/index.ts @@ -15,7 +15,6 @@ export { HomeStructuredData } from './home-structured-data' export type { JsonLdData } from './json-ld' export { JsonLd } from './json-ld' export { LandingShell } from './landing-shell' -export { Lightbox } from './lightbox' export { LogoShell } from './logo-shell' export { Navbar } from './navbar' export { PlatformHeroVisual } from './platform-hero-visual' diff --git a/apps/sim/app/(landing)/components/lightbox/index.ts b/apps/sim/app/(landing)/components/lightbox/index.ts deleted file mode 100644 index 1055bfa3ff7..00000000000 --- a/apps/sim/app/(landing)/components/lightbox/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Lightbox } from './lightbox' diff --git a/apps/sim/app/(landing)/components/lightbox/lightbox.tsx b/apps/sim/app/(landing)/components/lightbox/lightbox.tsx deleted file mode 100644 index 7afd596cd72..00000000000 --- a/apps/sim/app/(landing)/components/lightbox/lightbox.tsx +++ /dev/null @@ -1,65 +0,0 @@ -'use client' - -import { useEffect, useEffectEvent, useRef } from 'react' - -interface LightboxProps { - isOpen: boolean - onClose: () => void - src: string - alt: string -} - -export function Lightbox({ isOpen, onClose, src, alt }: LightboxProps) { - const overlayRef = useRef(null) - - const onCloseEvent = useEffectEvent(onClose) - - useEffect(() => { - if (!isOpen) return - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - onCloseEvent() - } - } - - const handleClickOutside = (event: MouseEvent) => { - if (overlayRef.current && event.target === overlayRef.current) { - onCloseEvent() - } - } - - document.addEventListener('keydown', handleKeyDown) - document.addEventListener('click', handleClickOutside) - document.body.style.overflow = 'hidden' - - return () => { - document.removeEventListener('keydown', handleKeyDown) - document.removeEventListener('click', handleClickOutside) - document.body.style.overflow = 'unset' - } - }, [isOpen]) - - if (!isOpen) return null - - return ( -
-
- -
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx index 0e6e3602842..ddf44547039 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx @@ -1,7 +1,7 @@ 'use client' import { memo, useCallback, useEffect, useRef, useState } from 'react' -import { cn } from '@sim/emcn' +import { bindPreviewWheelZoom, cn } from '@sim/emcn' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { sanitizeRenderedHyperlinks, stripEmbeddedFrames } from '@/lib/core/security/url-safety' @@ -9,7 +9,6 @@ import { assertOoxmlPreviewWithinLimits } from '@/lib/file-parsers/ooxml-preview import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { PREVIEW_LOADING_OVERLAY, PreviewError, resolvePreviewError } from './preview-shared' import { PreviewToolbar } from './preview-toolbar' -import { bindPreviewWheelZoom } from './preview-wheel-zoom' import { useDocPreviewBinary } from './use-doc-preview-binary' const logger = createLogger('DocxPreview') diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx index d548c8ac2f2..653d7b2bde2 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx @@ -9,9 +9,9 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { pdfjs, Document as ReactPdfDocument, Page as ReactPdfPage } from 'react-pdf' import 'react-pdf/dist/Page/TextLayer.css' +import { bindPreviewWheelZoom } from '@sim/emcn' import { PREVIEW_LOADING_OVERLAY } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared' import { PreviewToolbar } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar' -import { bindPreviewWheelZoom } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom' /** * The worker runs in its own context that browser-polyfills cannot reach, so diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-sandbox-host.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-sandbox-host.tsx index cc769c274a2..e99013757cf 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-sandbox-host.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-sandbox-host.tsx @@ -1,11 +1,11 @@ 'use client' import { memo, useCallback, useEffect, useRef, useState } from 'react' +import { bindPreviewWheelZoom } from '@sim/emcn' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { openSimPptxViewer, type SimPptxViewerHandle } from '@/lib/pptx-renderer/sim-pptx-viewer' import { PreviewToolbar } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar' -import { bindPreviewWheelZoom } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom' const logger = createLogger('PptxSandboxHost') diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-horizontal-wheel-scroll.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-horizontal-wheel-scroll.ts index d5d941d26c0..0cd9ff3e40a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-horizontal-wheel-scroll.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-horizontal-wheel-scroll.ts @@ -1,7 +1,7 @@ 'use client' import { useCallback, useRef } from 'react' -import { bindPreviewHorizontalWheel } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom' +import { bindPreviewHorizontalWheel } from '@sim/emcn' /** * Ref callback that gives a preview scroll container horizontal wheel scrolling. diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/zoomable-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/zoomable-preview.tsx index 327e2808393..afa9665bf77 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/zoomable-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/zoomable-preview.tsx @@ -2,9 +2,8 @@ import type { MouseEvent, ReactNode } from 'react' import { useCallback, useLayoutEffect, useRef, useState } from 'react' -import { cn } from '@sim/emcn' +import { bindPreviewWheelZoom, cn } from '@sim/emcn' import { PreviewToolbar } from './preview-toolbar' -import { bindPreviewWheelZoom } from './preview-wheel-zoom' const ZOOM_MIN = 0.25 const ZOOM_MAX = 4 diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/chat-message-attachments.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/chat-message-attachments.tsx index ff13612456f..10d16ddd7f0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/chat-message-attachments.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/chat-message-attachments.tsx @@ -1,4 +1,4 @@ -import { cn } from '@sim/emcn' +import { cn, Lightbox } from '@sim/emcn' import { getDocumentIcon } from '@/components/icons/document-icons' import type { ChatMessageAttachment } from '@/app/workspace/[workspaceId]/home/types' @@ -57,9 +57,15 @@ export function ChatMessageAttachments(props: { ) } return ( -
- {att.filename} -
+ + + ) })} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx index db3f8c36ecd..4f42d169c1c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx @@ -1,7 +1,7 @@ 'use client' import React, { useState } from 'react' -import { cn, Loader } from '@sim/emcn' +import { cn, Lightbox, Loader } from '@sim/emcn' import { X } from '@sim/emcn/icons' import { getDocumentIcon } from '@/components/icons/document-icons' import { getFileExtension } from '@/lib/uploads/utils/file-utils' @@ -41,74 +41,86 @@ const AttachedFileChip = React.memo(function AttachedFileChip({ const isMedia = isVideo || file.type.startsWith('image/') const extension = getFileExtension(file.name) const [previewFailed, setPreviewFailed] = useState(false) + const lightboxSrc = file.type.startsWith('image/') && !previewFailed ? file.previewUrl : undefined - return ( - /* Owns the width cap: it anchors the remove badge, which a max-content button would strand. */ -
onFileClick(file)} + aria-label={lightboxSrc ? `Preview ${file.name}` : `Open ${file.name}`} > - + + )} + {file.uploading && ( + + + + )} + + ) + + return ( + /* Owns the width cap: it anchors the remove badge, which a max-content button would strand. */ +
+ {lightboxSrc ? ( + + {preview} + + ) : ( + preview + )} {!file.uploading && ( + + ) + ) + await click('Open image') + }) + + afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + document.body.replaceChildren() + document.body.removeAttribute('style') + vi.restoreAllMocks() + }) + + it.each([{ ctrlKey: true }, { metaKey: true }])( + 'zooms the media in both directions and cancels page zoom for %j', + (modifier) => { + const zoomIn = wheel({ ...modifier, deltaY: -40 }) + expect(zoomIn.defaultPrevented).toBe(true) + expect(Number(media().style.zoom)).toBeCloseTo(Math.exp(0.2)) + + const zoomOut = wheel({ ...modifier, deltaY: 40 }) + expect(zoomOut.defaultPrevented).toBe(true) + expect(Number(media().style.zoom)).toBeCloseTo(1) + } + ) + + it('leaves ordinary vertical scrolling available without changing zoom', () => { + const event = wheel({ deltaY: 40 }) + expect(event.defaultPrevented).toBe(false) + expect(media().style.zoom).toBe('1') + }) + + it('keeps toolbar controls open, clamps zoom, and resets to fit', async () => { + await click('Zoom in') + expect(media().style.zoom).toBe('1.25') + await click('Zoom out') + expect(media().style.zoom).toBe('1') + + wheel({ ctrlKey: true, deltaY: -10000 }) + expect(media().style.zoom).toBe('4') + expect(button('Zoom in').disabled).toBe(true) + wheel({ ctrlKey: true, deltaY: 10000 }) + expect(media().style.zoom).toBe('0.25') + expect(button('Zoom out').disabled).toBe(true) + await click('Reset zoom (25%)') + expect(media().style.zoom).toBe('1') + }) + + it('preserves the point beneath the gesture when zoom changes', () => { + const frame = button('Close media viewer') + const viewport = frame.parentElement?.parentElement + if (!viewport) throw new Error('Missing viewport') + const getBounds = vi.spyOn(frame, 'getBoundingClientRect') + getBounds.mockReturnValueOnce(new DOMRect(100, 100, 400, 300)) + getBounds.mockReturnValueOnce(new DOMRect(100, 100, 800, 600)) + + wheel({ ctrlKey: true, deltaY: -Math.log(2) / 0.005, clientX: 200, clientY: 175 }) + + expect(viewport.scrollLeft).toBeCloseTo(100) + expect(viewport.scrollTop).toBeCloseTo(75) + }) + + it('closes on the image, restores focus, and binds gestures again after reopening', async () => { + const oldMedia = media() + wheel({ ctrlKey: true, deltaY: -40 }) + await click('Close media viewer') + expect(document.querySelector('[role="dialog"]')).toBeNull() + await vi.waitFor(() => expect(document.activeElement).toBe(button('Open image'))) + const detachedWheel = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + ctrlKey: true, + deltaY: -40, + }) + oldMedia.dispatchEvent(detachedWheel) + expect(detachedWheel.defaultPrevented).toBe(false) + + await click('Open image') + expect(media().style.zoom).toBe('1') + expect(wheel({ ctrlKey: true, deltaY: -40 }).defaultPrevented).toBe(true) + expect(Number(media().style.zoom)).toBeGreaterThan(1) + }) + + it('dismisses on the viewport background and Escape', async () => { + const dialog = document.querySelector('[role="dialog"]') + await act(async () => dialog?.click()) + expect(document.querySelector('[role="dialog"]')).toBeNull() + await click('Open image') + await act(async () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + }) + expect(document.querySelector('[role="dialog"]')).toBeNull() + }) +}) diff --git a/packages/emcn/src/components/lightbox/lightbox.tsx b/packages/emcn/src/components/lightbox/lightbox.tsx new file mode 100644 index 00000000000..cc67372190d --- /dev/null +++ b/packages/emcn/src/components/lightbox/lightbox.tsx @@ -0,0 +1,201 @@ +'use client' + +import { type ReactElement, useCallback, useLayoutEffect, useRef, useState } from 'react' +import { + bindPreviewWheelZoom, + Chip, + chipFieldSurfaceClass, + cn, + Modal, + ModalClose, + ModalContent, + ModalTrigger, +} from '@sim/emcn' +import { Minus, Plus } from '@sim/emcn/icons' + +export interface LightboxProps { + /** A button that opens the viewer. */ + children: ReactElement + src: string + alt: string + type?: 'image' | 'video' + /** Playback position to resume when a video opens. */ + startTime?: number +} + +const ZOOM_MIN = 0.25 +const ZOOM_MAX = 4 +const ZOOM_STEP = 0.25 +const ZOOM_WHEEL_SENSITIVITY = 0.005 +const MEDIA_CLASS = 'block h-auto max-h-[calc(100dvh-6rem)] w-auto max-w-[92vw] object-contain' + +interface ZoomAnchor { + clientX: number + clientY: number + fractionX: number + fractionY: number +} + +function centerViewport(viewport: HTMLDivElement | null) { + if (!viewport) return + viewport.scrollLeft = (viewport.scrollWidth - viewport.clientWidth) / 2 + viewport.scrollTop = (viewport.scrollHeight - viewport.clientHeight) / 2 +} + +/** + * A click-to-close media viewer with bottom zoom controls, the platform's modal + * focus trap, Escape dismissal, and focus restoration to its trigger. + * + * @example + * ```tsx + * + * + * + * ``` + */ +export function Lightbox({ children, src, alt, type = 'image', startTime = 0 }: LightboxProps) { + const viewportRef = useRef(null) + const mediaFrameRef = useRef(null) + const controlsRef = useRef(null) + const zoomAnchorRef = useRef(null) + const [open, setOpen] = useState(false) + const [zoom, setZoom] = useState(1) + + function handleOpenChange(nextOpen: boolean) { + if (nextOpen) { + zoomAnchorRef.current = null + setZoom(1) + } + setOpen(nextOpen) + } + + function handleControlZoom(nextZoom: number) { + zoomAnchorRef.current = null + setZoom(Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, nextZoom))) + } + + const attachViewport = useCallback((viewport: HTMLDivElement | null) => { + viewportRef.current = viewport + if (!viewport) return + + const unbind = bindPreviewWheelZoom(viewport, (event) => { + const frame = mediaFrameRef.current?.getBoundingClientRect() + if (frame && frame.width > 0 && frame.height > 0) { + zoomAnchorRef.current = { + clientX: event.clientX, + clientY: event.clientY, + fractionX: (event.clientX - frame.left) / frame.width, + fractionY: (event.clientY - frame.top) / frame.height, + } + } + setZoom((current) => + Math.min( + ZOOM_MAX, + Math.max(ZOOM_MIN, current * Math.exp(-event.deltaY * ZOOM_WHEEL_SENSITIVITY)) + ) + ) + }) + + return () => { + unbind() + viewportRef.current = null + } + }, []) + + useLayoutEffect(() => { + const viewport = viewportRef.current + const anchor = zoomAnchorRef.current + const frame = mediaFrameRef.current?.getBoundingClientRect() + if (viewport && anchor && frame) { + viewport.scrollLeft += frame.left + anchor.fractionX * frame.width - anchor.clientX + viewport.scrollTop += frame.top + anchor.fractionY * frame.height - anchor.clientY + } else { + centerViewport(viewport) + } + zoomAnchorRef.current = null + }, [open, zoom]) + + return ( + + {children} + { + if (!controlsRef.current?.contains(event.target as Node)) setOpen(false) + }} + > +
+
+ + + +
+
+
+ handleControlZoom(zoom - ZOOM_STEP)} + /> + handleControlZoom(1)} + className='min-w-16 text-center tabular-nums' + > + {Math.round(zoom * 100)}% + + = ZOOM_MAX} + onClick={() => handleControlZoom(zoom + ZOOM_STEP)} + /> +
+
+
+ ) +} diff --git a/packages/emcn/src/components/modal/modal.tsx b/packages/emcn/src/components/modal/modal.tsx index 6309ee44ae7..bbf47f5e36e 100644 --- a/packages/emcn/src/components/modal/modal.tsx +++ b/packages/emcn/src/components/modal/modal.tsx @@ -426,6 +426,8 @@ export type ModalSize = keyof typeof MODAL_SIZES export interface ModalContentProps extends React.ComponentPropsWithoutRef { + /** Backdrop styling for specialized surfaces such as media viewers. */ + overlayClassName?: string /** * Whether to show the close button * @default true @@ -488,6 +490,7 @@ const ModalContent = React.forwardRef< ( { className, + overlayClassName, children, showClose = true, size = 'md', @@ -562,6 +565,7 @@ const ModalContent = React.forwardRef< return ( Date: Mon, 14 Sep 2026 19:19:46 -0700 Subject: [PATCH 02/11] fix(knowledge): allow concurrent connector document saves (#7841) --- .../connector-save-concurrency.integration.ts | 434 ++++++++++++++++++ .../knowledge/connectors/sync-persistence.ts | 10 +- 2 files changed, 441 insertions(+), 3 deletions(-) create mode 100644 apps/sim/lib/knowledge/__integration__/connector-save-concurrency.integration.ts diff --git a/apps/sim/lib/knowledge/__integration__/connector-save-concurrency.integration.ts b/apps/sim/lib/knowledge/__integration__/connector-save-concurrency.integration.ts new file mode 100644 index 00000000000..5595ad571a6 --- /dev/null +++ b/apps/sim/lib/knowledge/__integration__/connector-save-concurrency.integration.ts @@ -0,0 +1,434 @@ +/** Real PostgreSQL lock compatibility through the connector persistence entry points. */ +import { mkdtempSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { db } from '@sim/db' +import { + document, + embedding, + knowledgeBase, + knowledgeConnector, + organization, + outboxEvent, + user, + workspace, +} from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DbTransaction } from '@/lib/db/types' + +const fixtureStorage = vi.hoisted(() => ({ root: '' })) +vi.mock('@/lib/uploads/core/setup.server', () => ({ + get UPLOAD_DIR_SERVER() { + return fixtureStorage.root + }, +})) + +import { + createKnowledgeAclFixtureIds, + seedKnowledgeAclFixture, +} from '@/lib/knowledge/__integration__/seed-source-access-fixture' +import { stillHoldsSyncLock } from '@/lib/knowledge/connectors/sync-lock' +import { + addDocument, + persistSkippedDocuments, + persistSkippedRetryHashes, + persistSourceDocumentFailures, + updateDocument, +} from '@/lib/knowledge/connectors/sync-persistence' +import { deleteKnowledgeBase } from '@/lib/knowledge/service' +import type { ExternalDocument } from '@/connectors/types' + +const WAIT_OPTIONS = { interval: 1, timeout: 5000 } +const SAVE_KINDS = ['add', 'update', 'skip', 'retry hash', 'source failure'] as const +type SaveKind = (typeof SAVE_KINDS)[number] + +function deferred() { + let resolve!: () => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +/** Pause only after the real transaction callback has finished all its writes, before commit. */ +function holdTransactions(...predicates: Array<(tx: DbTransaction) => Promise>) { + const gates = predicates.map((matches) => ({ + matches, + pid: undefined as number | undefined, + release: deferred(), + })) + const transaction = db.transaction.bind(db) + vi.spyOn(db, 'transaction').mockImplementation((callback, config) => + transaction(async (tx) => { + await tx.execute(sql`SET LOCAL statement_timeout = '10s'`) + await tx.execute(sql`SET LOCAL idle_in_transaction_session_timeout = '15s'`) + const result = await callback(tx) + for (const gate of gates) { + if (gate.pid !== undefined || !(await gate.matches(tx))) continue + const [backend] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`) + gate.pid = backend.pid + await gate.release.promise + } + return result + }, config) + ) + return gates +} + +async function blockedBackend(blockingPid: number) { + const [row] = await db.execute<{ pid: number; query: string }>(sql` + SELECT pid, query FROM pg_stat_activity + WHERE datname = current_database() AND wait_event_type = 'Lock' + AND ${blockingPid} = ANY(pg_blocking_pids(pid)) + LIMIT 1 + `) + return row +} + +describe('independent connector saves in one knowledge base', () => { + let ids: ReturnType + + beforeAll(() => { + fixtureStorage.root = mkdtempSync(path.join(tmpdir(), 'sim-connector-concurrency-')) + }) + beforeEach(async () => { + ids = createKnowledgeAclFixtureIds() + await seedKnowledgeAclFixture(ids) + }) + afterEach(async () => { + vi.restoreAllMocks() + await db + .delete(outboxEvent) + .where(sql`${outboxEvent.payload}->>'workspaceId' = ${ids.workspaceId}`) + await db.delete(workspace).where(eq(workspace.id, ids.workspaceId)) + await db.delete(organization).where(eq(organization.id, ids.organizationId)) + await db.delete(user).where(inArray(user.id, [ids.aliceId, ids.bobId])) + }) + afterAll(async () => { + await rm(fixtureStorage.root, { recursive: true, force: true }) + await db.$client.end() + }) + + async function source(kind: SaveKind) { + const item = { + documentId: generateId(), + extDoc: { + externalId: generateId(), + title: 'Concurrent source', + content: 'New source content', + mimeType: 'text/plain', + contentHash: generateId(), + skippedReason: 'Source intentionally excluded', + } satisfies ExternalDocument, + } + if (kind !== 'add') { + await db.insert(document).values({ + id: item.documentId, + knowledgeBaseId: ids.knowledgeBaseId, + connectorId: ids.connectorId, + externalId: item.extDoc.externalId, + filename: 'Old source', + fileUrl: 'data:text/plain,old', + fileSize: 3, + mimeType: 'text/plain', + contentHash: 'old-content', + processingStatus: 'completed', + }) + } + return item + } + + function save(kind: SaveKind, item: Awaited>) { + const lease = { stillHeld: () => stillHoldsSyncLock(ids.connectorId, ids.lockId) } + const args = [ + ids.knowledgeBaseId, + ids.connectorId, + 'confluence', + item.extDoc, + { workspaceId: ids.workspaceId, userId: ids.aliceId }, + undefined, + 'workspace', + lease, + ] as const + switch (kind) { + case 'add': + return addDocument(...args) + case 'update': + return updateDocument(item.documentId, ...args) + case 'skip': + return persistSkippedDocuments( + ids.knowledgeBaseId, + ids.connectorId, + 'confluence', + [{ type: 'skip', existingId: item.documentId, extDoc: item.extDoc }], + undefined, + 'workspace', + lease + ) + case 'retry hash': + return persistSkippedRetryHashes( + ids.knowledgeBaseId, + ids.connectorId, + [{ existingId: item.documentId, ...item.extDoc }], + lease + ) + case 'source failure': + return persistSourceDocumentFailures({ + knowledgeBaseId: ids.knowledgeBaseId, + connectorId: ids.connectorId, + connectorType: 'confluence', + documents: [item.extDoc], + failedExternalIds: new Set([item.extDoc.externalId]), + priorByExternalId: new Map([[item.extDoc.externalId, { id: item.documentId }]]), + sourceConfig: {}, + access: 'workspace', + lease, + }) + } + } + + function wrote(kind: SaveKind, item: Awaited>) { + return async (tx: DbTransaction) => { + const rows = await tx + .select({ id: document.id }) + .from(document) + .where( + and( + eq(document.connectorId, ids.connectorId), + eq(document.externalId, item.extDoc.externalId), + kind === 'source failure' + ? isNull(document.contentHash) + : eq(document.contentHash, item.extDoc.contentHash) + ) + ) + return rows.length === 1 + } + } + + it.each(SAVE_KINDS)( + '%s reaches writes for different documents before either commits', + async (kind) => { + const first = await source(kind) + const second = await source(kind) + const gates = holdTransactions(wrote(kind, first), wrote(kind, second)) + const firstResult = Promise.allSettled([save(kind, first)]) + let secondResult: ReturnType | undefined + try { + await expect.poll(() => gates[0].pid, WAIT_OPTIONS).toBeDefined() + secondResult = Promise.allSettled([save(kind, second)]) + await expect.poll(() => gates[1].pid, WAIT_OPTIONS).toBeDefined() + expect(gates[0].pid).not.toBe(gates[1].pid) + const committed = await db + .select({ contentHash: document.contentHash }) + .from(document) + .where(eq(document.connectorId, ids.connectorId)) + expect(committed).toEqual( + kind === 'add' ? [] : [{ contentHash: 'old-content' }, { contentHash: 'old-content' }] + ) + for (const gate of gates) gate.release.resolve() + expect(await firstResult).toMatchObject([{ status: 'fulfilled' }]) + expect(await secondResult).toMatchObject([{ status: 'fulfilled' }]) + } finally { + for (const gate of gates) gate.release.resolve() + await firstResult + await secondResult + } + } + ) + + it.each(['add', 'update'] as const)( + 'deletion waits for an in-flight %s and archives its committed document', + async (kind) => { + const item = await source(kind) + const [gate] = holdTransactions(wrote(kind, item)) + const saved = Promise.allSettled([save(kind, item)]) + let deleted: Promise[]> | undefined + try { + await expect.poll(() => gate.pid, WAIT_OPTIONS).toBeDefined() + deleted = Promise.allSettled([ + deleteKnowledgeBase(ids.knowledgeBaseId, 'concurrent-delete'), + ]) + await expect.poll(() => blockedBackend(gate.pid!), WAIT_OPTIONS).toBeDefined() + const [active] = await db + .select() + .from(knowledgeBase) + .where(eq(knowledgeBase.id, ids.knowledgeBaseId)) + expect(active.deletedAt).toBeNull() + gate.release.resolve() + expect(await saved).toMatchObject([{ status: 'fulfilled' }]) + expect(await deleted).toMatchObject([{ status: 'fulfilled' }]) + const [row] = await db + .select() + .from(document) + .where(eq(document.externalId, item.extDoc.externalId)) + expect(row.archivedAt).toBeInstanceOf(Date) + expect(row.contentHash).toBe(item.extDoc.contentHash) + } finally { + gate.release.resolve() + await saved + await deleted + } + } + ) + + it('rejects a save that waits behind a deletion which commits first', async () => { + const item = await source('add') + const [gate] = holdTransactions(async (tx) => { + const rows = await tx + .select({ id: knowledgeBase.id }) + .from(knowledgeBase) + .where(and(eq(knowledgeBase.id, ids.knowledgeBaseId), isNotNull(knowledgeBase.deletedAt))) + return rows.length === 1 + }) + const deleted = Promise.allSettled([deleteKnowledgeBase(ids.knowledgeBaseId, 'delete-first')]) + let saved: ReturnType | undefined + try { + await expect.poll(() => gate.pid, WAIT_OPTIONS).toBeDefined() + saved = Promise.allSettled([save('add', item)]) + await expect.poll(() => blockedBackend(gate.pid!), WAIT_OPTIONS).toBeDefined() + gate.release.resolve() + expect(await deleted).toMatchObject([{ status: 'fulfilled' }]) + expect(await saved).toMatchObject([ + { + status: 'rejected', + reason: { message: `Knowledge base ${ids.knowledgeBaseId} is deleted` }, + }, + ]) + expect( + await db.select().from(document).where(eq(document.externalId, item.extDoc.externalId)) + ).toEqual([]) + const guards = await db + .select({ status: outboxEvent.status }) + .from(outboxEvent) + .where(sql`${outboxEvent.payload}->>'workspaceId' = ${ids.workspaceId}`) + expect(guards).toEqual([{ status: 'pending' }]) + } finally { + gate.release.resolve() + await deleted + await saved + } + }) + + it('serializes same-document updates at the document lock while another document saves', async () => { + const first = await source('update') + const second = { ...first, extDoc: { ...first.extDoc, contentHash: generateId() } } + const independent = await source('update') + const [gate] = holdTransactions(wrote('update', first)) + const firstResult = Promise.allSettled([save('update', first)]) + let secondResult: ReturnType | undefined + try { + await expect.poll(() => gate.pid, WAIT_OPTIONS).toBeDefined() + secondResult = Promise.allSettled([save('update', second)]) + await expect + .poll(() => blockedBackend(gate.pid!), WAIT_OPTIONS) + .toMatchObject({ + query: expect.stringMatching(/from "document".*for update/i), + }) + await save('update', independent) + gate.release.resolve() + expect(await firstResult).toMatchObject([{ status: 'fulfilled' }]) + expect(await secondResult).toMatchObject([{ status: 'fulfilled' }]) + const [row] = await db.select().from(document).where(eq(document.id, first.documentId)) + expect(row.contentHash).toBe(second.extDoc.contentHash) + } finally { + gate.release.resolve() + await firstResult + await secondResult + } + }) + + it('holds the connector lease until commit and rejects writes from the reclaimed lease', async () => { + const item = await source('add') + const [gate] = holdTransactions(wrote('add', item)) + const saved = Promise.allSettled([save('add', item)]) + let reclaimed: ReturnType | undefined + try { + await expect.poll(() => gate.pid, WAIT_OPTIONS).toBeDefined() + reclaimed = Promise.allSettled([ + db.transaction(async (tx) => { + await tx + .update(knowledgeConnector) + .set({ syncLockToken: generateId() }) + .where(eq(knowledgeConnector.id, ids.connectorId)) + }), + ]) + await expect + .poll(() => blockedBackend(gate.pid!), WAIT_OPTIONS) + .toMatchObject({ + query: expect.stringMatching(/update "knowledge_connector"/i), + }) + gate.release.resolve() + expect(await saved).toMatchObject([{ status: 'fulfilled' }]) + expect(await reclaimed).toMatchObject([{ status: 'fulfilled' }]) + const stale = await source('add') + await expect(save('add', stale)).rejects.toThrow( + `Sync lock for connector ${ids.connectorId} was reclaimed during sync` + ) + expect( + await db.select().from(document).where(eq(document.externalId, stale.extDoc.externalId)) + ).toEqual([]) + } finally { + gate.release.resolve() + await saved + await reclaimed + } + }) + + it('allows an embedding FK check while a save waits on its document lock', async () => { + const item = await source('update') + holdTransactions() + const publish = deferred() + let processingPid: number | undefined + const processing = db.transaction(async (tx) => { + await tx.execute(sql`SET LOCAL statement_timeout = '10s'`) + await tx + .select({ id: document.id }) + .from(document) + .where(eq(document.id, item.documentId)) + .for('update') + const [backend] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`) + processingPid = backend.pid + await publish.promise + await tx.insert(embedding).values({ + id: generateId(), + knowledgeBaseId: ids.knowledgeBaseId, + documentId: item.documentId, + chunkIndex: 0, + chunkHash: 'fixture', + content: 'fixture', + contentLength: 7, + tokenCount: 1, + startOffset: 0, + endOffset: 7, + embedding: Array.from({ length: 1536 }, () => 0.01), + }) + }) + const processed = Promise.allSettled([processing]) + let saved: ReturnType | undefined + try { + await expect.poll(() => processingPid, WAIT_OPTIONS).toBeDefined() + saved = Promise.allSettled([save('update', item)]) + await expect + .poll(() => blockedBackend(processingPid!), WAIT_OPTIONS) + .toMatchObject({ + query: expect.stringMatching(/from "document".*for update/i), + }) + publish.resolve() + expect(await processed).toMatchObject([{ status: 'fulfilled' }]) + expect(await saved).toMatchObject([{ status: 'fulfilled' }]) + expect( + await db + .select({ id: embedding.id }) + .from(embedding) + .where(eq(embedding.documentId, item.documentId)) + ).toHaveLength(1) + } finally { + publish.resolve() + await processed + await saved + } + }) +}) diff --git a/apps/sim/lib/knowledge/connectors/sync-persistence.ts b/apps/sim/lib/knowledge/connectors/sync-persistence.ts index 3598bd78e8f..e09daed3b81 100644 --- a/apps/sim/lib/knowledge/connectors/sync-persistence.ts +++ b/apps/sim/lib/knowledge/connectors/sync-persistence.ts @@ -209,18 +209,22 @@ function connectorStoredArtifact(extDoc: ExternalDocument): { mimeType: 'text/plain', } } -type KnowledgeBaseLockingTx = Pick +type KnowledgeBaseLockingTx = Pick +/** + * Holds an active KB through commit without serializing independent document saves. + * SHARE blocks soft deletion's NO KEY UPDATE but permits other saves and FK checks. + * Callers acquire this before connector/document locks and must not update the KB. + */ async function isKnowledgeBaseActiveInTx( tx: KnowledgeBaseLockingTx, knowledgeBaseId: string ): Promise { - await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`) - const rows = await tx .select({ id: knowledgeBase.id }) .from(knowledgeBase) .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) + .for('share') .limit(1) return rows.length > 0 From 44b6ea086a65d3446912224ae3e5085f32f7dc64 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 14 Sep 2026 19:33:23 -0700 Subject: [PATCH 03/11] fix(workflows): recover loads with legacy loop counts (#7843) --- .../[workspaceId]/w/[workflowId]/workflow.tsx | 41 +++++-- apps/sim/executor/orchestrators/loop.test.ts | 8 +- .../lib/workflows/persistence/utils.test.ts | 107 ++++++++++++++++++ .../stores/workflows/registry/store.test.ts | 51 +++++++++ packages/workflow-persistence/src/load.ts | 5 + 5 files changed, 198 insertions(+), 14 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 475d610c851..2a6a00d8b32 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -71,7 +71,10 @@ import { type ConnectionBlockSelectorData, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector' import { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors' -import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index' +import { + ErrorBoundary, + ErrorUI, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index' import { FocusBlockDeepLink } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/focus-block-deep-link' import { WorkflowSearchReplace } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace' import { WorkflowControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls' @@ -2668,6 +2671,10 @@ const WorkflowContent = React.memo( const loadingWorkflowRef = useRef(null) const currentWorkflowExists = !isWorkflowMapPlaceholderData && Boolean(workflows[workflowIdParam]) + const workflowLoadError = + hydration.phase === 'error' && hydration.workflowId === workflowIdParam + ? hydration.error + : null useEffect(() => { const currentId = workflowIdParam @@ -5126,16 +5133,28 @@ const WorkflowContent = React.memo( > {!isWorkflowReady && (
-
+ {workflowLoadError ? ( + { + setActiveWorkflow(workflowIdParam).catch((error) => { + logger.error(`Failed to retry workflow ${workflowIdParam}:`, error) + }) + }} + /> + ) : ( +
+ )}
)} diff --git a/apps/sim/executor/orchestrators/loop.test.ts b/apps/sim/executor/orchestrators/loop.test.ts index 67af5313573..4ac9c026383 100644 --- a/apps/sim/executor/orchestrators/loop.test.ts +++ b/apps/sim/executor/orchestrators/loop.test.ts @@ -154,7 +154,7 @@ describe('LoopOrchestrator', () => { expect(loopEnd.incomingEdges.has(parallelEndId)).toBe(true) }) - it('resolves forEach collections with the loop start sentinel scope', async () => { + it('resolves forEach collections with the loop start sentinel scope independently of the count', async () => { const loopId = 'loop-1' const dag: DAG = { nodes: new Map(), @@ -165,6 +165,7 @@ describe('LoopOrchestrator', () => { id: loopId, nodes: ['task-1'], loopType: 'forEach', + iterations: 1, forEachItems: '', }, ], @@ -172,7 +173,7 @@ describe('LoopOrchestrator', () => { parallelConfigs: new Map(), } const resolver = { - resolveSingleReference: vi.fn().mockResolvedValue(['item-1']), + resolveSingleReference: vi.fn().mockResolvedValue(['item-1', 'item-2', 'item-3']), } const orchestrator = new LoopOrchestrator(dag, createState(), resolver as any, {}, { clearDeactivatedEdgesForNodes: vi.fn(), @@ -188,7 +189,8 @@ describe('LoopOrchestrator', () => { undefined, { allowLargeValueRefs: true } ) - expect(scope.maxIterations).toBe(1) + expect(scope.maxIterations).toBe(3) + expect(scope.items).toEqual(['item-1', 'item-2', 'item-3']) }) it('projects forEach resolution failures before logging or persisting them', async () => { diff --git a/apps/sim/lib/workflows/persistence/utils.test.ts b/apps/sim/lib/workflows/persistence/utils.test.ts index b4b50ab88a0..63642ba2529 100644 --- a/apps/sim/lib/workflows/persistence/utils.test.ts +++ b/apps/sim/lib/workflows/persistence/utils.test.ts @@ -23,10 +23,12 @@ import { schemaMock, } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { workflowStateSchema } from '@/lib/api/contracts/workflows' import type { BlockState as AppBlockState, WorkflowState as AppWorkflowState, } from '@/stores/workflows/workflow/types' +import { generateLoopBlocks } from '@/stores/workflows/workflow/utils' /** * Type helper for converting test workflow state to app workflow state. @@ -348,6 +350,110 @@ describe('Database Helpers', () => { }) describe('loadWorkflowFromNormalizedTables', () => { + it.each(['for', 'forEach', 'while', 'doWhile'] as const)( + 'preserves valid block counts and expressions for %s loops even when subflow counts differ', + async (loopType) => { + const data = { + count: 9, + loopType, + collection: '', + whileCondition: '', + doWhileCondition: '', + width: 600, + parentId: 'outer-loop', + extent: 'parent' as const, + } + queueLoadFixtures({ + blocks: [{ ...toDbBlock(createLoopBlock({ id: 'loop-1' }), mockWorkflowId), data }], + subflows: [ + { + id: 'loop-1', + type: 'loop', + config: { + nodes: [], + loopType, + iterations: 3, + forEachItems: data.collection, + whileCondition: data.whileCondition, + doWhileCondition: data.doWhileCondition, + }, + }, + ], + }) + + const loaded = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId) + const parsed = workflowStateSchema.parse(loaded) + + expect(parsed.blocks['loop-1'].data).toEqual(data) + expect(parsed.loops?.['loop-1'].iterations).toBe(3) + expect(generateLoopBlocks(loaded!.blocks)['loop-1']).toMatchObject({ + iterations: 9, + loopType, + forEachItems: data.collection, + whileCondition: data.whileCondition, + doWhileCondition: data.doWhileCondition, + }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + } + ) + + it('keeps an absent block count absent so serialization retains its existing default', async () => { + queueLoadFixtures({ + blocks: [ + { + ...toDbBlock(createLoopBlock({ id: 'loop-1' }), mockWorkflowId), + data: { loopType: 'for' }, + }, + ], + subflows: [ + { id: 'loop-1', type: 'loop', config: { nodes: [], loopType: 'for', iterations: 3 } }, + ], + }) + + const loaded = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId) + + expect(loaded?.blocks['loop-1'].data?.count).toBeUndefined() + expect(loaded?.loops['loop-1'].iterations).toBe(3) + expect(generateLoopBlocks(loaded!.blocks)['loop-1'].iterations).toBe(5) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('serves a legacy forEach loop with a string count through the workflow read contract', async () => { + const collection = '' + const loopRow = toDbBlock(createLoopBlock({ id: 'loop-1' }), mockWorkflowId) + queueLoadFixtures({ + blocks: [ + { + ...loopRow, + data: { ...loopRow.data, loopType: 'forEach', count: collection, collection }, + }, + ], + subflows: [ + { + id: 'loop-1', + type: 'loop', + config: { + nodes: [], + loopType: 'forEach', + iterations: collection, + forEachItems: collection, + }, + }, + ], + }) + + const loaded = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId) + const parsed = workflowStateSchema.parse(loaded) + + expect(parsed.blocks['loop-1'].data).toMatchObject({ count: 1, collection }) + expect(parsed.loops?.['loop-1']).toMatchObject({ + loopType: 'forEach', + iterations: 1, + forEachItems: collection, + }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + it('should successfully load workflow data from normalized tables', async () => { queueLoadFixtures({ blocks: mockBlocksFromDb, @@ -401,6 +507,7 @@ describe('Database Helpers', () => { whileCondition: '', enabled: true, }) + expect(result?.blocks['loop-1'].data?.count).toBe(3) expect(result?.parallels['parallel-1']).toEqual({ id: 'parallel-1', diff --git a/apps/sim/stores/workflows/registry/store.test.ts b/apps/sim/stores/workflows/registry/store.test.ts index 5dc7979e858..8b67c823e34 100644 --- a/apps/sim/stores/workflows/registry/store.test.ts +++ b/apps/sim/stores/workflows/registry/store.test.ts @@ -228,6 +228,35 @@ describe('registry store loadWorkflowState (collapsed cache)', () => { expect(mockRequestJson).toHaveBeenCalledTimes(2) }) + it('exposes a failed load and recovers when the user retries the same workflow', async () => { + mockRequestJson.mockRejectedValueOnce(new Error('Unable to fetch workflow')) + + await expect(useWorkflowRegistry.getState().setActiveWorkflow('wf-1')).rejects.toThrow( + 'Unable to fetch workflow' + ) + expect(useWorkflowRegistry.getState().hydration).toMatchObject({ + phase: 'error', + workflowId: 'wf-1', + error: 'Unable to fetch workflow', + }) + expect(replaceWorkflowState).not.toHaveBeenCalled() + + mockRequestJson.mockResolvedValueOnce({ data: makeEnvelope() }) + const retry = useWorkflowRegistry.getState().setActiveWorkflow('wf-1') + expect(useWorkflowRegistry.getState().hydration).toMatchObject({ + phase: 'state-loading', + error: null, + }) + await retry + + expect(mockRequestJson).toHaveBeenCalledTimes(2) + expect(useWorkflowRegistry.getState().hydration).toMatchObject({ + phase: 'ready', + workflowId: 'wf-1', + error: null, + }) + }) + it('discards a superseded response via the staleness guard', async () => { // First load (wf-1) is in-flight; a second load (wf-2) supersedes the // hydration workflowId, then wf-1 finally resolves. The guard compares the @@ -256,4 +285,26 @@ describe('registry store loadWorkflowState (collapsed cache)', () => { expect(replaceWorkflowState.mock.calls.length).toBe(projectionsAfterSecond) expect(useWorkflowRegistry.getState().activeWorkflowId).toBe('wf-2') }) + + it('does not show a stale load error after switching to another workflow', async () => { + let rejectFirst: (reason: Error) => void = () => {} + const firstPending = new Promise((_resolve, reject) => { + rejectFirst = reject + }) + mockRequestJson + .mockImplementationOnce(() => firstPending) + .mockResolvedValueOnce({ data: makeEnvelope({ id: 'wf-2' }) }) + + const firstLoad = useWorkflowRegistry.getState().setActiveWorkflow('wf-1') + await useWorkflowRegistry.getState().setActiveWorkflow('wf-2') + rejectFirst(new Error('Previous workflow failed to load')) + await firstLoad + + expect(useWorkflowRegistry.getState().activeWorkflowId).toBe('wf-2') + expect(useWorkflowRegistry.getState().hydration).toMatchObject({ + phase: 'ready', + workflowId: 'wf-2', + error: null, + }) + }) }) diff --git a/packages/workflow-persistence/src/load.ts b/packages/workflow-persistence/src/load.ts index 999bb715d6c..706c979c371 100644 --- a/packages/workflow-persistence/src/load.ts +++ b/packages/workflow-persistence/src/load.ts @@ -204,6 +204,11 @@ export async function loadWorkflowFromNormalizedTablesRaw( ...block, data: { ...block.data, + /** Repair legacy values without changing valid counts used by serialization. */ + count: + block.data?.count === undefined || typeof block.data.count === 'number' + ? block.data?.count + : loop.iterations, collection: loop.forEachItems ?? block.data?.collection ?? '', whileCondition: loop.whileCondition ?? block.data?.whileCondition ?? '', doWhileCondition: loop.doWhileCondition ?? block.data?.doWhileCondition ?? '', From 3889e4be156c6b016c007d1e818928e59d696ef2 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 14 Sep 2026 19:45:14 -0700 Subject: [PATCH 04/11] fix(workflows): preserve absent loop count fields (#7845) --- apps/sim/lib/workflows/persistence/utils.test.ts | 1 + packages/workflow-persistence/src/load.ts | 7 +++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/workflows/persistence/utils.test.ts b/apps/sim/lib/workflows/persistence/utils.test.ts index 63642ba2529..5832f05b8fb 100644 --- a/apps/sim/lib/workflows/persistence/utils.test.ts +++ b/apps/sim/lib/workflows/persistence/utils.test.ts @@ -413,6 +413,7 @@ describe('Database Helpers', () => { const loaded = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId) expect(loaded?.blocks['loop-1'].data?.count).toBeUndefined() + expect(Object.hasOwn(loaded!.blocks['loop-1'].data!, 'count')).toBe(false) expect(loaded?.loops['loop-1'].iterations).toBe(3) expect(generateLoopBlocks(loaded!.blocks)['loop-1'].iterations).toBe(5) expect(dbChainMockFns.update).not.toHaveBeenCalled() diff --git a/packages/workflow-persistence/src/load.ts b/packages/workflow-persistence/src/load.ts index 706c979c371..68160101aff 100644 --- a/packages/workflow-persistence/src/load.ts +++ b/packages/workflow-persistence/src/load.ts @@ -205,10 +205,9 @@ export async function loadWorkflowFromNormalizedTablesRaw( data: { ...block.data, /** Repair legacy values without changing valid counts used by serialization. */ - count: - block.data?.count === undefined || typeof block.data.count === 'number' - ? block.data?.count - : loop.iterations, + ...(block.data?.count !== undefined && typeof block.data.count !== 'number' + ? { count: loop.iterations } + : {}), collection: loop.forEachItems ?? block.data?.collection ?? '', whileCondition: loop.whileCondition ?? block.data?.whileCondition ?? '', doWhileCondition: loop.doWhileCondition ?? block.data?.doWhileCondition ?? '', From 7ae92b7701112781149e08920ce06ebff3d61bf3 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 14 Sep 2026 19:52:35 -0700 Subject: [PATCH 05/11] chore(files): align PDF viewer import order (#7846) --- .../[workspaceId]/files/components/file-viewer/pdf-viewer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx index 653d7b2bde2..43aaa175d3f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx @@ -6,12 +6,12 @@ */ import '@/lib/core/utils/browser-polyfills' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { bindPreviewWheelZoom } from '@sim/emcn' import { createLogger } from '@sim/logger' import { pdfjs, Document as ReactPdfDocument, Page as ReactPdfPage } from 'react-pdf' -import 'react-pdf/dist/Page/TextLayer.css' -import { bindPreviewWheelZoom } from '@sim/emcn' import { PREVIEW_LOADING_OVERLAY } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared' import { PreviewToolbar } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar' +import 'react-pdf/dist/Page/TextLayer.css' /** * The worker runs in its own context that browser-polyfills cannot reach, so From a77abd649880cf3160ac725f2cf2c5a83ef1a06a Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 14 Sep 2026 20:08:30 -0700 Subject: [PATCH 06/11] fix(executor): load permission config once per run and retry transient reads (#7847) * fix(executor): load permission config once per run and retry transient reads Every block ran on a shallow copy of the execution context, so the permission-config memo written onto it was discarded and the full permission-group config reloaded from the database before every block. That per-block load had no retry, so a single transient database error failed the whole run, and the raw query error (SQL and bound parameters) surfaced as the block error. - Memoize the in-flight load in a run-scoped map shared by every block copy, keyed by governed subject and workspace; failed loads are evicted - Move the bounded transient-read retry from the tool-only wrapper into the shared loader so block, model, agent and tool gates all get it - Replace a database query error's message in the block error handler and log only its redacted cause - Redact bound parameters in the execution failure cause log * fix(executor): honor caller cancellation in unshared permission loads --- .../access-control/utils/permission-check.ts | 94 ++++++--- .../utils/permission-gate-subject.test.ts | 187 +++++++++++++++++- .../executor/execution/block-executor.test.ts | 45 +++++ apps/sim/executor/execution/block-executor.ts | 25 ++- apps/sim/executor/execution/executor.test.ts | 31 +++ apps/sim/executor/execution/executor.ts | 1 + apps/sim/executor/types.ts | 7 +- .../lib/core/errors/database-query-error.ts | 10 + .../lib/workflows/executor/execution-core.ts | 4 +- apps/sim/tools/index.test.ts | 60 +----- apps/sim/tools/index.ts | 82 ++------ 11 files changed, 386 insertions(+), 160 deletions(-) create mode 100644 apps/sim/lib/core/errors/database-query-error.ts diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts index 6a3c189c95c..4dc5b900f8b 100644 --- a/apps/sim/ee/access-control/utils/permission-check.ts +++ b/apps/sim/ee/access-control/utils/permission-check.ts @@ -1,10 +1,15 @@ import { createLogger } from '@sim/logger' +import { describeError } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { backoffWithJitter } from '@sim/utils/retry' import type { ShareAuthType } from '@/lib/api/contracts/public-shares' import { getAllowedIntegrationsFromEnv, isInvitationsDisabled, isPublicApiDisabled, } from '@/lib/core/config/env-flags' +import { findDatabaseQueryError } from '@/lib/core/errors/database-query-error' +import { isRetryableInfrastructureError } from '@/lib/core/errors/retryable-infrastructure' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import { CAPABILITY_RULES, @@ -199,43 +204,78 @@ function governedSubjectUserId( return declared ?? undefined } +const PERMISSION_CONFIG_LOAD_MAX_ATTEMPTS = 3 +const PERMISSION_CONFIG_LOAD_RETRY_BACKOFF = { baseMs: 25, maxMs: 100 } as const + /** - * Cache-aware wrapper around `getUserPermissionConfig`. When an - * `ExecutionContext` is provided, the resolved config is memoized on the - * context so repeated checks during a single workflow run share one DB hit. - * - * The subject is resolved HERE rather than by each caller, because the memo is - * keyed by nothing but the context. `validateModelProvider` and - * `validateBlockType` take the actor's id positionally, so a run declaring a - * different gate subject had the first model check fill the cache with the - * BILLING actor's group — and every later `assertPermissionsAllowed`, having - * correctly resolved the governed subject, was handed that stale entry. Doing - * the derivation at the one place the config is loaded makes the memo correct - * by construction: within a run `capabilityGovernedUserId` is fixed, so every - * path resolves and caches the same person. + * Loads a permission config, retrying a transient database read failure a bounded number of times. + * The last failure is rethrown: resolving `null` would turn every gate off. + */ +async function loadPermissionConfig( + userId: string, + workspaceId: string, + signal: AbortSignal | undefined +): Promise { + for (let attempt = 1; ; attempt += 1) { + signal?.throwIfAborted() + try { + return await getUserPermissionConfig(userId, workspaceId) + } catch (error) { + signal?.throwIfAborted() + if ( + attempt >= PERMISSION_CONFIG_LOAD_MAX_ATTEMPTS || + !findDatabaseQueryError(error) || + !isRetryableInfrastructureError(error) + ) { + throw error + } + + const delayMs = backoffWithJitter(attempt, null, PERMISSION_CONFIG_LOAD_RETRY_BACKOFF) + logger.warn('Retrying permission config load after database error', { + workspaceId, + attempt, + maxAttempts: PERMISSION_CONFIG_LOAD_MAX_ATTEMPTS, + delayMs, + cause: describeError(error), + }) + await sleep(delayMs) + } + } +} + +/** + * Loads the governed subject's permission config. The subject is resolved here, not by callers, + * so every gate reads the same person's group. On a run context the in-flight load is memoized per + * subject and workspace in the run's `permissionConfigCache`, and a failed load is evicted. A shared + * load observes only the run's abort signal, so one caller's cancellation cannot fail it for others; + * an unshared load observes the caller's `signal`. */ async function getPermissionConfig( actorUserId: string | undefined, workspaceId: string | undefined, - ctx?: ExecutionContext + ctx?: ExecutionContext, + signal?: AbortSignal ): Promise { const userId = governedSubjectUserId(actorUserId, ctx) if (!userId || !workspaceId) { return mergeEnvAllowlist(null) } - if (ctx) { - if (ctx.permissionConfigLoaded) { - return ctx.permissionConfig ?? null - } - - const config = await getUserPermissionConfig(userId, workspaceId) - ctx.permissionConfig = config - ctx.permissionConfigLoaded = true - return config + const cache = ctx?.permissionConfigCache + if (!cache) { + return loadPermissionConfig(userId, workspaceId, signal ?? ctx?.abortSignal) } - return getUserPermissionConfig(userId, workspaceId) + const key = `${userId}:${workspaceId}` + const cached = cache.get(key) + if (cached) return cached + + const pending = loadPermissionConfig(userId, workspaceId, ctx?.abortSignal) + cache.set(key, pending) + pending.catch(() => { + if (cache.get(key) === pending) cache.delete(key) + }) + return pending } /** @@ -499,6 +539,8 @@ interface PermissionAssertion { toolId?: string toolKind?: ToolKind ctx?: ExecutionContext + /** Caller cancellation, observed while loading a config that is not shared through a run cache. */ + signal?: AbortSignal } /** @@ -516,7 +558,7 @@ interface PermissionAssertion { /** permission-group-enforced: custom_tools.use — gates tool invocation during a run, not an operation */ /** permission-group-enforced: skills.use — gates skill loading during a run, not an operation */ export async function assertPermissionsAllowed(req: PermissionAssertion): Promise { - const { workspaceId, model, blockType, toolId, toolKind, ctx } = req + const { workspaceId, model, blockType, toolId, toolKind, ctx, signal } = req const userId = governedSubjectUserId(req.userId, ctx) const blockTypeExempt = blockType ? isBlockTypeAccessControlExempt(blockType) : false @@ -527,7 +569,7 @@ export async function assertPermissionsAllowed(req: PermissionAssertion): Promis const config = userId && workspaceId - ? await getPermissionConfig(userId, workspaceId, ctx) + ? await getPermissionConfig(userId, workspaceId, ctx, signal) : mergeEnvAllowlist(null) const subject = { userId, workspaceId } diff --git a/apps/sim/ee/access-control/utils/permission-gate-subject.test.ts b/apps/sim/ee/access-control/utils/permission-gate-subject.test.ts index 5c1cfdfb3bb..9516d8a36e7 100644 --- a/apps/sim/ee/access-control/utils/permission-gate-subject.test.ts +++ b/apps/sim/ee/access-control/utils/permission-gate-subject.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { DrizzleQueryError } from 'drizzle-orm/errors' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -18,6 +19,7 @@ vi.mock('@/lib/billing/core/subscription', () => ({ isOrganizationOnEnterprisePlan: vi.fn(), })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ getWorkspaceWithOwner: vi.fn() })) +vi.mock('@sim/utils/helpers', () => ({ sleep: vi.fn().mockResolvedValue(undefined) })) vi.mock('@/providers/utils', () => ({ isFunctionToolCall: () => false, getProviderFromModel: () => 'openai', @@ -36,7 +38,10 @@ import { * field and keeps gating on the caller. */ function runDeclaring(capabilityGovernedUserId?: string | null): ExecutionContext { - return { metadata: { capabilityGovernedUserId } } as unknown as ExecutionContext + return { + metadata: { capabilityGovernedUserId }, + permissionConfigCache: new Map(), + } as unknown as ExecutionContext } describe('the subject a run’s permission gate is decided about', () => { @@ -164,3 +169,183 @@ describe('the group a run’s later gates read from its cache', () => { expect(mocks.getUserPermissionConfig).not.toHaveBeenCalled() }) }) + +function databaseError(code = 'ECONNRESET'): DrizzleQueryError { + return new DrizzleQueryError( + 'select "billing_blocked" from "user_stats" where "user_stats"."user_id" = $1', + ['owner-secret-id'], + Object.assign(new Error(`driver failure ${code}`), { code }) + ) +} + +/** Every block runs on a shallow copy of the run's context, so the memo lives in a Map they share. */ +describe('the run-scoped permission config cache', () => { + function runContext(overrides: Partial = {}): ExecutionContext { + return { + metadata: {}, + permissionConfigCache: new Map(), + ...overrides, + } as unknown as ExecutionContext + } + + function gate(ctx: ExecutionContext, workspaceId = 'workspace-1') { + return assertPermissionsAllowed({ + userId: 'user-1', + workspaceId, + toolId: 'http_request', + ctx, + }) + } + + beforeEach(() => { + vi.clearAllMocks() + mocks.getUserPermissionConfig.mockResolvedValue({ deniedTools: [] }) + }) + + it('loads once across the per-block copies of one run', async () => { + const run = runContext() + + await gate({ ...run }) + await gate({ ...run }) + + expect(mocks.getUserPermissionConfig).toHaveBeenCalledExactlyOnceWith('user-1', 'workspace-1') + }) + + it('shares one in-flight load between concurrent parallel branches', async () => { + const run = runContext() + let release!: (config: unknown) => void + mocks.getUserPermissionConfig.mockReturnValueOnce( + new Promise((resolve) => { + release = resolve + }) + ) + + const branches = Promise.all(Array.from({ length: 5 }, () => gate({ ...run }))) + release({ deniedTools: [] }) + await branches + + expect(mocks.getUserPermissionConfig).toHaveBeenCalledTimes(1) + }) + + it('keeps a separate entry per workspace', async () => { + const run = runContext() + mocks.getUserPermissionConfig.mockImplementation(async (_userId, workspaceId) => + workspaceId === 'workspace-2' ? { deniedTools: ['http_request'] } : { deniedTools: [] } + ) + + await gate({ ...run }, 'workspace-1') + await expect(gate({ ...run }, 'workspace-2')).rejects.toBeInstanceOf(ToolNotAllowedError) + + expect(mocks.getUserPermissionConfig).toHaveBeenCalledTimes(2) + }) + + it('evicts a failed load so a later gate loads again', async () => { + const run = runContext() + mocks.getUserPermissionConfig.mockRejectedValueOnce(new Error('config unavailable')) + + await expect(gate({ ...run })).rejects.toThrow('config unavailable') + await gate({ ...run }) + + expect(mocks.getUserPermissionConfig).toHaveBeenCalledTimes(2) + }) + + it('retries a transient database failure and then caches the result', async () => { + const run = runContext() + mocks.getUserPermissionConfig.mockRejectedValueOnce(databaseError()) + + await gate({ ...run }) + await gate({ ...run }) + + expect(mocks.getUserPermissionConfig).toHaveBeenCalledTimes(2) + }) + + it('does not retry a database failure that is not transient', async () => { + const sqlError = databaseError('42703') + mocks.getUserPermissionConfig.mockRejectedValue(sqlError) + + await expect(gate(runContext())).rejects.toBe(sqlError) + expect(mocks.getUserPermissionConfig).toHaveBeenCalledTimes(1) + }) + + it('fails closed with the last error once retries are exhausted', async () => { + const error = databaseError() + mocks.getUserPermissionConfig.mockRejectedValue(error) + + await expect(gate(runContext())).rejects.toBe(error) + expect(mocks.getUserPermissionConfig).toHaveBeenCalledTimes(3) + }) + + it('stops retrying when the run is cancelled', async () => { + const controller = new AbortController() + const reason = new Error('Execution cancelled') + mocks.getUserPermissionConfig.mockImplementationOnce(async () => { + controller.abort(reason) + throw databaseError() + }) + + await expect(gate(runContext({ abortSignal: controller.signal }))).rejects.toBe(reason) + expect(mocks.getUserPermissionConfig).toHaveBeenCalledTimes(1) + }) + + it('does not memoize on a context that carries no run cache', async () => { + const ctx = { metadata: {} } as unknown as ExecutionContext + + await gate(ctx) + await gate(ctx) + + expect(mocks.getUserPermissionConfig).toHaveBeenCalledTimes(2) + expect(ctx.permissionConfigCache).toBeUndefined() + }) + + it('stops retrying when the caller of a check outside a run cancels', async () => { + const controller = new AbortController() + const reason = new Error('Tool cancelled') + mocks.getUserPermissionConfig.mockImplementationOnce(async () => { + controller.abort(reason) + throw databaseError() + }) + + await expect( + assertPermissionsAllowed({ + userId: 'user-1', + workspaceId: 'workspace-1', + toolId: 'http_request', + signal: controller.signal, + }) + ).rejects.toBe(reason) + expect(mocks.getUserPermissionConfig).toHaveBeenCalledTimes(1) + }) + + it('does not let one caller cancel a load shared through the run cache', async () => { + const run = runContext() + const controller = new AbortController() + mocks.getUserPermissionConfig.mockImplementationOnce(async () => { + controller.abort(new Error('Tool cancelled')) + throw databaseError() + }) + + const cancelled = assertPermissionsAllowed({ + userId: 'user-1', + workspaceId: 'workspace-1', + toolId: 'http_request', + ctx: { ...run }, + signal: controller.signal, + }) + const other = gate({ ...run }) + + await expect(Promise.all([cancelled, other])).resolves.toBeDefined() + expect(mocks.getUserPermissionConfig).toHaveBeenCalledTimes(2) + }) + + it('retries a transient failure for a check made outside a run', async () => { + mocks.getUserPermissionConfig.mockRejectedValueOnce(databaseError()) + + await assertPermissionsAllowed({ + userId: 'user-1', + workspaceId: 'workspace-1', + toolId: 'http_request', + }) + + expect(mocks.getUserPermissionConfig).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/executor/execution/block-executor.test.ts b/apps/sim/executor/execution/block-executor.test.ts index 1cae20df636..2753bca8b3e 100644 --- a/apps/sim/executor/execution/block-executor.test.ts +++ b/apps/sim/executor/execution/block-executor.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { loggerMock } from '@sim/testing' +import { DrizzleQueryError } from 'drizzle-orm/errors' import { beforeEach, describe, expect, it, vi } from 'vitest' import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache' import { createLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest' @@ -9,6 +10,7 @@ import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manif import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref' import { projectTraceSpansForSecrets } from '@/lib/logs/execution/trace-secret-projection' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' +import { validateBlockType } from '@/ee/access-control/utils/permission-check' import { BlockType, EDGE } from '@/executor/constants' import type { DAGNode } from '@/executor/dag/builder' import { BlockExecutor } from '@/executor/execution/block-executor' @@ -752,6 +754,49 @@ describe('BlockExecutor', () => { expect(JSON.stringify(ctx.blockLogs)).not.toContain('"x"') }) + it('never surfaces the SQL or bound parameters of a database failure the block raises', async () => { + const block = createBlock() + const workflow: SerializedWorkflow = { + version: '1', + blocks: [block], + connections: [], + loops: {}, + parallels: {}, + } + const state = new ExecutionState() + const resolver = new VariableResolver(workflow, {}, state) + const handler: BlockHandler = { canHandle: () => true, execute: vi.fn() } + const executor = new BlockExecutor([handler], resolver, {}, state) + const ctx = createContext(state) + const driverError = Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }) + const databaseError = new DrizzleQueryError( + 'select "billing_blocked" from "user_stats" where "user_stats"."user_id" = $1 limit $2', + ['owner-secret-id', 1], + driverError + ) + vi.mocked(validateBlockType).mockRejectedValueOnce(databaseError) + const message = 'An internal error occurred while executing the block. Please try again.' + + const thrown = await executor.execute(ctx, createNode(block), block).catch((error) => error) + + expect(thrown).toBeInstanceOf(Error) + expect(thrown.message).toBe(`Function: ${message}`) + expect(thrown.cause.cause).toBe(databaseError) + expect(handler.execute).not.toHaveBeenCalled() + expect(state.getBlockOutput(block.id)).toEqual({ error: message }) + expect(ctx.blockLogs[0]?.error).toBe(message) + const surfaced = JSON.stringify([state.getBlockOutput(block.id), ctx.blockLogs]) + expect(surfaced).not.toContain('Failed query') + expect(surfaced).not.toContain('owner-secret-id') + + const executionLogger = blockExecutorBaseLogger.withMetadata.mock.results.at(-1)?.value + const logged = executionLogger.error.mock.calls.at(-1)?.[1] + expect(logged).toEqual( + expect.objectContaining({ cause: expect.objectContaining({ code: 'ECONNRESET' }) }) + ) + expect(JSON.stringify(logged)).not.toContain('owner-secret-id') + }) + it('fires block completion callbacks for pausing blocks so clients receive pause output', async () => { const block = { ...createBlock(), diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index 5da5299e51d..e19b7a464ac 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -1,6 +1,8 @@ import { createLogger, type Logger } from '@sim/logger' +import { describeError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { isRecordLike } from '@sim/utils/object' +import { DrizzleQueryError } from 'drizzle-orm/errors' import { isTimeoutAbortReason } from '@/lib/core/execution-limits/types' import { redactApiKeys } from '@/lib/core/security/redaction' import { normalizeStringArray } from '@/lib/core/utils/arrays' @@ -97,6 +99,10 @@ function addTrustedExecutionCosts( } } +/** Replaces a database query failure's message, which carries SQL text and bound parameters. */ +const INTERNAL_DATABASE_ERROR_MESSAGE = + 'An internal error occurred while executing the block. Please try again.' + export class BlockExecutor { private execLogger: Logger @@ -625,7 +631,8 @@ export class BlockExecutor { ): Promise { const endedAt = new Date().toISOString() const duration = performance.now() - startTime - const errorMessage = normalizeError(error) + const isDatabaseError = error instanceof DrizzleQueryError + const errorMessage = isDatabaseError ? INTERNAL_DATABASE_ERROR_MESSAGE : normalizeError(error) const hasLogInputs = inputsForLog && typeof inputsForLog === 'object' && Object.keys(inputsForLog).length > 0 const input = hasLogInputs @@ -764,10 +771,12 @@ export class BlockExecutor { ) { diagnosticRegistry.mergeToolCallRegistry(ctx.resolvedSecretTraceRegistry) } - const errorDiagnostic = projectResolvedSecretDiagnosticError( - error, - diagnosticRegistry ?? ctx.resolvedSecretTraceRegistry - ) + const errorDiagnostic = isDatabaseError + ? { cause: describeError(error) } + : projectResolvedSecretDiagnosticError( + error, + diagnosticRegistry ?? ctx.resolvedSecretTraceRegistry + ) this.execLogger.error( phase === 'input_resolution' ? 'Failed to resolve block inputs' : 'Block execution failed', @@ -818,7 +827,11 @@ export class BlockExecutor { return errorOutput } - const errorToThrow = error instanceof Error ? error : new Error(errorMessage) + const errorToThrow = isDatabaseError + ? new Error(errorMessage, { cause: error }) + : error instanceof Error + ? error + : new Error(errorMessage) throw buildBlockExecutionError({ block, diff --git a/apps/sim/executor/execution/executor.test.ts b/apps/sim/executor/execution/executor.test.ts index ae4280ce553..e03a2b160b3 100644 --- a/apps/sim/executor/execution/executor.test.ts +++ b/apps/sim/executor/execution/executor.test.ts @@ -452,3 +452,34 @@ describe('DAGExecutor executor delegation origin', () => { expect(context.executorDelegationOrigin).toBe(executorDelegationOrigin) }) }) + +describe('DAGExecutor run-scoped permission config cache', () => { + function createContext(executor: DAGExecutor): ExecutionContext { + return ( + executor as unknown as { + createExecutionContext: (workflowId: string) => { context: ExecutionContext } + } + ).createExecutionContext('wf-1').context + } + + it('seeds one cache per run that survives per-block context copies', () => { + const executor = new DAGExecutor({ + workflow: { version: '1', blocks: [], connections: [] }, + contextExtensions: { workspaceId: 'ws-1' }, + }) + + const context = createContext(executor) + const blockContext = { ...context } + + expect(context.permissionConfigCache).toBeInstanceOf(Map) + expect(blockContext.permissionConfigCache).toBe(context.permissionConfigCache) + }) + + it('never shares the cache between runs', () => { + const workflow = { version: '1', blocks: [], connections: [] } + const parent = createContext(new DAGExecutor({ workflow, contextExtensions: {} })) + const child = createContext(new DAGExecutor({ workflow, contextExtensions: {} })) + + expect(child.permissionConfigCache).not.toBe(parent.permissionConfigCache) + }) +}) diff --git a/apps/sim/executor/execution/executor.ts b/apps/sim/executor/execution/executor.ts index c567df277c8..95e7c4eb0e6 100644 --- a/apps/sim/executor/execution/executor.ts +++ b/apps/sim/executor/execution/executor.ts @@ -493,6 +493,7 @@ export class DAGExecutor { : new Set(), // Deliberately not restored from a snapshot: it is a cache, so a resumed run re-resolves. toolBindingLabelCache: new Map(), + permissionConfigCache: new Map(), loopExecutions: snapshotState?.loopExecutions ? new Map( Object.entries(snapshotState.loopExecutions).map(([loopId, scope]) => [ diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index 5fa4b81d0ce..a2e4ae658af 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -421,8 +421,11 @@ export interface ExecutionContext { /** In-flight block-output PII redaction policy (resolved `blockOutputs` stage). */ piiBlockOutputRedaction?: PiiBlockOutputRedaction - permissionConfig?: PermissionGroupConfig | null - permissionConfigLoaded?: boolean + /** + * Per-run memo of permission config loads, keyed by subject and workspace. A Map so the per-block + * shallow copies of this context share it; never inherited by a child workflow's context. + */ + permissionConfigCache?: Map> /** * Resolved display names for the resources an agent tool is bound to, keyed `${kind}:${id}`, diff --git a/apps/sim/lib/core/errors/database-query-error.ts b/apps/sim/lib/core/errors/database-query-error.ts new file mode 100644 index 00000000000..2d7cfde1474 --- /dev/null +++ b/apps/sim/lib/core/errors/database-query-error.ts @@ -0,0 +1,10 @@ +import { findCause } from '@sim/utils/errors' +import { DrizzleQueryError } from 'drizzle-orm/errors' + +/** + * The Drizzle query failure anywhere in `error`'s cause chain. Its message carries the SQL text and + * bound parameters, so it must never reach a user; its `cause` holds the driver error and code. + */ +export function findDatabaseQueryError(error: unknown): DrizzleQueryError | undefined { + return findCause(error, (cause): cause is DrizzleQueryError => cause instanceof DrizzleQueryError) +} diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index 09fbb27c2d7..bd5c24234db 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -7,7 +7,7 @@ import { resolvePrincipalSubject } from '@sim/auth/principal' import { db } from '@sim/db' import { organization, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' +import { getErrorMessage, redactBoundParameters } from '@sim/utils/errors' import { filterUndefined, isPlainRecord, isRecordLike } from '@sim/utils/object' import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks' import type { Edge } from '@xyflow/react' @@ -100,7 +100,7 @@ function describeErrorCause(error: unknown): Record | undefined if (!driver) return undefined return filterUndefined({ name: driver.name, - message: driver.message, + message: redactBoundParameters(driver.message), code: driver.code, severity: driver.severity, detail: driver.detail, diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index e2c0fb8508e..360ddd0d67f 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -1348,41 +1348,7 @@ describe('executeTool Function', () => { ) }) - it('retries transient database failures during permission preflight', async () => { - const driverError = Object.assign(new Error('read ECONNRESET'), { - code: 'ECONNRESET', - errno: 'ECONNRESET', - syscall: 'read', - }) - const databaseError = new DrizzleQueryError( - 'select "id" from "workspace" where "workspace"."id" = $1 limit $2', - ['workspace-secret-id', 1], - driverError - ) - mockAssertPermissionsAllowed.mockRejectedValueOnce(databaseError) - mockToolsLogger.warn.mockClear() - - const result = await executeTool( - 'function_execute', - { code: 'return 1' }, - { executionContext: createToolExecutionContext({ userId: 'user-123' }) } - ) - - expect(result.success).toBe(true) - expect(mockAssertPermissionsAllowed).toHaveBeenCalledTimes(2) - expect(mockExecuteFunction).toHaveBeenCalledTimes(1) - expect(global.fetch).not.toHaveBeenCalled() - expect(mockToolsLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Retrying tool permission preflight after database error'), - expect.objectContaining({ - attempt: 1, - maxAttempts: 3, - cause: expect.objectContaining({ code: 'ECONNRESET' }), - }) - ) - }) - - it('logs exhausted database retries without exposing query details to the caller', async () => { + it('logs a permission database failure without exposing query details to the caller', async () => { const driverError = Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET', errno: 'ECONNRESET', @@ -1408,7 +1374,7 @@ describe('executeTool Function', () => { ) expect(JSON.stringify(result)).not.toContain('Failed query') expect(JSON.stringify(result)).not.toContain('workspace-secret-id') - expect(mockAssertPermissionsAllowed).toHaveBeenCalledTimes(3) + expect(mockAssertPermissionsAllowed).toHaveBeenCalledTimes(1) expect(global.fetch).not.toHaveBeenCalled() const loggedError = mockToolsLogger.error.mock.calls.at(-1)?.[1] @@ -1431,28 +1397,6 @@ describe('executeTool Function', () => { expect(JSON.stringify(loggedError)).not.toContain('workspace-secret-id') }) - it('does not retry non-transient database failures during permission preflight', async () => { - const databaseError = new DrizzleQueryError( - 'select "missing_column" from "workspace"', - [], - Object.assign(new Error('column does not exist'), { code: '42703' }) - ) - mockAssertPermissionsAllowed.mockRejectedValue(databaseError) - - const result = await executeTool( - 'function_execute', - { code: 'return 1' }, - { executionContext: createToolExecutionContext({ userId: 'user-123' }) } - ) - - expect(result.success).toBe(false) - expect(result.error).toBe( - 'An internal error occurred while executing the tool. Please try again.' - ) - expect(mockAssertPermissionsAllowed).toHaveBeenCalledTimes(1) - expect(global.fetch).not.toHaveBeenCalled() - }) - it('surfaces cancellation instead of a concurrent permission database failure', async () => { const controller = new AbortController() const abortReason = new Error('Execution cancelled') diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 748c4d8a7c7..63282079e88 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -1,10 +1,9 @@ import { createLogger } from '@sim/logger' import { isLoopbackIp, unwrapIpv6Brackets } from '@sim/security/ssrf' -import { describeError, findCause, getErrorMessage, toError } from '@sim/utils/errors' +import { describeError, getErrorMessage, toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { isPlainRecord, isRecordLike } from '@sim/utils/object' import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry' -import { DrizzleQueryError } from 'drizzle-orm/errors' import { ApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import type { FunctionExecuteBody } from '@/lib/api/contracts' @@ -17,7 +16,7 @@ import { serializeBillingAttributionHeader, } from '@/lib/billing/core/billing-attribution' import { isHosted } from '@/lib/core/config/env-flags' -import { isRetryableInfrastructureError } from '@/lib/core/errors/retryable-infrastructure' +import { findDatabaseQueryError } from '@/lib/core/errors/database-query-error' import { createTimeoutAbortController, DEFAULT_EXECUTION_TIMEOUT_MS, @@ -116,8 +115,6 @@ const logger = createLogger('Tools') const PRIVATE_TOOL_METADATA_ERROR_MESSAGE = 'Internal tool response metadata could not be verified' const INTERNAL_DATABASE_ERROR_MESSAGE = 'An internal error occurred while executing the tool. Please try again.' -const PERMISSION_PREFLIGHT_MAX_ATTEMPTS = 3 -const PERMISSION_PREFLIGHT_RETRY_BACKOFF = { baseMs: 25, maxMs: 100 } as const function projectToolLogMetadata( metadata: Record, @@ -134,53 +131,6 @@ function projectToolLogMetadata( : { ...structuralFallback, redacted: true } } -interface ToolPermissionPreflight { - userId: string - workspaceId: string - toolId: string - toolKind?: 'skill' | 'custom' | 'mcp' - ctx?: ExecutionContext - requestId: string - signal?: AbortSignal -} - -async function assertToolPermissionsWithRetry({ - requestId, - signal, - ...permission -}: ToolPermissionPreflight): Promise { - for (let attempt = 1; ; attempt += 1) { - signal?.throwIfAborted() - try { - await assertPermissionsAllowed(permission) - return - } catch (error) { - signal?.throwIfAborted() - const isDatabaseQueryError = Boolean( - findCause(error, (cause): cause is DrizzleQueryError => cause instanceof DrizzleQueryError) - ) - if ( - attempt >= PERMISSION_PREFLIGHT_MAX_ATTEMPTS || - !isDatabaseQueryError || - !isRetryableInfrastructureError(error) - ) { - throw error - } - - const delayMs = backoffWithJitter(attempt, null, PERMISSION_PREFLIGHT_RETRY_BACKOFF) - logger.warn(`[${requestId}] Retrying tool permission preflight after database error`, { - toolId: permission.toolId, - attempt, - maxAttempts: PERMISSION_PREFLIGHT_MAX_ATTEMPTS, - delayMs, - cause: describeError(error), - }) - await sleep(delayMs) - signal?.throwIfAborted() - } - } -} - /** * Which environment-variable reference forms a caller's `user-only` params may use. * @@ -1796,15 +1746,20 @@ async function executeToolImplementation( // Runs for ALL tools (not just kinded ones) so the per-tool `deniedTools` // denylist is enforced alongside the existing mcp/custom/skill gates. if (scope.userId && scope.workspaceId) { - await assertToolPermissionsWithRetry({ - userId: scope.userId, - workspaceId: scope.workspaceId, - toolId: normalizedToolId, - toolKind, - ctx: executionContext, - requestId, - signal: effectiveSignal, - }) + effectiveSignal?.throwIfAborted() + try { + await assertPermissionsAllowed({ + userId: scope.userId, + workspaceId: scope.workspaceId, + toolId: normalizedToolId, + toolKind, + ctx: executionContext, + signal: effectiveSignal, + }) + } catch (error) { + effectiveSignal?.throwIfAborted() + throw error + } } if (normalizedToolId === 'load_skill') { @@ -2327,10 +2282,7 @@ async function executeToolImplementation( } } catch (error: any) { const normalizedError = toError(error) - const databaseQueryError = findCause( - error, - (cause): cause is DrizzleQueryError => cause instanceof DrizzleQueryError - ) + const databaseQueryError = findDatabaseQueryError(error) const databaseErrorCause = databaseQueryError ? describeError(error) : undefined logger.error( `[${requestId}] Error executing tool ${toolId}:`, From 350e5b65bb9ba52c256b60a514cbf357777f2a1d Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 14 Sep 2026 20:09:42 -0700 Subject: [PATCH 07/11] chore: cleanup comments (#7848) --- apps/docs/app/global.css | 2 -- apps/docs/lib/source.ts | 3 +-- apps/sim/lib/knowledge/access/drive-permissions.test.ts | 5 ----- 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css index b0f169b33fb..f98df319fc2 100644 --- a/apps/docs/app/global.css +++ b/apps/docs/app/global.css @@ -1013,8 +1013,6 @@ div.flex.flex-row.items-start.bg-fd-secondary.border.rounded-lg.text-xs { display: none !important; } -/* Method+path bar — cleaner, lighter styling like Gumloop. - Override bg-fd-card CSS variable directly for reliability. */ #nd-page:has(.api-page-header) div.flex.flex-row.items-center.rounded-xl.border.not-prose { --color-fd-card: var(--surface-3) !important; background-color: var(--surface-3) !important; diff --git a/apps/docs/lib/source.ts b/apps/docs/lib/source.ts index dc2e655a521..8fb22c4197a 100644 --- a/apps/docs/lib/source.ts +++ b/apps/docs/lib/source.ts @@ -17,8 +17,7 @@ const METHOD_COLORS: Record = { } /** - * Custom openapi plugin that places method badges BEFORE the page name - * in the sidebar (like Mintlify/Gumloop) instead of after. + * Places HTTP method badges before page names in the sidebar. */ function openapiPluginBadgeLeft() { return { diff --git a/apps/sim/lib/knowledge/access/drive-permissions.test.ts b/apps/sim/lib/knowledge/access/drive-permissions.test.ts index d0dc1956dc0..81564eed5b8 100644 --- a/apps/sim/lib/knowledge/access/drive-permissions.test.ts +++ b/apps/sim/lib/knowledge/access/drive-permissions.test.ts @@ -96,11 +96,6 @@ describe('driveFileAcl', () => { ).toEqual(['u:alice@corp.com']) }) - /** - * The deviation from Onyx that matters most: their file path makes an - * `anyone` grant public without consulting `allowFileDiscovery`, so a file - * anyone ever shared by link becomes fully searchable. - */ it('still refuses a link-only anyone share', () => { expect(acl([{ type: 'anyone', allowFileDiscovery: false }], OPEN)).toEqual(['link']) }) From a900c0d4736ebf072911e7fd7850c9d5c7b9a958 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 14 Sep 2026 20:24:04 -0700 Subject: [PATCH 08/11] fix(settings): seed the server deployment shape on organization and standalone settings surfaces (#7849) * fix(organizations): seed the server deployment shape on the organization surface * chore(organizations): describe organization-surface deployment shape seeding * improvement(settings): carry the deployment shape on the organization context and seed standalone settings --- .claude/rules/global.md | 2 +- .cursor/rules/global.mdc | 2 +- CLAUDE.md | 2 +- apps/sim/app/account/settings/layout.tsx | 7 +- .../app/o/[organizationId]/layout.test.tsx | 5 +- apps/sim/app/o/[organizationId]/layout.tsx | 3 +- .../providers/organization-provider.test.tsx | 79 +++++++++++++++++++ .../providers/organization-provider.tsx | 17 ++-- apps/sim/app/selfhost/settings/layout.tsx | 7 +- .../providers/workspace-host-provider.tsx | 20 +---- ...standalone-settings-shell-seeding.test.tsx | 74 +++++++++++++++++ .../settings/standalone-settings-shell.tsx | 5 ++ apps/sim/hooks/use-seed-deployment-shape.ts | 16 ++++ apps/sim/lib/core/config/deployment-shape.ts | 14 ++-- apps/sim/lib/organizations/surface.test.ts | 2 + apps/sim/lib/organizations/surface.ts | 6 +- 16 files changed, 218 insertions(+), 43 deletions(-) create mode 100644 apps/sim/app/o/[organizationId]/providers/organization-provider.test.tsx create mode 100644 apps/sim/components/settings/standalone-settings-shell-seeding.test.tsx create mode 100644 apps/sim/hooks/use-seed-deployment-shape.ts diff --git a/.claude/rules/global.md b/.claude/rules/global.md index 90852e76206..3a222b935d5 100644 --- a/.claude/rules/global.md +++ b/.claude/rules/global.md @@ -73,7 +73,7 @@ const filtered = filterUndefined(obj) ``` ## Deployment flags in the browser -Client code inside a workspace reads `hosted`, `billingEnabled`, `chatEnabled`, and the enterprise feature set through `useDeploymentShape()` (components) or `getDeploymentShape()` (block conditions, stores, helpers) from `@/lib/core/config/deployment-shape`, never the `isHosted`/`isBillingEnabled` constants from `env-flags`. Those constants freeze at module init from the root layout's `NEXT_PUBLIC_*` transport, which Next's bare 404 shell and `global-error` never emit, so a tab recovered from one would render Sim Cloud as self-hosted. The reader is seeded from the server-resolved workspace host context. Server code keeps reading `env-flags`. +Client code inside a workspace, organization, or standalone settings surface reads `hosted`, `billingEnabled`, `chatEnabled`, and the enterprise feature set through `useDeploymentShape()` (components) or `getDeploymentShape()` (block conditions, stores, helpers) from `@/lib/core/config/deployment-shape`, never the `isHosted`/`isBillingEnabled` constants from `env-flags`. Those constants freeze at module init from the root layout's `NEXT_PUBLIC_*` transport, which Next's bare 404 shell and `global-error` never emit, so a tab recovered from one would render Sim Cloud as self-hosted. The reader is seeded from the server-resolved workspace host context, organization layout, or standalone settings layout. Server code keeps reading `env-flags`. ## Package Manager Use `bun` and `bunx`, not `npm` and `npx`. diff --git a/.cursor/rules/global.mdc b/.cursor/rules/global.mdc index 09c1388c196..1bf193b00ec 100644 --- a/.cursor/rules/global.mdc +++ b/.cursor/rules/global.mdc @@ -76,7 +76,7 @@ const filtered = filterUndefined(obj) ``` ## Deployment flags in the browser -Client code inside a workspace reads `hosted`, `billingEnabled`, `chatEnabled`, and the enterprise feature set through `useDeploymentShape()` (components) or `getDeploymentShape()` (block conditions, stores, helpers) from `@/lib/core/config/deployment-shape`, never the `isHosted`/`isBillingEnabled` constants from `env-flags`. Those constants freeze at module init from the root layout's `NEXT_PUBLIC_*` transport, which Next's bare 404 shell and `global-error` never emit, so a tab recovered from one would render Sim Cloud as self-hosted. The reader is seeded from the server-resolved workspace host context. Server code keeps reading `env-flags`. +Client code inside a workspace, organization, or standalone settings surface reads `hosted`, `billingEnabled`, `chatEnabled`, and the enterprise feature set through `useDeploymentShape()` (components) or `getDeploymentShape()` (block conditions, stores, helpers) from `@/lib/core/config/deployment-shape`, never the `isHosted`/`isBillingEnabled` constants from `env-flags`. Those constants freeze at module init from the root layout's `NEXT_PUBLIC_*` transport, which Next's bare 404 shell and `global-error` never emit, so a tab recovered from one would render Sim Cloud as self-hosted. The reader is seeded from the server-resolved workspace host context, organization layout, or standalone settings layout. Server code keeps reading `env-flags`. ## Package Manager Use `bun` and `bunx`, not `npm` and `npx`. diff --git a/CLAUDE.md b/CLAUDE.md index b0f73d13053..09dabbc5d78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ You are a professional software engineer. All code must follow best practices: a - `omit(obj, keys)` / `filterUndefined(obj)` from `@sim/utils/object` — object trimming; never `Object.fromEntries(Object.entries(...).filter(...))` - `truncate(str, maxLength, suffix?)` from `@sim/utils/string` — never inline slice + ellipsis - `backoffWithJitter(attempt, retryAfterMs, options?)` / `parseRetryAfter(header)` from `@sim/utils/retry` — shared retry pacing; never reimplement exponential backoff inline -- **Deployment flags in the browser**: client code inside a workspace reads `hosted`, `billingEnabled`, `chatEnabled`, and the enterprise feature set through `useDeploymentShape()` (components) or `getDeploymentShape()` (block conditions, stores, helpers) from `@/lib/core/config/deployment-shape`, never `isHosted`/`isBillingEnabled`/... from `env-flags`. The constants freeze at module init from the root layout's `NEXT_PUBLIC_*` transport, which Next's bare 404 shell and `global-error` never emit; the reader is seeded from the server-resolved workspace host context instead. Server code keeps reading `env-flags` +- **Deployment flags in the browser**: client code inside a workspace, organization, or standalone settings surface reads `hosted`, `billingEnabled`, `chatEnabled`, and the enterprise feature set through `useDeploymentShape()` (components) or `getDeploymentShape()` (block conditions, stores, helpers) from `@/lib/core/config/deployment-shape`, never `isHosted`/`isBillingEnabled`/... from `env-flags`. The constants freeze at module init from the root layout's `NEXT_PUBLIC_*` transport, which Next's bare 404 shell and `global-error` never emit; the reader is seeded from the server-resolved workspace host context, organization layout, or standalone settings layout instead. Server code keeps reading `env-flags` - **Package Manager**: Use `bun` and `bunx`, not `npm` and `npx` - **Type-checking**: Run `bun run type-check` (per workspace) or `bunx turbo run type-check` (all of them). Do not remove the `@typescript/native` alias from the root `devDependencies` — nothing imports it, but it is what makes a bare `tsc` resolve to the native TypeScript 7 compiler instead of the ~10x slower JavaScript TypeScript 6 one that `@typescript/typescript6` pulls in transitively. `bun run check:native-typecheck` enforces this diff --git a/apps/sim/app/account/settings/layout.tsx b/apps/sim/app/account/settings/layout.tsx index cafd6d2f3c8..e18e8bb84f1 100644 --- a/apps/sim/app/account/settings/layout.tsx +++ b/apps/sim/app/account/settings/layout.tsx @@ -1,6 +1,7 @@ import { redirect } from 'next/navigation' import { StandaloneSettingsShell } from '@/components/settings/standalone-settings-shell' import { getSession } from '@/lib/auth' +import { resolveDeploymentShape } from '@/lib/core/config/deployment-shape' import { isPlatformAdmin } from '@/lib/permissions/super-user' export default async function AccountSettingsLayout({ children }: { children: React.ReactNode }) { @@ -9,7 +10,11 @@ export default async function AccountSettingsLayout({ children }: { children: Re const isSuperUser = await isPlatformAdmin(session.user.id) return ( - + {children} ) diff --git a/apps/sim/app/o/[organizationId]/layout.test.tsx b/apps/sim/app/o/[organizationId]/layout.test.tsx index 2f16491f8b5..0ad7389f7ac 100644 --- a/apps/sim/app/o/[organizationId]/layout.test.tsx +++ b/apps/sim/app/o/[organizationId]/layout.test.tsx @@ -7,7 +7,7 @@ import { authMockFns } from '@sim/testing' import { dehydrate } from '@tanstack/react-query' import { renderToStaticMarkup } from 'react-dom/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { isChatEnabled } from '@/lib/core/config/env-flags' +import { resolveDeploymentShape } from '@/lib/core/config/deployment-shape' const { mockGetOrganizationSurfaceContext, @@ -83,6 +83,7 @@ const SURFACE_CONTEXT = { organization: { id: 'org-1', name: 'Acme', slug: 'acme', logo: null, memberCount: 1 }, viewer: { role: 'member', isAdmin: false }, searchAccess: { memberScoped: true, sourceMirrored: true }, + deployment: resolveDeploymentShape(), } describe('OrganizationLayout', () => { @@ -127,7 +128,7 @@ describe('OrganizationLayout', () => { expect(html).toContain('Organization child') expect(mockUseMothershipChatEvents).toHaveBeenCalledWith( { organizationId: 'org-1' }, - isChatEnabled + SURFACE_CONTEXT.deployment.chatEnabled ) expect(html).not.toContain('Stop impersonating') expect(mockWorkspaceChrome).toHaveBeenCalledWith( diff --git a/apps/sim/app/o/[organizationId]/layout.tsx b/apps/sim/app/o/[organizationId]/layout.tsx index 41628908ca4..fb4b66e85bd 100644 --- a/apps/sim/app/o/[organizationId]/layout.tsx +++ b/apps/sim/app/o/[organizationId]/layout.tsx @@ -3,7 +3,6 @@ import { cookies } from 'next/headers' import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { getActiveOrganizationId } from '@/lib/auth/session-response' -import { isChatEnabled } from '@/lib/core/config/env-flags' import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' import { getQueryClient } from '@/app/_shell/providers/get-query-client' @@ -62,7 +61,7 @@ export default async function OrganizationLayout({ return ( - +
diff --git a/apps/sim/app/o/[organizationId]/providers/organization-provider.test.tsx b/apps/sim/app/o/[organizationId]/providers/organization-provider.test.tsx new file mode 100644 index 00000000000..a188abc9442 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/providers/organization-provider.test.tsx @@ -0,0 +1,79 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockUseMothershipChatEvents } = vi.hoisted(() => ({ + mockUseMothershipChatEvents: vi.fn(), +})) + +vi.mock('@/hooks/use-mothership-chat-events', () => ({ + useMothershipChatEvents: mockUseMothershipChatEvents, +})) + +import { + getDeploymentShape, + resetDeploymentShape, + resolveDeploymentShape, + useDeploymentShape, +} from '@/lib/core/config/deployment-shape' +import type { OrganizationSurfaceContext } from '@/lib/organizations/surface' +import { OrganizationProvider } from '@/app/o/[organizationId]/providers/organization-provider' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +function ScimReader() { + return {String(useDeploymentShape().features.scim)} +} + +let host: HTMLDivElement +let root: Root + +beforeEach(() => { + resetDeploymentShape() + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) +}) + +afterEach(() => { + act(() => root.unmount()) + host.remove() + vi.clearAllMocks() +}) + +describe('OrganizationProvider', () => { + it("seeds the context's deployment shape before children render and follows its chat switch", () => { + /** Differs from this environment's env fallback in every field read below. */ + const fallback = resolveDeploymentShape() + const deployment = { + ...fallback, + chatEnabled: !fallback.chatEnabled, + features: { ...fallback.features, scim: !fallback.features.scim }, + } + const context = { + organization: { id: 'org-1', name: 'Acme', slug: 'acme', logo: null, memberCount: 1 }, + searchAccess: { memberScoped: true, sourceMirrored: true }, + deployment, + } as unknown as OrganizationSurfaceContext + + act(() => + root.render( + + + + ) + ) + + expect(host.querySelector('[data-testid="scim"]')?.textContent).toBe( + String(deployment.features.scim) + ) + expect(getDeploymentShape()).toBe(deployment) + expect(mockUseMothershipChatEvents).toHaveBeenCalledWith( + { organizationId: 'org-1' }, + deployment.chatEnabled + ) + }) +}) diff --git a/apps/sim/app/o/[organizationId]/providers/organization-provider.tsx b/apps/sim/app/o/[organizationId]/providers/organization-provider.tsx index af1f23d4260..6a980627e0d 100644 --- a/apps/sim/app/o/[organizationId]/providers/organization-provider.tsx +++ b/apps/sim/app/o/[organizationId]/providers/organization-provider.tsx @@ -3,28 +3,27 @@ import { createContext, type ReactNode, useContext } from 'react' import type { OrganizationSurfaceContext } from '@/lib/organizations/surface' import { useMothershipChatEvents } from '@/hooks/use-mothership-chat-events' +import { useSeedDeploymentShape } from '@/hooks/use-seed-deployment-shape' const OrganizationContextValue = createContext(null) interface OrganizationProviderProps { children: ReactNode context: OrganizationSurfaceContext - chatEnabled: boolean } /** * Provides the route-resolved organization and the viewer's standing in it to the - * organization surface. The layout resolves both on the server, so the first paint - * already knows the organization's name and logo. + * organization surface, and seeds the context's deployment shape before any child + * renders, as the workspace host provider does for workspace routes. The layout + * resolves the context on the server, so the first paint already knows the + * organization's name, logo, and which features this deployment serves. */ -export function OrganizationProvider({ - children, - context, - chatEnabled, -}: OrganizationProviderProps) { +export function OrganizationProvider({ children, context }: OrganizationProviderProps) { + useSeedDeploymentShape(context.deployment) useMothershipChatEvents( context.searchAccess.memberScoped ? { organizationId: context.organization.id } : undefined, - chatEnabled + context.deployment.chatEnabled ) return ( diff --git a/apps/sim/app/selfhost/settings/layout.tsx b/apps/sim/app/selfhost/settings/layout.tsx index ad705241995..54298406a75 100644 --- a/apps/sim/app/selfhost/settings/layout.tsx +++ b/apps/sim/app/selfhost/settings/layout.tsx @@ -1,10 +1,15 @@ import { redirect } from 'next/navigation' import { StandaloneSettingsShell } from '@/components/settings/standalone-settings-shell' import { getSession } from '@/lib/auth' +import { resolveDeploymentShape } from '@/lib/core/config/deployment-shape' export default async function SelfHostSettingsLayout({ children }: { children: React.ReactNode }) { const session = await getSession() if (!session?.user) redirect('/login') - return {children} + return ( + + {children} + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/providers/workspace-host-provider.tsx b/apps/sim/app/workspace/[workspaceId]/providers/workspace-host-provider.tsx index b4ff6a1c872..cc22294ec90 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/workspace-host-provider.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/workspace-host-provider.tsx @@ -1,28 +1,14 @@ 'use client' -import { createContext, type ReactNode, useContext, useEffect, useState } from 'react' +import { createContext, type ReactNode, useContext } from 'react' import { isApiClientError } from '@/lib/api/client/errors' -import type { DeploymentShape, WorkspaceHostContext } from '@/lib/api/contracts/workspaces' -import { seedDeploymentShape } from '@/lib/core/config/deployment-shape' +import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' import { WorkspaceAccessDenied } from '@/app/workspace/[workspaceId]/components/workspace-access-denied' import { useWorkspaceHostContextQuery } from '@/hooks/queries/workspace-host' +import { useSeedDeploymentShape } from '@/hooks/use-seed-deployment-shape' const WorkspaceHostContextValue = createContext(null) -/** - * Seeds from the provider's own render, ahead of any child, so the first workspace - * paint already reads the server value; the effect then follows the host context as - * it refetches. The lazy initializer is React's once-per-mount hook for work that must - * precede children. Lives here rather than with the reader because block definitions - * import the reader into React Server Component graphs, where React hooks are rejected. - */ -function useSeedDeploymentShape(shape: DeploymentShape | undefined): void { - useState(() => seedDeploymentShape(shape)) - useEffect(() => { - seedDeploymentShape(shape) - }, [shape]) -} - interface WorkspaceHostProviderProps { children: ReactNode workspaceId: string diff --git a/apps/sim/components/settings/standalone-settings-shell-seeding.test.tsx b/apps/sim/components/settings/standalone-settings-shell-seeding.test.tsx new file mode 100644 index 00000000000..8e8dbc1d4cf --- /dev/null +++ b/apps/sim/components/settings/standalone-settings-shell-seeding.test.tsx @@ -0,0 +1,74 @@ +/** + * @vitest-environment jsdom + */ +import type { ReactNode } from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockSettingsSidebar } = vi.hoisted(() => ({ + mockSettingsSidebar: vi.fn((_props: { items: { id: string }[] }) => null), +})) + +vi.mock('next/navigation', () => ({ usePathname: () => '/selfhost/settings/general' })) +vi.mock('@/components/settings/settings-sidebar', () => ({ SettingsSidebar: mockSettingsSidebar })) +vi.mock('@/components/settings/settings-header', () => ({ + SettingsHeaderProvider: ({ children }: { children: ReactNode }) => children, + SettingsHeaderShell: ({ children }: { children: ReactNode }) => children, +})) +vi.mock('@/components/settings/settings-panel', () => ({ + SettingsSectionProvider: ({ children }: { children: ReactNode }) => children, +})) +vi.mock('@/components/settings/use-settings-before-unload', () => ({ + useSettingsBeforeUnload: vi.fn(), +})) + +import { StandaloneSettingsShell } from '@/components/settings/standalone-settings-shell' +import { + getDeploymentShape, + resetDeploymentShape, + resolveDeploymentShape, +} from '@/lib/core/config/deployment-shape' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +let host: HTMLDivElement +let root: Root + +beforeEach(() => { + resetDeploymentShape() + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) +}) + +afterEach(() => { + act(() => root.unmount()) + host.remove() + vi.clearAllMocks() +}) + +describe('StandaloneSettingsShell', () => { + it('filters its navigation by the server-resolved shape, not the env fallback', () => { + /** Inverts the fallback's hosted and billing switches, which decide the Billing and Chat keys items. */ + const fallback = resolveDeploymentShape() + const deployment = { + ...fallback, + hosted: !fallback.hosted, + billingEnabled: !fallback.billingEnabled, + } + + act(() => + root.render( + + {null} + + ) + ) + + const itemIds = mockSettingsSidebar.mock.calls[0][0].items.map((item) => item.id) + expect(itemIds.includes('billing')).toBe(deployment.billingEnabled) + expect(itemIds.includes('chat-keys')).toBe(deployment.hosted) + expect(getDeploymentShape()).toBe(deployment) + }) +}) diff --git a/apps/sim/components/settings/standalone-settings-shell.tsx b/apps/sim/components/settings/standalone-settings-shell.tsx index 42394caab56..e25f6f8e48a 100644 --- a/apps/sim/components/settings/standalone-settings-shell.tsx +++ b/apps/sim/components/settings/standalone-settings-shell.tsx @@ -17,11 +17,15 @@ import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settin import { SettingsSectionProvider } from '@/components/settings/settings-panel' import { SettingsSidebar } from '@/components/settings/settings-sidebar' import { useSettingsBeforeUnload } from '@/components/settings/use-settings-before-unload' +import type { DeploymentShape } from '@/lib/api/contracts/workspaces' import { useDeploymentShape } from '@/lib/core/config/deployment-shape' +import { useSeedDeploymentShape } from '@/hooks/use-seed-deployment-shape' import { SIDEBAR_WIDTH } from '@/stores/constants' interface StandaloneSettingsShellBaseProps { children: ReactNode + /** The server-resolved deployment shape, seeded before the sidebar and sections read it. */ + deployment: DeploymentShape } interface AccountSettingsShellProps extends StandaloneSettingsShellBaseProps { @@ -37,6 +41,7 @@ type StandaloneSettingsShellProps = AccountSettingsShellProps | SelfHostSettings export function StandaloneSettingsShell(props: StandaloneSettingsShellProps) { const { children, plane } = props + useSeedDeploymentShape(props.deployment) useSettingsBeforeUnload() const pathname = usePathname() const { hosted, billingEnabled } = useDeploymentShape() diff --git a/apps/sim/hooks/use-seed-deployment-shape.ts b/apps/sim/hooks/use-seed-deployment-shape.ts new file mode 100644 index 00000000000..9f27576df93 --- /dev/null +++ b/apps/sim/hooks/use-seed-deployment-shape.ts @@ -0,0 +1,16 @@ +'use client' + +import { useEffect, useState } from 'react' +import type { DeploymentShape } from '@/lib/api/contracts/workspaces' +import { seedDeploymentShape } from '@/lib/core/config/deployment-shape' + +/** + * Seeds a server-resolved deployment shape during the caller's own render, ahead of its + * children, then follows later changes to the shape in an effect. + */ +export function useSeedDeploymentShape(shape: DeploymentShape | undefined): void { + useState(() => seedDeploymentShape(shape)) + useEffect(() => { + seedDeploymentShape(shape) + }, [shape]) +} diff --git a/apps/sim/lib/core/config/deployment-shape.ts b/apps/sim/lib/core/config/deployment-shape.ts index 299610a5ff6..5d415b26020 100644 --- a/apps/sim/lib/core/config/deployment-shape.ts +++ b/apps/sim/lib/core/config/deployment-shape.ts @@ -34,18 +34,18 @@ import { * after `retry()` or a client-side navigation recovers the app in place. Sim Cloud then * renders as self-hosted: API Key fields on hosted models, no Auto model, no billing. * - * Workspace surfaces therefore read the shape the workspace host context carries, - * resolved on the server per request and seeded here by the host provider before any - * workspace child renders. The constants remain the fallback only outside a workspace, - * where the root layout always runs. + * Workspace, organization, and standalone settings surfaces therefore read a shape + * resolved on the server per request, seeded here by their provider or shell before any + * child renders. The constants remain the fallback only outside those surfaces, where + * the root layout always runs. * * Block definitions import this module, which puts it in React Server Component graphs * (the block registry is loaded by auth and workflow lifecycle code), so it must not - * import React hooks itself; the seeding hook lives with the client-side host provider. + * import React hooks itself; the seeding hook lives in `@/hooks/use-seed-deployment-shape`. */ interface DeploymentShapeState { - /** Server-resolved shape from the workspace host context; `null` until a workspace mounts. */ + /** Server-resolved shape seeded by a surface provider; `null` until one mounts. */ seeded: DeploymentShape | null seed: (shape: DeploymentShape) => void reset: () => void @@ -140,7 +140,7 @@ export function resetDeploymentShape(): void { /** * The deployment shape for code that runs outside React, such as block `condition` * functions and sub-block visibility. Server callers get the resolved truth; browser - * callers get the seeded server value inside a workspace, and the `NEXT_PUBLIC_*` + * callers get the seeded server value inside a seeded surface, and the `NEXT_PUBLIC_*` * fallback elsewhere. */ export function getDeploymentShape(): DeploymentShape { diff --git a/apps/sim/lib/organizations/surface.test.ts b/apps/sim/lib/organizations/surface.test.ts index 76255e17a13..4a622a29d61 100644 --- a/apps/sim/lib/organizations/surface.test.ts +++ b/apps/sim/lib/organizations/surface.test.ts @@ -72,6 +72,7 @@ describe('getOrganizationSurfaceContext', () => { billingEnabled: true, hasEnterprisePlan: true, }), + deployment: expect.objectContaining({ hosted: true, billingEnabled: true }), }) expect(mockSearchAccess).toHaveBeenCalledWith({ organizationId: 'org-1' }) expect(mockEnterprisePlan).toHaveBeenCalledWith('org-1') @@ -102,6 +103,7 @@ describe('getOrganizationSurfaceContext', () => { billingEnabled: false, selfHosted: { 'audit-logs': true }, }, + deployment: { hosted: false, billingEnabled: false, features: { auditLogs: true } }, }) expect(mockEnterprisePlan).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/organizations/surface.ts b/apps/sim/lib/organizations/surface.ts index 8b7b22e716c..ee2c3283c5a 100644 --- a/apps/sim/lib/organizations/surface.ts +++ b/apps/sim/lib/organizations/surface.ts @@ -7,6 +7,7 @@ import { type OrganizationSettingsFeatures, } from '@/components/settings/navigation' import type { OrganizationRole } from '@/lib/api/contracts/primitives' +import type { DeploymentShape } from '@/lib/api/contracts/workspaces' import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' import { getDeploymentShape } from '@/lib/core/config/deployment-shape' import { isInvitationsDisabled } from '@/lib/core/config/env-flags' @@ -37,7 +38,8 @@ interface OrganizationSurfaceViewer { /** * Everything the organization surface (`/o/[organizationId]`) needs before it renders: - * the routed organization's identity and the viewer's standing in it. A `null` + * the routed organization's identity, the viewer's standing in it, and the + * server-resolved deployment shape its client code reads. A `null` * result is an explicit access denial — the viewer is not a member, or there is no * such organization. */ @@ -47,6 +49,7 @@ export interface OrganizationSurfaceContext { connectedAccountsAvailable: boolean searchAccess: KnowledgeAccessAvailability settingsFeatures: OrganizationSettingsFeatures + deployment: DeploymentShape } /** @@ -110,6 +113,7 @@ async function resolveOrganizationSurfaceContext( connectedAccountsAvailable, searchAccess, settingsFeatures: getOrganizationSettingsFeatures(hasEnterprisePlan, deployment), + deployment, } } From e92de57edb3ee734d76513f5317bd505663a5ac1 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 14 Sep 2026 20:36:03 -0700 Subject: [PATCH 09/11] fix(executor): seed run-wide access key lists and scope exact-key cache grants (#7850) - Seed largeValueKeys/fileKeys in createExecutionContext so keys a block records on its per-block context copy reach later blocks; child executors get their own lists, never the parent's - Share one exact-key grant predicate between the async storage check and the sync large-value cache, which previously honored a granted key without checking the key's workspace and workflow - Correct the executionFilesById TSDoc: the index is built per block from block states --- apps/sim/executor/execution/executor.test.ts | 68 +++++++++++++------ apps/sim/executor/execution/executor.ts | 4 +- apps/sim/executor/types.ts | 9 ++- apps/sim/lib/execution/payloads/cache.test.ts | 48 ++++++++++++- apps/sim/lib/execution/payloads/cache.ts | 3 +- .../lib/execution/payloads/large-value-ref.ts | 16 +++++ .../payloads/materialization.server.ts | 4 +- 7 files changed, 122 insertions(+), 30 deletions(-) diff --git a/apps/sim/executor/execution/executor.test.ts b/apps/sim/executor/execution/executor.test.ts index e03a2b160b3..40ac10a677c 100644 --- a/apps/sim/executor/execution/executor.test.ts +++ b/apps/sim/executor/execution/executor.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { describe, expect, it, vi } from 'vitest' +import { mergeFileKeys, mergeLargeValueKeys } from '@/lib/execution/payloads/access-keys' import { BlockType } from '@/executor/constants' import { DAGBuilder } from '@/executor/dag/builder' import { DAGExecutor } from '@/executor/execution/executor' @@ -10,6 +11,15 @@ import type { ExecutionContext, ExecutionResult } from '@/executor/types' import { buildSentinelStartId } from '@/executor/utils/subflow-utils' import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' +/** Reaches the executor's private context factory, which every run's root context comes from. */ +function createExecutionContext(executor: DAGExecutor, workflowId = 'wf-1'): ExecutionContext { + return ( + executor as unknown as { + createExecutionContext: (workflowId: string) => { context: ExecutionContext } + } + ).createExecutionContext(workflowId).context +} + function createExecutor(): DAGExecutor { return new DAGExecutor({ workflow: { @@ -404,12 +414,7 @@ describe('DAGExecutor createExecutionContext useDraftState', () => { : ({ useDraftState: opts.metadataUseDraftState } as ExecutionContext['metadata']), }, }) - const { context } = ( - executor as unknown as { - createExecutionContext: (workflowId: string) => { context: ExecutionContext } - } - ).createExecutionContext('wf-1') - return context.metadata.useDraftState + return createExecutionContext(executor).metadata.useDraftState } it('honors explicit useDraftState=true even when isDeployedContext is true (table dispatcher)', () => { @@ -442,11 +447,7 @@ describe('DAGExecutor executor delegation origin', () => { contextExtensions: { executorDelegationOrigin }, }) - const { context } = ( - executor as unknown as { - createExecutionContext: (workflowId: string) => { context: ExecutionContext } - } - ).createExecutionContext('child-workflow') + const context = createExecutionContext(executor, 'child-workflow') expect(context.workflowId).toBe('child-workflow') expect(context.executorDelegationOrigin).toBe(executorDelegationOrigin) @@ -454,21 +455,13 @@ describe('DAGExecutor executor delegation origin', () => { }) describe('DAGExecutor run-scoped permission config cache', () => { - function createContext(executor: DAGExecutor): ExecutionContext { - return ( - executor as unknown as { - createExecutionContext: (workflowId: string) => { context: ExecutionContext } - } - ).createExecutionContext('wf-1').context - } - it('seeds one cache per run that survives per-block context copies', () => { const executor = new DAGExecutor({ workflow: { version: '1', blocks: [], connections: [] }, contextExtensions: { workspaceId: 'ws-1' }, }) - const context = createContext(executor) + const context = createExecutionContext(executor) const blockContext = { ...context } expect(context.permissionConfigCache).toBeInstanceOf(Map) @@ -477,9 +470,40 @@ describe('DAGExecutor run-scoped permission config cache', () => { it('never shares the cache between runs', () => { const workflow = { version: '1', blocks: [], connections: [] } - const parent = createContext(new DAGExecutor({ workflow, contextExtensions: {} })) - const child = createContext(new DAGExecutor({ workflow, contextExtensions: {} })) + const parent = createExecutionContext(new DAGExecutor({ workflow, contextExtensions: {} })) + const child = createExecutionContext(new DAGExecutor({ workflow, contextExtensions: {} })) expect(child.permissionConfigCache).not.toBe(parent.permissionConfigCache) }) }) + +describe('DAGExecutor exact access key lists', () => { + function createContext(contextExtensions: Record): ExecutionContext { + return createExecutionContext( + new DAGExecutor({ + workflow: { version: '1', blocks: [], connections: [] }, + contextExtensions, + }) + ) + } + + it('keeps keys a block records on its context copy for later blocks', () => { + const context = createContext({}) + + mergeLargeValueKeys({ ...context }, ['large-value-key']) + mergeFileKeys({ ...context }, ['file-key']) + + expect(context.largeValueKeys).toEqual(['large-value-key']) + expect(context.fileKeys).toEqual(['file-key']) + }) + + it('shares the lists a run passes in rather than copying them', () => { + const largeValueKeys = ['inherited-large-value-key'] + const fileKeys = ['inherited-file-key'] + + const context = createContext({ largeValueKeys, fileKeys }) + + expect(context.largeValueKeys).toBe(largeValueKeys) + expect(context.fileKeys).toBe(fileKeys) + }) +}) diff --git a/apps/sim/executor/execution/executor.ts b/apps/sim/executor/execution/executor.ts index 95e7c4eb0e6..3c7b23e9a6a 100644 --- a/apps/sim/executor/execution/executor.ts +++ b/apps/sim/executor/execution/executor.ts @@ -418,8 +418,8 @@ export class DAGExecutor { workspaceId: this.contextExtensions.workspaceId, executionId: this.contextExtensions.executionId, largeValueExecutionIds: this.contextExtensions.largeValueExecutionIds, - largeValueKeys: this.contextExtensions.largeValueKeys, - fileKeys: this.contextExtensions.fileKeys, + largeValueKeys: this.contextExtensions.largeValueKeys ?? [], + fileKeys: this.contextExtensions.fileKeys ?? [], allowLargeValueWorkflowScope: this.contextExtensions.allowLargeValueWorkflowScope, userId: this.contextExtensions.userId, principal: this.contextExtensions.principal, diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index a2e4ae658af..164d3de0874 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -405,6 +405,10 @@ export interface ExecutionContext { workspaceId?: string executionId?: string largeValueExecutionIds?: string[] + /** + * Exact large-value and file keys this run may read. Seeded by the executor so every block's + * shallow context copy appends to one run-wide list; an executor not handed lists starts empty. + */ largeValueKeys?: string[] fileKeys?: string[] allowLargeValueWorkflowScope?: boolean @@ -447,8 +451,9 @@ export interface ExecutionContext { * in any block state or workspace row, so nothing else can resolve it. The * index only *selects*; every read is still authorized on its own. * - * A Map for the same reason as {@link toolBindingLabelCache}: `blockCtx` is a - * shallow clone per block execution, so only a shared reference survives. + * Built lazily on the block's context from the current block states, so it lives + * for one block: shared by that block's agent turns and tool calls, rebuilt by the + * next block. Files from earlier blocks reach it through their committed outputs. */ executionFilesById?: Map diff --git a/apps/sim/lib/execution/payloads/cache.test.ts b/apps/sim/lib/execution/payloads/cache.test.ts index 0bc852fca89..1ee34831afb 100644 --- a/apps/sim/lib/execution/payloads/cache.test.ts +++ b/apps/sim/lib/execution/payloads/cache.test.ts @@ -16,7 +16,7 @@ import { const MB = 1024 * 1024 const SCOPE = { executionId: 'exec-1' } -function makeRef(id: string, size: number): LargeValueRef { +function makeRef(id: string, size: number, overrides: Partial = {}): LargeValueRef { return { __simLargeValueRef: true, version: LARGE_VALUE_REF_VERSION, @@ -24,6 +24,7 @@ function makeRef(id: string, size: number): LargeValueRef { kind: 'object', size, executionId: 'exec-1', + ...overrides, } } @@ -131,3 +132,48 @@ describe('large value cache retention policy', () => { expect(getLargeValueCacheStats()).toEqual({ entries: 1, trackedBytes: 200 * MB }) }) }) + +describe('exact-key access to a cached large value', () => { + const key = 'execution/ws-1/wf-1/exec-source/large-value-lv_keyed.json' + const keyedRef = makeRef('lv_keyed', 16, { key, executionId: 'exec-source' }) + + beforeEach(() => { + clearLargeValueCacheForTests() + cacheLargeValue('lv_keyed', { secret: 'value' }, 16, { + workspaceId: 'ws-1', + workflowId: 'wf-1', + executionId: 'exec-source', + }) + }) + + afterEach(() => { + clearLargeValueCacheForTests() + }) + + it('serves a key granted to another execution of the same workflow', () => { + expect( + materializeLargeValueRefSync(keyedRef, { + workspaceId: 'ws-1', + workflowId: 'wf-1', + executionId: 'exec-reader', + largeValueKeys: [key], + }) + ).toEqual({ secret: 'value' }) + }) + + it('refuses a granted key that belongs to another workspace or workflow', () => { + for (const scope of [ + { workspaceId: 'ws-2', workflowId: 'wf-1' }, + { workspaceId: 'ws-1', workflowId: 'wf-2' }, + { workspaceId: undefined, workflowId: undefined }, + ]) { + expect( + materializeLargeValueRefSync(keyedRef, { + ...scope, + executionId: 'exec-reader', + largeValueKeys: [key], + }) + ).toBeUndefined() + } + }) +}) diff --git a/apps/sim/lib/execution/payloads/cache.ts b/apps/sim/lib/execution/payloads/cache.ts index 4f3a2d14859..55462de08dd 100644 --- a/apps/sim/lib/execution/payloads/cache.ts +++ b/apps/sim/lib/execution/payloads/cache.ts @@ -1,5 +1,6 @@ import { getLargeValueMaterializationError, + isGrantedLargeValueKey, isLargeValueRef, type LargeValueRef, } from '@/lib/execution/payloads/large-value-ref' @@ -167,7 +168,7 @@ function scopeMatchesRef( callerScope.executionId, ...(callerScope.largeValueExecutionIds ?? []), ]) - if (ref.key && callerScope.largeValueKeys?.includes(ref.key)) { + if (ref.key && isGrantedLargeValueKey(ref.key, callerScope)) { return true } const workflowScopeAllowed = diff --git a/apps/sim/lib/execution/payloads/large-value-ref.ts b/apps/sim/lib/execution/payloads/large-value-ref.ts index 6397504de95..e5d696380e8 100644 --- a/apps/sim/lib/execution/payloads/large-value-ref.ts +++ b/apps/sim/lib/execution/payloads/large-value-ref.ts @@ -20,6 +20,22 @@ export interface LargeValueRef { const LARGE_VALUE_ID_PATTERN = /^lv_[A-Za-z0-9_-]{12}$/ +/** + * Whether `key` is an exact large-value grant in `scope`. A grant only reaches values stored under + * the scope's own workspace and workflow, so a key recorded for another tenant never unlocks one. + */ +export function isGrantedLargeValueKey( + key: string, + scope: { workspaceId?: string; workflowId?: string; largeValueKeys?: readonly string[] } +): boolean { + return Boolean( + scope.workspaceId && + scope.workflowId && + key.startsWith(`execution/${scope.workspaceId}/${scope.workflowId}/`) && + scope.largeValueKeys?.includes(key) + ) +} + export function isLargeValueStorageKey(key: string, id: string, executionId?: string): boolean { if (!key.startsWith('execution/')) return false if (!key.endsWith(`/large-value-${id}.json`)) return false diff --git a/apps/sim/lib/execution/payloads/materialization.server.ts b/apps/sim/lib/execution/payloads/materialization.server.ts index b919b3ca20b..b0e6eb3483f 100644 --- a/apps/sim/lib/execution/payloads/materialization.server.ts +++ b/apps/sim/lib/execution/payloads/materialization.server.ts @@ -6,6 +6,7 @@ import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { isUserFileWithMetadata } from '@/lib/core/utils/user-file' import { getLargeValueMaterializationError, + isGrantedLargeValueKey, isLargeValueRef, isLargeValueStorageKey, type LargeValueRef, @@ -107,7 +108,6 @@ export function assertLargeValueRefAccess( context.executionId, ...(context.largeValueExecutionIds ?? []), ]) - const allowedKeys = new Set(context.largeValueKeys ?? []) const parts = ref.key?.split('/') ?? [] const [, workspaceId, workflowId, executionId] = parts @@ -131,7 +131,7 @@ export function assertLargeValueRefAccess( if (context.workflowId && workflowId !== context.workflowId) { throw new Error('Large execution value is not available in this execution.') } - if (allowedKeys.has(ref.key)) { + if (isGrantedLargeValueKey(ref.key, context)) { return } if (ref.executionId && !allowedExecutionIds.has(ref.executionId) && !workflowScopeAllowed) { From e94f069fe44d9948f815221b2bbfc3f38a42c2d4 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 14 Sep 2026 21:45:26 -0700 Subject: [PATCH 10/11] feat(providers): refresh model catalogs and support custom routing (#7851) * feat(providers): refresh model catalogs and support custom routing * fix(providers): add verified Qwen 3.8 release dates --- .../content/docs/workflows/blocks/agent.mdx | 23 +- .../ollama-cloud/models/route.test.ts | 23 + apps/sim/blocks/provider-credentials.test.ts | 34 + apps/sim/blocks/utils.test.ts | 13 +- apps/sim/blocks/utils.ts | 65 +- apps/sim/lib/api-key/byok.test.ts | 93 ++ apps/sim/lib/api-key/byok.ts | 38 +- .../core/security/input-validation.test.ts | 2 + .../sim/lib/core/security/input-validation.ts | 4 +- .../lib/workflows/editing/validation.test.ts | 34 + apps/sim/lib/workflows/editing/validation.ts | 11 +- .../providers/azure-anthropic/index.test.ts | 11 + apps/sim/providers/azure-anthropic/index.ts | 2 +- apps/sim/providers/azure-openai/index.test.ts | 29 + apps/sim/providers/azure-openai/index.ts | 6 +- apps/sim/providers/baseten/index.test.ts | 8 + apps/sim/providers/baseten/index.ts | 2 +- apps/sim/providers/bedrock/index.test.ts | 32 + apps/sim/providers/bedrock/index.ts | 14 +- .../bedrock/streaming-tool-loop.test.ts | 64 +- .../providers/bedrock/streaming-tool-loop.ts | 81 +- apps/sim/providers/bedrock/utils.test.ts | 28 + apps/sim/providers/bedrock/utils.ts | 85 +- apps/sim/providers/cerebras/index.ts | 5 +- apps/sim/providers/deepseek/index.test.ts | 14 + apps/sim/providers/deepseek/index.ts | 6 +- apps/sim/providers/fireworks/index.test.ts | 12 + apps/sim/providers/fireworks/index.ts | 2 +- .../sim/providers/gemini/core.request.test.ts | 172 ++++ apps/sim/providers/gemini/core.ts | 27 +- .../providers/gemini/streaming-tool-loop.ts | 2 +- apps/sim/providers/google/utils.test.ts | 4 + apps/sim/providers/google/utils.ts | 1 + apps/sim/providers/groq/index.test.ts | 17 + apps/sim/providers/groq/index.ts | 5 +- apps/sim/providers/index.test.ts | 24 + apps/sim/providers/index.ts | 8 +- apps/sim/providers/kimi/index.ts | 46 +- apps/sim/providers/litellm/index.test.ts | 5 + apps/sim/providers/litellm/index.ts | 2 +- apps/sim/providers/models.test.ts | 141 ++- apps/sim/providers/models.ts | 930 +++++++++++++++++- apps/sim/providers/nvidia/index.ts | 38 +- apps/sim/providers/ollama-cloud/index.test.ts | 12 + apps/sim/providers/ollama-cloud/index.ts | 2 +- apps/sim/providers/ollama/index.test.ts | 8 + apps/sim/providers/ollama/index.ts | 27 +- apps/sim/providers/openrouter/index.test.ts | 6 + apps/sim/providers/openrouter/index.ts | 2 +- apps/sim/providers/openrouter/utils.ts | 2 +- apps/sim/providers/sakana/index.ts | 25 +- .../providers/settled-tool-streams.test.ts | 49 +- .../providers/specialist-reasoning.test.ts | 164 +++ apps/sim/providers/together/index.test.ts | 8 + apps/sim/providers/together/index.ts | 2 +- apps/sim/providers/utils.test.ts | 86 +- apps/sim/providers/utils.ts | 40 +- apps/sim/providers/vertex/index.test.ts | 43 +- apps/sim/providers/vertex/index.ts | 10 +- apps/sim/providers/vllm/index.test.ts | 9 + apps/sim/providers/vllm/index.ts | 2 +- 61 files changed, 2378 insertions(+), 282 deletions(-) create mode 100644 apps/sim/blocks/provider-credentials.test.ts create mode 100644 apps/sim/providers/gemini/core.request.test.ts create mode 100644 apps/sim/providers/specialist-reasoning.test.ts diff --git a/apps/docs/content/docs/workflows/blocks/agent.mdx b/apps/docs/content/docs/workflows/blocks/agent.mdx index b880916cd78..dde3a58689c 100644 --- a/apps/docs/content/docs/workflows/blocks/agent.mdx +++ b/apps/docs/content/docs/workflows/blocks/agent.mdx @@ -27,6 +27,10 @@ Answer in two sentences, cite the doc you used, and never guess a price. The model that runs the step. Defaults to `claude-sonnet-4-6`. Type or pick any model from OpenAI, Anthropic, Google, xAI, Groq, Cerebras, DeepSeek, Azure, AWS Bedrock, Google Vertex, or OpenRouter, or a local model through Ollama or VLLM. +For a custom cloud deployment, enter its provider prefix and model ID: `azure/my-deployment`, `azure-anthropic/my-deployment`, `bedrock/my-inference-profile`, or `vertex/my-gemini-model`. The prefix selects the provider and shows its credential fields even when the ID is absent from the catalog. Bedrock accepts full inference profile ARNs after `bedrock/`; Vertex uses the Gemini API and accepts Google model resource names. The deployment must support the selected provider's API. Custom IDs have no catalog pricing or token limits. + +Ollama Cloud, OpenRouter, Fireworks, Together AI, Baseten, Ollama, vLLM, and LiteLLM load their available models from the configured provider. New models appear through that discovery without a Sim catalog release. You can also enter a namespaced ID directly, such as `ollama-cloud/deepseek-v4.1-flash`, `openrouter/provider/model`, or `ollama/my-local-model`. Provider prefixes are case-insensitive; the model ID after the prefix keeps its original casing. + ### Files Files for the model to read: images for a vision-capable model, or documents for text. Upload them on the block, or pass a file from an earlier block, such as an upload trigger or an [API](/workflows/blocks/api) response, with a connection tag. @@ -110,18 +114,19 @@ Live tool-call chips stream for **OpenAI, Anthropic, Azure Anthropic, Google, Ve | Provider | Streamed thinking | Models | |----------|-------------------|--------| -| OpenAI | Summaries only — Requires OpenAI organization verification; falls back to no summaries. | `gpt-6-astra`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5-pro`, `gpt-5.5`, `gpt-5.4-pro`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.2-pro`, `gpt-5.2`, `gpt-5.1`, `gpt-5-pro`, `gpt-5`, `gpt-5-mini`, `gpt-5-nano`, `o4-mini`, `o3`, `o3-mini`, `o1` | +| OpenAI | Summaries only — Requires OpenAI organization verification; falls back to no summaries. | `gpt-6-astra`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5-pro`, `gpt-5.5`, `gpt-5.4-pro`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.3-codex`, `gpt-5.2-pro`, `gpt-5.2`, `gpt-5.1`, `gpt-5-pro`, `gpt-5`, `gpt-5-mini`, `gpt-5-nano`, `o4-mini`, `o3`, `o3-mini`, `o1` | | Anthropic | Summaries only — These generations omit full thinking; Sim requests summarized thinking on streaming runs. | `claude-fable-5-1`, `claude-fable-5`, `claude-sonnet-5`, `claude-opus-5`, `claude-opus-4-8`, `claude-opus-4-7`, `claude-opus-4-6`, `claude-sonnet-4-6`, `claude-opus-4-5`, `claude-sonnet-4-5`, `claude-haiku-4-5` | -| Azure OpenAI | Summaries only — Requires OpenAI organization verification; falls back to no summaries. | `azure/gpt-5.4`, `azure/gpt-5.4-mini`, `azure/gpt-5.4-nano`, `azure/gpt-5.2`, `azure/gpt-5.1`, `azure/gpt-5.1-codex`, `azure/gpt-5`, `azure/gpt-5-mini`, `azure/gpt-5-nano`, `azure/o3`, `azure/o4-mini` | -| Azure Anthropic | Summaries only — These generations omit full thinking; Sim requests summarized thinking on streaming runs. | `azure-anthropic/claude-opus-4-6`, `azure-anthropic/claude-opus-4-5`, `azure-anthropic/claude-sonnet-4-5`, `azure-anthropic/claude-opus-4-1`, `azure-anthropic/claude-haiku-4-5` | -| Google | Summaries only | `gemini-3.8-flash`, `gemini-3.6-flash`, `gemini-3.5-flash-lite`, `gemini-3.5-flash`, `gemini-3.1-pro-preview`, `gemini-3.1-flash-lite`, `gemini-3-flash-preview`, `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite` | -| Vertex AI | Summaries only | `vertex/gemini-3.5-flash`, `vertex/gemini-3.1-pro-preview`, `vertex/gemini-3.1-flash-lite`, `vertex/gemini-3-flash-preview`, `vertex/gemini-2.5-pro`, `vertex/gemini-2.5-flash`, `vertex/gemini-2.5-flash-lite` | -| DeepSeek | Full thinking deltas | `deepseek-v4-pro`, `deepseek-v4-flash`, `deepseek-reasoner` | +| Azure OpenAI | Summaries only — Requires OpenAI organization verification; falls back to no summaries. | `azure/gpt-6-astra`, `azure/gpt-5.6-sol`, `azure/gpt-5.6-terra`, `azure/gpt-5.6-luna`, `azure/gpt-5.5`, `azure/gpt-5.4-pro`, `azure/gpt-5.4`, `azure/gpt-5.4-mini`, `azure/gpt-5.4-nano`, `azure/gpt-5.2`, `azure/gpt-5.1`, `azure/gpt-5.1-codex`, `azure/gpt-5`, `azure/gpt-5-mini`, `azure/gpt-5-nano`, `azure/o3`, `azure/o4-mini` | +| Azure Anthropic | Summaries only — These generations omit full thinking; Sim requests summarized thinking on streaming runs. | `azure-anthropic/claude-fable-5-1`, `azure-anthropic/claude-opus-5`, `azure-anthropic/claude-opus-4-8`, `azure-anthropic/claude-opus-4-7`, `azure-anthropic/claude-opus-4-6`, `azure-anthropic/claude-opus-4-5`, `azure-anthropic/claude-sonnet-5`, `azure-anthropic/claude-sonnet-4-6`, `azure-anthropic/claude-sonnet-4-5`, `azure-anthropic/claude-opus-4-1`, `azure-anthropic/claude-haiku-4-5` | +| Google | Summaries only | `gemini-3.8-flash`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash-lite`, `gemini-3.5-flash`, `gemini-3.1-pro-preview`, `gemini-3.1-flash-lite`, `gemini-3-flash-preview`, `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite` | +| Vertex AI | Summaries only | `vertex/gemini-3.8-flash`, `vertex/gemini-3.7-flash`, `vertex/gemini-3.6-flash`, `vertex/gemini-3.5-flash-lite`, `vertex/gemini-3.5-flash`, `vertex/gemini-3.1-pro-preview`, `vertex/gemini-3.1-flash-lite`, `vertex/gemini-3-flash-preview`, `vertex/gemini-2.5-pro`, `vertex/gemini-2.5-flash`, `vertex/gemini-2.5-flash-lite` | +| DeepSeek | Full thinking deltas | `deepseek-v4-pro`, `deepseek-flash`, `deepseek-v4-flash`, `deepseek-reasoner` | | xAI | Full thinking deltas | `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309` | -| Groq | Full thinking deltas | `groq/openai/gpt-oss-120b`, `groq/openai/gpt-oss-20b`, `groq/openai/gpt-oss-safeguard-20b`, `groq/qwen/qwen3.6-27b` | +| Cerebras | Full thinking deltas | `cerebras/qwen-3.8-27b` | +| Groq | Full thinking deltas | `groq/openai/gpt-oss-120b`, `groq/openai/gpt-oss-20b`, `groq/openai/gpt-oss-safeguard-20b`, `groq/qwen/qwen3.8-27b`, `groq/qwen/qwen3.6-27b` | | Meta | Not streamed | `muse-spark-1.3`, `muse-spark-1.1` | -| Kimi | Full thinking deltas | `kimi-k2.6` | -| Z.ai | Full thinking deltas | `glm-5.3`, `glm-5.2`, `glm-5.1`, `glm-5`, `glm-5-turbo`, `glm-4.7`, `glm-4.6`, `glm-4.5`, `glm-4.5-air` | +| Kimi | Full thinking deltas | `kimi-k3`, `kimi-k2.6` | +| Z.ai | Full thinking deltas | `glm-5.3`, `glm-5.3-flash`, `glm-5.2`, `glm-5.1`, `glm-5`, `glm-5-turbo`, `glm-4.7`, `glm-4.6`, `glm-4.5`, `glm-4.5-air` | {/* agent-stream-capabilities:end */} diff --git a/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts b/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts index a4b4cbfedda..3c6d2537159 100644 --- a/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts +++ b/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts @@ -144,6 +144,29 @@ describe('GET /api/providers/ollama-cloud/models', () => { expect(fetchAuthHeader()).toBe('Bearer byok-ollama-key') }) + it('discovers newly available models on subsequent requests without a static catalog update', async () => { + grantWorkspaceAccess() + mockGetBYOKKey.mockResolvedValue({ apiKey: 'byok-ollama-key' }) + mockFetch + .mockResolvedValueOnce(okResponse({ models: [{ name: 'kimi-k3' }] })) + .mockResolvedValueOnce( + okResponse({ + models: [{ name: 'kimi-k3' }, { name: 'deepseek-v4.1-flash' }, { name: 'glm-5.3' }], + }) + ) + + const first = await GET(requestWithWorkspace('ws-1')) + expect(await first.json()).toEqual({ models: ['ollama-cloud/kimi-k3'] }) + const refreshed = await GET(requestWithWorkspace('ws-1')) + expect(await refreshed.json()).toEqual({ + models: ['ollama-cloud/kimi-k3', 'ollama-cloud/deepseek-v4.1-flash', 'ollama-cloud/glm-5.3'], + }) + expect(mockFetch).toHaveBeenLastCalledWith( + OLLAMA_CLOUD_TAGS_URL, + expect.objectContaining({ cache: 'no-store' }) + ) + }) + it('does not call getBYOKKey when there is a workspaceId but no session', async () => { mockGetSession.mockResolvedValue(null) diff --git a/apps/sim/blocks/provider-credentials.test.ts b/apps/sim/blocks/provider-credentials.test.ts new file mode 100644 index 00000000000..dcaaee7aceb --- /dev/null +++ b/apps/sim/blocks/provider-credentials.test.ts @@ -0,0 +1,34 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { evaluateSubBlockCondition } from '@/lib/workflows/subblocks/visibility' +import { getProviderCredentialSubBlocks } from '@/blocks/utils' + +describe('provider credential visibility', () => { + const subBlocks = getProviderCredentialSubBlocks().filter(({ id }) => id !== 'apiKey') + + it.each([ + ['azure/MyDeployment', ['azureEndpoint', 'azureApiVersion']], + ['AZURE/MyDeployment', ['azureEndpoint', 'azureApiVersion']], + ['azure-anthropic/MyDeployment', ['azureEndpoint', 'azureApiVersion']], + ['bedrock/custom-profile', ['bedrockAccessKeyId', 'bedrockSecretKey', 'bedrockRegion']], + [ + 'vertex/publishers/google/models/custom-gemini', + ['vertexCredential', 'vertexManualCredential', 'vertexProject', 'vertexLocation'], + ], + [ + 'VERTEX/CustomModel', + ['vertexCredential', 'vertexManualCredential', 'vertexProject', 'vertexLocation'], + ], + ['gpt-4o', []], + ['unknown/model', []], + ['', []], + ])('shows only the routed provider credentials for %s', (model, expected) => { + const visible = subBlocks + .filter((subBlock) => evaluateSubBlockCondition(subBlock.condition, { model })) + .map(({ id }) => id) + + expect(visible).toEqual(expected) + }) +}) diff --git a/apps/sim/blocks/utils.test.ts b/apps/sim/blocks/utils.test.ts index 1cd89fa4229..c260de06e87 100644 --- a/apps/sim/blocks/utils.test.ts +++ b/apps/sim/blocks/utils.test.ts @@ -31,7 +31,8 @@ const { mockProviders } = vi.hoisted(() => ({ }, })) -vi.mock('@/providers/models', () => ({ +vi.mock('@/providers/models', async (importOriginal) => ({ + ...(await importOriginal()), getProviderFileAttachment: vi .fn() .mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }), @@ -180,6 +181,16 @@ describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => { }) describe('provider store lookup (client-side)', () => { + it('requires the cloud key even when a local discovered name uses its namespace', () => { + mockProviders.value.ollama.models = ['azure/MyDeployment', 'ollama-cloud/MyModel'] + expect(evaluateCondition('azure/MyDeployment')).toBe(true) + expect(evaluateCondition('ollama-cloud/MyModel')).toBe(true) + }) + + it('does not require an API key for an undiscovered namespaced Ollama model', () => { + expect(evaluateCondition('OLLAMA/Org/CustomModel')).toBe(false) + }) + it('does not require API key when model is in the Ollama store bucket', () => { mockProviders.value.ollama.models = ['llama3:latest', 'mistral:latest'] expect(evaluateCondition('llama3:latest')).toBe(false) diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index dca2389b503..bc81c39cc2a 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -6,12 +6,14 @@ import { getScopesForService } from '@/lib/oauth/utils' import { containsReference } from '@/lib/workflows/sanitization/references' import type { SubBlockConfig } from '@/blocks/types' import { + findProviderFromModel, getBaseModelProviders, getHostedModels, getModelSunsetStatus, getProviderIcon, getProviderModels, isAutoModel, + isCustomModelId, orderModelIdsByReleaseDate, SIM_AUTO_MODEL_ID, } from '@/providers/models' @@ -20,8 +22,6 @@ import type { ProviderId } from '@/providers/types' import { getProviderFromModel } from '@/providers/utils' import { useProvidersStore } from '@/stores/providers/store' -export const VERTEX_MODELS = getProviderModels('vertex') -export const BEDROCK_MODELS = getProviderModels('bedrock') export const AZURE_MODELS = [ ...getProviderModels('azure-openai'), ...getProviderModels('azure-anthropic'), @@ -164,10 +164,16 @@ function shouldRequireApiKeyForModel(model: string): boolean { ) { return false } - if (normalizedModel.startsWith('vllm/') || normalizedModel.startsWith('litellm/')) { + if ( + normalizedModel.startsWith('ollama/') || + normalizedModel.startsWith('vllm/') || + normalizedModel.startsWith('litellm/') + ) { return false } + if (isCustomModelId(normalizedModel)) return true + const storeProvider = getProviderFromStore(normalizedModel) if (storeProvider === 'ollama' || storeProvider === 'vllm' || storeProvider === 'litellm') return false @@ -272,6 +278,14 @@ export function getCohereRerankerApiKeyCondition() { } } +function getModelProviderCondition(...providerIds: ProviderId[]) { + return (values?: Record) => { + const model = typeof values?.model === 'string' ? values.model : '' + const provider = findProviderFromModel(model.trim()) + return buildModelVisibilityCondition(model, provider !== null && providerIds.includes(provider)) + } +} + /** * Returns the standard provider credential subblocks used by LLM-based blocks. * This includes: Vertex AI OAuth, API Key, Azure (OpenAI + Anthropic), Vertex AI config, and Bedrock config. @@ -290,10 +304,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { requiredScopes: getScopesForService('vertex-ai'), placeholder: 'Select Google Cloud account', required: true, - condition: { - field: 'model', - value: VERTEX_MODELS, - }, + condition: getModelProviderCondition('vertex'), }, { id: 'vertexManualCredential', @@ -303,10 +314,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { mode: 'advanced', placeholder: 'Enter credential ID', required: true, - condition: { - field: 'model', - value: VERTEX_MODELS, - }, + condition: getModelProviderCondition('vertex'), }, { id: 'apiKey', @@ -326,10 +334,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { placeholder: 'https://your-resource.services.ai.azure.com', connectionDroppable: false, hideWhenEnvSet: 'NEXT_PUBLIC_AZURE_CONFIGURED', - condition: { - field: 'model', - value: AZURE_MODELS, - }, + condition: getModelProviderCondition('azure-openai', 'azure-anthropic'), }, { id: 'azureApiVersion', @@ -338,10 +343,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { placeholder: 'Enter API version', connectionDroppable: false, hideWhenEnvSet: 'NEXT_PUBLIC_AZURE_CONFIGURED', - condition: { - field: 'model', - value: AZURE_MODELS, - }, + condition: getModelProviderCondition('azure-openai', 'azure-anthropic'), }, { id: 'vertexProject', @@ -351,10 +353,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { placeholder: 'your-gcp-project-id', connectionDroppable: false, required: true, - condition: { - field: 'model', - value: VERTEX_MODELS, - }, + condition: getModelProviderCondition('vertex'), }, { id: 'vertexLocation', @@ -363,10 +362,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { placeholder: 'us-central1', connectionDroppable: false, required: true, - condition: { - field: 'model', - value: VERTEX_MODELS, - }, + condition: getModelProviderCondition('vertex'), }, { id: 'bedrockAccessKeyId', @@ -377,10 +373,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { connectionDroppable: false, required: true, hideWhenEnvSet: 'NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS', - condition: { - field: 'model', - value: BEDROCK_MODELS, - }, + condition: getModelProviderCondition('bedrock'), }, { id: 'bedrockSecretKey', @@ -391,10 +384,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { connectionDroppable: false, required: true, hideWhenEnvSet: 'NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS', - condition: { - field: 'model', - value: BEDROCK_MODELS, - }, + condition: getModelProviderCondition('bedrock'), }, { id: 'bedrockRegion', @@ -402,10 +392,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { type: 'short-input', placeholder: 'us-east-1', connectionDroppable: false, - condition: { - field: 'model', - value: BEDROCK_MODELS, - }, + condition: getModelProviderCondition('bedrock'), }, ] } diff --git a/apps/sim/lib/api-key/byok.test.ts b/apps/sim/lib/api-key/byok.test.ts index abba750dbac..5a905237cf2 100644 --- a/apps/sim/lib/api-key/byok.test.ts +++ b/apps/sim/lib/api-key/byok.test.ts @@ -451,6 +451,99 @@ describe('getBYOKKey', () => { }) }) +describe('getApiKeyWithBYOK provider classification', () => { + const dynamicProviders = [ + 'ollama', + 'vllm', + 'litellm', + 'fireworks', + 'together', + 'baseten', + 'ollama-cloud', + ] as const + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockIsHosted.value = true + mockEnv.AZURE_OPENAI_API_KEY = 'azure-env-key' + mockEnv.AZURE_ANTHROPIC_API_KEY = 'azure-anthropic-env-key' + mockEnv.VLLM_API_KEY = 'vllm-env-key' + mockEnv.LITELLM_API_KEY = 'litellm-env-key' + dbChainMockFns.orderBy.mockResolvedValue([storedKey('other-provider-key')]) + mockDecryptSecret.mockImplementation(async (encrypted: string) => ({ + decrypted: encrypted.replace('encrypted-', 'decrypted-'), + })) + }) + + it.each(dynamicProviders)( + 'keeps Azure credentials when %s discovery contains the same model ID', + async (discoveredProvider) => { + const model = 'AZURE/CustomDeployment' + vi.mocked(useProvidersStore.getState).mockReturnValue({ + providers: Object.fromEntries( + dynamicProviders.map((provider) => [ + provider, + { models: provider === discoveredProvider ? [model] : [] }, + ]) + ), + } as ReturnType) + + const result = await getApiKeyWithBYOK('azure-openai', model, uniqueWorkspaceId()) + + expect(result).toEqual({ apiKey: 'azure-env-key', isBYOK: false }) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + expect(mockGetRotatingApiKey).not.toHaveBeenCalled() + } + ) + + it.each([ + ['vertex', 'vertex/CustomDeployment', 'vertex-access-token'], + ['azure-anthropic', 'azure-anthropic/CustomDeployment', 'azure-anthropic-user-key'], + ])( + 'retains caller credentials for %s despite a local model name collision', + async (provider, model, apiKey) => { + vi.mocked(useProvidersStore.getState).mockReturnValue({ + providers: Object.fromEntries(dynamicProviders.map((name) => [name, { models: [model] }])), + } as ReturnType) + + expect(await getApiKeyWithBYOK(provider, model, uniqueWorkspaceId(), apiKey)).toEqual({ + apiKey, + isBYOK: false, + }) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + } + ) + + it.each([ + ['ollama', 'empty'], + ['vllm', 'vllm-env-key'], + ['litellm', 'litellm-env-key'], + ])('preserves %s authentication for a custom unprefixed model', async (provider, apiKey) => { + expect(await getApiKeyWithBYOK(provider, 'MyCustomModel', uniqueWorkspaceId())).toEqual({ + apiKey, + isBYOK: false, + }) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) + + it.each(['vllm', 'litellm'])( + 'prefers a caller key to the configured %s key for a local model', + async (provider) => { + expect( + await getApiKeyWithBYOK(provider, 'MyCustomModel', uniqueWorkspaceId(), 'caller-key') + ).toEqual({ apiKey: 'caller-key', isBYOK: false }) + } + ) + + it('uses Bedrock credentials for an uncataloged inference profile', async () => { + expect( + await getApiKeyWithBYOK('bedrock', 'BEDROCK/MyInferenceProfile', uniqueWorkspaceId()) + ).toEqual({ apiKey: 'placeholder', isBYOK: false }) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) +}) + describe('getApiKeyWithBYOK for Fireworks', () => { const HOSTED_POOL_MODEL = 'fireworks/glm-5.2' diff --git a/apps/sim/lib/api-key/byok.ts b/apps/sim/lib/api-key/byok.ts index 1bacd8af607..a9ce7d43e08 100644 --- a/apps/sim/lib/api-key/byok.ts +++ b/apps/sim/lib/api-key/byok.ts @@ -10,7 +10,6 @@ import { isHosted } from '@/lib/core/config/env-flags' import { decryptSecret } from '@/lib/core/security/encryption' import { getHostedModels } from '@/providers/models' import { PROVIDER_PLACEHOLDER_KEY } from '@/providers/utils' -import { useProvidersStore } from '@/stores/providers/store' import type { BYOKProviderId } from '@/tools/types' const logger = createLogger('BYOKKeys') @@ -183,6 +182,9 @@ export async function getBYOKKey( } /** + * Resolves credentials for the provider already selected by model routing. + * Discovery lists must not override that provider and select a different key pool. + * * `scope` is present only when the key came from a stored BYOK pool; a * Sim-hosted, env, or caller-supplied key has no scope. Declared rather than * dropped so the returned type matches what a BYOK branch actually hands back. @@ -193,28 +195,19 @@ export async function getApiKeyWithBYOK( workspaceId: string | undefined | null, userProvidedKey?: string ): Promise<{ apiKey: string; isBYOK: boolean; scope?: BYOKKeyScopeName }> { - const isOllamaModel = - provider === 'ollama' || useProvidersStore.getState().providers.ollama.models.includes(model) - if (isOllamaModel) { + if (provider === 'ollama') { return { apiKey: 'empty', isBYOK: false } } - const isVllmModel = - provider === 'vllm' || useProvidersStore.getState().providers.vllm.models.includes(model) - if (isVllmModel) { + if (provider === 'vllm') { return { apiKey: userProvidedKey || env.VLLM_API_KEY || 'empty', isBYOK: false } } - const isLitellmModel = - provider === 'litellm' || useProvidersStore.getState().providers.litellm.models.includes(model) - if (isLitellmModel) { + if (provider === 'litellm') { return { apiKey: userProvidedKey || env.LITELLM_API_KEY || 'empty', isBYOK: false } } - const isFireworksModel = - provider === 'fireworks' || - useProvidersStore.getState().providers.fireworks.models.includes(model) - if (isFireworksModel) { + if (provider === 'fireworks') { if (workspaceId) { const byokResult = await getBYOKKey(workspaceId, 'fireworks') if (byokResult) { @@ -260,10 +253,7 @@ export async function getApiKeyWithBYOK( throw new Error(`API key is required for Fireworks ${model}`) } - const isTogetherModel = - provider === 'together' || - useProvidersStore.getState().providers.together.models.includes(model) - if (isTogetherModel) { + if (provider === 'together') { if (workspaceId) { const byokResult = await getBYOKKey(workspaceId, 'together') if (byokResult) { @@ -284,9 +274,7 @@ export async function getApiKeyWithBYOK( throw new Error(`API key is required for Together AI ${model}`) } - const isBasetenModel = - provider === 'baseten' || useProvidersStore.getState().providers.baseten.models.includes(model) - if (isBasetenModel) { + if (provider === 'baseten') { if (workspaceId) { const byokResult = await getBYOKKey(workspaceId, 'baseten') if (byokResult) { @@ -303,10 +291,7 @@ export async function getApiKeyWithBYOK( throw new Error(`API key is required for Baseten ${model}`) } - const isOllamaCloudModel = - provider === 'ollama-cloud' || - useProvidersStore.getState().providers['ollama-cloud'].models.includes(model) - if (isOllamaCloudModel) { + if (provider === 'ollama-cloud') { if (workspaceId) { const byokResult = await getBYOKKey(workspaceId, 'ollama-cloud') if (byokResult) { @@ -324,8 +309,7 @@ export async function getApiKeyWithBYOK( throw new Error(`API key is required for Ollama Cloud ${model}`) } - const isBedrockModel = provider === 'bedrock' || model.startsWith('bedrock/') - if (isBedrockModel) { + if (provider === 'bedrock') { return { apiKey: PROVIDER_PLACEHOLDER_KEY, isBYOK: false } } diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index d86b7d87d49..7c06184c525 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -1402,6 +1402,8 @@ describe('validateGoogleCloudLocation', () => { 'africa-south1', 'me-central2', 'global', + 'us', + 'eu', ])('should accept %s', (location) => { const result = validateGoogleCloudLocation(location) expect(result.isValid).toBe(true) diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index 9aeb1b6eb19..4a1e460d19f 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -661,7 +661,7 @@ export function validateAwsRegion( * and relocate the request — along with any attached credential — to an * attacker-controlled host. * - * Accepts `global` plus the documented `{geography}-{direction}{index}` region + * Accepts `global`, the `us` and `eu` multi-regions, and the `{geography}-{direction}{index}` region * form (e.g. us-central1, europe-west4, northamerica-northeast1, me-central2). * * @param value - The location to validate @@ -677,7 +677,7 @@ export function validateGoogleCloudLocation( } const googleLocationPattern = - /^(global|(africa|asia|australia|europe|me|northamerica|southamerica|us)-(central|east|north|northeast|northwest|south|southeast|southwest|west)\d{1,2})$/ + /^(global|us|eu|(africa|asia|australia|europe|me|northamerica|southamerica|us)-(central|east|north|northeast|northwest|south|southeast|southwest|west)\d{1,2})$/ if (!googleLocationPattern.test(value)) { logger.warn('Invalid Google Cloud location format', { diff --git a/apps/sim/lib/workflows/editing/validation.test.ts b/apps/sim/lib/workflows/editing/validation.test.ts index bd1a65a2d89..1f192cb1721 100644 --- a/apps/sim/lib/workflows/editing/validation.test.ts +++ b/apps/sim/lib/workflows/editing/validation.test.ts @@ -493,6 +493,40 @@ describe('validateInputsForBlock', () => { expect(result.validInputs.model).toBe('ollama/my-private-model') }) + it.each([ + 'azure/MyDeployment', + 'AZURE/MyDeployment', + 'azure-anthropic/MyDeployment', + 'bedrock/custom-inference-profile', + 'vertex/publishers/google/models/custom-gemini', + 'GROQ/Org/CustomModel', + 'CEREBRAS/CustomModel', + 'NVIDIA/CustomModel', + ])('accepts a custom cloud model ID: %s', (model) => { + for (const blockType of ['agent', 'router_v2']) { + const result = validateInputsForBlock(blockType, { model: ` ${model} ` }, 'block-1') + expect(result.errors).toEqual([]) + expect(result.validInputs.model).toBe(model) + } + }) + + it.each([ + 'azure/', + 'azure-anthropic/', + 'bedrock/', + 'vertex/', + 'groq/', + 'cerebras/', + 'nvidia/', + 'ollama/', + 'ollama-cloud/', + 'unknown/model', + ])('rejects incomplete or unsupported cloud namespaces: %s', (model) => { + const result = validateInputsForBlock('agent', { model }, 'agent-1') + expect(result.validInputs.model).toBeUndefined() + expect(result.errors[0]?.error).toContain('Unknown model id') + }) + it('validates the model field on router_v2 blocks too', () => { const valid = validateInputsForBlock('router_v2', { model: 'claude-sonnet-4-6' }, 'router-1') expect(valid.errors).toHaveLength(0) diff --git a/apps/sim/lib/workflows/editing/validation.ts b/apps/sim/lib/workflows/editing/validation.ts index ec2a366a1bd..e02ca16ef02 100644 --- a/apps/sim/lib/workflows/editing/validation.ts +++ b/apps/sim/lib/workflows/editing/validation.ts @@ -24,7 +24,12 @@ import type { SubBlockConfig } from '@/blocks/types' import { getModelOptions } from '@/blocks/utils' import { overlayVisibility } from '@/blocks/visibility/context' import { BlockType, EDGE, normalizeName } from '@/executor/constants' -import { isAutoModel, isKnownModelId, suggestModelIdsForUnknownModel } from '@/providers/models' +import { + isAutoModel, + isCustomModelId, + isKnownModelId, + suggestModelIdsForUnknownModel, +} from '@/providers/models' import { isPiByokOnlyMode } from '@/providers/pi-providers' import { getTool } from '@/tools/utils' import { @@ -680,7 +685,7 @@ export function validateValueForSubBlockType( if (trimmed !== '' && isAutoModel(trimmed) && isHostedDeployment) { return { valid: true, value: trimmed.toLowerCase() } } - if (trimmed !== '' && !isKnownModelId(trimmed)) { + if (trimmed !== '' && !isKnownModelId(trimmed) && !isCustomModelId(trimmed)) { const suggestions = suggestModelIdsForUnknownModel(trimmed) const suggestionText = suggestions.length > 0 ? ` Valid options include: ${suggestions.join(', ')}.` : '' @@ -691,7 +696,7 @@ export function validateValueForSubBlockType( blockType, field: fieldName, value, - error: `Unknown model id "${trimmed}" for block "${blockType}". Read components/blocks/${blockType}.json (the model.options array) for valid ids; prefer entries with recommended: true and avoid deprecated: true. For user-configured models (Ollama, Ollama Cloud, vLLM, LiteLLM, OpenRouter, Fireworks, Together AI, Baseten), prefix the id with the provider slash, e.g. "ollama/llama3.1:8b" or "ollama-cloud/gpt-oss:120b".${suggestionText}`, + error: `Unknown model id "${trimmed}" for block "${blockType}". Read components/blocks/${blockType}.json (the model.options array) for valid ids; prefer entries with recommended: true and avoid deprecated: true. For user-configured models, use a supported provider namespace, e.g. "azure/my-deployment", "azure-anthropic/my-deployment", "bedrock/my-inference-profile", "vertex/my-model", "ollama/llama3.1:8b", or "openrouter/provider/model".${suggestionText}`, }, } } diff --git a/apps/sim/providers/azure-anthropic/index.test.ts b/apps/sim/providers/azure-anthropic/index.test.ts index 1de56f6ed15..cb0c777c82b 100644 --- a/apps/sim/providers/azure-anthropic/index.test.ts +++ b/apps/sim/providers/azure-anthropic/index.test.ts @@ -110,6 +110,17 @@ describe('azureAnthropicProvider — SSRF pinning', () => { expect(buildClientOptions().defaultHeaders).not.toHaveProperty('anthropic-beta') }) + it('preserves custom deployment casing when removing an uppercase routing prefix', async () => { + setEnv({ AZURE_ANTHROPIC_ENDPOINT: 'https://custom.services.ai.azure.com' }) + const providerRequest = request({ model: 'AZURE-ANTHROPIC/Team-Claude-Deployment' }) + + await azureAnthropicProvider.executeRequest(providerRequest) + + const [forwardedRequest, config] = mockExecuteAnthropic.mock.calls[0] + expect(forwardedRequest.model).toBe('AZURE-ANTHROPIC/Team-Claude-Deployment') + expect(config.resolveWireModel(forwardedRequest)).toBe('Team-Claude-Deployment') + }) + it('throws and never builds a client when validation blocks the endpoint', async () => { mockValidate.mockResolvedValue({ isValid: false, error: 'resolves to a blocked IP address' }) diff --git a/apps/sim/providers/azure-anthropic/index.ts b/apps/sim/providers/azure-anthropic/index.ts index dff9f1be274..8431204cd73 100644 --- a/apps/sim/providers/azure-anthropic/index.ts +++ b/apps/sim/providers/azure-anthropic/index.ts @@ -64,7 +64,7 @@ export const azureAnthropicProvider: ProviderConfig = { return executeAnthropicProviderRequest(request, { providerId: 'azure-anthropic', providerLabel: 'Azure Anthropic', - resolveWireModel: ({ model }) => model.replace(/^azure-anthropic\//, ''), + resolveWireModel: ({ model }) => model.replace(/^azure-anthropic\//i, ''), createClient: (apiKey) => { const cacheKey = [ 'azure-anthropic', diff --git a/apps/sim/providers/azure-openai/index.test.ts b/apps/sim/providers/azure-openai/index.test.ts index 48c7431cb36..bf64de8121d 100644 --- a/apps/sim/providers/azure-openai/index.test.ts +++ b/apps/sim/providers/azure-openai/index.test.ts @@ -163,6 +163,20 @@ describe('azureOpenAIProvider — SSRF pinning', () => { expect(responsesConfig().fetch).toBeUndefined() }) + it.each([false, true])( + 'preserves a custom deployment name through Responses routing (full endpoint: %s)', + async (fullEndpoint) => { + mockIsResponsesEndpoint.mockReturnValue(fullEndpoint) + setEnv({ AZURE_OPENAI_ENDPOINT: 'https://custom.openai.azure.com' }) + const providerRequest = request({ model: 'AZURE/Team-GPT-Deployment' }) + + await azureOpenAIProvider.executeRequest(providerRequest) + + expect(mockExecuteResponses.mock.calls[0][0].model).toBe('AZURE/Team-GPT-Deployment') + expect(responsesConfig().modelName).toBe('Team-GPT-Deployment') + } + ) + it('throws and never reaches the Responses core when validation blocks the endpoint', async () => { mockValidate.mockResolvedValue({ isValid: false, error: 'resolves to a blocked IP address' }) @@ -215,6 +229,21 @@ describe('azureOpenAIProvider — SSRF pinning', () => { expect(azureOpenAIArgs[0]).not.toHaveProperty('fetch') }) + it('preserves a custom deployment name through Chat Completions routing', async () => { + mockIsChatCompletionsEndpoint.mockReturnValue(true) + setEnv({ + AZURE_OPENAI_ENDPOINT: 'https://custom.openai.azure.com/openai/v1/chat/completions', + }) + mockChatCreate.mockResolvedValue({ + choices: [{ message: { content: 'hi' } }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }) + + await azureOpenAIProvider.executeRequest(request({ model: 'AZURE/Team-GPT-Deployment' })) + + expect(mockChatCreate.mock.calls[0][0].model).toBe('Team-GPT-Deployment') + }) + it('projects the settled tool-loop answer without a final streaming request', async () => { mockIsChatCompletionsEndpoint.mockReturnValue(true) mockValidate.mockResolvedValue({ isValid: true, resolvedIP: '203.0.113.10' }) diff --git a/apps/sim/providers/azure-openai/index.ts b/apps/sim/providers/azure-openai/index.ts index 72251763d99..5905b59f4f2 100644 --- a/apps/sim/providers/azure-openai/index.ts +++ b/apps/sim/providers/azure-openai/index.ts @@ -703,7 +703,7 @@ export const azureOpenAIProvider: ProviderConfig = { // Try to extract deployment from URL, fall back to model name const urlDeployment = extractDeploymentFromUrl(azureEndpoint) - const deploymentName = urlDeployment || request.model.replace('azure/', '') + const deploymentName = urlDeployment || request.model.replace(/^azure\//i, '') // Try to extract api-version from URL, fall back to request param or env or default const urlApiVersion = extractApiVersionFromUrl(azureEndpoint) @@ -733,7 +733,7 @@ export const azureOpenAIProvider: ProviderConfig = { if (isResponsesEndpoint(azureEndpoint)) { logger.info('Detected full responses endpoint URL, using it directly') - const deploymentName = request.model.replace('azure/', '') + const deploymentName = request.model.replace(/^azure\//i, '') // Use the URL as-is since it's already complete return executeResponsesProviderRequest( @@ -758,7 +758,7 @@ export const azureOpenAIProvider: ProviderConfig = { logger.info('Using base endpoint, constructing Responses API URL') const azureApiVersion = request.azureApiVersion || env.AZURE_OPENAI_API_VERSION || '2024-07-01-preview' - const deploymentName = request.model.replace('azure/', '') + const deploymentName = request.model.replace(/^azure\//i, '') const apiUrl = `${azureEndpoint.replace(/\/$/, '')}/openai/v1/responses?api-version=${azureApiVersion}` return executeResponsesProviderRequest( diff --git a/apps/sim/providers/baseten/index.test.ts b/apps/sim/providers/baseten/index.test.ts index d0a4ed0308c..2dabec8909e 100644 --- a/apps/sim/providers/baseten/index.test.ts +++ b/apps/sim/providers/baseten/index.test.ts @@ -155,6 +155,14 @@ describe('basetenProvider', () => { await expect(basetenProvider.executeRequest(baseRequest)).rejects.toBeInstanceOf(ProviderError) }) + it('preserves custom model casing after an uppercase provider prefix', async () => { + mockCreate.mockResolvedValueOnce(textResponse('ok')) + + await basetenProvider.executeRequest({ ...baseRequest, model: 'BASETEN/Org/Custom-Model' }) + + expect(callBody(0).model).toBe('Org/Custom-Model') + }) + it('streams directly when there are no tools', async () => { mockCreate.mockResolvedValueOnce({}) diff --git a/apps/sim/providers/baseten/index.ts b/apps/sim/providers/baseten/index.ts index 5b00cecfcf0..79463252954 100644 --- a/apps/sim/providers/baseten/index.ts +++ b/apps/sim/providers/baseten/index.ts @@ -92,7 +92,7 @@ export const basetenProvider: ProviderConfig = { baseURL: 'https://inference.baseten.co/v1', }) - const requestedModel = request.model.replace(/^baseten\//, '') + const requestedModel = request.model.replace(/^baseten\//i, '') logger.info('Preparing Baseten request', { model: requestedModel, diff --git a/apps/sim/providers/bedrock/index.test.ts b/apps/sim/providers/bedrock/index.test.ts index bf4b2fdecdd..487cc092d2f 100644 --- a/apps/sim/providers/bedrock/index.test.ts +++ b/apps/sim/providers/bedrock/index.test.ts @@ -22,6 +22,7 @@ vi.mock('@/providers/bedrock/utils', () => ({ checkForForcedToolUsage: vi.fn(), createReadableStreamFromBedrockStream: vi.fn(), generateToolUseId: vi.fn().mockReturnValue('tool-1'), + getBedrockBaseModelId: (model: string) => model.replace(/^bedrock\//i, ''), getBedrockStreamError: vi.fn().mockReturnValue(null), // The mocked inference profile above is a Claude model, which supports it. supportsToolResultStatus: vi.fn().mockReturnValue(true), @@ -35,6 +36,8 @@ vi.mock('@/providers/models', () => ({ getProviderModels: vi.fn().mockReturnValue([]), getProviderDefaultModel: vi.fn().mockReturnValue('us.anthropic.claude-3-5-sonnet-20241022-v2:0'), supportsNativeStructuredOutputs: vi.fn().mockReturnValue(false), + getModelCapabilities: vi.fn().mockReturnValue({ temperature: { min: 0, max: 1 } }), + isKnownModelId: vi.fn().mockReturnValue(true), })) vi.mock('@/providers/utils', () => ({ @@ -64,6 +67,7 @@ import { BedrockRuntimeClient, ConverseCommand } from '@aws-sdk/client-bedrock-r import type { StreamingExecution } from '@/executor/types' import { bedrockProvider } from '@/providers/bedrock/index' import { clearProviderClientCacheForTests } from '@/providers/client-cache' +import { getModelCapabilities, isKnownModelId } from '@/providers/models' import { prepareToolsWithUsageControl } from '@/providers/utils' describe('bedrockProvider credential handling', () => { @@ -135,6 +139,34 @@ describe('bedrockProvider credential handling', () => { }) }) + it('omits temperature for catalog models that do not support it', async () => { + vi.mocked(getModelCapabilities).mockReturnValueOnce({ maxOutputTokens: 128000 }) + await bedrockProvider.executeRequest({ + ...baseRequest, + model: 'bedrock/anthropic.claude-opus-5', + temperature: 0.7, + }) + expect(ConverseCommand).toHaveBeenCalledWith(expect.objectContaining({ inferenceConfig: {} })) + }) + + it('preserves explicit temperature for a custom model without catalog capabilities', async () => { + vi.mocked(isKnownModelId).mockReturnValueOnce(false) + await bedrockProvider.executeRequest({ + ...baseRequest, + model: 'bedrock/MyCustomProfile', + temperature: 0.2, + }) + expect(ConverseCommand).toHaveBeenCalledWith( + expect.objectContaining({ inferenceConfig: { temperature: 0.2 } }) + ) + }) + + it('leaves temperature to the service default for a custom model when omitted', async () => { + vi.mocked(isKnownModelId).mockReturnValueOnce(false) + await bedrockProvider.executeRequest({ ...baseRequest, model: 'bedrock/MyCustomProfile' }) + expect(ConverseCommand).toHaveBeenCalledWith(expect.objectContaining({ inferenceConfig: {} })) + }) + it('uses the live loop for streaming tool requests without a caller flag', async () => { vi.mocked(prepareToolsWithUsageControl).mockReturnValueOnce({ tools: [ diff --git a/apps/sim/providers/bedrock/index.ts b/apps/sim/providers/bedrock/index.ts index 28b9b112d27..944785aca63 100644 --- a/apps/sim/providers/bedrock/index.ts +++ b/apps/sim/providers/bedrock/index.ts @@ -26,13 +26,16 @@ import { checkForForcedToolUsage, createReadableStreamFromBedrockStream, generateToolUseId, + getBedrockBaseModelId, getBedrockInferenceProfileId, supportsToolResultStatus, } from '@/providers/bedrock/utils' import { getCachedProviderClient } from '@/providers/client-cache' import { + getModelCapabilities, getProviderDefaultModel, getProviderModels, + isKnownModelId, supportsNativeStructuredOutputs, } from '@/providers/models' import { executeProviderTool } from '@/providers/runtime-context' @@ -376,8 +379,15 @@ export const bedrockProvider: ProviderConfig = { const systemPromptWithSchema = systemContent - const inferenceConfig: { temperature: number; maxTokens?: number } = { - temperature: Number.parseFloat(String(request.temperature ?? 0.7)), + const canonicalModelId = `bedrock/${getBedrockBaseModelId(request.model)}` + const knownModel = isKnownModelId(canonicalModelId) + const modelCapabilities = getModelCapabilities(canonicalModelId) + const inferenceConfig: { temperature?: number; maxTokens?: number } = {} + if ( + (knownModel && modelCapabilities?.temperature) || + (!knownModel && request.temperature != null) + ) { + inferenceConfig.temperature = Number.parseFloat(String(request.temperature ?? 0.7)) } if (request.maxTokens != null) { inferenceConfig.maxTokens = Number.parseInt(String(request.maxTokens)) diff --git a/apps/sim/providers/bedrock/streaming-tool-loop.test.ts b/apps/sim/providers/bedrock/streaming-tool-loop.test.ts index ebb828e1dea..cbc4e73eb00 100644 --- a/apps/sim/providers/bedrock/streaming-tool-loop.test.ts +++ b/apps/sim/providers/bedrock/streaming-tool-loop.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import type { ConverseStreamCommand } from '@aws-sdk/client-bedrock-runtime' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createBedrockStreamingToolLoopStream } from '@/providers/bedrock/streaming-tool-loop' import type { AgentStreamEvent } from '@/providers/stream-events' @@ -55,18 +56,51 @@ describe('createBedrockStreamingToolLoopStream', () => { }) }) - it('emits tool_call_start/end and final text; no invented thinking', async () => { + it('preserves signed and redacted reasoning across tool turns without exposing it', async () => { const turns = [ (async function* () { yield { - contentBlockStart: { + contentBlockDelta: { contentBlockIndex: 0, - start: { toolUse: { toolUseId: 'tooluse_1', name: 'http_request' } }, + delta: { reasoningContent: { text: 'Check the endpoint.' } }, + }, + } + yield { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { reasoningContent: { signature: 'signature-' } }, }, } yield { contentBlockDelta: { contentBlockIndex: 0, + delta: { reasoningContent: { signature: 'value' } }, + }, + } + yield { + contentBlockDelta: { contentBlockIndex: 1, delta: { text: 'Checking now.' } }, + } + yield { + contentBlockDelta: { + contentBlockIndex: 2, + delta: { reasoningContent: { redactedContent: new Uint8Array([1, 2]) } }, + }, + } + yield { + contentBlockDelta: { + contentBlockIndex: 2, + delta: { reasoningContent: { redactedContent: new Uint8Array([3]) } }, + }, + } + yield { + contentBlockStart: { + contentBlockIndex: 3, + start: { toolUse: { toolUseId: 'tooluse_1', name: 'http_request' } }, + }, + } + yield { + contentBlockDelta: { + contentBlockIndex: 3, delta: { toolUse: { input: '{"url":"https://example.com"}' } }, }, } @@ -91,7 +125,7 @@ describe('createBedrockStreamingToolLoopStream', () => { let turnIdx = 0 const client = { - send: vi.fn(async () => ({ stream: turns[turnIdx++] })), + send: vi.fn(async (_command: ConverseStreamCommand) => ({ stream: turns[turnIdx++] })), } const onComplete = vi.fn() @@ -128,6 +162,26 @@ describe('createBedrockStreamingToolLoopStream', () => { const events = await collectEvents(stream) + expect(client.send.mock.calls[1][0].input.messages?.[1]).toEqual({ + role: 'assistant', + content: [ + { + reasoningContent: { + reasoningText: { text: 'Check the endpoint.', signature: 'signature-value' }, + }, + }, + { text: 'Checking now.' }, + { reasoningContent: { redactedContent: new Uint8Array([1, 2, 3]) } }, + { + toolUse: { + toolUseId: 'tooluse_1', + name: 'http_request', + input: { url: 'https://example.com' }, + }, + }, + ], + }) + expect(events.some((e) => e.type === 'thinking_delta')).toBe(false) expect(events.filter((e) => e.type === 'tool_call_start')).toEqual([ { type: 'tool_call_start', id: 'tooluse_1', name: 'http_request' }, @@ -141,7 +195,7 @@ describe('createBedrockStreamingToolLoopStream', () => { .filter((e) => e.type === 'text_delta' && e.turn === 'pending') .map((e) => e.text) .join('') - ).toBe('Request completed.') + ).toBe('Checking now.Request completed.') expect(events.filter((e) => e.type === 'turn_end').map((e) => e.turn)).toEqual([ 'intermediate', 'final', diff --git a/apps/sim/providers/bedrock/streaming-tool-loop.ts b/apps/sim/providers/bedrock/streaming-tool-loop.ts index 0ecd6c276cf..13b3d8646e2 100644 --- a/apps/sim/providers/bedrock/streaming-tool-loop.ts +++ b/apps/sim/providers/bedrock/streaming-tool-loop.ts @@ -1,8 +1,8 @@ /** * Live Bedrock ConverseStream tool loop. * - * Capability-honest: text + tool_call_start/end only — Sim does not request - * Bedrock reasoning, so no thinking is invented. Text emits live as `pending` + * Text + tool_call_start/end events, with provider reasoning preserved for + * subsequent model requests. Text emits live as `pending` * deltas and a `turn_end` event classifies each turn, so the pump projects * only final-turn text to the answer channel. Abort → cancelled. */ @@ -47,7 +47,7 @@ export interface CreateBedrockStreamingToolLoopStreamOptions { request: ProviderRequest messages: BedrockMessage[] system?: SystemContentBlock[] - inferenceConfig: { temperature: number; maxTokens?: number } + inferenceConfig: { temperature?: number; maxTokens?: number } bedrockTools: Tool[] toolChoice: ToolConfiguration['toolChoice'] logger: Logger @@ -62,6 +62,8 @@ interface AssembledToolUse { inputJson: string } +type DrainedContentBlock = ContentBlock | { pendingToolUseId: string } + type ToolUseInput = NonNullable function parseToolInput(inputJson: string): Record { @@ -84,12 +86,18 @@ async function drainBedrockTurn( ): Promise<{ text: string toolUses: AssembledToolUse[] + content: DrainedContentBlock[] inputTokens: number outputTokens: number stopReason?: string }> { let text = '' const toolsByIndex = new Map() + const textByIndex = new Map() + const reasoningByIndex = new Map< + number, + { text: string; signature: string; redacted: Uint8Array[] } + >() let currentIndex: number | undefined let inputTokens = 0 let outputTokens = 0 @@ -119,8 +127,23 @@ async function drainBedrockTurn( if (event.contentBlockDelta) { const idx = event.contentBlockDelta.contentBlockIndex ?? currentIndex const delta = event.contentBlockDelta.delta + if (delta?.reasoningContent && typeof idx === 'number') { + let reasoning = reasoningByIndex.get(idx) + if (!reasoning) { + reasoning = { text: '', signature: '', redacted: [] } + reasoningByIndex.set(idx, reasoning) + } + reasoning.text += delta.reasoningContent.text ?? '' + reasoning.signature += delta.reasoningContent.signature ?? '' + if (delta.reasoningContent.redactedContent) { + reasoning.redacted.push(delta.reasoningContent.redactedContent) + } + } if (delta?.text) { text += delta.text + if (typeof idx === 'number') { + textByIndex.set(idx, (textByIndex.get(idx) ?? '') + delta.text) + } // Live pending text: sinks render it now; the pump projects it to the // answer only when this turn's turn_end says 'final'. controller.enqueue({ type: 'text_delta', text: delta.text, turn: 'pending' }) @@ -145,9 +168,39 @@ async function drainBedrockTurn( } } + const contentByIndex = new Map() + for (const [index, blockText] of textByIndex) { + if (blockText.trim()) contentByIndex.set(index, { text: blockText }) + } + for (const [index, tool] of toolsByIndex) { + contentByIndex.set(index, { pendingToolUseId: tool.toolUseId }) + } + for (const [index, reasoning] of reasoningByIndex) { + if (reasoning.redacted.length > 0) { + const redactedContent = new Uint8Array( + reasoning.redacted.reduce((size, chunk) => size + chunk.length, 0) + ) + let offset = 0 + for (const chunk of reasoning.redacted) { + redactedContent.set(chunk, offset) + offset += chunk.length + } + contentByIndex.set(index, { reasoningContent: { redactedContent } }) + } else { + contentByIndex.set(index, { + reasoningContent: { + reasoningText: { text: reasoning.text, signature: reasoning.signature }, + }, + }) + } + } + return { text, toolUses: [...toolsByIndex.values()], + content: [...contentByIndex.entries()] + .sort(([left], [right]) => left - right) + .map(([, block]) => block), inputTokens, outputTokens, stopReason, @@ -499,21 +552,17 @@ export function createBedrockStreamingToolLoopStream( toolsTime += Date.now() - toolsStartTime - const assistantContent: ContentBlock[] = [ - // Bedrock rejects a blank text block, and a model can emit only - // whitespace before a tool call. - ...(drained.text.trim() ? [{ text: drained.text }] : []), - ...assembledToolUses.map((toolUse) => ({ - toolUse: { - toolUseId: toolUse.toolUseId, - name: toolUse.name, - input: toolUse.input, - }, - })), - ] + const toolUsesById = new Map( + assembledToolUses.map((toolUse) => [toolUse.toolUseId, toolUse]) + ) currentMessages.push({ role: 'assistant' as ConversationRole, - content: assistantContent, + content: drained.content.map((block) => { + if (!('pendingToolUseId' in block)) return block + const toolUse = toolUsesById.get(block.pendingToolUseId) + if (!toolUse) throw new Error('Missing assembled Bedrock tool use') + return { toolUse } + }), }) const toolResultContent: ContentBlock[] = [] diff --git a/apps/sim/providers/bedrock/utils.test.ts b/apps/sim/providers/bedrock/utils.test.ts index 421e46bcff4..851a84991aa 100644 --- a/apps/sim/providers/bedrock/utils.test.ts +++ b/apps/sim/providers/bedrock/utils.test.ts @@ -26,6 +26,34 @@ describe('getBedrockInferenceProfileId', () => { ) }) + it.each([ + 'arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/MyProfile', + 'arn:aws:bedrock:us-east-1:123456789012:custom-model-deployment/MyDeployment', + 'MyCustomProfile', + 'future.vendor-model-v1:0', + 'global.anthropic.claude-opus-5', + ])('preserves caller-supplied model ID %s', (model) => { + expect(getBedrockInferenceProfileId(`BEDROCK/${model}`, 'us-east-1')).toBe(model) + }) + + it.concurrent('uses only published geographic prefixes for new catalog models', () => { + expect(getBedrockInferenceProfileId('bedrock/anthropic.claude-opus-5', 'us-east-1')).toBe( + 'us.anthropic.claude-opus-5' + ) + expect(getBedrockInferenceProfileId('bedrock/anthropic.claude-opus-5', 'ap-southeast-2')).toBe( + 'au.anthropic.claude-opus-5' + ) + expect(getBedrockInferenceProfileId('bedrock/openai.gpt-5.6-sol', 'us-east-1')).toBe( + 'us.openai.gpt-5.6-sol' + ) + expect(() => getBedrockInferenceProfileId('bedrock/openai.gpt-5.6-sol', 'eu-west-1')).toThrow( + 'Supply an explicit bedrock/global.' + ) + expect(getBedrockInferenceProfileId('bedrock/openai.gpt-oss-120b-1:0', 'us-east-1')).toBe( + 'openai.gpt-oss-120b-1:0' + ) + }) + it.concurrent('returns the bare model ID for models without geo profile support', () => { expect( getBedrockInferenceProfileId('bedrock/mistral.mistral-large-3-675b-instruct', 'us-east-1') diff --git a/apps/sim/providers/bedrock/utils.ts b/apps/sim/providers/bedrock/utils.ts index a8e837b1142..b84cba8c9a0 100644 --- a/apps/sim/providers/bedrock/utils.ts +++ b/apps/sim/providers/bedrock/utils.ts @@ -122,22 +122,49 @@ export function generateToolUseId(toolName: string): string { } /** - * Models whose AWS model cards state geo/cross-region inference profiles are - * not supported ("Geo inference ID: Not supported"). These must be invoked - * with the bare in-region model ID — prefixing them with a geo profile - * (e.g. us.mistral...) produces an invalid model identifier. + * Catalog models with documented geographic inference profiles. Unknown model + * IDs and caller-supplied inference profile IDs/ARNs must pass through unchanged. */ -const GEO_PROFILE_UNSUPPORTED_MODEL_IDS = new Set([ - 'mistral.mistral-large-3-675b-instruct', - 'mistral.mistral-large-2407-v1:0', - 'mistral.magistral-small-2509', - 'mistral.ministral-3-14b-instruct', - 'mistral.ministral-3-8b-instruct', - 'mistral.ministral-3-3b-instruct', - 'mistral.mixtral-8x7b-instruct-v0:1', - 'amazon.titan-text-premier-v1:0', - 'cohere.command-r-v1:0', - 'cohere.command-r-plus-v1:0', +const GEO_PROFILE_MODEL_IDS = new Set([ + 'anthropic.claude-opus-4-5-20251101-v1:0', + 'anthropic.claude-sonnet-4-5-20250929-v1:0', + 'anthropic.claude-haiku-4-5-20251001-v1:0', + 'anthropic.claude-opus-4-1-20250805-v1:0', + 'amazon.nova-2-lite-v1:0', + 'amazon.nova-premier-v1:0', + 'amazon.nova-pro-v1:0', + 'amazon.nova-lite-v1:0', + 'amazon.nova-micro-v1:0', + 'meta.llama4-maverick-17b-instruct-v1:0', + 'meta.llama4-scout-17b-instruct-v1:0', + 'meta.llama3-3-70b-instruct-v1:0', + 'meta.llama3-2-90b-instruct-v1:0', + 'meta.llama3-2-11b-instruct-v1:0', + 'meta.llama3-2-3b-instruct-v1:0', + 'meta.llama3-2-1b-instruct-v1:0', + 'meta.llama3-1-405b-instruct-v1:0', + 'meta.llama3-1-70b-instruct-v1:0', + 'meta.llama3-1-8b-instruct-v1:0', + 'mistral.pixtral-large-2502-v1:0', +]) + +/** Current Claude profiles use AU rather than the older APAC geography. */ +const CLAUDE_GEO_PROFILE_MODEL_IDS = new Set([ + 'anthropic.claude-opus-5', + 'anthropic.claude-sonnet-5', + 'anthropic.claude-opus-4-8', + 'anthropic.claude-opus-4-7', + 'anthropic.claude-opus-4-6-v1', + 'anthropic.claude-sonnet-4-6', +]) + +/** These models currently publish US and global inference profiles only. */ +const US_GEO_PROFILE_MODEL_IDS = new Set([ + 'anthropic.claude-fable-5', + 'openai.gpt-6-astra', + 'openai.gpt-5.6-sol', + 'openai.gpt-5.6-terra', + 'openai.gpt-5.6-luna', ]) /** Cross-region inference profile prefixes Bedrock prepends to a base model ID. */ @@ -147,8 +174,8 @@ const GEO_PROFILE_PREFIX_PATTERN = /^(us-gov|us|eu|apac|au|ca|jp|global)\./ * Strips Sim's `bedrock/` namespace and any cross-region inference prefix, * leaving the bare `.` ID that capability checks key off. */ -function getBedrockBaseModelId(modelId: string): string { - const withoutNamespace = modelId.startsWith('bedrock/') ? modelId.slice(8) : modelId +export function getBedrockBaseModelId(modelId: string): string { + const withoutNamespace = modelId.replace(/^bedrock\//i, '') return withoutNamespace.replace(GEO_PROFILE_PREFIX_PATTERN, '') } @@ -177,13 +204,33 @@ export function supportsToolResultStatus(modelId: string): boolean { * @returns The inference profile ID (e.g., "us.anthropic.claude-sonnet-4-5-20250929-v1:0") */ export function getBedrockInferenceProfileId(modelId: string, region: string): string { - const baseModelId = modelId.startsWith('bedrock/') ? modelId.slice(8) : modelId + const baseModelId = modelId.replace(/^bedrock\//i, '') if (GEO_PROFILE_PREFIX_PATTERN.test(baseModelId)) { return baseModelId } - if (GEO_PROFILE_UNSUPPORTED_MODEL_IDS.has(baseModelId)) { + if (CLAUDE_GEO_PROFILE_MODEL_IDS.has(baseModelId)) { + if ((region.startsWith('us-') && !region.startsWith('us-gov-')) || region.startsWith('ca-')) { + return `us.${baseModelId}` + } + if (region.startsWith('eu-')) return `eu.${baseModelId}` + if (region === 'ap-southeast-2' || region === 'ap-southeast-4') return `au.${baseModelId}` + throw new Error( + `No geographic inference profile is configured for ${baseModelId} in ${region}. ` + + 'Supply an explicit bedrock/global. model ID or an inference profile ARN.' + ) + } + + if (US_GEO_PROFILE_MODEL_IDS.has(baseModelId)) { + if (region.startsWith('us-') && !region.startsWith('us-gov-')) return `us.${baseModelId}` + throw new Error( + `${baseModelId} only has a US geographic inference profile. ` + + 'Supply an explicit bedrock/global. model ID to use global inference.' + ) + } + + if (!GEO_PROFILE_MODEL_IDS.has(baseModelId)) { return baseModelId } diff --git a/apps/sim/providers/cerebras/index.ts b/apps/sim/providers/cerebras/index.ts index bb79fcfa807..14613a0b7bb 100644 --- a/apps/sim/providers/cerebras/index.ts +++ b/apps/sim/providers/cerebras/index.ts @@ -81,11 +81,14 @@ export const cerebrasProvider: ProviderConfig = { : undefined const payload: any = { - model: request.model.replace('cerebras/', ''), + model: request.model.replace(/^cerebras\//i, ''), messages: formattedMessages, } if (request.temperature !== undefined) payload.temperature = request.temperature if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens + if (request.reasoningEffort && request.reasoningEffort !== 'auto') { + payload.reasoning_effort = request.reasoningEffort + } if (request.responseFormat) { payload.response_format = { type: 'json_schema', diff --git a/apps/sim/providers/deepseek/index.test.ts b/apps/sim/providers/deepseek/index.test.ts index 174123c7ffa..c1c363f38a1 100644 --- a/apps/sim/providers/deepseek/index.test.ts +++ b/apps/sim/providers/deepseek/index.test.ts @@ -108,6 +108,20 @@ describe('deepseekProvider thinking payload', () => { expect(payload.thinking).toBeUndefined() }) + it.each([ + ['low', 'low'], + ['minimal', 'low'], + ['medium', 'high'], + ['xhigh', 'high'], + ['max', 'max'], + ] as const)('maps Flash reasoning effort %s to %s', async (reasoningEffort, expected) => { + await deepseekProvider.executeRequest(request({ model: 'deepseek-flash', reasoningEffort })) + expect(mockCreate.mock.calls[0][0]).toMatchObject({ + model: 'deepseek-flash', + reasoning_effort: expected, + }) + }) + it('selects the live tool loop without a caller flag', async () => { mockPrepareToolsWithUsageControl.mockReturnValue({ tools: [ diff --git a/apps/sim/providers/deepseek/index.ts b/apps/sim/providers/deepseek/index.ts index 2897d8e9ab0..7dd067f5a26 100644 --- a/apps/sim/providers/deepseek/index.ts +++ b/apps/sim/providers/deepseek/index.ts @@ -105,9 +105,9 @@ export const deepseekProvider: ProviderConfig = { } if (request.reasoningEffort && !['auto', 'none'].includes(request.reasoningEffort)) { payload.reasoning_effort = - request.reasoningEffort === 'xhigh' - ? 'max' - : request.reasoningEffort === 'low' || request.reasoningEffort === 'medium' + request.reasoningEffort === 'minimal' + ? 'low' + : request.reasoningEffort === 'xhigh' || request.reasoningEffort === 'medium' ? 'high' : request.reasoningEffort } diff --git a/apps/sim/providers/fireworks/index.test.ts b/apps/sim/providers/fireworks/index.test.ts index 9dcd6d9cfe5..8a5ef735ad3 100644 --- a/apps/sim/providers/fireworks/index.test.ts +++ b/apps/sim/providers/fireworks/index.test.ts @@ -127,6 +127,18 @@ describe('fireworksProvider', () => { apiKey: 'fw-test-key', } + it('preserves custom deployment paths when stripping an uppercase namespace', async () => { + mockCreate.mockResolvedValueOnce(textResponse('ok')) + await fireworksProvider.executeRequest({ + ...baseRequest, + model: 'FIREWORKS/accounts/Example/models/CustomModel', + }) + expect(mockResolveFireworksWireModel).toHaveBeenCalledWith( + 'accounts/Example/models/CustomModel' + ) + expect(callBody(0).model).toBe('accounts/Example/models/CustomModel') + }) + it('throws when the API key is missing', async () => { await expect( fireworksProvider.executeRequest({ ...baseRequest, apiKey: undefined }) diff --git a/apps/sim/providers/fireworks/index.ts b/apps/sim/providers/fireworks/index.ts index 5ebb31606cd..f82370e02d8 100644 --- a/apps/sim/providers/fireworks/index.ts +++ b/apps/sim/providers/fireworks/index.ts @@ -93,7 +93,7 @@ export const fireworksProvider: ProviderConfig = { baseURL: 'https://api.fireworks.ai/inference/v1', }) - const requestedModel = resolveFireworksWireModel(request.model.replace(/^fireworks\//, '')) + const requestedModel = resolveFireworksWireModel(request.model.replace(/^fireworks\//i, '')) logger.info('Preparing Fireworks request', { model: requestedModel, diff --git a/apps/sim/providers/gemini/core.request.test.ts b/apps/sim/providers/gemini/core.request.test.ts new file mode 100644 index 00000000000..1d813deb3a5 --- /dev/null +++ b/apps/sim/providers/gemini/core.request.test.ts @@ -0,0 +1,172 @@ +/** + * @vitest-environment node + */ +import type { GenerateContentParameters, GenerateContentResponse } from '@google/genai' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { StreamingExecution } from '@/executor/types' +import { executeGeminiRequest } from '@/providers/gemini/core' +import type { ProviderRequest, ProviderResponse } from '@/providers/types' + +const { mockExecuteTool } = vi.hoisted(() => ({ mockExecuteTool: vi.fn() })) + +vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 })) + +function textTurn(): GenerateContentResponse { + return { + candidates: [{ content: { role: 'model', parts: [{ text: 'answer' }] }, finishReason: 'STOP' }], + usageMetadata: { + promptTokenCount: 1000, + cachedContentTokenCount: 200, + candidatesTokenCount: 100, + totalTokenCount: 1100, + }, + } as GenerateContentResponse +} + +async function run( + model: string, + generateContent: ReturnType, + overrides: Partial = {} +) { + return (await executeGeminiRequest({ + ai: { models: { generateContent, generateContentStream: vi.fn() } } as never, + model: model.replace(/^vertex\//i, ''), + providerType: 'vertex', + request: { + model, + apiKey: 'test-key', + messages: [{ role: 'user', content: 'Look this up' }], + temperature: 0.5, + thinkingLevel: 'medium', + ...overrides, + }, + })) as ProviderResponse +} + +describe('Vertex Gemini request compatibility', () => { + beforeEach(() => { + vi.clearAllMocks() + mockExecuteTool.mockResolvedValue({ success: true, output: { value: 'tool result' } }) + }) + + it.each([ + 'vertex/gemini-3.8-flash', + 'vertex/gemini-3.7-flash', + 'vertex/gemini-3.6-flash', + 'vertex/gemini-3.5-flash-lite', + ])('uses thinking levels and omits ignored temperature for %s', async (model) => { + const generateContent = vi.fn().mockResolvedValue(textTurn()) + await run(model, generateContent) + + const params = generateContent.mock.calls[0][0] as GenerateContentParameters + expect(params.model).toBe(model.replace('vertex/', '')) + expect(params.config).not.toHaveProperty('temperature') + expect(params.config?.thinkingConfig).toEqual({ + thinkingLevel: 'MEDIUM', + includeThoughts: false, + }) + }) + + it.each(['vertex/gemini-2.5-flash', 'vertex/Custom-Gemini-Model'])( + 'preserves temperature for supported or uncataloged %s', + async (model) => { + const generateContent = vi.fn().mockResolvedValue(textTurn()) + await run(model, generateContent) + + expect(generateContent.mock.calls[0][0].config.temperature).toBe(0.5) + } + ) + + it('prices Vertex-only catalog entries using the namespaced model ID', async () => { + const generateContent = vi.fn().mockResolvedValue(textTurn()) + const result = await run('vertex/gemini-3.8-flash', generateContent) + + expect(result.cost?.input).toBeCloseTo((800 * 0.75 + 200 * 0.075) / 1e6, 10) + expect(result.cost?.output).toBeCloseTo((100 * 3.75) / 1e6, 10) + }) + + it.each([false, true])( + 'retains Vertex pricing when streaming with tools enabled: %s', + async (withTools) => { + const generateContentStream = vi.fn().mockImplementation(async function* () { + yield textTurn() + }) + const result = (await executeGeminiRequest({ + ai: { models: { generateContentStream } } as never, + model: 'gemini-3.8-flash', + providerType: 'vertex', + request: { + model: 'vertex/gemini-3.8-flash', + messages: [{ role: 'user', content: 'Hello' }], + stream: true, + ...(withTools + ? { + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Look up a query', + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + } + : {}), + }, + })) as StreamingExecution + const reader = result.stream.getReader() + while (!(await reader.read()).done) {} + reader.releaseLock() + + expect(result.execution.output.cost?.input).toBeCloseTo((800 * 0.75 + 200 * 0.075) / 1e6, 10) + expect(result.execution.output.cost?.output).toBeCloseTo((100 * 3.75) / 1e6, 10) + } + ) + + it('echoes IDs and thought signatures for parallel same-name function calls', async () => { + const functionCalls = [ + { id: 'call-first', name: 'lookup', args: { query: 'first' } }, + { id: 'call-second', name: 'lookup', args: { query: 'second' } }, + ] + const parts = functionCalls.map((functionCall, index) => ({ + functionCall, + thoughtSignature: `signature-${index}`, + })) + const generateContent = vi + .fn() + .mockResolvedValueOnce({ + candidates: [{ content: { role: 'model', parts }, finishReason: 'STOP' }], + functionCalls, + usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 20, totalTokenCount: 30 }, + }) + .mockResolvedValueOnce(textTurn()) + + await run('vertex/gemini-3.8-flash', generateContent, { + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Look up a query', + parameters: { + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'], + }, + }, + ], + }) + + expect(generateContent).toHaveBeenCalledTimes(2) + const followUp = generateContent.mock.calls[1][0] as GenerateContentParameters + expect(followUp.contents).toEqual([ + expect.objectContaining({ role: 'user' }), + { role: 'model', parts }, + { + role: 'user', + parts: functionCalls.map(({ id, name }) => ({ + functionResponse: { id, name, response: { value: 'tool result' } }, + })), + }, + ]) + }) +}) diff --git a/apps/sim/providers/gemini/core.ts b/apps/sim/providers/gemini/core.ts index 175b71d96b7..d47e4c06cfa 100644 --- a/apps/sim/providers/gemini/core.ts +++ b/apps/sim/providers/gemini/core.ts @@ -31,6 +31,7 @@ import { mapToThinkingLevel, supportsDisablingGemini25Thinking, } from '@/providers/google/utils' +import { getModelCapabilities, isKnownModelId } from '@/providers/models' import { executeProviderTool } from '@/providers/runtime-context' import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' @@ -215,6 +216,7 @@ async function executeToolCallsBatch( functionResponse: { name: r.toolName, response: 'modelResultContent' in r ? r.modelResultContent : r.resultContent, + ...(r.part.functionCall?.id ? { id: r.part.functionCall.id } : {}), }, })) @@ -966,7 +968,10 @@ export async function executeGeminiRequest( if (request.abortSignal) { geminiConfig.abortSignal = request.abortSignal } - if (request.temperature !== undefined) { + if ( + request.temperature !== undefined && + (!isKnownModelId(request.model) || getModelCapabilities(request.model)?.temperature) + ) { geminiConfig.temperature = request.temperature } if (request.maxTokens != null) { @@ -1148,7 +1153,7 @@ export async function executeGeminiRequest( streamingResult.execution.output.content = content streamingResult.execution.output.tokens = { ...split, total: usage.totalTokenCount } - streamingResult.execution.output.cost = priceGeminiTokens(model, split) + streamingResult.execution.output.cost = priceGeminiTokens(request.model, split) if (thinking) { const segment = streamingResult.execution.output.providerTiming?.timeSegments?.[0] @@ -1192,11 +1197,11 @@ export async function executeGeminiRequest( initialUsage, firstResponseTime, initialCallTime, - model, + request.model, toolConfig ) enrichLastModelSegmentFromGeminiResponse(state.timeSegments, response, { - model, + model: request.model, }) const forcedTools = preparedTools?.forcedTools ?? [] @@ -1225,12 +1230,12 @@ export async function executeGeminiRequest( const finalState = updateStateWithResponse( currentState, finalResponse, - model, + request.model, finalStartTime, Date.now() ) enrichLastModelSegmentFromGeminiResponse(finalState.timeSegments, finalResponse, { - model, + model: request.model, }) return { state: finalState, response: finalResponse } } @@ -1324,9 +1329,15 @@ export async function executeGeminiRequest( contents: state.contents, config: nextConfig, }) - state = updateStateWithResponse(state, nextResponse, model, nextModelStartTime, Date.now()) + state = updateStateWithResponse( + state, + nextResponse, + request.model, + nextModelStartTime, + Date.now() + ) enrichLastModelSegmentFromGeminiResponse(state.timeSegments, nextResponse, { - model, + model: request.model, }) currentResponse = nextResponse diff --git a/apps/sim/providers/gemini/streaming-tool-loop.ts b/apps/sim/providers/gemini/streaming-tool-loop.ts index 25167caeb19..646f19acfca 100644 --- a/apps/sim/providers/gemini/streaming-tool-loop.ts +++ b/apps/sim/providers/gemini/streaming-tool-loop.ts @@ -339,7 +339,7 @@ export function createGeminiStreamingToolLoopStream( tokens.cacheRead += split.cacheRead tokens.total += drained.usage.totalTokenCount - const turnCost = priceGeminiTokens(model, split) + const turnCost = priceGeminiTokens(request.model, split) costInput += turnCost.input costOutput += turnCost.output costTotal += turnCost.total diff --git a/apps/sim/providers/google/utils.test.ts b/apps/sim/providers/google/utils.test.ts index ead73247543..12f21ffdc2e 100644 --- a/apps/sim/providers/google/utils.test.ts +++ b/apps/sim/providers/google/utils.test.ts @@ -280,6 +280,10 @@ describe('convertToGeminiFormat', () => { const result = convertToGeminiFormat(request) + expect(result.contents[1].parts?.[0].functionCall).toMatchObject({ + id: 'call_123', + name: 'get_weather', + }) const toolResponseContent = result.contents.find( (c) => c.parts?.[0] && 'functionResponse' in c.parts[0] ) diff --git a/apps/sim/providers/google/utils.ts b/apps/sim/providers/google/utils.ts index 63822da75d0..79aa652fb74 100644 --- a/apps/sim/providers/google/utils.ts +++ b/apps/sim/providers/google/utils.ts @@ -162,6 +162,7 @@ export function convertToGeminiFormat( if (message.role === 'assistant' && message.tool_calls?.length) { const functionCalls = message.tool_calls.map((toolCall) => ({ functionCall: { + id: toolCall.id, name: toolCall.function?.name, args: JSON.parse(toolCall.function?.arguments || '{}') as Record, }, diff --git a/apps/sim/providers/groq/index.test.ts b/apps/sim/providers/groq/index.test.ts index a10fdce4454..b946d249153 100644 --- a/apps/sim/providers/groq/index.test.ts +++ b/apps/sim/providers/groq/index.test.ts @@ -140,6 +140,23 @@ describe('groqProvider reasoning payload', () => { expect(payload.reasoning_effort).toBe('none') }) + it.each(['none', 'low', 'medium', 'high'] as const)( + 'Qwen 3.8 forwards explicit reasoning effort %s', + async (reasoningEffort) => { + await groqProvider.executeRequest( + request({ model: 'groq/qwen/qwen3.8-27b', reasoningEffort }) + ) + const payload = mockCreate.mock.calls[0][0] + expect(payload.reasoning_effort).toBe(reasoningEffort) + expect(payload.reasoning_format).toBe(reasoningEffort === 'none' ? undefined : 'parsed') + } + ) + + it('strips only the leading routing prefix and preserves custom model case', async () => { + await groqProvider.executeRequest(request({ model: 'Groq/Custom/Model-A' })) + expect(mockCreate.mock.calls[0][0].model).toBe('Custom/Model-A') + }) + it('selects the live tool loop without a caller flag', async () => { mockPrepareToolsWithUsageControl.mockReturnValue({ tools: [ diff --git a/apps/sim/providers/groq/index.ts b/apps/sim/providers/groq/index.ts index 6bccdb9479a..4b6c310b96f 100644 --- a/apps/sim/providers/groq/index.ts +++ b/apps/sim/providers/groq/index.ts @@ -79,7 +79,7 @@ export const groqProvider: ProviderConfig = { : undefined const payload: any = { - model: request.model.replace('groq/', ''), + model: request.model.replace(/^groq\//i, ''), messages: formattedMessages, } @@ -101,6 +101,9 @@ export const groqProvider: ProviderConfig = { if (isGptOss && (hasExplicitEffort || hasThinkingLevel)) { payload.include_reasoning = true payload.reasoning_effort = hasExplicitEffort ? request.reasoningEffort : 'medium' + } else if (isQwenReasoning && hasExplicitEffort) { + payload.reasoning_effort = request.reasoningEffort + if (request.reasoningEffort !== 'none') payload.reasoning_format = 'parsed' } else if (isQwenReasoning && hasThinkingLevel) { payload.reasoning_format = 'parsed' } else if (isQwenReasoning && request.thinkingLevel === 'none') { diff --git a/apps/sim/providers/index.test.ts b/apps/sim/providers/index.test.ts index f6f962b71e5..e7219714432 100644 --- a/apps/sim/providers/index.test.ts +++ b/apps/sim/providers/index.test.ts @@ -1599,6 +1599,30 @@ describe('executeProviderRequest — model level normalization', () => { expect(sentRequest().reasoningEffort).toBe('high') }) + it.each([ + ['azure-openai', 'azure/MyDeployment'], + ['azure-anthropic', 'azure-anthropic/MyDeployment'], + ['bedrock', 'bedrock/custom-inference-profile'], + ['vertex', 'vertex/custom-gemini'], + ])('preserves tuning levels for a custom %s deployment', async (provider, model) => { + await executeProviderRequest(provider, { + model, + workspaceId: 'ws-1', + reasoningEffort: 'high', + verbosity: 'low', + thinkingLevel: 'high', + temperature: 0.7, + }) + + expect(sentRequest()).toMatchObject({ + model, + reasoningEffort: 'high', + verbosity: 'low', + thinkingLevel: 'high', + temperature: 0.7, + }) + }) + it('still drops levels for a dynamic-provider model that does not take them', async () => { await executeProviderRequest('ollama', { model: 'ollama/llama3', diff --git a/apps/sim/providers/index.ts b/apps/sim/providers/index.ts index 01a64387c99..8c7b315f677 100644 --- a/apps/sim/providers/index.ts +++ b/apps/sim/providers/index.ts @@ -114,10 +114,6 @@ function sanitizeRequest(request: ProviderRequest): ProviderRequest { sanitizedRequest.verbosity = normalizeModelLevel(sanitizedRequest.verbosity) sanitizedRequest.thinkingLevel = normalizeModelLevel(sanitizedRequest.thinkingLevel) - if (model && !supportsTemperature(model)) { - sanitizedRequest.temperature = undefined - } - /** * A model absent from the catalogue is unknown, not known-incapable. The model field is an * editable combobox, so a model newer than `models.ts` reaches this point routed by pattern @@ -128,6 +124,10 @@ function sanitizeRequest(request: ProviderRequest): ProviderRequest { */ const isCatalogued = Boolean(model) && isKnownModelId(model) + if (model && isCatalogued && !supportsTemperature(model)) { + sanitizedRequest.temperature = undefined + } + if (model && isCatalogued && !supportsReasoningEffort(model)) { sanitizedRequest.reasoningEffort = undefined } diff --git a/apps/sim/providers/kimi/index.ts b/apps/sim/providers/kimi/index.ts index 0b25f27b5fe..c36a2450116 100644 --- a/apps/sim/providers/kimi/index.ts +++ b/apps/sim/providers/kimi/index.ts @@ -48,6 +48,16 @@ const THINKING_TOGGLE_MODELS = new Set( ) ) +function buildRequiredToolPayload( + tools: OpenAI.Chat.Completions.ChatCompletionTool[], + name: string +) { + return { + tools: tools.filter((tool) => tool.type === 'function' && tool.function.name === name), + tool_choice: 'required' as const, + } +} + function buildResponseFormatPayload( responseFormat: NonNullable ) { @@ -78,8 +88,8 @@ function buildResponseFormatPayload( * rejects the object form whenever thinking is enabled ("tool_choice 'specified' is * incompatible with thinking enabled", verified live). On models with a thinking toggle the * adapter therefore sends `thinking: { type: "disabled" }` for the duration of a forced-tool - * request; on always-thinking models (kimi-k3, kimi-k2.7-code) it downgrades the forced - * choice to `"auto"` with a warning, mirroring the Z.ai adapter's behavior. + * request. K3 supports `required`, so it forces a named tool by offering only that tool; + * K2.7 Code falls back to `auto` because it supports neither forcing mechanism. */ export const kimiProvider: ProviderConfig = { id: 'kimi', @@ -137,6 +147,9 @@ export const kimiProvider: ProviderConfig = { } if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens + if (request.reasoningEffort && request.reasoningEffort !== 'auto') { + payload.reasoning_effort = request.reasoningEffort + } if ( THINKING_TOGGLE_MODELS.has(request.model) && @@ -162,7 +175,12 @@ export const kimiProvider: ProviderConfig = { hasActiveTools = true if (typeof toolChoice === 'object') { - if (THINKING_TOGGLE_MODELS.has(request.model)) { + if (request.model === 'kimi-k3' && toolChoice.type === 'function') { + Object.assign( + payload, + buildRequiredToolPayload(filteredTools, toolChoice.function.name) + ) + } else if (THINKING_TOGGLE_MODELS.has(request.model)) { if (payload.thinking?.type === 'enabled') { logger.warn( 'Kimi rejects forced tool_choice while thinking is enabled — disabling thinking for this forced-tool request', @@ -241,7 +259,8 @@ export const kimiProvider: ProviderConfig = { } const initialCallTime = Date.now() - const originalToolChoice = payload.tool_choice + const originalToolChoice = + request.model === 'kimi-k3' ? preparedTools?.toolChoice : payload.tool_choice const forcedTools = preparedTools?.forcedTools || [] let usedForcedTools: string[] = [] @@ -457,22 +476,33 @@ export const kimiProvider: ProviderConfig = { const nextPayload = { ...payload, messages: currentMessages, + tools: preparedTools?.tools, } + let nextToolChoice = nextPayload.tool_choice if ( typeof originalToolChoice === 'object' && - hasUsedForcedTool && + (hasUsedForcedTool || request.model === 'kimi-k3') && forcedTools.length > 0 ) { const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) if (remainingTools.length > 0) { - nextPayload.tool_choice = { + nextToolChoice = { type: 'function', function: { name: remainingTools[0] }, } + if (request.model === 'kimi-k3') { + Object.assign( + nextPayload, + buildRequiredToolPayload(preparedTools?.tools || [], remainingTools[0]) + ) + } else { + nextPayload.tool_choice = nextToolChoice + } logger.info(`Forcing next tool: ${remainingTools[0]}`) } else { + nextToolChoice = 'auto' nextPayload.tool_choice = 'auto' logger.info('All forced tools have been used, switching to auto tool_choice') } @@ -486,10 +516,10 @@ export const kimiProvider: ProviderConfig = { const toolCallsResponse = currentResponse.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall) - if (typeof nextPayload.tool_choice === 'object' && toolCallsResponse?.length) { + if (typeof nextToolChoice === 'object' && toolCallsResponse?.length) { const result = trackForcedToolUsage( toolCallsResponse, - nextPayload.tool_choice, + nextToolChoice, logger, 'openai', forcedTools, diff --git a/apps/sim/providers/litellm/index.test.ts b/apps/sim/providers/litellm/index.test.ts index 09525507656..7bc9c3253d0 100644 --- a/apps/sim/providers/litellm/index.test.ts +++ b/apps/sim/providers/litellm/index.test.ts @@ -140,6 +140,11 @@ describe('litellmProvider.executeRequest', () => { mockExecuteTool.mockResolvedValue({ success: true, output: { ok: true } }) }) + it('preserves a custom proxy model name when stripping an uppercase namespace', async () => { + await run({ model: 'LITELLM/Org/CustomModel' }) + expect(firstPayload().model).toBe('Org/CustomModel') + }) + it('assembles messages, strips the model prefix, and maps params', async () => { const result = await run({ systemPrompt: 'You are helpful.', diff --git a/apps/sim/providers/litellm/index.ts b/apps/sim/providers/litellm/index.ts index 2bdcb7e4a66..aa670263b3e 100644 --- a/apps/sim/providers/litellm/index.ts +++ b/apps/sim/providers/litellm/index.ts @@ -142,7 +142,7 @@ export const litellmProvider: ProviderConfig = { : undefined const payload: any = { - model: request.model.replace(/^litellm\//, ''), + model: request.model.replace(/^litellm\//i, ''), messages: formattedMessages, } diff --git a/apps/sim/providers/models.test.ts b/apps/sim/providers/models.test.ts index d6fa3568d05..bcb1d103709 100644 --- a/apps/sim/providers/models.test.ts +++ b/apps/sim/providers/models.test.ts @@ -3,6 +3,7 @@ */ import { describe, expect, it } from 'vitest' import { + findProviderFromModel, getBaseModelProviders, getHostedModels, getModelCapabilities, @@ -11,14 +12,100 @@ import { getPromptCachingMinimumTokens, getProviderModels, getThinkingStreamVisibility, + isCustomModelId, + isKnownModelId, isModelDeprecated, orderModelIdsByReleaseDate, PROVIDER_DEFINITIONS, supportsForcedToolUse, updateFireworksModels, + updateOllamaModels, } from '@/providers/models' import { supportsPromptCaching } from '@/providers/utils' +describe('custom cloud model routing', () => { + it.each([ + 'ollama', + 'ollama-cloud', + 'vllm', + 'litellm', + 'openrouter', + 'fireworks', + 'together', + 'baseten', + ] as const)( + 'accepts new model IDs in the %s namespace without accepting an empty ID', + (provider) => { + expect(findProviderFromModel(`${provider.toUpperCase()}/Org/CustomModel`)).toBe(provider) + expect(isKnownModelId(`${provider}/Org/CustomModel`)).toBe(true) + expect(isKnownModelId(`${provider}/`)).toBe(false) + expect(isKnownModelId(`${provider}/ `)).toBe(false) + } + ) + + it('keeps explicit provider namespaces authoritative over discovered local model names', () => { + const originalModels = PROVIDER_DEFINITIONS.ollama.models + try { + updateOllamaModels([ + 'azure/MyDeployment', + 'bedrock/CustomModel', + 'vertex/CustomModel', + 'openrouter/Org/CustomModel', + 'groq/Org/CustomModel', + 'cerebras/CustomModel', + ]) + expect(findProviderFromModel('azure/MyDeployment')).toBe('azure-openai') + expect(findProviderFromModel('bedrock/CustomModel')).toBe('bedrock') + expect(findProviderFromModel('vertex/CustomModel')).toBe('vertex') + expect(findProviderFromModel('openrouter/Org/CustomModel')).toBe('openrouter') + expect(findProviderFromModel('groq/Org/CustomModel')).toBe('groq') + expect(findProviderFromModel('cerebras/CustomModel')).toBe('cerebras') + } finally { + PROVIDER_DEFINITIONS.ollama.models = originalModels + } + }) + + it.each([ + ['azure/MyDeployment', 'azure-openai'], + ['AZURE/MyDeployment', 'azure-openai'], + ['azure-anthropic/MyDeployment', 'azure-anthropic'], + ['bedrock/custom-model:0', 'bedrock'], + ['BEDROCK/custom-model:0', 'bedrock'], + ['vertex/publishers/google/models/custom-gemini', 'vertex'], + ['VERTEX/CustomModel', 'vertex'], + ['GROQ/Org/CustomModel', 'groq'], + ['CEREBRAS/CustomModel', 'cerebras'], + ['NVIDIA/CustomModel', 'nvidia'], + ])('routes %s without requiring a catalog entry', (model, provider) => { + expect(findProviderFromModel(model)).toBe(provider) + expect(isCustomModelId(model)).toBe(true) + expect(isKnownModelId(model)).toBe(false) + expect(getModelPricing(model)).toBeNull() + expect(getHostedModels()).not.toContain(model) + }) + + it.each([ + 'azure/', + 'azure/ ', + 'azure-anthropic/', + 'bedrock/', + 'vertex/', + 'groq/', + 'cerebras/', + 'nvidia/', + 'unknown/model', + 'gpt-100/model', + 'mistral/model', + ])('does not accept an empty or unrecognized namespace as a custom model: %s', (model) => { + expect(isCustomModelId(model)).toBe(false) + }) + + it('keeps catalog name typos distinct from custom reseller IDs', () => { + expect(isCustomModelId('claude-sonnet-4.6')).toBe(false) + expect(isCustomModelId('gpt-100-ultra')).toBe(false) + }) +}) + describe('OpenAI provider definition', () => { const openai = PROVIDER_DEFINITIONS.openai @@ -52,6 +139,19 @@ describe('OpenAI provider definition', () => { }) }) +describe('direct provider catalog additions', () => { + it.each([ + ['chat-latest', 'openai'], + ['gpt-5.3-codex', 'openai'], + ['gemini-3.7-flash', 'google'], + ])('routes %s through its hosted provider %s', (model, provider) => { + expect(findProviderFromModel(model)).toBe(provider) + expect(isKnownModelId(model)).toBe(true) + expect(getHostedModels()).toContain(model) + expect(getModelPricing(model)?.input).toBeGreaterThan(0) + }) +}) + describe('catalog featured model metadata', () => { it('defines at most one active featured model per provider', () => { for (const provider of Object.values(PROVIDER_DEFINITIONS)) { @@ -292,18 +392,34 @@ describe('sakana provider definition', () => { expect(sakana.modelPatterns).toEqual([/^fugu/]) }) - it('exposes fugu and fugu-ultra with a 1M context window', () => { - expect(sakana.models.map((m) => m.id)).toEqual(['fugu', 'fugu-ultra']) - for (const model of sakana.models) { - expect(model.contextWindow).toBe(1000000) - } + it('preserves existing aliases and exposes current versioned models', () => { + expect(sakana.models.map((model) => model.id)).toEqual( + expect.arrayContaining([ + 'fugu', + 'fugu-ultra', + 'fugu-ultra-v2.0', + 'fugu-max', + 'fugu-max-v1.0', + 'sakana-namazu', + 'sakana-namazu-v1.0', + ]) + ) + expect(sakana.models.find((model) => model.id === 'fugu')?.pricing).toMatchObject({ + input: 5, + output: 30, + cachedInput: 0.5, + updatedAt: '2026-06-22', + }) }) - it('prices both models at the documented fugu-ultra ceiling', () => { - for (const model of sakana.models) { - expect(model.pricing.input).toBe(5) - expect(model.pricing.output).toBe(30) - expect(model.pricing.cachedInput).toBe(0.5) + it('keeps current Ultra aliases aligned with the versioned long-context rate', () => { + for (const id of ['fugu-ultra', 'fugu-ultra-v2.0']) { + expect(sakana.models.find((model) => model.id === id)?.pricing).toMatchObject({ + input: 5, + output: 30, + cachedInput: 0.5, + tiers: [{ aboveInputTokens: 272000, input: 10, cachedInput: 1, output: 45 }], + }) } }) @@ -311,6 +427,8 @@ describe('sakana provider definition', () => { const baseModels = getBaseModelProviders() expect(baseModels.fugu).toBe('sakana') expect(baseModels['fugu-ultra']).toBe('sakana') + expect(baseModels['fugu-max-v1.0']).toBe('sakana') + expect(baseModels['sakana-namazu-v1.0']).toBe('sakana') }) }) @@ -318,6 +436,7 @@ describe('nvidia provider definition', () => { const nvidia = PROVIDER_DEFINITIONS.nvidia const expectedModels = [ + { id: 'nvidia/nemotron-3.5-lightning-30b-a3b', contextWindow: 1000000 }, { 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 }, @@ -333,7 +452,7 @@ describe('nvidia provider definition', () => { expect(nvidia.modelPatterns).toEqual([/^nvidia\//]) }) - it('exposes all six Nemotron models with the documented context windows', () => { + it('exposes 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) diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index 37ccde6c448..edd140d3b28 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -635,6 +635,23 @@ export const PROVIDER_DEFINITIONS: Record = { releaseDate: '2026-03-17', speedOptimized: true, }, + { + id: 'gpt-5.3-codex', + pricing: { + input: 1.75, + cachedInput: 0.175, + output: 14.0, + updatedAt: '2026-09-14', + }, + capabilities: { + reasoningEffort: { + values: ['low', 'medium', 'high', 'xhigh'], + }, + maxOutputTokens: 128000, + }, + contextWindow: 400000, + releaseDate: '2026-02-05', + }, // GPT-5.2 family { id: 'gpt-5.2-pro', @@ -770,6 +787,20 @@ export const PROVIDER_DEFINITIONS: Record = { contextWindow: 400000, releaseDate: '2025-08-07', }, + { + id: 'chat-latest', + pricing: { + input: 5.0, + cachedInput: 0.5, + output: 30.0, + updatedAt: '2026-09-14', + }, + capabilities: { + maxOutputTokens: 128000, + }, + contextWindow: 400000, + releaseDate: '2026-05-05', + }, { id: 'gpt-5-chat-latest', pricing: { @@ -1245,6 +1276,132 @@ export const PROVIDER_DEFINITIONS: Record = { icon: AzureIcon, isReseller: true, models: [ + { + id: 'azure/gpt-6-astra', + pricing: { + input: 10, + cachedInput: 1, + output: 50, + updatedAt: '2026-09-14', + }, + capabilities: { + reasoningEffort: { + values: ['low', 'medium', 'high', 'xhigh', 'max'], + }, + verbosity: { + values: ['low', 'medium', 'high'], + }, + maxOutputTokens: 128000, + }, + contextWindow: 1050000, + releaseDate: '2026-09-03', + }, + { + id: 'azure/gpt-5.6-sol', + pricing: { + input: 4, + cachedInput: 0.5, + output: 20, + updatedAt: '2026-09-14', + }, + capabilities: { + reasoningEffort: { + values: ['none', 'low', 'medium', 'high', 'xhigh', 'max'], + }, + verbosity: { + values: ['low', 'medium', 'high'], + }, + maxOutputTokens: 128000, + }, + contextWindow: 1050000, + releaseDate: '2026-07-09', + }, + { + id: 'azure/gpt-5.6-terra', + pricing: { + input: 2, + cachedInput: 0.2, + output: 12, + updatedAt: '2026-09-14', + }, + capabilities: { + reasoningEffort: { + values: ['none', 'low', 'medium', 'high', 'xhigh', 'max'], + }, + verbosity: { + values: ['low', 'medium', 'high'], + }, + maxOutputTokens: 128000, + }, + contextWindow: 1050000, + releaseDate: '2026-07-09', + }, + { + id: 'azure/gpt-5.6-luna', + pricing: { + input: 0.2, + cachedInput: 0.02, + output: 1.2, + updatedAt: '2026-09-14', + }, + capabilities: { + reasoningEffort: { + values: ['none', 'low', 'medium', 'high', 'xhigh', 'max'], + }, + verbosity: { + values: ['low', 'medium', 'high'], + }, + maxOutputTokens: 128000, + }, + contextWindow: 1050000, + releaseDate: '2026-07-09', + }, + { + id: 'azure/gpt-5.5', + pricing: { + input: 5, + cachedInput: 0.5, + output: 30, + updatedAt: '2026-09-14', + }, + capabilities: { + reasoningEffort: { + values: ['none', 'low', 'medium', 'high', 'xhigh'], + }, + verbosity: { + values: ['low', 'medium', 'high'], + }, + maxOutputTokens: 128000, + }, + contextWindow: 1050000, + releaseDate: '2026-04-24', + }, + { + id: 'azure/gpt-5.4-pro', + pricing: { + input: 30, + output: 180, + tiers: [ + { + aboveInputTokens: 272000, + input: 60, + output: 270, + }, + ], + updatedAt: '2026-09-14', + }, + capabilities: { + reasoningEffort: { + values: ['medium', 'high', 'xhigh'], + }, + verbosity: { + values: ['low', 'medium', 'high'], + }, + maxOutputTokens: 128000, + }, + contextWindow: 1050000, + releaseDate: '2026-03-05', + }, { id: 'azure/gpt-4o', pricing: { @@ -1563,6 +1720,90 @@ export const PROVIDER_DEFINITIONS: Record = { promptCaching: { minimumCacheableTokens: 1024 }, }, models: [ + { + id: 'azure-anthropic/claude-fable-5-1', + pricing: { + input: 10, + cachedInput: 0.25, + output: 50, + updatedAt: '2026-09-14', + }, + capabilities: { + forcedToolUse: false, + nativeStructuredOutputs: true, + maxOutputTokens: 128000, + promptCaching: { minimumCacheableTokens: 512 }, + thinking: { + levels: ['low', 'medium', 'high', 'xhigh'], + default: 'high', + streamed: 'summary', + }, + }, + contextWindow: 1000000, + releaseDate: '2026-09-01', + }, + { + id: 'azure-anthropic/claude-opus-5', + pricing: { + input: 5, + cachedInput: 0.5, + output: 25, + updatedAt: '2026-09-14', + }, + capabilities: { + nativeStructuredOutputs: true, + maxOutputTokens: 128000, + promptCaching: { minimumCacheableTokens: 512 }, + thinking: { + levels: ['low', 'medium', 'high', 'xhigh', 'max'], + default: 'high', + streamed: 'summary', + }, + }, + contextWindow: 1000000, + releaseDate: '2026-07-24', + }, + { + id: 'azure-anthropic/claude-opus-4-8', + pricing: { + input: 5, + cachedInput: 0.5, + output: 25, + updatedAt: '2026-09-14', + }, + capabilities: { + nativeStructuredOutputs: true, + maxOutputTokens: 128000, + thinking: { + levels: ['low', 'medium', 'high', 'xhigh', 'max'], + default: 'high', + streamed: 'summary', + }, + }, + contextWindow: 1000000, + releaseDate: '2026-05-28', + }, + { + id: 'azure-anthropic/claude-opus-4-7', + pricing: { + input: 5, + cachedInput: 0.5, + output: 25, + updatedAt: '2026-09-14', + }, + capabilities: { + nativeStructuredOutputs: true, + maxOutputTokens: 128000, + promptCaching: { minimumCacheableTokens: 2048 }, + thinking: { + levels: ['low', 'medium', 'high', 'xhigh', 'max'], + default: 'high', + streamed: 'summary', + }, + }, + contextWindow: 1000000, + releaseDate: '2026-04-16', + }, { id: 'azure-anthropic/claude-opus-4-6', pricing: { @@ -1607,6 +1848,47 @@ export const PROVIDER_DEFINITIONS: Record = { contextWindow: 200000, releaseDate: '2025-11-24', }, + { + id: 'azure-anthropic/claude-sonnet-5', + pricing: { + input: 2, + cachedInput: 0.2, + output: 10, + updatedAt: '2026-09-14', + }, + capabilities: { + nativeStructuredOutputs: true, + maxOutputTokens: 128000, + thinking: { + levels: ['low', 'medium', 'high', 'xhigh', 'max'], + default: 'high', + streamed: 'summary', + }, + }, + contextWindow: 1000000, + releaseDate: '2026-06-30', + }, + { + id: 'azure-anthropic/claude-sonnet-4-6', + pricing: { + input: 3, + cachedInput: 0.3, + output: 15, + updatedAt: '2026-09-14', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + nativeStructuredOutputs: true, + maxOutputTokens: 128000, + thinking: { + levels: ['low', 'medium', 'high', 'max'], + default: 'high', + streamed: 'summary', + }, + }, + contextWindow: 1000000, + releaseDate: '2026-02-17', + }, { id: 'azure-anthropic/claude-sonnet-4-5', pricing: { @@ -1707,13 +1989,32 @@ export const PROVIDER_DEFINITIONS: Record = { featured: true, recommended: true, }, + { + id: 'gemini-3.7-flash', + pricing: { + input: 0.75, + cachedInput: 0.075, + output: 3.75, + updatedAt: '2026-09-14', + }, + capabilities: { + temperature: { min: 0, max: 2 }, + thinking: { + levels: ['low', 'medium', 'high'], + default: 'medium', + }, + maxOutputTokens: 65536, + }, + contextWindow: 1048576, + releaseDate: '2026-08-13', + }, { id: 'gemini-3.6-flash', pricing: { - input: 1.5, - cachedInput: 0.15, - output: 7.5, - updatedAt: '2026-07-21', + input: 0.75, + cachedInput: 0.075, + output: 3.75, + updatedAt: '2026-09-14', }, capabilities: { temperature: { min: 0, max: 2 }, @@ -1771,7 +2072,15 @@ export const PROVIDER_DEFINITIONS: Record = { input: 2.0, cachedInput: 0.2, output: 12.0, - updatedAt: '2026-06-11', + tiers: [ + { + aboveInputTokens: 200000, + input: 4.0, + cachedInput: 0.4, + output: 18.0, + }, + ], + updatedAt: '2026-09-14', }, capabilities: { temperature: { min: 0, max: 2 }, @@ -1829,7 +2138,15 @@ export const PROVIDER_DEFINITIONS: Record = { input: 1.25, cachedInput: 0.125, output: 10.0, - updatedAt: '2026-06-11', + tiers: [ + { + aboveInputTokens: 200000, + input: 2.5, + cachedInput: 0.25, + output: 15.0, + }, + ], + updatedAt: '2026-09-14', }, capabilities: { temperature: { min: 0, max: 2 }, @@ -1942,6 +2259,79 @@ export const PROVIDER_DEFINITIONS: Record = { toolUsageControl: true, }, models: [ + { + id: 'vertex/gemini-3.8-flash', + pricing: { + input: 0.75, + cachedInput: 0.075, + output: 3.75, + updatedAt: '2026-09-14', + }, + capabilities: { + thinking: { + levels: ['low', 'medium', 'high'], + default: 'medium', + }, + maxOutputTokens: 65536, + }, + contextWindow: 1048576, + releaseDate: '2026-09-02', + }, + { + id: 'vertex/gemini-3.7-flash', + pricing: { + input: 0.75, + cachedInput: 0.075, + output: 3.75, + updatedAt: '2026-09-14', + }, + capabilities: { + thinking: { + levels: ['low', 'medium', 'high'], + default: 'medium', + }, + maxOutputTokens: 65536, + }, + contextWindow: 1048576, + releaseDate: '2026-08-13', + }, + { + id: 'vertex/gemini-3.6-flash', + pricing: { + input: 0.75, + cachedInput: 0.075, + output: 3.75, + updatedAt: '2026-09-14', + }, + capabilities: { + thinking: { + levels: ['minimal', 'low', 'medium', 'high'], + default: 'medium', + }, + maxOutputTokens: 65536, + }, + contextWindow: 1048576, + releaseDate: '2026-07-21', + }, + { + id: 'vertex/gemini-3.5-flash-lite', + pricing: { + input: 0.3, + cachedInput: 0.03, + output: 2.5, + updatedAt: '2026-09-14', + }, + capabilities: { + thinking: { + levels: ['minimal', 'low', 'medium', 'high'], + default: 'minimal', + }, + maxOutputTokens: 65536, + }, + contextWindow: 1048576, + releaseDate: '2026-07-21', + speedOptimized: true, + }, { id: 'vertex/gemini-3.5-flash', pricing: { @@ -2159,14 +2549,15 @@ export const PROVIDER_DEFINITIONS: Record = { { id: 'deepseek-v4-pro', pricing: { - input: 0.435, - cachedInput: 0.003625, - output: 0.87, - updatedAt: '2026-06-16', + /** Peak rates; off-peak API requests cost half these rates. */ + input: 1.32, + cachedInput: 0.044, + output: 3.96, + updatedAt: '2026-09-14', }, capabilities: { reasoningEffort: { - values: ['high', 'max'], + values: ['low', 'high', 'max'], }, thinking: { levels: ['none', 'enabled'], @@ -2177,18 +2568,42 @@ export const PROVIDER_DEFINITIONS: Record = { contextWindow: 1000000, releaseDate: '2026-04-24', }, + { + id: 'deepseek-flash', + pricing: { + /** Peak rates; off-peak API requests cost half these rates. */ + input: 0.3, + cachedInput: 0.006, + output: 1.2, + updatedAt: '2026-09-14', + }, + capabilities: { + temperature: { min: 0, max: 2 }, + reasoningEffort: { + values: ['low', 'high', 'max'], + }, + thinking: { + levels: ['none', 'enabled'], + default: 'enabled', + }, + maxOutputTokens: 384000, + }, + contextWindow: 1000000, + releaseDate: '2026-09-10', + }, { id: 'deepseek-v4-flash', pricing: { - input: 0.14, - cachedInput: 0.0028, - output: 0.28, - updatedAt: '2026-06-16', + /** Legacy alias now serves V4.1 Flash, including its peak pricing. */ + input: 0.3, + cachedInput: 0.006, + output: 1.2, + updatedAt: '2026-09-14', }, capabilities: { temperature: { min: 0, max: 2 }, reasoningEffort: { - values: ['high', 'max'], + values: ['low', 'high', 'max'], }, thinking: { levels: ['none', 'enabled'], @@ -2289,7 +2704,15 @@ export const PROVIDER_DEFINITIONS: Record = { input: 2.0, cachedInput: 0.5, output: 6.0, - updatedAt: '2026-08-12', + tiers: [ + { + aboveInputTokens: 200000, + input: 4.0, + cachedInput: 1.0, + output: 12.0, + }, + ], + updatedAt: '2026-09-14', }, capabilities: { temperature: { min: 0, max: 2 }, @@ -2529,6 +2952,23 @@ export const PROVIDER_DEFINITIONS: Record = { toolUsageControl: true, }, models: [ + { + id: 'cerebras/qwen-3.8-27b', + pricing: { + input: 0.99, + output: 1.49, + updatedAt: '2026-09-14', + }, + capabilities: { + temperature: { min: 0, max: 2 }, + reasoningEffort: { + values: ['none', 'low', 'medium', 'high'], + }, + maxOutputTokens: 40960, + }, + contextWindow: 131072, + releaseDate: '2026-08-14', + }, { id: 'cerebras/gpt-oss-120b', pricing: { @@ -2681,6 +3121,23 @@ export const PROVIDER_DEFINITIONS: Record = { releaseDate: '2025-04-29', sunset: { status: 'deprecated' }, }, + { + id: 'groq/qwen/qwen3.8-27b', + pricing: { + input: 0.8, + output: 4.0, + updatedAt: '2026-09-14', + }, + capabilities: { + temperature: { min: 0, max: 2 }, + reasoningEffort: { + values: ['none', 'low', 'medium', 'high'], + }, + maxOutputTokens: 16384, + }, + contextWindow: 131042, + releaseDate: '2026-08-14', + }, { id: 'groq/qwen/qwen3.6-27b', pricing: { @@ -2689,7 +3146,7 @@ export const PROVIDER_DEFINITIONS: Record = { updatedAt: '2026-07-10', }, capabilities: { - maxOutputTokens: 32768, + maxOutputTokens: 16384, thinking: { levels: ['enabled'], default: 'enabled', @@ -2756,16 +3213,69 @@ export const PROVIDER_DEFINITIONS: Record = { sakana: { id: 'sakana', name: 'Sakana AI', - description: "Sakana AI's Fugu multi-agent models via an OpenAI-compatible API", + description: 'Sakana AI Fugu and Namazu models via an OpenAI-compatible API', defaultModel: 'fugu', modelPatterns: [/^fugu/], icon: SakanaIcon, color: '#E60000', capabilities: { - temperature: { min: 0, max: 2 }, toolUsageControl: true, }, models: [ + { + id: 'fugu-ultra-v2.0', + pricing: { + input: 5, + cachedInput: 0.5, + output: 30, + tiers: [{ aboveInputTokens: 272000, input: 10, cachedInput: 1, output: 45 }], + updatedAt: '2026-09-14', + }, + capabilities: {}, + releaseDate: '2026-09-11', + }, + { + id: 'fugu-max', + pricing: { + input: 2, + cachedInput: 0.25, + output: 6, + updatedAt: '2026-09-14', + }, + capabilities: {}, + releaseDate: '2026-09-11', + }, + { + id: 'fugu-max-v1.0', + pricing: { + input: 2, + cachedInput: 0.25, + output: 6, + updatedAt: '2026-09-14', + }, + capabilities: {}, + releaseDate: '2026-09-11', + }, + { + id: 'sakana-namazu', + pricing: { + input: 0.95, + cachedInput: 0.15, + output: 4, + updatedAt: '2026-09-14', + }, + capabilities: {}, + }, + { + id: 'sakana-namazu-v1.0', + pricing: { + input: 0.95, + cachedInput: 0.15, + output: 4, + updatedAt: '2026-09-14', + }, + capabilities: {}, + }, { id: 'fugu', pricing: { @@ -2785,7 +3295,8 @@ export const PROVIDER_DEFINITIONS: Record = { input: 5, cachedInput: 0.5, output: 30, - updatedAt: '2026-06-22', + tiers: [{ aboveInputTokens: 272000, input: 10, cachedInput: 1, output: 45 }], + updatedAt: '2026-09-14', }, capabilities: {}, contextWindow: 1000000, @@ -2805,10 +3316,22 @@ export const PROVIDER_DEFINITIONS: Record = { isReseller: true, contextInformationAvailable: true, capabilities: { - temperature: { min: 0, max: 2 }, toolUsageControl: true, }, models: [ + { + id: 'nvidia/nemotron-3.5-lightning-30b-a3b', + pricing: { + input: 0, + output: 0, + updatedAt: '2026-09-14', + }, + capabilities: { + nativeStructuredOutputs: false, + }, + contextWindow: 1000000, + releaseDate: '2026-08-11', + }, { id: 'nvidia/llama-3.1-nemotron-70b-instruct', pricing: { @@ -2976,6 +3499,9 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { toolUsageControl: true, + reasoningEffort: { + values: ['low', 'high', 'max'], + }, maxOutputTokens: 1048576, }, contextWindow: 1048576, @@ -3048,8 +3574,9 @@ export const PROVIDER_DEFINITIONS: Record = { id: 'glm-5.3', pricing: { input: 1.4, + cachedInput: 0.26, output: 4.4, - updatedAt: '2026-08-26', + updatedAt: '2026-09-14', }, capabilities: { temperature: { min: 0, max: 1 }, @@ -3067,13 +3594,17 @@ export const PROVIDER_DEFINITIONS: Record = { id: 'glm-5.3-flash', pricing: { input: 0.15, + cachedInput: 0.03, output: 0.5, - updatedAt: '2026-08-26', + updatedAt: '2026-09-14', }, capabilities: { temperature: { min: 0, max: 1 }, toolUsageControl: true, maxOutputTokens: 131072, + reasoningEffort: { + values: ['low', 'high', 'max'], + }, }, contextWindow: 1000000, releaseDate: '2026-08-26', @@ -3311,6 +3842,65 @@ export const PROVIDER_DEFINITIONS: Record = { toolUsageControl: true, }, models: [ + { + id: 'mistral-medium-3-5', + pricing: { + input: 1.5, + cachedInput: 0.15, + output: 7.5, + updatedAt: '2026-09-14', + }, + capabilities: { + temperature: { min: 0, max: 1.5 }, + }, + contextWindow: 256000, + releaseDate: '2026-04-28', + }, + { + id: 'mistral-medium-3', + pricing: { + input: 1.5, + cachedInput: 0.15, + output: 7.5, + updatedAt: '2026-09-14', + }, + capabilities: { + temperature: { min: 0, max: 1.5 }, + }, + contextWindow: 256000, + releaseDate: '2026-04-28', + }, + { + id: 'zai-glm-5-2', + pricing: { + input: 1.4, + cachedInput: 0.14, + output: 4.4, + updatedAt: '2026-09-14', + }, + capabilities: { + temperature: { min: 0, max: 1.5 }, + maxOutputTokens: 128000, + }, + contextWindow: 1000000, + releaseDate: '2026-08-06', + }, + { + /** Free preview retires September 30, 2026: https://docs.mistral.ai/resources/changelogs */ + id: 'labs-leanstral-1-5', + pricing: { + input: 0, + cachedInput: 0, + output: 0, + updatedAt: '2026-09-14', + }, + capabilities: { + temperature: { min: 0, max: 1.5 }, + maxOutputTokens: 128000, + }, + contextWindow: 256000, + releaseDate: '2026-06-30', + }, { id: 'mistral-large-latest', pricing: { @@ -3435,15 +4025,16 @@ export const PROVIDER_DEFINITIONS: Record = { { id: 'mistral-medium-latest', pricing: { - input: 0.4, - output: 2.0, - updatedAt: '2026-06-11', + input: 1.5, + cachedInput: 0.15, + output: 7.5, + updatedAt: '2026-09-14', }, capabilities: { temperature: { min: 0, max: 1.5 }, }, - contextWindow: 128000, - releaseDate: '2025-08-12', + contextWindow: 256000, + releaseDate: '2026-04-28', }, { id: 'mistral-medium-2604', @@ -3696,7 +4287,7 @@ export const PROVIDER_DEFINITIONS: Record = { name: 'Ollama', description: 'Local LLM models via Ollama', defaultModel: '', - modelPatterns: [], + modelPatterns: [/^ollama\//], icon: OllamaIcon, capabilities: { toolUsageControl: false, // Ollama does not support tool_choice parameter @@ -3714,10 +4305,111 @@ export const PROVIDER_DEFINITIONS: Record = { color: '#FF9900', isReseller: true, capabilities: { - temperature: { min: 0, max: 1 }, toolUsageControl: true, }, models: [ + { + id: 'bedrock/anthropic.claude-opus-5', + pricing: { + input: 5.5, + cachedInput: 0.55, + output: 27.5, + updatedAt: '2026-09-14', + }, + capabilities: { + maxOutputTokens: 128000, + }, + contextWindow: 1000000, + releaseDate: '2026-07-24', + }, + { + id: 'bedrock/anthropic.claude-sonnet-5', + pricing: { + input: 2.2, + cachedInput: 0.22, + output: 11, + updatedAt: '2026-09-14', + }, + capabilities: { + maxOutputTokens: 128000, + }, + contextWindow: 1000000, + releaseDate: '2026-06-30', + }, + { + id: 'bedrock/anthropic.claude-fable-5', + pricing: { + input: 11, + cachedInput: 1.1, + output: 55, + updatedAt: '2026-09-14', + }, + capabilities: { + maxOutputTokens: 128000, + }, + contextWindow: 1000000, + releaseDate: '2026-06-09', + }, + { + id: 'bedrock/anthropic.claude-opus-4-8', + pricing: { + input: 5.5, + cachedInput: 0.55, + output: 27.5, + updatedAt: '2026-09-14', + }, + capabilities: { + maxOutputTokens: 128000, + }, + contextWindow: 1000000, + releaseDate: '2026-05-28', + }, + { + id: 'bedrock/anthropic.claude-opus-4-7', + pricing: { + input: 5.5, + cachedInput: 0.55, + output: 27.5, + updatedAt: '2026-09-14', + }, + capabilities: { + maxOutputTokens: 128000, + }, + contextWindow: 1000000, + releaseDate: '2026-04-16', + }, + { + id: 'bedrock/anthropic.claude-sonnet-4-6', + pricing: { + input: 3.3, + cachedInput: 0.33, + output: 16.5, + updatedAt: '2026-09-14', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + nativeStructuredOutputs: true, + maxOutputTokens: 64000, + }, + contextWindow: 1000000, + releaseDate: '2026-02-17', + }, + { + id: 'bedrock/anthropic.claude-opus-4-6-v1', + pricing: { + input: 5.5, + cachedInput: 0.55, + output: 27.5, + updatedAt: '2026-09-14', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + nativeStructuredOutputs: true, + maxOutputTokens: 128000, + }, + contextWindow: 1000000, + releaseDate: '2026-02-05', + }, { id: 'bedrock/anthropic.claude-opus-4-5-20251101-v1:0', pricing: { @@ -3785,6 +4477,140 @@ export const PROVIDER_DEFINITIONS: Record = { releaseDate: '2025-08-05', sunset: { status: 'legacy' }, }, + { + id: 'bedrock/openai.gpt-6-astra', + pricing: { + input: 11, + output: 55, + tiers: [{ aboveInputTokens: 272000, input: 22, output: 82.5 }], + updatedAt: '2026-09-14', + }, + capabilities: { + maxOutputTokens: 128000, + }, + contextWindow: 1050000, + releaseDate: '2026-09-08', + }, + { + id: 'bedrock/openai.gpt-5.6-sol', + pricing: { + input: 4.4, + output: 22, + tiers: [{ aboveInputTokens: 272000, input: 8.8, output: 33 }], + updatedAt: '2026-09-14', + }, + capabilities: {}, + contextWindow: 1000000, + releaseDate: '2026-07-13', + }, + { + id: 'bedrock/openai.gpt-5.6-terra', + pricing: { + input: 2.2, + output: 13.2, + tiers: [{ aboveInputTokens: 272000, input: 4.4, output: 19.8 }], + updatedAt: '2026-09-14', + }, + capabilities: {}, + contextWindow: 1000000, + releaseDate: '2026-07-13', + }, + { + id: 'bedrock/openai.gpt-5.6-luna', + pricing: { + input: 0.22, + output: 1.32, + tiers: [{ aboveInputTokens: 272000, input: 0.44, output: 1.98 }], + updatedAt: '2026-09-14', + }, + capabilities: {}, + contextWindow: 1000000, + releaseDate: '2026-07-13', + }, + { + id: 'bedrock/openai.gpt-oss-120b-1:0', + pricing: { + input: 0.15, + output: 0.6, + updatedAt: '2026-09-14', + }, + capabilities: { + nativeStructuredOutputs: true, + maxOutputTokens: 16000, + }, + contextWindow: 128000, + releaseDate: '2025-08-05', + }, + { + id: 'bedrock/openai.gpt-oss-20b-1:0', + pricing: { + input: 0.07, + output: 0.3, + updatedAt: '2026-09-14', + }, + capabilities: { + nativeStructuredOutputs: true, + maxOutputTokens: 16000, + }, + contextWindow: 128000, + releaseDate: '2025-08-05', + }, + { + id: 'bedrock/minimax.minimax-m2.5', + pricing: { + input: 0.3, + output: 1.2, + updatedAt: '2026-09-14', + }, + capabilities: { + nativeStructuredOutputs: true, + maxOutputTokens: 8000, + }, + contextWindow: 196000, + releaseDate: '2026-02-12', + }, + { + id: 'bedrock/zai.glm-5', + pricing: { + input: 1, + output: 3.2, + updatedAt: '2026-09-14', + }, + capabilities: { + nativeStructuredOutputs: true, + maxOutputTokens: 128000, + }, + contextWindow: 200000, + releaseDate: '2026-02-11', + }, + { + id: 'bedrock/moonshotai.kimi-k2.5', + pricing: { + input: 0.6, + output: 3, + updatedAt: '2026-09-14', + }, + capabilities: { + nativeStructuredOutputs: true, + maxOutputTokens: 16000, + }, + contextWindow: 256000, + releaseDate: '2026-01-27', + }, + { + id: 'bedrock/deepseek.v3.2', + pricing: { + input: 0.62, + output: 1.85, + updatedAt: '2026-09-14', + }, + capabilities: { + nativeStructuredOutputs: true, + maxOutputTokens: 8000, + }, + contextWindow: 164000, + releaseDate: '2025-12-01', + }, { id: 'bedrock/amazon.nova-2-lite-v1:0', pricing: { @@ -4233,6 +5059,18 @@ export const DYNAMIC_MODEL_PROVIDERS = [ 'baseten', ] as const +/** Providers whose adapter accepts namespaced IDs, rather than only a native model-family name. */ +const NAMESPACED_MODEL_PROVIDERS = new Set( + Object.entries(PROVIDER_DEFINITIONS) + .filter( + ([providerId, provider]) => + provider.isReseller || + (DYNAMIC_MODEL_PROVIDERS as readonly string[]).includes(providerId) || + provider.models.some((model) => model.id.startsWith(`${providerId}/`)) + ) + .map(([providerId]) => providerId) +) + function getAllStaticModelIds(): string[] { const ids: string[] = [] for (const [providerId, provider] of Object.entries(PROVIDER_DEFINITIONS)) { @@ -4253,7 +5091,7 @@ export function isKnownModelId(modelId: string): boolean { const lowered = trimmed.toLowerCase() for (const provider of DYNAMIC_MODEL_PROVIDERS) { - if (lowered.startsWith(`${provider}/`)) return true + if (lowered.startsWith(`${provider}/`) && lowered.slice(provider.length + 1).trim()) return true } return false @@ -4347,9 +5185,20 @@ export function getBaseModelProviders(): Record { ) } -export function getProviderFromModel(model: string): ProviderId { +/** Resolves catalog entries and provider patterns without guessing a fallback provider. */ +export function findProviderFromModel(model: string): ProviderId | null { const normalizedModel = model.toLowerCase() + /** Explicit provider namespaces take precedence over names discovered on a local server. */ + for (const [providerId, provider] of Object.entries(PROVIDER_DEFINITIONS)) { + if ( + NAMESPACED_MODEL_PROVIDERS.has(providerId) && + provider.modelPatterns?.some((pattern) => pattern.test(normalizedModel)) + ) { + return providerId as ProviderId + } + } + for (const [providerId, provider] of Object.entries(PROVIDER_DEFINITIONS)) { if ( provider.models.some((providerModel) => providerModel.id.toLowerCase() === normalizedModel) @@ -4364,7 +5213,20 @@ export function getProviderFromModel(model: string): ProviderId { } } - return 'ollama' + return null +} + +export function getProviderFromModel(model: string): ProviderId { + return findProviderFromModel(model) ?? 'ollama' +} + +/** Recognized provider namespaces accept deployment and model IDs outside the static catalog. */ +export function isCustomModelId(modelId: string): boolean { + const separator = modelId.indexOf('/') + if (separator < 1 || modelId.slice(separator + 1).trim().length === 0) return false + + const providerId = findProviderFromModel(modelId) + return providerId !== null && NAMESPACED_MODEL_PROVIDERS.has(providerId) } export function getProviderIcon(model: string): React.ComponentType<{ className?: string }> | null { diff --git a/apps/sim/providers/nvidia/index.ts b/apps/sim/providers/nvidia/index.ts index 5973ba2af54..4f29d730527 100644 --- a/apps/sim/providers/nvidia/index.ts +++ b/apps/sim/providers/nvidia/index.ts @@ -6,7 +6,11 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' -import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { + getModelCapabilities, + getProviderDefaultModel, + getProviderModels, +} from '@/providers/models' import { createReadableStreamFromNvidiaStream } from '@/providers/nvidia/utils' import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' import { executeProviderTool } from '@/providers/runtime-context' @@ -25,6 +29,7 @@ import type { import { ProviderError } from '@/providers/types' import { calculateCost, + generateSchemaInstructions, isFunctionToolCall, prepareToolExecution, prepareToolsWithUsageControl, @@ -86,13 +91,22 @@ export const nvidiaProvider: ProviderConfig = { allMessages.push(...request.messages) } const formattedMessages = formatMessagesForProvider(allMessages, 'nvidia') + const useJsonMode = + !!request.responseFormat && + getModelCapabilities(request.model)?.nativeStructuredOutputs === false + if (useJsonMode) { + formattedMessages.push({ + role: 'system', + content: generateSchemaInstructions(request.responseFormat), + }) + } const tools = request.tools?.length ? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool)) : undefined const payload: any = { - model: request.model, + model: request.model.replace(/^nvidia\//i, 'nvidia/'), messages: formattedMessages, } @@ -100,16 +114,20 @@ export const nvidiaProvider: ProviderConfig = { if (request.maxTokens != null) payload.max_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, - }, - } + ? useJsonMode + ? { type: 'json_object' as const } + : { + 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 + if (useJsonMode) payload.chat_template_kwargs = { enable_thinking: false } + let preparedTools: ReturnType | null = null let hasActiveTools = false diff --git a/apps/sim/providers/ollama-cloud/index.test.ts b/apps/sim/providers/ollama-cloud/index.test.ts index cbd33346641..ceed999a97a 100644 --- a/apps/sim/providers/ollama-cloud/index.test.ts +++ b/apps/sim/providers/ollama-cloud/index.test.ts @@ -184,6 +184,18 @@ describe('ollamaCloudProvider.executeRequest', () => { expect(result).toMatchObject({ content: 'hello', model: 'gpt-oss:120b' }) }) + it.each([ + ['ollama-cloud/deepseek-v4.1-flash', 'deepseek-v4.1-flash'], + ['ollama-cloud/glm-5.3', 'glm-5.3'], + ['OLLAMA-CLOUD/Org/CustomModel', 'Org/CustomModel'], + ])( + 'forwards new and custom cloud models without changing their IDs: %s', + async (model, wireModel) => { + await ollamaCloudProvider.executeRequest({ ...baseRequest, model }) + expect(mockCreate.mock.calls[0][0].model).toBe(wireModel) + } + ) + it('assembles system, context, then history in order and forwards params', async () => { await ollamaCloudProvider.executeRequest({ ...baseRequest, diff --git a/apps/sim/providers/ollama-cloud/index.ts b/apps/sim/providers/ollama-cloud/index.ts index 11d7bb4372b..b23f7ccc8f2 100644 --- a/apps/sim/providers/ollama-cloud/index.ts +++ b/apps/sim/providers/ollama-cloud/index.ts @@ -28,7 +28,7 @@ export const ollamaCloudProvider: ProviderConfig = { throw new Error('API key is required for Ollama Cloud') } - const requestedModel = request.model.replace(/^ollama-cloud\//, '') + const requestedModel = request.model.replace(/^ollama-cloud\//i, '') return executeOllamaProviderRequest( { ...request, model: requestedModel }, diff --git a/apps/sim/providers/ollama/index.test.ts b/apps/sim/providers/ollama/index.test.ts index feda81c9afa..d3940ca3fe7 100644 --- a/apps/sim/providers/ollama/index.test.ts +++ b/apps/sim/providers/ollama/index.test.ts @@ -149,6 +149,14 @@ describe('ollamaProvider.executeRequest', () => { mockExecuteTool.mockResolvedValue({ success: true, output: { ok: true } }) }) + it.each(['ollama/Org/CustomModel', 'OLLAMA/Org/CustomModel', 'Org/CustomModel'])( + 'preserves the local model ID while removing only its optional namespace: %s', + async (model) => { + await ollamaProvider.executeRequest({ ...baseRequest, model }) + expect(mockCreate.mock.calls[0][0].model).toBe('Org/CustomModel') + } + ) + it('assembles system, context, then history in order and forwards params', async () => { const result = (await ollamaProvider.executeRequest({ ...baseRequest, diff --git a/apps/sim/providers/ollama/index.ts b/apps/sim/providers/ollama/index.ts index cd0e16f8bd7..fac7a49a8d1 100644 --- a/apps/sim/providers/ollama/index.ts +++ b/apps/sim/providers/ollama/index.ts @@ -48,17 +48,20 @@ export const ollamaProvider: ProviderConfig = { executeRequest: async ( request: ProviderRequest ): Promise => { - return executeOllamaProviderRequest(request, { - providerId: 'ollama', - providerLabel: 'Ollama', - createClient: () => - new OpenAI({ - ...openAICompatTransport(), - apiKey: 'empty', - baseURL: `${OLLAMA_HOST}/v1`, - }), - createStream: createReadableStreamFromOllamaStream, - logger, - }) + return executeOllamaProviderRequest( + { ...request, model: request.model.replace(/^ollama\//i, '') }, + { + providerId: 'ollama', + providerLabel: 'Ollama', + createClient: () => + new OpenAI({ + ...openAICompatTransport(), + apiKey: 'empty', + baseURL: `${OLLAMA_HOST}/v1`, + }), + createStream: createReadableStreamFromOllamaStream, + logger, + } + ) }, } diff --git a/apps/sim/providers/openrouter/index.test.ts b/apps/sim/providers/openrouter/index.test.ts index 4af5747f4a3..694606065fb 100644 --- a/apps/sim/providers/openrouter/index.test.ts +++ b/apps/sim/providers/openrouter/index.test.ts @@ -174,6 +174,12 @@ describe('openRouterProvider.executeRequest', () => { expect(payload.messages.at(-1)).toEqual({ role: 'user', content: 'Hello' }) }) + it('preserves custom provider paths when stripping an uppercase namespace', async () => { + mockCreate.mockResolvedValueOnce(textResponse('ok')) + await openRouterProvider.executeRequest({ ...baseRequest, model: 'OPENROUTER/Org/CustomModel' }) + expect(mockCreate.mock.calls[0][0].model).toBe('Org/CustomModel') + }) + it('inserts context as a user message between system and history', async () => { mockCreate.mockResolvedValueOnce(textResponse('ok')) diff --git a/apps/sim/providers/openrouter/index.ts b/apps/sim/providers/openrouter/index.ts index fbfeddd6193..2ca900a9905 100644 --- a/apps/sim/providers/openrouter/index.ts +++ b/apps/sim/providers/openrouter/index.ts @@ -107,7 +107,7 @@ export const openRouterProvider: ProviderConfig = { baseURL: 'https://openrouter.ai/api/v1', }) - const requestedModel = request.model.replace(/^openrouter\//, '') + const requestedModel = request.model.replace(/^openrouter\//i, '') logger.info('Preparing OpenRouter request', { model: requestedModel, diff --git a/apps/sim/providers/openrouter/utils.ts b/apps/sim/providers/openrouter/utils.ts index 2f8a7850fc9..c6763c6b514 100644 --- a/apps/sim/providers/openrouter/utils.ts +++ b/apps/sim/providers/openrouter/utils.ts @@ -77,7 +77,7 @@ export async function getOpenRouterModelCapabilities( cacheTimestamp = now } - const normalizedId = modelId.replace(/^openrouter\//, '') + const normalizedId = modelId.replace(/^openrouter\//i, '') return modelCapabilitiesCache.get(normalizedId) ?? null } diff --git a/apps/sim/providers/sakana/index.ts b/apps/sim/providers/sakana/index.ts index 33c9861a626..4648bdc7a72 100644 --- a/apps/sim/providers/sakana/index.ts +++ b/apps/sim/providers/sakana/index.ts @@ -7,6 +7,7 @@ import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' import { executeProviderTool } from '@/providers/runtime-context' import { createReadableStreamFromSakanaStream } from '@/providers/sakana/utils' import { createSettledAgentEventStream } from '@/providers/stream-events' @@ -38,7 +39,7 @@ const SAKANA_BASE_URL = 'https://api.sakana.ai/v1' export const sakanaProvider: ProviderConfig = { id: 'sakana', name: 'Sakana AI', - description: "Sakana AI's Fugu multi-agent models via an OpenAI-compatible API", + description: 'Sakana AI Fugu and Namazu models via an OpenAI-compatible API', version: '1.0.0', models: getProviderModels('sakana'), defaultModel: getProviderDefaultModel('sakana'), @@ -339,18 +340,16 @@ export const sakanaProvider: ProviderConfig = { const executionResults = await Promise.all(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, - }, - })), - }) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning_content'], + }) + ) + } for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = diff --git a/apps/sim/providers/settled-tool-streams.test.ts b/apps/sim/providers/settled-tool-streams.test.ts index 44f4d4622ab..69fbfdae77a 100644 --- a/apps/sim/providers/settled-tool-streams.test.ts +++ b/apps/sim/providers/settled-tool-streams.test.ts @@ -48,7 +48,11 @@ vi.mock('@/providers/models', () => ({ .fn() .mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }), INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024, - getModelCapabilities: vi.fn(), + getModelCapabilities: vi.fn((model: string) => + model === 'nvidia/nemotron-3.5-lightning-30b-a3b' + ? { nativeStructuredOutputs: false } + : undefined + ), getProviderModels: vi.fn((provider: string) => [`${provider}/test-model`]), getProviderDefaultModel: vi.fn((provider: string) => `${provider}/test-model`), })) @@ -181,6 +185,12 @@ const PROVIDERS = [ ] as const const REASONING_HISTORY_PROVIDERS = [ + { + name: 'Sakana Namazu', + provider: sakanaProvider, + model: 'sakana-namazu-v1.0', + field: 'reasoning_content', + }, { name: 'Cerebras', provider: cerebrasProvider, @@ -238,6 +248,13 @@ const CAPPED_PROVIDERS = [ ] as const const STRUCTURED_OUTPUT_PROVIDERS = [ + { + name: 'NVIDIA Lightning', + provider: nvidiaProvider, + model: 'nvidia/nemotron-3.5-lightning-30b-a3b', + responseFormatType: 'json_object', + disablesTools: 'none', + }, { name: 'Baseten', provider: basetenProvider, @@ -389,6 +406,36 @@ describe('settled provider tool streams', () => { mockExecuteTool.mockResolvedValue({ success: true, output: { value: 'found' } }) }) + it('normalizes the NVIDIA namespace while preserving the upstream model name', async () => { + mockCreate.mockResolvedValueOnce(response('ok')) + await nvidiaProvider.executeRequest({ + apiKey: 'test-key', + model: 'NVIDIA/CustomModel', + messages: [{ role: 'user', content: 'Hello' }], + }) + expect(mockCreate.mock.calls[0][0].model).toBe('nvidia/CustomModel') + }) + + it('uses NVIDIA Lightning JSON mode with schema instructions and reasoning disabled', async () => { + mockCreate.mockResolvedValueOnce(response('{"value":"found"}')) + + await nvidiaProvider.executeRequest({ + apiKey: 'test-key', + model: 'nvidia/nemotron-3.5-lightning-30b-a3b', + messages: [{ role: 'user', content: 'Return a value' }], + responseFormat: { + name: 'result', + schema: { type: 'object', properties: { value: { type: 'string' } } }, + }, + }) + + expect(mockCreate.mock.calls[0][0]).toMatchObject({ + response_format: { type: 'json_object' }, + chat_template_kwargs: { enable_thinking: false }, + messages: expect.arrayContaining([{ role: 'system', content: 'SCHEMA_INSTRUCTIONS' }]), + }) + }) + it.each(PROVIDERS)( '$name projects the existing final answer without another provider call', async ({ provider, model }) => { diff --git a/apps/sim/providers/specialist-reasoning.test.ts b/apps/sim/providers/specialist-reasoning.test.ts new file mode 100644 index 00000000000..5122cd6398f --- /dev/null +++ b/apps/sim/providers/specialist-reasoning.test.ts @@ -0,0 +1,164 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProviderRequest } from '@/providers/types' + +const { mockCreate } = vi.hoisted(() => ({ mockCreate: vi.fn() })) + +vi.mock('openai', () => ({ + default: vi.fn().mockImplementation( + class { + chat = { completions: { create: mockCreate } } + } + ), +})) + +vi.mock('@cerebras/cerebras_cloud_sdk', () => ({ + Cerebras: vi.fn().mockImplementation( + class { + chat = { completions: { create: mockCreate } } + } + ), +})) + +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 3 })) +vi.mock('@/providers/attachments', () => ({ + formatMessagesForProvider: vi.fn((messages) => messages), +})) +vi.mock('@/providers/trace-enrichment', () => ({ + enrichLastModelSegmentFromChatCompletions: vi.fn(), +})) +vi.mock('@/providers/runtime-context', () => ({ + executeProviderTool: vi.fn().mockResolvedValue({ + rawResponse: { success: true, output: { result: 'found' } }, + modelResponse: { success: true, output: { result: 'found' } }, + }), +})) + +import { cerebrasProvider } from '@/providers/cerebras' +import { kimiProvider } from '@/providers/kimi' + +function request(model: string, overrides: Partial = {}): ProviderRequest { + return { + model, + apiKey: 'test-key', + messages: [{ role: 'user', content: 'hello' }], + ...overrides, + } +} + +describe('specialist provider reasoning parameters', () => { + beforeEach(() => { + mockCreate.mockReset() + mockCreate.mockResolvedValue({ + choices: [{ message: { content: 'ok', tool_calls: [] } }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }) + }) + + it.each(['none', 'low', 'medium', 'high'] as const)( + 'Cerebras forwards Qwen 3.8 effort %s', + async (reasoningEffort) => { + await cerebrasProvider.executeRequest( + request('cerebras/qwen-3.8-27b', { reasoningEffort, maxTokens: 40960 }) + ) + expect(mockCreate.mock.calls[0][0]).toMatchObject({ + model: 'qwen-3.8-27b', + reasoning_effort: reasoningEffort, + max_completion_tokens: 40960, + }) + } + ) + + it.each(['low', 'high', 'max'] as const)( + 'Kimi K3 forwards effort %s', + async (reasoningEffort) => { + await kimiProvider.executeRequest(request('kimi-k3', { reasoningEffort, temperature: 0.2 })) + const payload = mockCreate.mock.calls[0][0] + expect(payload.reasoning_effort).toBe(reasoningEffort) + expect(payload.temperature).toBeUndefined() + expect(payload.thinking).toBeUndefined() + } + ) + + it.each([ + { provider: cerebrasProvider, model: 'cerebras/qwen-3.8-27b' }, + { provider: kimiProvider, model: 'kimi-k3' }, + ])('preserves the server default for $model', async ({ provider, model }) => { + await provider.executeRequest(request(model, { reasoningEffort: 'auto' })) + expect(mockCreate.mock.calls[0][0].reasoning_effort).toBeUndefined() + }) + + it('preserves custom Cerebras identifiers while removing only the leading prefix', async () => { + await cerebrasProvider.executeRequest(request('Cerebras/Organization/Model-A')) + expect(mockCreate.mock.calls[0][0].model).toBe('Organization/Model-A') + }) + + it('K3 requires each forced tool, then restores all tools with automatic choice', async () => { + for (const name of ['search', 'lookup']) { + mockCreate.mockResolvedValueOnce({ + choices: [ + { + message: { + content: null, + reasoning_content: `Use ${name}`, + tool_calls: [ + { id: `call-${name}`, type: 'function', function: { name, arguments: '{}' } }, + ], + }, + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }) + } + await kimiProvider.executeRequest( + request('kimi-k3', { + tools: ['search', 'lookup', 'optional'].map((id) => ({ + id, + name: id, + description: id, + params: {}, + parameters: { type: 'object', properties: {} }, + usageControl: id === 'optional' ? 'auto' : 'force', + })), + }) + ) + const payloads = mockCreate.mock.calls.map(([payload]) => payload) + expect(payloads).toHaveLength(3) + expect(payloads[0].tool_choice).toBe('required') + expect( + payloads[0].tools.map((tool: { function: { name: string } }) => tool.function.name) + ).toEqual(['search']) + expect(payloads[1].tool_choice).toBe('required') + expect( + payloads[1].tools.map((tool: { function: { name: string } }) => tool.function.name) + ).toEqual(['lookup']) + expect(payloads[2].tool_choice).toBe('auto') + expect(payloads[2].tools).toHaveLength(3) + expect(payloads[1].messages).toContainEqual( + expect.objectContaining({ reasoning_content: 'Use search' }) + ) + }) + + it.each(['kimi-k2.7-code', 'kimi-k2.7-code-highspeed'])( + '%s keeps automatic tool choice when forced selection is unsupported', + async (model) => { + await kimiProvider.executeRequest( + request(model, { + tools: [ + { + id: 'lookup', + description: 'Lookup', + params: {}, + parameters: {}, + usageControl: 'force', + }, + ], + }) + ) + expect(mockCreate.mock.calls[0][0].tool_choice).toBe('auto') + expect(mockCreate.mock.calls[0][0].thinking).toBeUndefined() + } + ) +}) diff --git a/apps/sim/providers/together/index.test.ts b/apps/sim/providers/together/index.test.ts index c9c75846cb9..123da1e9c03 100644 --- a/apps/sim/providers/together/index.test.ts +++ b/apps/sim/providers/together/index.test.ts @@ -154,6 +154,14 @@ describe('togetherProvider', () => { expect(result).toHaveProperty('execution') }) + it('preserves custom model casing after an uppercase provider prefix', async () => { + mockCreate.mockResolvedValueOnce(textResponse('ok')) + + await togetherProvider.executeRequest({ ...baseRequest, model: 'TOGETHER/Org/Custom-Model' }) + + expect(callBody(0).model).toBe('Org/Custom-Model') + }) + it('sends a json_schema response_format with no strict field', async () => { mockCreate.mockResolvedValueOnce(textResponse('{}')) diff --git a/apps/sim/providers/together/index.ts b/apps/sim/providers/together/index.ts index 27d0f7ed769..55047796976 100644 --- a/apps/sim/providers/together/index.ts +++ b/apps/sim/providers/together/index.ts @@ -92,7 +92,7 @@ export const togetherProvider: ProviderConfig = { baseURL: 'https://api.together.ai/v1', }) - const requestedModel = request.model.replace(/^together\//, '') + const requestedModel = request.model.replace(/^together\//i, '') logger.info('Preparing Together request', { model: requestedModel, diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index 14fbe4d68d1..a3c148277e0 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -37,6 +37,7 @@ import { getReasoningEffortValuesForModel, getThinkingLevelsForModel, getVerbosityValuesForModel, + isGemini3Model, isProviderBlacklisted, MODELS_TEMP_RANGE_0_1, MODELS_TEMP_RANGE_0_2, @@ -57,6 +58,7 @@ import { transformBlockTool, updateOllamaProviderModels, } from '@/providers/utils' +import { useProvidersStore } from '@/stores/providers/store' const mockGetRotatingApiKey = vi.fn().mockReturnValue('rotating-server-key') const originalRequire = module.require @@ -163,6 +165,25 @@ describe('getApiKey', () => { expect(key2).toBe('empty') }) + it.each(['ollama', 'vllm', 'litellm'] as const)( + 'uses the routed cloud provider credentials despite a name collision in %s discovery', + (localProvider) => { + const originalProviders = useProvidersStore.getState().providers + useProvidersStore.setState({ + providers: { + ...originalProviders, + [localProvider]: { ...originalProviders[localProvider], models: ['azure/MyDeployment'] }, + }, + }) + try { + expect(getApiKey('azure-openai', 'azure/MyDeployment', 'azure-key')).toBe('azure-key') + expect(() => getApiKey('azure-openai', 'azure/MyDeployment')).toThrow('API key is required') + } finally { + useProvidersStore.setState({ providers: originalProviders }) + } + } + ) + it('should return empty or user-provided key for vllm provider without requiring API key', () => { setEnvFlags({ isHosted: false }) @@ -546,10 +567,11 @@ describe('Model Capabilities', () => { (m) => m.includes('gpt-5') && !m.includes('chat-latest') && - !m.includes('gpt-5.5-pro') && - !m.includes('gpt-5.4-pro') && - !m.includes('gpt-5.2-pro') && - !m.includes('gpt-5-pro') + m !== 'gpt-5.5-pro' && + m !== 'gpt-5.4-pro' && + m !== 'gpt-5.3-codex' && + m !== 'gpt-5.2-pro' && + m !== 'gpt-5-pro' ) const gpt5ModelsWithVerbosity = MODELS_WITH_VERBOSITY.filter( (m) => m.includes('gpt-5') && !m.includes('chat-latest') @@ -562,6 +584,9 @@ describe('Model Capabilities', () => { expect(MODELS_WITH_REASONING_EFFORT).toContain('gpt-5.4-pro') expect(MODELS_WITH_VERBOSITY).not.toContain('gpt-5.4-pro') + expect(MODELS_WITH_REASONING_EFFORT).toContain('gpt-5.3-codex') + expect(MODELS_WITH_VERBOSITY).not.toContain('gpt-5.3-codex') + expect(MODELS_WITH_REASONING_EFFORT).toContain('gpt-5.2-pro') expect(MODELS_WITH_VERBOSITY).not.toContain('gpt-5.2-pro') @@ -828,6 +853,27 @@ describe('Cost Calculation', () => { expect(longContext).toMatchObject({ input: 1.088004, output: 1.8, total: 2.888004 }) }) + it.each([ + ['gemini-3.1-pro-preview', 2, 0.2, 12, 4, 0.4, 18], + ['gemini-2.5-pro', 1.25, 0.125, 10, 2.5, 0.25, 15], + ['grok-4.6', 2, 0.5, 6, 4, 1, 12], + ])( + 'applies %s long-context rates only above 200k prompt tokens, including cached input', + (model, input, cached, output, longInput, longCached, longOutput) => { + const shortContext = calculateCost(model, 200_000, 100_000) + const longContext = calculateCost(model, 200_001, 100_000) + const shortCached = calculateCost(model, 200_000, 100_000, true) + const longCachedCost = calculateCost(model, 200_001, 100_000, true) + + expect(shortContext.input).toBeCloseTo(input * 0.2, 10) + expect(shortContext.output).toBeCloseTo(output * 0.1, 10) + expect(longContext.input).toBeCloseTo((longInput * 200_001) / 1e6, 10) + expect(longContext.output).toBeCloseTo(longOutput * 0.1, 10) + expect(shortCached.input).toBeCloseTo(cached * 0.2, 10) + expect(longCachedCost.input).toBeCloseTo((longCached * 200_001) / 1e6, 10) + } + ) + it('should return default pricing for unknown models', () => { const result = calculateCost('unknown-model', 1000, 500, false) @@ -2105,6 +2151,18 @@ describe('describeModelLevel', () => { }) describe('findProviderFromModel', () => { + it.each([ + ['azure/MyDeployment', 'azure-openai'], + ['AZURE/MyDeployment', 'azure-openai'], + ['azure-anthropic/MyDeployment', 'azure-anthropic'], + ['bedrock/custom-inference-profile', 'bedrock'], + ['vertex/publishers/google/models/custom-gemini', 'vertex'], + ])('uses the declared provider namespace for %s', (model, provider) => { + expect(findProviderFromModel(model)).toBe(provider) + expect(getProviderFromModel(model)).toBe(provider) + expect(shouldBillModelUsage(model)).toBe(false) + }) + it('resolves a chat model to its declaring provider', () => { expect(findProviderFromModel('claude-sonnet-5')).toBe('anthropic') expect(findProviderFromModel('gpt-5.2')).toBe('openai') @@ -2128,6 +2186,26 @@ describe('findProviderFromModel', () => { }) }) +describe('isGemini3Model', () => { + it.each([ + 'gemini-3.8-flash', + 'VERTEX/gemini-3.8-flash', + 'vertex/google/gemini-3.8-flash', + 'vertex/publishers/google/models/gemini-3.8-flash', + 'vertex/projects/test-project/locations/global/publishers/google/models/gemini-3.8-flash', + ])('recognizes the Gemini family in %s', (model) => { + expect(isGemini3Model(model)).toBe(true) + }) + + it.each([ + 'vertex/gemini-2.5-pro', + 'vertex/custom-gemini-3-deployment', + 'vertex/publishers/another-provider/models/gemini-3.8-flash', + ])('does not infer Gemini 3 behavior from %s', (model) => { + expect(isGemini3Model(model)).toBe(false) + }) +}) + describe('transformBlockTool param decoding', () => { /** * `StoredTool.params` stringifies every value, so a tool row hands a block the same diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index ba9d19a2728..be2d64b46dd 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -25,6 +25,7 @@ import { assembleCustomBlockInputMapping, isCustomBlockType } from '@/blocks/cus import type { SubBlockConfig } from '@/blocks/types' import { isCustomTool } from '@/executor/constants' import { + findProviderFromModel as findProviderFromDefinitions, getComputerUseModels, getHostedModels as getHostedModelsFromDefinitions, getMaxOutputTokensForModel as getMaxOutputTokensForModelFromDefinitions, @@ -60,7 +61,6 @@ import { registerPreparedProviderToolInputProvenance, } from '@/providers/tool-input-provenance' import type { ModelPricing, ProviderId, ProviderToolConfig } from '@/providers/types' -import { useProvidersStore } from '@/stores/providers/store' import { mergeToolParameters } from '@/tools/merge-params' import { buildToolParamShapes, decodeToolParams } from '@/tools/param-shape' import type { WorkflowToolExecutionContext } from '@/tools/types' @@ -282,18 +282,7 @@ export function getAllModelProviders(): Record { * that was never about it. */ export function findProviderFromModel(model: string): ProviderId | null { - const normalizedModel = model.toLowerCase() - - const declared = getAllModelProviders()[normalizedModel] - if (declared) return declared - - for (const [id, config] of Object.entries(providers)) { - for (const pattern of config.modelPatterns ?? []) { - if (pattern.test(normalizedModel)) return id as ProviderId - } - } - - return null + return findProviderFromDefinitions(model) } export function getProviderFromModel(model: string): ProviderId { @@ -1163,27 +1152,20 @@ export const PROVIDER_PLACEHOLDER_KEY = 'provider-uses-own-credentials' export function getApiKey(provider: string, model: string, userProvidedKey?: string): string { const hasUserKey = !!userProvidedKey - const isOllamaModel = - provider === 'ollama' || useProvidersStore.getState().providers.ollama.models.includes(model) - if (isOllamaModel) { + if (provider === 'ollama') { return 'empty' } - const isVllmModel = - provider === 'vllm' || useProvidersStore.getState().providers.vllm.models.includes(model) - if (isVllmModel) { + if (provider === 'vllm') { return userProvidedKey || 'empty' } - const isLitellmModel = - provider === 'litellm' || useProvidersStore.getState().providers.litellm.models.includes(model) - if (isLitellmModel) { + if (provider === 'litellm') { return userProvidedKey || 'empty' } - // Bedrock uses its own credentials (bedrockAccessKeyId/bedrockSecretKey), not apiKey - const isBedrockModel = provider === 'bedrock' || model.startsWith('bedrock/') - if (isBedrockModel) { + /** Bedrock authenticates through its configured AWS credentials. */ + if (provider === 'bedrock') { return PROVIDER_PLACEHOLDER_KEY } @@ -1569,7 +1551,13 @@ export function isDeepResearchModel(model: string): boolean { } export function isGemini3Model(model: string): boolean { - const normalized = model.toLowerCase().replace(/^vertex\//, '') + const normalized = model + .toLowerCase() + .replace(/^vertex\//, '') + .replace( + /^(?:google\/|(?:projects\/[^/]+\/locations\/[^/]+\/)?publishers\/google\/models\/)/, + '' + ) return normalized.startsWith('gemini-3') } diff --git a/apps/sim/providers/vertex/index.test.ts b/apps/sim/providers/vertex/index.test.ts index 35282292617..424dcee8c5b 100644 --- a/apps/sim/providers/vertex/index.test.ts +++ b/apps/sim/providers/vertex/index.test.ts @@ -25,10 +25,6 @@ vi.mock('google-auth-library', () => ({ }, })) vi.mock('@/providers/gemini/core', () => ({ executeGeminiRequest: mockExecuteGeminiRequest })) -vi.mock('@/providers/models', () => ({ - getProviderModels: () => ['vertex/gemini-2.0-flash'], - getProviderDefaultModel: () => 'vertex/gemini-2.0-flash', -})) vi.mock('@/lib/core/config/env', () => ({ env: {} })) import { vertexProvider } from '@/providers/vertex' @@ -102,4 +98,43 @@ describe('vertexProvider location and project validation', () => { expect(genAIArgs[0]).toMatchObject({ location: 'us-central1' }) }) + + it.each([ + 'vertex/gemini-3.8-flash', + 'vertex/gemini-3.7-flash', + 'vertex/gemini-3.6-flash', + 'vertex/gemini-3.5-flash-lite', + 'vertex/publishers/google/models/gemini-3.8-flash', + ])('defaults %s to the supported global endpoint', async (model) => { + await vertexProvider.executeRequest(request({ model })) + + expect(genAIArgs[0]).toMatchObject({ location: 'global' }) + }) + + it.each(['us', 'eu', 'global'])( + 'preserves the explicitly selected %s endpoint for Gemini 3', + async (vertexLocation) => { + await vertexProvider.executeRequest( + request({ model: 'vertex/gemini-3.8-flash', vertexLocation }) + ) + + expect(genAIArgs[0]).toMatchObject({ location: vertexLocation }) + } + ) + + it.each([ + ['VERTEX/Custom-Deployment', 'Custom-Deployment'], + ['vertex/publishers/google/models/Custom-Model', 'publishers/google/models/Custom-Model'], + [ + 'vertex/projects/MyProject/locations/global/publishers/google/models/Custom-Model', + 'projects/MyProject/locations/global/publishers/google/models/Custom-Model', + ], + ['publishers/vertex/models/Custom-Model', 'publishers/vertex/models/Custom-Model'], + ])('preserves the custom model identifier in %s', async (model, expectedModel) => { + await vertexProvider.executeRequest(request({ model })) + + expect(mockExecuteGeminiRequest).toHaveBeenCalledWith( + expect.objectContaining({ model: expectedModel, providerType: 'vertex' }) + ) + }) }) diff --git a/apps/sim/providers/vertex/index.ts b/apps/sim/providers/vertex/index.ts index 59c22efc2ad..65e49151d81 100644 --- a/apps/sim/providers/vertex/index.ts +++ b/apps/sim/providers/vertex/index.ts @@ -10,6 +10,7 @@ import type { StreamingExecution } from '@/executor/types' import { executeGeminiRequest } from '@/providers/gemini/core' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import type { ProviderConfig, ProviderRequest, ProviderResponse } from '@/providers/types' +import { isGemini3Model } from '@/providers/utils' const logger = createLogger('VertexProvider') @@ -34,13 +35,13 @@ export const vertexProvider: ProviderConfig = { executeRequest: async ( request: ProviderRequest ): Promise => { + const model = request.model.replace(/^vertex\//i, '') const vertexProject = request.vertexProject || env.VERTEX_PROJECT - // Hostnames are case-insensitive, so a mixed-case location reaches Google fine - // today. Normalize before validating rather than rejecting it as malformed. + /** Gemini 3 models use global or multi-region endpoints instead of legacy regions. */ const vertexLocation = ( request.vertexLocation || env.VERTEX_LOCATION || - 'us-central1' + (isGemini3Model(model) ? 'global' : 'us-central1') ).toLowerCase() if (!vertexProject) { @@ -71,9 +72,6 @@ export const vertexProvider: ProviderConfig = { ) } - // Strip 'vertex/' prefix from model name if present - const model = request.model.replace('vertex/', '') - logger.info('Creating Vertex AI client', { project: vertexProject, location: vertexLocation, diff --git a/apps/sim/providers/vllm/index.test.ts b/apps/sim/providers/vllm/index.test.ts index 1057bb89e5e..29459978c6d 100644 --- a/apps/sim/providers/vllm/index.test.ts +++ b/apps/sim/providers/vllm/index.test.ts @@ -150,6 +150,15 @@ describe('vllmProvider', () => { mockCreatePinnedFetch.mockReturnValue(pinnedFetchFn) }) + it('preserves a custom served-model name when stripping an uppercase namespace', async () => { + mockCreate.mockResolvedValueOnce(chatResponse('hello')) + await vllmProvider.executeRequest({ + model: 'VLLM/Org/CustomModel', + messages: [{ role: 'user', content: 'hi' }], + }) + expect(createPayload(0).model).toBe('Org/CustomModel') + }) + describe('endpoint SSRF protection', () => { it('does not validate or pin when no endpoint is supplied (uses env base URL)', async () => { mockCreate.mockResolvedValueOnce(chatResponse('hi')) diff --git a/apps/sim/providers/vllm/index.ts b/apps/sim/providers/vllm/index.ts index 3bad1cba441..d1394a94230 100644 --- a/apps/sim/providers/vllm/index.ts +++ b/apps/sim/providers/vllm/index.ts @@ -181,7 +181,7 @@ export const vllmProvider: ProviderConfig = { : undefined const payload: any = { - model: request.model.replace(/^vllm\//, ''), + model: request.model.replace(/^vllm\//i, ''), messages: formattedMessages, } From e21130b1990a10de20b299725f8f557b6e46a641 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 14 Sep 2026 21:57:38 -0700 Subject: [PATCH 11/11] fix(landing): stabilize announcement scrolling and mobile previews (#7852) --- .../production-workflow-stage.tsx | 6 +- .../navbar-shell/navbar-shell.test.tsx | 114 +++++++++++++++++- .../components/navbar-shell/navbar-shell.tsx | 64 ++++++++-- .../(landing)/components/navbar/navbar.tsx | 3 +- .../responsive-design-stage.test.ts | 24 ++-- .../responsive-design-stage.tsx | 14 +-- 6 files changed, 182 insertions(+), 43 deletions(-) diff --git a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/production-workflow-stage.tsx b/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/production-workflow-stage.tsx index 18e64a2a105..b142adc61ce 100644 --- a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/production-workflow-stage.tsx +++ b/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/production-workflow-stage.tsx @@ -67,6 +67,8 @@ const FIT_MAX_ZOOM = 1 * just fits a 1750px frame and only overflows on narrower ones. */ const FIT_MIN_ZOOM = 0.64 +/** Static overviews must fit on phones, where cropping can leave no visible cards. */ +const REDUCED_MOTION_FIT_MIN_ZOOM = 0.05 const FIT_DURATION_MS = 600 const EMPTY_IDS: ReadonlySet = new Set() @@ -616,7 +618,7 @@ function ProductionWorkflowCanvas({ const zoom = Math.min( FIT_MAX_ZOOM, Math.max( - FIT_MIN_ZOOM, + reducedMotion ? REDUCED_MOTION_FIT_MIN_ZOOM : FIT_MIN_ZOOM, Math.min( (width - 2 * FIT_PADDING_PX) / bounds.width, (height - 2 * FIT_PADDING_PX) / bounds.height @@ -747,7 +749,7 @@ function ProductionWorkflowCanvas({ onNodesChange={handleNodesChange} nodeTypes={NODE_TYPES} edgeTypes={EDGE_TYPES} - minZoom={MIN_ZOOM} + minZoom={scripted && reducedMotion ? REDUCED_MOTION_FIT_MIN_ZOOM : MIN_ZOOM} maxZoom={MAX_ZOOM} defaultViewport={{ x: 0, y: 48, zoom: FOCUSED_NODE_MIN_ZOOM }} panOnDrag={interactive} diff --git a/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.test.tsx b/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.test.tsx index 580f7484de2..4160191677d 100644 --- a/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.test.tsx +++ b/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.test.tsx @@ -90,6 +90,19 @@ function click(label: string) { act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true }))) } +function announcement(): HTMLElement { + const element = host.querySelector('[data-test-announcement]')?.parentElement + if (!element) throw new Error('Missing announcement') + return element +} + +function scrollTo(position: number) { + act(() => { + host.scrollTop = position + host.dispatchEvent(new Event('scroll')) + }) +} + function unmount() { act(() => root.unmount()) mounted = false @@ -103,8 +116,10 @@ beforeEach(() => { vi.stubGlobal('ResizeObserver', ControlledResizeObserver) vi.stubGlobal('IntersectionObserver', ControlledIntersectionObserver) vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { - return new DOMRect(0, 0, 1440, this.tagName === 'HEADER' ? headerHeight : 0) + const height = this.tagName === 'HEADER' ? headerHeight : 32 + return new DOMRect(0, 0, 1440, height) }) + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(32) host = document.createElement('div') host.style.overflowY = 'scroll' @@ -114,13 +129,21 @@ beforeEach(() => { Object.defineProperties(host, { offsetWidth: { value: 1440 }, clientWidth: { value: 1420 }, + scrollHeight: { value: 2000 }, + clientHeight: { value: 800 }, }) document.body.append(host) root = createRoot(host) mounted = true act(() => { root.render( - + + Read update + + } + > ) @@ -136,7 +159,9 @@ afterEach(() => { describe('NavbarShell menu positioning and scroll containment', () => { it('publishes the current header height before a resize and updates it when header content changes', () => { - expect(header().style.getPropertyValue('--landing-header-height')).toBe('104px') + expect(header().style.getPropertyValue('--landing-header-height')).toBe( + 'calc(104px - var(--landing-announcement-offset, 0px))' + ) expect(host.style.scrollPaddingTop).toBe('104px') expect(resizeObservers).toHaveLength(1) expect(resizeObservers[0].observe).toHaveBeenCalledWith(header()) @@ -144,7 +169,9 @@ describe('NavbarShell menu positioning and scroll containment', () => { headerHeight = 76 act(() => resizeObservers[0].resize(header())) - expect(header().style.getPropertyValue('--landing-header-height')).toBe('76px') + expect(header().style.getPropertyValue('--landing-header-height')).toBe( + 'calc(76px - var(--landing-announcement-offset, 0px))' + ) expect(host.style.scrollPaddingTop).toBe('76px') expect(host.scrollTop).toBe(320) }) @@ -196,3 +223,82 @@ describe('NavbarShell menu positioning and scroll containment', () => { expect(host.style.paddingRight).toBe('12px') }) }) + +describe('NavbarShell announcement scroll behavior', () => { + it('hides on downward scroll and restores on upward scroll without changing the scroll position', () => { + expect(announcement().hasAttribute('inert')).toBe(false) + + scrollTo(400) + + expect(announcement().hasAttribute('inert')).toBe(true) + expect(announcement().getAttribute('aria-hidden')).toBe('true') + expect(host.style.scrollPaddingTop).toBe('104px') + expect(host.scrollTop).toBe(400) + + scrollTo(380) + + expect(announcement().hasAttribute('inert')).toBe(false) + expect(host.style.scrollPaddingTop).toBe('104px') + expect(host.scrollTop).toBe(380) + }) + + it('ignores small direction changes but accumulates slow scrolling', () => { + scrollTo(324) + expect(announcement().hasAttribute('inert')).toBe(false) + scrollTo(329) + expect(announcement().hasAttribute('inert')).toBe(true) + scrollTo(326) + expect(announcement().hasAttribute('inert')).toBe(true) + scrollTo(320) + expect(announcement().hasAttribute('inert')).toBe(false) + }) + + it('keeps the banner visible near the top and ignores overscroll bounce at both ends', () => { + scrollTo(400) + scrollTo(-30) + expect(announcement().hasAttribute('inert')).toBe(false) + scrollTo(10) + expect(announcement().hasAttribute('inert')).toBe(false) + + scrollTo(1200) + scrollTo(1250) + scrollTo(1200) + expect(announcement().hasAttribute('inert')).toBe(true) + scrollTo(1180) + expect(announcement().hasAttribute('inert')).toBe(false) + }) + + it('keeps the header stationary while a navigation menu is open', () => { + scrollTo(400) + click('Open mobile') + scrollTo(300) + expect(announcement().hasAttribute('inert')).toBe(true) + expect(host.style.scrollPaddingTop).toBe('104px') + + click('Close mobile') + scrollTo(280) + expect(announcement().hasAttribute('inert')).toBe(false) + }) + + it('does not hide a focused announcement link', () => { + host.querySelector('[data-test-announcement]')?.focus() + scrollTo(400) + expect(announcement().hasAttribute('inert')).toBe(false) + }) + + it('restores the full header if native focus scrolling reaches the top while a menu is open', () => { + scrollTo(400) + click('Open mobile') + scrollTo(0) + + expect(announcement().hasAttribute('inert')).toBe(false) + expect(host.style.scrollPaddingTop).toBe('104px') + expect(host.style.overflowY).toBe('hidden') + }) + + it('removes the scroll listener when the shell unmounts', () => { + const removeListener = vi.spyOn(host, 'removeEventListener') + unmount() + expect(removeListener).toHaveBeenCalledWith('scroll', expect.any(Function)) + }) +}) diff --git a/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.tsx b/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.tsx index dff811a914d..f3f4c4d94c2 100644 --- a/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.tsx +++ b/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.tsx @@ -22,6 +22,7 @@ interface NavbarFrostContextValue { } const NavbarFrostContext = createContext(null) +const SCROLL_DIRECTION_THRESHOLD = 8 /** Lets each nav surface report its open state so the shell can coordinate shared effects. */ export function useNavbarFrost(): NavbarFrostContextValue | null { @@ -29,6 +30,7 @@ export function useNavbarFrost(): NavbarFrostContextValue | null { } interface NavbarShellProps { + announcement?: ReactNode children: ReactNode } @@ -39,8 +41,7 @@ interface NavbarShellProps { * At the very top the bar uses the same solid canvas token as the hero, so it is * visually seamless while still preventing route content from painting through * the sticky header. A 1px sentinel at the top of the landing shell's internal - * scroll port is watched by an {@link IntersectionObserver} - no scroll listener - * and no per-frame work. Past that point the bar gains the shared + * scroll port is watched by an {@link IntersectionObserver}. Past that point the bar gains the shared * {@link NAVBAR_GLASS_SURFACE} (`--bg` at 92% via `color-mix` plus a strong 40px * backdrop blur) - a white/glass surface built entirely from the platform's * light tokens, not invented colors. @@ -51,8 +52,12 @@ interface NavbarShellProps { * while the fill still fades, so the frost appears smoothly without the jitter. * * The measured header height anchors the desktop panel and bounds the mobile - * sheet, including changes to the announcement strip or text sizing. The same - * height offsets native page and hash scrolling inside the landing scroll port. + * sheet, including changes to the announcement strip or text sizing. Native + * page and hash scrolling reserve the full height so changing banner visibility + * does not move the scroll anchor and leaves room for the banner to return. + * Scrolling down slides the announcement above the viewport; scrolling up + * restores it. Moving the sticky inset preserves document flow and scroll + * position. Menu offsets use only the visible portion of the header. * * Both navigation surfaces report open state through {@link NavbarFrostContext}. * While either is open, the shell locks its actual scroll port, preserves the @@ -75,10 +80,12 @@ interface NavbarShellProps { * Only this shell hydrates; the nav content is server-rendered and passed through * as {@link children}, so the wordmark and links stay zero-hydration and crawlable. */ -export function NavbarShell({ children }: NavbarShellProps) { +export function NavbarShell({ announcement, children }: NavbarShellProps) { const sentinelRef = useRef(null) const headerRef = useRef(null) + const announcementRef = useRef(null) const [scrolled, setScrolled] = useState(false) + const [announcementHidden, setAnnouncementHidden] = useState(false) const [menuOpenBySource, setMenuOpenBySource] = useState({ desktop: false, mobile: false }) const menuOpen = menuOpenBySource.desktop || menuOpenBySource.mobile @@ -88,24 +95,50 @@ export function NavbarShell({ children }: NavbarShellProps) { if (!header || !scrollPort) return const previousScrollPaddingTop = scrollPort.style.scrollPaddingTop - let previousHeight = 0 const updateHeight = () => { const height = header.getBoundingClientRect().height - if (height === previousHeight) return - previousHeight = height - header.style.setProperty('--landing-header-height', `${height}px`) + const announcementHeight = announcementRef.current?.getBoundingClientRect().height ?? 0 + header.style.setProperty('--landing-announcement-height', `${announcementHeight}px`) + header.style.setProperty( + '--landing-header-height', + `calc(${height}px - var(--landing-announcement-offset, 0px))` + ) scrollPort.style.scrollPaddingTop = `${height}px` } updateHeight() const observer = new ResizeObserver(updateHeight) observer.observe(header) + if (announcementRef.current) observer.observe(announcementRef.current) return () => { observer.disconnect() scrollPort.style.scrollPaddingTop = previousScrollPaddingTop } }, []) + useEffect(() => { + const scrollPort = sentinelRef.current?.parentElement + const banner = announcementRef.current + if (!scrollPort || !banner) return + + const scrollPosition = () => + Math.max(0, Math.min(scrollPort.scrollTop, scrollPort.scrollHeight - scrollPort.clientHeight)) + let previousPosition = scrollPosition() + const onScroll = () => { + const position = scrollPosition() + const delta = position - previousPosition + const nearTop = position <= banner.offsetHeight + if (!nearTop && (menuOpen || Math.abs(delta) < SCROLL_DIRECTION_THRESHOLD)) return + + previousPosition = position + if (banner.contains(document.activeElement)) return + setAnnouncementHidden(!nearTop && delta > 0) + } + + scrollPort.addEventListener('scroll', onScroll, { passive: true }) + return () => scrollPort.removeEventListener('scroll', onScroll) + }, [menuOpen]) + useEffect(() => { const sentinel = sentinelRef.current if (!sentinel) return @@ -166,7 +199,13 @@ export function NavbarShell({ children }: NavbarShellProps) {
- + }>