Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

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
}