diff --git a/.agents/skills/add-model/SKILL.md b/.agents/skills/add-model/SKILL.md index f97c966e1f3..e46f22c43e5 100644 --- a/.agents/skills/add-model/SKILL.md +++ b/.agents/skills/add-model/SKILL.md @@ -143,12 +143,12 @@ If anything matches, run the affected provider tests and update assertions as ne ### New API behavior is NOT data-driven -The Consumption Matrix (Step 2) tells you which capability *flags* are honored by existing provider code. But if the new model needs **net-new** request handling that the provider doesn't implement yet — a new beta header (e.g. Anthropic's `anthropic-beta` structured-outputs header in `anthropic/index.ts`), a new thinking/reasoning encoding, a Responses-API quirk — you must edit `apps/sim/providers//core.ts` / `index.ts`. Setting a flag whose behavior isn't implemented is a silent no-op. +The Consumption Matrix (Step 2) tells you which capability *flags* are honored by existing provider code. But if the new model needs **net-new** request handling that the provider doesn't implement yet — a new beta header (e.g. Anthropic's `anthropic-beta` structured-outputs header in `anthropic/index.ts`), a new thinking/reasoning encoding, a Responses-API quirk — you must edit `apps/sim/providers//core.ts` / `index.ts`. Setting a flag whose behavior isn't implemented is a silent no-op. When you do edit provider code, reuse the shared helpers rather than hand-rolling: streaming responses are assembled via `createStreamingExecution` (`@/providers/streaming-execution`) and tool schemas via `adaptOpenAIChatToolSchema` / `adaptAnthropicToolSchema` (`@/providers/tool-schema-adapter`). ### Wrong family entirely? - **Embedding or rerank model** → it does NOT go in the `models[]` array. Use `EMBEDDING_MODEL_PRICING` / `RERANK_MODEL_PRICING` in `models.ts` instead. -- **Brand-new provider** (not just a new model under an existing one) → much larger surface: add the id to `ProviderId` in `providers/types.ts`, a registry entry in `providers/registry.ts`, a provider implementation under `providers//`, an icon in `components/icons.tsx`, and the `PROVIDER_DEFINITIONS` block. That is beyond this skill — tell the user. +- **Brand-new provider** (not just a new model under an existing one) → much larger surface: add the id to `ProviderId` in `providers/types.ts`, a registry entry in `providers/registry.ts`, a provider implementation under `providers//` (assemble streaming responses with `createStreamingExecution` and wrap tool schemas with the `@/providers/tool-schema-adapter` helpers), an icon in `components/icons.tsx`, and the `PROVIDER_DEFINITIONS` block. That is beyond this skill — tell the user. ## Step 5: Write, lint diff --git a/apps/sim/executor/execution/state.ts b/apps/sim/executor/execution/state.ts index c12cca8bb86..8f7567d06a8 100644 --- a/apps/sim/executor/execution/state.ts +++ b/apps/sim/executor/execution/state.ts @@ -1,24 +1,22 @@ import type { BlockStateController } from '@/executor/execution/types' import type { BlockState, NormalizedBlockOutput } from '@/executor/types' +import { SubflowNodeIdCodec } from '@/executor/utils/subflow-node-id-codec' import { buildOuterBranchScopedId, extractOuterBranchIndex, stripCloneSuffixes, } from '@/executor/utils/subflow-utils' -const BRANCH_SUFFIX_PATTERN = /₍\d+₎/u -const LOOP_SUFFIX_PATTERN = /_loop\d+/ - function normalizeLookupId(id: string): string { - return id.replace(/₍\d+₎/gu, '').replace(/_loop\d+/g, '') + return SubflowNodeIdCodec.normalizeLookupId(id) } function extractBranchSuffix(id: string): string { - return id.match(BRANCH_SUFFIX_PATTERN)?.[0] ?? '' + return SubflowNodeIdCodec.extractBranchSuffix(id) } function extractLoopSuffix(id: string): string { - return id.match(LOOP_SUFFIX_PATTERN)?.[0] ?? '' + return SubflowNodeIdCodec.extractLoopSuffix(id) } export interface LoopScope { iteration: number diff --git a/apps/sim/executor/utils/subflow-node-id-codec.test.ts b/apps/sim/executor/utils/subflow-node-id-codec.test.ts new file mode 100644 index 00000000000..c38fdc3ecc3 --- /dev/null +++ b/apps/sim/executor/utils/subflow-node-id-codec.test.ts @@ -0,0 +1,175 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { SubflowNodeIdCodec } from '@/executor/utils/subflow-node-id-codec' + +describe('SubflowNodeIdCodec', () => { + describe('branch subscripts', () => { + it('builds and round-trips branch node IDs', () => { + const id = SubflowNodeIdCodec.buildBranchNodeId('block-1', 2) + expect(id).toBe('block-1₍2₎') + expect(SubflowNodeIdCodec.isBranchNodeId(id)).toBe(true) + expect(SubflowNodeIdCodec.extractBaseBlockId(id)).toBe('block-1') + expect(SubflowNodeIdCodec.extractBranchIndex(id)).toBe(2) + }) + + it('returns null index and false predicate for non-branch IDs', () => { + expect(SubflowNodeIdCodec.isBranchNodeId('block-1')).toBe(false) + expect(SubflowNodeIdCodec.extractBranchIndex('block-1')).toBeNull() + expect(SubflowNodeIdCodec.extractBaseBlockId('block-1')).toBe('block-1') + }) + + it('only strips a trailing branch subscript', () => { + expect(SubflowNodeIdCodec.extractBaseBlockId('a₍1₎b')).toBe('a₍1₎b') + expect(SubflowNodeIdCodec.extractBaseBlockId('a₍1₎b₍2₎')).toBe('a₍1₎b') + }) + }) + + describe('loop sentinels', () => { + it('builds and parses loop sentinel IDs', () => { + const start = SubflowNodeIdCodec.buildLoopSentinelStartId('loop-1') + const end = SubflowNodeIdCodec.buildLoopSentinelEndId('loop-1') + expect(start).toBe('loop-loop-1-sentinel-start') + expect(end).toBe('loop-loop-1-sentinel-end') + expect(SubflowNodeIdCodec.isLoopSentinelNodeId(start)).toBe(true) + expect(SubflowNodeIdCodec.isLoopSentinelNodeId(end)).toBe(true) + expect(SubflowNodeIdCodec.extractLoopIdFromSentinel(start)).toBe('loop-1') + expect(SubflowNodeIdCodec.extractLoopIdFromSentinel(end)).toBe('loop-1') + }) + + it('returns null when not a loop sentinel', () => { + expect(SubflowNodeIdCodec.isLoopSentinelNodeId('block-1')).toBe(false) + expect(SubflowNodeIdCodec.extractLoopIdFromSentinel('block-1')).toBeNull() + }) + }) + + describe('parallel sentinels', () => { + it('builds and parses parallel sentinel IDs', () => { + const start = SubflowNodeIdCodec.buildParallelSentinelStartId('p-1') + const end = SubflowNodeIdCodec.buildParallelSentinelEndId('p-1') + expect(start).toBe('parallel-p-1-sentinel-start') + expect(end).toBe('parallel-p-1-sentinel-end') + expect(SubflowNodeIdCodec.isParallelSentinelNodeId(start)).toBe(true) + expect(SubflowNodeIdCodec.isParallelSentinelNodeId(end)).toBe(true) + expect(SubflowNodeIdCodec.extractParallelIdFromSentinel(start)).toBe('p-1') + expect(SubflowNodeIdCodec.extractParallelIdFromSentinel(end)).toBe('p-1') + }) + + it('returns null when not a parallel sentinel', () => { + expect(SubflowNodeIdCodec.isParallelSentinelNodeId('block-1')).toBe(false) + expect(SubflowNodeIdCodec.extractParallelIdFromSentinel('block-1')).toBeNull() + }) + }) + + describe('outer-branch clone scoping', () => { + it('builds and extracts outer branch index', () => { + const id = SubflowNodeIdCodec.buildOuterBranchScopedId('loop-1', 3) + expect(id).toBe('loop-1__obranch-3') + expect(SubflowNodeIdCodec.extractOuterBranchIndex(id)).toBe(3) + }) + + it('extracts the innermost outer branch index for nested clones', () => { + const id = 'loop-1__obranch-2__obranch-5' + expect(SubflowNodeIdCodec.extractOuterBranchIndex(id)).toBe(2) + expect(SubflowNodeIdCodec.extractInnermostOuterBranchIndex(id)).toBe(5) + }) + + it('returns undefined when no outer branch suffix is present', () => { + expect(SubflowNodeIdCodec.extractOuterBranchIndex('loop-1')).toBeUndefined() + expect(SubflowNodeIdCodec.extractInnermostOuterBranchIndex('loop-1')).toBeUndefined() + }) + + it('strips outer-branch and clone-digest suffixes', () => { + expect(SubflowNodeIdCodec.stripOuterBranchSuffix('loop-1__obranch-2')).toBe('loop-1') + expect(SubflowNodeIdCodec.stripOuterBranchSuffix('loop-1__cloneABCDEF__obranch-2')).toBe( + 'loop-1' + ) + }) + + it('strips all clone suffixes and branch subscripts to the base block ID', () => { + expect(SubflowNodeIdCodec.stripCloneSuffixes('block-1__obranch-2₍3₎')).toBe('block-1') + expect(SubflowNodeIdCodec.stripCloneSuffixes('block-1__clone0a1f__obranch-2₍0₎')).toBe( + 'block-1' + ) + }) + }) + + describe('normalizeNodeId', () => { + it('normalizes branch, loop sentinel, and parallel sentinel IDs', () => { + expect(SubflowNodeIdCodec.normalizeNodeId('block-1₍2₎')).toBe('block-1') + expect(SubflowNodeIdCodec.normalizeNodeId('loop-loop-1-sentinel-start')).toBe('loop-1') + expect(SubflowNodeIdCodec.normalizeNodeId('parallel-p-1-sentinel-end')).toBe('p-1') + expect(SubflowNodeIdCodec.normalizeNodeId('block-1')).toBe('block-1') + }) + }) + + describe('loop digest lookup helpers', () => { + it('strips branch subscripts and loop digests for lookup keys', () => { + expect(SubflowNodeIdCodec.normalizeLookupId('block-1₍2₎_loop3')).toBe('block-1') + expect(SubflowNodeIdCodec.normalizeLookupId('block-1')).toBe('block-1') + }) + + it('extracts the leading branch suffix and loop digest segments', () => { + expect(SubflowNodeIdCodec.extractBranchSuffix('block-1₍2₎_loop3')).toBe('₍2₎') + expect(SubflowNodeIdCodec.extractBranchSuffix('block-1')).toBe('') + expect(SubflowNodeIdCodec.extractLoopSuffix('block-1₍2₎_loop3')).toBe('_loop3') + expect(SubflowNodeIdCodec.extractLoopSuffix('block-1')).toBe('') + }) + }) + + describe('findEffectiveContainerId', () => { + it('returns the original ID for branch 0 / missing scope', () => { + const map = new Map([['loop-1', {}]]) + expect(SubflowNodeIdCodec.findEffectiveContainerId('loop-1', 'block-1', map)).toBe('loop-1') + }) + + it('prefers the mapped cloned scope when present', () => { + const map = new Map([ + ['loop-1', {}], + ['loop-1__obranch-2', {}], + ]) + expect(SubflowNodeIdCodec.findEffectiveContainerId('loop-1', 'block-1', map, 2)).toBe( + 'loop-1__obranch-2' + ) + }) + + it('resolves the cloned scope from the current node ID suffix', () => { + const map = new Map([ + ['loop-1', {}], + ['loop-1__obranch-3', {}], + ]) + expect(SubflowNodeIdCodec.findEffectiveContainerId('loop-1', 'block-1__obranch-3', map)).toBe( + 'loop-1__obranch-3' + ) + }) + + it('prefers __clone scopes when the current node carries a clone marker', () => { + const map = new Map([ + ['loop-1__obranch-2', {}], + ['loop-1__cloneabc__obranch-2', {}], + ]) + expect( + SubflowNodeIdCodec.findEffectiveContainerId('loop-1', 'block-1__cloneabc__obranch-2', map) + ).toBe('loop-1__cloneabc__obranch-2') + }) + }) + + describe('round-trip parse ∘ build', () => { + it('builds then parses branch IDs symmetrically', () => { + for (const index of [0, 1, 7, 20]) { + const id = SubflowNodeIdCodec.buildBranchNodeId('base-id', index) + expect(SubflowNodeIdCodec.extractBranchIndex(id)).toBe(index) + expect(SubflowNodeIdCodec.extractBaseBlockId(id)).toBe('base-id') + } + }) + + it('builds then parses outer-branch scoped IDs symmetrically', () => { + for (const index of [1, 4, 19]) { + const id = SubflowNodeIdCodec.buildOuterBranchScopedId('base-id', index) + expect(SubflowNodeIdCodec.extractOuterBranchIndex(id)).toBe(index) + expect(SubflowNodeIdCodec.stripOuterBranchSuffix(id)).toBe('base-id') + } + }) + }) +}) diff --git a/apps/sim/executor/utils/subflow-node-id-codec.ts b/apps/sim/executor/utils/subflow-node-id-codec.ts new file mode 100644 index 00000000000..8b7aa2ecdff --- /dev/null +++ b/apps/sim/executor/utils/subflow-node-id-codec.ts @@ -0,0 +1,263 @@ +import { LOOP, PARALLEL } from '@/executor/constants' + +/** + * Single source of truth for parsing and building subflow node IDs. + * + * Runtime node IDs layer several encodings onto a workflow-level block ID: + * - Branch subscripts `₍N₎` mark a parallel branch instance. + * - Loop/parallel sentinels wrap a container ID (`loop-{id}-sentinel-start`). + * - Outer-branch clone suffixes `__obranch-N` and clone digests `__clone{hex}` + * scope a cloned subflow to a global outer parallel branch. + * - Loop digests `_loopN` scope a block output to a loop iteration. + * + * All regexes and string templates for these encodings live here so callers + * never reconstruct them inline. + */ + +const SENTINEL = { + LOOP_START: new RegExp(`${LOOP.SENTINEL.PREFIX}(.+)${LOOP.SENTINEL.START_SUFFIX}`), + LOOP_END: new RegExp(`${LOOP.SENTINEL.PREFIX}(.+)${LOOP.SENTINEL.END_SUFFIX}`), + PARALLEL_START: new RegExp(`${PARALLEL.SENTINEL.PREFIX}(.+)${PARALLEL.SENTINEL.START_SUFFIX}`), + PARALLEL_END: new RegExp(`${PARALLEL.SENTINEL.PREFIX}(.+)${PARALLEL.SENTINEL.END_SUFFIX}`), +} as const + +const BRANCH = { + MATCH: new RegExp(`${PARALLEL.BRANCH.PREFIX}\\d+${PARALLEL.BRANCH.SUFFIX}$`), + INDEX: new RegExp(`${PARALLEL.BRANCH.PREFIX}(\\d+)${PARALLEL.BRANCH.SUFFIX}$`), + SUFFIX: /₍\d+₎/u, + SUFFIX_GLOBAL: /₍\d+₎/gu, +} as const + +const OUTER_BRANCH = { + MATCH: /__obranch-(\d+)/, + MATCH_GLOBAL: /__obranch-(\d+)/g, + STRIP: /__obranch-\d+/g, +} as const + +const CLONE = { + DIGEST_STRIP: /__clone[0-9a-f]+/gi, + MARKER: '__clone', +} as const + +const LOOP_DIGEST = { + MATCH: /_loop\d+/, + STRIP: /_loop\d+/g, +} as const + +/** + * Builds the loop sentinel-start node ID for a container. + */ +function buildLoopSentinelStartId(loopId: string): string { + return `${LOOP.SENTINEL.PREFIX}${loopId}${LOOP.SENTINEL.START_SUFFIX}` +} + +/** + * Builds the loop sentinel-end node ID for a container. + */ +function buildLoopSentinelEndId(loopId: string): string { + return `${LOOP.SENTINEL.PREFIX}${loopId}${LOOP.SENTINEL.END_SUFFIX}` +} + +/** + * Builds the parallel sentinel-start node ID for a container. + */ +function buildParallelSentinelStartId(parallelId: string): string { + return `${PARALLEL.SENTINEL.PREFIX}${parallelId}${PARALLEL.SENTINEL.START_SUFFIX}` +} + +/** + * Builds the parallel sentinel-end node ID for a container. + */ +function buildParallelSentinelEndId(parallelId: string): string { + return `${PARALLEL.SENTINEL.PREFIX}${parallelId}${PARALLEL.SENTINEL.END_SUFFIX}` +} + +function isLoopSentinelNodeId(nodeId: string): boolean { + return ( + nodeId.startsWith(LOOP.SENTINEL.PREFIX) && + (nodeId.endsWith(LOOP.SENTINEL.START_SUFFIX) || nodeId.endsWith(LOOP.SENTINEL.END_SUFFIX)) + ) +} + +function isParallelSentinelNodeId(nodeId: string): boolean { + return ( + nodeId.startsWith(PARALLEL.SENTINEL.PREFIX) && + (nodeId.endsWith(PARALLEL.SENTINEL.START_SUFFIX) || + nodeId.endsWith(PARALLEL.SENTINEL.END_SUFFIX)) + ) +} + +function extractLoopIdFromSentinel(sentinelId: string): string | null { + const startMatch = sentinelId.match(SENTINEL.LOOP_START) + if (startMatch) return startMatch[1] + const endMatch = sentinelId.match(SENTINEL.LOOP_END) + if (endMatch) return endMatch[1] + return null +} + +function extractParallelIdFromSentinel(sentinelId: string): string | null { + const startMatch = sentinelId.match(SENTINEL.PARALLEL_START) + if (startMatch) return startMatch[1] + const endMatch = sentinelId.match(SENTINEL.PARALLEL_END) + if (endMatch) return endMatch[1] + return null +} + +function buildBranchNodeId(baseId: string, branchIndex: number): string { + return `${baseId}${PARALLEL.BRANCH.PREFIX}${branchIndex}${PARALLEL.BRANCH.SUFFIX}` +} + +function extractBaseBlockId(branchNodeId: string): string { + return branchNodeId.replace(BRANCH.MATCH, '') +} + +function extractBranchIndex(branchNodeId: string): number | null { + const match = branchNodeId.match(BRANCH.INDEX) + return match ? Number.parseInt(match[1], 10) : null +} + +function isBranchNodeId(nodeId: string): boolean { + return BRANCH.MATCH.test(nodeId) +} + +function extractOuterBranchIndex(clonedId: string): number | undefined { + const match = clonedId.match(OUTER_BRANCH.MATCH) + return match ? Number.parseInt(match[1], 10) : undefined +} + +function extractInnermostOuterBranchIndex(clonedId: string): number | undefined { + const matches = Array.from(clonedId.matchAll(OUTER_BRANCH.MATCH_GLOBAL)) + const lastMatch = matches.at(-1) + return lastMatch ? Number.parseInt(lastMatch[1], 10) : undefined +} + +function stripCloneSuffixes(nodeId: string): string { + return extractBaseBlockId(nodeId.replace(OUTER_BRANCH.STRIP, '').replace(CLONE.DIGEST_STRIP, '')) +} + +function buildOuterBranchScopedId(originalId: string, branchIndex: number): string { + return `${originalId}__obranch-${branchIndex}` +} + +function stripOuterBranchSuffix(id: string): string { + return id.replace(OUTER_BRANCH.STRIP, '').replace(CLONE.DIGEST_STRIP, '') +} + +function hasCloneMarker(id: string): boolean { + return id.includes(CLONE.MARKER) +} + +function normalizeNodeId(nodeId: string): string { + if (isBranchNodeId(nodeId)) { + return extractBaseBlockId(nodeId) + } + if (isLoopSentinelNodeId(nodeId)) { + return extractLoopIdFromSentinel(nodeId) || nodeId + } + if (isParallelSentinelNodeId(nodeId)) { + return extractParallelIdFromSentinel(nodeId) || nodeId + } + return nodeId +} + +function findEffectiveContainerId( + originalId: string, + currentNodeId: string, + executionMap: Map, + mappedBranchIndex?: number +): string { + if (mappedBranchIndex !== undefined && mappedBranchIndex > 0) { + const cloneSuffix = `__obranch-${mappedBranchIndex}` + const candidateId = buildOuterBranchScopedId(originalId, mappedBranchIndex) + if (executionMap.has(candidateId)) { + return candidateId + } + + for (const scopeId of executionMap.keys()) { + if (scopeId.endsWith(cloneSuffix) && stripOuterBranchSuffix(scopeId) === originalId) { + return scopeId + } + } + } + + const match = currentNodeId.match(OUTER_BRANCH.MATCH) + if (match) { + const branchIndex = Number.parseInt(match[1], 10) + const cloneSuffix = `__obranch-${branchIndex}` + if (hasCloneMarker(currentNodeId)) { + for (const scopeId of executionMap.keys()) { + if ( + hasCloneMarker(scopeId) && + scopeId.endsWith(cloneSuffix) && + stripOuterBranchSuffix(scopeId) === originalId + ) { + return scopeId + } + } + } + + const candidateId = buildOuterBranchScopedId(originalId, branchIndex) + if (executionMap.has(candidateId)) { + return candidateId + } + + for (const scopeId of executionMap.keys()) { + if (scopeId.endsWith(cloneSuffix) && stripOuterBranchSuffix(scopeId) === originalId) { + return scopeId + } + } + } + + return originalId +} + +/** + * Strips branch subscripts (`₍N₎`) and loop digests (`_loopN`) from a node ID, + * yielding the lookup key used by execution-state block-output resolution. + */ +function normalizeLookupId(id: string): string { + return id.replace(BRANCH.SUFFIX_GLOBAL, '').replace(LOOP_DIGEST.STRIP, '') +} + +/** + * Returns the leading branch subscript (`₍N₎`) of a node ID, or '' when absent. + */ +function extractBranchSuffix(id: string): string { + return id.match(BRANCH.SUFFIX)?.[0] ?? '' +} + +/** + * Returns the loop digest segment (`_loopN`) of a node ID, or '' when absent. + */ +function extractLoopSuffix(id: string): string { + return id.match(LOOP_DIGEST.MATCH)?.[0] ?? '' +} + +/** + * Codec exposing all subflow node-ID parsing/building operations as a single, + * pattern-free interface. Implementation owns every regex and string template. + */ +export const SubflowNodeIdCodec = { + buildLoopSentinelStartId, + buildLoopSentinelEndId, + buildParallelSentinelStartId, + buildParallelSentinelEndId, + isLoopSentinelNodeId, + isParallelSentinelNodeId, + extractLoopIdFromSentinel, + extractParallelIdFromSentinel, + buildBranchNodeId, + extractBaseBlockId, + extractBranchIndex, + isBranchNodeId, + extractOuterBranchIndex, + extractInnermostOuterBranchIndex, + stripCloneSuffixes, + buildOuterBranchScopedId, + stripOuterBranchSuffix, + normalizeNodeId, + findEffectiveContainerId, + normalizeLookupId, + extractBranchSuffix, + extractLoopSuffix, +} as const diff --git a/apps/sim/executor/utils/subflow-utils.ts b/apps/sim/executor/utils/subflow-utils.ts index 8a5846b7a5c..9cd0b44f27d 100644 --- a/apps/sim/executor/utils/subflow-utils.ts +++ b/apps/sim/executor/utils/subflow-utils.ts @@ -1,57 +1,42 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { DEFAULTS, LOOP, PARALLEL } from '@/executor/constants' +import { DEFAULTS } from '@/executor/constants' import type { ContextExtensions } from '@/executor/execution/types' import { type BlockLog, type ExecutionContext, getNextExecutionOrder } from '@/executor/types' import { buildContainerIterationContext } from '@/executor/utils/iteration-context' +import { SubflowNodeIdCodec } from '@/executor/utils/subflow-node-id-codec' import type { SerializedWorkflow } from '@/serializer/types' const logger = createLogger('SubflowUtils') -const BRANCH_PATTERN = new RegExp(`${PARALLEL.BRANCH.PREFIX}\\d+${PARALLEL.BRANCH.SUFFIX}$`) -const BRANCH_INDEX_PATTERN = new RegExp(`${PARALLEL.BRANCH.PREFIX}(\\d+)${PARALLEL.BRANCH.SUFFIX}$`) -const LOOP_SENTINEL_START_PATTERN = new RegExp( - `${LOOP.SENTINEL.PREFIX}(.+)${LOOP.SENTINEL.START_SUFFIX}` -) -const LOOP_SENTINEL_END_PATTERN = new RegExp( - `${LOOP.SENTINEL.PREFIX}(.+)${LOOP.SENTINEL.END_SUFFIX}` -) -const PARALLEL_SENTINEL_START_PATTERN = new RegExp( - `${PARALLEL.SENTINEL.PREFIX}(.+)${PARALLEL.SENTINEL.START_SUFFIX}` -) -const PARALLEL_SENTINEL_END_PATTERN = new RegExp( - `${PARALLEL.SENTINEL.PREFIX}(.+)${PARALLEL.SENTINEL.END_SUFFIX}` -) - +/** + * Builds the loop sentinel-start node ID for a container. + */ export function buildSentinelStartId(loopId: string): string { - return `${LOOP.SENTINEL.PREFIX}${loopId}${LOOP.SENTINEL.START_SUFFIX}` + return SubflowNodeIdCodec.buildLoopSentinelStartId(loopId) } +/** + * Builds the loop sentinel-end node ID for a container. + */ export function buildSentinelEndId(loopId: string): string { - return `${LOOP.SENTINEL.PREFIX}${loopId}${LOOP.SENTINEL.END_SUFFIX}` + return SubflowNodeIdCodec.buildLoopSentinelEndId(loopId) } export function buildParallelSentinelStartId(parallelId: string): string { - return `${PARALLEL.SENTINEL.PREFIX}${parallelId}${PARALLEL.SENTINEL.START_SUFFIX}` + return SubflowNodeIdCodec.buildParallelSentinelStartId(parallelId) } export function buildParallelSentinelEndId(parallelId: string): string { - return `${PARALLEL.SENTINEL.PREFIX}${parallelId}${PARALLEL.SENTINEL.END_SUFFIX}` + return SubflowNodeIdCodec.buildParallelSentinelEndId(parallelId) } export function isLoopSentinelNodeId(nodeId: string): boolean { - return ( - nodeId.startsWith(LOOP.SENTINEL.PREFIX) && - (nodeId.endsWith(LOOP.SENTINEL.START_SUFFIX) || nodeId.endsWith(LOOP.SENTINEL.END_SUFFIX)) - ) + return SubflowNodeIdCodec.isLoopSentinelNodeId(nodeId) } export function isParallelSentinelNodeId(nodeId: string): boolean { - return ( - nodeId.startsWith(PARALLEL.SENTINEL.PREFIX) && - (nodeId.endsWith(PARALLEL.SENTINEL.START_SUFFIX) || - nodeId.endsWith(PARALLEL.SENTINEL.END_SUFFIX)) - ) + return SubflowNodeIdCodec.isParallelSentinelNodeId(nodeId) } export function isSentinelNodeId(nodeId: string): boolean { @@ -59,19 +44,11 @@ export function isSentinelNodeId(nodeId: string): boolean { } export function extractLoopIdFromSentinel(sentinelId: string): string | null { - const startMatch = sentinelId.match(LOOP_SENTINEL_START_PATTERN) - if (startMatch) return startMatch[1] - const endMatch = sentinelId.match(LOOP_SENTINEL_END_PATTERN) - if (endMatch) return endMatch[1] - return null + return SubflowNodeIdCodec.extractLoopIdFromSentinel(sentinelId) } export function extractParallelIdFromSentinel(sentinelId: string): string | null { - const startMatch = sentinelId.match(PARALLEL_SENTINEL_START_PATTERN) - if (startMatch) return startMatch[1] - const endMatch = sentinelId.match(PARALLEL_SENTINEL_END_PATTERN) - if (endMatch) return endMatch[1] - return null + return SubflowNodeIdCodec.extractParallelIdFromSentinel(sentinelId) } /** @@ -79,39 +56,32 @@ export function extractParallelIdFromSentinel(sentinelId: string): string | null * Example: ("blockId", 2) → "blockId₍2₎" */ export function buildBranchNodeId(baseId: string, branchIndex: number): string { - return `${baseId}${PARALLEL.BRANCH.PREFIX}${branchIndex}${PARALLEL.BRANCH.SUFFIX}` + return SubflowNodeIdCodec.buildBranchNodeId(baseId, branchIndex) } + export function extractBaseBlockId(branchNodeId: string): string { - return branchNodeId.replace(BRANCH_PATTERN, '') + return SubflowNodeIdCodec.extractBaseBlockId(branchNodeId) } export function extractBranchIndex(branchNodeId: string): number | null { - const match = branchNodeId.match(BRANCH_INDEX_PATTERN) - return match ? Number.parseInt(match[1], 10) : null + return SubflowNodeIdCodec.extractBranchIndex(branchNodeId) } export function isBranchNodeId(nodeId: string): boolean { - return BRANCH_PATTERN.test(nodeId) + return SubflowNodeIdCodec.isBranchNodeId(nodeId) } -const OUTER_BRANCH_PATTERN = /__obranch-(\d+)/ -const OUTER_BRANCH_STRIP_PATTERN = /__obranch-\d+/g -const CLONE_DIGEST_STRIP_PATTERN = /__clone[0-9a-f]+/gi - /** * Extracts the outer branch index from a cloned subflow ID. * Cloned IDs follow the pattern `{originalId}__obranch-{index}`. * Returns undefined if the ID is not a clone. */ export function extractOuterBranchIndex(clonedId: string): number | undefined { - const match = clonedId.match(OUTER_BRANCH_PATTERN) - return match ? Number.parseInt(match[1], 10) : undefined + return SubflowNodeIdCodec.extractOuterBranchIndex(clonedId) } export function extractInnermostOuterBranchIndex(clonedId: string): number | undefined { - const matches = Array.from(clonedId.matchAll(/__obranch-(\d+)/g)) - const lastMatch = matches.at(-1) - return lastMatch ? Number.parseInt(lastMatch[1], 10) : undefined + return SubflowNodeIdCodec.extractInnermostOuterBranchIndex(clonedId) } /** @@ -119,23 +89,21 @@ export function extractInnermostOuterBranchIndex(clonedId: string): number | und * from a node ID, returning the original workflow-level block ID. */ export function stripCloneSuffixes(nodeId: string): string { - return extractBaseBlockId( - nodeId.replace(OUTER_BRANCH_STRIP_PATTERN, '').replace(CLONE_DIGEST_STRIP_PATTERN, '') - ) + return SubflowNodeIdCodec.stripCloneSuffixes(nodeId) } /** * Builds a stable ID for an output scoped to a global outer parallel branch. */ export function buildOuterBranchScopedId(originalId: string, branchIndex: number): string { - return `${originalId}__obranch-${branchIndex}` + return SubflowNodeIdCodec.buildOuterBranchScopedId(originalId, branchIndex) } /** * Builds a cloned subflow ID from an original ID and outer branch index. */ export function buildClonedSubflowId(originalId: string, branchIndex: number): string { - return buildOuterBranchScopedId(originalId, branchIndex) + return SubflowNodeIdCodec.buildOuterBranchScopedId(originalId, branchIndex) } /** @@ -143,7 +111,7 @@ export function buildClonedSubflowId(originalId: string, branchIndex: number): s * returning the original workflow-level subflow ID. */ export function stripOuterBranchSuffix(id: string): string { - return id.replace(OUTER_BRANCH_STRIP_PATTERN, '').replace(CLONE_DIGEST_STRIP_PATTERN, '') + return SubflowNodeIdCodec.stripOuterBranchSuffix(id) } /** @@ -163,67 +131,16 @@ export function findEffectiveContainerId( executionMap: Map, mappedBranchIndex?: number ): string { - if (mappedBranchIndex !== undefined && mappedBranchIndex > 0) { - const cloneSuffix = `__obranch-${mappedBranchIndex}` - const candidateId = buildClonedSubflowId(originalId, mappedBranchIndex) - if (executionMap.has(candidateId)) { - return candidateId - } - - for (const scopeId of executionMap.keys()) { - if (scopeId.endsWith(cloneSuffix) && stripOuterBranchSuffix(scopeId) === originalId) { - return scopeId - } - } - } - - // Prefer the cloned variant when currentNodeId carries an __obranch-N suffix. - // During concurrent parallel-in-loop execution both the original (branch 0) - // and cloned variants coexist in the map; the clone is the correct scope. - const match = currentNodeId.match(OUTER_BRANCH_PATTERN) - if (match) { - const branchIndex = Number.parseInt(match[1], 10) - const cloneSuffix = `__obranch-${branchIndex}` - if (currentNodeId.includes('__clone')) { - for (const scopeId of executionMap.keys()) { - if ( - scopeId.includes('__clone') && - scopeId.endsWith(cloneSuffix) && - stripOuterBranchSuffix(scopeId) === originalId - ) { - return scopeId - } - } - } - - const candidateId = buildClonedSubflowId(originalId, branchIndex) - if (executionMap.has(candidateId)) { - return candidateId - } - - for (const scopeId of executionMap.keys()) { - if (scopeId.endsWith(cloneSuffix) && stripOuterBranchSuffix(scopeId) === originalId) { - return scopeId - } - } - } - - // Return original ID — for branch-0 (non-cloned) or when scope is missing. - // Callers handle the missing-scope case gracefully. - return originalId + return SubflowNodeIdCodec.findEffectiveContainerId( + originalId, + currentNodeId, + executionMap, + mappedBranchIndex + ) } export function normalizeNodeId(nodeId: string): string { - if (isBranchNodeId(nodeId)) { - return extractBaseBlockId(nodeId) - } - if (isLoopSentinelNodeId(nodeId)) { - return extractLoopIdFromSentinel(nodeId) || nodeId - } - if (isParallelSentinelNodeId(nodeId)) { - return extractParallelIdFromSentinel(nodeId) || nodeId - } - return nodeId + return SubflowNodeIdCodec.normalizeNodeId(nodeId) } type SubflowContainerType = 'loop' | 'parallel' diff --git a/apps/sim/providers/anthropic/core.ts b/apps/sim/providers/anthropic/core.ts index c265d1e903c..57056e6acca 100644 --- a/apps/sim/providers/anthropic/core.ts +++ b/apps/sim/providers/anthropic/core.ts @@ -16,6 +16,8 @@ import { supportsNativeStructuredOutputs, supportsTemperature, } from '@/providers/models' +import { createStreamingExecution } from '@/providers/streaming-execution' +import { adaptAnthropicToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegment } from '@/providers/trace-enrichment' import type { ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types' import { ProviderError } from '@/providers/types' @@ -251,15 +253,7 @@ export async function executeAnthropicProviderRequest( } let anthropicTools: Anthropic.Messages.Tool[] | undefined = request.tools?.length - ? request.tools.map((tool) => ({ - name: tool.id, - description: tool.description, - input_schema: { - type: 'object' as const, - properties: tool.parameters.properties, - required: tool.parameters.required, - }, - })) + ? request.tools.map((tool) => adaptAnthropicToolSchema(tool)) : undefined let toolChoice: 'none' | 'auto' | { type: 'tool'; name: string } = 'auto' @@ -403,79 +397,38 @@ export async function executeAnthropicProviderRequest( request.abortSignal ? { signal: request.abortSignal } : undefined ) - const streamingResult = { - stream: createReadableStreamFromAnthropicStream( - streamResponse as AsyncIterable, - (content, usage) => { - streamingResult.execution.output.content = content - streamingResult.execution.output.tokens = { - input: usage.input_tokens, - output: usage.output_tokens, - total: usage.input_tokens + usage.output_tokens, - } - - const costResult = calculateCost(request.model, usage.input_tokens, usage.output_tokens) - streamingResult.execution.output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } - - const streamEndTime = Date.now() - const streamEndTimeISO = new Date(streamEndTime).toISOString() - - if (streamingResult.execution.output.providerTiming) { - streamingResult.execution.output.providerTiming.endTime = streamEndTimeISO - streamingResult.execution.output.providerTiming.duration = - streamEndTime - providerStartTime + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: request.model }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { total: 0.0, input: 0.0, output: 0.0 }, + isStreaming: true, + createStream: ({ output, finalizeTiming }) => + createReadableStreamFromAnthropicStream( + streamResponse as AsyncIterable, + (content, usage) => { + output.content = content + output.tokens = { + input: usage.input_tokens, + output: usage.output_tokens, + total: usage.input_tokens + usage.output_tokens, + } - if (streamingResult.execution.output.providerTiming.timeSegments?.[0]) { - streamingResult.execution.output.providerTiming.timeSegments[0].endTime = - streamEndTime - streamingResult.execution.output.providerTiming.timeSegments[0].duration = - streamEndTime - providerStartTime + const costResult = calculateCost(request.model, usage.input_tokens, usage.output_tokens) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, } + + finalizeTiming() } - } - ), - execution: { - success: true, - output: { - content: '', - model: request.model, - tokens: { input: 0, output: 0, total: 0 }, - toolCalls: undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - timeSegments: [ - { - type: 'model', - name: request.model, - startTime: providerStartTime, - endTime: Date.now(), - duration: Date.now() - providerStartTime, - }, - ], - }, - cost: { - total: 0.0, - input: 0.0, - output: 0.0, - }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, - isStreaming: true, - }, - } + ), + }) - return streamingResult as StreamingExecution + return streamingResult } if (request.stream && !shouldStreamToolCalls) { @@ -811,81 +764,57 @@ export async function executeAnthropicProviderRequest( request.abortSignal ? { signal: request.abortSignal } : undefined ) - const streamingResult = { - stream: createReadableStreamFromAnthropicStream( - streamResponse as AsyncIterable, - (streamContent, usage) => { - streamingResult.execution.output.content = streamContent - streamingResult.execution.output.tokens = { - input: tokens.input + usage.input_tokens, - output: tokens.output + usage.output_tokens, - total: tokens.total + usage.input_tokens + usage.output_tokens, - } - - const streamCost = calculateCost(request.model, usage.input_tokens, usage.output_tokens) - const tc = sumToolCosts(toolResults) - streamingResult.execution.output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: iterationCount + 1, + timeSegments, + }, + initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: undefined as number | undefined, + total: accumulatedCost.total, + }, + toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + isStreaming: true, + createStream: ({ output, finalizeTiming }) => + createReadableStreamFromAnthropicStream( + streamResponse as AsyncIterable, + (streamContent, usage) => { + output.content = streamContent + output.tokens = { + input: tokens.input + usage.input_tokens, + output: tokens.output + usage.output_tokens, + total: tokens.total + usage.input_tokens + usage.output_tokens, + } - const streamEndTime = Date.now() - const streamEndTimeISO = new Date(streamEndTime).toISOString() + const streamCost = calculateCost( + request.model, + usage.input_tokens, + usage.output_tokens + ) + const tc = sumToolCosts(toolResults) + output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } - if (streamingResult.execution.output.providerTiming) { - streamingResult.execution.output.providerTiming.endTime = streamEndTimeISO - streamingResult.execution.output.providerTiming.duration = - streamEndTime - providerStartTime + finalizeTiming() } - } - ), - execution: { - success: true, - output: { - content: '', - model: request.model, - tokens: { - input: tokens.input, - output: tokens.output, - total: tokens.total, - }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - modelTime: modelTime, - toolsTime: toolsTime, - firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, - timeSegments: timeSegments, - }, - cost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - toolCost: undefined as number | undefined, - total: accumulatedCost.total, - }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, - isStreaming: true, - }, - } + ), + }) - return streamingResult as StreamingExecution + return streamingResult } catch (error) { const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() @@ -1258,81 +1187,57 @@ export async function executeAnthropicProviderRequest( request.abortSignal ? { signal: request.abortSignal } : undefined ) - const streamingResult = { - stream: createReadableStreamFromAnthropicStream( - streamResponse as AsyncIterable, - (streamContent, usage) => { - streamingResult.execution.output.content = streamContent - streamingResult.execution.output.tokens = { - input: tokens.input + usage.input_tokens, - output: tokens.output + usage.output_tokens, - total: tokens.total + usage.input_tokens + usage.output_tokens, - } - - const streamCost = calculateCost(request.model, usage.input_tokens, usage.output_tokens) - const tc2 = sumToolCosts(toolResults) - streamingResult.execution.output.cost = { - input: cost.input + streamCost.input, - output: cost.output + streamCost.output, - toolCost: tc2 || undefined, - total: cost.total + streamCost.total + tc2, - } + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: iterationCount + 1, + timeSegments, + }, + initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, + initialCost: { + input: cost.input, + output: cost.output, + toolCost: undefined as number | undefined, + total: cost.total, + }, + toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + isStreaming: true, + createStream: ({ output, finalizeTiming }) => + createReadableStreamFromAnthropicStream( + streamResponse as AsyncIterable, + (streamContent, usage) => { + output.content = streamContent + output.tokens = { + input: tokens.input + usage.input_tokens, + output: tokens.output + usage.output_tokens, + total: tokens.total + usage.input_tokens + usage.output_tokens, + } - const streamEndTime = Date.now() - const streamEndTimeISO = new Date(streamEndTime).toISOString() + const streamCost = calculateCost( + request.model, + usage.input_tokens, + usage.output_tokens + ) + const tc2 = sumToolCosts(toolResults) + output.cost = { + input: cost.input + streamCost.input, + output: cost.output + streamCost.output, + toolCost: tc2 || undefined, + total: cost.total + streamCost.total + tc2, + } - if (streamingResult.execution.output.providerTiming) { - streamingResult.execution.output.providerTiming.endTime = streamEndTimeISO - streamingResult.execution.output.providerTiming.duration = - streamEndTime - providerStartTime + finalizeTiming() } - } - ), - execution: { - success: true, - output: { - content: '', - model: request.model, - tokens: { - input: tokens.input, - output: tokens.output, - total: tokens.total, - }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - modelTime: modelTime, - toolsTime: toolsTime, - firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, - timeSegments: timeSegments, - }, - cost: { - input: cost.input, - output: cost.output, - toolCost: undefined as number | undefined, - total: cost.total, - }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, - isStreaming: true, - }, - } + ), + }) - return streamingResult as StreamingExecution + return streamingResult } return { diff --git a/apps/sim/providers/azure-openai/index.ts b/apps/sim/providers/azure-openai/index.ts index a7b879891d8..ddae32b7fb4 100644 --- a/apps/sim/providers/azure-openai/index.ts +++ b/apps/sim/providers/azure-openai/index.ts @@ -27,6 +27,8 @@ import { } from '@/providers/azure-openai/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { executeResponsesProviderRequest } from '@/providers/openai/core' +import { createStreamingExecution } from '@/providers/streaming-execution' +import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { FunctionCallResponse, @@ -117,14 +119,7 @@ async function executeChatCompletionsRequest( } const tools: ChatCompletionTool[] | undefined = request.tools?.length - ? request.tools.map((tool) => ({ - type: 'function' as const, - function: { - name: tool.id, - description: tool.description, - parameters: tool.parameters, - }, - })) + ? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool)) : undefined const payload: ChatCompletionCreateParamsBase & { verbosity?: string } = { @@ -197,75 +192,38 @@ async function executeChatCompletionsRequest( request.abortSignal ? { signal: request.abortSignal } : undefined ) - const streamingResult = { - stream: createReadableStreamFromAzureOpenAIStream(streamResponse, (content, usage) => { - streamingResult.execution.output.content = content - streamingResult.execution.output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } - - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - streamingResult.execution.output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } - - const streamEndTime = Date.now() - const streamEndTimeISO = new Date(streamEndTime).toISOString() - - if (streamingResult.execution.output.providerTiming) { - streamingResult.execution.output.providerTiming.endTime = streamEndTimeISO - streamingResult.execution.output.providerTiming.duration = - streamEndTime - providerStartTime + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: request.model }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + createStream: ({ output, finalizeTiming }) => + createReadableStreamFromAzureOpenAIStream(streamResponse, (content, usage) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } - if (streamingResult.execution.output.providerTiming.timeSegments?.[0]) { - streamingResult.execution.output.providerTiming.timeSegments[0].endTime = - streamEndTime - streamingResult.execution.output.providerTiming.timeSegments[0].duration = - streamEndTime - providerStartTime + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, } - } - }), - execution: { - success: true, - output: { - content: '', - model: request.model, - tokens: { input: 0, output: 0, total: 0 }, - toolCalls: undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - timeSegments: [ - { - type: 'model', - name: request.model, - startTime: providerStartTime, - endTime: Date.now(), - duration: Date.now() - providerStartTime, - }, - ], - }, - cost: { input: 0, output: 0, total: 0 }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, - }, - } as StreamingExecution - return streamingResult as StreamingExecution + finalizeTiming() + }), + }) + + return streamingResult } const initialCallTime = Date.now() @@ -528,80 +486,62 @@ async function executeChatCompletionsRequest( request.abortSignal ? { signal: request.abortSignal } : undefined ) - const streamingResult = { - stream: createReadableStreamFromAzureOpenAIStream(streamResponse, (content, usage) => { - streamingResult.execution.output.content = content - streamingResult.execution.output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - streamingResult.execution.output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: iterationCount + 1, + timeSegments, + }, + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + total: accumulatedCost.total, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + createStream: ({ output, finalizeTiming }) => + createReadableStreamFromAzureOpenAIStream(streamResponse, (content, usage) => { + output.content = content + output.tokens = { + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, + total: tokens.total + usage.total_tokens, + } - const streamEndTime = Date.now() - const streamEndTimeISO = new Date(streamEndTime).toISOString() + const streamCost = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + const tc = sumToolCosts(toolResults) + output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } - if (streamingResult.execution.output.providerTiming) { - streamingResult.execution.output.providerTiming.endTime = streamEndTimeISO - streamingResult.execution.output.providerTiming.duration = - streamEndTime - providerStartTime - } - }), - execution: { - success: true, - output: { - content: '', - model: request.model, - tokens: { - input: tokens.input, - output: tokens.output, - total: tokens.total, - }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - modelTime: modelTime, - toolsTime: toolsTime, - firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, - timeSegments: timeSegments, - }, - cost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - total: accumulatedCost.total, - }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, - }, - } as StreamingExecution + finalizeTiming() + }), + }) - return streamingResult as StreamingExecution + return streamingResult } const providerEndTime = Date.now() diff --git a/apps/sim/providers/baseten/index.ts b/apps/sim/providers/baseten/index.ts index a1dd2cfb7c2..a25fb29cb9c 100644 --- a/apps/sim/providers/baseten/index.ts +++ b/apps/sim/providers/baseten/index.ts @@ -11,6 +11,8 @@ import { supportsNativeStructuredOutputs, } from '@/providers/baseten/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createStreamingExecution } from '@/providers/streaming-execution' +import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { FunctionCallResponse, @@ -111,14 +113,7 @@ export const basetenProvider: ProviderConfig = { const formattedMessages = formatMessagesForProvider(allMessages, 'baseten') as Message[] const tools = request.tools?.length - ? request.tools.map((tool) => ({ - type: 'function', - function: { - name: tool.id, - description: tool.description, - parameters: tool.parameters, - }, - })) + ? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool)) : undefined const payload: any = { @@ -165,71 +160,38 @@ export const basetenProvider: ProviderConfig = { request.abortSignal ? { signal: request.abortSignal } : undefined ) - const streamingResult = { - stream: createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { - streamingResult.execution.output.content = content - streamingResult.execution.output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } - - const costResult = calculateCost( - requestedModel, - usage.prompt_tokens, - usage.completion_tokens - ) - streamingResult.execution.output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } + const streamingResult = createStreamingExecution({ + model: requestedModel, + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: request.model }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + createStream: ({ output, finalizeTiming }) => + createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } - const end = Date.now() - const endISO = new Date(end).toISOString() - if (streamingResult.execution.output.providerTiming) { - streamingResult.execution.output.providerTiming.endTime = endISO - streamingResult.execution.output.providerTiming.duration = end - providerStartTime - if (streamingResult.execution.output.providerTiming.timeSegments?.[0]) { - streamingResult.execution.output.providerTiming.timeSegments[0].endTime = end - streamingResult.execution.output.providerTiming.timeSegments[0].duration = - end - providerStartTime + const costResult = calculateCost( + requestedModel, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, } - } - }), - execution: { - success: true, - output: { - content: '', - model: requestedModel, - tokens: { input: 0, output: 0, total: 0 }, - toolCalls: undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - timeSegments: [ - { - type: 'model', - name: request.model, - startTime: providerStartTime, - endTime: Date.now(), - duration: Date.now() - providerStartTime, - }, - ], - }, - cost: { input: 0, output: 0, total: 0 }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, - }, - } as StreamingExecution - return streamingResult as StreamingExecution + finalizeTiming() + }), + }) + + return streamingResult } const initialCallTime = Date.now() @@ -487,67 +449,51 @@ export const basetenProvider: ProviderConfig = { request.abortSignal ? { signal: request.abortSignal } : undefined ) - const streamingResult = { - stream: createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { - streamingResult.execution.output.content = content - streamingResult.execution.output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } - - const streamCost = calculateCost( - requestedModel, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - streamingResult.execution.output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - execution: { - success: true, - output: { - content: '', - model: requestedModel, - tokens: { input: tokens.input, output: tokens.output, total: tokens.total }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - modelTime: modelTime, - toolsTime: toolsTime, - firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, - timeSegments: timeSegments, - }, - cost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - total: accumulatedCost.total, - }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, + const streamingResult = createStreamingExecution({ + model: requestedModel, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: iterationCount + 1, + timeSegments, + }, + initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + total: accumulatedCost.total, }, - } as StreamingExecution + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + createStream: ({ output }) => + createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { + output.content = content + output.tokens = { + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, + total: tokens.total + usage.total_tokens, + } + + const streamCost = calculateCost( + requestedModel, + usage.prompt_tokens, + usage.completion_tokens + ) + const tc = sumToolCosts(toolResults) + output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } + }), + }) - return streamingResult as StreamingExecution + return streamingResult } if (request.responseFormat && hasActiveTools) { diff --git a/apps/sim/providers/bedrock/index.ts b/apps/sim/providers/bedrock/index.ts index e6ab3d572a9..32be4078675 100644 --- a/apps/sim/providers/bedrock/index.ts +++ b/apps/sim/providers/bedrock/index.ts @@ -25,6 +25,7 @@ import { getBedrockInferenceProfileId, } from '@/providers/bedrock/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createStreamingExecution } from '@/providers/streaming-execution' import { enrichLastModelSegment } from '@/providers/trace-enrichment' import type { FunctionCallResponse, @@ -360,76 +361,36 @@ export const bedrockProvider: ProviderConfig = { throw new Error('No stream returned from Bedrock') } - const streamingResult = { - stream: createReadableStreamFromBedrockStream(streamResponse.stream, (content, usage) => { - streamingResult.execution.output.content = content - streamingResult.execution.output.tokens = { - input: usage.inputTokens, - output: usage.outputTokens, - total: usage.inputTokens + usage.outputTokens, - } - - const costResult = calculateCost(request.model, usage.inputTokens, usage.outputTokens) - streamingResult.execution.output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } - - const streamEndTime = Date.now() - const streamEndTimeISO = new Date(streamEndTime).toISOString() - - if (streamingResult.execution.output.providerTiming) { - streamingResult.execution.output.providerTiming.endTime = streamEndTimeISO - streamingResult.execution.output.providerTiming.duration = - streamEndTime - providerStartTime + const bedrockStream = streamResponse.stream + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: request.model }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { total: 0.0, input: 0.0, output: 0.0 }, + isStreaming: true, + createStream: ({ output, finalizeTiming }) => + createReadableStreamFromBedrockStream(bedrockStream, (content, usage) => { + output.content = content + output.tokens = { + input: usage.inputTokens, + output: usage.outputTokens, + total: usage.inputTokens + usage.outputTokens, + } - if (streamingResult.execution.output.providerTiming.timeSegments?.[0]) { - streamingResult.execution.output.providerTiming.timeSegments[0].endTime = - streamEndTime - streamingResult.execution.output.providerTiming.timeSegments[0].duration = - streamEndTime - providerStartTime + const costResult = calculateCost(request.model, usage.inputTokens, usage.outputTokens) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, } - } - }), - execution: { - success: true, - output: { - content: '', - model: request.model, - tokens: { input: 0, output: 0, total: 0 }, - toolCalls: undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - timeSegments: [ - { - type: 'model', - name: request.model, - startTime: providerStartTime, - endTime: Date.now(), - duration: Date.now() - providerStartTime, - }, - ], - }, - cost: { - total: 0.0, - input: 0.0, - output: 0.0, - }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, - isStreaming: true, - }, - } - return streamingResult as StreamingExecution + finalizeTiming() + }), + }) + + return streamingResult } const providerStartTime = Date.now() @@ -884,12 +845,33 @@ export const bedrockProvider: ProviderConfig = { throw new Error('No stream returned from Bedrock') } - const streamingResult = { - stream: createReadableStreamFromBedrockStream( - streamResponse.stream, - (streamContent, usage) => { - streamingResult.execution.output.content = streamContent - streamingResult.execution.output.tokens = { + const bedrockStream = streamResponse.stream + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: iterationCount + 1, + timeSegments, + }, + initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, + initialCost: { + input: cost.input, + output: cost.output, + toolCost: undefined as number | undefined, + total: cost.total, + }, + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + isStreaming: true, + createStream: ({ output, finalizeTiming }) => + createReadableStreamFromBedrockStream(bedrockStream, (streamContent, usage) => { + output.content = streamContent + output.tokens = { input: tokens.input + usage.inputTokens, output: tokens.output + usage.outputTokens, total: tokens.total + usage.inputTokens + usage.outputTokens, @@ -897,68 +879,18 @@ export const bedrockProvider: ProviderConfig = { const streamCost = calculateCost(request.model, usage.inputTokens, usage.outputTokens) const tc = sumToolCosts(toolResults) - streamingResult.execution.output.cost = { + output.cost = { input: cost.input + streamCost.input, output: cost.output + streamCost.output, toolCost: tc || undefined, total: cost.total + streamCost.total + tc, } - const streamEndTime = Date.now() - const streamEndTimeISO = new Date(streamEndTime).toISOString() - - if (streamingResult.execution.output.providerTiming) { - streamingResult.execution.output.providerTiming.endTime = streamEndTimeISO - streamingResult.execution.output.providerTiming.duration = - streamEndTime - providerStartTime - } - } - ), - execution: { - success: true, - output: { - content: '', - model: request.model, - tokens: { - input: tokens.input, - output: tokens.output, - total: tokens.total, - }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - cost: { - input: cost.input, - output: cost.output, - toolCost: undefined as number | undefined, - total: cost.total, - }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, - isStreaming: true, - }, - } + finalizeTiming() + }), + }) - return streamingResult as StreamingExecution + return streamingResult } return { diff --git a/apps/sim/providers/cerebras/index.ts b/apps/sim/providers/cerebras/index.ts index f5991be9c3c..c351ffc4268 100644 --- a/apps/sim/providers/cerebras/index.ts +++ b/apps/sim/providers/cerebras/index.ts @@ -7,6 +7,8 @@ import { formatMessagesForProvider } from '@/providers/attachments' import type { CerebrasResponse } from '@/providers/cerebras/types' import { createReadableStreamFromCerebrasStream } from '@/providers/cerebras/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createStreamingExecution } from '@/providers/streaming-execution' +import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { ProviderConfig, @@ -68,14 +70,7 @@ export const cerebrasProvider: ProviderConfig = { const formattedMessages = formatMessagesForProvider(allMessages, 'cerebras') const tools = request.tools?.length - ? request.tools.map((tool) => ({ - type: 'function', - function: { - name: tool.id, - description: tool.description, - parameters: tool.parameters, - }, - })) + ? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool)) : undefined const payload: any = { @@ -130,60 +125,37 @@ export const cerebrasProvider: ProviderConfig = { request.abortSignal ? { signal: request.abortSignal } : undefined ) - const streamingResult = { - stream: createReadableStreamFromCerebrasStream(streamResponse, (content, usage) => { - streamingResult.execution.output.content = content - streamingResult.execution.output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: request.model }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + isStreaming: true, + createStream: ({ output }) => + createReadableStreamFromCerebrasStream(streamResponse, (content, usage) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - streamingResult.execution.output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } - }), - execution: { - success: true, - output: { - content: '', - model: request.model, - tokens: { input: 0, output: 0, total: 0 }, - toolCalls: undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - timeSegments: [ - { - type: 'model', - name: request.model, - startTime: providerStartTime, - endTime: Date.now(), - duration: Date.now() - providerStartTime, - }, - ], - }, - cost: { input: 0, output: 0, total: 0 }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, - isStreaming: true, - }, - } + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } + }), + }) - return streamingResult as StreamingExecution + return streamingResult } const initialCallTime = Date.now() @@ -489,73 +461,62 @@ export const cerebrasProvider: ProviderConfig = { const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) - const streamingResult = { - stream: createReadableStreamFromCerebrasStream(streamResponse, (content, usage) => { - streamingResult.execution.output.content = content - streamingResult.execution.output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - streamingResult.execution.output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - execution: { - success: true, - output: { - content: '', - model: request.model, - tokens: { - input: tokens.input, - output: tokens.output, - total: tokens.total, - }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - modelTime: modelTime, - toolsTime: toolsTime, - firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, - timeSegments: timeSegments, - }, - cost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - toolCost: undefined as number | undefined, - total: accumulatedCost.total, - }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, - isStreaming: true, + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: iterationCount + 1, + timeSegments, }, - } + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: undefined as number | undefined, + total: accumulatedCost.total, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + isStreaming: true, + createStream: ({ output }) => + createReadableStreamFromCerebrasStream(streamResponse, (content, usage) => { + output.content = content + output.tokens = { + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, + total: tokens.total + usage.total_tokens, + } + + const streamCost = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + const tc = sumToolCosts(toolResults) + output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } + }), + }) - return streamingResult as StreamingExecution + return streamingResult } return { diff --git a/apps/sim/providers/deepseek/index.ts b/apps/sim/providers/deepseek/index.ts index e42592ebc4f..37f09254be3 100644 --- a/apps/sim/providers/deepseek/index.ts +++ b/apps/sim/providers/deepseek/index.ts @@ -6,6 +6,8 @@ import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { createReadableStreamFromDeepseekStream } from '@/providers/deepseek/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createStreamingExecution } from '@/providers/streaming-execution' +import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { ProviderConfig, @@ -71,14 +73,7 @@ export const deepseekProvider: ProviderConfig = { const formattedMessages = formatMessagesForProvider(allMessages, 'deepseek') const tools = request.tools?.length - ? request.tools.map((tool) => ({ - type: 'function', - function: { - name: tool.id, - description: tool.description, - parameters: tool.parameters, - }, - })) + ? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool)) : undefined const payload: any = { @@ -127,12 +122,18 @@ export const deepseekProvider: ProviderConfig = { request.abortSignal ? { signal: request.abortSignal } : undefined ) - const streamingResult = { - stream: createReadableStreamFromDeepseekStream( - streamResponse as any, - (content, usage) => { - streamingResult.execution.output.content = content - streamingResult.execution.output.tokens = { + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: request.model }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + isStreaming: true, + createStream: ({ output }) => + createReadableStreamFromDeepseekStream(streamResponse as any, (content, usage) => { + output.content = content + output.tokens = { input: usage.prompt_tokens, output: usage.completion_tokens, total: usage.total_tokens, @@ -143,47 +144,15 @@ export const deepseekProvider: ProviderConfig = { usage.prompt_tokens, usage.completion_tokens ) - streamingResult.execution.output.cost = { + output.cost = { input: costResult.input, output: costResult.output, total: costResult.total, } - } - ), - execution: { - success: true, - output: { - content: '', - model: request.model, - tokens: { input: 0, output: 0, total: 0 }, - toolCalls: undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - timeSegments: [ - { - type: 'model', - name: request.model, - startTime: providerStartTime, - endTime: Date.now(), - duration: Date.now() - providerStartTime, - }, - ], - }, - cost: { input: 0, output: 0, total: 0 }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, - isStreaming: true, - }, - } + }), + }) - return streamingResult as StreamingExecution + return streamingResult } const initialCallTime = Date.now() @@ -480,12 +449,41 @@ export const deepseekProvider: ProviderConfig = { const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) - const streamingResult = { - stream: createReadableStreamFromDeepseekStream( - streamResponse as any, - (content, usage) => { - streamingResult.execution.output.content = content - streamingResult.execution.output.tokens = { + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: iterationCount + 1, + timeSegments, + }, + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: undefined as number | undefined, + total: accumulatedCost.total, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + isStreaming: true, + createStream: ({ output }) => + createReadableStreamFromDeepseekStream(streamResponse as any, (content, usage) => { + output.content = content + output.tokens = { input: tokens.input + usage.prompt_tokens, output: tokens.output + usage.completion_tokens, total: tokens.total + usage.total_tokens, @@ -497,59 +495,16 @@ export const deepseekProvider: ProviderConfig = { usage.completion_tokens ) const tc = sumToolCosts(toolResults) - streamingResult.execution.output.cost = { + output.cost = { input: accumulatedCost.input + streamCost.input, output: accumulatedCost.output + streamCost.output, toolCost: tc || undefined, total: accumulatedCost.total + streamCost.total + tc, } - } - ), - execution: { - success: true, - output: { - content: '', - model: request.model, - tokens: { - input: tokens.input, - output: tokens.output, - total: tokens.total, - }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - modelTime: modelTime, - toolsTime: toolsTime, - firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, - timeSegments: timeSegments, - }, - cost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - toolCost: undefined as number | undefined, - total: accumulatedCost.total, - }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, - isStreaming: true, - }, - } + }), + }) - return streamingResult as StreamingExecution + return streamingResult } return { diff --git a/apps/sim/providers/fireworks/index.ts b/apps/sim/providers/fireworks/index.ts index cdf355d3451..c6981d4abf0 100644 --- a/apps/sim/providers/fireworks/index.ts +++ b/apps/sim/providers/fireworks/index.ts @@ -11,6 +11,8 @@ import { supportsNativeStructuredOutputs, } from '@/providers/fireworks/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createStreamingExecution } from '@/providers/streaming-execution' +import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { FunctionCallResponse, @@ -111,14 +113,7 @@ export const fireworksProvider: ProviderConfig = { const formattedMessages = formatMessagesForProvider(allMessages, 'fireworks') as Message[] const tools = request.tools?.length - ? request.tools.map((tool) => ({ - type: 'function', - function: { - name: tool.id, - description: tool.description, - parameters: tool.parameters, - }, - })) + ? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool)) : undefined const payload: any = { @@ -165,71 +160,38 @@ export const fireworksProvider: ProviderConfig = { request.abortSignal ? { signal: request.abortSignal } : undefined ) - const streamingResult = { - stream: createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { - streamingResult.execution.output.content = content - streamingResult.execution.output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } - - const costResult = calculateCost( - requestedModel, - usage.prompt_tokens, - usage.completion_tokens - ) - streamingResult.execution.output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } + const streamingResult = createStreamingExecution({ + model: requestedModel, + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: request.model }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + createStream: ({ output, finalizeTiming }) => + createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } - const end = Date.now() - const endISO = new Date(end).toISOString() - if (streamingResult.execution.output.providerTiming) { - streamingResult.execution.output.providerTiming.endTime = endISO - streamingResult.execution.output.providerTiming.duration = end - providerStartTime - if (streamingResult.execution.output.providerTiming.timeSegments?.[0]) { - streamingResult.execution.output.providerTiming.timeSegments[0].endTime = end - streamingResult.execution.output.providerTiming.timeSegments[0].duration = - end - providerStartTime + const costResult = calculateCost( + requestedModel, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, } - } - }), - execution: { - success: true, - output: { - content: '', - model: requestedModel, - tokens: { input: 0, output: 0, total: 0 }, - toolCalls: undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - timeSegments: [ - { - type: 'model', - name: request.model, - startTime: providerStartTime, - endTime: Date.now(), - duration: Date.now() - providerStartTime, - }, - ], - }, - cost: { input: 0, output: 0, total: 0 }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, - }, - } as StreamingExecution - return streamingResult as StreamingExecution + finalizeTiming() + }), + }) + + return streamingResult } const initialCallTime = Date.now() @@ -487,67 +449,51 @@ export const fireworksProvider: ProviderConfig = { request.abortSignal ? { signal: request.abortSignal } : undefined ) - const streamingResult = { - stream: createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { - streamingResult.execution.output.content = content - streamingResult.execution.output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } - - const streamCost = calculateCost( - requestedModel, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - streamingResult.execution.output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - execution: { - success: true, - output: { - content: '', - model: requestedModel, - tokens: { input: tokens.input, output: tokens.output, total: tokens.total }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - modelTime: modelTime, - toolsTime: toolsTime, - firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, - timeSegments: timeSegments, - }, - cost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - total: accumulatedCost.total, - }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, + const streamingResult = createStreamingExecution({ + model: requestedModel, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: iterationCount + 1, + timeSegments, + }, + initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + total: accumulatedCost.total, }, - } as StreamingExecution + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + createStream: ({ output }) => + createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { + output.content = content + output.tokens = { + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, + total: tokens.total + usage.total_tokens, + } + + const streamCost = calculateCost( + requestedModel, + usage.prompt_tokens, + usage.completion_tokens + ) + const tc = sumToolCosts(toolResults) + output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } + }), + }) - return streamingResult as StreamingExecution + return streamingResult } if (request.responseFormat && hasActiveTools) { diff --git a/apps/sim/providers/groq/index.ts b/apps/sim/providers/groq/index.ts index 750c55d04ce..15d854e145c 100644 --- a/apps/sim/providers/groq/index.ts +++ b/apps/sim/providers/groq/index.ts @@ -6,6 +6,8 @@ import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { createReadableStreamFromGroqStream } from '@/providers/groq/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createStreamingExecution } from '@/providers/streaming-execution' +import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { ProviderConfig, @@ -64,14 +66,7 @@ export const groqProvider: ProviderConfig = { const formattedMessages = formatMessagesForProvider(allMessages, 'groq') const tools = request.tools?.length - ? request.tools.map((tool) => ({ - type: 'function', - function: { - name: tool.id, - description: tool.description, - parameters: tool.parameters, - }, - })) + ? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool)) : undefined const payload: any = { @@ -131,60 +126,37 @@ export const groqProvider: ProviderConfig = { request.abortSignal ? { signal: request.abortSignal } : undefined ) - const streamingResult = { - stream: createReadableStreamFromGroqStream(streamResponse as any, (content, usage) => { - streamingResult.execution.output.content = content - streamingResult.execution.output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: request.model }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + isStreaming: true, + createStream: ({ output }) => + createReadableStreamFromGroqStream(streamResponse as any, (content, usage) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - streamingResult.execution.output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } - }), - execution: { - success: true, - output: { - content: '', - model: request.model, - tokens: { input: 0, output: 0, total: 0 }, - toolCalls: undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - timeSegments: [ - { - type: 'model', - name: request.model, - startTime: providerStartTime, - endTime: Date.now(), - duration: Date.now() - providerStartTime, - }, - ], - }, - cost: { input: 0, output: 0, total: 0 }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, - isStreaming: true, - }, - } + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } + }), + }) - return streamingResult as StreamingExecution + return streamingResult } const providerStartTime = Date.now() @@ -437,73 +409,62 @@ export const groqProvider: ProviderConfig = { const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) - const streamingResult = { - stream: createReadableStreamFromGroqStream(streamResponse as any, (content, usage) => { - streamingResult.execution.output.content = content - streamingResult.execution.output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - streamingResult.execution.output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - execution: { - success: true, - output: { - content: '', - model: request.model, - tokens: { - input: tokens.input, - output: tokens.output, - total: tokens.total, - }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - modelTime: modelTime, - toolsTime: toolsTime, - firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, - timeSegments: timeSegments, - }, - cost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - toolCost: undefined as number | undefined, - total: accumulatedCost.total, - }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, - isStreaming: true, + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: iterationCount + 1, + timeSegments, }, - } + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: undefined as number | undefined, + total: accumulatedCost.total, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + isStreaming: true, + createStream: ({ output }) => + createReadableStreamFromGroqStream(streamResponse as any, (content, usage) => { + output.content = content + output.tokens = { + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, + total: tokens.total + usage.total_tokens, + } + + const streamCost = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + const tc = sumToolCosts(toolResults) + output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } + }), + }) - return streamingResult as StreamingExecution + return streamingResult } const providerEndTime = Date.now() diff --git a/apps/sim/providers/litellm/index.ts b/apps/sim/providers/litellm/index.ts index 53a5360d2c9..0f5fc2d3d2c 100644 --- a/apps/sim/providers/litellm/index.ts +++ b/apps/sim/providers/litellm/index.ts @@ -8,6 +8,8 @@ import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { createReadableStreamFromLiteLLMStream } from '@/providers/litellm/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createStreamingExecution } from '@/providers/streaming-execution' +import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { Message, @@ -129,14 +131,7 @@ export const litellmProvider: ProviderConfig = { const formattedMessages = formatMessagesForProvider(allMessages, 'litellm') as Message[] const tools = request.tools?.length - ? request.tools.map((tool) => ({ - type: 'function', - function: { - name: tool.id, - description: tool.description, - parameters: tool.parameters, - }, - })) + ? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool)) : undefined const payload: any = { @@ -216,81 +211,44 @@ export const litellmProvider: ProviderConfig = { request.abortSignal ? { signal: request.abortSignal } : undefined ) - const streamingResult = { - stream: createReadableStreamFromLiteLLMStream(streamResponse, (content, usage) => { - let cleanContent = content - if (cleanContent && request.responseFormat) { - cleanContent = cleanContent.replace(/```json\n?|\n?```/g, '').trim() - } - - streamingResult.execution.output.content = cleanContent - streamingResult.execution.output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } - - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - streamingResult.execution.output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } - - const streamEndTime = Date.now() - const streamEndTimeISO = new Date(streamEndTime).toISOString() + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: request.model }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + isStreaming: true, + createStream: ({ output, finalizeTiming }) => + createReadableStreamFromLiteLLMStream(streamResponse, (content, usage) => { + let cleanContent = content + if (cleanContent && request.responseFormat) { + cleanContent = cleanContent.replace(/```json\n?|\n?```/g, '').trim() + } - if (streamingResult.execution.output.providerTiming) { - streamingResult.execution.output.providerTiming.endTime = streamEndTimeISO - streamingResult.execution.output.providerTiming.duration = - streamEndTime - providerStartTime + output.content = cleanContent + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } - if (streamingResult.execution.output.providerTiming.timeSegments?.[0]) { - streamingResult.execution.output.providerTiming.timeSegments[0].endTime = - streamEndTime - streamingResult.execution.output.providerTiming.timeSegments[0].duration = - streamEndTime - providerStartTime + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, } - } - }), - execution: { - success: true, - output: { - content: '', - model: request.model, - tokens: { input: 0, output: 0, total: 0 }, - toolCalls: undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - timeSegments: [ - { - type: 'model', - name: request.model, - startTime: providerStartTime, - endTime: Date.now(), - duration: Date.now() - providerStartTime, - }, - ], - }, - cost: { input: 0, output: 0, total: 0 }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, - isStreaming: true, - }, - } as StreamingExecution - return streamingResult as StreamingExecution + finalizeTiming() + }), + }) + + return streamingResult } const initialCallTime = Date.now() @@ -600,77 +558,66 @@ export const litellmProvider: ProviderConfig = { request.abortSignal ? { signal: request.abortSignal } : undefined ) - const streamingResult = { - stream: createReadableStreamFromLiteLLMStream(streamResponse, (content, usage) => { - let cleanContent = content - if (cleanContent && request.responseFormat) { - cleanContent = cleanContent.replace(/```json\n?|\n?```/g, '').trim() - } + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: iterationCount + 1, + timeSegments, + }, + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + total: accumulatedCost.total, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + isStreaming: true, + createStream: ({ output }) => + createReadableStreamFromLiteLLMStream(streamResponse, (content, usage) => { + let cleanContent = content + if (cleanContent && request.responseFormat) { + cleanContent = cleanContent.replace(/```json\n?|\n?```/g, '').trim() + } - streamingResult.execution.output.content = cleanContent - streamingResult.execution.output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } + output.content = cleanContent + output.tokens = { + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, + total: tokens.total + usage.total_tokens, + } - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - streamingResult.execution.output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - execution: { - success: true, - output: { - content: '', - model: request.model, - tokens: { - input: tokens.input, - output: tokens.output, - total: tokens.total, - }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - modelTime: modelTime, - toolsTime: toolsTime, - firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, - timeSegments: timeSegments, - }, - cost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - total: accumulatedCost.total, - }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, - isStreaming: true, - }, - } as StreamingExecution + const streamCost = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + const tc = sumToolCosts(toolResults) + output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } + }), + }) - return streamingResult as StreamingExecution + return streamingResult } if (deferResponseFormat && responseFormatPayload) { diff --git a/apps/sim/providers/mistral/index.ts b/apps/sim/providers/mistral/index.ts index 33fb3a5b524..95c66422a72 100644 --- a/apps/sim/providers/mistral/index.ts +++ b/apps/sim/providers/mistral/index.ts @@ -7,6 +7,8 @@ import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { createReadableStreamFromMistralStream } from '@/providers/mistral/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createStreamingExecution } from '@/providers/streaming-execution' +import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { ProviderConfig, @@ -81,14 +83,7 @@ export const mistralProvider: ProviderConfig = { const formattedMessages = formatMessagesForProvider(allMessages, 'mistral') const tools = request.tools?.length - ? request.tools.map((tool) => ({ - type: 'function', - function: { - name: tool.id, - description: tool.description, - parameters: tool.parameters, - }, - })) + ? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool)) : undefined const payload: any = { @@ -153,75 +148,38 @@ export const mistralProvider: ProviderConfig = { request.abortSignal ? { signal: request.abortSignal } : undefined ) - const streamingResult = { - stream: createReadableStreamFromMistralStream(streamResponse, (content, usage) => { - streamingResult.execution.output.content = content - streamingResult.execution.output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } - - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - streamingResult.execution.output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } - - const streamEndTime = Date.now() - const streamEndTimeISO = new Date(streamEndTime).toISOString() - - if (streamingResult.execution.output.providerTiming) { - streamingResult.execution.output.providerTiming.endTime = streamEndTimeISO - streamingResult.execution.output.providerTiming.duration = - streamEndTime - providerStartTime + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: request.model }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + createStream: ({ output, finalizeTiming }) => + createReadableStreamFromMistralStream(streamResponse, (content, usage) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } - if (streamingResult.execution.output.providerTiming.timeSegments?.[0]) { - streamingResult.execution.output.providerTiming.timeSegments[0].endTime = - streamEndTime - streamingResult.execution.output.providerTiming.timeSegments[0].duration = - streamEndTime - providerStartTime + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, } - } - }), - execution: { - success: true, - output: { - content: '', - model: request.model, - tokens: { input: 0, output: 0, total: 0 }, - toolCalls: undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - timeSegments: [ - { - type: 'model', - name: request.model, - startTime: providerStartTime, - endTime: Date.now(), - duration: Date.now() - providerStartTime, - }, - ], - }, - cost: { input: 0, output: 0, total: 0 }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, - }, - } as StreamingExecution - return streamingResult as StreamingExecution + finalizeTiming() + }), + }) + + return streamingResult } const initialCallTime = Date.now() @@ -493,71 +451,60 @@ export const mistralProvider: ProviderConfig = { request.abortSignal ? { signal: request.abortSignal } : undefined ) - const streamingResult = { - stream: createReadableStreamFromMistralStream(streamResponse, (content, usage) => { - streamingResult.execution.output.content = content - streamingResult.execution.output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - streamingResult.execution.output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - execution: { - success: true, - output: { - content: '', - model: request.model, - tokens: { - input: tokens.input, - output: tokens.output, - total: tokens.total, - }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - modelTime: modelTime, - toolsTime: toolsTime, - firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, - timeSegments: timeSegments, - }, - cost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - total: accumulatedCost.total, - }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: iterationCount + 1, + timeSegments, + }, + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, }, - } as StreamingExecution + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + total: accumulatedCost.total, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + createStream: ({ output }) => + createReadableStreamFromMistralStream(streamResponse, (content, usage) => { + output.content = content + output.tokens = { + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, + total: tokens.total + usage.total_tokens, + } + + const streamCost = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + const tc = sumToolCosts(toolResults) + output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } + }), + }) - return streamingResult as StreamingExecution + return streamingResult } const providerEndTime = Date.now() diff --git a/apps/sim/providers/ollama/core.ts b/apps/sim/providers/ollama/core.ts index 3021d410919..9e10b7c436a 100644 --- a/apps/sim/providers/ollama/core.ts +++ b/apps/sim/providers/ollama/core.ts @@ -9,6 +9,8 @@ import type { CompletionUsage } from 'openai/resources/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' +import { createStreamingExecution } from '@/providers/streaming-execution' +import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { Message, ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types' import { ProviderError } from '@/providers/types' @@ -103,14 +105,7 @@ export async function executeOllamaProviderRequest( const formattedMessages = formatMessagesForProvider(allMessages, providerId) as Message[] const tools = request.tools?.length - ? request.tools.map((tool) => ({ - type: 'function', - function: { - name: tool.id, - description: tool.description, - parameters: tool.parameters, - }, - })) + ? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool)) : undefined const payload: any = { @@ -179,82 +174,43 @@ export async function executeOllamaProviderRequest( request.abortSignal ? { signal: request.abortSignal } : undefined ) - const streamingResult = { - stream: config.createStream(streamResponse, (content, usage) => { - streamingResult.execution.output.content = content - - if (content && request.responseFormat) { - streamingResult.execution.output.content = content - .replace(/```json\n?|\n?```/g, '') - .trim() - } - - streamingResult.execution.output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } - - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - streamingResult.execution.output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } - - const streamEndTime = Date.now() - const streamEndTimeISO = new Date(streamEndTime).toISOString() + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: request.model }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + createStream: ({ output, finalizeTiming }) => + config.createStream(streamResponse, (content, usage) => { + output.content = content + + if (content && request.responseFormat) { + output.content = content.replace(/```json\n?|\n?```/g, '').trim() + } - if (streamingResult.execution.output.providerTiming) { - streamingResult.execution.output.providerTiming.endTime = streamEndTimeISO - streamingResult.execution.output.providerTiming.duration = - streamEndTime - providerStartTime + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } - if (streamingResult.execution.output.providerTiming.timeSegments?.[0]) { - streamingResult.execution.output.providerTiming.timeSegments[0].endTime = - streamEndTime - streamingResult.execution.output.providerTiming.timeSegments[0].duration = - streamEndTime - providerStartTime + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, } - } - }), - execution: { - success: true, - output: { - content: '', - model: request.model, - tokens: { input: 0, output: 0, total: 0 }, - toolCalls: undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - timeSegments: [ - { - type: 'model', - name: request.model, - startTime: providerStartTime, - endTime: Date.now(), - duration: Date.now() - providerStartTime, - }, - ], - }, - cost: { input: 0, output: 0, total: 0 }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, - }, - } as StreamingExecution - return streamingResult as StreamingExecution + finalizeTiming() + }), + }) + + return streamingResult } const initialCallTime = Date.now() @@ -504,78 +460,65 @@ export async function executeOllamaProviderRequest( request.abortSignal ? { signal: request.abortSignal } : undefined ) - const streamingResult = { - stream: config.createStream(streamResponse, (content, usage) => { - streamingResult.execution.output.content = content - - if (content && request.responseFormat) { - streamingResult.execution.output.content = content - .replace(/```json\n?|\n?```/g, '') - .trim() - } + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: iterationCount + 1, + timeSegments, + }, + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + total: accumulatedCost.total, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + createStream: ({ output }) => + config.createStream(streamResponse, (content, usage) => { + output.content = content + + if (content && request.responseFormat) { + output.content = content.replace(/```json\n?|\n?```/g, '').trim() + } - streamingResult.execution.output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } + output.tokens = { + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, + total: tokens.total + usage.total_tokens, + } - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - streamingResult.execution.output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - execution: { - success: true, - output: { - content: '', - model: request.model, - tokens: { - input: tokens.input, - output: tokens.output, - total: tokens.total, - }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - modelTime: modelTime, - toolsTime: toolsTime, - firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, - timeSegments: timeSegments, - }, - cost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - total: accumulatedCost.total, - }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, - }, - } as StreamingExecution + const streamCost = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + const tc = sumToolCosts(toolResults) + output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } + }), + }) - return streamingResult as StreamingExecution + return streamingResult } // Deferred structured output: one final JSON-mode call now that tools have run. diff --git a/apps/sim/providers/openai/core.ts b/apps/sim/providers/openai/core.ts index 6f19cef1562..c0fa50def86 100644 --- a/apps/sim/providers/openai/core.ts +++ b/apps/sim/providers/openai/core.ts @@ -3,6 +3,8 @@ import { getErrorMessage, toError } from '@sim/utils/errors' import type OpenAI from 'openai' import type { IterationToolCall, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' +import { createStreamingExecution } from '@/providers/streaming-execution' +import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegment, parseToolCallArguments } from '@/providers/trace-enrichment' import type { Message, ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types' import { ProviderError } from '@/providers/types' @@ -138,14 +140,7 @@ export async function executeResponsesProviderRequest( } const tools = request.tools?.length - ? request.tools.map((tool) => ({ - type: 'function', - function: { - name: tool.id, - description: tool.description, - parameters: tool.parameters, - }, - })) + ? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool)) : undefined let preparedTools: PreparedTools | null = null @@ -246,75 +241,38 @@ export async function executeResponsesProviderRequest( throw new Error(`${config.providerLabel} API error (${streamResponse.status}): ${message}`) } - const streamingResult = { - stream: createReadableStreamFromResponses(streamResponse, (content, usage) => { - streamingResult.execution.output.content = content - streamingResult.execution.output.tokens = { - input: usage?.promptTokens || 0, - output: usage?.completionTokens || 0, - total: usage?.totalTokens || 0, - } - - const costResult = calculateCost( - request.model, - usage?.promptTokens || 0, - usage?.completionTokens || 0 - ) - streamingResult.execution.output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } - - const streamEndTime = Date.now() - const streamEndTimeISO = new Date(streamEndTime).toISOString() - - if (streamingResult.execution.output.providerTiming) { - streamingResult.execution.output.providerTiming.endTime = streamEndTimeISO - streamingResult.execution.output.providerTiming.duration = - streamEndTime - providerStartTime + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: request.model }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + createStream: ({ output, finalizeTiming }) => + createReadableStreamFromResponses(streamResponse, (content, usage) => { + output.content = content + output.tokens = { + input: usage?.promptTokens || 0, + output: usage?.completionTokens || 0, + total: usage?.totalTokens || 0, + } - if (streamingResult.execution.output.providerTiming.timeSegments?.[0]) { - streamingResult.execution.output.providerTiming.timeSegments[0].endTime = - streamEndTime - streamingResult.execution.output.providerTiming.timeSegments[0].duration = - streamEndTime - providerStartTime + const costResult = calculateCost( + request.model, + usage?.promptTokens || 0, + usage?.completionTokens || 0 + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, } - } - }), - execution: { - success: true, - output: { - content: '', - model: request.model, - tokens: { input: 0, output: 0, total: 0 }, - toolCalls: undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - timeSegments: [ - { - type: 'model', - name: request.model, - startTime: providerStartTime, - endTime: Date.now(), - duration: Date.now() - providerStartTime, - }, - ], - }, - cost: { input: 0, output: 0, total: 0 }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, - }, - } as StreamingExecution - return streamingResult as StreamingExecution + finalizeTiming() + }), + }) + + return streamingResult } const initialCallTime = Date.now() @@ -697,71 +655,50 @@ export async function executeResponsesProviderRequest( throw new Error(`${config.providerLabel} API error (${streamResponse.status}): ${message}`) } - const streamingResult = { - stream: createReadableStreamFromResponses(streamResponse, (content, usage) => { - streamingResult.execution.output.content = content - streamingResult.execution.output.tokens = { - input: tokens.input + (usage?.promptTokens || 0), - output: tokens.output + (usage?.completionTokens || 0), - total: tokens.total + (usage?.totalTokens || 0), - } - - const streamCost = calculateCost( - request.model, - usage?.promptTokens || 0, - usage?.completionTokens || 0 - ) - const tc = sumToolCosts(toolResults) - streamingResult.execution.output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - execution: { - success: true, - output: { - content: '', - model: request.model, - tokens: { - input: tokens.input, - output: tokens.output, - total: tokens.total, - }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - modelTime: modelTime, - toolsTime: toolsTime, - firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, - timeSegments: timeSegments, - }, - cost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - total: accumulatedCost.total, - }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: iterationCount + 1, + timeSegments, }, - } as StreamingExecution + initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + total: accumulatedCost.total, + }, + toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + createStream: ({ output }) => + createReadableStreamFromResponses(streamResponse, (content, usage) => { + output.content = content + output.tokens = { + input: tokens.input + (usage?.promptTokens || 0), + output: tokens.output + (usage?.completionTokens || 0), + total: tokens.total + (usage?.totalTokens || 0), + } + + const streamCost = calculateCost( + request.model, + usage?.promptTokens || 0, + usage?.completionTokens || 0 + ) + const tc = sumToolCosts(toolResults) + output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } + }), + }) - return streamingResult as StreamingExecution + return streamingResult } const providerEndTime = Date.now() diff --git a/apps/sim/providers/openrouter/index.ts b/apps/sim/providers/openrouter/index.ts index 9bc180bdd11..8821483fa4e 100644 --- a/apps/sim/providers/openrouter/index.ts +++ b/apps/sim/providers/openrouter/index.ts @@ -11,6 +11,8 @@ import { createReadableStreamFromOpenAIStream, supportsNativeStructuredOutputs, } from '@/providers/openrouter/utils' +import { createStreamingExecution } from '@/providers/streaming-execution' +import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { FunctionCallResponse, @@ -113,14 +115,7 @@ export const openRouterProvider: ProviderConfig = { const formattedMessages = formatMessagesForProvider(allMessages, 'openrouter') as Message[] const tools = request.tools?.length - ? request.tools.map((tool) => ({ - type: 'function', - function: { - name: tool.id, - description: tool.description, - parameters: tool.parameters, - }, - })) + ? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool)) : undefined const payload: any = { @@ -167,71 +162,38 @@ export const openRouterProvider: ProviderConfig = { request.abortSignal ? { signal: request.abortSignal } : undefined ) - const streamingResult = { - stream: createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { - streamingResult.execution.output.content = content - streamingResult.execution.output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } - - const costResult = calculateCost( - requestedModel, - usage.prompt_tokens, - usage.completion_tokens - ) - streamingResult.execution.output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } + const streamingResult = createStreamingExecution({ + model: requestedModel, + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: request.model }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + createStream: ({ output, finalizeTiming }) => + createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } - const end = Date.now() - const endISO = new Date(end).toISOString() - if (streamingResult.execution.output.providerTiming) { - streamingResult.execution.output.providerTiming.endTime = endISO - streamingResult.execution.output.providerTiming.duration = end - providerStartTime - if (streamingResult.execution.output.providerTiming.timeSegments?.[0]) { - streamingResult.execution.output.providerTiming.timeSegments[0].endTime = end - streamingResult.execution.output.providerTiming.timeSegments[0].duration = - end - providerStartTime + const costResult = calculateCost( + requestedModel, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, } - } - }), - execution: { - success: true, - output: { - content: '', - model: requestedModel, - tokens: { input: 0, output: 0, total: 0 }, - toolCalls: undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - timeSegments: [ - { - type: 'model', - name: request.model, - startTime: providerStartTime, - endTime: Date.now(), - duration: Date.now() - providerStartTime, - }, - ], - }, - cost: { input: 0, output: 0, total: 0 }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, - }, - } as StreamingExecution - return streamingResult as StreamingExecution + finalizeTiming() + }), + }) + + return streamingResult } const initialCallTime = Date.now() @@ -489,67 +451,51 @@ export const openRouterProvider: ProviderConfig = { request.abortSignal ? { signal: request.abortSignal } : undefined ) - const streamingResult = { - stream: createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { - streamingResult.execution.output.content = content - streamingResult.execution.output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } - - const streamCost = calculateCost( - requestedModel, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - streamingResult.execution.output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - execution: { - success: true, - output: { - content: '', - model: requestedModel, - tokens: { input: tokens.input, output: tokens.output, total: tokens.total }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - modelTime: modelTime, - toolsTime: toolsTime, - firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, - timeSegments: timeSegments, - }, - cost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - total: accumulatedCost.total, - }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, + const streamingResult = createStreamingExecution({ + model: requestedModel, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: iterationCount + 1, + timeSegments, + }, + initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + total: accumulatedCost.total, }, - } as StreamingExecution + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + createStream: ({ output }) => + createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { + output.content = content + output.tokens = { + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, + total: tokens.total + usage.total_tokens, + } + + const streamCost = calculateCost( + requestedModel, + usage.prompt_tokens, + usage.completion_tokens + ) + const tc = sumToolCosts(toolResults) + output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } + }), + }) - return streamingResult as StreamingExecution + return streamingResult } if (request.responseFormat && hasActiveTools) { diff --git a/apps/sim/providers/streaming-execution.test.ts b/apps/sim/providers/streaming-execution.test.ts new file mode 100644 index 00000000000..25e7708b1f0 --- /dev/null +++ b/apps/sim/providers/streaming-execution.test.ts @@ -0,0 +1,210 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import type { NormalizedBlockOutput } from '@/executor/types' +import { createStreamingExecution } from '@/providers/streaming-execution' + +/** + * Builds a fake stream factory mirroring the providers' `createReadableStreamFrom*` + * helpers: it returns a sentinel stream and synchronously invokes the drain + * callback so the test can assert the populated output without a real stream. + */ +function fakeStreamFactory( + drain: (handles: { output: NormalizedBlockOutput; finalizeTiming: () => void }) => void +) { + const stream = new ReadableStream() + return { + stream, + createStream: (handles: { output: NormalizedBlockOutput; finalizeTiming: () => void }) => { + drain(handles) + return stream + }, + } +} + +describe('createStreamingExecution', () => { + const providerStartTime = 1_000 + const providerStartTimeISO = new Date(providerStartTime).toISOString() + + it('assembles the simple (no-tools) shape and finalizes timing on drain', () => { + const drainTime = 5_000 + vi.spyOn(Date, 'now').mockReturnValue(drainTime) + + const { stream, createStream } = fakeStreamFactory(({ output, finalizeTiming }) => { + output.content = 'hello' + output.tokens = { input: 10, output: 20, total: 30 } + output.cost = { input: 0.1, output: 0.2, total: 0.3 } + finalizeTiming() + }) + + const result = createStreamingExecution({ + model: 'test-model', + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: 'test-model' }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + isStreaming: true, + createStream, + }) + + expect(result.stream).toBe(stream) + + const output = result.execution.output + expect(output.content).toBe('hello') + expect(output.model).toBe('test-model') + expect(output.tokens).toEqual({ input: 10, output: 20, total: 30 }) + expect(output.cost).toEqual({ input: 0.1, output: 0.2, total: 0.3 }) + expect(output.toolCalls).toBeUndefined() + + const timing = output.providerTiming + expect(timing?.startTime).toBe(providerStartTimeISO) + expect(timing?.endTime).toBe(new Date(drainTime).toISOString()) + expect(timing?.duration).toBe(drainTime - providerStartTime) + expect(timing?.modelTime).toBeUndefined() + + const segment = timing?.timeSegments?.[0] + expect(segment).toMatchObject({ + type: 'model', + name: 'test-model', + startTime: providerStartTime, + }) + expect(segment?.endTime).toBe(drainTime) + expect(segment?.duration).toBe(drainTime - providerStartTime) + + expect(result.execution.success).toBe(true) + expect(result.execution.logs).toEqual([]) + expect(result.execution.isStreaming).toBe(true) + expect(result.execution.metadata?.startTime).toBe(providerStartTimeISO) + + vi.restoreAllMocks() + }) + + it('assembles the accumulated (post-tools) shape with pre-built segments', () => { + const drainTime = 7_000 + vi.spyOn(Date, 'now').mockReturnValue(drainTime) + + const timeSegments = [ + { type: 'model' as const, name: 'iter 1', startTime: 1_000, endTime: 2_000, duration: 1_000 }, + { type: 'tool' as const, name: 'lookup', startTime: 2_000, endTime: 2_500, duration: 500 }, + ] + + const { createStream } = fakeStreamFactory(({ output }) => { + output.content = 'final' + output.tokens = { input: 110, output: 220, total: 330 } + output.cost = { input: 1.1, output: 2.2, toolCost: 0.5, total: 3.8 } + }) + + const result = createStreamingExecution({ + model: 'tool-model', + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime: 1_500, + toolsTime: 500, + firstResponseTime: 800, + iterations: 2, + timeSegments, + }, + initialTokens: { input: 100, output: 200, total: 300 }, + initialCost: { input: 1, output: 2, toolCost: undefined, total: 3 }, + toolCalls: { list: [{ name: 'lookup' }], count: 1 }, + isStreaming: true, + createStream, + }) + + const output = result.execution.output + expect(output.content).toBe('final') + expect(output.tokens).toEqual({ input: 110, output: 220, total: 330 }) + expect(output.cost).toEqual({ input: 1.1, output: 2.2, toolCost: 0.5, total: 3.8 }) + expect(output.toolCalls).toEqual({ list: [{ name: 'lookup' }], count: 1 }) + + const timing = output.providerTiming + expect(timing?.modelTime).toBe(1_500) + expect(timing?.toolsTime).toBe(500) + expect(timing?.firstResponseTime).toBe(800) + expect(timing?.iterations).toBe(2) + expect(timing?.timeSegments).toBe(timeSegments) + expect(timing?.startTime).toBe(providerStartTimeISO) + expect(timing?.endTime).toBe(new Date(drainTime).toISOString()) + expect(timing?.duration).toBe(drainTime - providerStartTime) + + vi.restoreAllMocks() + }) + + it('only finalizes timing when the provider calls finalizeTiming', () => { + const constructTime = 1_200 + vi.spyOn(Date, 'now').mockReturnValue(constructTime) + + const result = createStreamingExecution({ + model: 'no-finalize', + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime: 0, + toolsTime: 0, + firstResponseTime: 0, + iterations: 1, + timeSegments: [], + }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + createStream: ({ output }) => { + output.content = 'no-timing-mutation' + return new ReadableStream() + }, + }) + + const timing = result.execution.output.providerTiming + expect(timing?.endTime).toBe(new Date(constructTime).toISOString()) + expect(timing?.duration).toBe(constructTime - providerStartTime) + expect(result.execution.isStreaming).toBeUndefined() + + vi.restoreAllMocks() + }) + + it('finalizeTiming touches only top-level aggregate for accumulated timing', () => { + const constructTime = 1_000 + const drainTime = 9_000 + const nowMock = vi.spyOn(Date, 'now').mockReturnValue(constructTime) + + const segment = { type: 'model' as const, name: 's', startTime: 1, endTime: 2, duration: 1 } + + const result = createStreamingExecution({ + model: 'm', + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime: 0, + toolsTime: 0, + firstResponseTime: 0, + iterations: 1, + timeSegments: [segment], + }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + createStream: ({ finalizeTiming }) => { + nowMock.mockReturnValue(drainTime) + finalizeTiming() + return new ReadableStream() + }, + }) + + const timing = result.execution.output.providerTiming + expect(timing?.endTime).toBe(new Date(drainTime).toISOString()) + expect(timing?.duration).toBe(drainTime - providerStartTime) + expect(timing?.timeSegments?.[0]).toEqual({ + type: 'model', + name: 's', + startTime: 1, + endTime: 2, + duration: 1, + }) + + vi.restoreAllMocks() + }) +}) diff --git a/apps/sim/providers/streaming-execution.ts b/apps/sim/providers/streaming-execution.ts new file mode 100644 index 00000000000..7a217af283d --- /dev/null +++ b/apps/sim/providers/streaming-execution.ts @@ -0,0 +1,196 @@ +import type { NormalizedBlockOutput, StreamingExecution } from '@/executor/types' +import type { TimeSegment } from '@/providers/types' + +/** + * Provider-agnostic assembly of the {@link StreamingExecution} object that every + * LLM provider returns from its streaming path. Centralizes the start/end timing, + * duration tracking, time-segment wiring, the `success`/`logs`/`metadata` envelope, + * and the timing-finalization contract that was previously copy-pasted across + * providers. Callers inject only the provider-specific stream iterable (which + * writes final content/tokens/cost) via {@link CreateStreamingExecutionOptions.createStream}. + */ + +/** Initial cost slice; shape is opaque so providers may include `toolCost`/`pricing`. */ +type CostSlice = NonNullable + +/** Initial token slice written into `execution.output.tokens`. */ +type TokenSlice = NonNullable + +/** + * Tool-call container written into `execution.output.toolCalls`. Providers build + * structurally-compatible list items whose `result` field is `unknown`; the + * container is widened here (mirroring the providers' former `as StreamingExecution` + * cast) and narrowed on assignment. + */ +type ToolCallSlice = { list: unknown[]; count: number } + +/** + * Timing for the no-tools streaming path. The factory builds a single inline + * `model` time segment and, when {@link StreamFinalizer.finalizeTiming} runs, + * overwrites the top-level `endTime`/`duration` and that segment's + * `endTime`/`duration` from the drain timestamp. + */ +interface SimpleTiming { + kind: 'simple' + /** Segment label — providers use the model id. */ + segmentName: string +} + +/** + * Timing for the post-tool streaming path. The factory emits the pre-built + * segments and aggregate counters from the tool-execution loop and, when + * {@link StreamFinalizer.finalizeTiming} runs, overwrites only the top-level + * `endTime`/`duration` (segments are already finalized by the loop). + */ +interface AccumulatedTiming { + kind: 'accumulated' + modelTime: number + toolsTime: number + firstResponseTime: number + iterations: number + timeSegments: TimeSegment[] +} + +type StreamingTiming = SimpleTiming | AccumulatedTiming + +/** Handles passed to {@link CreateStreamingExecutionOptions.createStream}. */ +interface StreamFinalizer { + /** Live output object — write final `content`/`tokens`/`cost` here on drain. */ + output: NormalizedBlockOutput + /** Overwrites placeholder timing from the drain timestamp. Call once on drain. */ + finalizeTiming: () => void +} + +interface CreateStreamingExecutionOptions { + /** Model id echoed into `execution.output.model`. */ + model: string + /** Wall-clock ms when the provider started the request (`Date.now()`). */ + providerStartTime: number + /** ISO form of {@link providerStartTime}. */ + providerStartTimeISO: string + /** Timing shape — `simple` (no tools) or `accumulated` (post-tools). */ + timing: StreamingTiming + /** Initial token counts (zeroed for the simple path, accumulated otherwise). */ + initialTokens: TokenSlice + /** Initial cost (zeroed for the simple path, accumulated otherwise). */ + initialCost: CostSlice + /** Tool-call container, or `undefined` when none were used. */ + toolCalls?: ToolCallSlice + /** Marks `execution.isStreaming = true` when set. */ + isStreaming?: boolean + /** + * Builds the provider stream. Receives the live `output` object and a + * `finalizeTiming` hook. The provider wires its native stream factory and, in + * the drain callback, writes final content/tokens/cost onto `output` then + * calls `finalizeTiming()`. + */ + createStream: (handles: StreamFinalizer) => ReadableStream +} + +/** + * Assembles a fully-wired {@link StreamingExecution}. The provider's stream + * (from {@link CreateStreamingExecutionOptions.createStream}) populates the + * output and finalizes timing on drain. + */ +export function createStreamingExecution( + options: CreateStreamingExecutionOptions +): StreamingExecution { + const { + model, + providerStartTime, + providerStartTimeISO, + timing, + initialTokens, + initialCost, + toolCalls, + isStreaming, + createStream, + } = options + + const now = Date.now() + const nowISO = new Date(now).toISOString() + const duration = now - providerStartTime + + const providerTiming: NonNullable = + timing.kind === 'simple' + ? { + startTime: providerStartTimeISO, + endTime: nowISO, + duration, + timeSegments: [ + { + type: 'model', + name: timing.segmentName, + startTime: providerStartTime, + endTime: now, + duration, + }, + ], + } + : { + startTime: providerStartTimeISO, + endTime: nowISO, + duration, + modelTime: timing.modelTime, + toolsTime: timing.toolsTime, + firstResponseTime: timing.firstResponseTime, + iterations: timing.iterations, + timeSegments: timing.timeSegments, + } + + const output: NormalizedBlockOutput = { + content: '', + model, + tokens: initialTokens, + toolCalls: toolCalls as NormalizedBlockOutput['toolCalls'], + providerTiming, + cost: initialCost, + } + + const timingKind = timing.kind + const stream = createStream({ + output, + finalizeTiming: () => finalizeTiming(output, providerStartTime, timingKind), + }) + + return { + stream, + execution: { + success: true, + output, + logs: [], + metadata: { + startTime: providerStartTimeISO, + endTime: nowISO, + duration, + }, + ...(isStreaming ? { isStreaming: true } : {}), + }, + } +} + +/** + * Overwrites the placeholder timing with the drain timestamp. For the simple + * path the first time segment is also finalized; for the accumulated path only + * the top-level aggregate is touched (segments are pre-finalized by the loop). + */ +function finalizeTiming( + output: NormalizedBlockOutput, + providerStartTime: number, + kind: StreamingTiming['kind'] +): void { + const streamEndTime = Date.now() + const providerTiming = output.providerTiming + if (!providerTiming) return + + providerTiming.endTime = new Date(streamEndTime).toISOString() + providerTiming.duration = streamEndTime - providerStartTime + + if (kind === 'simple') { + const segment = providerTiming.timeSegments?.[0] + if (segment) { + segment.endTime = streamEndTime + segment.duration = streamEndTime - providerStartTime + } + } +} diff --git a/apps/sim/providers/together/index.ts b/apps/sim/providers/together/index.ts index aff98633f51..34c22f5c18c 100644 --- a/apps/sim/providers/together/index.ts +++ b/apps/sim/providers/together/index.ts @@ -6,11 +6,13 @@ import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createStreamingExecution } from '@/providers/streaming-execution' import { checkForForcedToolUsage, createReadableStreamFromOpenAIStream, supportsNativeStructuredOutputs, } from '@/providers/together/utils' +import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { FunctionCallResponse, @@ -111,14 +113,7 @@ export const togetherProvider: ProviderConfig = { const formattedMessages = formatMessagesForProvider(allMessages, 'together') as Message[] const tools = request.tools?.length - ? request.tools.map((tool) => ({ - type: 'function', - function: { - name: tool.id, - description: tool.description, - parameters: tool.parameters, - }, - })) + ? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool)) : undefined const payload: any = { @@ -165,71 +160,38 @@ export const togetherProvider: ProviderConfig = { request.abortSignal ? { signal: request.abortSignal } : undefined ) - const streamingResult = { - stream: createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { - streamingResult.execution.output.content = content - streamingResult.execution.output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } - - const costResult = calculateCost( - requestedModel, - usage.prompt_tokens, - usage.completion_tokens - ) - streamingResult.execution.output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } + const streamingResult = createStreamingExecution({ + model: requestedModel, + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: request.model }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + createStream: ({ output, finalizeTiming }) => + createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } - const end = Date.now() - const endISO = new Date(end).toISOString() - if (streamingResult.execution.output.providerTiming) { - streamingResult.execution.output.providerTiming.endTime = endISO - streamingResult.execution.output.providerTiming.duration = end - providerStartTime - if (streamingResult.execution.output.providerTiming.timeSegments?.[0]) { - streamingResult.execution.output.providerTiming.timeSegments[0].endTime = end - streamingResult.execution.output.providerTiming.timeSegments[0].duration = - end - providerStartTime + const costResult = calculateCost( + requestedModel, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, } - } - }), - execution: { - success: true, - output: { - content: '', - model: requestedModel, - tokens: { input: 0, output: 0, total: 0 }, - toolCalls: undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - timeSegments: [ - { - type: 'model', - name: request.model, - startTime: providerStartTime, - endTime: Date.now(), - duration: Date.now() - providerStartTime, - }, - ], - }, - cost: { input: 0, output: 0, total: 0 }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, - }, - } as StreamingExecution - return streamingResult as StreamingExecution + finalizeTiming() + }), + }) + + return streamingResult } const initialCallTime = Date.now() @@ -487,67 +449,51 @@ export const togetherProvider: ProviderConfig = { request.abortSignal ? { signal: request.abortSignal } : undefined ) - const streamingResult = { - stream: createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { - streamingResult.execution.output.content = content - streamingResult.execution.output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } - - const streamCost = calculateCost( - requestedModel, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - streamingResult.execution.output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - execution: { - success: true, - output: { - content: '', - model: requestedModel, - tokens: { input: tokens.input, output: tokens.output, total: tokens.total }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - modelTime: modelTime, - toolsTime: toolsTime, - firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, - timeSegments: timeSegments, - }, - cost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - total: accumulatedCost.total, - }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, + const streamingResult = createStreamingExecution({ + model: requestedModel, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: iterationCount + 1, + timeSegments, + }, + initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + total: accumulatedCost.total, }, - } as StreamingExecution + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + createStream: ({ output }) => + createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { + output.content = content + output.tokens = { + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, + total: tokens.total + usage.total_tokens, + } + + const streamCost = calculateCost( + requestedModel, + usage.prompt_tokens, + usage.completion_tokens + ) + const tc = sumToolCosts(toolResults) + output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } + }), + }) - return streamingResult as StreamingExecution + return streamingResult } if (request.responseFormat && hasActiveTools) { diff --git a/apps/sim/providers/tool-schema-adapter.test.ts b/apps/sim/providers/tool-schema-adapter.test.ts new file mode 100644 index 00000000000..745d376e526 --- /dev/null +++ b/apps/sim/providers/tool-schema-adapter.test.ts @@ -0,0 +1,114 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + adaptAnthropicToolSchema, + adaptOpenAIChatToolSchema, +} from '@/providers/tool-schema-adapter' +import type { ProviderToolConfig } from '@/providers/types' + +const sampleTool: ProviderToolConfig = { + id: 'search_web', + name: 'Search Web', + description: 'Search the web for a query', + params: {}, + parameters: { + type: 'object', + properties: { query: { type: 'string', description: 'The query' } }, + required: ['query'], + }, +} + +const noDescriptionTool: ProviderToolConfig = { + id: 'noop', + name: 'Noop', + description: '', + params: {}, + parameters: { + type: 'object', + properties: {}, + required: [], + }, +} + +const emptyParametersTool: ProviderToolConfig = { + id: 'ping', + name: 'Ping', + description: 'Ping the server', + params: {}, + parameters: {} as ProviderToolConfig['parameters'], +} + +describe('adaptOpenAIChatToolSchema', () => { + it('wraps the tool in the chat function shape', () => { + expect(adaptOpenAIChatToolSchema(sampleTool)).toEqual({ + type: 'function', + function: { + name: 'search_web', + description: 'Search the web for a query', + parameters: sampleTool.parameters, + }, + }) + }) + + it('preserves an empty description', () => { + expect(adaptOpenAIChatToolSchema(noDescriptionTool)).toEqual({ + type: 'function', + function: { + name: 'noop', + description: '', + parameters: noDescriptionTool.parameters, + }, + }) + }) + + it('passes through empty parameters unchanged', () => { + expect(adaptOpenAIChatToolSchema(emptyParametersTool)).toEqual({ + type: 'function', + function: { + name: 'ping', + description: 'Ping the server', + parameters: emptyParametersTool.parameters, + }, + }) + }) +}) + +describe('adaptAnthropicToolSchema', () => { + it('produces the Anthropic input_schema shape', () => { + expect(adaptAnthropicToolSchema(sampleTool)).toEqual({ + name: 'search_web', + description: 'Search the web for a query', + input_schema: { + type: 'object', + properties: sampleTool.parameters.properties, + required: sampleTool.parameters.required, + }, + }) + }) + + it('preserves an empty description', () => { + expect(adaptAnthropicToolSchema(noDescriptionTool)).toEqual({ + name: 'noop', + description: '', + input_schema: { + type: 'object', + properties: noDescriptionTool.parameters.properties, + required: noDescriptionTool.parameters.required, + }, + }) + }) + + it('passes through empty parameters as undefined properties/required', () => { + expect(adaptAnthropicToolSchema(emptyParametersTool)).toEqual({ + name: 'ping', + description: 'Ping the server', + input_schema: { + type: 'object', + properties: undefined, + required: undefined, + }, + }) + }) +}) diff --git a/apps/sim/providers/tool-schema-adapter.ts b/apps/sim/providers/tool-schema-adapter.ts new file mode 100644 index 00000000000..8c35651fa35 --- /dev/null +++ b/apps/sim/providers/tool-schema-adapter.ts @@ -0,0 +1,56 @@ +import type { ProviderToolConfig } from '@/providers/types' + +/** + * OpenAI Chat Completions-style tool definition, shared by every + * OpenAI-compatible provider (groq, mistral, together, etc.). + */ +export interface OpenAIChatToolSchema { + type: 'function' + function: { + name: string + description: string + parameters: ProviderToolConfig['parameters'] + } +} + +/** + * Anthropic Messages API tool definition. + */ +export interface AnthropicToolSchema { + name: string + description: string + input_schema: { + type: 'object' + properties: ProviderToolConfig['parameters']['properties'] + required: ProviderToolConfig['parameters']['required'] + } +} + +/** + * Adapts a tool config to the OpenAI Chat Completions function-wrapped shape. + */ +export function adaptOpenAIChatToolSchema(tool: ProviderToolConfig): OpenAIChatToolSchema { + return { + type: 'function', + function: { + name: tool.id, + description: tool.description, + parameters: tool.parameters, + }, + } +} + +/** + * Adapts a tool config to the Anthropic Messages `input_schema` shape. + */ +export function adaptAnthropicToolSchema(tool: ProviderToolConfig): AnthropicToolSchema { + return { + name: tool.id, + description: tool.description, + input_schema: { + type: 'object', + properties: tool.parameters.properties, + required: tool.parameters.required, + }, + } +} diff --git a/apps/sim/providers/vllm/index.ts b/apps/sim/providers/vllm/index.ts index 87610d9c43e..7251ec1c31a 100644 --- a/apps/sim/providers/vllm/index.ts +++ b/apps/sim/providers/vllm/index.ts @@ -7,6 +7,8 @@ import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createStreamingExecution } from '@/providers/streaming-execution' +import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { Message, @@ -126,14 +128,7 @@ export const vllmProvider: ProviderConfig = { const formattedMessages = formatMessagesForProvider(allMessages, 'vllm') as Message[] const tools = request.tools?.length - ? request.tools.map((tool) => ({ - type: 'function', - function: { - name: tool.id, - description: tool.description, - parameters: tool.parameters, - }, - })) + ? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool)) : undefined const payload: any = { @@ -199,80 +194,43 @@ export const vllmProvider: ProviderConfig = { request.abortSignal ? { signal: request.abortSignal } : undefined ) - const streamingResult = { - stream: createReadableStreamFromVLLMStream(streamResponse, (content, usage) => { - let cleanContent = content - if (cleanContent && request.responseFormat) { - cleanContent = cleanContent.replace(/```json\n?|\n?```/g, '').trim() - } - - streamingResult.execution.output.content = cleanContent - streamingResult.execution.output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } - - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - streamingResult.execution.output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } - - const streamEndTime = Date.now() - const streamEndTimeISO = new Date(streamEndTime).toISOString() + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: request.model }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + createStream: ({ output, finalizeTiming }) => + createReadableStreamFromVLLMStream(streamResponse, (content, usage) => { + let cleanContent = content + if (cleanContent && request.responseFormat) { + cleanContent = cleanContent.replace(/```json\n?|\n?```/g, '').trim() + } - if (streamingResult.execution.output.providerTiming) { - streamingResult.execution.output.providerTiming.endTime = streamEndTimeISO - streamingResult.execution.output.providerTiming.duration = - streamEndTime - providerStartTime + output.content = cleanContent + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } - if (streamingResult.execution.output.providerTiming.timeSegments?.[0]) { - streamingResult.execution.output.providerTiming.timeSegments[0].endTime = - streamEndTime - streamingResult.execution.output.providerTiming.timeSegments[0].duration = - streamEndTime - providerStartTime + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, } - } - }), - execution: { - success: true, - output: { - content: '', - model: request.model, - tokens: { input: 0, output: 0, total: 0 }, - toolCalls: undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - timeSegments: [ - { - type: 'model', - name: request.model, - startTime: providerStartTime, - endTime: Date.now(), - duration: Date.now() - providerStartTime, - }, - ], - }, - cost: { input: 0, output: 0, total: 0 }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, - }, - } as StreamingExecution - return streamingResult as StreamingExecution + finalizeTiming() + }), + }) + + return streamingResult } const initialCallTime = Date.now() @@ -556,76 +514,65 @@ export const vllmProvider: ProviderConfig = { request.abortSignal ? { signal: request.abortSignal } : undefined ) - const streamingResult = { - stream: createReadableStreamFromVLLMStream(streamResponse, (content, usage) => { - let cleanContent = content - if (cleanContent && request.responseFormat) { - cleanContent = cleanContent.replace(/```json\n?|\n?```/g, '').trim() - } + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: iterationCount + 1, + timeSegments, + }, + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + total: accumulatedCost.total, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + createStream: ({ output }) => + createReadableStreamFromVLLMStream(streamResponse, (content, usage) => { + let cleanContent = content + if (cleanContent && request.responseFormat) { + cleanContent = cleanContent.replace(/```json\n?|\n?```/g, '').trim() + } - streamingResult.execution.output.content = cleanContent - streamingResult.execution.output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } + output.content = cleanContent + output.tokens = { + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, + total: tokens.total + usage.total_tokens, + } - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - streamingResult.execution.output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - execution: { - success: true, - output: { - content: '', - model: request.model, - tokens: { - input: tokens.input, - output: tokens.output, - total: tokens.total, - }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - modelTime: modelTime, - toolsTime: toolsTime, - firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, - timeSegments: timeSegments, - }, - cost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - total: accumulatedCost.total, - }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, - }, - } as StreamingExecution + const streamCost = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + const tc = sumToolCosts(toolResults) + output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } + }), + }) - return streamingResult as StreamingExecution + return streamingResult } const providerEndTime = Date.now() diff --git a/apps/sim/providers/xai/index.ts b/apps/sim/providers/xai/index.ts index 0d2dcb7b27c..42ebeb1254c 100644 --- a/apps/sim/providers/xai/index.ts +++ b/apps/sim/providers/xai/index.ts @@ -6,6 +6,8 @@ import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createStreamingExecution } from '@/providers/streaming-execution' +import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { Message, @@ -79,14 +81,7 @@ export const xAIProvider: ProviderConfig = { } const formattedMessages = formatMessagesForProvider(allMessages, 'xai') as Message[] const tools = request.tools?.length - ? request.tools.map((tool) => ({ - type: 'function', - function: { - name: tool.id, - description: tool.description, - parameters: tool.parameters, - }, - })) + ? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool)) : undefined if (tools?.length && request.responseFormat) { logger.warn( @@ -125,60 +120,37 @@ export const xAIProvider: ProviderConfig = { request.abortSignal ? { signal: request.abortSignal } : undefined ) - const streamingResult = { - stream: createReadableStreamFromXAIStream(streamResponse, (content, usage) => { - streamingResult.execution.output.content = content - streamingResult.execution.output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: request.model }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + isStreaming: true, + createStream: ({ output }) => + createReadableStreamFromXAIStream(streamResponse, (content, usage) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - streamingResult.execution.output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } - }), - execution: { - success: true, - output: { - content: '', - model: request.model, - tokens: { input: 0, output: 0, total: 0 }, - toolCalls: undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - timeSegments: [ - { - type: 'model', - name: request.model, - startTime: providerStartTime, - endTime: Date.now(), - duration: Date.now() - providerStartTime, - }, - ], - }, - cost: { input: 0, output: 0, total: 0 }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, - isStreaming: true, - }, - } + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } + }), + }) - return streamingResult as StreamingExecution + return streamingResult } const providerStartTime = Date.now() const providerStartTimeISO = new Date(providerStartTime).toISOString() @@ -520,73 +492,62 @@ export const xAIProvider: ProviderConfig = { const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) - const streamingResult = { - stream: createReadableStreamFromXAIStream(streamResponse as any, (content, usage) => { - streamingResult.execution.output.content = content - streamingResult.execution.output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - streamingResult.execution.output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - execution: { - success: true, - output: { - content: '', - model: request.model, - tokens: { - input: tokens.input, - output: tokens.output, - total: tokens.total, - }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - modelTime: modelTime, - toolsTime: toolsTime, - firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, - timeSegments: timeSegments, - }, - cost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - toolCost: undefined as number | undefined, - total: accumulatedCost.total, - }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, - isStreaming: true, + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: iterationCount + 1, + timeSegments, }, - } + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: undefined as number | undefined, + total: accumulatedCost.total, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + isStreaming: true, + createStream: ({ output }) => + createReadableStreamFromXAIStream(streamResponse as any, (content, usage) => { + output.content = content + output.tokens = { + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, + total: tokens.total + usage.total_tokens, + } + + const streamCost = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + const tc = sumToolCosts(toolResults) + output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } + }), + }) - return streamingResult as StreamingExecution + return streamingResult } const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString()