Compare commits

..

55 Commits

Author SHA1 Message Date
Vikhyath Mondreti
479cd347ad v0.5.83: agent skills, concurrent workers for v8s, airweave integration 2026-02-07 12:27:11 -08:00
Vikhyath Mondreti
0cb6714496 fix(rooms): cleanup edge case for 1hr ttl (#3163)
* fix(rooms): cleanup edge case for 1hr ttl

* revert feature flags

* address comments

* remove console log
2026-02-07 12:18:07 -08:00
Waleed
7b36f9257e improvement(models): reorder models dropdown (#3164) 2026-02-07 12:05:10 -08:00
Waleed
99ae5435e3 feat(models): updated model configs, updated anthropic provider to propagate errors back to user if any (#3159)
* feat(models): updated model configs, updated anthropic provider to propagate errors back to user if any

* moved max tokens to advanced

* updated model configs and testesd

* removed default in max config for output tokens

* moved more stuff to advanced mode in the agent block

* stronger typing

* move api key under model, update mistral and groq

* update openrouter, fixed serializer to allow ollama/vllm models without api key

* removed ollama handling
2026-02-06 22:35:57 -08:00
Vikhyath Mondreti
925f06add7 improvement(preview): render nested values like input format correctly in workflow execution preview (#3154)
* improvement(preview): nested workflow snapshots/preview when not executed

* improvements to resolve nested subblock values

* few more things

* add try catch

* fix fallback case

* deps
2026-02-06 22:12:40 -08:00
Vikhyath Mondreti
193b95cfec fix(auth): swap out hybrid auth in relevant callsites (#3160)
* fix(logs): execution files should always use our internal route

* correct degree of access control

* fix tests

* fix tag defs flag

* fix type check

* fix mcp tools

* make webhooks consistent

* fix ollama and vllm visibility

* remove dup test
2026-02-06 22:07:55 -08:00
Waleed
a3a99eda19 v0.5.82: slack trigger files, pagination for linear, executor fixes 2026-02-06 00:41:52 -08:00
Waleed
1a66d48add v0.5.81: traces fix, additional confluence tools, azure anthropic support, opus 4.6 2026-02-05 11:28:54 -08:00
Waleed
46822e91f3 v0.5.80: lock feature, enterprise modules, time formatting consolidation, files, UX and UI improvements, longer timeouts 2026-02-04 18:27:05 -08:00
Waleed
2bb68335ee v0.5.79: longer MCP tools timeout, optimize loop/parallel regeneration, enrich.so integration 2026-01-31 21:57:56 -08:00
Waleed
8528fbe2d2 v0.5.78: billing fixes, mcp timeout increase, reactquery migrations, updated tool param visibilities, DSPy and Google Maps integrations 2026-01-31 13:48:22 -08:00
Waleed
31fdd2be13 v0.5.77: room manager redis migration, tool outputs, ui fixes 2026-01-30 14:57:17 -08:00
Waleed
028bc652c2 v0.5.76: posthog improvements, readme updates 2026-01-29 00:13:19 -08:00
Waleed
c6bf5cd58c v0.5.75: search modal overhaul, helm chart updates, run from block, terminal and visual debugging improvements 2026-01-28 22:54:13 -08:00
Vikhyath Mondreti
11dc18a80d v0.5.74: autolayout improvements, clerk integration, auth enforcements 2026-01-27 20:37:39 -08:00
Waleed
ab4e9dc72f v0.5.73: ci, helm updates, kb, ui fixes, note block enhancements 2026-01-26 22:04:35 -08:00
Vikhyath Mondreti
1c58c35bd8 v0.5.72: azure connection string, supabase improvement, multitrigger resolution, docs quick reference 2026-01-25 23:42:27 -08:00
Waleed
d63a5cb504 v0.5.71: ux, ci improvements, docs updates 2026-01-25 03:08:08 -08:00
Waleed
8bd5d41723 v0.5.70: router fix, anthropic agent response format adherence 2026-01-24 20:57:02 -08:00
Waleed
c12931bc50 v0.5.69: kb upgrades, blog, copilot improvements, auth consolidation (#2973)
* fix(subflows): tag dropdown + resolution logic (#2949)

* fix(subflows): tag dropdown + resolution logic

* fixes;

* revert parallel change

* chore(deps): bump posthog-js to 1.334.1 (#2948)

* fix(idempotency): add conflict target to atomicallyClaimDb query + remove redundant db namespace tracking (#2950)

* fix(idempotency): add conflict target to atomicallyClaimDb query

* delete needs to account for namespace

* simplify namespace filtering logic

* fix cleanup

* consistent target

* improvement(kb): add document filtering, select all, and React Query migration (#2951)

* improvement(kb): add document filtering, select all, and React Query migration

* test(kb): update tests for enabledFilter and removed userId params

* fix(kb): remove non-null assertion, add explicit guard

* improvement(logs): trace span, details (#2952)

* improvement(action-bar): ordering

* improvement(logs): details, trace span

* feat(blog): v0.5 release post (#2953)

* feat(blog): v0.5 post

* improvement(blog): simplify title and remove code block header

- Simplified blog title from Introducing Sim Studio v0.5 to Introducing Sim v0.5
- Removed language label header and copy button from code blocks for cleaner appearance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* ack PR comments

* small styling improvements

* created system to create post-specific components

* updated componnet

* cache invalidation

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* feat(admin): add credits endpoint to issue credits to users (#2954)

* feat(admin): add credits endpoint to issue credits to users

* fix(admin): use existing credit functions and handle enterprise seats

* fix(admin): reject NaN and Infinity in amount validation

* styling

* fix(admin): validate userId and email are strings

* improvement(copilot): fast mode, subagent tool responses and allow preferences (#2955)

* Improvements

* Fix actions mapping

* Remove console logs

* fix(billing): handle missing userStats and prevent crashes (#2956)

* fix(billing): handle missing userStats and prevent crashes

* fix(billing): correct import path for getFilledPillColor

* fix(billing): add Number.isFinite check to lastPeriodCost

* fix(logs): refresh logic to refresh logs details (#2958)

* fix(security): add authentication and input validation to API routes (#2959)

* fix(security): add authentication and input validation to API routes

* moved utils

* remove extraneous commetns

* removed unused dep

* improvement(helm): add internal ingress support and same-host path consolidation (#2960)

* improvement(helm): add internal ingress support and same-host path consolidation

* improvement(helm): clean up ingress template comments

Simplify verbose inline Helm comments and section dividers to match the
minimal style used in services.yaml.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(helm): add missing copilot path consolidation for realtime host

When copilot.host equals realtime.host but differs from app.host,
copilot paths were not being routed. Added logic to consolidate
copilot paths into the realtime rule for this scenario.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* improvement(helm): follow ingress best practices

- Remove orphan comments that appeared when services were disabled
- Add documentation about path ordering requirements
- Paths rendered in order: realtime, copilot, app (specific before catch-all)
- Clean template output matching industry Helm chart standards

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* feat(blog): enterprise post (#2961)

* feat(blog): enterprise post

* added more images, styling

* more content

* updated v0-5 post

* remove unused transition

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>

* fix(envvars): resolution standardized (#2957)

* fix(envvars): resolution standardized

* remove comments

* address bugbot

* fix highlighting for env vars

* remove comments

* address greptile

* address bugbot

* fix(copilot): mask credentials fix (#2963)

* Fix copilot masking

* Clean up

* Lint

* improvement(webhooks): remove dead code (#2965)

* fix(webhooks): subscription recreation path

* improvement(webhooks): remove dead code

* fix tests

* address bugbot comments

* fix restoration edge case

* fix more edge cases

* address bugbot comments

* fix gmail polling

* add warnings for UI indication for credential sets

* fix(preview): subblock values (#2969)

* fix(child-workflow): nested spans handoff (#2966)

* fix(child-workflow): nested spans handoff

* remove overly defensive programming

* update type check

* type more code

* remove more dead code

* address bugbot comments

* fix(security): restrict API key access on internal-only routes (#2964)

* fix(security): restrict API key access on internal-only routes

* test(security): update function execute tests for checkInternalAuth

* updated agent handler

* move session check higher in checkSessionOrInternalAuth

* extracted duplicate code into helper for resolving user from jwt

* fix(copilot): update copilot chat title (#2968)

* fix(hitl): fix condition blocks after hitl (#2967)

* fix(notes): ghost edges (#2970)

* fix(notes): ghost edges

* fix deployed state fallback

* fallback

* remove UI level checks

* annotation missing from autoconnect source check

* improvement(docs): loop and parallel var reference syntax (#2975)

* fix(blog): slash actions description (#2976)

* improvement(docs): loop and parallel var reference syntax

* fix(blog): slash actions description

* fix(auth): copilot routes (#2977)

* Fix copilot auth

* Fix

* Fix

* Fix

* fix(copilot): fix edit summary for loops/parallels (#2978)

* fix(integrations): hide from tool bar (#2544)

* fix(landing): ui (#2979)

* fix(edge-validation): race condition on collaborative add (#2980)

* fix(variables): boolean type support and input improvements (#2981)

* fix(variables): boolean type support and input improvements

* fix formatting

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-01-24 14:29:53 -08:00
Waleed
e9c4251c1c v0.5.68: router block reasoning, executor improvements, variable resolution consolidation, helm updates (#2946)
* improvement(workflow-item): stabilize avatar layout and fix name truncation (#2939)

* improvement(workflow-item): stabilize avatar layout and fix name truncation

* fix(avatars): revert overflow bg to hardcoded color for contrast

* fix(executor): stop parallel execution when block errors (#2940)

* improvement(helm): add per-deployment extraVolumes support (#2942)

* fix(gmail): expose messageId field in read email block (#2943)

* fix(resolver): consolidate reference resolution  (#2941)

* fix(resolver): consolidate code to resolve references

* fix edge cases

* use already formatted error

* fix multi index

* fix backwards compat reachability

* handle backwards compatibility accurately

* use shared constant correctly

* feat(router): expose reasoning output in router v2 block (#2945)

* fix(copilot): always allow, credential masking (#2947)

* Fix always allow, credential validation

* Credential masking

* Autoload

* fix(executor): handle condition dead-end branches in loops (#2944)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
2026-01-22 13:48:15 -08:00
Waleed
cc2be33d6b v0.5.67: loading, password reset, ui improvements, helm updates (#2928)
* fix(zustand): updated to useShallow from deprecated createWithEqualityFn (#2919)

* fix(logger): use direct env access for webpack inlining (#2920)

* fix(notifications): text overflow with line-clamp (#2921)

* chore(helm): add env vars for Vertex AI, orgs, and telemetry (#2922)

* fix(auth): improve reset password flow and consolidate brand detection (#2924)

* fix(auth): improve reset password flow and consolidate brand detection

* fix(auth): set errorHandled for EMAIL_NOT_VERIFIED to prevent duplicate error

* fix(auth): clear success message on login errors

* chore(auth): fix import order per lint

* fix(action-bar): duplicate subflows with children (#2923)

* fix(action-bar): duplicate subflows with children

* fix(action-bar): add validateTriggerPaste for subflow duplicate

* fix(resolver): agent response format, input formats, root level (#2925)

* fix(resolvers): agent response format, input formats, root level

* fix response block initial seeding

* fix tests

* fix(messages-input): fix cursor alignment and auto-resize with overlay (#2926)

* fix(messages-input): fix cursor alignment and auto-resize with overlay

* fixed remaining zustand warnings

* fix(stores): remove dead code causing log spam on startup (#2927)

* fix(stores): remove dead code causing log spam on startup

* fix(stores): replace custom tools zustand store with react query cache

* improvement(ui): use BrandedButton and BrandedLink components (#2930)

- Refactor auth forms to use BrandedButton component
- Add BrandedLink component for changelog page
- Reduce code duplication in login, signup, reset-password forms
- Update star count default value

* fix(custom-tools): remove unsafe title fallback in getCustomTool (#2929)

* fix(custom-tools): remove unsafe title fallback in getCustomTool

* fix(custom-tools): restore title fallback in getCustomTool lookup

Custom tools are referenced by title (custom_${title}), not database ID.
The title fallback is required for client-side tool resolution to work.

* fix(null-bodies): empty bodies handling (#2931)

* fix(null-statuses): empty bodies handling

* address bugbot comment

* fix(token-refresh): microsoft, notion, x, linear (#2933)

* fix(microsoft): proactive refresh needed

* fix(x): missing token refresh flag

* notion and linear missing flag too

* address bugbot comment

* fix(auth): handle EMAIL_NOT_VERIFIED in onError callback (#2932)

* fix(auth): handle EMAIL_NOT_VERIFIED in onError callback

* refactor(auth): extract redirectToVerify helper to reduce duplication

* fix(workflow-selector): use dedicated selector for workflow dropdown (#2934)

* feat(workflow-block): preview (#2935)

* improvement(copilot): tool configs to show nested props (#2936)

* fix(auth): add genericOAuth providers to trustedProviders (#2937)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
2026-01-21 22:53:25 -08:00
Vikhyath Mondreti
45371e521e v0.5.66: external http requests fix, ring highlighting 2026-01-21 02:55:39 -08:00
Waleed
0ce0f98aa5 v0.5.65: gemini updates, textract integration, ui updates (#2909)
* fix(google): wrap primitive tool responses for Gemini API compatibility (#2900)

* fix(canonical): copilot path + update parent (#2901)

* fix(rss): add top-level title, link, pubDate fields to RSS trigger output (#2902)

* fix(rss): add top-level title, link, pubDate fields to RSS trigger output

* fix(imap): add top-level fields to IMAP trigger output

* improvement(browseruse): add profile id param (#2903)

* improvement(browseruse): add profile id param

* make request a stub since we have directExec

* improvement(executor): upgraded abort controller to handle aborts for loops and parallels (#2880)

* improvement(executor): upgraded abort controller to handle aborts for loops and parallels

* comments

* improvement(files): update execution for passing base64 strings (#2906)

* progress

* improvement(execution): update execution for passing base64 strings

* fix types

* cleanup comments

* path security vuln

* reject promise correctly

* fix redirect case

* remove proxy routes

* fix tests

* use ipaddr

* feat(tools): added textract, added v2 for mistral, updated tag dropdown (#2904)

* feat(tools): added textract

* cleanup

* ack pr comments

* reorder

* removed upload for textract async version

* fix additional fields dropdown in editor, update parser to leave validation to be done on the server

* added mistral v2, files v2, and finalized textract

* updated the rest of the old file patterns, updated mistral outputs for v2

* updated tag dropdown to parse non-operation fields as well

* updated extension finder

* cleanup

* added description for inputs to workflow

* use helper for internal route check

* fix tag dropdown merge conflict change

* remove duplicate code

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>

* fix(ui): change add inputs button to match output selector (#2907)

* fix(canvas): removed invite to workspace from canvas popover (#2908)

* fix(canvas): removed invite to workspace

* removed unused props

* fix(copilot): legacy tool display names (#2911)

* fix(a2a): canonical merge  (#2912)

* fix canonical merge

* fix empty array case

* fix(change-detection): copilot diffs have extra field (#2913)

* improvement(logs): improved logs ui bugs, added subflow disable UI (#2910)

* improvement(logs): improved logs ui bugs, added subflow disable UI

* added duplicate to action bar for subflows

* feat(broadcast): email v0.5 (#2905)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
2026-01-20 23:54:55 -08:00
Waleed
dff1c9d083 v0.5.64: unsubscribe, search improvements, metrics, additional SSO configuration 2026-01-20 00:34:11 -08:00
Vikhyath Mondreti
b09f683072 v0.5.63: ui and performance improvements, more google tools 2026-01-18 15:22:42 -08:00
Vikhyath Mondreti
a8bb0db660 v0.5.62: webhook bug fixes, seeding default subblock values, block selection fixes 2026-01-16 20:27:06 -08:00
Waleed
af82820a28 v0.5.61: webhook improvements, workflow controls, react query for deployment status, chat fixes, reducto and pulse OCR, linear fixes 2026-01-16 18:06:23 -08:00
Waleed
4372841797 v0.5.60: invitation flow improvements, chat fixes, a2a improvements, additional copilot actions 2026-01-15 00:02:18 -08:00
Waleed
5e8c843241 v0.5.59: a2a support, documentation 2026-01-13 13:21:21 -08:00
Waleed
7bf3d73ee6 v0.5.58: export folders, new tools, permissions groups enhancements 2026-01-13 00:56:59 -08:00
Vikhyath Mondreti
7ffc11a738 v0.5.57: subagents, context menu improvements, bug fixes 2026-01-11 11:38:40 -08:00
Waleed
be578e2ed7 v0.5.56: batch operations, access control and permission groups, billing fixes 2026-01-10 00:31:34 -08:00
Waleed
f415e5edc4 v0.5.55: polling groups, bedrock provider, devcontainer fixes, workflow preview enhancements 2026-01-08 23:36:56 -08:00
Waleed
13a6e6c3fa v0.5.54: seo, model blacklist, helm chart updates, fireflies integration, autoconnect improvements, billing fixes 2026-01-07 16:09:45 -08:00
Waleed
f5ab7f21ae v0.5.53: hotkey improvements, added redis fallback, fixes for workflow tool 2026-01-06 23:34:52 -08:00
Waleed
bfb6fffe38 v0.5.52: new port-based router block, combobox expression and variable support 2026-01-06 16:14:10 -08:00
Waleed
4fbec0a43f v0.5.51: triggers, kb, condition block improvements, supabase and grain integration updates 2026-01-06 14:26:46 -08:00
Waleed
585f5e365b v0.5.50: import improvements, ui upgrades, kb styling and performance improvements 2026-01-05 00:35:55 -08:00
Waleed
3792bdd252 v0.5.49: hitl improvements, new email styles, imap trigger, logs context menu (#2672)
* feat(logs-context-menu): consolidated logs utils and types, added logs record context menu (#2659)

* feat(email): welcome email; improvement(emails): ui/ux (#2658)

* feat(email): welcome email; improvement(emails): ui/ux

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

Co-authored-by: waleed <walif6@gmail.com>

* fix(logging): hitl + trigger dev crash protection (#2664)

* hitl gaps

* deal with trigger worker crashes

* cleanup import strcuture

* feat(imap): added support for imap trigger (#2663)

* feat(tools): added support for imap trigger

* feat(imap): added parity, tested

* ack PR comments

* final cleanup

* feat(i18n): update translations (#2665)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* fix(grain): updated grain trigger to auto-establish trigger (#2666)

Co-authored-by: aadamgough <adam@sim.ai>

* feat(admin): routes to manage deployments (#2667)

* feat(admin): routes to manage deployments

* fix naming fo deployed by

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date (#2668)

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date

* removed unused params, cleaned up redundant utils

* improvement(invite): aligned styling (#2669)

* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments

---------

Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: aadamgough <adam@sim.ai>
2026-01-03 13:19:18 -08:00
Waleed
eb5d1f3e5b v0.5.48: copy-paste workflow blocks, docs updates, mcp tool fixes 2025-12-31 18:00:04 -08:00
Waleed
54ab82c8dd v0.5.47: deploy workflow as mcp, kb chunks tokenizer, UI improvements, jira service management tools 2025-12-30 23:18:58 -08:00
Waleed
f895bf469b v0.5.46: build improvements, greptile, light mode improvements 2025-12-29 02:17:52 -08:00
Waleed
dd3209af06 v0.5.45: light mode fixes, realtime usage indicator, docker build improvements 2025-12-27 19:57:42 -08:00
Waleed
b6ba3b50a7 v0.5.44: keyboard shortcuts, autolayout, light mode, byok, testing improvements 2025-12-26 21:25:19 -08:00
Waleed
b304233062 v0.5.43: export logs, circleback, grain, vertex, code hygiene, schedule improvements 2025-12-23 19:19:18 -08:00
Vikhyath Mondreti
57e4b49bd6 v0.5.42: fix memory migration 2025-12-23 01:24:54 -08:00
Vikhyath Mondreti
e12dd204ed v0.5.41: memory fixes, copilot improvements, knowledgebase improvements, LLM providers standardization 2025-12-23 00:15:18 -08:00
Vikhyath Mondreti
3d9d9cbc54 v0.5.40: supabase ops to allow non-public schemas, jira uuid 2025-12-21 22:28:05 -08:00
Waleed
0f4ec962ad v0.5.39: notion, workflow variables fixes 2025-12-20 20:44:00 -08:00
Waleed
4827866f9a v0.5.38: snap to grid, copilot ux improvements, billing line items 2025-12-20 17:24:38 -08:00
Waleed
3e697d9ed9 v0.5.37: redaction utils consolidation, logs updates, autoconnect improvements, additional kb tag types 2025-12-19 22:31:55 -08:00
Martin Yankov
4431a1a484 fix(helm): add custom egress rules to realtime network policy (#2481)
The realtime service network policy was missing the custom egress rules section
that allows configuration of additional egress rules via values.yaml. This caused
the realtime pods to be unable to connect to external databases (e.g., PostgreSQL
on port 5432) when using external database configurations.

The app network policy already had this section, but the realtime network policy
was missing it, creating an inconsistency and preventing the realtime service
from accessing external databases configured via networkPolicy.egress values.

This fix adds the same custom egress rules template section to the realtime
network policy, matching the app network policy behavior and allowing users to
configure database connectivity via values.yaml.
2025-12-19 18:59:08 -08:00
Waleed
4d1a9a3f22 v0.5.36: hitl improvements, opengraph, slack fixes, one-click unsubscribe, auth checks, new db indexes 2025-12-19 01:27:49 -08:00
Vikhyath Mondreti
eb07a080fb v0.5.35: helm updates, copilot improvements, 404 for docs, salesforce fixes, subflow resize clamping 2025-12-18 16:23:19 -08:00
38 changed files with 1045 additions and 596 deletions

View File

@@ -89,7 +89,7 @@ export function WorkflowSelector({
onMouseDown={(e) => handleRemove(e, w.id)} onMouseDown={(e) => handleRemove(e, w.id)}
> >
{w.name} {w.name}
<X className='h-3 w-3' /> <X className='!text-[var(--text-primary)] h-4 w-4 flex-shrink-0 opacity-50' />
</Badge> </Badge>
))} ))}
{selectedWorkflows.length > 2 && ( {selectedWorkflows.length > 2 && (

View File

@@ -35,6 +35,7 @@ interface CredentialSelectorProps {
disabled?: boolean disabled?: boolean
isPreview?: boolean isPreview?: boolean
previewValue?: any | null previewValue?: any | null
previewContextValues?: Record<string, unknown>
} }
export function CredentialSelector({ export function CredentialSelector({
@@ -43,6 +44,7 @@ export function CredentialSelector({
disabled = false, disabled = false,
isPreview = false, isPreview = false,
previewValue, previewValue,
previewContextValues,
}: CredentialSelectorProps) { }: CredentialSelectorProps) {
const [showOAuthModal, setShowOAuthModal] = useState(false) const [showOAuthModal, setShowOAuthModal] = useState(false)
const [editingValue, setEditingValue] = useState('') const [editingValue, setEditingValue] = useState('')
@@ -67,7 +69,11 @@ export function CredentialSelector({
canUseCredentialSets canUseCredentialSets
) )
const { depsSatisfied, dependsOn } = useDependsOnGate(blockId, subBlock, { disabled, isPreview }) const { depsSatisfied, dependsOn } = useDependsOnGate(blockId, subBlock, {
disabled,
isPreview,
previewContextValues,
})
const hasDependencies = dependsOn.length > 0 const hasDependencies = dependsOn.length > 0
const effectiveDisabled = disabled || (hasDependencies && !depsSatisfied) const effectiveDisabled = disabled || (hasDependencies && !depsSatisfied)

View File

@@ -5,6 +5,7 @@ import { Tooltip } from '@/components/emcn'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import type { SelectorContext } from '@/hooks/selectors/types' import type { SelectorContext } from '@/hooks/selectors/types'
@@ -33,7 +34,9 @@ export function DocumentSelector({
previewContextValues, previewContextValues,
}) })
const [knowledgeBaseIdFromStore] = useSubBlockValue(blockId, 'knowledgeBaseId') const [knowledgeBaseIdFromStore] = useSubBlockValue(blockId, 'knowledgeBaseId')
const knowledgeBaseIdValue = previewContextValues?.knowledgeBaseId ?? knowledgeBaseIdFromStore const knowledgeBaseIdValue = previewContextValues
? resolvePreviewContextValue(previewContextValues.knowledgeBaseId)
: knowledgeBaseIdFromStore
const normalizedKnowledgeBaseId = const normalizedKnowledgeBaseId =
typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0 typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0
? knowledgeBaseIdValue ? knowledgeBaseIdValue

View File

@@ -17,6 +17,7 @@ import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input' import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import { useKnowledgeBaseTagDefinitions } from '@/hooks/kb/use-knowledge-base-tag-definitions' import { useKnowledgeBaseTagDefinitions } from '@/hooks/kb/use-knowledge-base-tag-definitions'
@@ -77,7 +78,9 @@ export function DocumentTagEntry({
}) })
const [knowledgeBaseIdFromStore] = useSubBlockValue(blockId, 'knowledgeBaseId') const [knowledgeBaseIdFromStore] = useSubBlockValue(blockId, 'knowledgeBaseId')
const knowledgeBaseIdValue = previewContextValues?.knowledgeBaseId ?? knowledgeBaseIdFromStore const knowledgeBaseIdValue = previewContextValues
? resolvePreviewContextValue(previewContextValues.knowledgeBaseId)
: knowledgeBaseIdFromStore
const knowledgeBaseId = const knowledgeBaseId =
typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0 typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0
? knowledgeBaseIdValue ? knowledgeBaseIdValue

View File

@@ -9,6 +9,7 @@ import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential' import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import { getBlock } from '@/blocks/registry' import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import { isDependency } from '@/blocks/utils' import { isDependency } from '@/blocks/utils'
@@ -62,42 +63,56 @@ export function FileSelectorInput({
const [domainValueFromStore] = useSubBlockValue(blockId, 'domain') const [domainValueFromStore] = useSubBlockValue(blockId, 'domain')
const connectedCredential = previewContextValues?.credential ?? blockValues.credential const connectedCredential = previewContextValues
const domainValue = previewContextValues?.domain ?? domainValueFromStore ? resolvePreviewContextValue(previewContextValues.credential)
: blockValues.credential
const domainValue = previewContextValues
? resolvePreviewContextValue(previewContextValues.domain)
: domainValueFromStore
const teamIdValue = useMemo( const teamIdValue = useMemo(
() => () =>
previewContextValues?.teamId ?? previewContextValues
resolveDependencyValue('teamId', blockValues, canonicalIndex, canonicalModeOverrides), ? resolvePreviewContextValue(previewContextValues.teamId)
[previewContextValues?.teamId, blockValues, canonicalIndex, canonicalModeOverrides] : resolveDependencyValue('teamId', blockValues, canonicalIndex, canonicalModeOverrides),
[previewContextValues, blockValues, canonicalIndex, canonicalModeOverrides]
) )
const siteIdValue = useMemo( const siteIdValue = useMemo(
() => () =>
previewContextValues?.siteId ?? previewContextValues
resolveDependencyValue('siteId', blockValues, canonicalIndex, canonicalModeOverrides), ? resolvePreviewContextValue(previewContextValues.siteId)
[previewContextValues?.siteId, blockValues, canonicalIndex, canonicalModeOverrides] : resolveDependencyValue('siteId', blockValues, canonicalIndex, canonicalModeOverrides),
[previewContextValues, blockValues, canonicalIndex, canonicalModeOverrides]
) )
const collectionIdValue = useMemo( const collectionIdValue = useMemo(
() => () =>
previewContextValues?.collectionId ?? previewContextValues
resolveDependencyValue('collectionId', blockValues, canonicalIndex, canonicalModeOverrides), ? resolvePreviewContextValue(previewContextValues.collectionId)
[previewContextValues?.collectionId, blockValues, canonicalIndex, canonicalModeOverrides] : resolveDependencyValue(
'collectionId',
blockValues,
canonicalIndex,
canonicalModeOverrides
),
[previewContextValues, blockValues, canonicalIndex, canonicalModeOverrides]
) )
const projectIdValue = useMemo( const projectIdValue = useMemo(
() => () =>
previewContextValues?.projectId ?? previewContextValues
resolveDependencyValue('projectId', blockValues, canonicalIndex, canonicalModeOverrides), ? resolvePreviewContextValue(previewContextValues.projectId)
[previewContextValues?.projectId, blockValues, canonicalIndex, canonicalModeOverrides] : resolveDependencyValue('projectId', blockValues, canonicalIndex, canonicalModeOverrides),
[previewContextValues, blockValues, canonicalIndex, canonicalModeOverrides]
) )
const planIdValue = useMemo( const planIdValue = useMemo(
() => () =>
previewContextValues?.planId ?? previewContextValues
resolveDependencyValue('planId', blockValues, canonicalIndex, canonicalModeOverrides), ? resolvePreviewContextValue(previewContextValues.planId)
[previewContextValues?.planId, blockValues, canonicalIndex, canonicalModeOverrides] : resolveDependencyValue('planId', blockValues, canonicalIndex, canonicalModeOverrides),
[previewContextValues, blockValues, canonicalIndex, canonicalModeOverrides]
) )
const normalizedCredentialId = const normalizedCredentialId =

View File

@@ -6,6 +6,7 @@ import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential' import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution' import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
@@ -17,6 +18,7 @@ interface FolderSelectorInputProps {
disabled?: boolean disabled?: boolean
isPreview?: boolean isPreview?: boolean
previewValue?: any | null previewValue?: any | null
previewContextValues?: Record<string, unknown>
} }
export function FolderSelectorInput({ export function FolderSelectorInput({
@@ -25,9 +27,13 @@ export function FolderSelectorInput({
disabled = false, disabled = false,
isPreview = false, isPreview = false,
previewValue, previewValue,
previewContextValues,
}: FolderSelectorInputProps) { }: FolderSelectorInputProps) {
const [storeValue] = useSubBlockValue(blockId, subBlock.id) const [storeValue] = useSubBlockValue(blockId, subBlock.id)
const [connectedCredential] = useSubBlockValue(blockId, 'credential') const [credentialFromStore] = useSubBlockValue(blockId, 'credential')
const connectedCredential = previewContextValues
? resolvePreviewContextValue(previewContextValues.credential)
: credentialFromStore
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow() const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const { activeWorkflowId } = useWorkflowRegistry() const { activeWorkflowId } = useWorkflowRegistry()
const [selectedFolderId, setSelectedFolderId] = useState<string>('') const [selectedFolderId, setSelectedFolderId] = useState<string>('')
@@ -47,7 +53,11 @@ export function FolderSelectorInput({
) )
// Central dependsOn gating // Central dependsOn gating
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview }) const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
disabled,
isPreview,
previewContextValues,
})
// Get the current value from the store or prop value if in preview mode // Get the current value from the store or prop value if in preview mode
useEffect(() => { useEffect(() => {

View File

@@ -7,6 +7,7 @@ import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input' import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useWorkflowState } from '@/hooks/queries/workflows' import { useWorkflowState } from '@/hooks/queries/workflows'
@@ -37,6 +38,8 @@ interface InputMappingProps {
isPreview?: boolean isPreview?: boolean
previewValue?: Record<string, unknown> previewValue?: Record<string, unknown>
disabled?: boolean disabled?: boolean
/** Sub-block values from the preview context for resolving sibling sub-block values */
previewContextValues?: Record<string, unknown>
} }
/** /**
@@ -50,9 +53,13 @@ export function InputMapping({
isPreview = false, isPreview = false,
previewValue, previewValue,
disabled = false, disabled = false,
previewContextValues,
}: InputMappingProps) { }: InputMappingProps) {
const [mapping, setMapping] = useSubBlockValue(blockId, subBlockId) const [mapping, setMapping] = useSubBlockValue(blockId, subBlockId)
const [selectedWorkflowId] = useSubBlockValue(blockId, 'workflowId') const [storeWorkflowId] = useSubBlockValue(blockId, 'workflowId')
const selectedWorkflowId = previewContextValues
? resolvePreviewContextValue(previewContextValues.workflowId)
: storeWorkflowId
const inputController = useSubBlockInput({ const inputController = useSubBlockInput({
blockId, blockId,

View File

@@ -17,6 +17,7 @@ import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input' import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import { useKnowledgeBaseTagDefinitions } from '@/hooks/kb/use-knowledge-base-tag-definitions' import { useKnowledgeBaseTagDefinitions } from '@/hooks/kb/use-knowledge-base-tag-definitions'
@@ -69,7 +70,9 @@ export function KnowledgeTagFilters({
const overlayRefs = useRef<Record<string, HTMLDivElement>>({}) const overlayRefs = useRef<Record<string, HTMLDivElement>>({})
const [knowledgeBaseIdFromStore] = useSubBlockValue(blockId, 'knowledgeBaseId') const [knowledgeBaseIdFromStore] = useSubBlockValue(blockId, 'knowledgeBaseId')
const knowledgeBaseIdValue = previewContextValues?.knowledgeBaseId ?? knowledgeBaseIdFromStore const knowledgeBaseIdValue = previewContextValues
? resolvePreviewContextValue(previewContextValues.knowledgeBaseId)
: knowledgeBaseIdFromStore
const knowledgeBaseId = const knowledgeBaseId =
typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0 typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0
? knowledgeBaseIdValue ? knowledgeBaseIdValue

View File

@@ -6,6 +6,7 @@ import { cn } from '@/lib/core/utils/cn'
import { LongInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input' import { LongInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input'
import { ShortInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input' import { ShortInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import { useMcpTools } from '@/hooks/mcp/use-mcp-tools' import { useMcpTools } from '@/hooks/mcp/use-mcp-tools'
import { formatParameterLabel } from '@/tools/params' import { formatParameterLabel } from '@/tools/params'
@@ -18,6 +19,7 @@ interface McpDynamicArgsProps {
disabled?: boolean disabled?: boolean
isPreview?: boolean isPreview?: boolean
previewValue?: any previewValue?: any
previewContextValues?: Record<string, unknown>
} }
/** /**
@@ -47,12 +49,19 @@ export function McpDynamicArgs({
disabled = false, disabled = false,
isPreview = false, isPreview = false,
previewValue, previewValue,
previewContextValues,
}: McpDynamicArgsProps) { }: McpDynamicArgsProps) {
const params = useParams() const params = useParams()
const workspaceId = params.workspaceId as string const workspaceId = params.workspaceId as string
const { mcpTools, isLoading } = useMcpTools(workspaceId) const { mcpTools, isLoading } = useMcpTools(workspaceId)
const [selectedTool] = useSubBlockValue(blockId, 'tool') const [toolFromStore] = useSubBlockValue(blockId, 'tool')
const [cachedSchema] = useSubBlockValue(blockId, '_toolSchema') const selectedTool = previewContextValues
? resolvePreviewContextValue(previewContextValues.tool)
: toolFromStore
const [schemaFromStore] = useSubBlockValue(blockId, '_toolSchema')
const cachedSchema = previewContextValues
? resolvePreviewContextValue(previewContextValues._toolSchema)
: schemaFromStore
const [toolArgs, setToolArgs] = useSubBlockValue(blockId, subBlockId) const [toolArgs, setToolArgs] = useSubBlockValue(blockId, subBlockId)
const selectedToolConfig = mcpTools.find((tool) => tool.id === selectedTool) const selectedToolConfig = mcpTools.find((tool) => tool.id === selectedTool)

View File

@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { Combobox } from '@/components/emcn/components' import { Combobox } from '@/components/emcn/components'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import { useMcpTools } from '@/hooks/mcp/use-mcp-tools' import { useMcpTools } from '@/hooks/mcp/use-mcp-tools'
@@ -13,6 +14,7 @@ interface McpToolSelectorProps {
disabled?: boolean disabled?: boolean
isPreview?: boolean isPreview?: boolean
previewValue?: string | null previewValue?: string | null
previewContextValues?: Record<string, unknown>
} }
export function McpToolSelector({ export function McpToolSelector({
@@ -21,6 +23,7 @@ export function McpToolSelector({
disabled = false, disabled = false,
isPreview = false, isPreview = false,
previewValue, previewValue,
previewContextValues,
}: McpToolSelectorProps) { }: McpToolSelectorProps) {
const params = useParams() const params = useParams()
const workspaceId = params.workspaceId as string const workspaceId = params.workspaceId as string
@@ -31,7 +34,10 @@ export function McpToolSelector({
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id) const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
const [, setSchemaCache] = useSubBlockValue(blockId, '_toolSchema') const [, setSchemaCache] = useSubBlockValue(blockId, '_toolSchema')
const [serverValue] = useSubBlockValue(blockId, 'server') const [serverFromStore] = useSubBlockValue(blockId, 'server')
const serverValue = previewContextValues
? resolvePreviewContextValue(previewContextValues.server)
: serverFromStore
const label = subBlock.placeholder || 'Select tool' const label = subBlock.placeholder || 'Select tool'

View File

@@ -9,6 +9,7 @@ import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential' import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import { getBlock } from '@/blocks/registry' import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution' import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution'
@@ -55,14 +56,19 @@ export function ProjectSelectorInput({
return (workflowValues as Record<string, Record<string, unknown>>)[blockId] || {} return (workflowValues as Record<string, Record<string, unknown>>)[blockId] || {}
}) })
const connectedCredential = previewContextValues?.credential ?? blockValues.credential const connectedCredential = previewContextValues
const jiraDomain = previewContextValues?.domain ?? jiraDomainFromStore ? resolvePreviewContextValue(previewContextValues.credential)
: blockValues.credential
const jiraDomain = previewContextValues
? resolvePreviewContextValue(previewContextValues.domain)
: jiraDomainFromStore
const linearTeamId = useMemo( const linearTeamId = useMemo(
() => () =>
previewContextValues?.teamId ?? previewContextValues
resolveDependencyValue('teamId', blockValues, canonicalIndex, canonicalModeOverrides), ? resolvePreviewContextValue(previewContextValues.teamId)
[previewContextValues?.teamId, blockValues, canonicalIndex, canonicalModeOverrides] : resolveDependencyValue('teamId', blockValues, canonicalIndex, canonicalModeOverrides),
[previewContextValues, blockValues, canonicalIndex, canonicalModeOverrides]
) )
const serviceId = subBlock.serviceId || '' const serviceId = subBlock.serviceId || ''

View File

@@ -8,6 +8,7 @@ import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/sub
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential' import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import { getBlock } from '@/blocks/registry' import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import { resolveSelectorForSubBlock, type SelectorResolution } from '@/hooks/selectors/resolution' import { resolveSelectorForSubBlock, type SelectorResolution } from '@/hooks/selectors/resolution'
@@ -66,9 +67,12 @@ export function SheetSelectorInput({
[blockValues, canonicalIndex, canonicalModeOverrides] [blockValues, canonicalIndex, canonicalModeOverrides]
) )
const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore const connectedCredential = previewContextValues
? resolvePreviewContextValue(previewContextValues.credential)
: connectedCredentialFromStore
const spreadsheetId = previewContextValues const spreadsheetId = previewContextValues
? (previewContextValues.spreadsheetId ?? previewContextValues.manualSpreadsheetId) ? (resolvePreviewContextValue(previewContextValues.spreadsheetId) ??
resolvePreviewContextValue(previewContextValues.manualSpreadsheetId))
: spreadsheetIdFromStore : spreadsheetIdFromStore
const normalizedCredentialId = const normalizedCredentialId =

View File

@@ -8,6 +8,7 @@ import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential' import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types' import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
@@ -58,9 +59,15 @@ export function SlackSelectorInput({
const [botToken] = useSubBlockValue(blockId, 'botToken') const [botToken] = useSubBlockValue(blockId, 'botToken')
const [connectedCredential] = useSubBlockValue(blockId, 'credential') const [connectedCredential] = useSubBlockValue(blockId, 'credential')
const effectiveAuthMethod = previewContextValues?.authMethod ?? authMethod const effectiveAuthMethod = previewContextValues
const effectiveBotToken = previewContextValues?.botToken ?? botToken ? resolvePreviewContextValue(previewContextValues.authMethod)
const effectiveCredential = previewContextValues?.credential ?? connectedCredential : authMethod
const effectiveBotToken = previewContextValues
? resolvePreviewContextValue(previewContextValues.botToken)
: botToken
const effectiveCredential = previewContextValues
? resolvePreviewContextValue(previewContextValues.credential)
: connectedCredential
const [_selectedValue, setSelectedValue] = useState<string | null>(null) const [_selectedValue, setSelectedValue] = useState<string | null>(null)
const serviceId = subBlock.serviceId || '' const serviceId = subBlock.serviceId || ''

View File

@@ -332,6 +332,7 @@ function FolderSelectorSyncWrapper({
dependsOn: uiComponent.dependsOn, dependsOn: uiComponent.dependsOn,
}} }}
disabled={disabled} disabled={disabled}
previewContextValues={previewContextValues}
/> />
</GenericSyncWrapper> </GenericSyncWrapper>
) )

View File

@@ -797,6 +797,7 @@ function SubBlockComponent({
disabled={isDisabled} disabled={isDisabled}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue} previewValue={previewValue}
previewContextValues={isPreview ? subBlockValues : undefined}
/> />
) )
@@ -832,6 +833,7 @@ function SubBlockComponent({
disabled={isDisabled} disabled={isDisabled}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue} previewValue={previewValue}
previewContextValues={isPreview ? subBlockValues : undefined}
/> />
) )
@@ -843,6 +845,7 @@ function SubBlockComponent({
disabled={isDisabled} disabled={isDisabled}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue} previewValue={previewValue}
previewContextValues={isPreview ? subBlockValues : undefined}
/> />
) )
@@ -865,6 +868,7 @@ function SubBlockComponent({
disabled={isDisabled} disabled={isDisabled}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue as any} previewValue={previewValue as any}
previewContextValues={isPreview ? subBlockValues : undefined}
/> />
) )
@@ -876,6 +880,7 @@ function SubBlockComponent({
disabled={isDisabled} disabled={isDisabled}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue as any} previewValue={previewValue as any}
previewContextValues={isPreview ? subBlockValues : undefined}
/> />
) )
@@ -887,6 +892,7 @@ function SubBlockComponent({
disabled={isDisabled} disabled={isDisabled}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue as any} previewValue={previewValue as any}
previewContextValues={isPreview ? subBlockValues : undefined}
/> />
) )
@@ -911,6 +917,7 @@ function SubBlockComponent({
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue as any} previewValue={previewValue as any}
disabled={isDisabled} disabled={isDisabled}
previewContextValues={isPreview ? subBlockValues : undefined}
/> />
) )
@@ -946,6 +953,7 @@ function SubBlockComponent({
disabled={isDisabled} disabled={isDisabled}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue} previewValue={previewValue}
previewContextValues={isPreview ? subBlockValues : undefined}
/> />
) )
@@ -979,6 +987,7 @@ function SubBlockComponent({
disabled={isDisabled} disabled={isDisabled}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue as any} previewValue={previewValue as any}
previewContextValues={isPreview ? subBlockValues : undefined}
/> />
) )
@@ -990,6 +999,7 @@ function SubBlockComponent({
disabled={isDisabled} disabled={isDisabled}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue} previewValue={previewValue}
previewContextValues={isPreview ? subBlockValues : undefined}
/> />
) )

View File

@@ -0,0 +1,18 @@
/**
* Extracts the raw value from a preview context entry.
*
* @remarks
* In the sub-block preview context, values are wrapped as `{ value: T }` objects
* (the full sub-block state). In the tool-input preview context, values are already
* raw. This function normalizes both cases to return the underlying value.
*
* @param raw - The preview context entry, which may be a raw value or a `{ value: T }` wrapper
* @returns The unwrapped value, or `null` if the input is nullish
*/
export function resolvePreviewContextValue(raw: unknown): unknown {
if (raw === null || raw === undefined) return null
if (typeof raw === 'object' && !Array.isArray(raw) && 'value' in raw) {
return (raw as Record<string, unknown>).value ?? null
}
return raw
}

View File

@@ -784,8 +784,12 @@ function PreviewEditorContent({
? childWorkflowSnapshotState ? childWorkflowSnapshotState
: childWorkflowState : childWorkflowState
const resolvedIsLoadingChildWorkflow = isExecutionMode ? false : isLoadingChildWorkflow const resolvedIsLoadingChildWorkflow = isExecutionMode ? false : isLoadingChildWorkflow
const isBlockNotExecuted = isExecutionMode && !executionData
const isMissingChildWorkflow = const isMissingChildWorkflow =
Boolean(childWorkflowId) && !resolvedIsLoadingChildWorkflow && !resolvedChildWorkflowState Boolean(childWorkflowId) &&
!isBlockNotExecuted &&
!resolvedIsLoadingChildWorkflow &&
!resolvedChildWorkflowState
/** Drills down into the child workflow or opens it in a new tab */ /** Drills down into the child workflow or opens it in a new tab */
const handleExpandChildWorkflow = useCallback(() => { const handleExpandChildWorkflow = useCallback(() => {
@@ -1192,7 +1196,7 @@ function PreviewEditorContent({
<div ref={subBlocksRef} className='subblocks-section flex flex-1 flex-col overflow-hidden'> <div ref={subBlocksRef} className='subblocks-section flex flex-1 flex-col overflow-hidden'>
<div className='flex-1 overflow-y-auto overflow-x-hidden'> <div className='flex-1 overflow-y-auto overflow-x-hidden'>
{/* Not Executed Banner - shown when in execution mode but block wasn't executed */} {/* Not Executed Banner - shown when in execution mode but block wasn't executed */}
{isExecutionMode && !executionData && ( {isBlockNotExecuted && (
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden border-[var(--border)] border-b px-[12px] py-[10px]'> <div className='flex min-w-0 flex-col gap-[8px] overflow-hidden border-[var(--border)] border-b px-[12px] py-[10px]'>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<Badge variant='gray-secondary' size='sm' dot> <Badge variant='gray-secondary' size='sm' dot>
@@ -1419,7 +1423,9 @@ function PreviewEditorContent({
) : ( ) : (
<div className='flex h-full items-center justify-center bg-[var(--surface-3)]'> <div className='flex h-full items-center justify-center bg-[var(--surface-3)]'>
<span className='text-[13px] text-[var(--text-tertiary)]'> <span className='text-[13px] text-[var(--text-tertiary)]'>
{isMissingChildWorkflow {isBlockNotExecuted
? 'Not Executed'
: isMissingChildWorkflow
? DELETED_WORKFLOW_LABEL ? DELETED_WORKFLOW_LABEL
: 'Unable to load preview'} : 'Unable to load preview'}
</span> </span>

View File

@@ -154,6 +154,7 @@ Return ONLY the JSON array.`,
type: 'dropdown', type: 'dropdown',
placeholder: 'Select reasoning effort...', placeholder: 'Select reasoning effort...',
options: [ options: [
{ label: 'auto', id: 'auto' },
{ label: 'low', id: 'low' }, { label: 'low', id: 'low' },
{ label: 'medium', id: 'medium' }, { label: 'medium', id: 'medium' },
{ label: 'high', id: 'high' }, { label: 'high', id: 'high' },
@@ -163,9 +164,12 @@ Return ONLY the JSON array.`,
const { useSubBlockStore } = await import('@/stores/workflows/subblock/store') const { useSubBlockStore } = await import('@/stores/workflows/subblock/store')
const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store') const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store')
const autoOption = { label: 'auto', id: 'auto' }
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId) { if (!activeWorkflowId) {
return [ return [
autoOption,
{ label: 'low', id: 'low' }, { label: 'low', id: 'low' },
{ label: 'medium', id: 'medium' }, { label: 'medium', id: 'medium' },
{ label: 'high', id: 'high' }, { label: 'high', id: 'high' },
@@ -178,6 +182,7 @@ Return ONLY the JSON array.`,
if (!modelValue) { if (!modelValue) {
return [ return [
autoOption,
{ label: 'low', id: 'low' }, { label: 'low', id: 'low' },
{ label: 'medium', id: 'medium' }, { label: 'medium', id: 'medium' },
{ label: 'high', id: 'high' }, { label: 'high', id: 'high' },
@@ -187,15 +192,16 @@ Return ONLY the JSON array.`,
const validOptions = getReasoningEffortValuesForModel(modelValue) const validOptions = getReasoningEffortValuesForModel(modelValue)
if (!validOptions) { if (!validOptions) {
return [ return [
autoOption,
{ label: 'low', id: 'low' }, { label: 'low', id: 'low' },
{ label: 'medium', id: 'medium' }, { label: 'medium', id: 'medium' },
{ label: 'high', id: 'high' }, { label: 'high', id: 'high' },
] ]
} }
return validOptions.map((opt) => ({ label: opt, id: opt })) return [autoOption, ...validOptions.map((opt) => ({ label: opt, id: opt }))]
}, },
value: () => 'medium', mode: 'advanced',
condition: { condition: {
field: 'model', field: 'model',
value: MODELS_WITH_REASONING_EFFORT, value: MODELS_WITH_REASONING_EFFORT,
@@ -207,6 +213,7 @@ Return ONLY the JSON array.`,
type: 'dropdown', type: 'dropdown',
placeholder: 'Select verbosity...', placeholder: 'Select verbosity...',
options: [ options: [
{ label: 'auto', id: 'auto' },
{ label: 'low', id: 'low' }, { label: 'low', id: 'low' },
{ label: 'medium', id: 'medium' }, { label: 'medium', id: 'medium' },
{ label: 'high', id: 'high' }, { label: 'high', id: 'high' },
@@ -216,9 +223,12 @@ Return ONLY the JSON array.`,
const { useSubBlockStore } = await import('@/stores/workflows/subblock/store') const { useSubBlockStore } = await import('@/stores/workflows/subblock/store')
const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store') const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store')
const autoOption = { label: 'auto', id: 'auto' }
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId) { if (!activeWorkflowId) {
return [ return [
autoOption,
{ label: 'low', id: 'low' }, { label: 'low', id: 'low' },
{ label: 'medium', id: 'medium' }, { label: 'medium', id: 'medium' },
{ label: 'high', id: 'high' }, { label: 'high', id: 'high' },
@@ -231,6 +241,7 @@ Return ONLY the JSON array.`,
if (!modelValue) { if (!modelValue) {
return [ return [
autoOption,
{ label: 'low', id: 'low' }, { label: 'low', id: 'low' },
{ label: 'medium', id: 'medium' }, { label: 'medium', id: 'medium' },
{ label: 'high', id: 'high' }, { label: 'high', id: 'high' },
@@ -240,15 +251,16 @@ Return ONLY the JSON array.`,
const validOptions = getVerbosityValuesForModel(modelValue) const validOptions = getVerbosityValuesForModel(modelValue)
if (!validOptions) { if (!validOptions) {
return [ return [
autoOption,
{ label: 'low', id: 'low' }, { label: 'low', id: 'low' },
{ label: 'medium', id: 'medium' }, { label: 'medium', id: 'medium' },
{ label: 'high', id: 'high' }, { label: 'high', id: 'high' },
] ]
} }
return validOptions.map((opt) => ({ label: opt, id: opt })) return [autoOption, ...validOptions.map((opt) => ({ label: opt, id: opt }))]
}, },
value: () => 'medium', mode: 'advanced',
condition: { condition: {
field: 'model', field: 'model',
value: MODELS_WITH_VERBOSITY, value: MODELS_WITH_VERBOSITY,
@@ -260,6 +272,7 @@ Return ONLY the JSON array.`,
type: 'dropdown', type: 'dropdown',
placeholder: 'Select thinking level...', placeholder: 'Select thinking level...',
options: [ options: [
{ label: 'none', id: 'none' },
{ label: 'minimal', id: 'minimal' }, { label: 'minimal', id: 'minimal' },
{ label: 'low', id: 'low' }, { label: 'low', id: 'low' },
{ label: 'medium', id: 'medium' }, { label: 'medium', id: 'medium' },
@@ -271,12 +284,11 @@ Return ONLY the JSON array.`,
const { useSubBlockStore } = await import('@/stores/workflows/subblock/store') const { useSubBlockStore } = await import('@/stores/workflows/subblock/store')
const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store') const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store')
const noneOption = { label: 'none', id: 'none' }
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId) { if (!activeWorkflowId) {
return [ return [noneOption, { label: 'low', id: 'low' }, { label: 'high', id: 'high' }]
{ label: 'low', id: 'low' },
{ label: 'high', id: 'high' },
]
} }
const workflowValues = useSubBlockStore.getState().workflowValues[activeWorkflowId] const workflowValues = useSubBlockStore.getState().workflowValues[activeWorkflowId]
@@ -284,23 +296,17 @@ Return ONLY the JSON array.`,
const modelValue = blockValues?.model as string const modelValue = blockValues?.model as string
if (!modelValue) { if (!modelValue) {
return [ return [noneOption, { label: 'low', id: 'low' }, { label: 'high', id: 'high' }]
{ label: 'low', id: 'low' },
{ label: 'high', id: 'high' },
]
} }
const validOptions = getThinkingLevelsForModel(modelValue) const validOptions = getThinkingLevelsForModel(modelValue)
if (!validOptions) { if (!validOptions) {
return [ return [noneOption, { label: 'low', id: 'low' }, { label: 'high', id: 'high' }]
{ label: 'low', id: 'low' },
{ label: 'high', id: 'high' },
]
} }
return validOptions.map((opt) => ({ label: opt, id: opt })) return [noneOption, ...validOptions.map((opt) => ({ label: opt, id: opt }))]
}, },
value: () => 'high', mode: 'advanced',
condition: { condition: {
field: 'model', field: 'model',
value: MODELS_WITH_THINKING, value: MODELS_WITH_THINKING,
@@ -391,6 +397,16 @@ Return ONLY the JSON array.`,
value: providers.bedrock.models, value: providers.bedrock.models,
}, },
}, },
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
placeholder: 'Enter your API key',
password: true,
connectionDroppable: false,
required: true,
condition: getApiKeyCondition(),
},
{ {
id: 'tools', id: 'tools',
title: 'Tools', title: 'Tools',
@@ -403,16 +419,6 @@ Return ONLY the JSON array.`,
type: 'skill-input', type: 'skill-input',
defaultValue: [], defaultValue: [],
}, },
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
placeholder: 'Enter your API key',
password: true,
connectionDroppable: false,
required: true,
condition: getApiKeyCondition(),
},
{ {
id: 'memoryType', id: 'memoryType',
title: 'Memory', title: 'Memory',
@@ -467,6 +473,7 @@ Return ONLY the JSON array.`,
min: 0, min: 0,
max: 1, max: 1,
defaultValue: 0.3, defaultValue: 0.3,
mode: 'advanced',
condition: () => ({ condition: () => ({
field: 'model', field: 'model',
value: (() => { value: (() => {
@@ -484,6 +491,7 @@ Return ONLY the JSON array.`,
min: 0, min: 0,
max: 2, max: 2,
defaultValue: 0.3, defaultValue: 0.3,
mode: 'advanced',
condition: () => ({ condition: () => ({
field: 'model', field: 'model',
value: (() => { value: (() => {
@@ -499,6 +507,7 @@ Return ONLY the JSON array.`,
title: 'Max Output Tokens', title: 'Max Output Tokens',
type: 'short-input', type: 'short-input',
placeholder: 'Enter max tokens (e.g., 4096)...', placeholder: 'Enter max tokens (e.g., 4096)...',
mode: 'advanced',
}, },
{ {
id: 'responseFormat', id: 'responseFormat',

View File

@@ -915,24 +915,17 @@ export class AgentBlockHandler implements BlockHandler {
} }
} }
// Find first system message
const firstSystemIndex = messages.findIndex((msg) => msg.role === 'system') const firstSystemIndex = messages.findIndex((msg) => msg.role === 'system')
if (firstSystemIndex === -1) { if (firstSystemIndex === -1) {
// No system message exists - add at position 0
messages.unshift({ role: 'system', content }) messages.unshift({ role: 'system', content })
} else if (firstSystemIndex === 0) { } else if (firstSystemIndex === 0) {
// System message already at position 0 - replace it
// Explicit systemPrompt parameter takes precedence over memory/messages
messages[0] = { role: 'system', content } messages[0] = { role: 'system', content }
} else { } else {
// System message exists but not at position 0 - move it to position 0
// and update with new content
messages.splice(firstSystemIndex, 1) messages.splice(firstSystemIndex, 1)
messages.unshift({ role: 'system', content }) messages.unshift({ role: 'system', content })
} }
// Remove any additional system messages (keep only the first one)
for (let i = messages.length - 1; i >= 1; i--) { for (let i = messages.length - 1; i >= 1; i--) {
if (messages[i].role === 'system') { if (messages[i].role === 'system') {
messages.splice(i, 1) messages.splice(i, 1)
@@ -998,13 +991,14 @@ export class AgentBlockHandler implements BlockHandler {
workflowId: ctx.workflowId, workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId, workspaceId: ctx.workspaceId,
stream: streaming, stream: streaming,
messages, messages: messages?.map(({ executionId, ...msg }) => msg),
environmentVariables: ctx.environmentVariables || {}, environmentVariables: ctx.environmentVariables || {},
workflowVariables: ctx.workflowVariables || {}, workflowVariables: ctx.workflowVariables || {},
blockData, blockData,
blockNameMapping, blockNameMapping,
reasoningEffort: inputs.reasoningEffort, reasoningEffort: inputs.reasoningEffort,
verbosity: inputs.verbosity, verbosity: inputs.verbosity,
thinkingLevel: inputs.thinkingLevel,
} }
} }
@@ -1074,6 +1068,7 @@ export class AgentBlockHandler implements BlockHandler {
isDeployedContext: ctx.isDeployedContext, isDeployedContext: ctx.isDeployedContext,
reasoningEffort: providerRequest.reasoningEffort, reasoningEffort: providerRequest.reasoningEffort,
verbosity: providerRequest.verbosity, verbosity: providerRequest.verbosity,
thinkingLevel: providerRequest.thinkingLevel,
}) })
return this.processProviderResponse(response, block, responseFormat) return this.processProviderResponse(response, block, responseFormat)
@@ -1091,8 +1086,6 @@ export class AgentBlockHandler implements BlockHandler {
logger.info(`[${requestId}] Resolving Vertex AI credential: ${credentialId}`) logger.info(`[${requestId}] Resolving Vertex AI credential: ${credentialId}`)
// Get the credential - we need to find the owner
// Since we're in a workflow context, we can query the credential directly
const credential = await db.query.account.findFirst({ const credential = await db.query.account.findFirst({
where: eq(account.id, credentialId), where: eq(account.id, credentialId),
}) })
@@ -1101,7 +1094,6 @@ export class AgentBlockHandler implements BlockHandler {
throw new Error(`Vertex AI credential not found: ${credentialId}`) throw new Error(`Vertex AI credential not found: ${credentialId}`)
} }
// Refresh the token if needed
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId) const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId)
if (!accessToken) { if (!accessToken) {

View File

@@ -34,6 +34,7 @@ export interface AgentInputs {
bedrockRegion?: string bedrockRegion?: string
reasoningEffort?: string reasoningEffort?: string
verbosity?: string verbosity?: string
thinkingLevel?: string
} }
export interface ToolInput { export interface ToolInput {

View File

@@ -33,11 +33,25 @@ export class SnapshotService implements ISnapshotService {
const existingSnapshot = await this.getSnapshotByHash(workflowId, stateHash) const existingSnapshot = await this.getSnapshotByHash(workflowId, stateHash)
if (existingSnapshot) { if (existingSnapshot) {
let refreshedState: WorkflowState = existingSnapshot.stateData
try {
await db
.update(workflowExecutionSnapshots)
.set({ stateData: state })
.where(eq(workflowExecutionSnapshots.id, existingSnapshot.id))
refreshedState = state
} catch (error) {
logger.warn(
`Failed to refresh snapshot stateData for ${existingSnapshot.id}, continuing with existing data`,
error
)
}
logger.info( logger.info(
`Reusing existing snapshot for workflow ${workflowId} (hash: ${stateHash.slice(0, 12)}...)` `Reusing existing snapshot for workflow ${workflowId} (hash: ${stateHash.slice(0, 12)}...)`
) )
return { return {
snapshot: existingSnapshot, snapshot: { ...existingSnapshot, stateData: refreshedState },
isNew: false, isNew: false,
} }
} }

View File

@@ -1,5 +1,6 @@
import type Anthropic from '@anthropic-ai/sdk' import type Anthropic from '@anthropic-ai/sdk'
import { transformJSONSchema } from '@anthropic-ai/sdk/lib/transform-json-schema' import { transformJSONSchema } from '@anthropic-ai/sdk/lib/transform-json-schema'
import type { RawMessageStreamEvent } from '@anthropic-ai/sdk/resources/messages/messages'
import type { Logger } from '@sim/logger' import type { Logger } from '@sim/logger'
import type { StreamingExecution } from '@/executor/types' import type { StreamingExecution } from '@/executor/types'
import { MAX_TOOL_ITERATIONS } from '@/providers' import { MAX_TOOL_ITERATIONS } from '@/providers'
@@ -34,11 +35,21 @@ export interface AnthropicProviderConfig {
logger: Logger logger: Logger
} }
/**
* Custom payload type extending the SDK's base message creation params.
* Adds fields not yet in the SDK: adaptive thinking, output_format, output_config.
*/
interface AnthropicPayload extends Omit<Anthropic.Messages.MessageStreamParams, 'thinking'> {
thinking?: Anthropic.Messages.ThinkingConfigParam | { type: 'adaptive' }
output_format?: { type: 'json_schema'; schema: Record<string, unknown> }
output_config?: { effort: string }
}
/** /**
* Generates prompt-based schema instructions for older models that don't support native structured outputs. * Generates prompt-based schema instructions for older models that don't support native structured outputs.
* This is a fallback approach that adds schema requirements to the system prompt. * This is a fallback approach that adds schema requirements to the system prompt.
*/ */
function generateSchemaInstructions(schema: any, schemaName?: string): string { function generateSchemaInstructions(schema: Record<string, unknown>, schemaName?: string): string {
const name = schemaName || 'response' const name = schemaName || 'response'
return `IMPORTANT: You must respond with a valid JSON object that conforms to the following schema. return `IMPORTANT: You must respond with a valid JSON object that conforms to the following schema.
Do not include any text before or after the JSON object. Only output the JSON. Do not include any text before or after the JSON object. Only output the JSON.
@@ -113,6 +124,30 @@ function buildThinkingConfig(
} }
} }
/**
* The Anthropic SDK requires streaming for non-streaming requests when max_tokens exceeds
* this threshold, to avoid HTTP timeouts. When thinking is enabled and pushes max_tokens
* above this limit, we use streaming internally and collect the final message.
*/
const ANTHROPIC_SDK_NON_STREAMING_MAX_TOKENS = 21333
/**
* Creates an Anthropic message, automatically using streaming internally when max_tokens
* exceeds the SDK's non-streaming threshold. Returns the same Message object either way.
*/
async function createMessage(
anthropic: Anthropic,
payload: AnthropicPayload
): Promise<Anthropic.Messages.Message> {
if (payload.max_tokens > ANTHROPIC_SDK_NON_STREAMING_MAX_TOKENS && !payload.stream) {
const stream = anthropic.messages.stream(payload as Anthropic.Messages.MessageStreamParams)
return stream.finalMessage()
}
return anthropic.messages.create(
payload as Anthropic.Messages.MessageCreateParamsNonStreaming
) as Promise<Anthropic.Messages.Message>
}
/** /**
* Executes a request using the Anthropic API with full tool loop support. * Executes a request using the Anthropic API with full tool loop support.
* This is the shared core implementation used by both the standard Anthropic provider * This is the shared core implementation used by both the standard Anthropic provider
@@ -135,7 +170,7 @@ export async function executeAnthropicProviderRequest(
const anthropic = config.createClient(request.apiKey, useNativeStructuredOutputs) const anthropic = config.createClient(request.apiKey, useNativeStructuredOutputs)
const messages: any[] = [] const messages: Anthropic.Messages.MessageParam[] = []
let systemPrompt = request.systemPrompt || '' let systemPrompt = request.systemPrompt || ''
if (request.context) { if (request.context) {
@@ -153,8 +188,8 @@ export async function executeAnthropicProviderRequest(
content: [ content: [
{ {
type: 'tool_result', type: 'tool_result',
tool_use_id: msg.name, tool_use_id: msg.name || '',
content: msg.content, content: msg.content || undefined,
}, },
], ],
}) })
@@ -188,12 +223,12 @@ export async function executeAnthropicProviderRequest(
systemPrompt = '' systemPrompt = ''
} }
let anthropicTools = request.tools?.length let anthropicTools: Anthropic.Messages.Tool[] | undefined = request.tools?.length
? request.tools.map((tool) => ({ ? request.tools.map((tool) => ({
name: tool.id, name: tool.id,
description: tool.description, description: tool.description,
input_schema: { input_schema: {
type: 'object', type: 'object' as const,
properties: tool.parameters.properties, properties: tool.parameters.properties,
required: tool.parameters.required, required: tool.parameters.required,
}, },
@@ -238,13 +273,12 @@ export async function executeAnthropicProviderRequest(
} }
} }
const payload: any = { const payload: AnthropicPayload = {
model: request.model, model: request.model,
messages, messages,
system: systemPrompt, system: systemPrompt,
max_tokens: max_tokens:
Number.parseInt(String(request.maxTokens)) || Number.parseInt(String(request.maxTokens)) || getMaxOutputTokensForModel(request.model),
getMaxOutputTokensForModel(request.model, request.stream ?? false),
temperature: Number.parseFloat(String(request.temperature ?? 0.7)), temperature: Number.parseFloat(String(request.temperature ?? 0.7)),
} }
@@ -268,13 +302,35 @@ export async function executeAnthropicProviderRequest(
} }
// Add extended thinking configuration if supported and requested // Add extended thinking configuration if supported and requested
if (request.thinkingLevel) { // The 'none' sentinel means "disable thinking" — skip configuration entirely.
if (request.thinkingLevel && request.thinkingLevel !== 'none') {
const thinkingConfig = buildThinkingConfig(request.model, request.thinkingLevel) const thinkingConfig = buildThinkingConfig(request.model, request.thinkingLevel)
if (thinkingConfig) { if (thinkingConfig) {
payload.thinking = thinkingConfig.thinking payload.thinking = thinkingConfig.thinking
if (thinkingConfig.outputConfig) { if (thinkingConfig.outputConfig) {
payload.output_config = thinkingConfig.outputConfig payload.output_config = thinkingConfig.outputConfig
} }
// Per Anthropic docs: budget_tokens must be less than max_tokens.
// Ensure max_tokens leaves room for both thinking and text output.
if (
thinkingConfig.thinking.type === 'enabled' &&
'budget_tokens' in thinkingConfig.thinking
) {
const budgetTokens = thinkingConfig.thinking.budget_tokens
const minMaxTokens = budgetTokens + 4096
if (payload.max_tokens < minMaxTokens) {
const modelMax = getMaxOutputTokensForModel(request.model)
payload.max_tokens = Math.min(minMaxTokens, modelMax)
logger.info(
`Adjusted max_tokens to ${payload.max_tokens} to satisfy budget_tokens (${budgetTokens}) constraint`
)
}
}
// Per Anthropic docs: thinking is not compatible with temperature or top_k modifications.
payload.temperature = undefined
const isAdaptive = thinkingConfig.thinking.type === 'adaptive' const isAdaptive = thinkingConfig.thinking.type === 'adaptive'
logger.info( logger.info(
`Using ${isAdaptive ? 'adaptive' : 'extended'} thinking for model: ${modelId} with ${isAdaptive ? `effort: ${request.thinkingLevel}` : `budget: ${(thinkingConfig.thinking as { budget_tokens: number }).budget_tokens}`}` `Using ${isAdaptive ? 'adaptive' : 'extended'} thinking for model: ${modelId} with ${isAdaptive ? `effort: ${request.thinkingLevel}` : `budget: ${(thinkingConfig.thinking as { budget_tokens: number }).budget_tokens}`}`
@@ -288,7 +344,16 @@ export async function executeAnthropicProviderRequest(
if (anthropicTools?.length) { if (anthropicTools?.length) {
payload.tools = anthropicTools payload.tools = anthropicTools
if (toolChoice !== 'auto') { // Per Anthropic docs: forced tool_choice (type: "tool" or "any") is incompatible with
// thinking. Only auto and none are supported when thinking is enabled.
if (payload.thinking) {
// Per Anthropic docs: only 'auto' (default) and 'none' work with thinking.
if (toolChoice === 'none') {
payload.tool_choice = { type: 'none' }
}
} else if (toolChoice === 'none') {
payload.tool_choice = { type: 'none' }
} else if (toolChoice !== 'auto') {
payload.tool_choice = toolChoice payload.tool_choice = toolChoice
} }
} }
@@ -301,13 +366,15 @@ export async function executeAnthropicProviderRequest(
const providerStartTime = Date.now() const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString() const providerStartTimeISO = new Date(providerStartTime).toISOString()
const streamResponse: any = await anthropic.messages.create({ const streamResponse = await anthropic.messages.create({
...payload, ...payload,
stream: true, stream: true,
}) } as Anthropic.Messages.MessageCreateParamsStreaming)
const streamingResult = { const streamingResult = {
stream: createReadableStreamFromAnthropicStream(streamResponse, (content, usage) => { stream: createReadableStreamFromAnthropicStream(
streamResponse as AsyncIterable<RawMessageStreamEvent>,
(content, usage) => {
streamingResult.execution.output.content = content streamingResult.execution.output.content = content
streamingResult.execution.output.tokens = { streamingResult.execution.output.tokens = {
input: usage.input_tokens, input: usage.input_tokens,
@@ -331,12 +398,14 @@ export async function executeAnthropicProviderRequest(
streamEndTime - providerStartTime streamEndTime - providerStartTime
if (streamingResult.execution.output.providerTiming.timeSegments?.[0]) { if (streamingResult.execution.output.providerTiming.timeSegments?.[0]) {
streamingResult.execution.output.providerTiming.timeSegments[0].endTime = streamEndTime streamingResult.execution.output.providerTiming.timeSegments[0].endTime =
streamEndTime
streamingResult.execution.output.providerTiming.timeSegments[0].duration = streamingResult.execution.output.providerTiming.timeSegments[0].duration =
streamEndTime - providerStartTime streamEndTime - providerStartTime
} }
} }
}), }
),
execution: { execution: {
success: true, success: true,
output: { output: {
@@ -385,21 +454,13 @@ export async function executeAnthropicProviderRequest(
const providerStartTime = Date.now() const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString() const providerStartTimeISO = new Date(providerStartTime).toISOString()
// Cap intermediate calls at non-streaming limit to avoid SDK timeout errors,
// but allow users to set lower values if desired
const nonStreamingLimit = getMaxOutputTokensForModel(request.model, false)
const nonStreamingMaxTokens = request.maxTokens
? Math.min(Number.parseInt(String(request.maxTokens)), nonStreamingLimit)
: nonStreamingLimit
const intermediatePayload = { ...payload, max_tokens: nonStreamingMaxTokens }
try { try {
const initialCallTime = Date.now() const initialCallTime = Date.now()
const originalToolChoice = intermediatePayload.tool_choice const originalToolChoice = payload.tool_choice
const forcedTools = preparedTools?.forcedTools || [] const forcedTools = preparedTools?.forcedTools || []
let usedForcedTools: string[] = [] let usedForcedTools: string[] = []
let currentResponse = await anthropic.messages.create(intermediatePayload) let currentResponse = await createMessage(anthropic, payload)
const firstResponseTime = Date.now() - initialCallTime const firstResponseTime = Date.now() - initialCallTime
let content = '' let content = ''
@@ -468,10 +529,10 @@ export async function executeAnthropicProviderRequest(
const toolExecutionPromises = toolUses.map(async (toolUse) => { const toolExecutionPromises = toolUses.map(async (toolUse) => {
const toolCallStartTime = Date.now() const toolCallStartTime = Date.now()
const toolName = toolUse.name const toolName = toolUse.name
const toolArgs = toolUse.input as Record<string, any> const toolArgs = toolUse.input as Record<string, unknown>
try { try {
const tool = request.tools?.find((t: any) => t.id === toolName) const tool = request.tools?.find((t) => t.id === toolName)
if (!tool) return null if (!tool) return null
const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request)
@@ -512,17 +573,8 @@ export async function executeAnthropicProviderRequest(
const executionResults = await Promise.allSettled(toolExecutionPromises) const executionResults = await Promise.allSettled(toolExecutionPromises)
// Collect all tool_use and tool_result blocks for batching // Collect all tool_use and tool_result blocks for batching
const toolUseBlocks: Array<{ const toolUseBlocks: Anthropic.Messages.ToolUseBlockParam[] = []
type: 'tool_use' const toolResultBlocks: Anthropic.Messages.ToolResultBlockParam[] = []
id: string
name: string
input: Record<string, unknown>
}> = []
const toolResultBlocks: Array<{
type: 'tool_result'
tool_use_id: string
content: string
}> = []
for (const settledResult of executionResults) { for (const settledResult of executionResults) {
if (settledResult.status === 'rejected' || !settledResult.value) continue if (settledResult.status === 'rejected' || !settledResult.value) continue
@@ -583,11 +635,25 @@ export async function executeAnthropicProviderRequest(
}) })
} }
// Add ONE assistant message with ALL tool_use blocks // Per Anthropic docs: thinking blocks must be preserved in assistant messages
// during tool use to maintain reasoning continuity.
const thinkingBlocks = currentResponse.content.filter(
(
item
): item is
| Anthropic.Messages.ThinkingBlock
| Anthropic.Messages.RedactedThinkingBlock =>
item.type === 'thinking' || item.type === 'redacted_thinking'
)
// Add ONE assistant message with thinking + tool_use blocks
if (toolUseBlocks.length > 0) { if (toolUseBlocks.length > 0) {
currentMessages.push({ currentMessages.push({
role: 'assistant', role: 'assistant',
content: toolUseBlocks as unknown as Anthropic.Messages.ContentBlock[], content: [
...thinkingBlocks,
...toolUseBlocks,
] as Anthropic.Messages.ContentBlockParam[],
}) })
} }
@@ -595,19 +661,23 @@ export async function executeAnthropicProviderRequest(
if (toolResultBlocks.length > 0) { if (toolResultBlocks.length > 0) {
currentMessages.push({ currentMessages.push({
role: 'user', role: 'user',
content: toolResultBlocks as unknown as Anthropic.Messages.ContentBlockParam[], content: toolResultBlocks as Anthropic.Messages.ContentBlockParam[],
}) })
} }
const thisToolsTime = Date.now() - toolsStartTime const thisToolsTime = Date.now() - toolsStartTime
toolsTime += thisToolsTime toolsTime += thisToolsTime
const nextPayload = { const nextPayload: AnthropicPayload = {
...intermediatePayload, ...payload,
messages: currentMessages, messages: currentMessages,
} }
// Per Anthropic docs: forced tool_choice is incompatible with thinking.
// Only auto and none are supported when thinking is enabled.
const thinkingEnabled = !!payload.thinking
if ( if (
!thinkingEnabled &&
typeof originalToolChoice === 'object' && typeof originalToolChoice === 'object' &&
hasUsedForcedTool && hasUsedForcedTool &&
forcedTools.length > 0 forcedTools.length > 0
@@ -624,7 +694,11 @@ export async function executeAnthropicProviderRequest(
nextPayload.tool_choice = undefined nextPayload.tool_choice = undefined
logger.info('All forced tools have been used, removing tool_choice parameter') logger.info('All forced tools have been used, removing tool_choice parameter')
} }
} else if (hasUsedForcedTool && typeof originalToolChoice === 'object') { } else if (
!thinkingEnabled &&
hasUsedForcedTool &&
typeof originalToolChoice === 'object'
) {
nextPayload.tool_choice = undefined nextPayload.tool_choice = undefined
logger.info( logger.info(
'Removing tool_choice parameter for subsequent requests after forced tool was used' 'Removing tool_choice parameter for subsequent requests after forced tool was used'
@@ -633,7 +707,7 @@ export async function executeAnthropicProviderRequest(
const nextModelStartTime = Date.now() const nextModelStartTime = Date.now()
currentResponse = await anthropic.messages.create(nextPayload) currentResponse = await createMessage(anthropic, nextPayload)
const nextCheckResult = checkForForcedToolUsage( const nextCheckResult = checkForForcedToolUsage(
currentResponse, currentResponse,
@@ -682,10 +756,14 @@ export async function executeAnthropicProviderRequest(
tool_choice: undefined, tool_choice: undefined,
} }
const streamResponse: any = await anthropic.messages.create(streamingPayload) const streamResponse = await anthropic.messages.create(
streamingPayload as Anthropic.Messages.MessageCreateParamsStreaming
)
const streamingResult = { const streamingResult = {
stream: createReadableStreamFromAnthropicStream(streamResponse, (streamContent, usage) => { stream: createReadableStreamFromAnthropicStream(
streamResponse as AsyncIterable<RawMessageStreamEvent>,
(streamContent, usage) => {
streamingResult.execution.output.content = streamContent streamingResult.execution.output.content = streamContent
streamingResult.execution.output.tokens = { streamingResult.execution.output.tokens = {
input: tokens.input + usage.input_tokens, input: tokens.input + usage.input_tokens,
@@ -708,7 +786,8 @@ export async function executeAnthropicProviderRequest(
streamingResult.execution.output.providerTiming.duration = streamingResult.execution.output.providerTiming.duration =
streamEndTime - providerStartTime streamEndTime - providerStartTime
} }
}), }
),
execution: { execution: {
success: true, success: true,
output: { output: {
@@ -778,21 +857,13 @@ export async function executeAnthropicProviderRequest(
const providerStartTime = Date.now() const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString() const providerStartTimeISO = new Date(providerStartTime).toISOString()
// Cap intermediate calls at non-streaming limit to avoid SDK timeout errors,
// but allow users to set lower values if desired
const nonStreamingLimit = getMaxOutputTokensForModel(request.model, false)
const toolLoopMaxTokens = request.maxTokens
? Math.min(Number.parseInt(String(request.maxTokens)), nonStreamingLimit)
: nonStreamingLimit
const toolLoopPayload = { ...payload, max_tokens: toolLoopMaxTokens }
try { try {
const initialCallTime = Date.now() const initialCallTime = Date.now()
const originalToolChoice = toolLoopPayload.tool_choice const originalToolChoice = payload.tool_choice
const forcedTools = preparedTools?.forcedTools || [] const forcedTools = preparedTools?.forcedTools || []
let usedForcedTools: string[] = [] let usedForcedTools: string[] = []
let currentResponse = await anthropic.messages.create(toolLoopPayload) let currentResponse = await createMessage(anthropic, payload)
const firstResponseTime = Date.now() - initialCallTime const firstResponseTime = Date.now() - initialCallTime
let content = '' let content = ''
@@ -872,7 +943,7 @@ export async function executeAnthropicProviderRequest(
const toolExecutionPromises = toolUses.map(async (toolUse) => { const toolExecutionPromises = toolUses.map(async (toolUse) => {
const toolCallStartTime = Date.now() const toolCallStartTime = Date.now()
const toolName = toolUse.name const toolName = toolUse.name
const toolArgs = toolUse.input as Record<string, any> const toolArgs = toolUse.input as Record<string, unknown>
// Preserve the original tool_use ID from Claude's response // Preserve the original tool_use ID from Claude's response
const toolUseId = toolUse.id const toolUseId = toolUse.id
@@ -918,17 +989,8 @@ export async function executeAnthropicProviderRequest(
const executionResults = await Promise.allSettled(toolExecutionPromises) const executionResults = await Promise.allSettled(toolExecutionPromises)
// Collect all tool_use and tool_result blocks for batching // Collect all tool_use and tool_result blocks for batching
const toolUseBlocks: Array<{ const toolUseBlocks: Anthropic.Messages.ToolUseBlockParam[] = []
type: 'tool_use' const toolResultBlocks: Anthropic.Messages.ToolResultBlockParam[] = []
id: string
name: string
input: Record<string, unknown>
}> = []
const toolResultBlocks: Array<{
type: 'tool_result'
tool_use_id: string
content: string
}> = []
for (const settledResult of executionResults) { for (const settledResult of executionResults) {
if (settledResult.status === 'rejected' || !settledResult.value) continue if (settledResult.status === 'rejected' || !settledResult.value) continue
@@ -989,11 +1051,23 @@ export async function executeAnthropicProviderRequest(
}) })
} }
// Add ONE assistant message with ALL tool_use blocks // Per Anthropic docs: thinking blocks must be preserved in assistant messages
// during tool use to maintain reasoning continuity.
const thinkingBlocks = currentResponse.content.filter(
(
item
): item is Anthropic.Messages.ThinkingBlock | Anthropic.Messages.RedactedThinkingBlock =>
item.type === 'thinking' || item.type === 'redacted_thinking'
)
// Add ONE assistant message with thinking + tool_use blocks
if (toolUseBlocks.length > 0) { if (toolUseBlocks.length > 0) {
currentMessages.push({ currentMessages.push({
role: 'assistant', role: 'assistant',
content: toolUseBlocks as unknown as Anthropic.Messages.ContentBlock[], content: [
...thinkingBlocks,
...toolUseBlocks,
] as Anthropic.Messages.ContentBlockParam[],
}) })
} }
@@ -1001,19 +1075,27 @@ export async function executeAnthropicProviderRequest(
if (toolResultBlocks.length > 0) { if (toolResultBlocks.length > 0) {
currentMessages.push({ currentMessages.push({
role: 'user', role: 'user',
content: toolResultBlocks as unknown as Anthropic.Messages.ContentBlockParam[], content: toolResultBlocks as Anthropic.Messages.ContentBlockParam[],
}) })
} }
const thisToolsTime = Date.now() - toolsStartTime const thisToolsTime = Date.now() - toolsStartTime
toolsTime += thisToolsTime toolsTime += thisToolsTime
const nextPayload = { const nextPayload: AnthropicPayload = {
...toolLoopPayload, ...payload,
messages: currentMessages, messages: currentMessages,
} }
if (typeof originalToolChoice === 'object' && hasUsedForcedTool && forcedTools.length > 0) { // Per Anthropic docs: forced tool_choice is incompatible with thinking.
// Only auto and none are supported when thinking is enabled.
const thinkingEnabled = !!payload.thinking
if (
!thinkingEnabled &&
typeof originalToolChoice === 'object' &&
hasUsedForcedTool &&
forcedTools.length > 0
) {
const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool))
if (remainingTools.length > 0) { if (remainingTools.length > 0) {
@@ -1026,7 +1108,11 @@ export async function executeAnthropicProviderRequest(
nextPayload.tool_choice = undefined nextPayload.tool_choice = undefined
logger.info('All forced tools have been used, removing tool_choice parameter') logger.info('All forced tools have been used, removing tool_choice parameter')
} }
} else if (hasUsedForcedTool && typeof originalToolChoice === 'object') { } else if (
!thinkingEnabled &&
hasUsedForcedTool &&
typeof originalToolChoice === 'object'
) {
nextPayload.tool_choice = undefined nextPayload.tool_choice = undefined
logger.info( logger.info(
'Removing tool_choice parameter for subsequent requests after forced tool was used' 'Removing tool_choice parameter for subsequent requests after forced tool was used'
@@ -1035,7 +1121,7 @@ export async function executeAnthropicProviderRequest(
const nextModelStartTime = Date.now() const nextModelStartTime = Date.now()
currentResponse = await anthropic.messages.create(nextPayload) currentResponse = await createMessage(anthropic, nextPayload)
const nextCheckResult = checkForForcedToolUsage( const nextCheckResult = checkForForcedToolUsage(
currentResponse, currentResponse,
@@ -1098,10 +1184,14 @@ export async function executeAnthropicProviderRequest(
tool_choice: undefined, tool_choice: undefined,
} }
const streamResponse: any = await anthropic.messages.create(streamingPayload) const streamResponse = await anthropic.messages.create(
streamingPayload as Anthropic.Messages.MessageCreateParamsStreaming
)
const streamingResult = { const streamingResult = {
stream: createReadableStreamFromAnthropicStream(streamResponse, (streamContent, usage) => { stream: createReadableStreamFromAnthropicStream(
streamResponse as AsyncIterable<RawMessageStreamEvent>,
(streamContent, usage) => {
streamingResult.execution.output.content = streamContent streamingResult.execution.output.content = streamContent
streamingResult.execution.output.tokens = { streamingResult.execution.output.tokens = {
input: tokens.input + usage.input_tokens, input: tokens.input + usage.input_tokens,
@@ -1124,7 +1214,8 @@ export async function executeAnthropicProviderRequest(
streamingResult.execution.output.providerTiming.duration = streamingResult.execution.output.providerTiming.duration =
streamEndTime - providerStartTime streamEndTime - providerStartTime
} }
}), }
),
execution: { execution: {
success: true, success: true,
output: { output: {
@@ -1179,7 +1270,7 @@ export async function executeAnthropicProviderRequest(
toolCalls.length > 0 toolCalls.length > 0
? toolCalls.map((tc) => ({ ? toolCalls.map((tc) => ({
name: tc.name, name: tc.name,
arguments: tc.arguments as Record<string, any>, arguments: tc.arguments as Record<string, unknown>,
startTime: tc.startTime, startTime: tc.startTime,
endTime: tc.endTime, endTime: tc.endTime,
duration: tc.duration, duration: tc.duration,

View File

@@ -1,6 +1,14 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { AzureOpenAI } from 'openai' import { AzureOpenAI } from 'openai'
import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' import type {
ChatCompletion,
ChatCompletionCreateParamsBase,
ChatCompletionCreateParamsStreaming,
ChatCompletionMessageParam,
ChatCompletionTool,
ChatCompletionToolChoiceOption,
} from 'openai/resources/chat/completions'
import type { ReasoningEffort } from 'openai/resources/shared'
import { env } from '@/lib/core/config/env' import { env } from '@/lib/core/config/env'
import type { StreamingExecution } from '@/executor/types' import type { StreamingExecution } from '@/executor/types'
import { MAX_TOOL_ITERATIONS } from '@/providers' import { MAX_TOOL_ITERATIONS } from '@/providers'
@@ -16,6 +24,7 @@ import {
import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { getProviderDefaultModel, getProviderModels } from '@/providers/models'
import { executeResponsesProviderRequest } from '@/providers/openai/core' import { executeResponsesProviderRequest } from '@/providers/openai/core'
import type { import type {
FunctionCallResponse,
ProviderConfig, ProviderConfig,
ProviderRequest, ProviderRequest,
ProviderResponse, ProviderResponse,
@@ -59,7 +68,7 @@ async function executeChatCompletionsRequest(
endpoint: azureEndpoint, endpoint: azureEndpoint,
}) })
const allMessages: any[] = [] const allMessages: ChatCompletionMessageParam[] = []
if (request.systemPrompt) { if (request.systemPrompt) {
allMessages.push({ allMessages.push({
@@ -76,12 +85,12 @@ async function executeChatCompletionsRequest(
} }
if (request.messages) { if (request.messages) {
allMessages.push(...request.messages) allMessages.push(...(request.messages as ChatCompletionMessageParam[]))
} }
const tools = request.tools?.length const tools: ChatCompletionTool[] | undefined = request.tools?.length
? request.tools.map((tool) => ({ ? request.tools.map((tool) => ({
type: 'function', type: 'function' as const,
function: { function: {
name: tool.id, name: tool.id,
description: tool.description, description: tool.description,
@@ -90,7 +99,7 @@ async function executeChatCompletionsRequest(
})) }))
: undefined : undefined
const payload: any = { const payload: ChatCompletionCreateParamsBase & { verbosity?: string } = {
model: deploymentName, model: deploymentName,
messages: allMessages, messages: allMessages,
} }
@@ -98,8 +107,10 @@ async function executeChatCompletionsRequest(
if (request.temperature !== undefined) payload.temperature = request.temperature if (request.temperature !== undefined) payload.temperature = request.temperature
if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens
if (request.reasoningEffort !== undefined) payload.reasoning_effort = request.reasoningEffort if (request.reasoningEffort !== undefined && request.reasoningEffort !== 'auto')
if (request.verbosity !== undefined) payload.verbosity = request.verbosity payload.reasoning_effort = request.reasoningEffort as ReasoningEffort
if (request.verbosity !== undefined && request.verbosity !== 'auto')
payload.verbosity = request.verbosity
if (request.responseFormat) { if (request.responseFormat) {
payload.response_format = { payload.response_format = {
@@ -121,8 +132,8 @@ async function executeChatCompletionsRequest(
const { tools: filteredTools, toolChoice } = preparedTools const { tools: filteredTools, toolChoice } = preparedTools
if (filteredTools?.length && toolChoice) { if (filteredTools?.length && toolChoice) {
payload.tools = filteredTools payload.tools = filteredTools as ChatCompletionTool[]
payload.tool_choice = toolChoice payload.tool_choice = toolChoice as ChatCompletionToolChoiceOption
logger.info('Azure OpenAI request configuration:', { logger.info('Azure OpenAI request configuration:', {
toolCount: filteredTools.length, toolCount: filteredTools.length,
@@ -231,7 +242,7 @@ async function executeChatCompletionsRequest(
const forcedTools = preparedTools?.forcedTools || [] const forcedTools = preparedTools?.forcedTools || []
let usedForcedTools: string[] = [] let usedForcedTools: string[] = []
let currentResponse = await azureOpenAI.chat.completions.create(payload) let currentResponse = (await azureOpenAI.chat.completions.create(payload)) as ChatCompletion
const firstResponseTime = Date.now() - initialCallTime const firstResponseTime = Date.now() - initialCallTime
let content = currentResponse.choices[0]?.message?.content || '' let content = currentResponse.choices[0]?.message?.content || ''
@@ -240,8 +251,8 @@ async function executeChatCompletionsRequest(
output: currentResponse.usage?.completion_tokens || 0, output: currentResponse.usage?.completion_tokens || 0,
total: currentResponse.usage?.total_tokens || 0, total: currentResponse.usage?.total_tokens || 0,
} }
const toolCalls = [] const toolCalls: (FunctionCallResponse & { success: boolean })[] = []
const toolResults = [] const toolResults: Record<string, unknown>[] = []
const currentMessages = [...allMessages] const currentMessages = [...allMessages]
let iterationCount = 0 let iterationCount = 0
let modelTime = firstResponseTime let modelTime = firstResponseTime
@@ -260,7 +271,7 @@ async function executeChatCompletionsRequest(
const firstCheckResult = checkForForcedToolUsage( const firstCheckResult = checkForForcedToolUsage(
currentResponse, currentResponse,
originalToolChoice, originalToolChoice ?? 'auto',
logger, logger,
forcedTools, forcedTools,
usedForcedTools usedForcedTools
@@ -356,10 +367,10 @@ async function executeChatCompletionsRequest(
duration: duration, duration: duration,
}) })
let resultContent: any let resultContent: Record<string, unknown>
if (result.success) { if (result.success) {
toolResults.push(result.output) toolResults.push(result.output as Record<string, unknown>)
resultContent = result.output resultContent = result.output as Record<string, unknown>
} else { } else {
resultContent = { resultContent = {
error: true, error: true,
@@ -409,11 +420,11 @@ async function executeChatCompletionsRequest(
} }
const nextModelStartTime = Date.now() const nextModelStartTime = Date.now()
currentResponse = await azureOpenAI.chat.completions.create(nextPayload) currentResponse = (await azureOpenAI.chat.completions.create(nextPayload)) as ChatCompletion
const nextCheckResult = checkForForcedToolUsage( const nextCheckResult = checkForForcedToolUsage(
currentResponse, currentResponse,
nextPayload.tool_choice, nextPayload.tool_choice ?? 'auto',
logger, logger,
forcedTools, forcedTools,
usedForcedTools usedForcedTools

View File

@@ -1,4 +1,5 @@
import type { Logger } from '@sim/logger' import type { Logger } from '@sim/logger'
import type OpenAI from 'openai'
import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
import type { CompletionUsage } from 'openai/resources/completions' import type { CompletionUsage } from 'openai/resources/completions'
import type { Stream } from 'openai/streaming' import type { Stream } from 'openai/streaming'
@@ -20,8 +21,8 @@ export function createReadableStreamFromAzureOpenAIStream(
* Uses the shared OpenAI-compatible forced tool usage helper. * Uses the shared OpenAI-compatible forced tool usage helper.
*/ */
export function checkForForcedToolUsage( export function checkForForcedToolUsage(
response: any, response: OpenAI.Chat.Completions.ChatCompletion,
toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any }, toolChoice: string | { type: string; function?: { name: string }; name?: string },
_logger: Logger, _logger: Logger,
forcedTools: string[], forcedTools: string[],
usedForcedTools: string[] usedForcedTools: string[]

View File

@@ -197,6 +197,9 @@ export const bedrockProvider: ProviderConfig = {
} else if (tc.type === 'function' && tc.function?.name) { } else if (tc.type === 'function' && tc.function?.name) {
toolChoice = { tool: { name: tc.function.name } } toolChoice = { tool: { name: tc.function.name } }
logger.info(`Using Bedrock tool_choice format: force tool "${tc.function.name}"`) logger.info(`Using Bedrock tool_choice format: force tool "${tc.function.name}"`)
} else if (tc.type === 'any') {
toolChoice = { any: {} }
logger.info('Using Bedrock tool_choice format: any tool')
} else { } else {
toolChoice = { auto: {} } toolChoice = { auto: {} }
} }
@@ -413,6 +416,7 @@ export const bedrockProvider: ProviderConfig = {
input: initialCost.input, input: initialCost.input,
output: initialCost.output, output: initialCost.output,
total: initialCost.total, total: initialCost.total,
pricing: initialCost.pricing,
} }
const toolCalls: any[] = [] const toolCalls: any[] = []
@@ -860,6 +864,12 @@ export const bedrockProvider: ProviderConfig = {
content, content,
model: request.model, model: request.model,
tokens, tokens,
cost: {
input: cost.input,
output: cost.output,
total: cost.total,
pricing: cost.pricing,
},
toolCalls: toolCalls:
toolCalls.length > 0 toolCalls.length > 0
? toolCalls.map((tc) => ({ ? toolCalls.map((tc) => ({

View File

@@ -24,7 +24,6 @@ import {
extractTextContent, extractTextContent,
mapToThinkingLevel, mapToThinkingLevel,
} from '@/providers/google/utils' } from '@/providers/google/utils'
import { getThinkingCapability } from '@/providers/models'
import type { FunctionCallResponse, ProviderRequest, ProviderResponse } from '@/providers/types' import type { FunctionCallResponse, ProviderRequest, ProviderResponse } from '@/providers/types'
import { import {
calculateCost, calculateCost,
@@ -432,13 +431,11 @@ export async function executeGeminiRequest(
logger.warn('Gemini does not support responseFormat with tools. Structured output ignored.') logger.warn('Gemini does not support responseFormat with tools. Structured output ignored.')
} }
// Configure thinking for models that support it // Configure thinking only when the user explicitly selects a thinking level
const thinkingCapability = getThinkingCapability(model) if (request.thinkingLevel && request.thinkingLevel !== 'none') {
if (thinkingCapability) {
const level = request.thinkingLevel ?? thinkingCapability.default ?? 'high'
const thinkingConfig: ThinkingConfig = { const thinkingConfig: ThinkingConfig = {
includeThoughts: false, includeThoughts: false,
thinkingLevel: mapToThinkingLevel(level), thinkingLevel: mapToThinkingLevel(request.thinkingLevel),
} }
geminiConfig.thinkingConfig = thinkingConfig geminiConfig.thinkingConfig = thinkingConfig
} }

View File

@@ -141,7 +141,6 @@ export const mistralProvider: ProviderConfig = {
const streamingParams: ChatCompletionCreateParamsStreaming = { const streamingParams: ChatCompletionCreateParamsStreaming = {
...payload, ...payload,
stream: true, stream: true,
stream_options: { include_usage: true },
} }
const streamResponse = await mistral.chat.completions.create(streamingParams) const streamResponse = await mistral.chat.completions.create(streamingParams)
@@ -453,7 +452,6 @@ export const mistralProvider: ProviderConfig = {
messages: currentMessages, messages: currentMessages,
tool_choice: 'auto', tool_choice: 'auto',
stream: true, stream: true,
stream_options: { include_usage: true },
} }
const streamResponse = await mistral.chat.completions.create(streamingParams) const streamResponse = await mistral.chat.completions.create(streamingParams)

View File

@@ -34,17 +34,8 @@ export interface ModelCapabilities {
toolUsageControl?: boolean toolUsageControl?: boolean
computerUse?: boolean computerUse?: boolean
nativeStructuredOutputs?: boolean nativeStructuredOutputs?: boolean
/** /** Maximum supported output tokens for this model */
* Max output tokens configuration for Anthropic SDK's streaming timeout workaround. maxOutputTokens?: number
* The Anthropic SDK throws an error for non-streaming requests that may take >10 minutes.
* This only applies to direct Anthropic API calls, not Bedrock (which uses AWS SDK).
*/
maxOutputTokens?: {
/** Maximum tokens for streaming requests */
max: number
/** Safe default for non-streaming requests (to avoid Anthropic SDK timeout errors) */
default: number
}
reasoningEffort?: { reasoningEffort?: {
values: string[] values: string[]
} }
@@ -109,7 +100,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
name: 'OpenAI', name: 'OpenAI',
description: "OpenAI's models", description: "OpenAI's models",
defaultModel: 'gpt-4o', defaultModel: 'gpt-4o',
modelPatterns: [/^gpt/, /^o1/, /^text-embedding/], modelPatterns: [/^gpt/, /^o\d/, /^text-embedding/],
icon: OpenAIIcon, icon: OpenAIIcon,
capabilities: { capabilities: {
toolUsageControl: true, toolUsageControl: true,
@@ -138,7 +129,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
}, },
capabilities: { capabilities: {
reasoningEffort: { reasoningEffort: {
values: ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'], values: ['none', 'low', 'medium', 'high', 'xhigh'],
}, },
verbosity: { verbosity: {
values: ['low', 'medium', 'high'], values: ['low', 'medium', 'high'],
@@ -164,60 +155,6 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
}, },
contextWindow: 400000, contextWindow: 400000,
}, },
// {
// id: 'gpt-5.1-mini',
// pricing: {
// input: 0.25,
// cachedInput: 0.025,
// output: 2.0,
// updatedAt: '2025-11-14',
// },
// capabilities: {
// reasoningEffort: {
// values: ['none', 'low', 'medium', 'high'],
// },
// verbosity: {
// values: ['low', 'medium', 'high'],
// },
// },
// contextWindow: 400000,
// },
// {
// id: 'gpt-5.1-nano',
// pricing: {
// input: 0.05,
// cachedInput: 0.005,
// output: 0.4,
// updatedAt: '2025-11-14',
// },
// capabilities: {
// reasoningEffort: {
// values: ['none', 'low', 'medium', 'high'],
// },
// verbosity: {
// values: ['low', 'medium', 'high'],
// },
// },
// contextWindow: 400000,
// },
// {
// id: 'gpt-5.1-codex',
// pricing: {
// input: 1.25,
// cachedInput: 0.125,
// output: 10.0,
// updatedAt: '2025-11-14',
// },
// capabilities: {
// reasoningEffort: {
// values: ['none', 'medium', 'high'],
// },
// verbosity: {
// values: ['low', 'medium', 'high'],
// },
// },
// contextWindow: 400000,
// },
{ {
id: 'gpt-5', id: 'gpt-5',
pricing: { pricing: {
@@ -280,8 +217,10 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
output: 10.0, output: 10.0,
updatedAt: '2025-08-07', updatedAt: '2025-08-07',
}, },
capabilities: {}, capabilities: {
contextWindow: 400000, temperature: { min: 0, max: 2 },
},
contextWindow: 128000,
}, },
{ {
id: 'o1', id: 'o1',
@@ -311,7 +250,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
values: ['low', 'medium', 'high'], values: ['low', 'medium', 'high'],
}, },
}, },
contextWindow: 128000, contextWindow: 200000,
}, },
{ {
id: 'o4-mini', id: 'o4-mini',
@@ -326,7 +265,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
values: ['low', 'medium', 'high'], values: ['low', 'medium', 'high'],
}, },
}, },
contextWindow: 128000, contextWindow: 200000,
}, },
{ {
id: 'gpt-4.1', id: 'gpt-4.1',
@@ -391,7 +330,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: { capabilities: {
temperature: { min: 0, max: 1 }, temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true, nativeStructuredOutputs: true,
maxOutputTokens: { max: 128000, default: 8192 }, maxOutputTokens: 128000,
thinking: { thinking: {
levels: ['low', 'medium', 'high', 'max'], levels: ['low', 'medium', 'high', 'max'],
default: 'high', default: 'high',
@@ -410,10 +349,10 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: { capabilities: {
temperature: { min: 0, max: 1 }, temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true, nativeStructuredOutputs: true,
maxOutputTokens: { max: 64000, default: 8192 }, maxOutputTokens: 64000,
thinking: { thinking: {
levels: ['low', 'medium', 'high'], levels: ['low', 'medium', 'high'],
default: 'medium', default: 'high',
}, },
}, },
contextWindow: 200000, contextWindow: 200000,
@@ -429,10 +368,10 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: { capabilities: {
temperature: { min: 0, max: 1 }, temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true, nativeStructuredOutputs: true,
maxOutputTokens: { max: 64000, default: 8192 }, maxOutputTokens: 64000,
thinking: { thinking: {
levels: ['low', 'medium', 'high'], levels: ['low', 'medium', 'high'],
default: 'medium', default: 'high',
}, },
}, },
contextWindow: 200000, contextWindow: 200000,
@@ -447,10 +386,10 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
}, },
capabilities: { capabilities: {
temperature: { min: 0, max: 1 }, temperature: { min: 0, max: 1 },
maxOutputTokens: { max: 64000, default: 8192 }, maxOutputTokens: 64000,
thinking: { thinking: {
levels: ['low', 'medium', 'high'], levels: ['low', 'medium', 'high'],
default: 'medium', default: 'high',
}, },
}, },
contextWindow: 200000, contextWindow: 200000,
@@ -466,10 +405,10 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: { capabilities: {
temperature: { min: 0, max: 1 }, temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true, nativeStructuredOutputs: true,
maxOutputTokens: { max: 64000, default: 8192 }, maxOutputTokens: 64000,
thinking: { thinking: {
levels: ['low', 'medium', 'high'], levels: ['low', 'medium', 'high'],
default: 'medium', default: 'high',
}, },
}, },
contextWindow: 200000, contextWindow: 200000,
@@ -484,10 +423,10 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
}, },
capabilities: { capabilities: {
temperature: { min: 0, max: 1 }, temperature: { min: 0, max: 1 },
maxOutputTokens: { max: 64000, default: 8192 }, maxOutputTokens: 64000,
thinking: { thinking: {
levels: ['low', 'medium', 'high'], levels: ['low', 'medium', 'high'],
default: 'medium', default: 'high',
}, },
}, },
contextWindow: 200000, contextWindow: 200000,
@@ -503,10 +442,10 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: { capabilities: {
temperature: { min: 0, max: 1 }, temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true, nativeStructuredOutputs: true,
maxOutputTokens: { max: 64000, default: 8192 }, maxOutputTokens: 64000,
thinking: { thinking: {
levels: ['low', 'medium', 'high'], levels: ['low', 'medium', 'high'],
default: 'medium', default: 'high',
}, },
}, },
contextWindow: 200000, contextWindow: 200000,
@@ -515,13 +454,13 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
id: 'claude-3-haiku-20240307', id: 'claude-3-haiku-20240307',
pricing: { pricing: {
input: 0.25, input: 0.25,
cachedInput: 0.025, cachedInput: 0.03,
output: 1.25, output: 1.25,
updatedAt: '2026-02-05', updatedAt: '2026-02-05',
}, },
capabilities: { capabilities: {
temperature: { min: 0, max: 1 }, temperature: { min: 0, max: 1 },
maxOutputTokens: { max: 4096, default: 4096 }, maxOutputTokens: 4096,
}, },
contextWindow: 200000, contextWindow: 200000,
}, },
@@ -536,10 +475,10 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: { capabilities: {
temperature: { min: 0, max: 1 }, temperature: { min: 0, max: 1 },
computerUse: true, computerUse: true,
maxOutputTokens: { max: 8192, default: 8192 }, maxOutputTokens: 64000,
thinking: { thinking: {
levels: ['low', 'medium', 'high'], levels: ['low', 'medium', 'high'],
default: 'medium', default: 'high',
}, },
}, },
contextWindow: 200000, contextWindow: 200000,
@@ -580,7 +519,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
}, },
capabilities: { capabilities: {
reasoningEffort: { reasoningEffort: {
values: ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'], values: ['none', 'low', 'medium', 'high', 'xhigh'],
}, },
verbosity: { verbosity: {
values: ['low', 'medium', 'high'], values: ['low', 'medium', 'high'],
@@ -606,42 +545,6 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
}, },
contextWindow: 400000, contextWindow: 400000,
}, },
{
id: 'azure/gpt-5.1-mini',
pricing: {
input: 0.25,
cachedInput: 0.025,
output: 2.0,
updatedAt: '2025-11-14',
},
capabilities: {
reasoningEffort: {
values: ['none', 'low', 'medium', 'high'],
},
verbosity: {
values: ['low', 'medium', 'high'],
},
},
contextWindow: 400000,
},
{
id: 'azure/gpt-5.1-nano',
pricing: {
input: 0.05,
cachedInput: 0.005,
output: 0.4,
updatedAt: '2025-11-14',
},
capabilities: {
reasoningEffort: {
values: ['none', 'low', 'medium', 'high'],
},
verbosity: {
values: ['low', 'medium', 'high'],
},
},
contextWindow: 400000,
},
{ {
id: 'azure/gpt-5.1-codex', id: 'azure/gpt-5.1-codex',
pricing: { pricing: {
@@ -652,7 +555,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
}, },
capabilities: { capabilities: {
reasoningEffort: { reasoningEffort: {
values: ['none', 'medium', 'high'], values: ['none', 'low', 'medium', 'high'],
}, },
verbosity: { verbosity: {
values: ['low', 'medium', 'high'], values: ['low', 'medium', 'high'],
@@ -722,23 +625,25 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
output: 10.0, output: 10.0,
updatedAt: '2025-08-07', updatedAt: '2025-08-07',
}, },
capabilities: {}, capabilities: {
contextWindow: 400000, temperature: { min: 0, max: 2 },
},
contextWindow: 128000,
}, },
{ {
id: 'azure/o3', id: 'azure/o3',
pricing: { pricing: {
input: 10, input: 2,
cachedInput: 2.5, cachedInput: 0.5,
output: 40, output: 8,
updatedAt: '2025-06-15', updatedAt: '2026-02-06',
}, },
capabilities: { capabilities: {
reasoningEffort: { reasoningEffort: {
values: ['low', 'medium', 'high'], values: ['low', 'medium', 'high'],
}, },
}, },
contextWindow: 128000, contextWindow: 200000,
}, },
{ {
id: 'azure/o4-mini', id: 'azure/o4-mini',
@@ -753,7 +658,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
values: ['low', 'medium', 'high'], values: ['low', 'medium', 'high'],
}, },
}, },
contextWindow: 128000, contextWindow: 200000,
}, },
{ {
id: 'azure/gpt-4.1', id: 'azure/gpt-4.1',
@@ -763,7 +668,35 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
output: 8.0, output: 8.0,
updatedAt: '2025-06-15', updatedAt: '2025-06-15',
}, },
capabilities: {}, capabilities: {
temperature: { min: 0, max: 2 },
},
contextWindow: 1000000,
},
{
id: 'azure/gpt-4.1-mini',
pricing: {
input: 0.4,
cachedInput: 0.1,
output: 1.6,
updatedAt: '2025-06-15',
},
capabilities: {
temperature: { min: 0, max: 2 },
},
contextWindow: 1000000,
},
{
id: 'azure/gpt-4.1-nano',
pricing: {
input: 0.1,
cachedInput: 0.025,
output: 0.4,
updatedAt: '2025-06-15',
},
capabilities: {
temperature: { min: 0, max: 2 },
},
contextWindow: 1000000, contextWindow: 1000000,
}, },
{ {
@@ -775,7 +708,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-06-15', updatedAt: '2025-06-15',
}, },
capabilities: {}, capabilities: {},
contextWindow: 1000000, contextWindow: 200000,
}, },
], ],
}, },
@@ -801,7 +734,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: { capabilities: {
temperature: { min: 0, max: 1 }, temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true, nativeStructuredOutputs: true,
maxOutputTokens: { max: 128000, default: 8192 }, maxOutputTokens: 128000,
thinking: { thinking: {
levels: ['low', 'medium', 'high', 'max'], levels: ['low', 'medium', 'high', 'max'],
default: 'high', default: 'high',
@@ -820,10 +753,10 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: { capabilities: {
temperature: { min: 0, max: 1 }, temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true, nativeStructuredOutputs: true,
maxOutputTokens: { max: 64000, default: 8192 }, maxOutputTokens: 64000,
thinking: { thinking: {
levels: ['low', 'medium', 'high'], levels: ['low', 'medium', 'high'],
default: 'medium', default: 'high',
}, },
}, },
contextWindow: 200000, contextWindow: 200000,
@@ -839,10 +772,10 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: { capabilities: {
temperature: { min: 0, max: 1 }, temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true, nativeStructuredOutputs: true,
maxOutputTokens: { max: 64000, default: 8192 }, maxOutputTokens: 64000,
thinking: { thinking: {
levels: ['low', 'medium', 'high'], levels: ['low', 'medium', 'high'],
default: 'medium', default: 'high',
}, },
}, },
contextWindow: 200000, contextWindow: 200000,
@@ -858,10 +791,10 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: { capabilities: {
temperature: { min: 0, max: 1 }, temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true, nativeStructuredOutputs: true,
maxOutputTokens: { max: 64000, default: 8192 }, maxOutputTokens: 64000,
thinking: { thinking: {
levels: ['low', 'medium', 'high'], levels: ['low', 'medium', 'high'],
default: 'medium', default: 'high',
}, },
}, },
contextWindow: 200000, contextWindow: 200000,
@@ -877,10 +810,10 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: { capabilities: {
temperature: { min: 0, max: 1 }, temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true, nativeStructuredOutputs: true,
maxOutputTokens: { max: 64000, default: 8192 }, maxOutputTokens: 64000,
thinking: { thinking: {
levels: ['low', 'medium', 'high'], levels: ['low', 'medium', 'high'],
default: 'medium', default: 'high',
}, },
}, },
contextWindow: 200000, contextWindow: 200000,
@@ -2548,14 +2481,11 @@ export function getThinkingLevelsForModel(modelId: string): string[] | null {
} }
/** /**
* Get the max output tokens for a specific model * Get the max output tokens for a specific model.
* Returns the model's max capacity for streaming requests,
* or the model's safe default for non-streaming requests to avoid timeout issues.
* *
* @param modelId - The model ID * @param modelId - The model ID
* @param streaming - Whether the request is streaming (default: false)
*/ */
export function getMaxOutputTokensForModel(modelId: string, streaming = false): number { export function getMaxOutputTokensForModel(modelId: string): number {
const normalizedModelId = modelId.toLowerCase() const normalizedModelId = modelId.toLowerCase()
const STANDARD_MAX_OUTPUT_TOKENS = 4096 const STANDARD_MAX_OUTPUT_TOKENS = 4096
@@ -2563,11 +2493,7 @@ export function getMaxOutputTokensForModel(modelId: string, streaming = false):
for (const model of provider.models) { for (const model of provider.models) {
const baseModelId = model.id.toLowerCase() const baseModelId = model.id.toLowerCase()
if (normalizedModelId === baseModelId || normalizedModelId.startsWith(`${baseModelId}-`)) { if (normalizedModelId === baseModelId || normalizedModelId.startsWith(`${baseModelId}-`)) {
const outputTokens = model.capabilities.maxOutputTokens return model.capabilities.maxOutputTokens || STANDARD_MAX_OUTPUT_TOKENS
if (outputTokens) {
return streaming ? outputTokens.max : outputTokens.default
}
return STANDARD_MAX_OUTPUT_TOKENS
} }
} }
} }

View File

@@ -1,4 +1,5 @@
import type { Logger } from '@sim/logger' import type { Logger } from '@sim/logger'
import type OpenAI from 'openai'
import type { StreamingExecution } from '@/executor/types' import type { StreamingExecution } from '@/executor/types'
import { MAX_TOOL_ITERATIONS } from '@/providers' import { MAX_TOOL_ITERATIONS } from '@/providers'
import type { Message, ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types' import type { Message, ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types'
@@ -30,7 +31,7 @@ type ToolChoice = PreparedTools['toolChoice']
* - Sets additionalProperties: false on all object types. * - Sets additionalProperties: false on all object types.
* - Ensures required includes ALL property keys. * - Ensures required includes ALL property keys.
*/ */
function enforceStrictSchema(schema: any): any { function enforceStrictSchema(schema: Record<string, unknown>): Record<string, unknown> {
if (!schema || typeof schema !== 'object') return schema if (!schema || typeof schema !== 'object') return schema
const result = { ...schema } const result = { ...schema }
@@ -41,23 +42,26 @@ function enforceStrictSchema(schema: any): any {
// Recursively process properties and ensure required includes all keys // Recursively process properties and ensure required includes all keys
if (result.properties && typeof result.properties === 'object') { if (result.properties && typeof result.properties === 'object') {
const propKeys = Object.keys(result.properties) const propKeys = Object.keys(result.properties as Record<string, unknown>)
result.required = propKeys // Strict mode requires ALL properties result.required = propKeys // Strict mode requires ALL properties
result.properties = Object.fromEntries( result.properties = Object.fromEntries(
Object.entries(result.properties).map(([key, value]) => [key, enforceStrictSchema(value)]) Object.entries(result.properties as Record<string, unknown>).map(([key, value]) => [
key,
enforceStrictSchema(value as Record<string, unknown>),
])
) )
} }
} }
// Handle array items // Handle array items
if (result.type === 'array' && result.items) { if (result.type === 'array' && result.items) {
result.items = enforceStrictSchema(result.items) result.items = enforceStrictSchema(result.items as Record<string, unknown>)
} }
// Handle anyOf, oneOf, allOf // Handle anyOf, oneOf, allOf
for (const keyword of ['anyOf', 'oneOf', 'allOf']) { for (const keyword of ['anyOf', 'oneOf', 'allOf']) {
if (Array.isArray(result[keyword])) { if (Array.isArray(result[keyword])) {
result[keyword] = result[keyword].map(enforceStrictSchema) result[keyword] = (result[keyword] as Record<string, unknown>[]).map(enforceStrictSchema)
} }
} }
@@ -65,7 +69,10 @@ function enforceStrictSchema(schema: any): any {
for (const defKey of ['$defs', 'definitions']) { for (const defKey of ['$defs', 'definitions']) {
if (result[defKey] && typeof result[defKey] === 'object') { if (result[defKey] && typeof result[defKey] === 'object') {
result[defKey] = Object.fromEntries( result[defKey] = Object.fromEntries(
Object.entries(result[defKey]).map(([key, value]) => [key, enforceStrictSchema(value)]) Object.entries(result[defKey] as Record<string, unknown>).map(([key, value]) => [
key,
enforceStrictSchema(value as Record<string, unknown>),
])
) )
} }
} }
@@ -123,29 +130,29 @@ export async function executeResponsesProviderRequest(
const initialInput = buildResponsesInputFromMessages(allMessages) const initialInput = buildResponsesInputFromMessages(allMessages)
const basePayload: Record<string, any> = { const basePayload: Record<string, unknown> = {
model: config.modelName, model: config.modelName,
} }
if (request.temperature !== undefined) basePayload.temperature = request.temperature if (request.temperature !== undefined) basePayload.temperature = request.temperature
if (request.maxTokens != null) basePayload.max_output_tokens = request.maxTokens if (request.maxTokens != null) basePayload.max_output_tokens = request.maxTokens
if (request.reasoningEffort !== undefined) { if (request.reasoningEffort !== undefined && request.reasoningEffort !== 'auto') {
basePayload.reasoning = { basePayload.reasoning = {
effort: request.reasoningEffort, effort: request.reasoningEffort,
summary: 'auto', summary: 'auto',
} }
} }
if (request.verbosity !== undefined) { if (request.verbosity !== undefined && request.verbosity !== 'auto') {
basePayload.text = { basePayload.text = {
...(basePayload.text ?? {}), ...((basePayload.text as Record<string, unknown>) ?? {}),
verbosity: request.verbosity, verbosity: request.verbosity,
} }
} }
// Store response format config - for Azure with tools, we defer applying it until after tool calls complete // Store response format config - for Azure with tools, we defer applying it until after tool calls complete
let deferredTextFormat: { type: string; name: string; schema: any; strict: boolean } | undefined let deferredTextFormat: OpenAI.Responses.ResponseFormatTextJSONSchemaConfig | undefined
const hasTools = !!request.tools?.length const hasTools = !!request.tools?.length
const isAzure = config.providerId === 'azure-openai' const isAzure = config.providerId === 'azure-openai'
@@ -171,7 +178,7 @@ export async function executeResponsesProviderRequest(
) )
} else { } else {
basePayload.text = { basePayload.text = {
...(basePayload.text ?? {}), ...((basePayload.text as Record<string, unknown>) ?? {}),
format: textFormat, format: textFormat,
} }
logger.info(`Added JSON schema response format to ${config.providerLabel} request`) logger.info(`Added JSON schema response format to ${config.providerLabel} request`)
@@ -231,7 +238,10 @@ export async function executeResponsesProviderRequest(
} }
} }
const createRequestBody = (input: ResponsesInputItem[], overrides: Record<string, any> = {}) => ({ const createRequestBody = (
input: ResponsesInputItem[],
overrides: Record<string, unknown> = {}
) => ({
...basePayload, ...basePayload,
input, input,
...overrides, ...overrides,
@@ -247,7 +257,9 @@ export async function executeResponsesProviderRequest(
} }
} }
const postResponses = async (body: Record<string, any>) => { const postResponses = async (
body: Record<string, unknown>
): Promise<OpenAI.Responses.Response> => {
const response = await fetch(config.endpoint, { const response = await fetch(config.endpoint, {
method: 'POST', method: 'POST',
headers: config.headers, headers: config.headers,
@@ -496,10 +508,10 @@ export async function executeResponsesProviderRequest(
duration: duration, duration: duration,
}) })
let resultContent: any let resultContent: Record<string, unknown>
if (result.success) { if (result.success) {
toolResults.push(result.output) toolResults.push(result.output)
resultContent = result.output resultContent = result.output as Record<string, unknown>
} else { } else {
resultContent = { resultContent = {
error: true, error: true,
@@ -615,11 +627,11 @@ export async function executeResponsesProviderRequest(
} }
// Make final call with the response format - build payload without tools // Make final call with the response format - build payload without tools
const finalPayload: Record<string, any> = { const finalPayload: Record<string, unknown> = {
model: config.modelName, model: config.modelName,
input: formattedInput, input: formattedInput,
text: { text: {
...(basePayload.text ?? {}), ...((basePayload.text as Record<string, unknown>) ?? {}),
format: deferredTextFormat, format: deferredTextFormat,
}, },
} }
@@ -627,15 +639,15 @@ export async function executeResponsesProviderRequest(
// Copy over non-tool related settings // Copy over non-tool related settings
if (request.temperature !== undefined) finalPayload.temperature = request.temperature if (request.temperature !== undefined) finalPayload.temperature = request.temperature
if (request.maxTokens != null) finalPayload.max_output_tokens = request.maxTokens if (request.maxTokens != null) finalPayload.max_output_tokens = request.maxTokens
if (request.reasoningEffort !== undefined) { if (request.reasoningEffort !== undefined && request.reasoningEffort !== 'auto') {
finalPayload.reasoning = { finalPayload.reasoning = {
effort: request.reasoningEffort, effort: request.reasoningEffort,
summary: 'auto', summary: 'auto',
} }
} }
if (request.verbosity !== undefined) { if (request.verbosity !== undefined && request.verbosity !== 'auto') {
finalPayload.text = { finalPayload.text = {
...finalPayload.text, ...((finalPayload.text as Record<string, unknown>) ?? {}),
verbosity: request.verbosity, verbosity: request.verbosity,
} }
} }
@@ -679,10 +691,10 @@ export async function executeResponsesProviderRequest(
const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output)
// For Azure with deferred format in streaming mode, include the format in the streaming call // For Azure with deferred format in streaming mode, include the format in the streaming call
const streamOverrides: Record<string, any> = { stream: true, tool_choice: 'auto' } const streamOverrides: Record<string, unknown> = { stream: true, tool_choice: 'auto' }
if (deferredTextFormat) { if (deferredTextFormat) {
streamOverrides.text = { streamOverrides.text = {
...(basePayload.text ?? {}), ...((basePayload.text as Record<string, unknown>) ?? {}),
format: deferredTextFormat, format: deferredTextFormat,
} }
} }

View File

@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import type OpenAI from 'openai'
import type { Message } from '@/providers/types' import type { Message } from '@/providers/types'
const logger = createLogger('ResponsesUtils') const logger = createLogger('ResponsesUtils')
@@ -38,7 +39,7 @@ export interface ResponsesToolDefinition {
type: 'function' type: 'function'
name: string name: string
description?: string description?: string
parameters?: Record<string, any> parameters?: Record<string, unknown>
} }
/** /**
@@ -85,7 +86,15 @@ export function buildResponsesInputFromMessages(messages: Message[]): ResponsesI
/** /**
* Converts tool definitions to the Responses API format. * Converts tool definitions to the Responses API format.
*/ */
export function convertToolsToResponses(tools: any[]): ResponsesToolDefinition[] { export function convertToolsToResponses(
tools: Array<{
type?: string
name?: string
description?: string
parameters?: Record<string, unknown>
function?: { name: string; description?: string; parameters?: Record<string, unknown> }
}>
): ResponsesToolDefinition[] {
return tools return tools
.map((tool) => { .map((tool) => {
const name = tool.function?.name ?? tool.name const name = tool.function?.name ?? tool.name
@@ -131,7 +140,7 @@ export function toResponsesToolChoice(
return 'auto' return 'auto'
} }
function extractTextFromMessageItem(item: any): string { function extractTextFromMessageItem(item: Record<string, unknown>): string {
if (!item) { if (!item) {
return '' return ''
} }
@@ -170,7 +179,7 @@ function extractTextFromMessageItem(item: any): string {
/** /**
* Extracts plain text from Responses API output items. * Extracts plain text from Responses API output items.
*/ */
export function extractResponseText(output: unknown): string { export function extractResponseText(output: OpenAI.Responses.ResponseOutputItem[]): string {
if (!Array.isArray(output)) { if (!Array.isArray(output)) {
return '' return ''
} }
@@ -181,7 +190,7 @@ export function extractResponseText(output: unknown): string {
continue continue
} }
const text = extractTextFromMessageItem(item) const text = extractTextFromMessageItem(item as unknown as Record<string, unknown>)
if (text) { if (text) {
textParts.push(text) textParts.push(text)
} }
@@ -193,7 +202,9 @@ export function extractResponseText(output: unknown): string {
/** /**
* Converts Responses API output items into input items for subsequent calls. * Converts Responses API output items into input items for subsequent calls.
*/ */
export function convertResponseOutputToInputItems(output: unknown): ResponsesInputItem[] { export function convertResponseOutputToInputItems(
output: OpenAI.Responses.ResponseOutputItem[]
): ResponsesInputItem[] {
if (!Array.isArray(output)) { if (!Array.isArray(output)) {
return [] return []
} }
@@ -205,7 +216,7 @@ export function convertResponseOutputToInputItems(output: unknown): ResponsesInp
} }
if (item.type === 'message') { if (item.type === 'message') {
const text = extractTextFromMessageItem(item) const text = extractTextFromMessageItem(item as unknown as Record<string, unknown>)
if (text) { if (text) {
items.push({ items.push({
role: 'assistant', role: 'assistant',
@@ -213,18 +224,20 @@ export function convertResponseOutputToInputItems(output: unknown): ResponsesInp
}) })
} }
const toolCalls = Array.isArray(item.tool_calls) ? item.tool_calls : [] // Handle Chat Completions-style tool_calls nested under message items
const msgRecord = item as unknown as Record<string, unknown>
const toolCalls = Array.isArray(msgRecord.tool_calls) ? msgRecord.tool_calls : []
for (const toolCall of toolCalls) { for (const toolCall of toolCalls) {
const callId = toolCall?.id const tc = toolCall as Record<string, unknown>
const name = toolCall?.function?.name ?? toolCall?.name const fn = tc.function as Record<string, unknown> | undefined
const callId = tc.id as string | undefined
const name = (fn?.name ?? tc.name) as string | undefined
if (!callId || !name) { if (!callId || !name) {
continue continue
} }
const argumentsValue = const argumentsValue =
typeof toolCall?.function?.arguments === 'string' typeof fn?.arguments === 'string' ? fn.arguments : JSON.stringify(fn?.arguments ?? {})
? toolCall.function.arguments
: JSON.stringify(toolCall?.function?.arguments ?? {})
items.push({ items.push({
type: 'function_call', type: 'function_call',
@@ -238,14 +251,18 @@ export function convertResponseOutputToInputItems(output: unknown): ResponsesInp
} }
if (item.type === 'function_call') { if (item.type === 'function_call') {
const callId = item.call_id ?? item.id const fc = item as OpenAI.Responses.ResponseFunctionToolCall
const name = item.name ?? item.function?.name const fcRecord = item as unknown as Record<string, unknown>
const callId = fc.call_id ?? (fcRecord.id as string | undefined)
const name =
fc.name ??
((fcRecord.function as Record<string, unknown> | undefined)?.name as string | undefined)
if (!callId || !name) { if (!callId || !name) {
continue continue
} }
const argumentsValue = const argumentsValue =
typeof item.arguments === 'string' ? item.arguments : JSON.stringify(item.arguments ?? {}) typeof fc.arguments === 'string' ? fc.arguments : JSON.stringify(fc.arguments ?? {})
items.push({ items.push({
type: 'function_call', type: 'function_call',
@@ -262,7 +279,9 @@ export function convertResponseOutputToInputItems(output: unknown): ResponsesInp
/** /**
* Extracts tool calls from Responses API output items. * Extracts tool calls from Responses API output items.
*/ */
export function extractResponseToolCalls(output: unknown): ResponsesToolCall[] { export function extractResponseToolCalls(
output: OpenAI.Responses.ResponseOutputItem[]
): ResponsesToolCall[] {
if (!Array.isArray(output)) { if (!Array.isArray(output)) {
return [] return []
} }
@@ -275,14 +294,18 @@ export function extractResponseToolCalls(output: unknown): ResponsesToolCall[] {
} }
if (item.type === 'function_call') { if (item.type === 'function_call') {
const callId = item.call_id ?? item.id const fc = item as OpenAI.Responses.ResponseFunctionToolCall
const name = item.name ?? item.function?.name const fcRecord = item as unknown as Record<string, unknown>
const callId = fc.call_id ?? (fcRecord.id as string | undefined)
const name =
fc.name ??
((fcRecord.function as Record<string, unknown> | undefined)?.name as string | undefined)
if (!callId || !name) { if (!callId || !name) {
continue continue
} }
const argumentsValue = const argumentsValue =
typeof item.arguments === 'string' ? item.arguments : JSON.stringify(item.arguments ?? {}) typeof fc.arguments === 'string' ? fc.arguments : JSON.stringify(fc.arguments ?? {})
toolCalls.push({ toolCalls.push({
id: callId, id: callId,
@@ -292,18 +315,20 @@ export function extractResponseToolCalls(output: unknown): ResponsesToolCall[] {
continue continue
} }
if (item.type === 'message' && Array.isArray(item.tool_calls)) { // Handle Chat Completions-style tool_calls nested under message items
for (const toolCall of item.tool_calls) { const msgRecord = item as unknown as Record<string, unknown>
const callId = toolCall?.id if (item.type === 'message' && Array.isArray(msgRecord.tool_calls)) {
const name = toolCall?.function?.name ?? toolCall?.name for (const toolCall of msgRecord.tool_calls) {
const tc = toolCall as Record<string, unknown>
const fn = tc.function as Record<string, unknown> | undefined
const callId = tc.id as string | undefined
const name = (fn?.name ?? tc.name) as string | undefined
if (!callId || !name) { if (!callId || !name) {
continue continue
} }
const argumentsValue = const argumentsValue =
typeof toolCall?.function?.arguments === 'string' typeof fn?.arguments === 'string' ? fn.arguments : JSON.stringify(fn?.arguments ?? {})
? toolCall.function.arguments
: JSON.stringify(toolCall?.function?.arguments ?? {})
toolCalls.push({ toolCalls.push({
id: callId, id: callId,
@@ -323,15 +348,17 @@ export function extractResponseToolCalls(output: unknown): ResponsesToolCall[] {
* Note: output_tokens is expected to include reasoning tokens; fall back to reasoning_tokens * Note: output_tokens is expected to include reasoning tokens; fall back to reasoning_tokens
* when output_tokens is missing or zero. * when output_tokens is missing or zero.
*/ */
export function parseResponsesUsage(usage: any): ResponsesUsageTokens | undefined { export function parseResponsesUsage(
if (!usage || typeof usage !== 'object') { usage: OpenAI.Responses.ResponseUsage | undefined
): ResponsesUsageTokens | undefined {
if (!usage) {
return undefined return undefined
} }
const inputTokens = Number(usage.input_tokens ?? 0) const inputTokens = usage.input_tokens ?? 0
const outputTokens = Number(usage.output_tokens ?? 0) const outputTokens = usage.output_tokens ?? 0
const cachedTokens = Number(usage.input_tokens_details?.cached_tokens ?? 0) const cachedTokens = usage.input_tokens_details?.cached_tokens ?? 0
const reasoningTokens = Number(usage.output_tokens_details?.reasoning_tokens ?? 0) const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? 0
const completionTokens = Math.max(outputTokens, reasoningTokens) const completionTokens = Math.max(outputTokens, reasoningTokens)
const totalTokens = inputTokens + completionTokens const totalTokens = inputTokens + completionTokens
@@ -398,7 +425,7 @@ export function createReadableStreamFromResponses(
continue continue
} }
let event: any let event: Record<string, unknown>
try { try {
event = JSON.parse(data) event = JSON.parse(data)
} catch (error) { } catch (error) {
@@ -416,7 +443,8 @@ export function createReadableStreamFromResponses(
eventType === 'error' || eventType === 'error' ||
eventType === 'response.failed' eventType === 'response.failed'
) { ) {
const message = event?.error?.message || 'Responses API stream error' const errorObj = event.error as Record<string, unknown> | undefined
const message = (errorObj?.message as string) || 'Responses API stream error'
controller.error(new Error(message)) controller.error(new Error(message))
return return
} }
@@ -426,12 +454,13 @@ export function createReadableStreamFromResponses(
eventType === 'response.output_json.delta' eventType === 'response.output_json.delta'
) { ) {
let deltaText = '' let deltaText = ''
if (typeof event.delta === 'string') { const delta = event.delta as string | Record<string, unknown> | undefined
deltaText = event.delta if (typeof delta === 'string') {
} else if (event.delta && typeof event.delta.text === 'string') { deltaText = delta
deltaText = event.delta.text } else if (delta && typeof delta.text === 'string') {
} else if (event.delta && event.delta.json !== undefined) { deltaText = delta.text
deltaText = JSON.stringify(event.delta.json) } else if (delta && delta.json !== undefined) {
deltaText = JSON.stringify(delta.json)
} else if (event.json !== undefined) { } else if (event.json !== undefined) {
deltaText = JSON.stringify(event.json) deltaText = JSON.stringify(event.json)
} else if (typeof event.text === 'string') { } else if (typeof event.text === 'string') {
@@ -445,7 +474,11 @@ export function createReadableStreamFromResponses(
} }
if (eventType === 'response.completed') { if (eventType === 'response.completed') {
finalUsage = parseResponsesUsage(event?.response?.usage ?? event?.usage) const responseObj = event.response as Record<string, unknown> | undefined
const usageData = (responseObj?.usage ?? event.usage) as
| OpenAI.Responses.ResponseUsage
| undefined
finalUsage = parseResponsesUsage(usageData)
} }
} }
} }

View File

@@ -431,19 +431,13 @@ export const openRouterProvider: ProviderConfig = {
const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output)
const streamingParams: ChatCompletionCreateParamsStreaming & { provider?: any } = { const streamingParams: ChatCompletionCreateParamsStreaming & { provider?: any } = {
model: payload.model, ...payload,
messages: [...currentMessages], messages: [...currentMessages],
tool_choice: 'auto',
stream: true, stream: true,
stream_options: { include_usage: true }, stream_options: { include_usage: true },
} }
if (payload.temperature !== undefined) {
streamingParams.temperature = payload.temperature
}
if (payload.max_tokens !== undefined) {
streamingParams.max_tokens = payload.max_tokens
}
if (request.responseFormat) { if (request.responseFormat) {
;(streamingParams as any).messages = await applyResponseFormat( ;(streamingParams as any).messages = await applyResponseFormat(
streamingParams as any, streamingParams as any,

View File

@@ -12,16 +12,22 @@ import {
getApiKey, getApiKey,
getBaseModelProviders, getBaseModelProviders,
getHostedModels, getHostedModels,
getMaxOutputTokensForModel,
getMaxTemperature, getMaxTemperature,
getModelPricing,
getProvider, getProvider,
getProviderConfigFromModel, getProviderConfigFromModel,
getProviderFromModel, getProviderFromModel,
getProviderModels, getProviderModels,
getReasoningEffortValuesForModel,
getThinkingLevelsForModel,
getVerbosityValuesForModel,
isProviderBlacklisted, isProviderBlacklisted,
MODELS_TEMP_RANGE_0_1, MODELS_TEMP_RANGE_0_1,
MODELS_TEMP_RANGE_0_2, MODELS_TEMP_RANGE_0_2,
MODELS_WITH_REASONING_EFFORT, MODELS_WITH_REASONING_EFFORT,
MODELS_WITH_TEMPERATURE_SUPPORT, MODELS_WITH_TEMPERATURE_SUPPORT,
MODELS_WITH_THINKING,
MODELS_WITH_VERBOSITY, MODELS_WITH_VERBOSITY,
PROVIDERS_WITH_TOOL_USAGE_CONTROL, PROVIDERS_WITH_TOOL_USAGE_CONTROL,
prepareToolExecution, prepareToolExecution,
@@ -169,6 +175,8 @@ describe('Model Capabilities', () => {
'gpt-4.1', 'gpt-4.1',
'gpt-4.1-mini', 'gpt-4.1-mini',
'gpt-4.1-nano', 'gpt-4.1-nano',
'gpt-5-chat-latest',
'azure/gpt-5-chat-latest',
'gemini-2.5-flash', 'gemini-2.5-flash',
'claude-sonnet-4-0', 'claude-sonnet-4-0',
'claude-opus-4-0', 'claude-opus-4-0',
@@ -186,34 +194,27 @@ describe('Model Capabilities', () => {
it.concurrent('should return false for models that do not support temperature', () => { it.concurrent('should return false for models that do not support temperature', () => {
const unsupportedModels = [ const unsupportedModels = [
'unsupported-model', 'unsupported-model',
'cerebras/llama-3.3-70b', // Cerebras models don't have temperature defined 'cerebras/llama-3.3-70b',
'groq/meta-llama/llama-4-scout-17b-16e-instruct', // Groq models don't have temperature defined 'groq/meta-llama/llama-4-scout-17b-16e-instruct',
// Reasoning models that don't support temperature
'o1', 'o1',
'o3', 'o3',
'o4-mini', 'o4-mini',
'azure/o3', 'azure/o3',
'azure/o4-mini', 'azure/o4-mini',
'deepseek-r1', 'deepseek-r1',
// Chat models that don't support temperature
'deepseek-chat', 'deepseek-chat',
'azure/gpt-4.1',
'azure/model-router', 'azure/model-router',
// GPT-5.1 models don't support temperature (removed in our implementation)
'gpt-5.1', 'gpt-5.1',
'azure/gpt-5.1', 'azure/gpt-5.1',
'azure/gpt-5.1-mini', 'azure/gpt-5.1-mini',
'azure/gpt-5.1-nano', 'azure/gpt-5.1-nano',
'azure/gpt-5.1-codex', 'azure/gpt-5.1-codex',
// GPT-5 models don't support temperature (removed in our implementation)
'gpt-5', 'gpt-5',
'gpt-5-mini', 'gpt-5-mini',
'gpt-5-nano', 'gpt-5-nano',
'gpt-5-chat-latest',
'azure/gpt-5', 'azure/gpt-5',
'azure/gpt-5-mini', 'azure/gpt-5-mini',
'azure/gpt-5-nano', 'azure/gpt-5-nano',
'azure/gpt-5-chat-latest',
] ]
for (const model of unsupportedModels) { for (const model of unsupportedModels) {
@@ -240,6 +241,8 @@ describe('Model Capabilities', () => {
const modelsRange02 = [ const modelsRange02 = [
'gpt-4o', 'gpt-4o',
'azure/gpt-4o', 'azure/gpt-4o',
'gpt-5-chat-latest',
'azure/gpt-5-chat-latest',
'gemini-2.5-pro', 'gemini-2.5-pro',
'gemini-2.5-flash', 'gemini-2.5-flash',
'deepseek-v3', 'deepseek-v3',
@@ -268,28 +271,23 @@ describe('Model Capabilities', () => {
expect(getMaxTemperature('unsupported-model')).toBeUndefined() expect(getMaxTemperature('unsupported-model')).toBeUndefined()
expect(getMaxTemperature('cerebras/llama-3.3-70b')).toBeUndefined() expect(getMaxTemperature('cerebras/llama-3.3-70b')).toBeUndefined()
expect(getMaxTemperature('groq/meta-llama/llama-4-scout-17b-16e-instruct')).toBeUndefined() expect(getMaxTemperature('groq/meta-llama/llama-4-scout-17b-16e-instruct')).toBeUndefined()
// Reasoning models that don't support temperature
expect(getMaxTemperature('o1')).toBeUndefined() expect(getMaxTemperature('o1')).toBeUndefined()
expect(getMaxTemperature('o3')).toBeUndefined() expect(getMaxTemperature('o3')).toBeUndefined()
expect(getMaxTemperature('o4-mini')).toBeUndefined() expect(getMaxTemperature('o4-mini')).toBeUndefined()
expect(getMaxTemperature('azure/o3')).toBeUndefined() expect(getMaxTemperature('azure/o3')).toBeUndefined()
expect(getMaxTemperature('azure/o4-mini')).toBeUndefined() expect(getMaxTemperature('azure/o4-mini')).toBeUndefined()
expect(getMaxTemperature('deepseek-r1')).toBeUndefined() expect(getMaxTemperature('deepseek-r1')).toBeUndefined()
// GPT-5.1 models don't support temperature
expect(getMaxTemperature('gpt-5.1')).toBeUndefined() expect(getMaxTemperature('gpt-5.1')).toBeUndefined()
expect(getMaxTemperature('azure/gpt-5.1')).toBeUndefined() expect(getMaxTemperature('azure/gpt-5.1')).toBeUndefined()
expect(getMaxTemperature('azure/gpt-5.1-mini')).toBeUndefined() expect(getMaxTemperature('azure/gpt-5.1-mini')).toBeUndefined()
expect(getMaxTemperature('azure/gpt-5.1-nano')).toBeUndefined() expect(getMaxTemperature('azure/gpt-5.1-nano')).toBeUndefined()
expect(getMaxTemperature('azure/gpt-5.1-codex')).toBeUndefined() expect(getMaxTemperature('azure/gpt-5.1-codex')).toBeUndefined()
// GPT-5 models don't support temperature
expect(getMaxTemperature('gpt-5')).toBeUndefined() expect(getMaxTemperature('gpt-5')).toBeUndefined()
expect(getMaxTemperature('gpt-5-mini')).toBeUndefined() expect(getMaxTemperature('gpt-5-mini')).toBeUndefined()
expect(getMaxTemperature('gpt-5-nano')).toBeUndefined() expect(getMaxTemperature('gpt-5-nano')).toBeUndefined()
expect(getMaxTemperature('gpt-5-chat-latest')).toBeUndefined()
expect(getMaxTemperature('azure/gpt-5')).toBeUndefined() expect(getMaxTemperature('azure/gpt-5')).toBeUndefined()
expect(getMaxTemperature('azure/gpt-5-mini')).toBeUndefined() expect(getMaxTemperature('azure/gpt-5-mini')).toBeUndefined()
expect(getMaxTemperature('azure/gpt-5-nano')).toBeUndefined() expect(getMaxTemperature('azure/gpt-5-nano')).toBeUndefined()
expect(getMaxTemperature('azure/gpt-5-chat-latest')).toBeUndefined()
}) })
it.concurrent('should be case insensitive', () => { it.concurrent('should be case insensitive', () => {
@@ -340,13 +338,13 @@ describe('Model Capabilities', () => {
expect(MODELS_TEMP_RANGE_0_2).toContain('gpt-4o') expect(MODELS_TEMP_RANGE_0_2).toContain('gpt-4o')
expect(MODELS_TEMP_RANGE_0_2).toContain('gemini-2.5-flash') expect(MODELS_TEMP_RANGE_0_2).toContain('gemini-2.5-flash')
expect(MODELS_TEMP_RANGE_0_2).toContain('deepseek-v3') expect(MODELS_TEMP_RANGE_0_2).toContain('deepseek-v3')
expect(MODELS_TEMP_RANGE_0_2).not.toContain('claude-sonnet-4-0') // Should be in 0-1 range expect(MODELS_TEMP_RANGE_0_2).not.toContain('claude-sonnet-4-0')
}) })
it.concurrent('should have correct models in MODELS_TEMP_RANGE_0_1', () => { it.concurrent('should have correct models in MODELS_TEMP_RANGE_0_1', () => {
expect(MODELS_TEMP_RANGE_0_1).toContain('claude-sonnet-4-0') expect(MODELS_TEMP_RANGE_0_1).toContain('claude-sonnet-4-0')
expect(MODELS_TEMP_RANGE_0_1).toContain('grok-3-latest') expect(MODELS_TEMP_RANGE_0_1).toContain('grok-3-latest')
expect(MODELS_TEMP_RANGE_0_1).not.toContain('gpt-4o') // Should be in 0-2 range expect(MODELS_TEMP_RANGE_0_1).not.toContain('gpt-4o')
}) })
it.concurrent('should have correct providers in PROVIDERS_WITH_TOOL_USAGE_CONTROL', () => { it.concurrent('should have correct providers in PROVIDERS_WITH_TOOL_USAGE_CONTROL', () => {
@@ -363,20 +361,19 @@ describe('Model Capabilities', () => {
expect(MODELS_WITH_TEMPERATURE_SUPPORT.length).toBe( expect(MODELS_WITH_TEMPERATURE_SUPPORT.length).toBe(
MODELS_TEMP_RANGE_0_2.length + MODELS_TEMP_RANGE_0_1.length MODELS_TEMP_RANGE_0_2.length + MODELS_TEMP_RANGE_0_1.length
) )
expect(MODELS_WITH_TEMPERATURE_SUPPORT).toContain('gpt-4o') // From 0-2 range expect(MODELS_WITH_TEMPERATURE_SUPPORT).toContain('gpt-4o')
expect(MODELS_WITH_TEMPERATURE_SUPPORT).toContain('claude-sonnet-4-0') // From 0-1 range expect(MODELS_WITH_TEMPERATURE_SUPPORT).toContain('claude-sonnet-4-0')
} }
) )
it.concurrent('should have correct models in MODELS_WITH_REASONING_EFFORT', () => { it.concurrent('should have correct models in MODELS_WITH_REASONING_EFFORT', () => {
// Should contain GPT-5.1 models that support reasoning effort
expect(MODELS_WITH_REASONING_EFFORT).toContain('gpt-5.1') expect(MODELS_WITH_REASONING_EFFORT).toContain('gpt-5.1')
expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/gpt-5.1') expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/gpt-5.1')
expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/gpt-5.1-mini')
expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/gpt-5.1-nano')
expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/gpt-5.1-codex') expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/gpt-5.1-codex')
// Should contain GPT-5 models that support reasoning effort expect(MODELS_WITH_REASONING_EFFORT).not.toContain('azure/gpt-5.1-mini')
expect(MODELS_WITH_REASONING_EFFORT).not.toContain('azure/gpt-5.1-nano')
expect(MODELS_WITH_REASONING_EFFORT).toContain('gpt-5') expect(MODELS_WITH_REASONING_EFFORT).toContain('gpt-5')
expect(MODELS_WITH_REASONING_EFFORT).toContain('gpt-5-mini') expect(MODELS_WITH_REASONING_EFFORT).toContain('gpt-5-mini')
expect(MODELS_WITH_REASONING_EFFORT).toContain('gpt-5-nano') expect(MODELS_WITH_REASONING_EFFORT).toContain('gpt-5-nano')
@@ -384,35 +381,30 @@ describe('Model Capabilities', () => {
expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/gpt-5-mini') expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/gpt-5-mini')
expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/gpt-5-nano') expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/gpt-5-nano')
// Should contain gpt-5.2 models
expect(MODELS_WITH_REASONING_EFFORT).toContain('gpt-5.2') expect(MODELS_WITH_REASONING_EFFORT).toContain('gpt-5.2')
expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/gpt-5.2') expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/gpt-5.2')
// Should contain o-series reasoning models (reasoning_effort added Dec 17, 2024)
expect(MODELS_WITH_REASONING_EFFORT).toContain('o1') expect(MODELS_WITH_REASONING_EFFORT).toContain('o1')
expect(MODELS_WITH_REASONING_EFFORT).toContain('o3') expect(MODELS_WITH_REASONING_EFFORT).toContain('o3')
expect(MODELS_WITH_REASONING_EFFORT).toContain('o4-mini') expect(MODELS_WITH_REASONING_EFFORT).toContain('o4-mini')
expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/o3') expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/o3')
expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/o4-mini') expect(MODELS_WITH_REASONING_EFFORT).toContain('azure/o4-mini')
// Should NOT contain non-reasoning GPT-5 models
expect(MODELS_WITH_REASONING_EFFORT).not.toContain('gpt-5-chat-latest') expect(MODELS_WITH_REASONING_EFFORT).not.toContain('gpt-5-chat-latest')
expect(MODELS_WITH_REASONING_EFFORT).not.toContain('azure/gpt-5-chat-latest') expect(MODELS_WITH_REASONING_EFFORT).not.toContain('azure/gpt-5-chat-latest')
// Should NOT contain other models
expect(MODELS_WITH_REASONING_EFFORT).not.toContain('gpt-4o') expect(MODELS_WITH_REASONING_EFFORT).not.toContain('gpt-4o')
expect(MODELS_WITH_REASONING_EFFORT).not.toContain('claude-sonnet-4-0') expect(MODELS_WITH_REASONING_EFFORT).not.toContain('claude-sonnet-4-0')
}) })
it.concurrent('should have correct models in MODELS_WITH_VERBOSITY', () => { it.concurrent('should have correct models in MODELS_WITH_VERBOSITY', () => {
// Should contain GPT-5.1 models that support verbosity
expect(MODELS_WITH_VERBOSITY).toContain('gpt-5.1') expect(MODELS_WITH_VERBOSITY).toContain('gpt-5.1')
expect(MODELS_WITH_VERBOSITY).toContain('azure/gpt-5.1') expect(MODELS_WITH_VERBOSITY).toContain('azure/gpt-5.1')
expect(MODELS_WITH_VERBOSITY).toContain('azure/gpt-5.1-mini')
expect(MODELS_WITH_VERBOSITY).toContain('azure/gpt-5.1-nano')
expect(MODELS_WITH_VERBOSITY).toContain('azure/gpt-5.1-codex') expect(MODELS_WITH_VERBOSITY).toContain('azure/gpt-5.1-codex')
// Should contain GPT-5 models that support verbosity expect(MODELS_WITH_VERBOSITY).not.toContain('azure/gpt-5.1-mini')
expect(MODELS_WITH_VERBOSITY).not.toContain('azure/gpt-5.1-nano')
expect(MODELS_WITH_VERBOSITY).toContain('gpt-5') expect(MODELS_WITH_VERBOSITY).toContain('gpt-5')
expect(MODELS_WITH_VERBOSITY).toContain('gpt-5-mini') expect(MODELS_WITH_VERBOSITY).toContain('gpt-5-mini')
expect(MODELS_WITH_VERBOSITY).toContain('gpt-5-nano') expect(MODELS_WITH_VERBOSITY).toContain('gpt-5-nano')
@@ -420,26 +412,39 @@ describe('Model Capabilities', () => {
expect(MODELS_WITH_VERBOSITY).toContain('azure/gpt-5-mini') expect(MODELS_WITH_VERBOSITY).toContain('azure/gpt-5-mini')
expect(MODELS_WITH_VERBOSITY).toContain('azure/gpt-5-nano') expect(MODELS_WITH_VERBOSITY).toContain('azure/gpt-5-nano')
// Should contain gpt-5.2 models
expect(MODELS_WITH_VERBOSITY).toContain('gpt-5.2') expect(MODELS_WITH_VERBOSITY).toContain('gpt-5.2')
expect(MODELS_WITH_VERBOSITY).toContain('azure/gpt-5.2') expect(MODELS_WITH_VERBOSITY).toContain('azure/gpt-5.2')
// Should NOT contain non-reasoning GPT-5 models
expect(MODELS_WITH_VERBOSITY).not.toContain('gpt-5-chat-latest') expect(MODELS_WITH_VERBOSITY).not.toContain('gpt-5-chat-latest')
expect(MODELS_WITH_VERBOSITY).not.toContain('azure/gpt-5-chat-latest') expect(MODELS_WITH_VERBOSITY).not.toContain('azure/gpt-5-chat-latest')
// Should NOT contain o-series models (they support reasoning_effort but not verbosity)
expect(MODELS_WITH_VERBOSITY).not.toContain('o1') expect(MODELS_WITH_VERBOSITY).not.toContain('o1')
expect(MODELS_WITH_VERBOSITY).not.toContain('o3') expect(MODELS_WITH_VERBOSITY).not.toContain('o3')
expect(MODELS_WITH_VERBOSITY).not.toContain('o4-mini') expect(MODELS_WITH_VERBOSITY).not.toContain('o4-mini')
// Should NOT contain other models
expect(MODELS_WITH_VERBOSITY).not.toContain('gpt-4o') expect(MODELS_WITH_VERBOSITY).not.toContain('gpt-4o')
expect(MODELS_WITH_VERBOSITY).not.toContain('claude-sonnet-4-0') expect(MODELS_WITH_VERBOSITY).not.toContain('claude-sonnet-4-0')
}) })
it.concurrent('should have correct models in MODELS_WITH_THINKING', () => {
expect(MODELS_WITH_THINKING).toContain('claude-opus-4-6')
expect(MODELS_WITH_THINKING).toContain('claude-opus-4-5')
expect(MODELS_WITH_THINKING).toContain('claude-opus-4-1')
expect(MODELS_WITH_THINKING).toContain('claude-opus-4-0')
expect(MODELS_WITH_THINKING).toContain('claude-sonnet-4-5')
expect(MODELS_WITH_THINKING).toContain('claude-sonnet-4-0')
expect(MODELS_WITH_THINKING).toContain('gemini-3-pro-preview')
expect(MODELS_WITH_THINKING).toContain('gemini-3-flash-preview')
expect(MODELS_WITH_THINKING).toContain('claude-haiku-4-5')
expect(MODELS_WITH_THINKING).not.toContain('gpt-4o')
expect(MODELS_WITH_THINKING).not.toContain('gpt-5')
expect(MODELS_WITH_THINKING).not.toContain('o3')
})
it.concurrent('should have GPT-5 models in both reasoning effort and verbosity arrays', () => { it.concurrent('should have GPT-5 models in both reasoning effort and verbosity arrays', () => {
// GPT-5 series models support both reasoning effort and verbosity
const gpt5ModelsWithReasoningEffort = MODELS_WITH_REASONING_EFFORT.filter( const gpt5ModelsWithReasoningEffort = MODELS_WITH_REASONING_EFFORT.filter(
(m) => m.includes('gpt-5') && !m.includes('chat-latest') (m) => m.includes('gpt-5') && !m.includes('chat-latest')
) )
@@ -448,11 +453,201 @@ describe('Model Capabilities', () => {
) )
expect(gpt5ModelsWithReasoningEffort.sort()).toEqual(gpt5ModelsWithVerbosity.sort()) expect(gpt5ModelsWithReasoningEffort.sort()).toEqual(gpt5ModelsWithVerbosity.sort())
// o-series models have reasoning effort but NOT verbosity
expect(MODELS_WITH_REASONING_EFFORT).toContain('o1') expect(MODELS_WITH_REASONING_EFFORT).toContain('o1')
expect(MODELS_WITH_VERBOSITY).not.toContain('o1') expect(MODELS_WITH_VERBOSITY).not.toContain('o1')
}) })
}) })
describe('Reasoning Effort Values Per Model', () => {
it.concurrent('should return correct values for GPT-5.2', () => {
const values = getReasoningEffortValuesForModel('gpt-5.2')
expect(values).toBeDefined()
expect(values).toContain('none')
expect(values).toContain('low')
expect(values).toContain('medium')
expect(values).toContain('high')
expect(values).toContain('xhigh')
expect(values).not.toContain('minimal')
})
it.concurrent('should return correct values for GPT-5', () => {
const values = getReasoningEffortValuesForModel('gpt-5')
expect(values).toBeDefined()
expect(values).toContain('minimal')
expect(values).toContain('low')
expect(values).toContain('medium')
expect(values).toContain('high')
})
it.concurrent('should return correct values for o-series models', () => {
for (const model of ['o1', 'o3', 'o4-mini']) {
const values = getReasoningEffortValuesForModel(model)
expect(values).toBeDefined()
expect(values).toContain('low')
expect(values).toContain('medium')
expect(values).toContain('high')
expect(values).not.toContain('none')
expect(values).not.toContain('minimal')
}
})
it.concurrent('should return null for non-reasoning models', () => {
expect(getReasoningEffortValuesForModel('gpt-4o')).toBeNull()
expect(getReasoningEffortValuesForModel('claude-sonnet-4-5')).toBeNull()
expect(getReasoningEffortValuesForModel('gemini-2.5-flash')).toBeNull()
})
it.concurrent('should return correct values for Azure GPT-5.2', () => {
const values = getReasoningEffortValuesForModel('azure/gpt-5.2')
expect(values).toBeDefined()
expect(values).not.toContain('minimal')
expect(values).toContain('xhigh')
})
})
describe('Verbosity Values Per Model', () => {
it.concurrent('should return correct values for GPT-5 family', () => {
for (const model of ['gpt-5.2', 'gpt-5.1', 'gpt-5', 'gpt-5-mini', 'gpt-5-nano']) {
const values = getVerbosityValuesForModel(model)
expect(values).toBeDefined()
expect(values).toContain('low')
expect(values).toContain('medium')
expect(values).toContain('high')
}
})
it.concurrent('should return null for o-series models', () => {
expect(getVerbosityValuesForModel('o1')).toBeNull()
expect(getVerbosityValuesForModel('o3')).toBeNull()
expect(getVerbosityValuesForModel('o4-mini')).toBeNull()
})
it.concurrent('should return null for non-reasoning models', () => {
expect(getVerbosityValuesForModel('gpt-4o')).toBeNull()
expect(getVerbosityValuesForModel('claude-sonnet-4-5')).toBeNull()
})
})
describe('Thinking Levels Per Model', () => {
it.concurrent('should return correct levels for Claude Opus 4.6 (adaptive)', () => {
const levels = getThinkingLevelsForModel('claude-opus-4-6')
expect(levels).toBeDefined()
expect(levels).toContain('low')
expect(levels).toContain('medium')
expect(levels).toContain('high')
expect(levels).toContain('max')
})
it.concurrent('should return correct levels for other Claude models (budget_tokens)', () => {
for (const model of ['claude-opus-4-5', 'claude-sonnet-4-5', 'claude-sonnet-4-0']) {
const levels = getThinkingLevelsForModel(model)
expect(levels).toBeDefined()
expect(levels).toContain('low')
expect(levels).toContain('medium')
expect(levels).toContain('high')
expect(levels).not.toContain('max')
}
})
it.concurrent('should return correct levels for Gemini 3 models', () => {
const proLevels = getThinkingLevelsForModel('gemini-3-pro-preview')
expect(proLevels).toBeDefined()
expect(proLevels).toContain('low')
expect(proLevels).toContain('high')
const flashLevels = getThinkingLevelsForModel('gemini-3-flash-preview')
expect(flashLevels).toBeDefined()
expect(flashLevels).toContain('minimal')
expect(flashLevels).toContain('low')
expect(flashLevels).toContain('medium')
expect(flashLevels).toContain('high')
})
it.concurrent('should return correct levels for Claude Haiku 4.5', () => {
const levels = getThinkingLevelsForModel('claude-haiku-4-5')
expect(levels).toBeDefined()
expect(levels).toContain('low')
expect(levels).toContain('medium')
expect(levels).toContain('high')
})
it.concurrent('should return null for non-thinking models', () => {
expect(getThinkingLevelsForModel('gpt-4o')).toBeNull()
expect(getThinkingLevelsForModel('gpt-5')).toBeNull()
expect(getThinkingLevelsForModel('o3')).toBeNull()
})
})
})
describe('Max Output Tokens', () => {
describe('getMaxOutputTokensForModel', () => {
it.concurrent('should return correct max for Claude Opus 4.6', () => {
expect(getMaxOutputTokensForModel('claude-opus-4-6')).toBe(128000)
})
it.concurrent('should return correct max for Claude Sonnet 4.5', () => {
expect(getMaxOutputTokensForModel('claude-sonnet-4-5')).toBe(64000)
})
it.concurrent('should return correct max for Claude Opus 4.1', () => {
expect(getMaxOutputTokensForModel('claude-opus-4-1')).toBe(64000)
})
it.concurrent('should return standard default for models without maxOutputTokens', () => {
expect(getMaxOutputTokensForModel('gpt-4o')).toBe(4096)
})
it.concurrent('should return standard default for unknown models', () => {
expect(getMaxOutputTokensForModel('unknown-model')).toBe(4096)
})
})
})
describe('Model Pricing Validation', () => {
it.concurrent('should have correct pricing for key Anthropic models', () => {
const opus46 = getModelPricing('claude-opus-4-6')
expect(opus46).toBeDefined()
expect(opus46.input).toBe(5.0)
expect(opus46.output).toBe(25.0)
const sonnet45 = getModelPricing('claude-sonnet-4-5')
expect(sonnet45).toBeDefined()
expect(sonnet45.input).toBe(3.0)
expect(sonnet45.output).toBe(15.0)
})
it.concurrent('should have correct pricing for key OpenAI models', () => {
const gpt4o = getModelPricing('gpt-4o')
expect(gpt4o).toBeDefined()
expect(gpt4o.input).toBe(2.5)
expect(gpt4o.output).toBe(10.0)
const o3 = getModelPricing('o3')
expect(o3).toBeDefined()
expect(o3.input).toBe(2.0)
expect(o3.output).toBe(8.0)
})
it.concurrent('should have correct pricing for Azure OpenAI o3', () => {
const azureO3 = getModelPricing('azure/o3')
expect(azureO3).toBeDefined()
expect(azureO3.input).toBe(2.0)
expect(azureO3.output).toBe(8.0)
})
it.concurrent('should return null for unknown models', () => {
expect(getModelPricing('unknown-model')).toBeNull()
})
})
describe('Context Window Validation', () => {
it.concurrent('should have correct context windows for key models', () => {
const allModels = getAllModels()
expect(allModels).toContain('gpt-5-chat-latest')
expect(allModels).toContain('o3')
expect(allModels).toContain('o4-mini')
})
}) })
describe('Cost Calculation', () => { describe('Cost Calculation', () => {
@@ -464,7 +659,7 @@ describe('Cost Calculation', () => {
expect(result.output).toBeGreaterThan(0) expect(result.output).toBeGreaterThan(0)
expect(result.total).toBeCloseTo(result.input + result.output, 6) expect(result.total).toBeCloseTo(result.input + result.output, 6)
expect(result.pricing).toBeDefined() expect(result.pricing).toBeDefined()
expect(result.pricing.input).toBe(2.5) // GPT-4o pricing expect(result.pricing.input).toBe(2.5)
}) })
it.concurrent('should handle cached input pricing when enabled', () => { it.concurrent('should handle cached input pricing when enabled', () => {
@@ -472,7 +667,7 @@ describe('Cost Calculation', () => {
const cachedCost = calculateCost('gpt-4o', 1000, 500, true) const cachedCost = calculateCost('gpt-4o', 1000, 500, true)
expect(cachedCost.input).toBeLessThan(regularCost.input) expect(cachedCost.input).toBeLessThan(regularCost.input)
expect(cachedCost.output).toBe(regularCost.output) // Output cost should be same expect(cachedCost.output).toBe(regularCost.output)
}) })
it.concurrent('should return default pricing for unknown models', () => { it.concurrent('should return default pricing for unknown models', () => {
@@ -481,7 +676,7 @@ describe('Cost Calculation', () => {
expect(result.input).toBe(0) expect(result.input).toBe(0)
expect(result.output).toBe(0) expect(result.output).toBe(0)
expect(result.total).toBe(0) expect(result.total).toBe(0)
expect(result.pricing.input).toBe(1.0) // Default pricing expect(result.pricing.input).toBe(1.0)
}) })
it.concurrent('should handle zero tokens', () => { it.concurrent('should handle zero tokens', () => {
@@ -528,19 +723,15 @@ describe('getHostedModels', () => {
it.concurrent('should return OpenAI, Anthropic, and Google models as hosted', () => { it.concurrent('should return OpenAI, Anthropic, and Google models as hosted', () => {
const hostedModels = getHostedModels() const hostedModels = getHostedModels()
// OpenAI models
expect(hostedModels).toContain('gpt-4o') expect(hostedModels).toContain('gpt-4o')
expect(hostedModels).toContain('o1') expect(hostedModels).toContain('o1')
// Anthropic models
expect(hostedModels).toContain('claude-sonnet-4-0') expect(hostedModels).toContain('claude-sonnet-4-0')
expect(hostedModels).toContain('claude-opus-4-0') expect(hostedModels).toContain('claude-opus-4-0')
// Google models
expect(hostedModels).toContain('gemini-2.5-pro') expect(hostedModels).toContain('gemini-2.5-pro')
expect(hostedModels).toContain('gemini-2.5-flash') expect(hostedModels).toContain('gemini-2.5-flash')
// Should not contain models from other providers
expect(hostedModels).not.toContain('deepseek-v3') expect(hostedModels).not.toContain('deepseek-v3')
expect(hostedModels).not.toContain('grok-4-latest') expect(hostedModels).not.toContain('grok-4-latest')
}) })
@@ -558,31 +749,24 @@ describe('getHostedModels', () => {
describe('shouldBillModelUsage', () => { describe('shouldBillModelUsage', () => {
it.concurrent('should return true for exact matches of hosted models', () => { it.concurrent('should return true for exact matches of hosted models', () => {
// OpenAI models
expect(shouldBillModelUsage('gpt-4o')).toBe(true) expect(shouldBillModelUsage('gpt-4o')).toBe(true)
expect(shouldBillModelUsage('o1')).toBe(true) expect(shouldBillModelUsage('o1')).toBe(true)
// Anthropic models
expect(shouldBillModelUsage('claude-sonnet-4-0')).toBe(true) expect(shouldBillModelUsage('claude-sonnet-4-0')).toBe(true)
expect(shouldBillModelUsage('claude-opus-4-0')).toBe(true) expect(shouldBillModelUsage('claude-opus-4-0')).toBe(true)
// Google models
expect(shouldBillModelUsage('gemini-2.5-pro')).toBe(true) expect(shouldBillModelUsage('gemini-2.5-pro')).toBe(true)
expect(shouldBillModelUsage('gemini-2.5-flash')).toBe(true) expect(shouldBillModelUsage('gemini-2.5-flash')).toBe(true)
}) })
it.concurrent('should return false for non-hosted models', () => { it.concurrent('should return false for non-hosted models', () => {
// Other providers
expect(shouldBillModelUsage('deepseek-v3')).toBe(false) expect(shouldBillModelUsage('deepseek-v3')).toBe(false)
expect(shouldBillModelUsage('grok-4-latest')).toBe(false) expect(shouldBillModelUsage('grok-4-latest')).toBe(false)
// Unknown models
expect(shouldBillModelUsage('unknown-model')).toBe(false) expect(shouldBillModelUsage('unknown-model')).toBe(false)
}) })
it.concurrent('should return false for versioned model names not in hosted list', () => { it.concurrent('should return false for versioned model names not in hosted list', () => {
// Versioned model names that are NOT in the hosted list
// These should NOT be billed (user provides own API key)
expect(shouldBillModelUsage('claude-sonnet-4-20250514')).toBe(false) expect(shouldBillModelUsage('claude-sonnet-4-20250514')).toBe(false)
expect(shouldBillModelUsage('gpt-4o-2024-08-06')).toBe(false) expect(shouldBillModelUsage('gpt-4o-2024-08-06')).toBe(false)
expect(shouldBillModelUsage('claude-3-5-sonnet-20241022')).toBe(false) expect(shouldBillModelUsage('claude-3-5-sonnet-20241022')).toBe(false)
@@ -595,8 +779,7 @@ describe('shouldBillModelUsage', () => {
}) })
it.concurrent('should not match partial model names', () => { it.concurrent('should not match partial model names', () => {
// Should not match partial/prefix models expect(shouldBillModelUsage('gpt-4')).toBe(false)
expect(shouldBillModelUsage('gpt-4')).toBe(false) // gpt-4o is hosted, not gpt-4
expect(shouldBillModelUsage('claude-sonnet')).toBe(false) expect(shouldBillModelUsage('claude-sonnet')).toBe(false)
expect(shouldBillModelUsage('gemini')).toBe(false) expect(shouldBillModelUsage('gemini')).toBe(false)
}) })
@@ -612,8 +795,8 @@ describe('Provider Management', () => {
}) })
it.concurrent('should use model patterns for pattern matching', () => { it.concurrent('should use model patterns for pattern matching', () => {
expect(getProviderFromModel('gpt-5-custom')).toBe('openai') // Matches /^gpt/ pattern expect(getProviderFromModel('gpt-5-custom')).toBe('openai')
expect(getProviderFromModel('claude-custom-model')).toBe('anthropic') // Matches /^claude/ pattern expect(getProviderFromModel('claude-custom-model')).toBe('anthropic')
}) })
it.concurrent('should default to ollama for unknown models', () => { it.concurrent('should default to ollama for unknown models', () => {
@@ -667,7 +850,6 @@ describe('Provider Management', () => {
expect(Array.isArray(allModels)).toBe(true) expect(Array.isArray(allModels)).toBe(true)
expect(allModels.length).toBeGreaterThan(0) expect(allModels.length).toBeGreaterThan(0)
// Should contain models from different providers
expect(allModels).toContain('gpt-4o') expect(allModels).toContain('gpt-4o')
expect(allModels).toContain('claude-sonnet-4-0') expect(allModels).toContain('claude-sonnet-4-0')
expect(allModels).toContain('gemini-2.5-pro') expect(allModels).toContain('gemini-2.5-pro')
@@ -712,7 +894,6 @@ describe('Provider Management', () => {
const baseProviders = getBaseModelProviders() const baseProviders = getBaseModelProviders()
expect(typeof baseProviders).toBe('object') expect(typeof baseProviders).toBe('object')
// Should exclude ollama models
}) })
}) })
@@ -720,10 +901,8 @@ describe('Provider Management', () => {
it.concurrent('should update ollama models', () => { it.concurrent('should update ollama models', () => {
const mockModels = ['llama2', 'codellama', 'mistral'] const mockModels = ['llama2', 'codellama', 'mistral']
// This should not throw
expect(() => updateOllamaProviderModels(mockModels)).not.toThrow() expect(() => updateOllamaProviderModels(mockModels)).not.toThrow()
// Verify the models were updated
const ollamaModels = getProviderModels('ollama') const ollamaModels = getProviderModels('ollama')
expect(ollamaModels).toEqual(mockModels) expect(ollamaModels).toEqual(mockModels)
}) })
@@ -754,7 +933,7 @@ describe('JSON and Structured Output', () => {
}) })
it.concurrent('should clean up common JSON issues', () => { it.concurrent('should clean up common JSON issues', () => {
const content = '{\n "key": "value",\n "number": 42,\n}' // Trailing comma const content = '{\n "key": "value",\n "number": 42,\n}'
const result = extractAndParseJSON(content) const result = extractAndParseJSON(content)
expect(result).toEqual({ key: 'value', number: 42 }) expect(result).toEqual({ key: 'value', number: 42 })
}) })
@@ -945,13 +1124,13 @@ describe('prepareToolExecution', () => {
const { toolParams } = prepareToolExecution(tool, llmArgs, request) const { toolParams } = prepareToolExecution(tool, llmArgs, request)
expect(toolParams.apiKey).toBe('user-key') expect(toolParams.apiKey).toBe('user-key')
expect(toolParams.channel).toBe('#general') // User value wins expect(toolParams.channel).toBe('#general')
expect(toolParams.message).toBe('Hello world') expect(toolParams.message).toBe('Hello world')
}) })
it.concurrent('should filter out empty string user params', () => { it.concurrent('should filter out empty string user params', () => {
const tool = { const tool = {
params: { apiKey: 'user-key', channel: '' }, // Empty channel params: { apiKey: 'user-key', channel: '' },
} }
const llmArgs = { message: 'Hello', channel: '#llm-channel' } const llmArgs = { message: 'Hello', channel: '#llm-channel' }
const request = {} const request = {}
@@ -959,7 +1138,7 @@ describe('prepareToolExecution', () => {
const { toolParams } = prepareToolExecution(tool, llmArgs, request) const { toolParams } = prepareToolExecution(tool, llmArgs, request)
expect(toolParams.apiKey).toBe('user-key') expect(toolParams.apiKey).toBe('user-key')
expect(toolParams.channel).toBe('#llm-channel') // LLM value used since user is empty expect(toolParams.channel).toBe('#llm-channel')
expect(toolParams.message).toBe('Hello') expect(toolParams.message).toBe('Hello')
}) })
}) })
@@ -969,7 +1148,7 @@ describe('prepareToolExecution', () => {
const tool = { const tool = {
params: { params: {
workflowId: 'child-workflow-123', workflowId: 'child-workflow-123',
inputMapping: '{}', // Empty JSON string from UI inputMapping: '{}',
}, },
} }
const llmArgs = { const llmArgs = {
@@ -979,7 +1158,6 @@ describe('prepareToolExecution', () => {
const { toolParams } = prepareToolExecution(tool, llmArgs, request) const { toolParams } = prepareToolExecution(tool, llmArgs, request)
// LLM values should be used since user object is empty
expect(toolParams.inputMapping).toEqual({ query: 'search term', limit: 10 }) expect(toolParams.inputMapping).toEqual({ query: 'search term', limit: 10 })
expect(toolParams.workflowId).toBe('child-workflow-123') expect(toolParams.workflowId).toBe('child-workflow-123')
}) })
@@ -988,7 +1166,7 @@ describe('prepareToolExecution', () => {
const tool = { const tool = {
params: { params: {
workflowId: 'child-workflow', workflowId: 'child-workflow',
inputMapping: '{"query": "", "customField": "user-value"}', // Partial values inputMapping: '{"query": "", "customField": "user-value"}',
}, },
} }
const llmArgs = { const llmArgs = {
@@ -998,7 +1176,6 @@ describe('prepareToolExecution', () => {
const { toolParams } = prepareToolExecution(tool, llmArgs, request) const { toolParams } = prepareToolExecution(tool, llmArgs, request)
// LLM fills empty query, user's customField preserved, LLM's limit included
expect(toolParams.inputMapping).toEqual({ expect(toolParams.inputMapping).toEqual({
query: 'llm-search', query: 'llm-search',
limit: 10, limit: 10,
@@ -1020,7 +1197,6 @@ describe('prepareToolExecution', () => {
const { toolParams } = prepareToolExecution(tool, llmArgs, request) const { toolParams } = prepareToolExecution(tool, llmArgs, request)
// User values win, but LLM's extra field is included
expect(toolParams.inputMapping).toEqual({ expect(toolParams.inputMapping).toEqual({
query: 'user-search', query: 'user-search',
limit: 5, limit: 5,
@@ -1032,7 +1208,7 @@ describe('prepareToolExecution', () => {
const tool = { const tool = {
params: { params: {
workflowId: 'child-workflow', workflowId: 'child-workflow',
inputMapping: { query: '', customField: 'user-value' }, // Object, not string inputMapping: { query: '', customField: 'user-value' },
}, },
} }
const llmArgs = { const llmArgs = {
@@ -1051,7 +1227,7 @@ describe('prepareToolExecution', () => {
it.concurrent('should use LLM inputMapping when user does not provide it', () => { it.concurrent('should use LLM inputMapping when user does not provide it', () => {
const tool = { const tool = {
params: { workflowId: 'child-workflow' }, // No inputMapping params: { workflowId: 'child-workflow' },
} }
const llmArgs = { const llmArgs = {
inputMapping: { query: 'llm-search', limit: 10 }, inputMapping: { query: 'llm-search', limit: 10 },
@@ -1070,7 +1246,7 @@ describe('prepareToolExecution', () => {
inputMapping: '{"query": "user-search"}', inputMapping: '{"query": "user-search"}',
}, },
} }
const llmArgs = {} // No inputMapping from LLM const llmArgs = {}
const request = {} const request = {}
const { toolParams } = prepareToolExecution(tool, llmArgs, request) const { toolParams } = prepareToolExecution(tool, llmArgs, request)
@@ -1092,7 +1268,6 @@ describe('prepareToolExecution', () => {
const { toolParams } = prepareToolExecution(tool, llmArgs, request) const { toolParams } = prepareToolExecution(tool, llmArgs, request)
// Should use LLM values since user JSON is invalid
expect(toolParams.inputMapping).toEqual({ query: 'llm-search' }) expect(toolParams.inputMapping).toEqual({ query: 'llm-search' })
}) })
@@ -1105,9 +1280,8 @@ describe('prepareToolExecution', () => {
const { toolParams } = prepareToolExecution(tool, llmArgs, request) const { toolParams } = prepareToolExecution(tool, llmArgs, request)
// Normal behavior: user values override LLM values
expect(toolParams.apiKey).toBe('user-key') expect(toolParams.apiKey).toBe('user-key')
expect(toolParams.channel).toBe('#general') // User value wins expect(toolParams.channel).toBe('#general')
expect(toolParams.message).toBe('Hello') expect(toolParams.message).toBe('Hello')
}) })
@@ -1125,8 +1299,6 @@ describe('prepareToolExecution', () => {
const { toolParams } = prepareToolExecution(tool, llmArgs, request) const { toolParams } = prepareToolExecution(tool, llmArgs, request)
// 0 and false should be preserved (they're valid values)
// empty string should be filled by LLM
expect(toolParams.inputMapping).toEqual({ expect(toolParams.inputMapping).toEqual({
limit: 0, limit: 0,
enabled: false, enabled: false,

View File

@@ -1,4 +1,5 @@
import { createLogger, type Logger } from '@sim/logger' import { createLogger, type Logger } from '@sim/logger'
import type OpenAI from 'openai'
import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
import type { CompletionUsage } from 'openai/resources/completions' import type { CompletionUsage } from 'openai/resources/completions'
import { env } from '@/lib/core/config/env' import { env } from '@/lib/core/config/env'
@@ -113,6 +114,8 @@ function buildProviderMetadata(providerId: ProviderId): ProviderMetadata {
} }
export const providers: Record<ProviderId, ProviderMetadata> = { export const providers: Record<ProviderId, ProviderMetadata> = {
ollama: buildProviderMetadata('ollama'),
vllm: buildProviderMetadata('vllm'),
openai: { openai: {
...buildProviderMetadata('openai'), ...buildProviderMetadata('openai'),
computerUseModels: ['computer-use-preview'], computerUseModels: ['computer-use-preview'],
@@ -123,19 +126,17 @@ export const providers: Record<ProviderId, ProviderMetadata> = {
getProviderModelsFromDefinitions('anthropic').includes(model) getProviderModelsFromDefinitions('anthropic').includes(model)
), ),
}, },
'azure-anthropic': buildProviderMetadata('azure-anthropic'),
google: buildProviderMetadata('google'), google: buildProviderMetadata('google'),
vertex: buildProviderMetadata('vertex'), vertex: buildProviderMetadata('vertex'),
'azure-openai': buildProviderMetadata('azure-openai'),
'azure-anthropic': buildProviderMetadata('azure-anthropic'),
deepseek: buildProviderMetadata('deepseek'), deepseek: buildProviderMetadata('deepseek'),
xai: buildProviderMetadata('xai'), xai: buildProviderMetadata('xai'),
cerebras: buildProviderMetadata('cerebras'), cerebras: buildProviderMetadata('cerebras'),
groq: buildProviderMetadata('groq'), groq: buildProviderMetadata('groq'),
vllm: buildProviderMetadata('vllm'),
mistral: buildProviderMetadata('mistral'), mistral: buildProviderMetadata('mistral'),
'azure-openai': buildProviderMetadata('azure-openai'),
openrouter: buildProviderMetadata('openrouter'),
ollama: buildProviderMetadata('ollama'),
bedrock: buildProviderMetadata('bedrock'), bedrock: buildProviderMetadata('bedrock'),
openrouter: buildProviderMetadata('openrouter'),
} }
export function updateOllamaProviderModels(models: string[]): void { export function updateOllamaProviderModels(models: string[]): void {
@@ -995,15 +996,12 @@ export function getThinkingLevelsForModel(model: string): string[] | null {
} }
/** /**
* Get max output tokens for a specific model * Get max output tokens for a specific model.
* Returns the model's maxOutputTokens capability for streaming requests,
* or a conservative default (8192) for non-streaming requests to avoid timeout issues.
* *
* @param model - The model ID * @param model - The model ID
* @param streaming - Whether the request is streaming (default: false)
*/ */
export function getMaxOutputTokensForModel(model: string, streaming = false): number { export function getMaxOutputTokensForModel(model: string): number {
return getMaxOutputTokensForModelFromDefinitions(model, streaming) return getMaxOutputTokensForModelFromDefinitions(model)
} }
/** /**
@@ -1126,8 +1124,8 @@ export function createOpenAICompatibleStream(
* @returns Object with hasUsedForcedTool flag and updated usedForcedTools array * @returns Object with hasUsedForcedTool flag and updated usedForcedTools array
*/ */
export function checkForForcedToolUsageOpenAI( export function checkForForcedToolUsageOpenAI(
response: any, response: OpenAI.Chat.Completions.ChatCompletion,
toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any }, toolChoice: string | { type: string; function?: { name: string }; name?: string },
providerName: string, providerName: string,
forcedTools: string[], forcedTools: string[],
usedForcedTools: string[], usedForcedTools: string[],

View File

@@ -21,7 +21,8 @@ export function setupConnectionHandlers(socket: AuthenticatedSocket, roomManager
cleanupPendingSubblocksForSocket(socket.id) cleanupPendingSubblocksForSocket(socket.id)
cleanupPendingVariablesForSocket(socket.id) cleanupPendingVariablesForSocket(socket.id)
const workflowId = await roomManager.removeUserFromRoom(socket.id) const workflowIdHint = [...socket.rooms].find((roomId) => roomId !== socket.id)
const workflowId = await roomManager.removeUserFromRoom(socket.id, workflowIdHint)
if (workflowId) { if (workflowId) {
await roomManager.broadcastPresenceUpdate(workflowId) await roomManager.broadcastPresenceUpdate(workflowId)

View File

@@ -51,26 +51,66 @@ export function setupWorkflowHandlers(socket: AuthenticatedSocket, roomManager:
const currentWorkflowId = await roomManager.getWorkflowIdForSocket(socket.id) const currentWorkflowId = await roomManager.getWorkflowIdForSocket(socket.id)
if (currentWorkflowId) { if (currentWorkflowId) {
socket.leave(currentWorkflowId) socket.leave(currentWorkflowId)
await roomManager.removeUserFromRoom(socket.id) await roomManager.removeUserFromRoom(socket.id, currentWorkflowId)
await roomManager.broadcastPresenceUpdate(currentWorkflowId) await roomManager.broadcastPresenceUpdate(currentWorkflowId)
} }
const STALE_THRESHOLD_MS = 60_000 // Keep this above Redis socket key TTL (1h) so a normal idle user is not evicted too aggressively.
const STALE_THRESHOLD_MS = 75 * 60 * 1000
const now = Date.now() const now = Date.now()
const existingUsers = await roomManager.getWorkflowUsers(workflowId) const existingUsers = await roomManager.getWorkflowUsers(workflowId)
for (const existingUser of existingUsers) { let liveSocketIds = new Set<string>()
if (existingUser.userId === userId && existingUser.socketId !== socket.id) { let canCheckLiveness = false
const isSameTab = tabSessionId && existingUser.tabSessionId === tabSessionId
const isStale =
now - (existingUser.lastActivity || existingUser.joinedAt || 0) > STALE_THRESHOLD_MS
if (isSameTab || isStale) { try {
logger.info( const liveSockets = await roomManager.io.in(workflowId).fetchSockets()
`Cleaning up socket ${existingUser.socketId} for user ${userId} (${isSameTab ? 'same tab' : 'stale'})` liveSocketIds = new Set(liveSockets.map((liveSocket) => liveSocket.id))
canCheckLiveness = true
} catch (error) {
logger.warn(
`Skipping stale cleanup for ${workflowId} due to live socket lookup failure`,
error
) )
await roomManager.removeUserFromRoom(existingUser.socketId)
roomManager.io.in(existingUser.socketId).socketsLeave(workflowId)
} }
for (const existingUser of existingUsers) {
try {
if (existingUser.socketId === socket.id) {
continue
}
const isSameTab = Boolean(
existingUser.userId === userId &&
tabSessionId &&
existingUser.tabSessionId === tabSessionId
)
if (isSameTab) {
logger.info(
`Cleaning up socket ${existingUser.socketId} for user ${existingUser.userId} (same tab)`
)
await roomManager.removeUserFromRoom(existingUser.socketId, workflowId)
await roomManager.io.in(existingUser.socketId).socketsLeave(workflowId)
continue
}
if (!canCheckLiveness || liveSocketIds.has(existingUser.socketId)) {
continue
}
const isStaleByActivity =
now - (existingUser.lastActivity || existingUser.joinedAt || 0) > STALE_THRESHOLD_MS
if (!isStaleByActivity) {
continue
}
logger.info(
`Cleaning up socket ${existingUser.socketId} for user ${existingUser.userId} (stale activity)`
)
await roomManager.removeUserFromRoom(existingUser.socketId, workflowId)
await roomManager.io.in(existingUser.socketId).socketsLeave(workflowId)
} catch (error) {
logger.warn(`Best-effort cleanup failed for socket ${existingUser.socketId}`, error)
} }
} }
@@ -136,7 +176,7 @@ export function setupWorkflowHandlers(socket: AuthenticatedSocket, roomManager:
logger.error('Error joining workflow:', error) logger.error('Error joining workflow:', error)
// Undo socket.join and room manager entry if any operation failed // Undo socket.join and room manager entry if any operation failed
socket.leave(workflowId) socket.leave(workflowId)
await roomManager.removeUserFromRoom(socket.id) await roomManager.removeUserFromRoom(socket.id, workflowId)
const isReady = roomManager.isReady() const isReady = roomManager.isReady()
socket.emit('join-workflow-error', { socket.emit('join-workflow-error', {
error: isReady ? 'Failed to join workflow' : 'Realtime unavailable', error: isReady ? 'Failed to join workflow' : 'Realtime unavailable',
@@ -156,7 +196,7 @@ export function setupWorkflowHandlers(socket: AuthenticatedSocket, roomManager:
if (workflowId && session) { if (workflowId && session) {
socket.leave(workflowId) socket.leave(workflowId)
await roomManager.removeUserFromRoom(socket.id) await roomManager.removeUserFromRoom(socket.id, workflowId)
await roomManager.broadcastPresenceUpdate(workflowId) await roomManager.broadcastPresenceUpdate(workflowId)
logger.info(`User ${session.userId} (${session.userName}) left workflow ${workflowId}`) logger.info(`User ${session.userId} (${session.userName}) left workflow ${workflowId}`)

View File

@@ -66,7 +66,7 @@ export class MemoryRoomManager implements IRoomManager {
logger.debug(`Added user ${presence.userId} to workflow ${workflowId} (socket: ${socketId})`) logger.debug(`Added user ${presence.userId} to workflow ${workflowId} (socket: ${socketId})`)
} }
async removeUserFromRoom(socketId: string): Promise<string | null> { async removeUserFromRoom(socketId: string, _workflowIdHint?: string): Promise<string | null> {
const workflowId = this.socketToWorkflow.get(socketId) const workflowId = this.socketToWorkflow.get(socketId)
if (!workflowId) { if (!workflowId) {

View File

@@ -10,9 +10,11 @@ const KEYS = {
workflowMeta: (wfId: string) => `workflow:${wfId}:meta`, workflowMeta: (wfId: string) => `workflow:${wfId}:meta`,
socketWorkflow: (socketId: string) => `socket:${socketId}:workflow`, socketWorkflow: (socketId: string) => `socket:${socketId}:workflow`,
socketSession: (socketId: string) => `socket:${socketId}:session`, socketSession: (socketId: string) => `socket:${socketId}:session`,
socketPresenceWorkflow: (socketId: string) => `socket:${socketId}:presence-workflow`,
} as const } as const
const SOCKET_KEY_TTL = 3600 const SOCKET_KEY_TTL = 3600
const SOCKET_PRESENCE_WORKFLOW_KEY_TTL = 24 * 60 * 60
/** /**
* Lua script for atomic user removal from room. * Lua script for atomic user removal from room.
@@ -22,11 +24,21 @@ const SOCKET_KEY_TTL = 3600
const REMOVE_USER_SCRIPT = ` const REMOVE_USER_SCRIPT = `
local socketWorkflowKey = KEYS[1] local socketWorkflowKey = KEYS[1]
local socketSessionKey = KEYS[2] local socketSessionKey = KEYS[2]
local socketPresenceWorkflowKey = KEYS[3]
local workflowUsersPrefix = ARGV[1] local workflowUsersPrefix = ARGV[1]
local workflowMetaPrefix = ARGV[2] local workflowMetaPrefix = ARGV[2]
local socketId = ARGV[3] local socketId = ARGV[3]
local workflowIdHint = ARGV[4]
local workflowId = redis.call('GET', socketWorkflowKey) local workflowId = redis.call('GET', socketWorkflowKey)
if not workflowId then
workflowId = redis.call('GET', socketPresenceWorkflowKey)
end
if not workflowId and workflowIdHint ~= '' then
workflowId = workflowIdHint
end
if not workflowId then if not workflowId then
return nil return nil
end end
@@ -35,7 +47,7 @@ local workflowUsersKey = workflowUsersPrefix .. workflowId .. ':users'
local workflowMetaKey = workflowMetaPrefix .. workflowId .. ':meta' local workflowMetaKey = workflowMetaPrefix .. workflowId .. ':meta'
redis.call('HDEL', workflowUsersKey, socketId) redis.call('HDEL', workflowUsersKey, socketId)
redis.call('DEL', socketWorkflowKey, socketSessionKey) redis.call('DEL', socketWorkflowKey, socketSessionKey, socketPresenceWorkflowKey)
local remaining = redis.call('HLEN', workflowUsersKey) local remaining = redis.call('HLEN', workflowUsersKey)
if remaining == 0 then if remaining == 0 then
@@ -54,11 +66,13 @@ const UPDATE_ACTIVITY_SCRIPT = `
local workflowUsersKey = KEYS[1] local workflowUsersKey = KEYS[1]
local socketWorkflowKey = KEYS[2] local socketWorkflowKey = KEYS[2]
local socketSessionKey = KEYS[3] local socketSessionKey = KEYS[3]
local socketPresenceWorkflowKey = KEYS[4]
local socketId = ARGV[1] local socketId = ARGV[1]
local cursorJson = ARGV[2] local cursorJson = ARGV[2]
local selectionJson = ARGV[3] local selectionJson = ARGV[3]
local lastActivity = ARGV[4] local lastActivity = ARGV[4]
local ttl = tonumber(ARGV[5]) local ttl = tonumber(ARGV[5])
local presenceWorkflowTtl = tonumber(ARGV[6])
local existingJson = redis.call('HGET', workflowUsersKey, socketId) local existingJson = redis.call('HGET', workflowUsersKey, socketId)
if not existingJson then if not existingJson then
@@ -78,6 +92,7 @@ existing.lastActivity = tonumber(lastActivity)
redis.call('HSET', workflowUsersKey, socketId, cjson.encode(existing)) redis.call('HSET', workflowUsersKey, socketId, cjson.encode(existing))
redis.call('EXPIRE', socketWorkflowKey, ttl) redis.call('EXPIRE', socketWorkflowKey, ttl)
redis.call('EXPIRE', socketSessionKey, ttl) redis.call('EXPIRE', socketSessionKey, ttl)
redis.call('EXPIRE', socketPresenceWorkflowKey, presenceWorkflowTtl)
return 1 return 1
` `
@@ -164,6 +179,8 @@ export class RedisRoomManager implements IRoomManager {
pipeline.hSet(KEYS.workflowMeta(workflowId), 'lastModified', Date.now().toString()) pipeline.hSet(KEYS.workflowMeta(workflowId), 'lastModified', Date.now().toString())
pipeline.set(KEYS.socketWorkflow(socketId), workflowId) pipeline.set(KEYS.socketWorkflow(socketId), workflowId)
pipeline.expire(KEYS.socketWorkflow(socketId), SOCKET_KEY_TTL) pipeline.expire(KEYS.socketWorkflow(socketId), SOCKET_KEY_TTL)
pipeline.set(KEYS.socketPresenceWorkflow(socketId), workflowId)
pipeline.expire(KEYS.socketPresenceWorkflow(socketId), SOCKET_PRESENCE_WORKFLOW_KEY_TTL)
pipeline.hSet(KEYS.socketSession(socketId), { pipeline.hSet(KEYS.socketSession(socketId), {
userId: presence.userId, userId: presence.userId,
userName: presence.userName, userName: presence.userName,
@@ -187,7 +204,11 @@ export class RedisRoomManager implements IRoomManager {
} }
} }
async removeUserFromRoom(socketId: string, retried = false): Promise<string | null> { async removeUserFromRoom(
socketId: string,
workflowIdHint?: string,
retried = false
): Promise<string | null> {
if (!this.removeUserScriptSha) { if (!this.removeUserScriptSha) {
logger.error('removeUserFromRoom called before initialize()') logger.error('removeUserFromRoom called before initialize()')
return null return null
@@ -195,19 +216,25 @@ export class RedisRoomManager implements IRoomManager {
try { try {
const workflowId = await this.redis.evalSha(this.removeUserScriptSha, { const workflowId = await this.redis.evalSha(this.removeUserScriptSha, {
keys: [KEYS.socketWorkflow(socketId), KEYS.socketSession(socketId)], keys: [
arguments: ['workflow:', 'workflow:', socketId], KEYS.socketWorkflow(socketId),
KEYS.socketSession(socketId),
KEYS.socketPresenceWorkflow(socketId),
],
arguments: ['workflow:', 'workflow:', socketId, workflowIdHint ?? ''],
}) })
if (workflowId) { if (typeof workflowId === 'string' && workflowId.length > 0) {
logger.debug(`Removed socket ${socketId} from workflow ${workflowId}`) logger.debug(`Removed socket ${socketId} from workflow ${workflowId}`)
return workflowId
} }
return workflowId as string | null
return null
} catch (error) { } catch (error) {
if ((error as Error).message?.includes('NOSCRIPT') && !retried) { if ((error as Error).message?.includes('NOSCRIPT') && !retried) {
logger.warn('Lua script not found, reloading...') logger.warn('Lua script not found, reloading...')
this.removeUserScriptSha = await this.redis.scriptLoad(REMOVE_USER_SCRIPT) this.removeUserScriptSha = await this.redis.scriptLoad(REMOVE_USER_SCRIPT)
return this.removeUserFromRoom(socketId, true) return this.removeUserFromRoom(socketId, workflowIdHint, true)
} }
logger.error(`Failed to remove user from room: ${socketId}`, error) logger.error(`Failed to remove user from room: ${socketId}`, error)
return null return null
@@ -215,7 +242,12 @@ export class RedisRoomManager implements IRoomManager {
} }
async getWorkflowIdForSocket(socketId: string): Promise<string | null> { async getWorkflowIdForSocket(socketId: string): Promise<string | null> {
return this.redis.get(KEYS.socketWorkflow(socketId)) const workflowId = await this.redis.get(KEYS.socketWorkflow(socketId))
if (workflowId) {
return workflowId
}
return this.redis.get(KEYS.socketPresenceWorkflow(socketId))
} }
async getUserSession(socketId: string): Promise<UserSession | null> { async getUserSession(socketId: string): Promise<UserSession | null> {
@@ -278,6 +310,7 @@ export class RedisRoomManager implements IRoomManager {
KEYS.workflowUsers(workflowId), KEYS.workflowUsers(workflowId),
KEYS.socketWorkflow(socketId), KEYS.socketWorkflow(socketId),
KEYS.socketSession(socketId), KEYS.socketSession(socketId),
KEYS.socketPresenceWorkflow(socketId),
], ],
arguments: [ arguments: [
socketId, socketId,
@@ -285,6 +318,7 @@ export class RedisRoomManager implements IRoomManager {
updates.selection !== undefined ? JSON.stringify(updates.selection) : '', updates.selection !== undefined ? JSON.stringify(updates.selection) : '',
(updates.lastActivity ?? Date.now()).toString(), (updates.lastActivity ?? Date.now()).toString(),
SOCKET_KEY_TTL.toString(), SOCKET_KEY_TTL.toString(),
SOCKET_PRESENCE_WORKFLOW_KEY_TTL.toString(),
], ],
}) })
} catch (error) { } catch (error) {
@@ -348,7 +382,7 @@ export class RedisRoomManager implements IRoomManager {
// Remove all users from Redis state // Remove all users from Redis state
for (const user of users) { for (const user of users) {
await this.removeUserFromRoom(user.socketId) await this.removeUserFromRoom(user.socketId, workflowId)
} }
// Clean up room data // Clean up room data

View File

@@ -65,9 +65,10 @@ export interface IRoomManager {
/** /**
* Remove a user from their current room * Remove a user from their current room
* Returns the workflowId they were in, or null if not in any room * Optional workflowIdHint is used when socket mapping keys are missing/expired.
* Returns the workflowId they were in, or null if not in any room.
*/ */
removeUserFromRoom(socketId: string): Promise<string | null> removeUserFromRoom(socketId: string, workflowIdHint?: string): Promise<string | null>
/** /**
* Get the workflow ID for a socket * Get the workflow ID for a socket