d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
267 lines
8.1 KiB
TypeScript
267 lines
8.1 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import { toError } from '@sim/utils/errors'
|
|
import { getApiKeyWithBYOK } from '@/lib/api-key/byok'
|
|
import { getCostMultiplier } from '@/lib/core/config/env-flags'
|
|
import type { StreamingExecution } from '@/executor/types'
|
|
import {
|
|
attachLargeFileRemoteUrls,
|
|
uploadLargeFilesToProvider,
|
|
} from '@/providers/file-attachments.server'
|
|
import { getProviderExecutor } from '@/providers/registry'
|
|
import type { ProviderId, ProviderRequest, ProviderResponse } from '@/providers/types'
|
|
import {
|
|
calculateCost,
|
|
generateStructuredOutputInstructions,
|
|
shouldBillModelUsage,
|
|
sumToolCosts,
|
|
supportsReasoningEffort,
|
|
supportsTemperature,
|
|
supportsThinking,
|
|
supportsVerbosity,
|
|
} from '@/providers/utils'
|
|
|
|
const logger = createLogger('Providers')
|
|
|
|
/**
|
|
* Maximum number of iterations for tool call loops to prevent infinite loops.
|
|
* Used across all providers that support tool/function calling.
|
|
*/
|
|
export const MAX_TOOL_ITERATIONS = 20
|
|
|
|
function sanitizeRequest(request: ProviderRequest): ProviderRequest {
|
|
const sanitizedRequest = { ...request }
|
|
const model = sanitizedRequest.model
|
|
|
|
if (model && !supportsTemperature(model)) {
|
|
sanitizedRequest.temperature = undefined
|
|
}
|
|
|
|
if (model && !supportsReasoningEffort(model)) {
|
|
sanitizedRequest.reasoningEffort = undefined
|
|
}
|
|
|
|
if (model && !supportsVerbosity(model)) {
|
|
sanitizedRequest.verbosity = undefined
|
|
}
|
|
|
|
if (model && !supportsThinking(model)) {
|
|
sanitizedRequest.thinkingLevel = undefined
|
|
}
|
|
|
|
return sanitizedRequest
|
|
}
|
|
|
|
function isStreamingExecution(response: any): response is StreamingExecution {
|
|
return response && typeof response === 'object' && 'stream' in response && 'execution' in response
|
|
}
|
|
|
|
function isReadableStream(response: any): response is ReadableStream {
|
|
return response instanceof ReadableStream
|
|
}
|
|
|
|
const ZERO_COST = Object.freeze({
|
|
input: 0,
|
|
output: 0,
|
|
total: 0,
|
|
pricing: Object.freeze({ input: 0, output: 0, updatedAt: new Date(0).toISOString() }),
|
|
})
|
|
|
|
const ZERO_SEGMENT_COST = Object.freeze({ input: 0, output: 0, total: 0 })
|
|
|
|
/**
|
|
* Zeroes per-segment model cost on already-populated time segments so that
|
|
* `calculateCostSummary` (which sums `span.children[].cost.total` from the
|
|
* trace-spans pipeline) does not re-introduce the gross hosted-rate cost
|
|
* after the provider response's block-level cost was correctly zeroed for
|
|
* BYOK. Tool-segment costs are intentionally left intact.
|
|
*/
|
|
function zeroModelSegmentCosts(
|
|
segments: { type?: string; cost?: { input?: number; output?: number; total?: number } }[]
|
|
): void {
|
|
for (const segment of segments) {
|
|
if (segment.type === 'model' && segment.cost) {
|
|
segment.cost = { ...ZERO_SEGMENT_COST }
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Prevents streaming callbacks from writing non-zero model cost for BYOK users
|
|
* while preserving tool costs. The property is frozen via defineProperty because
|
|
* providers set cost inside streaming callbacks that fire after this function returns.
|
|
*
|
|
* Also zeroes any per-segment model cost already written by trace enrichers
|
|
* (which run synchronously before streaming begins for all current providers).
|
|
*/
|
|
function zeroCostForBYOK(response: StreamingExecution): void {
|
|
const output = response.execution?.output as
|
|
| (Record<string, unknown> & {
|
|
providerTiming?: {
|
|
timeSegments?: Array<{
|
|
type?: string
|
|
cost?: { input?: number; output?: number; total?: number }
|
|
}>
|
|
}
|
|
})
|
|
| undefined
|
|
if (!output || typeof output !== 'object') {
|
|
logger.warn('zeroCostForBYOK: output not available at intercept time; cost may not be zeroed')
|
|
return
|
|
}
|
|
|
|
let toolCost = 0
|
|
Object.defineProperty(output, 'cost', {
|
|
get: () => (toolCost > 0 ? { ...ZERO_COST, toolCost, total: toolCost } : ZERO_COST),
|
|
set: (value: Record<string, unknown>) => {
|
|
if (value?.toolCost && typeof value.toolCost === 'number') {
|
|
toolCost = value.toolCost
|
|
}
|
|
},
|
|
configurable: true,
|
|
enumerable: true,
|
|
})
|
|
|
|
const segments = output.providerTiming?.timeSegments
|
|
if (Array.isArray(segments)) {
|
|
zeroModelSegmentCosts(segments)
|
|
}
|
|
}
|
|
|
|
export async function executeProviderRequest(
|
|
providerId: string,
|
|
request: ProviderRequest
|
|
): Promise<ProviderResponse | ReadableStream | StreamingExecution> {
|
|
const provider = await getProviderExecutor(providerId as ProviderId)
|
|
if (!provider) {
|
|
throw new Error(`Provider not found: ${providerId}`)
|
|
}
|
|
|
|
if (!provider.executeRequest) {
|
|
throw new Error(`Provider ${providerId} does not implement executeRequest`)
|
|
}
|
|
|
|
let resolvedRequest = sanitizeRequest(request)
|
|
let isBYOK = false
|
|
|
|
if (request.workspaceId) {
|
|
try {
|
|
const result = await getApiKeyWithBYOK(
|
|
providerId,
|
|
request.model,
|
|
request.workspaceId,
|
|
request.apiKey
|
|
)
|
|
resolvedRequest = { ...resolvedRequest, apiKey: result.apiKey }
|
|
isBYOK = result.isBYOK
|
|
logger.info('API key resolved', {
|
|
provider: providerId,
|
|
model: request.model,
|
|
workspaceId: request.workspaceId,
|
|
isBYOK,
|
|
})
|
|
} catch (error) {
|
|
logger.error('Failed to resolve API key:', {
|
|
provider: providerId,
|
|
model: request.model,
|
|
error: toError(error).message,
|
|
})
|
|
throw error
|
|
}
|
|
}
|
|
|
|
resolvedRequest.isBYOK = isBYOK
|
|
const sanitizedRequest = resolvedRequest
|
|
|
|
if (sanitizedRequest.responseFormat) {
|
|
if (
|
|
typeof sanitizedRequest.responseFormat === 'string' &&
|
|
sanitizedRequest.responseFormat === ''
|
|
) {
|
|
logger.info('Empty response format provided, ignoring it')
|
|
sanitizedRequest.responseFormat = undefined
|
|
} else {
|
|
const structuredOutputInstructions = generateStructuredOutputInstructions(
|
|
sanitizedRequest.responseFormat
|
|
)
|
|
|
|
if (structuredOutputInstructions.trim()) {
|
|
const originalPrompt = sanitizedRequest.systemPrompt || ''
|
|
sanitizedRequest.systemPrompt =
|
|
`${originalPrompt}\n\n${structuredOutputInstructions}`.trim()
|
|
|
|
logger.info('Added structured output instructions to system prompt')
|
|
}
|
|
}
|
|
}
|
|
|
|
await attachLargeFileRemoteUrls(sanitizedRequest, providerId)
|
|
await uploadLargeFilesToProvider(sanitizedRequest, providerId)
|
|
|
|
const response = await provider.executeRequest(sanitizedRequest)
|
|
|
|
if (isStreamingExecution(response)) {
|
|
logger.info('Provider returned StreamingExecution', { isBYOK })
|
|
if (isBYOK) {
|
|
zeroCostForBYOK(response)
|
|
}
|
|
return response
|
|
}
|
|
|
|
if (isReadableStream(response)) {
|
|
logger.info('Provider returned ReadableStream')
|
|
return response
|
|
}
|
|
|
|
if (response.tokens) {
|
|
const { input: promptTokens = 0, output: completionTokens = 0 } = response.tokens
|
|
const useCachedInput = !!request.context && request.context.length > 0
|
|
|
|
const shouldBill = shouldBillModelUsage(response.model) && !isBYOK
|
|
if (shouldBill) {
|
|
const costMultiplier = getCostMultiplier()
|
|
response.cost = calculateCost(
|
|
response.model,
|
|
promptTokens,
|
|
completionTokens,
|
|
useCachedInput,
|
|
costMultiplier,
|
|
costMultiplier
|
|
)
|
|
} else {
|
|
response.cost = {
|
|
input: 0,
|
|
output: 0,
|
|
total: 0,
|
|
pricing: {
|
|
input: 0,
|
|
output: 0,
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
}
|
|
if (isBYOK) {
|
|
logger.info(`Not billing model usage for ${response.model} - workspace BYOK key used`)
|
|
} else {
|
|
logger.info(
|
|
`Not billing model usage for ${response.model} - user provided API key or not hosted model`
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Per-segment model costs are written by trace enrichers regardless of BYOK
|
|
// status. Zero them here so the trace-spans aggregator (which sums child
|
|
// span cost) does not re-introduce the gross hosted-rate cost after the
|
|
// block-level response.cost was already set to zero above.
|
|
if (isBYOK && response.timing?.timeSegments) {
|
|
zeroModelSegmentCosts(response.timing.timeSegments)
|
|
}
|
|
|
|
const toolCost = sumToolCosts(response.toolResults)
|
|
if (toolCost > 0 && response.cost) {
|
|
response.cost.toolCost = toolCost
|
|
response.cost.total += toolCost
|
|
}
|
|
|
|
return response
|
|
}
|